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
This commit is contained in:
Michal
2026-08-09 19:06:06 +01:00
parent d7055a0953
commit b3a062ce28
10 changed files with 525 additions and 11 deletions

View File

@@ -34,6 +34,12 @@ import {
isMcpctlToken,
} from '../config/prime-agent.js';
import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js';
import {
installStatusLine,
installSlashCommand,
claudeConfigDir,
STATUSLINE_COMMAND,
} from '../utils/claude-ui.js';
import {
MCPCTL_SERVER_NAME,
mergeMcpctlServers,
@@ -278,8 +284,17 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.option('--stdout', 'Print to stdout instead of writing a file')
.option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)')
.option('--skip-marker', 'Do not write a .mcpctl-project marker next to the output file')
.option('--skip-ui', 'Do not install the status line or the /mcpctl slash command')
.option('--claude-dir <path>', '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; dryRun?: boolean }) => {
.action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }) => {
// 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
// developer's real ~/.claude.
const claudeDir = opts.claudeDir !== undefined ? resolve(opts.claudeDir) : claudeConfigDir();
const claudeSettings = join(claudeDir, 'settings.json');
const claudeCommand = join(claudeDir, 'commands', 'mcpctl.md');
if (!opts.project && !opts.inspect) {
log('Error: at least one of --project or --inspect is required');
process.exitCode = 1;
@@ -310,6 +325,8 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
? '<skipped>'
: join(dirname(outputPath), '.mcpctl-project'),
skills: opts.skipSkills === true ? '<skipped>' : 'sync + SessionStart hook',
statusLine: opts.skipUi === true ? '<skipped>' : `${claudeSettings} (${STATUSLINE_COMMAND})`,
slashCommand: opts.skipUi === true ? '<skipped>' : claudeCommand,
},
action: 'merge .mcp.json (one `mcpctl` entry, project behind it) + marker + skills sync + hook',
}, null, 2));
@@ -359,12 +376,34 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
}
try {
const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet');
const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet', claudeSettings);
log(updated ? `Installed SessionStart hook in ${settingsPath}` : `SessionStart hook already up to date in ${settingsPath}`);
} catch (err: unknown) {
log(`Warning: failed to install SessionStart hook: ${err instanceof Error ? err.message : String(err)}`);
}
}
// The UI bits are independent of --skip-skills: they are how you see
// and change the project, not how skills get there.
if (opts.project !== undefined && opts.skipUi !== true) {
try {
const outcome = await installStatusLine(claudeSettings);
if (outcome.status === 'installed') log(`Installed the active-project status line in ${claudeSettings}`);
else if (outcome.status === 'already') log('Status line already up to date');
else {
// Never clobber a status line someone built.
log(`Left your existing status line alone (${outcome.command}).`);
log(` To show the project too, append: $(${STATUSLINE_COMMAND})`);
}
} catch (err: unknown) {
log(`Warning: failed to install the status line: ${err instanceof Error ? err.message : String(err)}`);
}
try {
log(`Installed the /mcpctl switcher: ${await installSlashCommand(claudeCommand)}`);
} catch (err: unknown) {
log(`Warning: failed to install the /mcpctl command: ${err instanceof Error ? err.message : String(err)}`);
}
}
});
if (hidden) {
// Commander shows empty-description commands but they won't clutter help output

View File

@@ -0,0 +1,103 @@
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 { findProjectMarker } from '../utils/project-marker.js';
/**
* `mcpctl statusline` — print the active mcpctl project, for Claude Code's
* `statusLine` setting.
*
* Claude Code has no plugin API that can draw into its UI, but it does run a
* command for the status line and render whatever that prints. This is that
* command: it is what gives Claude Code the same at-a-glance "which project am
* I in" that opencode gets from a footer slot and pi/prime-agent from
* `setStatus`.
*
* Claude Code pipes a JSON blob in on stdin (session id, model, workspace). We
* only need the directory — the project is whatever `.mcp.json` there mounts,
* falling back to a `.mcpctl-project` marker up the tree so a checkout that is
* scoped but not yet wired still reports.
*
* Prints nothing at all when no project is active: an empty status line is
* better than one that says "none" on every unrelated repo you open.
*/
interface StatusLineInput {
workspace?: { current_dir?: string; project_dir?: string };
cwd?: string;
}
/** Read Claude Code's stdin payload. Absent or unparseable → no directory hint. */
async function readStdinJson(): Promise<StatusLineInput> {
if (process.stdin.isTTY === true) return {};
const chunks: Buffer[] = [];
try {
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
const raw = Buffer.concat(chunks).toString('utf-8').trim();
if (raw.length === 0) return {};
return JSON.parse(raw) as StatusLineInput;
} catch {
return {};
}
}
/**
* The directory whose project we should report.
*
* Claude Code's `current_dir` moves with `/cwd`, so it beats the process cwd
* (which is wherever the Claude Code binary was launched, often unrelated).
*/
export function resolveDirectory(input: StatusLineInput, fallback: string): string {
return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback;
}
/** The project `.mcp.json` in `dir` mounts, or null. */
export function projectFromMcpJson(dir: string): string | null {
try {
const parsed = JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson;
return activeProjectIn(parsed);
} catch {
return null;
}
}
/** Format for the status line. Empty string means "render nothing". */
export function formatStatus(project: string | null, prefix: string): string {
return project !== null && project !== '' ? `${prefix}${project}` : '';
}
export interface StatuslineDeps {
log: (line: string) => void;
cwd: () => string;
homeDir: () => string;
}
export function createStatuslineCommand(deps?: Partial<StatuslineDeps>): Command {
const log = deps?.log ?? ((line: string): void => { process.stdout.write(line); });
const cwd = deps?.cwd ?? ((): string => process.cwd());
const homeDir = deps?.homeDir ?? homedir;
return new Command('statusline')
.description('Print the active mcpctl project (for Claude Code\'s statusLine setting)')
.option('-d, --directory <path>', 'Directory to resolve the project for (default: from stdin, then cwd)')
.option('--prefix <text>', 'Text before the project name', 'mcpctl:')
.action(async (opts: { directory?: string; prefix: string }) => {
const input = opts.directory !== undefined ? {} : await readStdinJson();
const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd());
let project = projectFromMcpJson(dir);
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.
const marker = await findProjectMarker(dir, homeDir()).catch(() => null);
project = marker?.project ?? null;
}
const line = formatStatus(project, opts.prefix);
// No trailing newline: Claude Code renders the output as one line, and a
// stray newline shows up as a blank second row.
if (line !== '') log(line);
});
}

