diff --git a/manifests/install-assets/claude-project-scripts-package.json b/manifests/install-assets/claude-project-scripts-package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/manifests/install-assets/claude-project-scripts-package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/scripts/lib/install-targets/claude-project.js b/scripts/lib/install-targets/claude-project.js index a4fda3970..afd413716 100644 --- a/scripts/lib/install-targets/claude-project.js +++ b/scripts/lib/install-targets/claude-project.js @@ -9,6 +9,7 @@ const { } = require('./helpers'); const CLAUDE_ECC_NAMESPACE = 'ecc'; +const CLAUDE_PROJECT_COMMONJS_PACKAGE = 'manifests/install-assets/claude-project-scripts-package.json'; function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) { const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); @@ -65,7 +66,7 @@ module.exports = createInstallTargetAdapter({ return modules.flatMap(module => { const paths = Array.isArray(module.paths) ? module.paths : []; - return paths + const operations = paths .filter(p => !isForeignPlatformPath(p, 'claude')) .flatMap(sourceRelativePath => { if ( @@ -93,6 +94,21 @@ module.exports = createInstallTargetAdapter({ return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; }); + + if (module.id !== 'hooks-runtime') { + return operations; + } + + return [ + ...['hooks', 'lib'].map(directory => createRemappedOperation( + adapter, + module.id, + CLAUDE_PROJECT_COMMONJS_PACKAGE, + path.join(adapter.resolveRoot(planningInput), 'scripts', directory, 'package.json'), + { strategy: 'preserve-relative-path' } + )), + ...operations, + ]; }); }, }); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 576ba4389..f2846495f 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -1056,6 +1056,74 @@ function runTests() { } })) passed++; else failed++; + if (test('isolates project hooks from ESM package scopes without overwriting user Claude package data', () => { + const homeDir = createTempDir('install-apply-claude-project-esm-home-'); + const projectDir = createTempDir('install-apply-claude-project-esm-'); + const claudeRoot = path.join(projectDir, '.claude'); + const userPackagePath = path.join(claudeRoot, 'package.json'); + const scriptsPackagePath = path.join(claudeRoot, 'scripts', 'package.json'); + const hooksPackagePath = path.join(claudeRoot, 'scripts', 'hooks', 'package.json'); + const libPackagePath = path.join(claudeRoot, 'scripts', 'lib', 'package.json'); + const userPackage = '{"name":"user-claude-config","type":"module"}\n'; + const userScriptsPackage = '{"name":"user-claude-scripts","type":"module"}\n'; + + try { + fs.writeFileSync(path.join(projectDir, 'package.json'), '{"type":"module"}\n'); + fs.mkdirSync(path.dirname(scriptsPackagePath), { recursive: true }); + fs.writeFileSync(userPackagePath, userPackage); + fs.writeFileSync(scriptsPackagePath, userScriptsPackage); + + const firstInstall = run( + ['--target', 'claude-project', '--profile', 'core', '--enable-hooks'], + { cwd: projectDir, homeDir } + ); + assert.strictEqual(firstInstall.code, 0, firstInstall.stderr); + assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + assert.deepStrictEqual(readJson(hooksPackagePath), { type: 'commonjs' }); + assert.deepStrictEqual(readJson(libPackagePath), { type: 'commonjs' }); + + const hookResult = spawnSync( + process.execPath, + [path.join(claudeRoot, 'scripts', 'hooks', 'block-no-verify.js')], + { + input: JSON.stringify({ tool_input: { command: 'git commit --no-verify' } }), + encoding: 'utf8', + cwd: projectDir, + } + ); + assert.strictEqual(hookResult.status, 2, hookResult.stderr); + assert.match(hookResult.stderr, /no-verify/i); + + const secondInstall = run( + ['--target', 'claude-project', '--profile', 'core', '--enable-hooks'], + { cwd: projectDir, homeDir } + ); + assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); + assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + + const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); + const boundaryPaths = [hooksPackagePath, libPackagePath]; + const packageBoundaryOperations = state.operations.filter(operation => ( + boundaryPaths.includes(operation.destinationPath) + )); + assert.deepStrictEqual( + packageBoundaryOperations.map(operation => operation.destinationPath).sort(), + [...boundaryPaths].sort() + ); + assert.ok(packageBoundaryOperations.every(operation => operation.moduleId === 'hooks-runtime')); + assert.ok(packageBoundaryOperations.every(operation => ( + /^[a-f0-9]{64}$/i.test(operation.contentSha256) + ))); + assert.ok(!state.operations.some(operation => operation.destinationPath === userPackagePath)); + assert.ok(!state.operations.some(operation => operation.destinationPath === scriptsPackagePath)); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('preserves existing settings.json while disabling Claude co-author attribution', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); diff --git a/tests/scripts/uninstall.test.js b/tests/scripts/uninstall.test.js index 75759969d..a5532a256 100644 --- a/tests/scripts/uninstall.test.js +++ b/tests/scripts/uninstall.test.js @@ -132,6 +132,59 @@ function runTests() { } })) passed++; else failed++; + if (test('uninstalls the project hook module boundary and preserves user Claude package data', () => { + const homeDir = createTempDir('uninstall-claude-project-esm-home-'); + const projectRoot = createTempDir('uninstall-claude-project-esm-'); + const claudeRoot = path.join(projectRoot, '.claude'); + const userPackagePath = path.join(claudeRoot, 'package.json'); + const scriptsPackagePath = path.join(claudeRoot, 'scripts', 'package.json'); + const hooksPackagePath = path.join(claudeRoot, 'scripts', 'hooks', 'package.json'); + const libPackagePath = path.join(claudeRoot, 'scripts', 'lib', 'package.json'); + const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); + const userPackage = '{"name":"user-claude-config","type":"module"}\n'; + const userScriptsPackage = '{"name":"user-claude-scripts","type":"module"}\n'; + + try { + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{"type":"module"}\n'); + fs.mkdirSync(path.dirname(scriptsPackagePath), { recursive: true }); + fs.writeFileSync(userPackagePath, userPackage); + fs.writeFileSync(scriptsPackagePath, userScriptsPackage); + + execFileSync( + 'node', + [INSTALL_SCRIPT, '--target', 'claude-project', '--profile', 'core', '--enable-hooks'], + { + cwd: projectRoot, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: CLI_TIMEOUT_MS, + } + ); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(hooksPackagePath, 'utf8')), { type: 'commonjs' }); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(libPackagePath, 'utf8')), { type: 'commonjs' }); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + + const uninstallResult = run(['--target', 'claude-project'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr); + assert.strictEqual(fs.readFileSync(userPackagePath, 'utf8'), userPackage); + assert.strictEqual(fs.readFileSync(scriptsPackagePath, 'utf8'), userScriptsPackage); + assert.ok(!fs.existsSync(hooksPackagePath)); + assert.ok(!fs.existsSync(libPackagePath)); + assert.ok(!fs.existsSync(statePath)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('reverses non-copy operations and keeps unrelated files', () => { const homeDir = createTempDir('uninstall-home-'); const projectRoot = createTempDir('uninstall-project-');