From 30ab9e2cd7b8273d2f0e994d3098efd691614a48 Mon Sep 17 00:00:00 2001 From: Apptah Date: Wed, 1 Apr 2026 05:05:23 +0800 Subject: [PATCH] fix: extract inline SessionStart bootstrap to separate file (#1035) Inline `node -e "..."` in hooks.json contained `!` characters (e.g. `!org.isDirectory()`) that bash history expansion in certain shell environments would misinterpret, producing syntax errors and the "SessionStart:startup hook error" banner in the Claude Code CLI header. Extract the bootstrap logic to `scripts/hooks/session-start-bootstrap.js` so the shell never sees the JS source. Behaviour is identical: the script reads stdin, resolves the ECC plugin root via CLAUDE_PLUGIN_ROOT or a set of well-known fallback paths, then delegates to run-with-flags.js. Update the test that asserted the old inline pattern to verify the new file-based approach instead. Co-authored-by: Claude Sonnet 4.6 --- hooks/hooks.json | 2 +- scripts/hooks/session-start-bootstrap.js | 149 +++++++++++++++++++++++ tests/hooks/hooks.test.js | 22 +++- 3 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 scripts/hooks/session-start-bootstrap.js diff --git a/hooks/hooks.json b/hooks/hooks.json index 785a3e4c..8fb52cb3 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -146,7 +146,7 @@ "hooks": [ { "type": "command", - "command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const rel=path.join('scripts','hooks','run-with-flags.js');const hasRunnerRoot=candidate=>{const value=typeof candidate==='string'?candidate.trim():'';return value.length>0&&fs.existsSync(path.join(path.resolve(value),rel));};const root=(()=>{const envRoot=process.env.CLAUDE_PLUGIN_ROOT||'';if(hasRunnerRoot(envRoot))return path.resolve(envRoot.trim());const home=require('os').homedir();const claudeDir=path.join(home,'.claude');if(hasRunnerRoot(claudeDir))return claudeDir;for(const candidate of [path.join(claudeDir,'plugins','everything-claude-code'),path.join(claudeDir,'plugins','everything-claude-code@everything-claude-code'),path.join(claudeDir,'plugins','marketplace','everything-claude-code')]){if(hasRunnerRoot(candidate))return candidate;}try{const cacheBase=path.join(claudeDir,'plugins','cache','everything-claude-code');for(const org of fs.readdirSync(cacheBase,{withFileTypes:true})){if(!org.isDirectory())continue;for(const version of fs.readdirSync(path.join(cacheBase,org.name),{withFileTypes:true})){if(!version.isDirectory())continue;const candidate=path.join(cacheBase,org.name,version.name);if(hasRunnerRoot(candidate))return candidate;}}}catch{}return claudeDir;})();const script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'session:start','scripts/hooks/session-start.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000});const stdout=typeof result.stdout==='string'?result.stdout:'';if(stdout)process.stdout.write(stdout);else process.stdout.write(raw);if(result.stderr)process.stderr.write(result.stderr);if(result.error||result.status===null||result.signal){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');process.stderr.write('[SessionStart] ERROR: session-start hook failed: '+reason+String.fromCharCode(10));process.exit(1);}process.exit(Number.isInteger(result.status)?result.status:0);}process.stderr.write('[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook'+String.fromCharCode(10));process.stdout.write(raw);\"" + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start-bootstrap.js\"" } ], "description": "Load previous context and detect package manager on new session" diff --git a/scripts/hooks/session-start-bootstrap.js b/scripts/hooks/session-start-bootstrap.js new file mode 100644 index 00000000..581df60b --- /dev/null +++ b/scripts/hooks/session-start-bootstrap.js @@ -0,0 +1,149 @@ +#!/usr/bin/env node +'use strict'; + +/** + * session-start-bootstrap.js + * + * Bootstrap loader for the ECC SessionStart hook. + * + * Problem this solves: the previous approach embedded this logic as an inline + * `node -e "..."` string inside hooks.json. Characters like `!` (used in + * `!org.isDirectory()`) can trigger bash history expansion or other shell + * interpretation issues depending on the environment, causing + * "SessionStart:startup hook error" to appear in the Claude Code CLI header. + * + * By extracting to a standalone file, the shell never sees the JavaScript + * source and the `!` characters are safe. Behaviour is otherwise identical. + * + * How it works: + * 1. Reads the raw JSON event from stdin (passed by Claude Code). + * 2. Resolves the ECC plugin root directory (via CLAUDE_PLUGIN_ROOT env var + * or a set of well-known fallback paths). + * 3. Delegates to `scripts/hooks/run-with-flags.js` with the `session:start` + * event, which applies hook-profile gating and then runs session-start.js. + * 4. Passes stdout/stderr through and forwards the child exit code. + * 5. If the plugin root cannot be found, emits a warning and passes stdin + * through unchanged so Claude Code can continue normally. + */ + +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +// Read the raw JSON event from stdin +const raw = fs.readFileSync(0, 'utf8'); + +// Path (relative to plugin root) to the hook runner +const rel = path.join('scripts', 'hooks', 'run-with-flags.js'); + +/** + * Returns true when `candidate` looks like a valid ECC plugin root, i.e. the + * run-with-flags.js runner exists inside it. + * + * @param {unknown} candidate + * @returns {boolean} + */ +function hasRunnerRoot(candidate) { + const value = typeof candidate === 'string' ? candidate.trim() : ''; + return value.length > 0 && fs.existsSync(path.join(path.resolve(value), rel)); +} + +/** + * Resolves the ECC plugin root using the following priority order: + * 1. CLAUDE_PLUGIN_ROOT environment variable + * 2. ~/.claude (direct install) + * 3. Several well-known plugin sub-paths under ~/.claude/plugins/ + * 4. Versioned cache directories under ~/.claude/plugins/cache/everything-claude-code/ + * 5. Falls back to ~/.claude if nothing else matches + * + * @returns {string} + */ +function resolvePluginRoot() { + const envRoot = process.env.CLAUDE_PLUGIN_ROOT || ''; + if (hasRunnerRoot(envRoot)) { + return path.resolve(envRoot.trim()); + } + + const home = require('os').homedir(); + const claudeDir = path.join(home, '.claude'); + + if (hasRunnerRoot(claudeDir)) { + return claudeDir; + } + + const knownPaths = [ + path.join(claudeDir, 'plugins', 'everything-claude-code'), + path.join(claudeDir, 'plugins', 'everything-claude-code@everything-claude-code'), + path.join(claudeDir, 'plugins', 'marketplace', 'everything-claude-code'), + ]; + + for (const candidate of knownPaths) { + if (hasRunnerRoot(candidate)) { + return candidate; + } + } + + // Walk versioned cache: ~/.claude/plugins/cache/everything-claude-code/// + try { + const cacheBase = path.join(claudeDir, 'plugins', 'cache', 'everything-claude-code'); + for (const org of fs.readdirSync(cacheBase, { withFileTypes: true })) { + if (!org.isDirectory()) continue; + for (const version of fs.readdirSync(path.join(cacheBase, org.name), { withFileTypes: true })) { + if (!version.isDirectory()) continue; + const candidate = path.join(cacheBase, org.name, version.name); + if (hasRunnerRoot(candidate)) { + return candidate; + } + } + } + } catch { + // cache directory may not exist; that's fine + } + + return claudeDir; +} + +const root = resolvePluginRoot(); +const script = path.join(root, rel); + +if (fs.existsSync(script)) { + const result = spawnSync( + process.execPath, + [script, 'session:start', 'scripts/hooks/session-start.js', 'minimal,standard,strict'], + { + input: raw, + encoding: 'utf8', + env: process.env, + cwd: process.cwd(), + timeout: 30000, + } + ); + + const stdout = typeof result.stdout === 'string' ? result.stdout : ''; + if (stdout) { + process.stdout.write(stdout); + } else { + process.stdout.write(raw); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + if (result.error || result.status === null || result.signal) { + const reason = result.error + ? result.error.message + : result.signal + ? 'signal ' + result.signal + : 'missing exit status'; + process.stderr.write('[SessionStart] ERROR: session-start hook failed: ' + reason + '\n'); + process.exit(1); + } + + process.exit(Number.isInteger(result.status) ? result.status : 0); +} + +process.stderr.write( + '[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook\n' +); +process.stdout.write(raw); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 7d4475e6..9fa28def 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -1958,13 +1958,25 @@ async function runTests() { const sessionStartHook = hooks.hooks.SessionStart?.[0]?.hooks?.[0]; assert.ok(sessionStartHook, 'Should define a SessionStart hook'); - assert.ok(sessionStartHook.command.startsWith('node -e "'), 'SessionStart should use inline node resolver'); - assert.ok(sessionStartHook.command.includes('session:start'), 'SessionStart should invoke the session:start profile'); - assert.ok(sessionStartHook.command.includes('run-with-flags.js'), 'SessionStart should resolve the runner script'); - assert.ok(sessionStartHook.command.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should consult CLAUDE_PLUGIN_ROOT'); - assert.ok(sessionStartHook.command.includes('plugins'), 'SessionStart should probe known plugin roots'); + // The bootstrap was extracted to a standalone file to avoid shell history + // expansion of `!` characters that caused startup hook errors when the + // logic was embedded as an inline `node -e "..."` string. + assert.ok( + sessionStartHook.command.includes('session-start-bootstrap.js'), + 'SessionStart should delegate to the extracted bootstrap script' + ); + assert.ok(sessionStartHook.command.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT'); assert.ok(!sessionStartHook.command.includes('find '), 'Should not scan arbitrary plugin paths with find'); assert.ok(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path'); + + // Verify the bootstrap script itself contains the expected logic + const bootstrapPath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'session-start-bootstrap.js'); + assert.ok(fs.existsSync(bootstrapPath), 'Bootstrap script should exist at scripts/hooks/session-start-bootstrap.js'); + const bootstrapSrc = fs.readFileSync(bootstrapPath, 'utf8'); + assert.ok(bootstrapSrc.includes('session:start'), 'Bootstrap should invoke the session:start profile'); + assert.ok(bootstrapSrc.includes('run-with-flags.js'), 'Bootstrap should resolve the runner script'); + assert.ok(bootstrapSrc.includes('CLAUDE_PLUGIN_ROOT'), 'Bootstrap should consult CLAUDE_PLUGIN_ROOT'); + assert.ok(bootstrapSrc.includes('plugins'), 'Bootstrap should probe known plugin roots'); }) ) passed++;