View File

@@ -26,6 +26,7 @@ import { createMigrateCommand } from './commands/migrate.js';
import { createRotateCommand } from './commands/rotate.js';
import { createReviewCommand } from './commands/review.js';
import { createSkillsCommand } from './commands/skills.js';
import { createStatuslineCommand } from './commands/statusline.js';
import { createPasswdCommand } from './commands/passwd.js';
import { createErrorsCommand } from './commands/errors.js';
import { ApiClient, ApiError } from './api-client.js';
@@ -44,6 +45,7 @@ export function createProgram(): Command {
.option('-p, --project <name>', 'Target project for project commands');
program.addCommand(createStatusCommand());
program.addCommand(createStatuslineCommand());
program.addCommand(createLoginCommand());
program.addCommand(createLogoutCommand());

View File

@@ -0,0 +1,201 @@
/**
* The two pieces of Claude Code UI `mcpctl config claude` wires up:
*
* - a **status line** showing the active project, so Claude Code gets the
* same at-a-glance indicator opencode has in its footer and pi/prime-agent
* get from `setStatus`;
* - a **`/mcpctl` slash command** to switch projects from inside a session.
*
* Claude Code has no plugin API that can draw its own widget or open a picker,
* so neither is as native as the opencode switcher. The status line is a
* command Claude Code runs and renders; the slash command is a prompt file that
* drives the model through `mcpctl` CLI calls. That is the whole extension
* surface Claude Code offers, and it is enough for both jobs.
*/
import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import { MCPCTL_SERVER_NAME } from '../config/claude-mcp.js';
/** Same marker the SessionStart hook installer uses to recognise its own rows. */
export const MARKER_KEY = '_mcpctl_managed';
/** The command Claude Code runs to render the status line. */
export const STATUSLINE_COMMAND = 'mcpctl statusline';
/**
* Claude Code's config directory.
*
* `CLAUDE_CONFIG_DIR` is Claude Code's own override, so honouring it is correct
* behaviour first and test isolation second — without it, anything that
* provisions Claude writes into the developer's real ~/.claude when the test
* suite runs.
*/
export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string {
const override = env['CLAUDE_CONFIG_DIR'];
return override !== undefined && override !== '' ? override : join(homeDir, '.claude');
}
export function claudeSettingsPath(env?: NodeJS.ProcessEnv, homeDir?: string): string {
return join(claudeConfigDir(env, homeDir), 'settings.json');
}
export function claudeCommandPath(env?: NodeJS.ProcessEnv, homeDir?: string): string {
return join(claudeConfigDir(env, homeDir), 'commands', 'mcpctl.md');
}
interface StatusLine {
type?: string;
command?: string;
[k: string]: unknown;
}
interface Settings {
statusLine?: StatusLine;
[k: string]: unknown;
}
async function readSettings(path: string): Promise<Settings> {
try {
const raw = await readFile(path, 'utf-8');
if (raw.trim().length === 0) return {};
// Same heuristic as the hook installer: strip line comments so a file an
// editor added notes to still parses.
return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')) as Settings;
} catch (err: unknown) {
if ((err as { code?: string }).code === 'ENOENT') return {};
throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
}
}
async function writeSettings(path: string, settings: Settings): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp.${String(process.pid)}`;
await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
await rename(tmp, path);
}
/**
* Is this status line ours?
*
* The `_mcpctl_managed` marker alone is not enough: Claude Code rewrites
* settings.json against its own schema (on theme change, for instance) and
* **strips unknown keys from `statusLine`** — verified in a live session, where
* our tagged entry came back as a bare `{type, command}`. Hooks keep their
* marker; statusLine does not. Relying on the tag would mean reporting our own
* status line as somebody else's forever, and never upgrading the command.
*
* So the command string is the real evidence. It must *be* an `mcpctl
* statusline` invocation, not merely contain one — someone who composed ours
* into a bigger line (`my-prompt && mcpctl statusline`) owns that line, and we
* must not overwrite it.
*/
export function isOurStatusLine(current: StatusLine | null | undefined): boolean {
if (current === null || current === undefined) return false;
if (current[MARKER_KEY] === true) return true;
const command = current.command;
return typeof command === 'string' && /^\s*(\S*\/)?mcpctl\s+statusline(\s|$)/.test(command);
}
export type StatusLineOutcome =
| { status: 'installed' }
| { status: 'already' }
| { status: 'foreign'; command: string };
/**
* Install the status line — but never over one the user already has.
*
* A status line is a single slot, so installing ours on top of a custom one
* silently deletes work someone put effort into. When we find a foreign one we
* leave it and report it, so the caller can print the one-line snippet to add
* instead. Ours is tagged, so re-running is idempotent and an upgrade of the
* command string still lands.
*/
export async function installStatusLine(
settingsPath: string = claudeSettingsPath(),
command: string = STATUSLINE_COMMAND,
): Promise<StatusLineOutcome> {
const settings = await readSettings(settingsPath);
const current = settings.statusLine;
if (current !== undefined && current !== null) {
if (!isOurStatusLine(current)) return { status: 'foreign', command: String(current.command ?? '<unknown>') };
if (current.command === command) return { status: 'already' };
}
settings.statusLine = { type: 'command', command, [MARKER_KEY]: true };
await writeSettings(settingsPath, settings);
return { status: 'installed' };
}
/** Remove our status line, leaving a foreign one alone. */
export async function removeStatusLine(settingsPath: string = claudeSettingsPath()): Promise<boolean> {
const settings = await readSettings(settingsPath);
if (!isOurStatusLine(settings.statusLine)) return false;
delete settings.statusLine;
await writeSettings(settingsPath, settings);
return true;
}
/**
* The `/mcpctl` slash command.
*
* Claude Code slash commands are prompt files, not code — so unlike opencode's
* picker this drives the model through CLI calls. `allowed-tools` is scoped to
* the exact `mcpctl` invocations it needs, so accepting the command does not
* hand it a general shell.
*
* Every `!`-prefixed block below is pre-executed by Claude Code and checked
* against that same list — including `statusline`, which is easy to forget
* because it is context-gathering rather than an action. Omitting one fails the
* whole command with a permission error before the model sees anything.
*
* `--skip-marker` matters here for the same reason it does in the opencode
* switcher: the session's directory is whatever you happened to open, and
* re-scoping it would silently change which skills sync into it.
*/
export const MCPCTL_SLASH_COMMAND = `---
description: Switch the active mcpctl project (MCP servers + skills)
allowed-tools: Bash(mcpctl statusline:*), Bash(mcpctl get projects:*), Bash(mcpctl config claude:*), Bash(mcpctl skills sync:*)
---
# Switch the active mcpctl project
The user wants to change which mcpctl project this session is connected to.
There is exactly one mcpctl MCP server, named \`${MCPCTL_SERVER_NAME}\`; switching
projects changes what sits behind that name.
Requested project (may be empty): $ARGUMENTS
## Steps
1. Show the current project and the available ones:
!\`mcpctl statusline --prefix 'current: ' --directory .\`
!\`mcpctl get projects -o json\`
2. If \$ARGUMENTS names a project, use it. Otherwise list the projects
compactly (name — description) and ask which one. Do not guess.
3. Switch, keeping this directory's scope unchanged:
\`mcpctl config claude --project <name> --skip-marker\`
4. Report the switch as "now on <project>". Do not describe the project as
the server — the server is always \`${MCPCTL_SERVER_NAME}\`, only what sits
behind it changed.
5. Tell the user, in one short line, that they must now **reconnect the
\`${MCPCTL_SERVER_NAME}\` server from \`/mcp\`** for the new project's tools to
load. The config on disk is already correct; the running session still holds
the old connection until it is reconnected.
Keep the whole exchange to a few lines. This is a switcher, not a report.
`;
/** Write the `/mcpctl` slash command into Claude Code's user commands dir. */
export async function installSlashCommand(path: string = claudeCommandPath()): Promise<string> {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, MCPCTL_SLASH_COMMAND, 'utf-8');
return path;
}

