Files
mcpctl/src/cli/tests/commands/claude.test.ts

405 lines
17 KiB
TypeScript
Raw Normal View History

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
feat(claude): register the MCP server in user scope by default `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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:59:48 +01:00
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';
import type { ApiClient } from '../../src/api-client.js';
import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({})),
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('config claude', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
let tmpDir: string;
const log = (...args: string[]) => output.push(args.join(' '));
feat(claude): active-project status line + /mcpctl switcher, and stop tests writing to ~/.claude Claude Code had neither of the things opencode, pi and prime-agent all have: a visible active project, and a way to change it from inside a session. It has no plugin API that can draw a widget or open a picker, but it does run a command for its status line and it does load slash commands — which is enough for both. - `mcpctl statusline` prints the active project (from .mcp.json, falling back to a .mcpctl-project marker) and is wired into settings.json. It reads the directory out of the JSON Claude Code pipes in, so it follows /cwd rather than reporting wherever the binary was launched. Prints nothing when no project is active: an empty line beats "none" on every unrelated repo. - `/mcpctl [project]` switches and reminds you to reconnect from /mcp. allowed-tools is scoped to the four exact mcpctl invocations it needs. Three things found by running it rather than reasoning about it: - Claude Code REWRITES settings.json against its own schema and strips unknown keys from `statusLine` — our `_mcpctl_managed` marker came back gone, so ownership is now determined by the command string. (Hooks keep their marker; statusLine does not.) A composed line like `my-prompt && mcpctl statusline` is deliberately not claimed. - Every `!`-prefixed block in a slash command is permission-checked against allowed-tools. Omitting `statusline` failed the whole command before the model saw anything. A test now asserts every pre-executed command is covered. - Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn that auth may not work; claude-vllm now sets only the former and clears an inherited API key. Also fixes a pre-existing test-isolation bug this work would have made worse: `config claude` wrote into the developer's real ~/.claude when the suite ran, which is how an untagged duplicate of the skills-sync SessionStart hook got there. Both the hook installer and the new UI installers now honour CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first, isolation second), `config claude` gains --claude-dir for parity with --pi-dir and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical. Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost` switches and the line updates to `mcpctl:docmost` in the same session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:06:06 +01:00
/**
* Claude Code's config dir, redirected per test.
*
* Without this the suite writes a SessionStart hook, a status line and a
* slash command into the developer's real ~/.claude which is exactly how an
* untagged duplicate of the skills-sync hook ended up there.
*/
let claudeDir: string;
let priorClaudeConfigDir: string | undefined;
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-'));
feat(claude): active-project status line + /mcpctl switcher, and stop tests writing to ~/.claude Claude Code had neither of the things opencode, pi and prime-agent all have: a visible active project, and a way to change it from inside a session. It has no plugin API that can draw a widget or open a picker, but it does run a command for its status line and it does load slash commands — which is enough for both. - `mcpctl statusline` prints the active project (from .mcp.json, falling back to a .mcpctl-project marker) and is wired into settings.json. It reads the directory out of the JSON Claude Code pipes in, so it follows /cwd rather than reporting wherever the binary was launched. Prints nothing when no project is active: an empty line beats "none" on every unrelated repo. - `/mcpctl [project]` switches and reminds you to reconnect from /mcp. allowed-tools is scoped to the four exact mcpctl invocations it needs. Three things found by running it rather than reasoning about it: - Claude Code REWRITES settings.json against its own schema and strips unknown keys from `statusLine` — our `_mcpctl_managed` marker came back gone, so ownership is now determined by the command string. (Hooks keep their marker; statusLine does not.) A composed line like `my-prompt && mcpctl statusline` is deliberately not claimed. - Every `!`-prefixed block in a slash command is permission-checked against allowed-tools. Omitting `statusline` failed the whole command before the model saw anything. A test now asserts every pre-executed command is covered. - Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn that auth may not work; claude-vllm now sets only the former and clears an inherited API key. Also fixes a pre-existing test-isolation bug this work would have made worse: `config claude` wrote into the developer's real ~/.claude when the suite ran, which is how an untagged duplicate of the skills-sync SessionStart hook got there. Both the hook installer and the new UI installers now honour CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first, isolation second), `config claude` gains --claude-dir for parity with --pi-dir and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical. Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost` switches and the line updates to `mcpctl:docmost` in the same session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:06:06 +01:00
claudeDir = join(tmpDir, 'claude-home');
priorClaudeConfigDir = process.env['CLAUDE_CONFIG_DIR'];
process.env['CLAUDE_CONFIG_DIR'] = claudeDir;
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
feat(claude): active-project status line + /mcpctl switcher, and stop tests writing to ~/.claude Claude Code had neither of the things opencode, pi and prime-agent all have: a visible active project, and a way to change it from inside a session. It has no plugin API that can draw a widget or open a picker, but it does run a command for its status line and it does load slash commands — which is enough for both. - `mcpctl statusline` prints the active project (from .mcp.json, falling back to a .mcpctl-project marker) and is wired into settings.json. It reads the directory out of the JSON Claude Code pipes in, so it follows /cwd rather than reporting wherever the binary was launched. Prints nothing when no project is active: an empty line beats "none" on every unrelated repo. - `/mcpctl [project]` switches and reminds you to reconnect from /mcp. allowed-tools is scoped to the four exact mcpctl invocations it needs. Three things found by running it rather than reasoning about it: - Claude Code REWRITES settings.json against its own schema and strips unknown keys from `statusLine` — our `_mcpctl_managed` marker came back gone, so ownership is now determined by the command string. (Hooks keep their marker; statusLine does not.) A composed line like `my-prompt && mcpctl statusline` is deliberately not claimed. - Every `!`-prefixed block in a slash command is permission-checked against allowed-tools. Omitting `statusline` failed the whole command before the model saw anything. A test now asserts every pre-executed command is covered. - Setting ANTHROPIC_AUTH_TOKEN *and* ANTHROPIC_API_KEY makes Claude Code warn that auth may not work; claude-vllm now sets only the former and clears an inherited API key. Also fixes a pre-existing test-isolation bug this work would have made worse: `config claude` wrote into the developer's real ~/.claude when the suite ran, which is how an untagged duplicate of the skills-sync SessionStart hook got there. Both the hook installer and the new UI installers now honour CLAUDE_CONFIG_DIR (Claude Code's own override — correct behaviour first, isolation second), `config claude` gains --claude-dir for parity with --pi-dir and --opencode-dir, and the suite is verified to leave ~/.claude byte-identical. Verified live: status line renders `mcpctl:homeautomation`, `/mcpctl docmost` switches and the line updates to `mcpctl:docmost` in the same session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:06:06 +01:00
if (priorClaudeConfigDir === undefined) delete process.env['CLAUDE_CONFIG_DIR'];
else process.env['CLAUDE_CONFIG_DIR'] = priorClaudeConfigDir;
});
it('generates .mcp.json with mcpctl mcp bridge entry', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
feat(cli+mcpd): mcpctl skills sync + config claude extension Phase 5 of the Skills + Revisions + Proposals work. Skills are now materialised onto disk under ~/.claude/skills/<name>/, with hash-pinned diff against mcpd, atomic per-skill install, and preservation of locally-modified files. `mcpctl config claude --project X` now wires the full pickup chain: writes .mcpctl-project marker, runs the initial sync, installs the SessionStart hook so subsequent Claude invocations stay in sync transparently. ## Sync algorithm 1. Resolve project: `--project` flag overrides; else walk up from cwd looking for `.mcpctl-project`; else fall back to globals-only. 2. GET /api/v1/projects/:name/skills/visible (or /api/v1/skills?scope=global without a project). Server returns id + name + semver + scope + contentHash + metadata — no body, no files. The contentHash is sha256 of the canonicalised body, computed server-side; any reordering of keys produces the same hash, so it's a stable diff key. 3. Load ~/.mcpctl/skills-state.json (lives outside ~/.claude/skills/ on purpose — Claude Code reads that tree and we don't want to pollute it with our bookkeeping). 4. Diff: - server skill not in state → INSTALL - server skill, state contentHash matches → SKIP (cheap path) - server skill, state contentHash differs → UPDATE (fetch full body) - state skill not in server → orphan, REMOVE (preserve if locally modified, unless --force) 5. Atomic per-skill install: write to <targetDir>.mcpctl-staging-<pid>/, rename existing tree to .mcpctl-trash-<pid>, swap staging in, rmtree the trash. A concurrent reader (Claude Code starting up) never sees a partial tree. 6. State file updated with new versions, per-file SHA-256, install path. saveState is atomic (temp + rename). ## Failure semantics - `--quiet` mode (used by SessionStart hook): exit 0 on network / timeout / mcpd error. Fail-open is non-negotiable here — we never want a hung mcpd to block Claude Code starting up. - Auth failure: exit 1, clear "run mcpctl login" message. - Disk error during state save: exit 2. - Per-skill errors are collected in the result and reported as a count; one bad skill doesn't stop the others. Network fetches run with concurrency 5. The server-side `/visible` endpoint is metadata-only so the cheap path (everything unchanged) needs exactly one HTTP roundtrip total. ## Files added ### CLI utilities (src/cli/src/utils/) - skills-state.ts — load/save state, per-file sha256, edit detection. - project-marker.ts — walk-up to find `.mcpctl-project`, bounded by user home so we never search above $HOME. - sessionhook.ts — install/remove a SessionStart hook entry tagged with `_mcpctl_managed: true`. Idempotent. Defensive against missing/empty/JSONC settings.json. - skills-disk.ts — atomic install via staging-dir rename swap, symmetric atomic delete via trash-dir rename. Path-escape attempts in files{} are rejected. ### CLI command (src/cli/src/commands/) - skills.ts — `mcpctl skills sync` Commander wrapper + the `runSkillsSync(opts, deps)` library function (also called from `mcpctl config claude --project`). Supports `--dry-run`, `--force`, `--quiet`, `--keep-orphans`. `--skip-postinstall` is reserved (postInstall execution lands in a follow-up PR, not this one). ### Wiring - index.ts: registers `mcpctl skills` after `mcpctl review`. - config.ts: `mcpctl config claude --project X` now writes the `.mcpctl-project` marker, runs `runSkillsSync` in-process, and calls `installManagedSessionHook('mcpctl skills sync --quiet')`. New flag `--skip-skills` opts out (used by tests; useful for CI). ## Server-side change - src/mcpd/src/services/skill.service.ts: getVisibleSkills now computes contentHash on the fly from the canonical body shape the client will reconstruct. Cheap (sha256 of ~few KB per skill); no schema migration needed since hash is derived not stored. ## Tests Four new utility test files (31 tests) under src/cli/tests/utils/: - sessionhook.test.ts — creation, idempotency, command updates, preservation of user hooks, removal, empty/JSONC tolerance. - skills-disk.test.ts — atomic write, replacement without leftovers, path-escape rejection, atomic delete, listing ignores staging/trash artifacts. - skills-state.test.ts — sha256 determinism, state round-trip, schema-version drift handling, edit detection. - project-marker.test.ts — cwd hit, walk-up, $HOME boundary, empty marker, write+read round-trip. The existing `mcpctl config claude` test (claude.test.ts) was updated to pass `--skip-skills` so it stays focused on .mcp.json generation; the new sync flow is covered by the utility tests. Full suite: 162 test files / 2157 tests green (up from 158 / 2127). ## Deferred to a follow-up - `metadata.hooks` materialisation into `~/.claude/settings.json` — the data path exists, sync receives it; PR-7 or a focused follow-up will write the `_mcpctl_managed: true` entries for declarative hooks. - `metadata.mcpServers` auto-attach via mcpd API — likewise. - `metadata.postInstall` script execution — the most substantive deferred piece. Current sync logs a TODO and skips. The corporate trust model (publisher-side rigor, not client-side defence) means this is straightforward to add once we wire the curated env + timeout + audit emission. Orthogonal to file sync, easier to ship separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:26:35 +01:00
// PR-5: --skip-skills bypasses the new sync + SessionStart hook side
// effects so this test stays focused on .mcp.json generation. The new
// sync flow has its own tests under src/cli/tests/utils/.
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath, '--skip-skills'], { from: 'user' });
feat(cli+mcpd): mcpctl skills sync + config claude extension Phase 5 of the Skills + Revisions + Proposals work. Skills are now materialised onto disk under ~/.claude/skills/<name>/, with hash-pinned diff against mcpd, atomic per-skill install, and preservation of locally-modified files. `mcpctl config claude --project X` now wires the full pickup chain: writes .mcpctl-project marker, runs the initial sync, installs the SessionStart hook so subsequent Claude invocations stay in sync transparently. ## Sync algorithm 1. Resolve project: `--project` flag overrides; else walk up from cwd looking for `.mcpctl-project`; else fall back to globals-only. 2. GET /api/v1/projects/:name/skills/visible (or /api/v1/skills?scope=global without a project). Server returns id + name + semver + scope + contentHash + metadata — no body, no files. The contentHash is sha256 of the canonicalised body, computed server-side; any reordering of keys produces the same hash, so it's a stable diff key. 3. Load ~/.mcpctl/skills-state.json (lives outside ~/.claude/skills/ on purpose — Claude Code reads that tree and we don't want to pollute it with our bookkeeping). 4. Diff: - server skill not in state → INSTALL - server skill, state contentHash matches → SKIP (cheap path) - server skill, state contentHash differs → UPDATE (fetch full body) - state skill not in server → orphan, REMOVE (preserve if locally modified, unless --force) 5. Atomic per-skill install: write to <targetDir>.mcpctl-staging-<pid>/, rename existing tree to .mcpctl-trash-<pid>, swap staging in, rmtree the trash. A concurrent reader (Claude Code starting up) never sees a partial tree. 6. State file updated with new versions, per-file SHA-256, install path. saveState is atomic (temp + rename). ## Failure semantics - `--quiet` mode (used by SessionStart hook): exit 0 on network / timeout / mcpd error. Fail-open is non-negotiable here — we never want a hung mcpd to block Claude Code starting up. - Auth failure: exit 1, clear "run mcpctl login" message. - Disk error during state save: exit 2. - Per-skill errors are collected in the result and reported as a count; one bad skill doesn't stop the others. Network fetches run with concurrency 5. The server-side `/visible` endpoint is metadata-only so the cheap path (everything unchanged) needs exactly one HTTP roundtrip total. ## Files added ### CLI utilities (src/cli/src/utils/) - skills-state.ts — load/save state, per-file sha256, edit detection. - project-marker.ts — walk-up to find `.mcpctl-project`, bounded by user home so we never search above $HOME. - sessionhook.ts — install/remove a SessionStart hook entry tagged with `_mcpctl_managed: true`. Idempotent. Defensive against missing/empty/JSONC settings.json. - skills-disk.ts — atomic install via staging-dir rename swap, symmetric atomic delete via trash-dir rename. Path-escape attempts in files{} are rejected. ### CLI command (src/cli/src/commands/) - skills.ts — `mcpctl skills sync` Commander wrapper + the `runSkillsSync(opts, deps)` library function (also called from `mcpctl config claude --project`). Supports `--dry-run`, `--force`, `--quiet`, `--keep-orphans`. `--skip-postinstall` is reserved (postInstall execution lands in a follow-up PR, not this one). ### Wiring - index.ts: registers `mcpctl skills` after `mcpctl review`. - config.ts: `mcpctl config claude --project X` now writes the `.mcpctl-project` marker, runs `runSkillsSync` in-process, and calls `installManagedSessionHook('mcpctl skills sync --quiet')`. New flag `--skip-skills` opts out (used by tests; useful for CI). ## Server-side change - src/mcpd/src/services/skill.service.ts: getVisibleSkills now computes contentHash on the fly from the canonical body shape the client will reconstruct. Cheap (sha256 of ~few KB per skill); no schema migration needed since hash is derived not stored. ## Tests Four new utility test files (31 tests) under src/cli/tests/utils/: - sessionhook.test.ts — creation, idempotency, command updates, preservation of user hooks, removal, empty/JSONC tolerance. - skills-disk.test.ts — atomic write, replacement without leftovers, path-escape rejection, atomic delete, listing ignores staging/trash artifacts. - skills-state.test.ts — sha256 determinism, state round-trip, schema-version drift handling, edit detection. - project-marker.test.ts — cwd hit, walk-up, $HOME boundary, empty marker, write+read round-trip. The existing `mcpctl config claude` test (claude.test.ts) was updated to pass `--skip-skills` so it stays focused on .mcp.json generation; the new sync flow is covered by the utility tests. Full suite: 162 test files / 2157 tests green (up from 158 / 2127). ## Deferred to a follow-up - `metadata.hooks` materialisation into `~/.claude/settings.json` — the data path exists, sync receives it; PR-7 or a focused follow-up will write the `_mcpctl_managed: true` entries for declarative hooks. - `metadata.mcpServers` auto-attach via mcpd API — likewise. - `metadata.postInstall` script execution — the most substantive deferred piece. Current sync logs a TODO and skips. The corporate trust model (publisher-side rigor, not client-side defence) means this is straightforward to add once we wire the curated env + timeout + audit emission. Orthogonal to file sync, easier to ship separately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:26:35 +01:00
// No API call should be made when --skip-skills is set.
expect(client.get).not.toHaveBeenCalled();
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'homeautomation'],
});
expect(output.join('\n')).toContain('1 server(s)');
});
it('prints to stdout with --stdout', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
const parsed = JSON.parse(output[0]);
expect(parsed.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'myproj'],
});
});
it('always merges with existing .mcp.json', async () => {
const outPath = join(tmpDir, '.mcp.json');
writeFileSync(outPath, JSON.stringify({
mcpServers: { 'existing--server': { command: 'echo', args: [] } },
}));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['existing--server']).toBeDefined();
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
expect(output.join('\n')).toContain('2 server(s)');
});
it('adds inspect MCP server with --inspect', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--inspect', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['mcpctl-inspect']).toEqual({
command: 'mcpctl',
args: ['console', '--stdin-mcp'],
});
expect(output.join('\n')).toContain('1 server(s)');
});
it('adds both project and inspect with --project --inspect', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'ha', '--inspect', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['mcpctl']).toBeDefined();
expect(written.mcpServers['mcpctl-inspect']).toBeDefined();
expect(output.join('\n')).toContain('2 server(s)');
});
it('backward compat: claude-generate still works', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
});
it('uses one constant server key, whatever the project is called', async () => {
// The key used to be the project name, so `config claude` for a second
// project left the first one mounted too — every project ever configured
// stayed connected, with duplicate tool names.
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']);
expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'my-fancy-project']);
});
it('switching projects replaces the mount instead of stacking a second one', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'first', '-o', outPath, '--skip-skills'], { from: 'user' });
await cmd.parseAsync(['claude', '--project', 'second', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']);
expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'second']);
});
it('retires a legacy project-named entry left by an older CLI', async () => {
const outPath = join(tmpDir, '.mcp.json');
writeFileSync(outPath, JSON.stringify({
mcpServers: {
homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] },
'my-own-server': { command: 'echo', args: [] },
},
}));
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'docmost', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers).sort()).toEqual(['mcpctl', 'my-own-server']);
expect(output.join('\n')).toContain('Retired legacy per-project entry: homeautomation');
});
it('--dry-run reports the plan and writes nothing', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'p', '-o', outPath, '--dry-run'], { from: 'user' });
const plan = JSON.parse(output.join('\n'));
expect(plan.claude.server).toBe('mcpctl');
expect(plan.claude.entry.args).toEqual(['mcp', '-p', 'p']);
expect(existsSync(outPath)).toBe(false);
});
});
describe('config impersonate', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
let tmpDir: string;
const log = (...args: string[]) => output.push(args.join(' '));
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-impersonate-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('impersonates a user and saves backup', async () => {
saveCredentials({ token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com' }, { configDir: tmpDir });
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/auth/impersonate', { email: 'other@test.com' });
expect(output.join('\n')).toContain('Impersonating other@test.com');
const creds = loadCredentials({ configDir: tmpDir });
expect(creds!.user).toBe('other@test.com');
expect(creds!.token).toBe('impersonated-tok');
// Backup exists
const backup = JSON.parse(readFileSync(join(tmpDir, 'credentials-backup'), 'utf-8'));
expect(backup.user).toBe('admin@test.com');
});
it('quits impersonation and restores backup', async () => {
// Set up current (impersonated) credentials
saveCredentials({ token: 'impersonated-tok', mcpdUrl: 'http://localhost:3100', user: 'other@test.com' }, { configDir: tmpDir });
// Set up backup (original) credentials
writeFileSync(join(tmpDir, 'credentials-backup'), JSON.stringify({
token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com',
}));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
expect(output.join('\n')).toContain('Returned to admin@test.com');
const creds = loadCredentials({ configDir: tmpDir });
expect(creds!.user).toBe('admin@test.com');
expect(creds!.token).toBe('admin-tok');
});
it('errors when not logged in', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
expect(output.join('\n')).toContain('Not logged in');
});
it('errors when quitting with no backup', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
expect(output.join('\n')).toContain('No impersonation session to quit');
});
});
feat(claude): register the MCP server in user scope by default `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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:59:48 +01:00
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'");
});
// A user-scope switch never rewrites a directory's .mcp.json, so anything of
// ours left in one keeps answering in that directory. Saying so is the only
// way the user finds out — the switch otherwise reports plain success.
describe('warns when the working directory contradicts the switch', () => {
const switchTo = async (project: string): Promise<string> => {
await createConfigCommand({ configDeps: {}, log, cwd: () => tmpDir })
.parseAsync(['claude', '--project', project, '--skip-skills', '--skip-ui'], { from: 'user' });
return output.join('\n');
};
it('names a legacy entry that stays mounted alongside the new project', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: { homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] } },
}));
const out = await switchTo('sre');
expect(out).toContain(join(tmpDir, '.mcp.json'));
expect(out).toContain("'homeautomation'");
expect(out).toContain('mounted alongside');
});
it('says a canonical pin overrides the switch in that directory', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: { mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'docmost'] } },
}));
expect(await switchTo('sre')).toContain('overrides the switch here');
});
it('stays quiet when the directory already agrees, or wires nothing of ours', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: {
mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'sre'] },
'their-server': { command: 'docker', args: ['run', 'x'] },
},
}));
expect(await switchTo('sre')).not.toContain('Warning:');
});
it('stays quiet when there is no .mcp.json at all', async () => {
expect(await switchTo('sre')).not.toContain('Warning:');
});
});
feat(claude): register the MCP server in user scope by default `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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 19:59:48 +01:00
});