From 1d704e75e3b5989d8d99ca33775992cabcb2f30a Mon Sep 17 00:00:00 2001 From: azank Date: Thu, 15 Oct 2026 16:38:43 +0500 Subject: [PATCH 1/3] fix(hooks): load js-yaml lazily so session-start runs without node_modules The merge of #3470 moved the Flutter check in scripts/lib/project-detect.js onto js-yaml with a top-level require. session-start.js loads that module on every SessionStart, and plugin installs have no node_modules (#2822), so the hook now exits 1 with "Cannot find module 'js-yaml'" in every project, not only Dart ones. Require js-yaml inside hasFlutterSdkDependency instead. When it is missing, the existing catch returns false: Dart is still detected from pubspec.yaml and only the Flutter refinement is skipped. Installs that have their dependencies behave as before. Add a case to the no-node_modules plugin install test that runs session-start.js against a pubspec.yaml project. It fails on main. --- scripts/lib/project-detect.js | 5 ++- ...lugin-install-without-node-modules.test.js | 33 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/scripts/lib/project-detect.js b/scripts/lib/project-detect.js index 2aac4afbc..71ee09a01 100644 --- a/scripts/lib/project-detect.js +++ b/scripts/lib/project-detect.js @@ -9,7 +9,6 @@ const fs = require('fs'); const path = require('path'); -const yaml = require('js-yaml'); /** * Language detection rules. @@ -350,6 +349,10 @@ function hasFlutterSdkDependency(projectDir) { const pubspecPath = path.join(projectDir, 'pubspec.yaml'); if (!fs.existsSync(pubspecPath)) return false; const content = fs.readFileSync(pubspecPath, 'utf8'); + // Loaded here, not at the top: the SessionStart hook requires this module + // from plugin installs that have no node_modules (#2822). Without js-yaml + // the catch below costs only the Flutter check, not the whole hook. + const yaml = require('js-yaml'); const pubspec = yaml.load(content, { schema: yaml.JSON_SCHEMA }); // Only a declared Flutter SDK dependency identifies the framework. // Unrelated configuration, comments, and strings must not count. diff --git a/tests/scripts/plugin-install-without-node-modules.test.js b/tests/scripts/plugin-install-without-node-modules.test.js index 6f7d5ce26..0ae6790a6 100644 --- a/tests/scripts/plugin-install-without-node-modules.test.js +++ b/tests/scripts/plugin-install-without-node-modules.test.js @@ -13,7 +13,7 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { execFileSync } = require('child_process'); +const { execFileSync, spawnSync } = require('child_process'); const REPO_ROOT = path.join(__dirname, '..', '..'); @@ -109,6 +109,37 @@ function runTests() { assert.ok(result.stdout.includes('Usage:')); })) passed++; else failed++; + if (test('session-start.js runs without js-yaml installed', () => { + // The SessionStart hook loads scripts/lib/project-detect.js, so a + // top-level require of an npm package there fails every session start. + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-session-start-')); + try { + const project = path.join(sandbox, 'project'); + const home = path.join(sandbox, 'home'); + const data = path.join(home, '.claude'); + fs.mkdirSync(project, { recursive: true }); + fs.mkdirSync(data, { recursive: true }); + fs.writeFileSync(path.join(project, 'pubspec.yaml'), 'name: app\ndependencies:\n flutter:\n sdk: flutter\n'); + + const result = spawnSync(process.execPath, [path.join(pluginDir, 'scripts', 'hooks', 'session-start.js')], { + cwd: project, + env: { ...process.env, HOME: home, USERPROFILE: home, ECC_AGENT_DATA_HOME: data, + CLAUDE_CONFIG_DIR: data, CLAUDE_PROJECT_DIR: project, XDG_DATA_HOME: path.join(home, '.local', 'share'), + ECC_HOMUNCULUS_DIR: path.join(data, 'homunculus'), ECC_SESSION_START_CONTEXT: 'true' }, + input: JSON.stringify({ hook_event_name: 'SessionStart', source: 'startup', session_id: 'no-deps', cwd: project }), + encoding: 'utf8', + timeout: 15000, + }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, `stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Cannot find module'), `stderr: ${result.stderr}`); + const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext; + assert.ok(context.includes('"languages":["dart"]'), `additionalContext: ${context}`); + } finally { + fs.rmSync(sandbox, { recursive: true, force: true }); + } + })) passed++; else failed++; + if (test('install-plan.js --config gives an actionable error when ajv is genuinely missing', () => { const configPath = path.join(pluginDir, 'ecc-install.json'); fs.writeFileSync(configPath, JSON.stringify({ version: 1, profile: 'minimal' })); From 218f4ae85f771273e5b02b754cc653f031f1b8d1 Mon Sep 17 00:00:00 2001 From: azank Date: Thu, 15 Oct 2026 19:08:53 +0500 Subject: [PATCH 2/3] test(hooks): isolate the no-deps session-start test from NODE_PATH and the context cap A NODE_PATH that exposes js-yaml let the regression pass the test, and an inherited ECC_SESSION_START_MAX_CHARS=0 made it fail falsely. Drop NODE_PATH from the child environment and pin the cap to 8000, as session-start-alias-unavailable.test.js does. --- .../plugin-install-without-node-modules.test.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/scripts/plugin-install-without-node-modules.test.js b/tests/scripts/plugin-install-without-node-modules.test.js index 0ae6790a6..c555ba04a 100644 --- a/tests/scripts/plugin-install-without-node-modules.test.js +++ b/tests/scripts/plugin-install-without-node-modules.test.js @@ -121,11 +121,16 @@ function runTests() { fs.mkdirSync(data, { recursive: true }); fs.writeFileSync(path.join(project, 'pubspec.yaml'), 'name: app\ndependencies:\n flutter:\n sdk: flutter\n'); + const env = { ...process.env, HOME: home, USERPROFILE: home, ECC_AGENT_DATA_HOME: data, + CLAUDE_CONFIG_DIR: data, CLAUDE_PROJECT_DIR: project, XDG_DATA_HOME: path.join(home, '.local', 'share'), + ECC_HOMUNCULUS_DIR: path.join(data, 'homunculus'), ECC_SESSION_START_CONTEXT: 'true', + ECC_SESSION_START_MAX_CHARS: '8000' }; + // A NODE_PATH that exposes js-yaml would hide the missing dependency. + delete env.NODE_PATH; + const result = spawnSync(process.execPath, [path.join(pluginDir, 'scripts', 'hooks', 'session-start.js')], { cwd: project, - env: { ...process.env, HOME: home, USERPROFILE: home, ECC_AGENT_DATA_HOME: data, - CLAUDE_CONFIG_DIR: data, CLAUDE_PROJECT_DIR: project, XDG_DATA_HOME: path.join(home, '.local', 'share'), - ECC_HOMUNCULUS_DIR: path.join(data, 'homunculus'), ECC_SESSION_START_CONTEXT: 'true' }, + env, input: JSON.stringify({ hook_event_name: 'SessionStart', source: 'startup', session_id: 'no-deps', cwd: project }), encoding: 'utf8', timeout: 15000, From 868ac1e8944762e46d09edb51ddc9f15a8f5b471 Mon Sep 17 00:00:00 2001 From: azank Date: Thu, 15 Oct 2026 19:21:38 +0500 Subject: [PATCH 3/3] test(hooks): build the session-start test env without mutating it AGENTS.md asks for new objects rather than mutation. Leave NODE_PATH out with a rest destructure, as pre-bash-tmux-reminder.test.js does, instead of deleting it from the env after building it. --- tests/scripts/plugin-install-without-node-modules.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/scripts/plugin-install-without-node-modules.test.js b/tests/scripts/plugin-install-without-node-modules.test.js index c555ba04a..61b770d92 100644 --- a/tests/scripts/plugin-install-without-node-modules.test.js +++ b/tests/scripts/plugin-install-without-node-modules.test.js @@ -121,12 +121,12 @@ function runTests() { fs.mkdirSync(data, { recursive: true }); fs.writeFileSync(path.join(project, 'pubspec.yaml'), 'name: app\ndependencies:\n flutter:\n sdk: flutter\n'); - const env = { ...process.env, HOME: home, USERPROFILE: home, ECC_AGENT_DATA_HOME: data, + // A NODE_PATH that exposes js-yaml would hide the missing dependency. + const { NODE_PATH: _nodePath, ...inheritedEnv } = process.env; + const env = { ...inheritedEnv, HOME: home, USERPROFILE: home, ECC_AGENT_DATA_HOME: data, CLAUDE_CONFIG_DIR: data, CLAUDE_PROJECT_DIR: project, XDG_DATA_HOME: path.join(home, '.local', 'share'), ECC_HOMUNCULUS_DIR: path.join(data, 'homunculus'), ECC_SESSION_START_CONTEXT: 'true', ECC_SESSION_START_MAX_CHARS: '8000' }; - // A NODE_PATH that exposes js-yaml would hide the missing dependency. - delete env.NODE_PATH; const result = spawnSync(process.execPath, [path.join(pluginDir, 'scripts', 'hooks', 'session-start.js')], { cwd: project,