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