Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
40 changes: 25 additions & 15 deletions src/claude/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -62,24 +63,28 @@ 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 });

const settingsPath = settings;
let existing: Record<string, any> = {};
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 <pkg>/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.
Expand All @@ -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 };
}
21 changes: 16 additions & 5 deletions src/claude/settings-merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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
Expand Down
14 changes: 8 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand All @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)`);
Expand Down
73 changes: 45 additions & 28 deletions src/hosts/claude-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/hosts/mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/hosts/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/upkeep-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading
Loading