From b7c0de2bf00d785b93dd909366f35648bd8fe36d Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:59:48 +0100 Subject: [PATCH] feat(claude): register the MCP server in user scope by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config claude` wrote a per-directory `.mcp.json`, so you had to re-run it in every checkout you opened — and in a repo that commits `.mcp.json` (this one does) it dirtied the working tree. Every other integration is already global: pi, prime-agent and opencode each have one active project, wired once. Claude Code's user scope is `mcpServers` in `.claude.json`, which applies in every directory and window. That is now the default. `--scope project`, or an explicit `-o/--output`, keeps the old per-directory file for a repo that wants its own pinned project. `--inspect` stays project-scope — it is a debugging server you turn on for one checkout. Details worth knowing: - The file path is asymmetric: `$CLAUDE_CONFIG_DIR/.claude.json` when that is set, but `$HOME/.claude.json` by default — beside `~/.claude/`, not inside it. Verified against a live Claude Code run with an isolated config dir. - `.claude.json` also holds onboarding state, caches and a per-project map that Claude Code rewrites while running, so this merges into the document and writes through a temp file + rename. - User scope writes no `.mcpctl-project` marker: it scopes nothing to a directory, and a marker beside `.claude.json` would sit in $HOME and scope every repo under it. - `statusline` now resolves directory-scoped `.mcp.json` first (a repo that pinned itself wins), then user scope, then the marker. Scope selection reads Commander's option source rather than process.argv — argv is the test runner's command line when the command is driven in-process, which the suite caught immediately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- README.md | 5 ++ completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 6 +- docs/claude-integration.md | 38 ++++++++++- src/cli/src/commands/config.ts | 97 ++++++++++++++++++++++----- src/cli/src/commands/statusline.ts | 15 ++++- src/cli/src/config/claude-mcp.ts | 76 ++++++++++++++++++++- src/cli/tests/commands/claude.test.ts | 84 ++++++++++++++++++++++- 8 files changed, 299 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 02c1515..b76b6d0 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,11 @@ Preview the change without writing anything: mcpctl config prime-agent --project monitoring --dry-run ``` +`config claude` registers in **user scope** by default — one `mcpctl` MCP server +in `~/.claude.json` that applies in every directory and window, so you wire it +once rather than per checkout. Use `--scope project` (or `-o`) for a repo that +wants its own pinned project. + Inside Claude Code: - `/mcpctl [project]` — switch projects, then reconnect the `mcpctl` server from diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 80c161a..a045a22 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -122,10 +122,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir --dry-run -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 9918f91..e79dc3b 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -287,7 +287,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config view" -s o -l output -d 'ou # config claude options complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' @@ -298,7 +299,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Prin # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' diff --git a/docs/claude-integration.md b/docs/claude-integration.md index da59b78..d0163b5 100644 --- a/docs/claude-integration.md +++ b/docs/claude-integration.md @@ -4,7 +4,7 @@ | Piece | Where | Purpose | |-------|-------|---------| -| MCP entry | `./.mcp.json` | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | +| MCP entry | `~/.claude.json` (**user scope, every directory**) | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | | Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | | Skills | `~/.claude/skills/` | the project's `SKILL.md` bundles | | SessionStart hook | `~/.claude/settings.json` | `mcpctl skills sync --quiet` on every session | @@ -14,6 +14,37 @@ `--claude-dir` (or Claude Code's own `CLAUDE_CONFIG_DIR`) redirects everything under `~/.claude`. +## User scope by default: wire it once, not per checkout + +Claude Code has two MCP scopes: **project** (`./.mcp.json`, that directory only, +and usually committed — so writing to it dirties the repo) and **user** +(`mcpServers` in `.claude.json`, every directory and every window). + +mcpctl now defaults to **user** scope. One active project everywhere is how the +pi, prime-agent and opencode integrations already behave; per-directory wiring +meant re-running `config claude` in every checkout you opened. Switching the +project is then one command from anywhere. + +```bash +mcpctl config claude --project homeautomation # applies everywhere +mcpctl config claude --project docmost --scope project # just this repo +mcpctl config claude --project docmost -o .mcp.json # same thing; -o implies project scope +``` + +User scope writes **no `.mcpctl-project` marker** — it scopes nothing to a +directory, and a marker beside `.claude.json` would sit in `$HOME` and scope +every repo under it. `--inspect` stays project-scope: it is a debugging server +you turn on for one checkout. + +> `.claude.json` also holds onboarding state, caches and a per-project map that +> Claude Code rewrites while running, so mcpctl merges into it and writes +> through a temp file + rename. A truncated write there costs far more than a +> stale MCP entry. +> +> Note the path asymmetry: with `CLAUDE_CONFIG_DIR` set the file is +> `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` — +> *beside* `~/.claude/`, not inside it. + ## One server named `mcpctl`, not one per project The MCP entry used to be named after the project. Because `.mcp.json` is @@ -70,8 +101,9 @@ to open, and re-scoping it would silently change which skills sync into it. ⏵⏵ bypass permissions on · ← for agents ``` -`mcpctl statusline` resolves the project from `.mcp.json`, falling back to a -`.mcpctl-project` marker up the tree so a checkout that is scoped but not yet +`mcpctl statusline` resolves the project from a directory-scoped `.mcp.json` +first (a repo that pinned itself wins), then the user-scope entry in +`.claude.json`, then a `.mcpctl-project` marker up the tree so a checkout that is scoped but not yet wired still reports. It reads the directory from the JSON Claude Code pipes in, so it follows `/cwd` rather than reporting wherever the binary was launched, and prints **nothing** when no project is active — an empty status line beats one diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 84e0eaf..8623513 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; @@ -43,8 +43,12 @@ import { import { MCPCTL_SERVER_NAME, mergeMcpctlServers, + mergeUserScopeServer, + userScopeProject, activeProjectIn, + claudeJsonPath, type McpJson, + type ClaudeJson, } from '../config/claude-mcp.js'; import { opencodeConfigDir, @@ -74,6 +78,23 @@ const OPENCODE_TOKEN_PREFIX = 'opencode'; * the caller starts fresh — the same behaviour as before, kept because a * half-written file must not stop you re-provisioning. */ +function readJsonFile(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T; + } catch { + return null; + } +} + +/** Write JSON through a temp file + rename, so a crash cannot truncate it. */ +function writeJsonAtomicSync(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + renameSync(tmp, path); +} + function readMcpJson(path: string): McpJson | null { if (!existsSync(path)) return null; try { @@ -279,7 +300,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .command(name) .description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook') .option('-p, --project ', 'Project name') - .option('-o, --output ', 'Output file path', '.mcp.json') + .option('--scope ', 'Where to register the MCP server: user (every directory) or project (this .mcp.json)', 'user') + .option('-o, --output ', 'Project-scope output file path (implies --scope project)', '.mcp.json') .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') @@ -287,7 +309,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--skip-ui', 'Do not install the status line or the /mcpctl slash command') .option('--claude-dir ', 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)') .option('--dry-run', 'Print what would change without writing or syncing') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }) => { + .action(async (opts: { project?: string; scope: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }, command: Command) => { // Resolve Claude's config dir once: an explicit --claude-dir wins, then // $CLAUDE_CONFIG_DIR, then ~/.claude. Threading it explicitly (rather // than letting each helper default) is what keeps the test suite off the @@ -301,12 +323,41 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - const outputPath = resolve(opts.output); - const existing = readMcpJson(outputPath); - const { config: finalConfig, retired } = mergeMcpctlServers(existing, { - ...(opts.project !== undefined ? { project: opts.project } : {}), - ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), - }); + // An explicit --output only makes sense for the per-directory file, so + // it selects project scope on its own — no need to pass both. + // Commander's source tracking, not process.argv: the latter is the test + // runner's command line when the command is driven in-process. + const explicitOutput = command.getOptionValueSource('output') === 'cli'; + const scope = explicitOutput ? 'project' : opts.scope; + if (scope !== 'user' && scope !== 'project') { + log(`Error: unknown --scope '${scope}' (expected 'user' or 'project')`); + process.exitCode = 1; + return; + } + const userScope = scope === 'user'; + + const outputPath = userScope ? claudeJsonPath() : resolve(opts.output); + const existing = userScope + ? (readJsonFile(outputPath) ?? {}) + : readMcpJson(outputPath); + + // `--inspect` is a project-scope idea (a debugging server you turn on + // for one checkout), so it stays on .mcp.json even in user scope. + let finalConfig: McpJson | ClaudeJson; + let retired: string[]; + if (userScope) { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for user scope (--scope project for an --inspect-only .mcp.json)'); + process.exitCode = 1; + return; + } + ({ config: finalConfig, retired } = mergeUserScopeServer(existing as ClaudeJson, opts.project)); + } else { + ({ config: finalConfig, retired } = mergeMcpctlServers(existing as McpJson, { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), + })); + } if (opts.stdout === true) { log(JSON.stringify(finalConfig, null, 2)); @@ -316,10 +367,13 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (opts.dryRun === true) { log(JSON.stringify({ claude: { + scope, output: outputPath, - previousProject: activeProjectIn(existing), + previousProject: userScope + ? userScopeProject(existing as ClaudeJson) + : activeProjectIn(existing as McpJson), server: MCPCTL_SERVER_NAME, - entry: finalConfig.mcpServers[MCPCTL_SERVER_NAME] ?? '', + entry: finalConfig.mcpServers?.[MCPCTL_SERVER_NAME] ?? '', retiredLegacyEntries: retired, marker: opts.skipMarker === true || opts.project === undefined ? '' @@ -333,9 +387,14 @@ export function createConfigCommand(deps?: Partial, apiDeps?: return; } - writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); - const serverCount = Object.keys(finalConfig.mcpServers).length; - log(`Wrote ${outputPath} (${serverCount} server(s))`); + // Atomic: `.claude.json` also holds Claude Code's onboarding state and + // per-project map, and Claude Code rewrites it while running — a + // truncated write there costs far more than a stale MCP entry. + writeJsonAtomicSync(outputPath, finalConfig); + const serverCount = Object.keys(finalConfig.mcpServers ?? {}).length; + log(userScope + ? `Registered '${MCPCTL_SERVER_NAME}' for every directory in ${outputPath}` + : `Wrote ${outputPath} (${String(serverCount)} server(s))`); if (retired.length > 0) { // Before the constant name, every project you configured stayed // mounted alongside the new one. @@ -344,12 +403,20 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (opts.project !== undefined) { log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`); } + if (userScope) { + // The whole point of user scope: you do this once, not per checkout. + log('This applies in every directory — no need to re-run it per repo.'); + } // PR-5: write project marker, run initial skills sync, install // SessionStart hook. Skipped when --inspect-only or --skip-skills. if (opts.project && !opts.skipSkills) { const projectDir = dirname(outputPath); - if (opts.skipMarker === true) { + if (userScope) { + // User scope deliberately scopes nothing to a directory; writing a + // marker into $HOME would silently scope every repo under it. + log('Skipped .mcpctl-project marker (user scope is not directory-specific)'); + } else if (opts.skipMarker === true) { log('Skipped .mcpctl-project marker (--skip-marker)'); } else { try { diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts index a3d7c29..7f22ede 100644 --- a/src/cli/src/commands/statusline.ts +++ b/src/cli/src/commands/statusline.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; -import { activeProjectIn, type McpJson } from '../config/claude-mcp.js'; +import { activeProjectIn, claudeJsonPath, userScopeProject, type McpJson, type ClaudeJson } from '../config/claude-mcp.js'; import { findProjectMarker } from '../utils/project-marker.js'; /** @@ -53,6 +53,15 @@ export function resolveDirectory(input: StatusLineInput, fallback: string): stri return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; } +/** The project Claude Code's user-scope config mounts, or null. */ +export function projectFromUserScope(path: string): string | null { + try { + return userScopeProject(JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson); + } catch { + return null; + } +} + /** The project `.mcp.json` in `dir` mounts, or null. */ export function projectFromMcpJson(dir: string): string | null { try { @@ -87,7 +96,9 @@ export function createStatuslineCommand(deps?: Partial): Command const input = opts.directory !== undefined ? {} : await readStdinJson(); const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); - let project = projectFromMcpJson(dir); + // Directory-scoped wiring wins: a repo with its own .mcp.json entry has + // deliberately pinned itself, and that beats the global default. + let project = projectFromMcpJson(dir) ?? projectFromUserScope(claudeJsonPath()); if (project === null) { // Not wired here (or wired above this directory) — the marker is the // other thing `config claude` writes, and skills sync already trusts it. diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts index 6feaa0f..d426b26 100644 --- a/src/cli/src/config/claude-mcp.ts +++ b/src/cli/src/config/claude-mcp.ts @@ -1,3 +1,6 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + /** * `.mcp.json` shaping for `mcpctl config claude`. * @@ -19,7 +22,24 @@ * `isLegacyMcpctlEntry` for what counts as ours. */ -/** The one MCP server name mcpctl owns in `.mcp.json`. */ +/** + * WHERE THE ENTRY LIVES + * + * Claude Code has two MCP scopes: + * - **project** — `./.mcp.json`, which applies only in that directory (and is + * usually committed, so writing to it dirties the repo); + * - **user** — `mcpServers` in `.claude.json`, which applies in every + * directory and every window. + * + * mcpctl defaults to **user** scope, because one active project everywhere is + * how the pi, prime-agent and opencode integrations already behave — and + * because per-directory wiring means re-running `config claude` in every + * checkout you open. `--scope project` (or an explicit `--output`) keeps the + * old per-directory file for a repo that genuinely wants its own pinned + * project. + */ + +/** The one MCP server name mcpctl owns. */ export const MCPCTL_SERVER_NAME = 'mcpctl'; /** Name of the optional traffic-inspection server (`--inspect`). */ @@ -131,3 +151,57 @@ export function mergeMcpctlServers( delete rest.mcpServers; return { config: { ...rest, mcpServers: servers }, retired }; } + + +/** + * Path of Claude Code's user-scope config. + * + * NOTE the asymmetry: with `CLAUDE_CONFIG_DIR` set the file is + * `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` + * — *beside* `~/.claude/`, not inside it. Verified against a live Claude Code + * run with an isolated config dir. + */ +export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: string): string { + const override = env['CLAUDE_CONFIG_DIR']; + const home = homeDir ?? homedir(); + return override !== undefined && override !== '' + ? join(override, '.claude.json') + : join(home, '.claude.json'); +} + +/** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */ +export interface ClaudeJson { + mcpServers?: Record; + [key: string]: unknown; +} + +/** + * Set the user-scope entry, returning the new document and any legacy + * project-named entries retired from it. + * + * `.claude.json` also holds onboarding state, caches and a per-project map that + * Claude Code rewrites constantly — so this merges into the document it was + * given and never reconstructs it. + */ +export function mergeUserScopeServer( + existing: ClaudeJson | null | undefined, + project: string, +): { config: ClaudeJson; retired: string[] } { + const doc: ClaudeJson = { ...(existing ?? {}) }; + const servers: Record = { ...(doc.mcpServers ?? {}) }; + const retired: string[] = []; + for (const name of Object.keys(servers)) { + if (isLegacyMcpctlEntry(name, servers[name])) { + delete servers[name]; + retired.push(name); + } + } + servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(project); + doc.mcpServers = servers; + return { config: doc, retired }; +} + +/** The project the user-scope entry mounts, or null. */ +export function userScopeProject(doc: ClaudeJson | null | undefined): string | null { + return activeProjectIn({ mcpServers: doc?.mcpServers ?? {} }); +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9a079f7..9f43bd6 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -278,3 +278,85 @@ describe('config impersonate', () => { expect(output.join('\n')).toContain('No impersonation session to quit'); }); }); + +describe('config claude — user scope', () => { + let output: string[]; + let tmpDir: string; + let claudeDir: string; + let prior: string | undefined; + const log = (...args: string[]): void => { output.push(args.join(' ')); }; + const claudeJson = (): string => join(claudeDir, '.claude.json'); + + beforeEach(() => { + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-user-')); + claudeDir = join(tmpDir, 'claude-home'); + mkdirSync(claudeDir, { recursive: true }); + prior = process.env['CLAUDE_CONFIG_DIR']; + process.env['CLAUDE_CONFIG_DIR'] = claudeDir; + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + if (prior === undefined) delete process.env['CLAUDE_CONFIG_DIR']; + else process.env['CLAUDE_CONFIG_DIR'] = prior; + }); + + const cmd = () => createConfigCommand({ configDeps: {}, log }); + + it('registers in .claude.json by default, not a per-directory .mcp.json', async () => { + // The whole point: wire it once, not in every checkout you open. + await cmd().parseAsync(['claude', '--project', 'homeautomation', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.mcpServers.mcpctl).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] }); + expect(output.join('\n')).toContain('every directory'); + }); + + it('preserves everything else in .claude.json', async () => { + // That file also holds onboarding state, caches and the per-project map. + writeFileSync(claudeJson(), JSON.stringify({ + numStartups: 42, + mcpServers: { 'taskmaster-ai': { type: 'stdio', command: 'task-master-ai' } }, + projects: { '/some/repo': { allowedTools: [] } }, + })); + await cmd().parseAsync(['claude', '--project', 'p', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.numStartups).toBe(42); + expect(parsed.projects).toEqual({ '/some/repo': { allowedTools: [] } }); + expect(parsed.mcpServers['taskmaster-ai']).toBeDefined(); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'p']); + }); + + it('switching re-points the one entry', async () => { + await cmd().parseAsync(['claude', '--project', 'a', '--skip-skills', '--skip-ui'], { from: 'user' }); + await cmd().parseAsync(['claude', '--project', 'b', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(Object.keys(parsed.mcpServers)).toEqual(['mcpctl']); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'b']); + }); + + it('writes no .mcpctl-project marker — user scope is not directory-specific', async () => { + // A marker beside .claude.json would sit in $HOME and scope every repo under it. + const cwd = process.cwd(); + process.chdir(tmpDir); + try { + await cmd().parseAsync(['claude', '--project', 'p', '--skip-ui'], { from: 'user' }); + expect(existsSync(join(tmpDir, '.mcpctl-project'))).toBe(false); + expect(output.join('\n')).toContain('not directory-specific'); + } finally { process.chdir(cwd); } + }); + + it('an explicit --output still means the per-directory file', async () => { + const outPath = join(tmpDir, '.mcp.json'); + await cmd().parseAsync(['claude', '--project', 'p', '-o', outPath, '--skip-skills', '--skip-ui'], { from: 'user' }); + expect(existsSync(outPath)).toBe(true); + expect(existsSync(claudeJson())).toBe(false); + }); + + it('rejects an unknown scope instead of silently picking one', async () => { + const prevExit = process.exitCode; + await cmd().parseAsync(['claude', '--project', 'p', '--scope', 'global', '--skip-skills'], { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = prevExit; + expect(output.join('\n')).toContain("unknown --scope 'global'"); + }); +});