diff --git a/src/cli.ts b/src/cli.ts index 12130c77..f03b0c98 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -948,7 +948,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, global: opts.global }); const detectedIds = plan.filter((p) => p.detected).map((p) => p.id); const noAgents = (opts as { agents?: unknown }).agents === false; @@ -998,7 +998,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, global: opts.global }), ids, join(repo, child), home)); return; } if (ids.length === 0) { @@ -1099,7 +1099,12 @@ function wireTarget( for (const m of r.mcp) console.error(`✓ mcp ${m.id}: ${m.path} (${m.action})`); for (const h of r.hooks) console.error(`✓ hook ${h.id}: ${h.path} (${h.action})`); // Only worth saying when there was actually something out-of-repo to skip. - if (opts.global === false && selectedWrites(plan, ids).some((w) => w.scope === "global")) + // `plan` already reflects --no-global, so ask the unsuppressed plan whether + // there was anything out-of-repo to skip. + if ( + opts.global === false && + selectedWrites(planInit(repo, { home, mcp: opts.mcp, hooks: opts.hooks }), ids).some((w) => w.scope === "global") + ) console.error("· skipped out-of-repo writes (--no-global)"); } diff --git a/src/hosts/plan.ts b/src/hosts/plan.ts index 38b225b3..a8ecb480 100644 --- a/src/hosts/plan.ts +++ b/src/hosts/plan.ts @@ -65,26 +65,55 @@ 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 interface PlanInitOptions { + home?: string; + ids?: string[]; + /** `--no-mcp`: skip MCP server registration for the non-Claude hosts. */ + mcp?: boolean; + /** `--no-hooks`: skip hook installation for the non-Claude hosts. */ + hooks?: boolean; + /** `--no-global`: skip every write outside the repo. */ + global?: boolean; +} + +/** + * The same predicates `runHostsInit()` applies when it writes, so `--dry-run` + * describes the operation the flags actually select (#329): + * - `--no-mcp` drops the MCP registrations; + * - `--no-hooks` drops the Codex and Cursor hooks; + * - `--no-global` drops everything outside the repo: the `~/.claude` copy, the + * global MCP configs (Codex, Antigravity), the Codex hooks and the + * Antigravity skill. Cursor's hooks are repo-local and stay. + */ +export function planInit(repo: string, opts: PlanInitOptions = {}): HostPlan[] { const home = opts.home ?? homedir(); const probe = probeFor(home, repo); const detected = new Set(detectHosts(probe).map((h) => h.id)); + const withGlobal = opts.global !== false; + const withMcp = opts.mcp !== false; + const withHooks = opts.hooks !== false; + const inScope = (w: PlannedWrite) => withGlobal || w.scope !== 'global'; const plans: HostPlan[] = [ // 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), ...(withGlobal ? claudeGlobalTargets(home) : [])], + }, ...HOSTS.map((host) => ({ id: host.id, name: host.name, detected: detected.has(host.id), writes: [ instructionTarget(repo, host), - ...mcpTargets(repo, [host.id], { home }), - ...(host.id === 'agents' ? hookTargets(home) : []), - ...(host.id === 'cursor' ? cursorHookTargets(repo) : []), - ...(host.id === 'antigravity' ? antigravitySkillTargets(home) : []), + ...(withMcp ? mcpTargets(repo, [host.id], { home }).filter(inScope) : []), + ...(host.id === 'agents' && withHooks && withGlobal ? hookTargets(home) : []), + ...(host.id === 'cursor' && withHooks ? cursorHookTargets(repo) : []), + ...(host.id === 'antigravity' && withGlobal ? antigravitySkillTargets(home) : []), ], })), ]; diff --git a/test/hosts-plan.test.ts b/test/hosts-plan.test.ts index bc3129a4..2cfbc11b 100644 --- a/test/hosts-plan.test.ts +++ b/test/hosts-plan.test.ts @@ -115,3 +115,32 @@ test('selectedWrites filters to the chosen hosts only', () => { const writes = selectedWrites(plan, ['claude', 'adal']); assert.deepEqual([...new Set(writes.map((w) => w.hostId))].sort(), ['adal', 'claude']); }); + +test('planInit honors --no-mcp / --no-hooks / --no-global like runHostsInit does (#329)', () => { + const repo = fresh(); const home = fullHome(); + const byId = (plan: ReturnType, id: string) => plan.find((p) => p.id === id)!.writes; + + const full = planInit(repo, { home }); + assert.ok(byId(full, 'agents').some((w) => w.scope === 'global'), 'default plan lists the ~/.codex writes'); + assert.ok(byId(full, 'cursor').some((w) => w.path.endsWith('hooks.json')), 'default plan lists the Cursor hooks'); + + const noMcp = planInit(repo, { home, mcp: false }); + for (const p of noMcp) for (const w of p.writes) { + if (p.id === 'claude') continue; + assert.ok(!/mcp|config\.toml|settings\.json/.test(w.path) || w.path.endsWith('hooks.json'), `--no-mcp still plans ${w.path}`); + } + + const noHooks = planInit(repo, { home, hooks: false }); + assert.ok(!byId(noHooks, 'agents').some((w) => /hooks/.test(w.path)), '--no-hooks drops the Codex hooks'); + assert.ok(!byId(noHooks, 'cursor').some((w) => w.path.endsWith('hooks.json')), '--no-hooks drops the Cursor hooks'); + + const noGlobal = planInit(repo, { home, global: false }); + for (const p of noGlobal) for (const w of p.writes) { + assert.notEqual(w.scope, 'global', `--no-global still plans ${w.path}`); + assert.ok(!toPosixPath(w.path).startsWith(toPosixPath(home)), `--no-global still plans ${w.path} under ~`); + } + assert.ok(byId(noGlobal, 'cursor').some((w) => w.path.endsWith('hooks.json')), 'Cursor hooks are repo-local and survive --no-global'); + + const suppressed = planInit(repo, { home, mcp: false, hooks: false, global: false }); + assert.deepEqual(byId(suppressed, 'agents').map((w) => toPosixPath(w.path)), [toPosixPath(join(repo, 'AGENTS.md'))]); +});