Files
mcpctl/src/cli/tests/utils/claude-ui.test.ts

134 lines
6.4 KiB
TypeScript
Raw Normal View History

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
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`');
});
});