From b3a062ce28ebe2baf4860697725f1ad255f4b920 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 19:06:06 +0100 Subject: [PATCH] feat(claude): active-project status line + /mcpctl switcher, and stop tests writing to ~/.claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP --- completions/mcpctl.bash | 9 +- completions/mcpctl.fish | 11 +- src/cli/src/commands/config.ts | 43 +++++- src/cli/src/commands/statusline.ts | 103 +++++++++++++ src/cli/src/index.ts | 2 + src/cli/src/utils/claude-ui.ts | 201 ++++++++++++++++++++++++++ src/cli/src/utils/sessionhook.ts | 7 +- src/cli/tests/commands/claude.test.ts | 15 ++ src/cli/tests/utils/claude-ui.test.ts | 133 +++++++++++++++++ stack/claude-vllm | 12 +- 10 files changed, 525 insertions(+), 11 deletions(-) create mode 100644 src/cli/src/commands/statusline.ts create mode 100644 src/cli/src/utils/claude-ui.ts create mode 100644 src/cli/tests/utils/claude-ui.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 6e00b52..d83ba09 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" + local commands="status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" @@ -94,6 +94,9 @@ _mcpctl() { status) COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur")) return ;; + statusline) + COMPREPLY=($(compgen -W "-d --directory --prefix -h --help" -- "$cur")) + return ;; login) COMPREPLY=($(compgen -W "--mcpd-url -h --help" -- "$cur")) return ;; @@ -119,10 +122,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --dry-run -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 38665a4..0144057 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate +set -l commands status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server favourites # Disable file completions by default @@ -221,6 +221,7 @@ end # Top-level commands (without --project) complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show mcpctl status and connectivity' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a statusline -d 'Print the active mcpctl project (for Claude Code\'s statusLine setting)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out and remove stored credentials' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage mcpctl configuration' @@ -291,6 +292,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Incl complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Print what would change without writing or syncing' # config claude-generate options @@ -300,6 +303,8 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l dry-run -d 'Print what would change without writing or syncing' # config pi options @@ -599,6 +604,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from rotate; and not __fish_seen_s # status options complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x +# statusline options +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -s d -l directory -d 'Directory to resolve the project for (default: from stdin, then cwd)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -l prefix -d 'Text before the project name' -x + # login options complete -c mcpctl -n "__fish_seen_subcommand_from login" -l mcpd-url -d 'mcpd URL to authenticate against' -x diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 2f78a35..d37c541 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -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, 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 ', '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, apiDeps?: ? '' : join(dirname(outputPath), '.mcpctl-project'), skills: opts.skipSkills === true ? '' : 'sync + SessionStart hook', + statusLine: opts.skipUi === true ? '' : `${claudeSettings} (${STATUSLINE_COMMAND})`, + slashCommand: opts.skipUi === true ? '' : 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, 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 diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts new file mode 100644 index 0000000..a3d7c29 --- /dev/null +++ b/src/cli/src/commands/statusline.ts @@ -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 { + 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): 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 ', 'Directory to resolve the project for (default: from stdin, then cwd)') + .option('--prefix ', '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); + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 7227068..47c2070 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -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 ', 'Target project for project commands'); program.addCommand(createStatusCommand()); + program.addCommand(createStatuslineCommand()); program.addCommand(createLoginCommand()); program.addCommand(createLogoutCommand()); diff --git a/src/cli/src/utils/claude-ui.ts b/src/cli/src/utils/claude-ui.ts new file mode 100644 index 0000000..e0a05d9 --- /dev/null +++ b/src/cli/src/utils/claude-ui.ts @@ -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 { + 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 { + 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 { + const settings = await readSettings(settingsPath); + const current = settings.statusLine; + + if (current !== undefined && current !== null) { + if (!isOurStatusLine(current)) return { status: 'foreign', command: String(current.command ?? '') }; + 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 { + 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 --skip-marker\` + +4. Report the switch as "now on ". 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 { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, MCPCTL_SLASH_COMMAND, 'utf-8'); + return path; +} diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts index 49a9479..f0f6cf4 100644 --- a/src/cli/src/utils/sessionhook.ts +++ b/src/cli/src/utils/sessionhook.ts @@ -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 { diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 4ebc6fd..9a079f7 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -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 () => { diff --git a/src/cli/tests/utils/claude-ui.test.ts b/src/cli/tests/utils/claude-ui.test.ts new file mode 100644 index 0000000..9f56e10 --- /dev/null +++ b/src/cli/tests/utils/claude-ui.test.ts @@ -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`'); + }); +}); diff --git a/stack/claude-vllm b/stack/claude-vllm index 17942f8..d7119c0 100755 --- a/stack/claude-vllm +++ b/stack/claude-vllm @@ -139,10 +139,13 @@ fi BASE="${BASE%/}"; BASE="${BASE%/v1}" export ANTHROPIC_BASE_URL="$BASE" -# Both forms: the gateway accepts either, and which one Claude Code sends has -# changed between releases (Authorization: Bearer vs x-api-key). +# ANTHROPIC_AUTH_TOKEN only. Setting ANTHROPIC_API_KEY as well makes Claude Code +# warn that "auth may not work as expected" — it wants exactly one. AUTH_TOKEN +# is the right one for a third-party gateway (sent as `Authorization: Bearer`), +# and an inherited ANTHROPIC_API_KEY would otherwise take precedence over it, so +# clear it for the child process. export ANTHROPIC_AUTH_TOKEN="$KEY" -export ANTHROPIC_API_KEY="$KEY" +unset ANTHROPIC_API_KEY [ -n "$MODEL_ID" ] && export ANTHROPIC_MODEL="$MODEL_ID" # Without a substitute, the background/summarisation calls ask the gateway for a # real Haiku it does not serve, and every one of them 404s. @@ -169,7 +172,8 @@ if [ "$PRINT_ENV" = 1 ]; then printf 'export %s=%q\n' "$v" "${!v}" done # Never printed: the credential. Use --list to confirm one was found. - echo 'export ANTHROPIC_AUTH_TOKEN= # and ANTHROPIC_API_KEY' + echo 'export ANTHROPIC_AUTH_TOKEN=' + echo 'unset ANTHROPIC_API_KEY' exit 0 fi