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