diff --git a/CHANGELOG.md b/CHANGELOG.md index 335cadf10..9550a8007 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## Unreleased + +### Fixed + +- **`graft init --no-mcp` / `--no-hooks` were ignored for Claude Code.** Those + flags only reached `runHostsInit` (Cursor/Codex/…). `runInit` always wrote + `.mcp.json` and the Claude hook shim/settings — including the user-level + copies under `~/.claude`. They now skip the matching Claude Code writes too, + `--dry-run` / the picker drop those paths, and a later wiring refresh + replays the stamp so an upgrade does not put them back. + ## 0.16.0 ### Added diff --git a/src/claude/init.ts b/src/claude/init.ts index 111c0f926..7805c59c0 100644 --- a/src/claude/init.ts +++ b/src/claude/init.ts @@ -16,17 +16,18 @@ import type { PlannedWrite } from '../hosts/plan.js'; * can report them up front. All repo-local: the Claude Code layer never writes * outside the project. */ -export function claudeTargets(dir: string): PlannedWrite[] { +export function claudeTargets(dir: string, opts: { mcp?: boolean; hooks?: boolean } = {}): PlannedWrite[] { const t = (path: string, what: string, kind: PlannedWrite['kind'] = 'claude'): PlannedWrite => ({ hostId: 'claude', id: 'claude', path, scope: 'repo', kind, what }); - return [ - t(join(dir, '.claude', 'settings.json'), 'graft statusline + hook blocks'), + const writes: PlannedWrite[] = [ + t(join(dir, '.claude', 'settings.json'), opts.hooks === false ? 'graft statusline' : 'graft statusline + hook blocks'), t(join(dir, '.claude', 'helpers', 'graft-statusline.cjs'), 'statusline shim'), - t(join(dir, '.claude', 'helpers', 'graft-hooks.cjs'), 'hooks shim'), - t(join(dir, '.claude', 'skills', 'graft', 'SKILL.md'), 'graft skill'), - // Tagged 'mcp' so the picker doesn't label Claude Code as having no MCP. - t(join(dir, '.mcp.json'), 'mcpServers.graft', 'mcp'), ]; + if (opts.hooks !== false) writes.push(t(join(dir, '.claude', 'helpers', 'graft-hooks.cjs'), 'hooks shim')); + writes.push(t(join(dir, '.claude', 'skills', 'graft', 'SKILL.md'), 'graft skill')); + // Tagged 'mcp' so the picker doesn't label Claude Code as having no MCP. + if (opts.mcp !== false) writes.push(t(join(dir, '.mcp.json'), 'mcpServers.graft', 'mcp')); + return writes; } /** @@ -62,9 +63,10 @@ export interface InitResult { export function runInit( dir: string, - opts: { build?: boolean; cliPath?: string; statusline?: boolean; global?: boolean; home?: string } = {}, + opts: { build?: boolean; cliPath?: string; mcp?: boolean; hooks?: boolean; statusline?: boolean; global?: boolean; home?: string } = {}, ): InitResult { - // Same list `--dry-run` and the picker report, so the two can't drift apart. + // Unfiltered path list so names stay stable when `--no-mcp` / `--no-hooks` + // omit a write. The filtered list is what `--dry-run` reports. const [settings, statusline, hooks, skill, mcpTarget] = claudeTargets(dir).map((t) => t.path); mkdirSync(dirname(statusline), { recursive: true }); @@ -72,14 +74,17 @@ export function runInit( const settingsPath = settings; let existing: Record = {}; try { existing = JSON.parse(readFileSync(settingsPath, 'utf8')); } catch { /* none/invalid → start fresh */ } - const { merged, warnings } = mergeGraftSettings(existing, { statusline: opts.statusline }); + const { merged, warnings } = mergeGraftSettings(existing, { hooks: opts.hooks, statusline: opts.statusline }); writeFileSync(settingsPath, `${JSON.stringify(merged, null, 2)}\n`); const sl = statusline; - const hk = hooks; const bakedDir = claudeDistDir(); // absolute /dist/claude — the shims' primary resolution path writeFileSync(sl, statuslineShim(bakedDir)); chmodSync(sl, 0o755); - writeFileSync(hk, hooksShim(bakedDir)); chmodSync(hk, 0o755); + const shims = [sl]; + if (opts.hooks !== false) { + writeFileSync(hooks, hooksShim(bakedDir)); chmodSync(hooks, 0o755); + shims.push(hooks); + } // Install the graft skill — the piece that redirects the agent to graft/ before it // greps source. Overwritten each run (graft owns this file), like the shims above. @@ -90,15 +95,20 @@ export function runInit( // Register the graft MCP server in the project's .mcp.json so Claude Code // exposes graft_find_code/graft_trace_calls/etc. as tools — the same keyed merge the // other hosts use (existing servers preserved; unparseable files skipped). - const mcp = mergeJsonKey('claude', mcpTarget, 'mcpServers', serverEntry()); + // `--no-mcp` skips this write: Claude Code can still call the CLI via the skill. + const mcp = opts.mcp === false + ? { id: 'claude', path: mcpTarget, action: 'skipped' as const } + : mergeJsonKey('claude', mcpTarget, 'mcpServers', serverEntry()); // The same wiring again, one level up in `~/.claude`, because everything above // this line can be erased by a `.gitignore` and lost to `git worktree add`. See // hosts/claude-global.ts for the failure that motivates it. Gated on the same // flag `registerMcpConfigs` uses, so `--no-global` still means "nothing outside // this repo". - const global = opts.global === false ? [] : installClaudeGlobal(opts.home ?? homedir()); + const global = opts.global === false + ? [] + : installClaudeGlobal(opts.home ?? homedir(), { mcp: opts.mcp, hooks: opts.hooks }); const built = buildGraphIfMissing(dir, opts); - return { settingsPath, shims: [sl, hk], skill: skillPath, mcp, global, warnings, built }; + return { settingsPath, shims, skill: skillPath, mcp, global, warnings, built }; } diff --git a/src/claude/settings-merge.ts b/src/claude/settings-merge.ts index 3ee1d15b8..ba670add2 100644 --- a/src/claude/settings-merge.ts +++ b/src/claude/settings-merge.ts @@ -113,7 +113,7 @@ function applyStatusline( export function mergeGraftSettings( existing: Json, - opts: { statusline?: boolean } = {}, + opts: { statusline?: boolean; hooks?: boolean } = {}, ): { merged: Json; warnings: string[] } { const merged: Json = { ...(existing ?? {}) }; const warnings: string[] = []; @@ -129,10 +129,21 @@ export function mergeGraftSettings( ); merged.hooks = { ...(merged.hooks ?? {}) }; - for (const [event, blocks] of Object.entries(graftBlocks())) { - const prior = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : []; - const foreign = prior.filter((e: Json) => !isGraftEntry(e)); // drop old Graft entries → idempotent - merged.hooks[event] = [...foreign, ...blocks]; + if (opts.hooks === false) { + // `--no-hooks`: drop graft hook entries, keep anyone else's. + for (const event of Object.keys(merged.hooks)) { + const prior = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : []; + const foreign = prior.filter((e: Json) => !isGraftEntry(e)); + if (foreign.length) merged.hooks[event] = foreign; + else delete merged.hooks[event]; + } + if (Object.keys(merged.hooks).length === 0) delete merged.hooks; + } else { + for (const [event, blocks] of Object.entries(graftBlocks())) { + const prior = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : []; + const foreign = prior.filter((e: Json) => !isGraftEntry(e)); // drop old Graft entries → idempotent + merged.hooks[event] = [...foreign, ...blocks]; + } } // Drop graft's own prior regex before re-adding, so a change to FOOTER replaces diff --git a/src/cli.ts b/src/cli.ts index edd7ef4e0..655bb3dbe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -916,8 +916,8 @@ program .option("--all-agents", "write instruction files for every known agent, detected or not") .option("--no-agents", "Claude Code wiring only; skip other agents") .option("--list-agents", "list known agent ids and exit") - .option("--no-mcp", "skip MCP server registration for other agents") - .option("--no-hooks", "skip hook installation for other agents") + .option("--no-mcp", "skip MCP server registration (Claude Code .mcp.json and other agents)") + .option("--no-hooks", "skip hook installation (Claude Code helpers + settings, and other agents)") .option("--no-statusline", "skip writing Claude Code statusLine (keep a user-defined one)") .option("--dry-run", "print every file init would touch, then exit without writing") .option("-y, --yes", "skip the picker and wire every detected agent (the pre-0.8 default)") @@ -944,7 +944,7 @@ program // guessing (pre-0.8 this silently wired every agent the machine had ever // installed — see --yes to get that back). const home = homedir(); - const plan = planInit(repo, { home }); + const plan = planInit(repo, { home, mcp: opts.mcp, hooks: opts.hooks }); const detectedIds = plan.filter((p) => p.detected).map((p) => p.id); const noAgents = (opts as { agents?: unknown }).agents === false; @@ -994,7 +994,7 @@ program if (opts.dryRun) { console.error(formatPlan(plan, ids, repo, home)); for (const child of children) - console.error(`\n— ${child}/ (workspace child)\n` + formatPlan(planInit(join(repo, child), { home }), ids, join(repo, child), home)); + console.error(`\n— ${child}/ (workspace child)\n` + formatPlan(planInit(join(repo, child), { home, mcp: opts.mcp, hooks: opts.hooks }), ids, join(repo, child), home)); return; } if (ids.length === 0) { @@ -1065,11 +1065,13 @@ function wireTarget( // `global`/`home` are threaded through alongside `statusline`: the claude layer // writes under `~/.claude` now (hosts/claude-global.ts), so --no-global has to // reach it or the flag would silently mean "no out-of-repo writes, except three". - const res = runInit(repo, { build: opts.build, cliPath, statusline: wantStatusline, global: opts.global, home }); + const res = runInit(repo, { build: opts.build, cliPath, mcp: opts.mcp, hooks: opts.hooks, statusline: wantStatusline, global: opts.global, home }); console.error(`✓ wrote ${res.settingsPath}`); for (const s of res.shims) console.error(`✓ wrote ${s}`); console.error(`✓ wrote ${res.skill}`); - if (res.mcp.action === "skipped-unparseable") + if (res.mcp.action === "skipped") + console.error(`· skipped Claude Code MCP registration (--no-mcp)`); + else if (res.mcp.action === "skipped-unparseable") console.error(`⚠ .mcp.json: ${res.mcp.path} left unchanged (not valid JSON) — add the graft server manually`); else if (res.mcp.action === "unchanged") console.error(`· mcp claude: ${res.mcp.path} (already registered)`); diff --git a/src/hosts/claude-global.ts b/src/hosts/claude-global.ts index 309008771..c75770d5d 100644 --- a/src/hosts/claude-global.ts +++ b/src/hosts/claude-global.ts @@ -54,14 +54,20 @@ export function globalHelpersDir(home: string): string { * a worktree is its own project entry there, so a local registration would miss it * for precisely the same reason the repo file does. */ -export function claudeGlobalTargets(home: string): PlannedWrite[] { +export function claudeGlobalTargets(home: string, opts: { mcp?: boolean; hooks?: boolean } = {}): PlannedWrite[] { const g = (id: string, path: string, kind: PlannedWrite['kind'], what: string): PlannedWrite => ({ hostId: 'claude', id, path, scope: 'global', kind, what }); - return [ - g('claude-global-shim', join(globalHelpersDir(home), 'graft-hooks.cjs'), 'hook', 'hooks shim (user level)'), - g('claude-global-hooks', join(home, '.claude', 'settings.json'), 'hook', 'SessionStart / UserPromptSubmit / PostToolUse / Stop'), - g('claude-global-mcp', join(home, '.claude.json'), 'mcp', 'mcpServers.graft'), - ]; + const writes: PlannedWrite[] = []; + // `--no-hooks` / `--no-mcp` must skip the user-level copies too — otherwise + // `~/.claude` would silently undo the repo-level skip in every worktree. + if (opts.hooks !== false) { + writes.push(g('claude-global-shim', join(globalHelpersDir(home), 'graft-hooks.cjs'), 'hook', 'hooks shim (user level)')); + writes.push(g('claude-global-hooks', join(home, '.claude', 'settings.json'), 'hook', 'SessionStart / UserPromptSubmit / PostToolUse / Stop')); + } + if (opts.mcp !== false) { + writes.push(g('claude-global-mcp', join(home, '.claude.json'), 'mcp', 'mcpServers.graft')); + } + return writes; } /** Merge graft's hook blocks into a settings file, preserving everything else. */ @@ -90,35 +96,46 @@ function upsertGlobalHooks(id: string, path: string, helpers: string): GlobalWri * Best-effort by contract, like every other writer here: a failure is reported as an * action, never raised, so a bad `~/.claude.json` can't fail a `graft init`. */ -export function installClaudeGlobal(home: string): GlobalWrite[] { - const [shim, settings, mcp] = claudeGlobalTargets(home); +export function installClaudeGlobal(home: string, opts: { mcp?: boolean; hooks?: boolean } = {}): GlobalWrite[] { + const targets = claudeGlobalTargets(home, opts); + const byId = new Map(targets.map((t) => [t.id, t])); const out: GlobalWrite[] = []; - try { - out.push(writeOwned(shim.id, shim.path, hooksShim(claudeDistDir()), 0o755)); - } catch { - out.push({ id: shim.id, path: shim.path, action: 'skipped-unparseable' }); - } - - // Only wire the hooks once the shim they call is actually on disk — a hook - // entry pointing at a missing file is an error in every session, which is a - // worse failure than not installing. - if (out[0].action !== 'skipped-unparseable') { + const shim = byId.get('claude-global-shim'); + if (shim) { try { - // Posix form in the command string: `join` gives backslashes on Windows and - // the template appends `/graft-hooks.cjs`, so the raw path produces a mixed - // `C:\Users\…\helpers/graft-hooks.cjs`. Node accepts forward slashes on - // Windows, so one separator throughout is both correct and readable. - out.push(upsertGlobalHooks(settings.id, settings.path, toPosixPath(globalHelpersDir(home)))); + out.push(writeOwned(shim.id, shim.path, hooksShim(claudeDistDir()), 0o755)); } catch { - out.push({ id: settings.id, path: settings.path, action: 'skipped-unparseable' }); + out.push({ id: shim.id, path: shim.path, action: 'skipped-unparseable' }); + } + } + + const settings = byId.get('claude-global-hooks'); + if (settings) { + // Only wire the hooks once the shim they call is actually on disk — a hook + // entry pointing at a missing file is an error in every session, which is a + // worse failure than not installing. + const shimWrite = out.find((w) => w.id === 'claude-global-shim'); + if (!shimWrite || shimWrite.action !== 'skipped-unparseable') { + try { + // Posix form in the command string: `join` gives backslashes on Windows and + // the template appends `/graft-hooks.cjs`, so the raw path produces a mixed + // `C:\Users\…\helpers/graft-hooks.cjs`. Node accepts forward slashes on + // Windows, so one separator throughout is both correct and readable. + out.push(upsertGlobalHooks(settings.id, settings.path, toPosixPath(globalHelpersDir(home)))); + } catch { + out.push({ id: settings.id, path: settings.path, action: 'skipped-unparseable' }); + } } } - try { - out.push(mergeJsonKey(mcp.id, mcp.path, 'mcpServers', serverEntry())); - } catch { - out.push({ id: mcp.id, path: mcp.path, action: 'skipped-unparseable' }); + const mcp = byId.get('claude-global-mcp'); + if (mcp) { + try { + out.push(mergeJsonKey(mcp.id, mcp.path, 'mcpServers', serverEntry())); + } catch { + out.push({ id: mcp.id, path: mcp.path, action: 'skipped-unparseable' }); + } } return out; diff --git a/src/hosts/mcp-config.ts b/src/hosts/mcp-config.ts index 111da50f3..defef38ba 100644 --- a/src/hosts/mcp-config.ts +++ b/src/hosts/mcp-config.ts @@ -14,8 +14,9 @@ import { homedir } from 'node:os'; import type { PlannedWrite } from './plan.js'; import { readJsonObject, type ConfigWrite } from './config-write.js'; -/** MCP registration reports the same write-result record every installer does. */ -export type McpWrite = ConfigWrite; +/** MCP registration reports the same write-result record every installer does, + * plus `skipped` when `--no-mcp` declines the write. */ +export type McpWrite = ConfigWrite | { id: string; path: string; action: 'skipped' }; /** A planned MCP write, plus the detail needed to actually perform it. */ export interface McpTarget extends PlannedWrite { diff --git a/src/hosts/plan.ts b/src/hosts/plan.ts index 38b225b38..24d66588a 100644 --- a/src/hosts/plan.ts +++ b/src/hosts/plan.ts @@ -65,7 +65,7 @@ function instructionTarget(repo: string, host: HostTarget): PlannedWrite { * touch. Claude Code comes first — it's the deep integration and the picker's * default. `ids`, when given, restricts the plan to those hosts. */ -export function planInit(repo: string, opts: { home?: string; ids?: string[] } = {}): HostPlan[] { +export function planInit(repo: string, opts: { home?: string; ids?: string[]; mcp?: boolean; hooks?: boolean } = {}): HostPlan[] { const home = opts.home ?? homedir(); const probe = probeFor(home, repo); const detected = new Set(detectHosts(probe).map((h) => h.id)); @@ -74,7 +74,7 @@ export function planInit(repo: string, opts: { home?: string; ids?: string[] } = // Repo writes plus the user-level copy under `~/.claude` — the picker and // `--dry-run` render 'global' writes in their own section, so a user sees // what lands outside the repo before agreeing to it. - { id: 'claude', name: 'Claude Code', detected: true, writes: [...claudeTargets(repo), ...claudeGlobalTargets(home)] }, + { id: 'claude', name: 'Claude Code', detected: true, writes: [...claudeTargets(repo, { mcp: opts.mcp, hooks: opts.hooks }), ...claudeGlobalTargets(home, { mcp: opts.mcp, hooks: opts.hooks })] }, ...HOSTS.map((host) => ({ id: host.id, name: host.name, diff --git a/src/upkeep-run.ts b/src/upkeep-run.ts index 7c64afa53..aa4167190 100644 --- a/src/upkeep-run.ts +++ b/src/upkeep-run.ts @@ -43,7 +43,7 @@ function rewriteWiring(repo: string, hosts: string[], opts: WiringOpts): void { // its `~/.claude` writes (hosts/claude-global.ts) are out-of-repo, and a user who // declined those at init time must keep declining them on every replay. if (hosts.includes('claude')) - runInit(repo, { build: false, cliPath: graftCliPath(), statusline: opts.statusline, global: opts.global }); + runInit(repo, { build: false, cliPath: graftCliPath(), mcp: opts.mcp, hooks: opts.hooks, statusline: opts.statusline, global: opts.global }); const others = hosts.filter((h) => h !== 'claude'); if (others.length) runHostsInit(repo, { agents: others, global: opts.global, mcp: opts.mcp, hooks: opts.hooks }); diff --git a/test/claude-init.test.ts b/test/claude-init.test.ts index 2a7fbf6d5..d67f0192f 100644 --- a/test/claude-init.test.ts +++ b/test/claude-init.test.ts @@ -36,6 +36,61 @@ test('runInit scaffolds settings + both shims + the skill (build skipped)', () = assert.ok(s.statusLine.command.includes('graft-statusline.cjs')); assert.ok(s.hooks.Stop[0].hooks[0].command.includes('graft-hooks.cjs')); assert.deepEqual(s.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); + assert.ok(existsSync(join(d, '.mcp.json')), 'default init still registers Claude Code MCP'); +}); + +test('runInit mcp:false skips .mcp.json', () => { + const d = fresh(); + const home = fresh(); + const r = runInit(d, { build: false, mcp: false, home }); + assert.ok(existsSync(join(d, '.claude', 'settings.json'))); + assert.ok(existsSync(join(d, '.claude', 'helpers', 'graft-hooks.cjs'))); + assert.ok(!existsSync(join(d, '.mcp.json'))); + assert.equal(r.mcp.action, 'skipped'); + assert.ok(!existsSync(join(home, '.claude.json')), 'user-scope MCP is skipped too'); + assert.ok(existsSync(join(home, '.claude', 'helpers', 'graft-hooks.cjs')), 'hooks still land in ~'); +}); + +test('runInit hooks:false skips the hooks shim and graft hook blocks', () => { + const d = fresh(); + const home = fresh(); + const r = runInit(d, { build: false, hooks: false, home }); + assert.ok(existsSync(join(d, '.claude', 'helpers', 'graft-statusline.cjs'))); + assert.ok(!existsSync(join(d, '.claude', 'helpers', 'graft-hooks.cjs'))); + assert.equal(r.shims.length, 1); + const s = JSON.parse(readFileSync(join(d, '.claude', 'settings.json'), 'utf8')); + assert.equal(s.hooks, undefined); + assert.ok(s.statusLine.command.includes('graft-statusline.cjs')); + assert.ok(!existsSync(join(home, '.claude', 'helpers', 'graft-hooks.cjs')), 'user-level hook shim skipped'); + assert.ok(existsSync(join(home, '.claude.json')), 'user-scope MCP still lands'); +}); + +test('CLI: --no-mcp --agents claude writes skill/hooks but no .mcp.json', () => { + const d = fresh(); + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'init', d, '--no-build', '--agents', 'claude', '--no-mcp'], + { encoding: 'utf8' }, + ); + assert.equal(res.status, 0, res.stderr); + assert.ok(existsSync(join(d, '.claude', 'settings.json'))); + assert.ok(existsSync(join(d, '.claude', 'helpers', 'graft-hooks.cjs'))); + assert.ok(!existsSync(join(d, '.mcp.json'))); + assert.match(res.stderr, /skipped Claude Code MCP registration/); +}); + +test('CLI: --no-hooks --agents claude writes statusline + skill but no hook shim', () => { + const d = fresh(); + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'init', d, '--no-build', '--agents', 'claude', '--no-hooks'], + { encoding: 'utf8' }, + ); + assert.equal(res.status, 0, res.stderr); + assert.ok(existsSync(join(d, '.claude', 'helpers', 'graft-statusline.cjs'))); + assert.ok(!existsSync(join(d, '.claude', 'helpers', 'graft-hooks.cjs'))); + const s = JSON.parse(readFileSync(join(d, '.claude', 'settings.json'), 'utf8')); + assert.equal(s.hooks, undefined); }); test('runInit overwrites a stale skill file', () => { diff --git a/test/claude-settings-merge.test.ts b/test/claude-settings-merge.test.ts index ad424557a..26f6a1866 100644 --- a/test/claude-settings-merge.test.ts +++ b/test/claude-settings-merge.test.ts @@ -137,3 +137,15 @@ test('permissions object with no allow key gets one added; other keys preserved' assert.deepEqual(merged.permissions.deny, ['Bash(rm:*)']); assert.deepEqual(merged.permissions.allow, ['Bash(graft:*)', 'Bash(npx graft:*)', 'Bash(graft-dev:*)', 'Bash(node dist/cli.js:*)']); }); + +test('hooks:false drops graft hook blocks and leaves foreign hooks', () => { + const existing = { + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'mine.sh' }] }], + }, + }; + const { merged } = mergeGraftSettings(existing, { hooks: false }); + assert.equal(merged.hooks.Stop.length, 1); + assert.equal(merged.hooks.Stop[0].hooks[0].command, 'mine.sh'); + assert.equal(merged.hooks.PostToolUse, undefined); +}); diff --git a/test/hosts-claude-global.test.ts b/test/hosts-claude-global.test.ts index 6a690117b..571ac1972 100644 --- a/test/hosts-claude-global.test.ts +++ b/test/hosts-claude-global.test.ts @@ -178,6 +178,29 @@ test('--no-global writes nothing outside the repo', () => { assert.ok(existsSync(join(repo, '.mcp.json'))); }); +test('--no-mcp skips user-scope MCP but still installs user-level hooks', () => { + const home = tmpRepo('cgnomcp'); + const repo = tmpRepo('cgnomcprepo'); + + const r = runInit(repo, { build: false, mcp: false, home }); + + assert.ok(!existsSync(userMcpOf(home))); + assert.ok(existsSync(shimOf(home))); + assert.ok(readJson(settingsOf(home)).hooks?.SessionStart); + assert.ok(r.global.every((w) => w.id !== 'claude-global-mcp')); +}); + +test('--no-hooks skips user-level hook files but still registers user-scope MCP', () => { + const home = tmpRepo('cgnohooks'); + const repo = tmpRepo('cgnohooksrepo'); + + runInit(repo, { build: false, hooks: false, home }); + + assert.equal(existsSync(shimOf(home)), false); + assert.equal(existsSync(settingsOf(home)), false); + assert.ok(readJson(userMcpOf(home)).mcpServers?.graft); +}); + /* ------------------------------------------------------------------ * * the plan, and putting it back * ------------------------------------------------------------------ */ diff --git a/test/hosts-plan.test.ts b/test/hosts-plan.test.ts index bc3129a4b..f12c691f1 100644 --- a/test/hosts-plan.test.ts +++ b/test/hosts-plan.test.ts @@ -58,6 +58,19 @@ test('claude writes: five in the repo, three in ~ that no .gitignore can eat', ( assert.ok(globals.every((w) => !w.path.startsWith(repo)), 'nothing global lands in the repo'); }); +test('claude plan omits .mcp.json and the hooks shim when those flags are off', () => { + const repo = fresh(); + const home = fullHome(); + const claude = planInit(repo, { home, ids: ['claude'], mcp: false, hooks: false })[0]; + // Repo: settings + statusline + skill. Global copies of hooks/MCP are skipped too. + assert.equal(claude.writes.length, 3); + assert.ok(!claude.writes.some((w) => w.path.endsWith('.mcp.json'))); + assert.ok(!claude.writes.some((w) => w.path.includes('graft-hooks.cjs'))); + assert.ok(!claude.writes.some((w) => w.path.endsWith('.claude.json'))); + assert.equal(claude.writes.filter((w) => w.scope === 'global').length, 0); + assert.ok(claude.writes.some((w) => w.path.endsWith('settings.json'))); +}); + test('the three ~/.codex writes are scoped global', () => { const home = fullHome(); const agents = planInit(fresh(), { home, ids: ['agents'] })[0];