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
This commit is contained in:
@@ -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<ConfigCommandDeps>, 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 <path>', '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<ConfigCommandDeps>, apiDeps?:
|
||||
? '<skipped>'
|
||||
: join(dirname(outputPath), '.mcpctl-project'),
|
||||
skills: opts.skipSkills === true ? '<skipped>' : 'sync + SessionStart hook',
|
||||
statusLine: opts.skipUi === true ? '<skipped>' : `${claudeSettings} (${STATUSLINE_COMMAND})`,
|
||||
slashCommand: opts.skipUi === true ? '<skipped>' : 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<ConfigCommandDeps>, 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
|
||||
|
||||
103
src/cli/src/commands/statusline.ts
Normal file
103
src/cli/src/commands/statusline.ts
Normal file
@@ -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<StatusLineInput> {
|
||||
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<StatuslineDeps>): 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 <path>', 'Directory to resolve the project for (default: from stdin, then cwd)')
|
||||
.option('--prefix <text>', '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);
|
||||
});
|
||||
}
|
||||
@@ -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 <name>', 'Target project for project commands');
|
||||
|
||||
program.addCommand(createStatusCommand());
|
||||
program.addCommand(createStatuslineCommand());
|
||||
program.addCommand(createLoginCommand());
|
||||
program.addCommand(createLogoutCommand());
|
||||
|
||||
|
||||
201
src/cli/src/utils/claude-ui.ts
Normal file
201
src/cli/src/utils/claude-ui.ts
Normal file
@@ -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<Settings> {
|
||||
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<void> {
|
||||
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<StatusLineOutcome> {
|
||||
const settings = await readSettings(settingsPath);
|
||||
const current = settings.statusLine;
|
||||
|
||||
if (current !== undefined && current !== null) {
|
||||
if (!isOurStatusLine(current)) return { status: 'foreign', command: String(current.command ?? '<unknown>') };
|
||||
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<boolean> {
|
||||
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 <name> --skip-marker\`
|
||||
|
||||
4. Report the switch as "now on <project>". 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<string> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, MCPCTL_SLASH_COMMAND, 'utf-8');
|
||||
return path;
|
||||
}
|
||||
@@ -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<Settings> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
133
src/cli/tests/utils/claude-ui.test.ts
Normal file
133
src/cli/tests/utils/claude-ui.test.ts
Normal file
@@ -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`');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user