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
134 lines
6.4 KiB
TypeScript
134 lines
6.4 KiB
TypeScript
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`');
|
|
});
|
|
});
|