View File

@@ -39,7 +39,12 @@ interface Settings {
}
function defaultSettingsPath(): string {
return join(homedir(), '.claude', 'settings.json');
// CLAUDE_CONFIG_DIR is Claude Code's own override. Honouring it also stops
// the test suite writing a hook into the developer's real ~/.claude — which
// is how an untagged duplicate of this very hook got there in the first place.
const override = process.env['CLAUDE_CONFIG_DIR'];
const base = override !== undefined && override !== '' ? override : join(homedir(), '.claude');
return join(base, 'settings.json');
}
async function readSettings(path: string): Promise<Settings> {

View File

@@ -21,14 +21,29 @@ describe('config claude', () => {
let tmpDir: string;
const log = (...args: string[]) => output.push(args.join(' '));
/**
* 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-'));
claudeDir = join(tmpDir, 'claude-home');
priorClaudeConfigDir = process.env['CLAUDE_CONFIG_DIR'];
process.env['CLAUDE_CONFIG_DIR'] = claudeDir;
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
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 () => {

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
installStatusLine,
removeStatusLine,
installSlashCommand,
MCPCTL_SLASH_COMMAND,
STATUSLINE_COMMAND,
MARKER_KEY,
} from '../../src/utils/claude-ui.js';
describe('installStatusLine', () => {
let dir: string;
let settings: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-ui-'));
settings = join(dir, 'settings.json');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('installs into a missing settings file', async () => {
expect(await installStatusLine(settings)).toEqual({ status: 'installed' });
const parsed = JSON.parse(readFileSync(settings, 'utf-8'));
expect(parsed.statusLine).toEqual({ type: 'command', command: STATUSLINE_COMMAND, [MARKER_KEY]: true });
});
it('is idempotent', async () => {
await installStatusLine(settings);
const before = readFileSync(settings, 'utf-8');
expect(await installStatusLine(settings)).toEqual({ status: 'already' });
expect(readFileSync(settings, 'utf-8')).toBe(before);
});
it('upgrades its own entry when the command changes', async () => {
await installStatusLine(settings, 'mcpctl statusline --prefix old:');
expect(await installStatusLine(settings, STATUSLINE_COMMAND)).toEqual({ status: 'installed' });
expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe(STATUSLINE_COMMAND);
});
it('still recognises its own line after Claude Code strips the marker', async () => {
// Claude Code rewrites settings.json against its own schema and drops
// unknown keys from statusLine — verified live. Without matching on the
// command we would call our own line foreign forever.
writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline' } }));
expect(await installStatusLine(settings)).toEqual({ status: 'already' });
writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline --prefix p:' } }));
expect(await installStatusLine(settings)).toEqual({ status: 'installed' });
});
it('does not claim a line that merely composes ours into a bigger one', async () => {
// That line is the user's work, even though our command appears in it.
writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-prompt && mcpctl statusline' } }));
expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-prompt && mcpctl statusline' });
});
it('never clobbers a status line the user built', async () => {
// A status line is a single slot; overwriting one silently deletes work.
writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-fancy-prompt' } }));
expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-fancy-prompt' });
expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('my-fancy-prompt');
});
it('preserves every other setting', async () => {
writeFileSync(settings, JSON.stringify({ permissions: { allow: ['Bash'] }, hooks: { SessionStart: [] } }));
await installStatusLine(settings);
const parsed = JSON.parse(readFileSync(settings, 'utf-8'));
expect(parsed.permissions).toEqual({ allow: ['Bash'] });
expect(parsed.hooks).toEqual({ SessionStart: [] });
});
it('tolerates line comments an editor may have added', async () => {
writeFileSync(settings, '{\n // my notes\n "permissions": { "allow": [] }\n}\n');
expect(await installStatusLine(settings)).toEqual({ status: 'installed' });
expect(JSON.parse(readFileSync(settings, 'utf-8')).permissions).toEqual({ allow: [] });
});
it('removes only its own entry', async () => {
writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'theirs' } }));
expect(await removeStatusLine(settings)).toBe(false);
expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('theirs');
await installStatusLine(join(dir, 'ours.json'));
expect(await removeStatusLine(join(dir, 'ours.json'))).toBe(true);
expect(JSON.parse(readFileSync(join(dir, 'ours.json'), 'utf-8')).statusLine).toBeUndefined();
});
});
describe('the /mcpctl slash command', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-cmd-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('is written where Claude Code looks for user commands', async () => {
const path = join(dir, 'commands', 'mcpctl.md');
expect(await installSlashCommand(path)).toBe(path);
expect(existsSync(path)).toBe(true);
});
it('scopes allowed-tools to mcpctl, not a general shell', async () => {
// Accepting the command must not hand it arbitrary Bash.
const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? '';
expect(tools).not.toMatch(/Bash\(\*\)|Bash\)/);
for (const t of tools.split(', ')) expect(t).toMatch(/^Bash\(mcpctl /);
});
it('permits every command it pre-executes', () => {
// A `!`-block missing from allowed-tools fails the whole command with a
// permission error before the model sees anything — which is exactly what
// happened live when `statusline` was omitted.
const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? '';
const permitted = tools.split(', ').map((t) => /^Bash\((.+?):?\*?\)$/.exec(t)?.[1] ?? '');
const preExecuted = [...MCPCTL_SLASH_COMMAND.matchAll(/!`([^`]+)`/g)].map((m) => m[1] ?? '');
expect(preExecuted.length).toBeGreaterThan(0);
for (const cmd of preExecuted) {
expect(permitted.some((p) => p !== '' && cmd.startsWith(p)), `"${cmd}" is not covered by allowed-tools`).toBe(true);
}
});
it('switches without re-scoping the directory the session opened in', () => {
expect(MCPCTL_SLASH_COMMAND).toContain('--skip-marker');
});
it('tells the user to reconnect, since the running session holds the old connection', () => {
expect(MCPCTL_SLASH_COMMAND).toMatch(/reconnect/i);
expect(MCPCTL_SLASH_COMMAND).toContain('/mcp');
});
it('refers to the one constant server name', () => {
expect(MCPCTL_SLASH_COMMAND).toContain('`mcpctl`');
});
});