Merge remote-tracking branch 'origin/main' into feat/web-search-templates

# Conflicts:
#	completions/mcpctl.bash
#	completions/mcpctl.fish
#	src/cli/src/commands/create.ts
#	src/db/src/seed/index.ts
This commit is contained in:
Michal
2026-08-12 23:11:54 +01:00
83 changed files with 7354 additions and 318 deletions

View File

@@ -5,7 +5,8 @@ import { z } from 'zod';
import type { ApiClient } from '../api-client.js';
const HealthCheckSchema = z.object({
tool: z.string().min(1),
/** Omit for a liveness-only probe (reports `live`); set it for readiness (`healthy`). */
tool: z.string().min(1).optional(),
arguments: z.record(z.unknown()).default({}),
intervalSeconds: z.number().int().min(5).max(3600).default(60),
timeoutSeconds: z.number().int().min(1).max(120).default(10),

View File

@@ -70,7 +70,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command {
}
/** What the chat is bound to: a named Agent or a Project. */
interface ChatSubject {
export interface ChatSubject {
kind: 'agent' | 'project';
name: string;
/** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */
@@ -97,14 +97,17 @@ function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject
* `personality` overlay (the project schema rejects unknown fields) and adds
* `allowSecrets` when requested.
*/
function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
export function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record<string, unknown> {
const o: Record<string, unknown> = { ...overrides };
if (subject.kind === 'project') {
delete o.personality;
if (subject.allowSecrets) o.allowSecrets = true;
}
const body: Record<string, unknown> = { message, ...o };
if (threadId !== undefined) body.threadId = threadId;
// Guard the empty string, not just undefined: a turn that dies before its
// `final` frame yields no thread id, and sending `threadId: ""` trips mcpd's
// min(1) validation — bricking every later message in the REPL with a 400.
if (threadId !== undefined && threadId !== '') body.threadId = threadId;
if (stream === true) body.stream = true;
return body;
}
@@ -205,7 +208,11 @@ async function runOneShot(
const bar = installStatusBar();
try {
const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar);
process.stderr.write(`\n(thread: ${finalThread})\n`);
if (finalThread !== undefined) {
process.stderr.write(`\n(thread: ${finalThread})\n`);
} else {
process.stderr.write('\n');
}
} finally {
bar?.teardown();
}
@@ -262,7 +269,9 @@ async function runRepl(
const answered = formatAnswered(res.llm, res.model, res.failedOver);
if (answered !== '') process.stderr.write(`${styleStats(`(${answered})`)}\n`);
} else {
threadId = await streamOnce(deps, subject, line, threadId, overrides, bar);
// A failed turn resolves undefined — keep the previous thread (or
// none) instead of overwriting it, so the next message still works.
threadId = await streamOnce(deps, subject, line, threadId, overrides, bar) ?? threadId;
process.stdout.write('\n');
}
} catch (err) {
@@ -502,15 +511,21 @@ async function chatRequestNonStream(
});
}
/** Stream a single chat call. Returns the resolved threadId. */
async function streamOnce(
/**
* Stream a single chat call. Returns the resolved threadId, or undefined when
* the turn never produced a `final` frame (upstream error, early disconnect).
* Returning undefined — instead of the old '' — lets callers keep their
* previous thread state rather than poisoning the next request with an empty
* id that mcpd's validation rejects.
*/
export async function streamOnce(
deps: ChatCommandDeps,
subject: ChatSubject,
message: string,
threadId: string | undefined,
overrides: Overrides,
bar: StatusBar | null = null,
): Promise<string> {
): Promise<string | undefined> {
const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`);
const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true));
@@ -531,7 +546,7 @@ async function streamOnce(
}
}
return new Promise<string>((resolve, reject) => {
return new Promise<string | undefined>((resolve, reject) => {
const driver = url.protocol === 'https:' ? https : http;
const req = driver.request({
hostname: url.hostname,
@@ -552,7 +567,7 @@ async function streamOnce(
return;
}
let buf = '';
let resolvedThread = threadId ?? '';
let resolvedThread: string | undefined = threadId;
let answered = '';
res.setEncoding('utf-8');
res.on('data', (chunk: string) => {

View File

@@ -1,5 +1,5 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { homedir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
@@ -34,6 +34,34 @@ 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,
mergeUserScopeServer,
userScopeProject,
activeProjectIn,
canonicalProjectIn,
legacyEntriesIn,
claudeJsonPath,
type McpJson,
type ClaudeJson,
} from '../config/claude-mcp.js';
import {
opencodeConfigDir,
opencodeStatePath,
withOpencodeDir,
installOpencodePlugins,
registerOpencodeTuiPlugin,
readOpencodeState,
writeOpencodeState,
storedToken,
} from '../utils/opencode-settings.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
/**
@@ -44,8 +72,76 @@ import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
*/
const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent';
interface McpConfig {
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
/** Same, for the tokens `config opencode` mints. */
const OPENCODE_TOKEN_PREFIX = 'opencode';
/**
* Read an existing `.mcp.json`. A missing or unparseable file yields null, and
* the caller starts fresh — the same behaviour as before, kept because a
* half-written file must not stop you re-provisioning.
*/
function readJsonFile<T>(path: string): T | null {
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as T;
} catch {
return null;
}
}
/** Write JSON through a temp file + rename, so a crash cannot truncate it. */
function writeJsonAtomicSync(path: string, value: unknown): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp.${String(process.pid)}`;
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
renameSync(tmp, path);
}
function readMcpJson(path: string): McpJson | null {
if (!existsSync(path)) return null;
try {
return JSON.parse(readFileSync(path, 'utf-8')) as McpJson;
} catch {
return null;
}
}
/**
* Warnings about a `.mcp.json` in `dir` that contradicts a user-scope switch to
* `project`.
*
* Claude Code merges the two scopes rather than picking one, so a
* directory-scoped entry does not go away when you switch globally:
* - a canonical `mcpctl` entry shares the name, and project scope wins — the
* switch has no effect in this directory at all;
* - a legacy project-named entry has a *different* name, so it is simply
* mounted alongside and the old project keeps answering here.
* Either way the user is owed the file path, because nothing else will tell
* them. Exported for tests.
*/
export function shadowWarnings(dir: string, project: string | undefined): string[] {
if (project === undefined || project === '') return [];
const path = join(dir, '.mcp.json');
const parsed = readMcpJson(path);
if (parsed === null) return [];
const pinned = canonicalProjectIn(parsed);
if (pinned !== null && pinned !== project) {
return [
`Warning: ${path} pins '${MCPCTL_SERVER_NAME}' to '${pinned}' for this directory, which overrides the switch here.`,
` Re-run with --scope project to repoint it, or delete the '${MCPCTL_SERVER_NAME}' entry to follow the user-scope project.`,
];
}
const stale = legacyEntriesIn(parsed).filter((e) => e.project !== project);
if (stale.length > 0) {
const names = stale.map((e) => `'${e.server}'`).join(', ');
return [
`Warning: ${path} still registers ${names} for this directory — mounted alongside '${project}', not replaced by it.`,
` Re-run with --scope project to retire ${stale.length === 1 ? 'it' : 'them'}, or delete the ${stale.length === 1 ? 'entry' : 'entries'} by hand.`,
];
}
return [];
}
export interface ConfigCommandDeps {
@@ -53,6 +149,8 @@ export interface ConfigCommandDeps {
log: (...args: string[]) => void;
/** API client for the skills sync side-effect of `config claude --project`. Optional so existing call sites work; without it we skip the sync step. */
apiClient?: ApiClient;
/** Working directory to check for a shadowing `.mcp.json`. Injectable so tests need not chdir. */
cwd?: () => string;
}
export interface ConfigApiDeps {
@@ -68,6 +166,7 @@ const defaultDeps: ConfigCommandDeps = {
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?: ConfigApiDeps): Command {
const { configDeps, log } = { ...defaultDeps, ...deps };
const cwd = deps?.cwd ?? ((): string => process.cwd());
// PR-5: api client used by `mcpctl config claude --project` to run the
// initial skills sync after wiring the .mcp.json. Threaded through from
// index.ts; falls back to apiDeps.client when not explicitly passed (the
@@ -76,6 +175,118 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
const config = new Command('config').description('Manage mcpctl configuration');
// ── shared credential plumbing ─────────────────────────────────────────────
// `config prime-agent` and `config opencode` both provision an mcptoken for
// the project they mount and retire the one they replace. The rules are
// identical; only the token *name* differs, so the agent label is a
// parameter rather than a second copy of this logic.
interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string }
/** The project's tokens, or null when the API can't be consulted. */
async function listProjectTokens(project: string): Promise<ProjectToken[] | null> {
if (!skillsClient) return null;
try {
const list = await skillsClient.get<unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`);
return Array.isArray(list) ? list as ProjectToken[] : null;
} catch {
return null;
}
}
/**
* Is the credential we already have for this project still usable?
*
* A key being *present* proves nothing — a revoked or expired token would
* short-circuit provisioning and leave the agent silently unable to reach
* the gateway while the command reported success. mcptokens are only ever
* shown once, so we compare the stored token's 16-char `tokenPrefix`
* against the project's *active* tokens instead of sending the secret.
*
* Fails open: no client, a non-mcpctl token (a user-supplied PAT of some
* other kind), or an unreachable API all mean "keep what's there" rather
* than minting a duplicate on every run.
*/
async function hasUsableCredential(project: string, key: string | null): Promise<boolean> {
if (key === null) return false;
if (!skillsClient || !isMcpctlToken(key)) return true;
const tokens = await listProjectTokens(project);
if (tokens === null) return true; // can't check → don't churn credentials
const prefix = mcpTokenPrefixOf(key);
const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix);
if (!live) {
log(`Stored credential for '${project}' is no longer active — minting a replacement`);
}
return live;
}
/**
* Retire the token this install used to hold, now that `keepToken` has
* replaced it on disk.
*
* Scoped to that one credential on purpose. Sweeping every `<agent>` token
* for the project would revoke the one a *different* install is using —
* another machine, or this machine when the run targeted a custom output
* path. Anything else that looks orphaned is reported, not deleted: an
* unnecessary token costs nothing, a revoked one costs a broken install.
* Best-effort throughout, and only ever called once the replacement is
* safely stored.
*/
async function retireSupersededToken(
agent: string,
project: string,
staleKey: string | null,
keepToken: string,
): Promise<void> {
if (!skillsClient) return;
const keepPrefix = mcpTokenPrefixOf(keepToken);
const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null;
if (stalePrefix === keepPrefix) return;
const tokens = await listProjectTokens(project);
if (tokens === null) return;
const orphans: string[] = [];
for (const t of tokens) {
if (typeof t.id !== 'string' || t.status !== 'active') continue;
if (t.tokenPrefix === keepPrefix) continue;
// Only ever consider tokens minted for this purpose.
const name = t.name ?? '';
if (name !== agent && !name.startsWith(`${agent}-`)) continue;
if (t.tokenPrefix === stalePrefix) {
try {
await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`);
log(`Revoked the superseded '${name}' token for '${project}'`);
} catch { /* best-effort */ }
} else {
orphans.push(name);
}
}
if (orphans.length > 0) {
log(`Note: '${project}' still has other ${agent} token(s): ${orphans.join(', ')}. `
+ `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken <name> --project ${project}\`.`);
}
}
/**
* Mint a fresh mcptoken for `project`.
*
* A unique `<agent>-<stamp>` name every time: `McpToken` is unique on
* (name, projectId) and revoke is a soft delete, so a fixed name could only
* ever be minted once per project.
*/
async function mintProjectToken(agent: string, project: string): Promise<string | null> {
if (!skillsClient) return null;
const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', {
name: `${agent}-${stamp}`,
projectName: project,
ttl: 'never',
description: `mcpctl proxy MCP credential for ${agent} (${new Date().toISOString()})`,
});
return typeof minted?.token === 'string' && minted.token.length > 0 ? minted.token : null;
}
config
.command('view')
.description('Show current configuration')
@@ -132,67 +343,136 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.command(name)
.description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook')
.option('-p, --project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--scope <scope>', 'Where to register the MCP server: user (every directory) or project (this .mcp.json)', 'user')
.option('-o, --output <path>', 'Project-scope output file path (implies --scope project)', '.mcp.json')
.option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring')
.option('--stdout', 'Print to stdout instead of writing a file')
.option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)')
.action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean }) => {
.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; scope: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }, command: Command) => {
// 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;
return;
}
const servers: McpConfig['mcpServers'] = {};
if (opts.project) {
servers[opts.project] = {
command: 'mcpctl',
args: ['mcp', '-p', opts.project],
};
// An explicit --output only makes sense for the per-directory file, so
// it selects project scope on its own — no need to pass both.
// Commander's source tracking, not process.argv: the latter is the test
// runner's command line when the command is driven in-process.
const explicitOutput = command.getOptionValueSource('output') === 'cli';
const scope = explicitOutput ? 'project' : opts.scope;
if (scope !== 'user' && scope !== 'project') {
log(`Error: unknown --scope '${scope}' (expected 'user' or 'project')`);
process.exitCode = 1;
return;
}
if (opts.inspect) {
servers['mcpctl-inspect'] = {
command: 'mcpctl',
args: ['console', '--stdin-mcp'],
};
const userScope = scope === 'user';
const outputPath = userScope ? claudeJsonPath() : resolve(opts.output);
const existing = userScope
? (readJsonFile<ClaudeJson>(outputPath) ?? {})
: readMcpJson(outputPath);
// `--inspect` is a project-scope idea (a debugging server you turn on
// for one checkout), so it stays on .mcp.json even in user scope.
let finalConfig: McpJson | ClaudeJson;
let retired: string[];
if (userScope) {
if (opts.project === undefined || opts.project === '') {
log('Error: --project is required for user scope (--scope project for an --inspect-only .mcp.json)');
process.exitCode = 1;
return;
}
({ config: finalConfig, retired } = mergeUserScopeServer(existing as ClaudeJson, opts.project));
} else {
({ config: finalConfig, retired } = mergeMcpctlServers(existing as McpJson, {
...(opts.project !== undefined ? { project: opts.project } : {}),
...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}),
}));
}
if (opts.stdout) {
log(JSON.stringify({ mcpServers: servers }, null, 2));
if (opts.stdout === true) {
log(JSON.stringify(finalConfig, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig: McpConfig = { mcpServers: servers };
// Always merge with existing .mcp.json — never overwrite other servers
if (existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...servers,
},
};
} catch {
// If existing file is invalid, start fresh
}
if (opts.dryRun === true) {
log(JSON.stringify({
claude: {
scope,
output: outputPath,
previousProject: userScope
? userScopeProject(existing as ClaudeJson)
: activeProjectIn(existing as McpJson),
server: MCPCTL_SERVER_NAME,
entry: finalConfig.mcpServers?.[MCPCTL_SERVER_NAME] ?? '<unchanged>',
retiredLegacyEntries: retired,
marker: opts.skipMarker === true || opts.project === undefined
? '<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));
return;
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
log(`Wrote ${outputPath} (${serverCount} server(s))`);
// Atomic: `.claude.json` also holds Claude Code's onboarding state and
// per-project map, and Claude Code rewrites it while running — a
// truncated write there costs far more than a stale MCP entry.
writeJsonAtomicSync(outputPath, finalConfig);
const serverCount = Object.keys(finalConfig.mcpServers ?? {}).length;
log(userScope
? `Registered '${MCPCTL_SERVER_NAME}' for every directory in ${outputPath}`
: `Wrote ${outputPath} (${String(serverCount)} server(s))`);
if (retired.length > 0) {
// Before the constant name, every project you configured stayed
// mounted alongside the new one.
log(`Retired legacy per-project entr${retired.length === 1 ? 'y' : 'ies'}: ${retired.join(', ')}`);
}
if (opts.project !== undefined) {
log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`);
}
if (userScope) {
// The whole point of user scope: you do this once, not per checkout.
log('This applies in every directory — no need to re-run it per repo.');
// ...except where a directory-scoped entry contradicts it. That file
// is never rewritten by a user-scope switch, so staying silent is how
// a switch ends up looking like it did nothing: the status line keeps
// naming the old project, and its server keeps answering here.
for (const line of shadowWarnings(cwd(), opts.project)) log(line);
}
// PR-5: write project marker, run initial skills sync, install
// SessionStart hook. Skipped when --inspect-only or --skip-skills.
if (opts.project && !opts.skipSkills) {
const projectDir = dirname(outputPath);
try {
const markerPath = await writeProjectMarker(projectDir, opts.project);
log(`Wrote ${markerPath}`);
} catch (err: unknown) {
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
if (userScope) {
// User scope deliberately scopes nothing to a directory; writing a
// marker into $HOME would silently scope every repo under it.
log('Skipped .mcpctl-project marker (user scope is not directory-specific)');
} else if (opts.skipMarker === true) {
log('Skipped .mcpctl-project marker (--skip-marker)');
} else {
try {
const markerPath = await writeProjectMarker(projectDir, opts.project);
log(`Wrote ${markerPath}`);
} catch (err: unknown) {
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
}
}
if (skillsClient) {
@@ -211,12 +491,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
@@ -299,7 +601,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
// Only when we actually replaced something: `--token` with a fresh
// auth.json must stay entirely offline, as documented.
if (staleKey !== null) {
await retireSupersededToken(opts.project, staleKey, opts.token);
await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, opts.token);
}
} else if (await hasUsableCredential(opts.project, staleKey)) {
log(`Bearer credential for '${opts.project}' already present in ${authPath}`);
@@ -309,18 +611,12 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
// (name, projectId) and revoke is a soft delete, so reusing a fixed
// name would collide with the revoked row forever. Retire the old
// tokens only *after* the replacement is safely on disk.
const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', {
name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`,
projectName: opts.project,
ttl: 'never',
description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`,
});
if (typeof minted?.token === 'string' && minted.token.length > 0) {
await writePrimeAgentAuth(opts.project, minted.token, authPath);
const minted = await mintProjectToken(PRIME_AGENT_TOKEN_PREFIX, opts.project);
if (minted !== null) {
await writePrimeAgentAuth(opts.project, minted, authPath);
log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
provisioned = true;
await retireSupersededToken(opts.project, staleKey, minted.token);
await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, minted);
} else {
log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`);
}
@@ -417,86 +713,6 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
void cmd;
}
/**
* Is the credential already in auth.json still usable?
*
* A key being *present* proves nothing — a revoked or expired token would
* short-circuit provisioning and leave prime-agent silently unable to reach
* the gateway while the command reported success. mcptokens are only ever
* shown once, so we compare the stored token's 16-char `tokenPrefix`
* against the project's *active* tokens instead of sending the secret.
*
* Fails open: no client, a non-mcpctl token (a user-supplied PAT of some
* other kind), or an unreachable API all mean "keep what's there" rather
* than minting a duplicate on every run.
*/
async function hasUsableCredential(project: string, key: string | null): Promise<boolean> {
if (key === null) return false;
if (!skillsClient || !isMcpctlToken(key)) return true;
const tokens = await listProjectTokens(project);
if (tokens === null) return true; // can't check → don't churn credentials
const prefix = mcpTokenPrefixOf(key);
const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix);
if (!live) {
log(`Stored credential for '${project}' is no longer active — minting a replacement`);
}
return live;
}
/**
* Retire the token this auth.json used to hold, now that `keepToken` has
* replaced it on disk.
*
* Scoped to that one credential on purpose. Sweeping every `prime-agent`
* token for the project would revoke the one a *different* auth.json is
* using — another machine, or this machine when the run targeted a custom
* `--output`. Anything else that looks orphaned is reported, not deleted:
* an unnecessary token costs nothing, a revoked one costs a broken install.
* Best-effort throughout, and only ever called once the replacement is
* safely stored.
*/
async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise<void> {
if (!skillsClient) return;
const keepPrefix = mcpTokenPrefixOf(keepToken);
const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null;
if (stalePrefix === keepPrefix) return;
const tokens = await listProjectTokens(project);
if (tokens === null) return;
const orphans: string[] = [];
for (const t of tokens) {
if (typeof t.id !== 'string' || t.status !== 'active') continue;
if (t.tokenPrefix === keepPrefix) continue;
// Only ever consider tokens minted for this purpose.
const name = t.name ?? '';
if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue;
if (t.tokenPrefix === stalePrefix) {
try {
await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`);
log(`Revoked the superseded '${name}' token for '${project}'`);
} catch { /* best-effort */ }
} else {
orphans.push(name);
}
}
if (orphans.length > 0) {
log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. `
+ `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken <name> --project ${project}\`.`);
}
}
interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string }
/** The project's tokens, or null when the API can't be consulted. */
async function listProjectTokens(project: string): Promise<ProjectToken[] | null> {
if (!skillsClient) return null;
try {
const list = await skillsClient.get<unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`);
return Array.isArray(list) ? list as ProjectToken[] : null;
} catch {
return null;
}
}
}
registerClaudeCommand('claude', false);
@@ -511,7 +727,8 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
.option('--skip-skills', 'Skip the initial skills sync')
.option('--settings <path>', 'pi settings.json path (default: ~/.pi/agent/settings.json)')
.option('--pi-dir <path>', 'Override the pi agent home (default: ~/.pi/agent)')
.action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string }) => {
.option('--dry-run', 'Print what would change without writing or syncing')
.action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string; dryRun?: boolean }) => {
if (!opts.project) {
log('Error: --project is required for mcpctl config pi');
process.exitCode = 1;
@@ -527,6 +744,20 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
// custom --pi-dir is used (also keeps tests off the real ~/.mcpctl).
const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath();
if (opts.dryRun === true) {
log(JSON.stringify({
pi: {
settingsPath,
extensionDir: extDest,
statePath,
skillsDir: opts.skipSkills === true ? '<skipped>' : skillsInstall,
source: opts.extensionDir ?? '<embedded>',
},
action: 'install extension files + register in settings.json (extensions + skills) + write active project + sync skills',
}, null, 2));
return;
}
// 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/.
// Default: write the embedded sources (works from an installed binary
// with no source tree). --extension-dir overrides with a source-tree
@@ -590,6 +821,190 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
registerPrimeAgentCommand('prime-agent', false);
registerPrimeAgentCommand('prime-agent-generate', true); // backward compat
// ── opencode: install the plugins + provision the gateway credential ──
//
// Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into
// the host's own config: opencode gets a server plugin that mounts the gateway
// through the running server's MCP API, so the bearer token stays in a 0600
// state file and switching projects needs no restart. See
// `utils/opencode-settings.ts` for the reasoning.
config
.command('opencode')
.description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills')
.option('-p, --project <name>', 'Project name to make active')
.option('--gateway-url <url>', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL)
.option('--token <pat>', 'mcpctl project bearer token to use (skips auto-minting)')
.option('--opencode-dir <path>', 'Override opencode\'s config dir (default: ~/.config/opencode)')
.option('--skip-skills', 'Skip the skills sync step')
.option('--skip-plugin', 'Do not (re)install or register the opencode plugins')
.option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory')
.option('--dry-run', 'Print what would change without writing or syncing')
.action(async (opts: {
project?: string;
gatewayUrl: string;
token?: string;
opencodeDir?: string;
skipSkills?: boolean;
skipPlugin?: boolean;
skipMarker?: boolean;
dryRun?: boolean;
}) => {
if (opts.project === undefined || opts.project === '') {
log('Error: --project is required for mcpctl config opencode');
process.exitCode = 1;
return;
}
const project = opts.project;
const configDir = opts.opencodeDir !== undefined ? resolve(opts.opencodeDir) : opencodeConfigDir();
const paths = withOpencodeDir(configDir);
const gatewayBase = opts.gatewayUrl.replace(/\/+$/, '');
// Isolate the state file under a custom --opencode-dir so tests (and
// side-by-side installs) never write the real ~/.mcpctl/opencode-state.json.
const statePath = opts.opencodeDir !== undefined
? join(configDir, 'mcpctl-state.json')
: opencodeStatePath();
if (opts.dryRun === true) {
log(JSON.stringify({
opencode: {
serverPlugin: opts.skipPlugin === true ? '<skipped>' : paths.serverPluginPath(),
tuiPlugin: opts.skipPlugin === true ? '<skipped>' : paths.tuiPluginPath(),
tuiJson: opts.skipPlugin === true ? '<skipped>' : paths.tuiJsonPath(),
statePath,
mcpUrl: `${gatewayBase}/projects/${encodeURIComponent(project)}/mcp`,
skillsDir: opts.skipSkills === true ? '<skipped>' : paths.skillsDir(),
marker: opts.skipMarker === true ? '<skipped>' : join(process.cwd(), '.mcpctl-project'),
},
action: 'install plugins + register in tui.json + write 0600 state (project, gateway, bearer token) + sync skills',
}, null, 2));
return;
}
// 1. Provision the bearer credential the gateway needs.
//
// This runs FIRST and is fatal on failure: the state file is what the
// plugins mount from, so writing a project we have no usable token for
// would swap a working mount for a 401. Keeping the old state means the
// previously active project keeps working, and the `/mcpctl` switcher
// (which reads this command's exit code) reports the switch as failed
// instead of leaving the user staring at a project with no tools.
const priorState = await readOpencodeState(statePath);
const staleKey = storedToken(priorState, project);
let token: string | null = null;
try {
if (opts.token !== undefined && opts.token !== '') {
token = opts.token;
} else if (await hasUsableCredential(project, staleKey)) {
token = staleKey;
log(`Bearer credential for '${project}' already present in ${statePath}`);
} else {
token = await mintProjectToken(OPENCODE_TOKEN_PREFIX, project);
if (token === null) {
log(skillsClient
? `Error: no token returned minting for '${project}'; pass --token to supply one`
: 'Error: no API client available to mint a project token — pass --token <pat>');
}
}
} catch (err: unknown) {
log(`Error: could not provision bearer credential for '${project}': ${err instanceof Error ? err.message : String(err)}`);
}
if (token === null || token === '') {
process.exitCode = 1;
log(`Aborted: leaving ${statePath} unchanged so the currently active project keeps working`);
return;
}
// 2. Write the state file (0600) — the single source of truth both
// plugins read for "which project, which gateway, which token".
try {
await writeOpencodeState({ project, gatewayUrl: gatewayBase, token }, statePath);
log(`Active project '${project}' → ${statePath} (${gatewayBase}/projects/${encodeURIComponent(project)}/mcp)`);
} catch (err: unknown) {
log(`Error: could not write ${statePath}: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
return;
}
// Only once the replacement is safely on disk.
if (token !== staleKey) {
await retireSupersededToken(OPENCODE_TOKEN_PREFIX, project, staleKey, token);
}
// 3. Install + register the plugins (skippable: the `/mcpctl` switcher
// re-runs this command in-process and must not rewrite the very file
// opencode has already loaded).
if (opts.skipPlugin !== true) {
try {
const written = await installOpencodePlugins(configDir);
log('Installed opencode plugins:');
for (const w of written) log(` ${w}`);
} catch (err: unknown) {
log(`Error: could not install opencode plugins: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
return;
}
try {
const { added } = await registerOpencodeTuiPlugin(paths.tuiJsonPath(), paths.tuiPluginPath());
log(added
? `Registered the TUI plugin in ${paths.tuiJsonPath()}`
: `TUI plugin already registered in ${paths.tuiJsonPath()}`);
} catch (err: unknown) {
// Non-fatal: without tui.json there is no /mcpctl command or footer
// indicator, but the server plugin still mounts the project's tools.
log(`Warning: could not update ${paths.tuiJsonPath()}: ${err instanceof Error ? err.message : String(err)}`);
}
}
// 4. Write the .mcpctl-project marker (same semantics as prime-agent:
// an explicit -p is authoritative, $HOME is never scoped, and the
// switcher opts out so it cannot re-scope an unrelated repo).
try {
if (opts.skipMarker === true) {
log('Skipped .mcpctl-project marker (--skip-marker)');
} else if (process.cwd() !== homedir()) {
const existing = await findProjectMarker(process.cwd(), homedir());
if (existing !== null && existing.project === project) {
log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`);
} else {
const markerPath = await writeProjectMarker(process.cwd(), project);
log(existing !== null
? `Updated project marker ${markerPath} ('${existing.project}' → '${project}')`
: `Wrote ${markerPath}`);
}
} else {
log('Skipped .mcpctl-project marker (running from $HOME)');
}
} catch (err: unknown) {
log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`);
}
// 5. Sync skills into opencode's skill dir (skippable). Best-effort: the
// mount is what determines whether the switch succeeded.
if (opts.skipSkills !== true) {
if (skillsClient) {
try {
const result = await runSkillsSync(
{ project, target: 'opencode', installRoot: paths.skillsDir() },
{ client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters<typeof console.error>)) },
);
const total = result.installed.length + result.updated.length + result.removed.length;
log(total > 0
? `Skills synced to ${paths.skillsDir()} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`
: 'Skills: no changes (already up to date)');
} catch (err: unknown) {
log(`Warning: skills sync failed: ${err instanceof Error ? err.message : String(err)}`);
}
} else {
log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent opencode` separately)');
}
}
if (opts.skipPlugin !== true) {
log('');
log('Next: restart opencode (or start a new session). Use /mcpctl to switch projects');
log('without restarting; the active project shows in the prompt footer.');
}
});
config.addCommand(createConfigSetupCommand({ configDeps }));

View File

@@ -42,6 +42,69 @@ export function buildFavouriteIndex(
return result;
}
export interface HealthCheckOpts {
healthCheckTool?: string;
healthCheckArgs?: string;
healthCheckInterval?: string;
healthCheckTimeout?: string;
healthCheckFailureThreshold?: string;
}
export interface HealthCheckSpec {
tool?: string;
arguments?: Record<string, unknown>;
intervalSeconds?: number;
timeoutSeconds?: number;
failureThreshold?: number;
}
function parsePositiveInt(flag: string, value: string): number {
const n = Number(value);
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`Invalid ${flag} '${value}'. Expected a positive integer.`);
}
return n;
}
/**
* Build a server `healthCheck` spec from `--health-check-*` flags, or undefined
* when none were given (so the field is only sent when intended).
*
* Mirrors the `healthCheck:` block accepted by `apply -f`, per the rule that
* everything applyable is also a create flag.
*/
export function buildHealthCheck(opts: HealthCheckOpts): HealthCheckSpec | undefined {
const { healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold } = opts;
const given = [healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold]
.some((v) => v !== undefined);
if (!given) return undefined;
if (healthCheckArgs !== undefined && healthCheckTool === undefined) {
throw new Error('--health-check-args requires --health-check-tool.');
}
const spec: HealthCheckSpec = {};
if (healthCheckTool !== undefined) spec.tool = healthCheckTool;
if (healthCheckArgs !== undefined) {
let parsed: unknown;
try {
parsed = JSON.parse(healthCheckArgs);
} catch {
throw new Error(`Invalid --health-check-args: not valid JSON. Expected a JSON object, e.g. '{"site":"default"}'.`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Invalid --health-check-args: expected a JSON object, e.g. '{"site":"default"}'.`);
}
spec.arguments = parsed as Record<string, unknown>;
}
if (healthCheckInterval !== undefined) spec.intervalSeconds = parsePositiveInt('--health-check-interval', healthCheckInterval);
if (healthCheckTimeout !== undefined) spec.timeoutSeconds = parsePositiveInt('--health-check-timeout', healthCheckTimeout);
if (healthCheckFailureThreshold !== undefined) {
spec.failureThreshold = parsePositiveInt('--health-check-failure-threshold', healthCheckFailureThreshold);
}
return spec;
}
/**
* Parse a `--ttl` value.
*
@@ -184,6 +247,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--replicas <count>', 'Number of replicas')
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
.option('--volume <spec>', 'Persistent volume: NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] (repeat for multiple)', collect, [])
.option('--health-check-tool <tool>', 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")')
.option('--health-check-args <json>', 'Readiness probe: JSON object of arguments for the probe tool')
.option('--health-check-interval <seconds>', 'Readiness probe interval in seconds (default 60)')
.option('--health-check-timeout <seconds>', 'Readiness probe timeout in seconds (default 10)')
.option('--health-check-failure-threshold <count>', 'Consecutive failures before the instance is marked unhealthy (default 3)')
.option('--from-template <name>', 'Create from template (name or name:version)')
.option('--env-from-secret <secret>', 'Map template env vars from a secret')
.option('--force', 'Update if already exists')
@@ -266,6 +334,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
// Merge over any healthCheck inherited from --from-template so partial
// flags (e.g. only --health-check-interval) tune rather than replace it.
const healthCheck = buildHealthCheck(opts as HealthCheckOpts);
if (healthCheck) {
body.healthCheck = { ...(base.healthCheck as HealthCheckSpec | undefined), ...healthCheck };
}
if (opts.env.length > 0) {
// Merge: CLI env entries override template env entries by name
const cliEnv = parseServerEnv(opts.env);
@@ -959,7 +1033,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
const buf = await fs.readFile(full);
// Reject non-UTF8 — v1 is text-only.
const text = buf.toString('utf-8');
if (text.includes('')) {
if (text.includes('\0')) {
throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`);
}
files[rel] = text;

View File

@@ -106,6 +106,12 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
lines.push('Health:');
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
if (healthStatus === 'live') {
lines.push(` ${pad('Probe:', 16)}liveness (tools/list) — process is up, but nothing`);
lines.push(` ${pad('', 16)}calls the server's upstream. Configure a readiness`);
lines.push(` ${pad('', 16)}probe to reach 'healthy':`);
lines.push(` ${pad('', 16)} mcpctl edit server ${server?.name ?? ''} → healthCheck.tool`);
}
}
const metadata = instance.metadata as Record<string, unknown> | undefined;

View File

@@ -33,6 +33,21 @@ import {
parseMcpServerDeps,
} from '../utils/mcpservers-materialiser.js';
import { ApiError } from '../api-client.js';
import { opencodeSkillsDir } from '../utils/opencode-settings.js';
/**
* Every agent whose skill tree `mcpctl skills sync --agent` can write.
*
* `claude` is the only one with Claude-specific behaviour (SessionStart hooks,
* postInstall, mcpServers auto-attach); every other target is a shared flat
* tree with per-project ownership.
*/
export const SYNC_TARGETS = ['claude', 'prime-agent', 'pi', 'opencode'] as const;
export type SyncTarget = (typeof SYNC_TARGETS)[number];
export function isSyncTarget(value: string): value is SyncTarget {
return (SYNC_TARGETS as readonly string[]).includes(value);
}
/**
* `mcpctl skills sync` — materialise server-side skills onto disk under
@@ -103,8 +118,10 @@ export interface SyncOpts {
* configuring a second project never deletes the
* first project's skills, and pre-existing
* (untracked) skill dirs are preserved.
* 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in
* ~/.pi/agent/skills and opencode's `skill` dir.
*/
target?: 'claude' | 'prime-agent' | 'pi';
target?: SyncTarget;
}
export interface SyncResult {
@@ -192,32 +209,23 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
// 3. Load state. Defaults depend on the sync target: Claude Code gets
// ~/.claude/skills + the shared state file; prime-agent gets its own
// tree + separate state file so the two never collide.
const isPrimeAgent = target === 'prime-agent';
const isPi = target === 'pi';
// prime-agent and pi share the same skill semantics: their own flat skill
// tree + separate state file, no SessionStart hooks / postInstall, no
// prime-agent, pi and opencode share the same skill semantics: their own flat
// skill tree + separate state file, no SessionStart hooks / postInstall, no
// mcpServers auto-attach. Only claude gets Claude-specific behaviour.
const isSharedTree = isPrimeAgent || isPi;
const isSharedTree = target !== 'claude';
// Canonical ownership scope for a *skill*: null when the skill is global
// (globals are visible from every project, so pinning one to whichever
// project happened to sync it would lock every other project out of ever
// updating it), otherwise the project that installed it.
const ownerOf = (s: VisibleSkill): string | null => (s.scope === 'global' ? null : (projectName ?? null));
const statePath = opts.statePath ?? (isPrimeAgent
? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json')
: isPi
? join(homeDir, '.mcpctl', 'skills-state-pi.json')
: defaultStatePath());
const statePath = opts.statePath
?? (target === 'claude' ? defaultStatePath() : join(homeDir, '.mcpctl', `skills-state-${target}.json`));
const state = await loadState(statePath);
// Which project last wrote this state file, captured before step 7 overwrites
// it. Skills tracked by a CLI that predates ownership recording carry no
// `project` field; this is the only evidence of who installed them.
const priorSyncProject = state.lastSyncProject;
const installRoot = opts.installRoot ?? (isPrimeAgent
? join(homeDir, '.prime', 'agent', 'skills')
: isPi
? join(homeDir, '.pi', 'agent', 'skills')
: join(homeDir, '.claude', 'skills'));
const installRoot = opts.installRoot ?? agentInstallRoot(target, homeDir);
// 4. Diff.
const visibleByName = new Map(visible.map((s) => [s.name, s]));
@@ -566,17 +574,20 @@ export interface SkillsCommandDeps {
* "claude" → ~/.claude/skills
* "prime-agent" → ~/.prime/agent/skills
* "pi" → ~/.pi/agent/skills
* Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree.
* "opencode" → ~/.config/opencode/skill (XDG-aware)
* Kept here so `mcpctl skills sync --agent` and `mcpctl config <agent>` agree.
*/
export function agentInstallRoot(agent: string | undefined): string {
export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string {
switch (agent) {
case 'prime-agent':
return join(homedir(), '.prime', 'agent', 'skills');
return join(homeDir, '.prime', 'agent', 'skills');
case 'pi':
return join(homedir(), '.pi', 'agent', 'skills');
return join(homeDir, '.pi', 'agent', 'skills');
case 'opencode':
return opencodeSkillsDir(process.env, homeDir);
case 'claude':
default:
return join(homedir(), '.claude', 'skills');
return join(homeDir, '.claude', 'skills');
}
}
@@ -589,9 +600,9 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)');
cmd.command('sync')
.description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)')
.description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)')
.option('-p, --project <name>', 'Project to sync (overrides .mcpctl-project marker)')
.option('--agent <name>', 'Sync target: claude (default), prime-agent, or pi', 'claude')
.option('--agent <name>', 'Sync target: claude (default), prime-agent, pi, or opencode', 'claude')
.option('--dry-run', 'Print what would change without writing anything')
.option('--force', 'Overwrite locally-modified skills')
.option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)')
@@ -609,8 +620,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
// Validate --agent so an unknown value fails loudly instead of silently
// running the default (Claude) sync.
const agent = opts.agent ?? 'claude';
if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') {
warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`);
if (!isSyncTarget(agent)) {
warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`);
process.exitCode = 1;
return;
}
@@ -622,7 +633,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
target: agent as 'claude' | 'prime-agent' | 'pi',
target: agent,
installRoot: agentInstallRoot(agent),
},
{ client, log, warn },

View File

@@ -0,0 +1,163 @@
import { Command } from 'commander';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import {
MCPCTL_SERVER_NAME,
canonicalProjectIn,
claudeJsonPath,
disabledServersFor,
legacyEntriesIn,
userScopeProject,
type McpJson,
type ClaudeJson,
} 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 then resolved from the most
* deliberate source that names one — see the ranking in the action below.
* Whatever it reports has to be a project that is genuinely mounted, so a
* server Claude Code has switched off for that directory is skipped.
*
* 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;
}
/** Claude Code's user-scope config, or null if it is missing or unreadable. */
export function readClaudeJson(path: string): ClaudeJson | null {
try {
return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson;
} catch {
return null;
}
}
/** The project Claude Code's user-scope config mounts, or null. */
export function projectFromUserScope(doc: ClaudeJson | null): string | null {
return userScopeProject(doc);
}
/** The `.mcp.json` in `dir`, or null if there isn't a readable one. */
export function readDirMcpJson(dir: string): McpJson | null {
try {
return JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson;
} catch {
return null;
}
}
/**
* The project the canonical `mcpctl` entry in `dir`'s `.mcp.json` pins, or null
* — skipped when Claude Code has that server switched off for `dir`.
*/
export function projectFromDirPin(mcpJson: McpJson | null, disabled: Set<string>): string | null {
if (disabled.has(MCPCTL_SERVER_NAME)) return null;
return canonicalProjectIn(mcpJson);
}
/**
* The project a *legacy* project-named entry in `dir`'s `.mcp.json` mounts, or
* null. Disabled entries are skipped, so a leftover the user already turned off
* in `/mcp` stops being reported.
*/
export function projectFromDirLegacy(mcpJson: McpJson | null, disabled: Set<string>): string | null {
return legacyEntriesIn(mcpJson).find((e) => !disabled.has(e.server))?.project ?? 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());
const claudeJson = readClaudeJson(claudeJsonPath());
const mcpJson = readDirMcpJson(dir);
const disabled = disabledServersFor(claudeJson, dir);
// Ranked by how deliberate each source is, because a switch has to be
// able to win:
// 1. a canonical `mcpctl` entry in this directory's .mcp.json — a
// deliberate pin, and the scope Claude Code itself prefers when both
// define the same server name;
// 2. user scope — what `config claude --project` writes, so switching
// projects must beat anything less deliberate than a pin;
// 3. a *legacy* project-named entry in .mcp.json. This used to outrank
// user scope, which made a switch look like it had done nothing: the
// residue an older mcpctl left in a checkout is not a pin, and never
// gets rewritten by a user-scope switch, so it reported the old
// project forever;
// 4. the .mcpctl-project marker — the other thing `config claude`
// writes, and what skills sync already trusts.
let project =
projectFromDirPin(mcpJson, disabled)
?? projectFromUserScope(claudeJson)
?? projectFromDirLegacy(mcpJson, disabled);
if (project === null) {
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);
});
}

View File

@@ -0,0 +1,248 @@
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* `.mcp.json` shaping for `mcpctl config claude`.
*
* WHY A CONSTANT SERVER NAME
*
* The entry used to be named after the project (`homeautomation`,
* `docmost`, …). Because `.mcp.json` is *merged* rather than rewritten, running
* `config claude` for a second project left the first one mounted too: every
* project you had ever configured stayed connected, with duplicate tool names
* and no way to tell which one was "active".
*
* The entry is now always called `mcpctl`, and switching projects rewrites what
* is behind that name. Claude Code can reconnect an existing MCP server from
* inside a session (`/mcp`), so a switch takes effect without restarting the
* app — and the tool prefix (`mcpctl__*`) stays stable across switches, so the
* model never sees a tool namespace disappear.
*
* Legacy project-named entries this CLI wrote are retired on the next run; see
* `isLegacyMcpctlEntry` for what counts as ours.
*/
/**
* WHERE THE ENTRY LIVES
*
* Claude Code has two MCP scopes:
* - **project** — `./.mcp.json`, which applies only in that directory (and is
* usually committed, so writing to it dirties the repo);
* - **user** — `mcpServers` in `.claude.json`, which applies in every
* directory and every window.
*
* mcpctl defaults to **user** scope, because one active project everywhere is
* how the pi, prime-agent and opencode integrations already behave — and
* because per-directory wiring means re-running `config claude` in every
* checkout you open. `--scope project` (or an explicit `--output`) keeps the
* old per-directory file for a repo that genuinely wants its own pinned
* project.
*/
/** The one MCP server name mcpctl owns. */
export const MCPCTL_SERVER_NAME = 'mcpctl';
/** Name of the optional traffic-inspection server (`--inspect`). */
export const MCPCTL_INSPECT_SERVER_NAME = 'mcpctl-inspect';
export interface McpServerEntry {
command?: string;
args?: string[];
url?: string;
env?: Record<string, string>;
[key: string]: unknown;
}
export interface McpJson {
mcpServers: Record<string, McpServerEntry>;
[key: string]: unknown;
}
/** The stdio-bridge entry that mounts `project`. */
export function mcpctlStdioServer(project: string): McpServerEntry {
return { command: 'mcpctl', args: ['mcp', '-p', project] };
}
/** The `--inspect` traffic monitor entry. */
export function mcpctlInspectServer(): McpServerEntry {
return { command: 'mcpctl', args: ['console', '--stdin-mcp'] };
}
/**
* The project an entry bridges to, or null if it is not an mcpctl stdio bridge.
*
* Reads it straight out of `args` rather than a bookkeeping key, so nothing
* non-standard is written into a file Claude Code owns.
*/
export function projectOfEntry(entry: unknown): string | null {
if (entry === null || typeof entry !== 'object') return null;
const rec = entry as McpServerEntry;
if (rec.command !== 'mcpctl' || !Array.isArray(rec.args)) return null;
const args = rec.args;
if (args[0] !== 'mcp') return null;
const flag = args.indexOf('-p') >= 0 ? args.indexOf('-p') : args.indexOf('--project');
if (flag < 0) return null;
const project = args[flag + 1];
return typeof project === 'string' && project !== '' ? project : null;
}
/**
* Is `name` an entry an older mcpctl wrote — i.e. named after the very project
* its command bridges to?
*
* That pairing is what makes retiring it safe. A server someone configured by
* hand would have to be named exactly after the project it bridges to *and* run
* our command to be mistaken for one of ours, at which point it is functionally
* the same entry anyway.
*/
export function isLegacyMcpctlEntry(name: string, entry: unknown): boolean {
if (name === MCPCTL_SERVER_NAME) return false;
return projectOfEntry(entry) === name;
}
/** The project the canonical `mcpctl` entry mounts, or null if there isn't one. */
export function canonicalProjectIn(config: Pick<McpJson, 'mcpServers'> | null | undefined): string | null {
return projectOfEntry(config?.mcpServers?.[MCPCTL_SERVER_NAME]);
}
/**
* Legacy project-named entries still present, in file order.
*
* Kept separate from the canonical entry because the two mean different things
* to a reader: the canonical entry is a deliberate pin, a legacy entry is
* residue from an older mcpctl that nothing has cleaned up yet. Callers that
* rank sources (the status line) must be able to tell them apart.
*/
export function legacyEntriesIn(config: Pick<McpJson, 'mcpServers'> | null | undefined): { server: string; project: string }[] {
const servers = config?.mcpServers;
if (!servers) return [];
return Object.entries(servers)
.filter(([name, entry]) => isLegacyMcpctlEntry(name, entry))
.map(([name]) => ({ server: name, project: name }));
}
/** The project currently mounted by `.mcp.json`, preferring the canonical entry. */
export function activeProjectIn(config: Pick<McpJson, 'mcpServers'> | null | undefined): string | null {
return canonicalProjectIn(config) ?? legacyEntriesIn(config)[0]?.project ?? null;
}
export interface MergeResult {
config: McpJson;
/** Legacy project-named entries dropped by this merge. */
retired: string[];
}
/**
* Merge mcpctl's entries into an existing `.mcp.json`.
*
* Every server the user configured is preserved; only our own legacy
* project-named entries are dropped, and only once the canonical entry replaces
* them. Passing no project leaves any existing mount alone (`--inspect` on its
* own must not unmount the project you are working in).
*/
export function mergeMcpctlServers(
existing: Partial<McpJson> | null | undefined,
opts: { project?: string; inspect?: boolean },
): MergeResult {
const servers: Record<string, McpServerEntry> = { ...(existing?.mcpServers ?? {}) };
const retired: string[] = [];
if (opts.project !== undefined && opts.project !== '') {
for (const name of Object.keys(servers)) {
if (isLegacyMcpctlEntry(name, servers[name])) {
delete servers[name];
retired.push(name);
}
}
servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(opts.project);
}
if (opts.inspect === true) {
servers[MCPCTL_INSPECT_SERVER_NAME] = mcpctlInspectServer();
}
// Preserve any sibling top-level keys the file carried.
const rest = { ...(existing ?? {}) } as Partial<McpJson>;
delete rest.mcpServers;
return { config: { ...rest, mcpServers: servers }, retired };
}
/**
* Path of Claude Code's user-scope config.
*
* NOTE the asymmetry: with `CLAUDE_CONFIG_DIR` set the file is
* `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json`
* — *beside* `~/.claude/`, not inside it. Verified against a live Claude Code
* run with an isolated config dir.
*/
export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: string): string {
const override = env['CLAUDE_CONFIG_DIR'];
const home = homeDir ?? homedir();
return override !== undefined && override !== ''
? join(override, '.claude.json')
: join(home, '.claude.json');
}
/** Per-directory state Claude Code keeps in `.claude.json`'s `projects` map. */
export interface ClaudeProjectEntry {
/** Servers switched off for this directory, whatever scope they came from. */
disabledMcpServers?: string[];
/** `.mcp.json` servers declined at the approval prompt. */
disabledMcpjsonServers?: string[];
[key: string]: unknown;
}
/** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */
export interface ClaudeJson {
mcpServers?: Record<string, McpServerEntry>;
projects?: Record<string, ClaudeProjectEntry>;
[key: string]: unknown;
}
/**
* Server names Claude Code has switched off in `dir`.
*
* A disabled server is not mounted, so naming its project as "active" is a
* plain lie — this is what lets the status line skip one. Only *explicit*
* disables count: a `.mcp.json` server in neither list is pending its approval
* prompt, and treating pending as off would blank the status line on a fresh
* checkout, which is the more confusing failure.
*/
export function disabledServersFor(doc: ClaudeJson | null | undefined, dir: string): Set<string> {
const entry = doc?.projects?.[dir];
return new Set([
...(Array.isArray(entry?.disabledMcpServers) ? entry.disabledMcpServers : []),
...(Array.isArray(entry?.disabledMcpjsonServers) ? entry.disabledMcpjsonServers : []),
]);
}
/**
* Set the user-scope entry, returning the new document and any legacy
* project-named entries retired from it.
*
* `.claude.json` also holds onboarding state, caches and a per-project map that
* Claude Code rewrites constantly — so this merges into the document it was
* given and never reconstructs it.
*/
export function mergeUserScopeServer(
existing: ClaudeJson | null | undefined,
project: string,
): { config: ClaudeJson; retired: string[] } {
const doc: ClaudeJson = { ...(existing ?? {}) };
const servers: Record<string, McpServerEntry> = { ...(doc.mcpServers ?? {}) };
const retired: string[] = [];
for (const name of Object.keys(servers)) {
if (isLegacyMcpctlEntry(name, servers[name])) {
delete servers[name];
retired.push(name);
}
}
servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(project);
doc.mcpServers = servers;
return { config: doc, retired };
}
/** The project the user-scope entry mounts, or null. */
export function userScopeProject(doc: ClaudeJson | null | undefined): string | null {
return activeProjectIn({ mcpServers: doc?.mcpServers ?? {} });
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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());

View 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;
}

View File

@@ -0,0 +1,204 @@
/**
* Wiring helpers for the opencode integration.
*
* `mcpctl config opencode --project X`:
* 1. writes the embedded plugin sources into opencode's config dir
* (`plugin/mcpctl.ts` — auto-discovered server plugin — and
* `mcpctl/mcpctl-tui.tsx` — the `/mcpctl` switcher + footer indicator),
* 2. registers the TUI plugin in `tui.json` (opencode does not auto-discover
* TUI plugins; they have to be listed),
* 3. writes `~/.mcpctl/opencode-state.json` (0600) with the active project,
* the gateway URL and the per-project bearer tokens.
*
* WHY THE STATE FILE AND NOT `opencode.json`:
* opencode's own `mcp` block is a fine way to mount a *static* server, but
* this integration needs two things it cannot give. The gateway wants an
* `Authorization` header, and opencode.json is a mode-0644 file users paste
* into bug reports; and switching projects has to work without restarting
* opencode, which a config file cannot do. The plugins read the state file
* and mount through opencode's live MCP API instead, so `opencode.json` is
* never touched at all.
*
* Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`.
*/
import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
import {
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_SERVER_PLUGIN_SOURCE,
OPENCODE_TUI_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_SOURCE,
} from '../config/opencode-extension.js';
/**
* opencode's config directory.
*
* opencode is XDG-aware, so an install with `XDG_CONFIG_HOME` set keeps its
* config somewhere other than `~/.config`. Reading the same variable is what
* stops us provisioning a directory opencode will never look at.
*/
export function opencodeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string {
const xdg = env['XDG_CONFIG_HOME'];
const base = xdg !== undefined && xdg !== '' ? xdg : join(homeDir, '.config');
return join(base, 'opencode');
}
/** Resolve every path `config opencode` writes, under an explicit config dir. */
export function withOpencodeDir(base: string): {
serverPluginPath: () => string;
tuiPluginDir: () => string;
tuiPluginPath: () => string;
tuiJsonPath: () => string;
skillsDir: () => string;
} {
return {
serverPluginPath: () => join(base, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME),
tuiPluginDir: () => join(base, 'mcpctl'),
tuiPluginPath: () => join(base, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME),
tuiJsonPath: () => join(base, 'tui.json'),
skillsDir: () => join(base, 'skill'),
};
}
/** Where `mcpctl skills sync --agent opencode` installs skill bundles. */
export function opencodeSkillsDir(env?: NodeJS.ProcessEnv, homeDir?: string): string {
return withOpencodeDir(opencodeConfigDir(env, homeDir)).skillsDir();
}
/** Path of the state file both plugins read. */
export function opencodeStatePath(homeDir: string = homedir()): string {
return join(homeDir, '.mcpctl', 'opencode-state.json');
}
/**
* Write the embedded plugin sources into opencode's config dir.
*
* This is the production path — it needs no source tree, so it works from an
* installed binary. Returns the files written.
*/
export async function installOpencodePlugins(base: string): Promise<string[]> {
const paths = withOpencodeDir(base);
const written: string[] = [];
for (const [path, source] of [
[paths.serverPluginPath(), OPENCODE_SERVER_PLUGIN_SOURCE],
[paths.tuiPluginPath(), OPENCODE_TUI_PLUGIN_SOURCE],
] as const) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, source, 'utf-8');
written.push(path);
}
return written;
}
interface TuiConfig {
$schema?: string;
plugin?: unknown[];
[k: string]: unknown;
}
/**
* Load tui.json.
* - Missing/empty → `{}` (a brand-new file about to be created).
* - Corrupt JSON → throws, so the caller refuses to overwrite it. One syntax
* error must not silently drop every other TUI plugin the user installed.
*/
async function readTuiConfig(path: string): Promise<TuiConfig> {
let raw: string;
try {
raw = await readFile(path, 'utf-8');
} 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)}`);
}
if (raw.trim().length === 0) return {};
try {
const parsed = JSON.parse(raw) as TuiConfig;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (err: unknown) {
throw new Error(
`${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`,
);
}
}
async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tmp = `${path}.tmp.${String(process.pid)}`;
await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8');
await rename(tmp, path);
}
/**
* Register the TUI plugin in `tui.json`.
*
* Idempotent, and a merge rather than a rewrite: any other TUI plugin the user
* has installed keeps working. Returns whether anything changed, so a no-op run
* doesn't reformat a hand-maintained file.
*/
export async function registerOpencodeTuiPlugin(tuiJsonPath: string, pluginPath: string): Promise<{ added: boolean }> {
const config = await readTuiConfig(tuiJsonPath);
const plugins = Array.isArray(config.plugin) ? [...config.plugin] : [];
if (plugins.some((p) => p === pluginPath)) return { added: false };
// Drop any stale entry pointing at an older install location of *our* plugin
// (e.g. a rename between releases): leaving it behind makes opencode fail to
// load a file that no longer exists on every start.
const kept = plugins.filter((p) => !(typeof p === 'string' && p.endsWith(`/${OPENCODE_TUI_PLUGIN_FILENAME}`)));
kept.push(pluginPath);
config.$schema = typeof config.$schema === 'string' ? config.$schema : 'https://opencode.ai/tui.json';
config.plugin = kept;
await writeJsonAtomic(tuiJsonPath, config);
return { added: true };
}
export interface OpencodeState {
project?: string;
gatewayUrl?: string;
tokens?: Record<string, string>;
}
/** Read the state file; missing or corrupt yields an empty state. */
export async function readOpencodeState(path: string = opencodeStatePath()): Promise<OpencodeState> {
try {
const parsed = JSON.parse(await readFile(path, 'utf-8')) as OpencodeState;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch {
return {};
}
}
/**
* Set the active project (and optionally its bearer token) in the state file.
*
* Tokens for other projects are preserved so switching back to a project
* already provisioned on this machine needs no new mint — and so a mint failure
* for project B cannot cost you the credential for project A.
*
* Written 0600: this file holds bearer tokens. `writeFile`'s mode only applies
* when the file is created, so we chmod after writing too.
*/
export async function writeOpencodeState(
update: { project: string; gatewayUrl: string; token?: string },
path: string = opencodeStatePath(),
): Promise<string> {
const current = await readOpencodeState(path);
const tokens = { ...(current.tokens ?? {}) };
if (update.token !== undefined && update.token !== '') tokens[update.project] = update.token;
const next: OpencodeState = { project: update.project, gatewayUrl: update.gatewayUrl, tokens };
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
try {
await chmod(path, 0o600);
} catch {
/* best-effort: a credential written is better than one refused */
}
return path;
}
/** The token currently stored for `project`, if any. */
export function storedToken(state: OpencodeState, project: string): string | null {
const token = state.tokens?.[project];
return typeof token === 'string' && token !== '' ? token : null;
}

View File

@@ -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> {
@@ -95,6 +100,32 @@ export async function installManagedSessionHook(
}
}
// Drop untagged copies of the very command we manage.
//
// Before this installer carried a marker — and, for a long while, whenever
// the test suite ran against the developer's real ~/.claude — an identical
// but untagged row could be left behind. It is invisible in the UI and simply
// runs the sync a second time on every session start. Scoped to an exact
// string match on our own command, so a hook someone wrote themselves (even
// one that also calls `mcpctl skills sync`, but with different flags) is
// never touched.
if (foundEntry) {
for (const group of groups) {
if (!Array.isArray(group?.hooks)) continue;
const kept = group.hooks.filter((e) => e[MARKER_KEY] === true || e.command !== command);
if (kept.length !== group.hooks.length) {
group.hooks = kept;
entryChanged = true;
}
}
// A group we emptied is noise in the file.
const nonEmpty = groups.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0);
if (nonEmpty.length !== groups.length) {
settings.hooks.SessionStart = nonEmpty;
entryChanged = true;
}
}
if (!foundEntry) {
groups.push({
hooks: [{ type: 'command', command, [MARKER_KEY]: true }],

View File

@@ -0,0 +1,116 @@
/**
* Regression: a failed first turn must not brick the REPL.
*
* Observed live: the first message died upstream (anthropic 429) before the
* stream's `final` frame, streamOnce resolved '' as the thread id, the REPL
* stored it, and every later message sent `threadId: ""` — which mcpd's
* `z.string().min(1)` rejects with HTTP 400. The session was permanently
* stuck: no turn could succeed again, so no `final` frame could ever repair
* the thread id.
*
* The fix has two independent layers, pinned separately below:
* 1. streamOnce resolves `undefined` (not '') when no `final` frame arrived,
* and the REPL keeps its previous thread state on undefined;
* 2. chatBody never serializes an empty threadId, even if one leaks in.
*/
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import { describe, it, expect, afterEach } from 'vitest';
import { chatBody, streamOnce } from '../../src/commands/chat.js';
import type { ChatCommandDeps, ChatSubject } from '../../src/commands/chat.js';
import type { ApiClient } from '../../src/api-client.js';
const subject: ChatSubject = {
kind: 'agent',
name: 'reviewer',
path: 'agents/reviewer',
allowSecrets: false,
};
// streamOnce only touches baseUrl + token; the ApiClient is for the
// non-streaming path and never dereferenced here.
function depsFor(baseUrl: string): ChatCommandDeps {
return { client: null as unknown as ApiClient, baseUrl, log: () => {} };
}
let server: http.Server | null = null;
afterEach(async () => {
if (server !== null) {
await new Promise<void>((r) => server!.close(() => r()));
server = null;
}
});
/** Serve one SSE response body for any POST, return the base URL. */
async function serveSse(frames: string[]): Promise<string> {
server = http.createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
for (const f of frames) res.write(`data: ${f}\n\n`);
res.end();
});
await new Promise<void>((r) => server!.listen(0, '127.0.0.1', r));
const { port } = server.address() as AddressInfo;
return `http://127.0.0.1:${String(port)}`;
}
describe('chatBody — threadId serialization', () => {
it('omits threadId when undefined', () => {
expect(chatBody(subject, 'hi', undefined, {})).not.toHaveProperty('threadId');
});
it('omits threadId when empty — the exact payload that 400s against mcpd', () => {
expect(chatBody(subject, 'hi', '', {})).not.toHaveProperty('threadId');
});
it('includes a real threadId', () => {
expect(chatBody(subject, 'hi', 'cthread123', {})).toHaveProperty('threadId', 'cthread123');
});
});
describe('streamOnce — thread id after a failed turn', () => {
it('resolves undefined when the stream errors before any final frame', async () => {
const base = await serveSse([
'{"type":"error","message":"anthropic stream: HTTP 429"}',
'[DONE]',
]);
const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {});
expect(resolved).toBeUndefined();
});
it('keeps the caller-supplied thread when the turn fails mid-conversation', async () => {
const base = await serveSse([
'{"type":"error","message":"upstream died"}',
'[DONE]',
]);
const resolved = await streamOnce(depsFor(base), subject, 'hi', 'cexisting1', {});
expect(resolved).toBe('cexisting1');
});
it('resolves the threadId announced by the final frame', async () => {
const base = await serveSse([
'{"type":"text","delta":"pong"}',
'{"type":"final","threadId":"cfresh42"}',
'[DONE]',
]);
const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {});
expect(resolved).toBe('cfresh42');
});
it('REPL chain: failed turn 1 leaves turn 2 sendable (the brick)', async () => {
const base = await serveSse([
'{"type":"error","message":"anthropic stream: HTTP 429"}',
'[DONE]',
]);
// Mirrors runRepl's assignment: threadId = streamOnce(...) ?? threadId
let threadId: string | undefined = undefined;
threadId = (await streamOnce(depsFor(base), subject, 'hi', threadId, {})) ?? threadId;
// Turn 2's body must be valid for mcpd: no threadId key at all.
const body = chatBody(subject, 'hi again', threadId, {}, true);
expect(body).not.toHaveProperty('threadId');
expect(body).toHaveProperty('message', 'hi again');
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
@@ -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 () => {
@@ -46,7 +61,7 @@ describe('config claude', () => {
expect(client.get).not.toHaveBeenCalled();
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['homeautomation']).toEqual({
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'homeautomation'],
});
@@ -61,7 +76,7 @@ describe('config claude', () => {
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
const parsed = JSON.parse(output[0]);
expect(parsed.mcpServers['myproj']).toEqual({
expect(parsed.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'myproj'],
});
@@ -81,7 +96,7 @@ describe('config claude', () => {
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['existing--server']).toBeDefined();
expect(written.mcpServers['proj-1']).toEqual({
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
@@ -113,7 +128,7 @@ describe('config claude', () => {
await cmd.parseAsync(['claude', '--project', 'ha', '--inspect', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['ha']).toBeDefined();
expect(written.mcpServers['mcpctl']).toBeDefined();
expect(written.mcpServers['mcpctl-inspect']).toBeDefined();
expect(output.join('\n')).toContain('2 server(s)');
});
@@ -127,21 +142,61 @@ describe('config claude', () => {
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['proj-1']).toEqual({
expect(written.mcpServers['mcpctl']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
});
it('uses project name as the server key', async () => {
it('uses one constant server key, whatever the project is called', async () => {
// The key used to be the project name, so `config claude` for a second
// project left the first one mounted too — every project ever configured
// stayed connected, with duplicate tool names.
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' });
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']);
expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'my-fancy-project']);
});
it('switching projects replaces the mount instead of stacking a second one', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'first', '-o', outPath, '--skip-skills'], { from: 'user' });
await cmd.parseAsync(['claude', '--project', 'second', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']);
expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'second']);
});
it('retires a legacy project-named entry left by an older CLI', async () => {
const outPath = join(tmpDir, '.mcp.json');
writeFileSync(outPath, JSON.stringify({
mcpServers: {
homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] },
'my-own-server': { command: 'echo', args: [] },
},
}));
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'docmost', '-o', outPath, '--skip-skills'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers).sort()).toEqual(['mcpctl', 'my-own-server']);
expect(output.join('\n')).toContain('Retired legacy per-project entry: homeautomation');
});
it('--dry-run reports the plan and writes nothing', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log });
await cmd.parseAsync(['claude', '--project', 'p', '-o', outPath, '--dry-run'], { from: 'user' });
const plan = JSON.parse(output.join('\n'));
expect(plan.claude.server).toBe('mcpctl');
expect(plan.claude.entry.args).toEqual(['mcp', '-p', 'p']);
expect(existsSync(outPath)).toBe(false);
});
});
@@ -223,3 +278,127 @@ describe('config impersonate', () => {
expect(output.join('\n')).toContain('No impersonation session to quit');
});
});
describe('config claude — user scope', () => {
let output: string[];
let tmpDir: string;
let claudeDir: string;
let prior: string | undefined;
const log = (...args: string[]): void => { output.push(args.join(' ')); };
const claudeJson = (): string => join(claudeDir, '.claude.json');
beforeEach(() => {
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-user-'));
claudeDir = join(tmpDir, 'claude-home');
mkdirSync(claudeDir, { recursive: true });
prior = process.env['CLAUDE_CONFIG_DIR'];
process.env['CLAUDE_CONFIG_DIR'] = claudeDir;
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
if (prior === undefined) delete process.env['CLAUDE_CONFIG_DIR'];
else process.env['CLAUDE_CONFIG_DIR'] = prior;
});
const cmd = () => createConfigCommand({ configDeps: {}, log });
it('registers in .claude.json by default, not a per-directory .mcp.json', async () => {
// The whole point: wire it once, not in every checkout you open.
await cmd().parseAsync(['claude', '--project', 'homeautomation', '--skip-skills', '--skip-ui'], { from: 'user' });
const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8'));
expect(parsed.mcpServers.mcpctl).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] });
expect(output.join('\n')).toContain('every directory');
});
it('preserves everything else in .claude.json', async () => {
// That file also holds onboarding state, caches and the per-project map.
writeFileSync(claudeJson(), JSON.stringify({
numStartups: 42,
mcpServers: { 'taskmaster-ai': { type: 'stdio', command: 'task-master-ai' } },
projects: { '/some/repo': { allowedTools: [] } },
}));
await cmd().parseAsync(['claude', '--project', 'p', '--skip-skills', '--skip-ui'], { from: 'user' });
const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8'));
expect(parsed.numStartups).toBe(42);
expect(parsed.projects).toEqual({ '/some/repo': { allowedTools: [] } });
expect(parsed.mcpServers['taskmaster-ai']).toBeDefined();
expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'p']);
});
it('switching re-points the one entry', async () => {
await cmd().parseAsync(['claude', '--project', 'a', '--skip-skills', '--skip-ui'], { from: 'user' });
await cmd().parseAsync(['claude', '--project', 'b', '--skip-skills', '--skip-ui'], { from: 'user' });
const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8'));
expect(Object.keys(parsed.mcpServers)).toEqual(['mcpctl']);
expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'b']);
});
it('writes no .mcpctl-project marker — user scope is not directory-specific', async () => {
// A marker beside .claude.json would sit in $HOME and scope every repo under it.
const cwd = process.cwd();
process.chdir(tmpDir);
try {
await cmd().parseAsync(['claude', '--project', 'p', '--skip-ui'], { from: 'user' });
expect(existsSync(join(tmpDir, '.mcpctl-project'))).toBe(false);
expect(output.join('\n')).toContain('not directory-specific');
} finally { process.chdir(cwd); }
});
it('an explicit --output still means the per-directory file', async () => {
const outPath = join(tmpDir, '.mcp.json');
await cmd().parseAsync(['claude', '--project', 'p', '-o', outPath, '--skip-skills', '--skip-ui'], { from: 'user' });
expect(existsSync(outPath)).toBe(true);
expect(existsSync(claudeJson())).toBe(false);
});
it('rejects an unknown scope instead of silently picking one', async () => {
const prevExit = process.exitCode;
await cmd().parseAsync(['claude', '--project', 'p', '--scope', 'global', '--skip-skills'], { from: 'user' });
expect(process.exitCode).toBe(1);
process.exitCode = prevExit;
expect(output.join('\n')).toContain("unknown --scope 'global'");
});
// A user-scope switch never rewrites a directory's .mcp.json, so anything of
// ours left in one keeps answering in that directory. Saying so is the only
// way the user finds out — the switch otherwise reports plain success.
describe('warns when the working directory contradicts the switch', () => {
const switchTo = async (project: string): Promise<string> => {
await createConfigCommand({ configDeps: {}, log, cwd: () => tmpDir })
.parseAsync(['claude', '--project', project, '--skip-skills', '--skip-ui'], { from: 'user' });
return output.join('\n');
};
it('names a legacy entry that stays mounted alongside the new project', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: { homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] } },
}));
const out = await switchTo('sre');
expect(out).toContain(join(tmpDir, '.mcp.json'));
expect(out).toContain("'homeautomation'");
expect(out).toContain('mounted alongside');
});
it('says a canonical pin overrides the switch in that directory', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: { mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'docmost'] } },
}));
expect(await switchTo('sre')).toContain('overrides the switch here');
});
it('stays quiet when the directory already agrees, or wires nothing of ours', async () => {
writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({
mcpServers: {
mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'sre'] },
'their-server': { command: 'docker', args: ['run', 'x'] },
},
}));
expect(await switchTo('sre')).not.toContain('Warning:');
});
it('stays quiet when there is no .mcp.json at all', async () => {
expect(await switchTo('sre')).not.toContain('Warning:');
});
});
});

View File

@@ -0,0 +1,210 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js';
interface ClientCalls { posts: Array<{ path: string; body?: unknown }> }
/**
* @param tokens what `GET /api/v1/mcptokens` reports as existing for a project
*/
function mockClient(calls: ClientCalls, tokens: unknown[] = []): ApiClient {
return {
get: vi.fn(async (path: string) => {
if (path.startsWith('/api/v1/mcptokens')) return tokens;
if (path.endsWith('/skills/visible')) return [];
return {};
}),
post: vi.fn(async (path: string, body?: unknown) => {
calls.posts.push({ path, body });
if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' };
return {};
}),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('config opencode', () => {
let output: string[];
let tmpDir: string;
let ocDir: string;
let calls: ClientCalls;
const log = (...args: string[]): void => { output.push(args.join(' ')); };
const statePath = (): string => join(ocDir, 'mcpctl-state.json');
/** Pre-existing state, as a machine that has already run this command has. */
function seedState(value: unknown): void {
mkdirSync(ocDir, { recursive: true });
writeFileSync(statePath(), JSON.stringify(value));
}
function command(client: ApiClient) {
return createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
}
beforeEach(() => {
output = [];
calls = { posts: [] };
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-oc-'));
ocDir = join(tmpDir, 'opencode');
});
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
it('requires a project', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--opencode-dir', ocDir, '--skip-skills'], { from: 'user' });
expect(output.join('\n')).toContain('--project is required');
expect(existsSync(statePath())).toBe(false);
});
it('installs both plugins, registers the TUI one and writes the state file', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'docmost', '--opencode-dir', ocDir,
'--gateway-url', 'https://gw.example', '--token', 'mcpctl_pat_SUPPLIED000000',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(true);
expect(existsSync(join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'))).toBe(true);
const tui = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8'));
expect(tui.plugin).toEqual([join(ocDir, 'mcpctl', 'mcpctl-tui.tsx')]);
const state = JSON.parse(readFileSync(statePath(), 'utf-8'));
expect(state).toEqual({
project: 'docmost',
gatewayUrl: 'https://gw.example',
tokens: { docmost: 'mcpctl_pat_SUPPLIED000000' },
});
});
it('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/',
'--token', 't', '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example');
});
it('--token never mints', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
});
it('mints a uniquely-named opencode token when none is stored', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
const mint = calls.posts.find((c) => c.path === '/api/v1/mcptokens');
expect(mint).toBeDefined();
// A fixed name could only ever be minted once per project: McpToken is
// unique on (name, projectId) and revoke is a soft delete.
expect((mint?.body as { name: string }).name).toMatch(/^opencode-/);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000');
});
it('reuses a stored token that the server still reports as active', async () => {
// 16-char prefix is what the server exposes; the secret is never re-sent.
const stored = 'mcpctl_pat_STORED000000000';
seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } });
const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'active', tokenPrefix: stored.slice(0, 16) }]);
await command(client).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
expect(output.join('\n')).toContain('already present');
});
it('re-mints when the stored token has been revoked server-side', async () => {
const stored = 'mcpctl_pat_REVOKED00000000';
seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } });
const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'revoked', tokenPrefix: stored.slice(0, 16) }]);
await command(client).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(1);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000');
});
it('leaves the previous project mounted when no credential can be provisioned', async () => {
// A switch with no usable credential is a FAILURE: exiting 0 here would
// have the /mcpctl switcher report success over a project with no tools.
seedState({ project: 'old', gatewayUrl: 'https://gw', tokens: { old: 'tok-old' } });
const client = {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})), // mint returns no token
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
const prevExit = process.exitCode;
await command(client).parseAsync(
['opencode', '--project', 'new', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(process.exitCode).toBe(1);
process.exitCode = prevExit;
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('old');
expect(output.join('\n')).toContain('Aborted');
});
it('--skip-plugin updates state without touching the loaded plugin files', async () => {
// This is the path the /mcpctl switcher takes: rewriting the very file
// opencode has already loaded buys nothing.
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-plugin', '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false);
expect(existsSync(join(ocDir, 'tui.json'))).toBe(false);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('p');
});
it('--dry-run reports the plan and writes nothing', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--dry-run'], { from: 'user' });
const plan = JSON.parse(output.join('\n'));
expect(plan.opencode.mcpUrl).toContain('/projects/p/mcp');
expect(plan.opencode.statePath).toBe(statePath());
expect(existsSync(ocDir)).toBe(false);
expect(calls.posts).toHaveLength(0);
});
it('--skip-marker leaves the working directory unscoped', async () => {
const cwd = process.cwd();
const workDir = join(tmpDir, 'work');
mkdirSync(workDir, { recursive: true });
process.chdir(workDir);
try {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(workDir, '.mcpctl-project'))).toBe(false);
} finally {
process.chdir(cwd);
}
});
it('writes a .mcpctl-project marker by default', async () => {
const cwd = process.cwd();
const workDir = join(tmpDir, 'work2');
mkdirSync(workDir, { recursive: true });
process.chdir(workDir);
try {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', '--skip-skills'],
{ from: 'user' });
expect(readFileSync(join(workDir, '.mcpctl-project'), 'utf-8')).toContain('p');
} finally {
process.chdir(cwd);
}
});
});

View File

@@ -0,0 +1,121 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createStatuslineCommand } from '../../src/commands/statusline.js';
/**
* The status line is what tells you which project you are in, so the property
* under test throughout is: after a switch, does it name the project you
* switched to?
*
* These drive the real command rather than the resolution helpers, because the
* bug they cover was in the *ranking* of sources, not in any one source.
*/
const bridge = (project: string): Record<string, unknown> => ({
command: 'mcpctl',
args: ['mcp', '-p', project],
});
let home: string;
let dir: string;
/** Claude Code's user-scope config, at the path `claudeJsonPath()` resolves. */
function writeClaudeJson(doc: unknown): void {
writeFileSync(join(home, '.claude.json'), JSON.stringify(doc));
}
function writeMcpJson(doc: unknown): void {
writeFileSync(join(dir, '.mcp.json'), JSON.stringify(doc));
}
/** Run `statusline` for `dir` and return exactly what it printed. */
async function statusline(): Promise<string> {
const out: string[] = [];
const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home });
await cmd.parseAsync(['--directory', dir], { from: 'user' });
return out.join('');
}
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-home-'));
dir = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-dir-'));
// Point claudeJsonPath() at the fake home; the CLI reads $CLAUDE_CONFIG_DIR
// first, which keeps this off the developer's real ~/.claude.json.
process.env['CLAUDE_CONFIG_DIR'] = home;
});
afterEach(() => {
delete process.env['CLAUDE_CONFIG_DIR'];
rmSync(home, { recursive: true, force: true });
rmSync(dir, { recursive: true, force: true });
});
describe('mcpctl statusline', () => {
it('reports the user-scope project when the directory wires nothing', async () => {
writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } });
expect(await statusline()).toBe('mcpctl:sre');
});
it('prints nothing at all when no project is active', async () => {
writeClaudeJson({ mcpServers: {} });
expect(await statusline()).toBe('');
});
it('lets a canonical .mcp.json pin override the user-scope project', async () => {
// Same server name in both scopes: Claude Code prefers project scope, so a
// deliberate pin is genuinely what is mounted here.
writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } });
writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } });
expect(await statusline()).toBe('mcpctl:docmost');
});
it('does not let a legacy project-named entry outrank a user-scope switch', async () => {
// The regression: an older mcpctl wrote `homeautomation` into a checkout,
// and a user-scope switch never rewrites that file — so the status line
// reported the old project forever and the switch looked like a no-op.
writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } });
writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } });
expect(await statusline()).toBe('mcpctl:sre');
});
it('still reports a legacy entry when nothing more deliberate names a project', async () => {
writeClaudeJson({ mcpServers: {} });
writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } });
expect(await statusline()).toBe('mcpctl:homeautomation');
});
it('skips a directory server Claude Code has switched off', async () => {
// A disabled server is not mounted, so naming its project is a lie.
writeClaudeJson({
mcpServers: {},
projects: { [dir]: { disabledMcpServers: ['homeautomation'] } },
});
writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } });
expect(await statusline()).toBe('');
});
it('skips a disabled pin and falls through to the user-scope project', async () => {
writeClaudeJson({
mcpServers: { mcpctl: bridge('sre') },
projects: { [dir]: { disabledMcpjsonServers: ['mcpctl'] } },
});
writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } });
expect(await statusline()).toBe('mcpctl:sre');
});
it('falls back to a .mcpctl-project marker when nothing is wired', async () => {
writeClaudeJson({ mcpServers: {} });
writeFileSync(join(dir, '.mcpctl-project'), 'lab\n');
expect(await statusline()).toBe('mcpctl:lab');
});
it('honours a custom prefix', async () => {
writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } });
const out: string[] = [];
const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home });
await cmd.parseAsync(['--directory', dir, '--prefix', 'proj '], { from: 'user' });
expect(out.join('')).toBe('proj sre');
});
});

View File

@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest';
import {
MCPCTL_SERVER_NAME,
mergeMcpctlServers,
projectOfEntry,
isLegacyMcpctlEntry,
activeProjectIn,
canonicalProjectIn,
legacyEntriesIn,
disabledServersFor,
} from '../../src/config/claude-mcp.js';
const bridge = (project: string): Record<string, unknown> => ({
command: 'mcpctl',
args: ['mcp', '-p', project],
});
describe('projectOfEntry', () => {
it('reads the project out of the bridge args', () => {
expect(projectOfEntry(bridge('docmost'))).toBe('docmost');
expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '--project', 'sre'] })).toBe('sre');
});
it('ignores anything that is not our stdio bridge', () => {
expect(projectOfEntry({ command: 'echo', args: ['mcp', '-p', 'x'] })).toBeNull();
expect(projectOfEntry({ command: 'mcpctl', args: ['console', '--stdin-mcp'] })).toBeNull();
expect(projectOfEntry({ type: 'remote', url: 'https://x/projects/y/mcp' })).toBeNull();
expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '-p'] })).toBeNull();
expect(projectOfEntry(null)).toBeNull();
expect(projectOfEntry('nope')).toBeNull();
});
});
describe('isLegacyMcpctlEntry', () => {
it('recognises an entry named after the very project it bridges to', () => {
expect(isLegacyMcpctlEntry('docmost', bridge('docmost'))).toBe(true);
});
it('never claims the canonical entry', () => {
expect(isLegacyMcpctlEntry(MCPCTL_SERVER_NAME, bridge('docmost'))).toBe(false);
});
it('leaves a hand-configured server alone', () => {
// Same name, different command — someone else's server.
expect(isLegacyMcpctlEntry('docmost', { command: 'docker', args: ['run', 'docmost'] })).toBe(false);
// Our command, but the name does not match the project: not something this
// CLI ever wrote, so it is the user's to keep.
expect(isLegacyMcpctlEntry('my-shortcut', bridge('docmost'))).toBe(false);
});
});
describe('activeProjectIn', () => {
it('prefers the canonical entry', () => {
expect(activeProjectIn({ mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre') } })).toBe('sre');
});
it('falls back to a legacy entry so pre-migration installs still report', () => {
expect(activeProjectIn({ mcpServers: { docmost: bridge('docmost') } })).toBe('docmost');
});
it('is null when nothing of ours is mounted', () => {
expect(activeProjectIn({ mcpServers: { other: { command: 'echo' } } })).toBeNull();
expect(activeProjectIn(null)).toBeNull();
expect(activeProjectIn({ mcpServers: {} })).toBeNull();
});
});
describe('canonicalProjectIn / legacyEntriesIn', () => {
it('tells a deliberate pin apart from pre-migration residue', () => {
const config = { mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre'), homeautomation: bridge('homeautomation') } };
expect(canonicalProjectIn(config)).toBe('sre');
expect(legacyEntriesIn(config)).toEqual([{ server: 'homeautomation', project: 'homeautomation' }]);
});
it('reports no canonical entry when only legacy ones are present', () => {
const config = { mcpServers: { docmost: bridge('docmost') } };
expect(canonicalProjectIn(config)).toBeNull();
expect(legacyEntriesIn(config)).toEqual([{ server: 'docmost', project: 'docmost' }]);
});
it('leaves servers that are not ours out of both', () => {
const config = { mcpServers: { other: { command: 'echo' } } };
expect(canonicalProjectIn(config)).toBeNull();
expect(legacyEntriesIn(config)).toEqual([]);
expect(legacyEntriesIn(null)).toEqual([]);
});
});
describe('disabledServersFor', () => {
const doc = {
projects: {
'/repo': { disabledMcpServers: ['homeautomation'], disabledMcpjsonServers: ['mcpctl'] },
'/other': { disabledMcpServers: ['sre'] },
},
};
it('unions both of Claude Code\'s disable lists for that directory', () => {
expect([...disabledServersFor(doc, '/repo')].sort()).toEqual(['homeautomation', 'mcpctl']);
});
it('is scoped to the directory asked about', () => {
expect([...disabledServersFor(doc, '/other')]).toEqual(['sre']);
expect([...disabledServersFor(doc, '/unknown')]).toEqual([]);
expect([...disabledServersFor(null, '/repo')]).toEqual([]);
});
it('survives a malformed entry rather than throwing on the status line', () => {
expect([...disabledServersFor({ projects: { '/repo': { disabledMcpServers: 'nope' } } }, '/repo')]).toEqual([]);
});
});
describe('mergeMcpctlServers', () => {
it('writes one constant entry regardless of project', () => {
const { config } = mergeMcpctlServers(null, { project: 'my-fancy-project' });
expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]);
expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('my-fancy-project'));
});
it('re-points rather than stacking on a second project', () => {
const first = mergeMcpctlServers(null, { project: 'a' }).config;
const { config } = mergeMcpctlServers(first, { project: 'b' });
expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]);
expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('b'));
});
it('retires legacy per-project entries and reports them', () => {
const existing = { mcpServers: { homeautomation: bridge('homeautomation'), sre: bridge('sre') } };
const { config, retired } = mergeMcpctlServers(existing, { project: 'docmost' });
expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]);
expect(retired.sort()).toEqual(['homeautomation', 'sre']);
});
it('preserves servers the user configured, and other top-level keys', () => {
const existing = {
mcpServers: { 'my-own': { command: 'echo', args: [] } },
someOtherKey: { keep: true },
};
const { config, retired } = mergeMcpctlServers(existing, { project: 'p' });
expect(config.mcpServers['my-own']).toEqual({ command: 'echo', args: [] });
expect(config['someOtherKey']).toEqual({ keep: true });
expect(retired).toEqual([]);
});
it('--inspect alone does not unmount the project you are working in', () => {
const existing = mergeMcpctlServers(null, { project: 'p' }).config;
const { config, retired } = mergeMcpctlServers(existing, { inspect: true });
expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('p'));
expect(config.mcpServers['mcpctl-inspect']).toEqual({ command: 'mcpctl', args: ['console', '--stdin-mcp'] });
expect(retired).toEqual([]);
});
it('does not mutate the config it was handed', () => {
const existing = { mcpServers: { homeautomation: bridge('homeautomation') } };
mergeMcpctlServers(existing, { project: 'docmost' });
expect(Object.keys(existing.mcpServers)).toEqual(['homeautomation']);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
OPENCODE_SERVER_PLUGIN_SOURCE,
OPENCODE_TUI_PLUGIN_SOURCE,
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_FILENAME,
} from '../../src/config/opencode-extension.js';
/**
* `mcpctl config opencode` installs the *embedded* copy of the plugins, not the
* files in src/opencode-ext/. Editing the sources without re-running the
* generator therefore ships stale code to users while the repo looks correct —
* and the embedded copy is the one thing no typecheck covers. Same guarantee
* the completions check gives.
*/
const repoRoot = join(import.meta.dirname, '..', '..', '..', '..');
const extDir = join(repoRoot, 'src', 'opencode-ext');
describe('embedded opencode plugins', () => {
it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'));
expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'));
});
it('install under names opencode can actually load', () => {
// The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is
// referenced by path from tui.json and must stay .tsx for its JSX to be
// transpiled.
expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts');
expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx');
});
it('are self-contained — the installed files have no mcpctl imports to resolve', () => {
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).not.toMatch(/from '@mcpctl\//);
expect(src).not.toMatch(/from '\.\.\//);
}
});
it('keep the JSX pragma the TUI plugin needs to render its indicator', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE.startsWith('/** @jsxImportSource @opentui/solid */')).toBe(true);
});
it('agree on the MCP server name, so a switch re-points one mount instead of stacking two', () => {
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).toContain("const SERVER_NAME = 'mcpctl'");
}
});
it('agree on the state file both read', () => {
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).toContain("join(homedir(), '.mcpctl', 'opencode-state.json')");
}
});
it('spawn the CLI without a shell, so a project name is never interpolated into a command string', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("execFile('mcpctl', args");
expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexecSync\s*\(/);
expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexec\(`/);
});
it('sync skills into opencode’s own tree, never Claude’s', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'");
});
it('bind the switcher to a chord as well as a slash command', () => {
// Switching is the repeated action; typing /mcpctl every time is friction.
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'");
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: '<leader>m'");
});
it('tear the outgoing mount down before re-pointing it', () => {
// An abandoned client keeps its mcp-session-id — and a gated project's
// unlocked state — alive on mcplocal.
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })');
});
it('clip rather than wrap the footer label on the narrow home prompt', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"');
});
it('switch without rewriting the plugin file opencode has already loaded', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'");
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'");
});
});
describe('embedded opencode plugins — state parsing', () => {
it('type-guard the parsed state, not just try/catch', () => {
// `JSON.parse('null')` succeeds and returns null, so a bare try/catch lets
// it through and the next `state.project` throws a TypeError that takes the
// plugin down. A hand-edited or truncated state file must degrade to "no
// project", never to a broken opencode.
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).toContain("typeof parsed === 'object' && parsed !== null");
expect(src).not.toMatch(/return JSON\.parse\(await readFile\([^)]*\)\) as OpencodeState;/);
}
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { orderProjects, indicatorLabel } from '../../../opencode-ext/mcpctl-opencode-tui.js';
/**
* opencode's select dialog filters as you type, so the picker only has to get
* the *order* right — the active project first, because "the one I am on" is
* the most likely pick and real installs run to hundreds of projects (smoke-test
* leftovers included).
*/
const PROJECTS = [
{ name: 'smoke-proj-none-mohimh46' },
{ name: 'homeautomation', description: 'house' },
{ name: 'docmost' },
{ name: 'copy-homeautomation' },
{ name: 'labctl' },
{ name: 'sre' },
];
describe('orderProjects', () => {
it('puts the active project first, then sorts alphabetically', () => {
expect(orderProjects(PROJECTS, 'labctl').map((p) => p.name)).toEqual([
'labctl',
'copy-homeautomation',
'docmost',
'homeautomation',
'smoke-proj-none-mohimh46',
'sre',
]);
});
it('sorts alphabetically when nothing is active', () => {
expect(orderProjects(PROJECTS, null).map((p) => p.name)).toEqual([
'copy-homeautomation',
'docmost',
'homeautomation',
'labctl',
'smoke-proj-none-mohimh46',
'sre',
]);
});
it('never drops or duplicates a project', () => {
expect(orderProjects(PROJECTS, 'sre')).toHaveLength(PROJECTS.length);
expect(orderProjects(PROJECTS, 'not-a-project')).toHaveLength(PROJECTS.length);
});
it('does not mutate the caller’s list', () => {
const input = [...PROJECTS];
orderProjects(input, 'sre');
expect(input.map((p) => p.name)).toEqual(PROJECTS.map((p) => p.name));
});
it('keeps descriptions, which the dialog shows under each row', () => {
expect(orderProjects(PROJECTS, null).find((p) => p.name === 'homeautomation')?.description).toBe('house');
});
});
describe('indicatorLabel', () => {
it('names the active project', () => {
expect(indicatorLabel('docmost')).toBe('mcpctl:docmost');
});
it('says so when there is none, rather than rendering a bare prefix', () => {
expect(indicatorLabel(null)).toBe('mcpctl:none');
expect(indicatorLabel('')).toBe('mcpctl:none');
});
});

View File

@@ -29,6 +29,34 @@ describe('embedded pi extension', () => {
expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js');
});
/**
* pi resolves an extension's bare specifiers through a hard-coded alias table
* in its own loader, and that table is not the same across pi distributions:
* `@earendil-works/*` exists only in the newer packages, `@mariozechner/*`
* installs alias only the old names, and neither resolves the other. An
* import of a package outside the intersection makes the whole extension fail
* to load with `Cannot find module` — every tool gone, on someone else's pi.
*
* `typebox` is aliased by every published pi, so it is the only safe bare
* runtime import. Type-only imports are erased before jiti resolves anything,
* so they may name whatever they like.
*/
it('imports nothing at runtime that some pi build cannot resolve', () => {
// `import x from "s"` / `import {..} from "s"` (but not `import type`),
// plus the side-effect form `import "s"`.
const runtimeImport =
/^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gm;
const allowed = /^(node:|\.\/|\.\.\/|typebox$|typebox\/)/;
for (const name of PI_EXTENSION_FILENAMES) {
const src = PI_EXTENSION_FILES[name] ?? '';
for (const match of src.matchAll(runtimeImport)) {
const specifier = match[1] ?? match[2] ?? '';
expect(specifier, `${name} runtime-imports ${specifier}`).toMatch(allowed);
}
}
});
it('carries the fixes the pi API requires', () => {
const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? '';
// ctx.ui.select takes string[] and returns the chosen string.

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
MCPCTL_SWITCH_EXTENSION,
MCPCTL_SWITCH_EXTENSION_FILENAME,
} from '../../src/config/prime-agent-extension.js';
/**
* `mcpctl config prime-agent` installs the *embedded* copy of the switcher, not
* the file in src/prime-agent-ext/. Editing the source without re-running the
* generator therefore ships stale code while the repo looks correct.
*
* This matters more here than for the pi and opencode extensions: until the
* source file existed, the switcher was only a string literal, so nothing
* typechecked or linted it at all. The guarantee only holds while the two stay
* in sync.
*/
const repoRoot = join(import.meta.dirname, '..', '..', '..', '..');
const extDir = join(repoRoot, 'src', 'prime-agent-ext');
describe('embedded prime-agent switcher', () => {
it('matches the source in src/prime-agent-ext (re-run scripts/generate-prime-agent-extension.ts)', () => {
expect(MCPCTL_SWITCH_EXTENSION, 'stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8'));
});
it('installs under the name prime-agent auto-discovers', () => {
expect(MCPCTL_SWITCH_EXTENSION_FILENAME).toBe('mcpctl-switch.ts');
});
it('is self-contained — the installed file has no mcpctl imports to resolve', () => {
expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '@mcpctl\//);
expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '\.\.\//);
});
it('switches without re-installing itself or re-scoping the launch directory', () => {
// Rewriting the extension file prime-agent has already loaded buys nothing;
// writing a marker would silently re-scope whatever repo it was started in.
expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-extension'");
expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-marker'");
});
it('publishes the active-project indicator, which is the only visible state', () => {
expect(MCPCTL_SWITCH_EXTENSION).toContain('setStatus');
expect(MCPCTL_SWITCH_EXTENSION).toContain("'turn_start'");
});
});

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { buildHealthCheck } from '../src/commands/create.js';
describe('buildHealthCheck — CLI flags → server healthCheck', () => {
it('returns undefined when no health-check flag is given', () => {
expect(buildHealthCheck({})).toBeUndefined();
});
it('builds a readiness probe from --health-check-tool', () => {
expect(buildHealthCheck({ healthCheckTool: 'list_sites' })).toEqual({ tool: 'list_sites' });
});
it('parses --health-check-args as a JSON object', () => {
expect(buildHealthCheck({ healthCheckTool: 'get_devices', healthCheckArgs: '{"site":"default"}' }))
.toEqual({ tool: 'get_devices', arguments: { site: 'default' } });
});
it('carries the timing flags through', () => {
expect(buildHealthCheck({
healthCheckTool: 'list_sites',
healthCheckInterval: '120',
healthCheckTimeout: '15',
healthCheckFailureThreshold: '2',
})).toEqual({
tool: 'list_sites',
intervalSeconds: 120,
timeoutSeconds: 15,
failureThreshold: 2,
});
});
it('allows tuning the liveness probe without a tool', () => {
// No `tool` → the probe stays liveness-only (reports `live`), but the
// interval is still configurable.
expect(buildHealthCheck({ healthCheckInterval: '300' })).toEqual({ intervalSeconds: 300 });
});
it('rejects --health-check-args without a tool', () => {
expect(() => buildHealthCheck({ healthCheckArgs: '{}' }))
.toThrow(/--health-check-args requires --health-check-tool/);
});
it('rejects non-JSON args', () => {
expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: 'site=default' }))
.toThrow(/not valid JSON/);
});
it('rejects JSON args that are not an object', () => {
expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: '["a"]' }))
.toThrow(/expected a JSON object/);
});
it('rejects non-positive-integer timings', () => {
expect(() => buildHealthCheck({ healthCheckInterval: '0' })).toThrow(/--health-check-interval/);
expect(() => buildHealthCheck({ healthCheckTimeout: 'abc' })).toThrow(/--health-check-timeout/);
expect(() => buildHealthCheck({ healthCheckFailureThreshold: '-1' }))
.toThrow(/--health-check-failure-threshold/);
});
});

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

View File

@@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
opencodeConfigDir,
opencodeSkillsDir,
withOpencodeDir,
installOpencodePlugins,
registerOpencodeTuiPlugin,
readOpencodeState,
writeOpencodeState,
storedToken,
} from '../../src/utils/opencode-settings.js';
import {
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_FILENAME,
} from '../../src/config/opencode-extension.js';
describe('opencodeConfigDir', () => {
it('defaults to ~/.config/opencode', () => {
expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode');
});
it('honours XDG_CONFIG_HOME — provisioning ~/.config would be invisible to opencode', () => {
expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/opencode');
});
it('ignores an empty XDG_CONFIG_HOME rather than resolving against ""', () => {
expect(opencodeConfigDir({ XDG_CONFIG_HOME: '' }, '/home/u')).toBe('/home/u/.config/opencode');
});
it('puts skills where opencode looks for them', () => {
expect(opencodeSkillsDir({}, '/home/u')).toBe('/home/u/.config/opencode/skill');
});
});
describe('installOpencodePlugins', () => {
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('writes the server plugin where opencode auto-discovers it and the TUI plugin beside it', async () => {
const written = await installOpencodePlugins(dir);
const paths = withOpencodeDir(dir);
expect(written).toEqual([paths.serverPluginPath(), paths.tuiPluginPath()]);
expect(existsSync(join(dir, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME))).toBe(true);
expect(existsSync(join(dir, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME))).toBe(true);
});
it('keeps the .tsx extension — opencode transpiles the TUI plugin by extension', async () => {
await installOpencodePlugins(dir);
expect(withOpencodeDir(dir).tuiPluginPath().endsWith('.tsx')).toBe(true);
});
it('is idempotent (re-running overwrites in place)', async () => {
await installOpencodePlugins(dir);
const first = readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8');
await installOpencodePlugins(dir);
expect(readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8')).toBe(first);
});
});
describe('registerOpencodeTuiPlugin', () => {
let dir: string;
let tuiJson: string;
const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx';
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-'));
tuiJson = join(dir, 'tui.json');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('creates tui.json with the plugin and a $schema', async () => {
expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true });
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual([pluginPath]);
expect(parsed.$schema).toBe('https://opencode.ai/tui.json');
});
it('is idempotent and leaves the file untouched on a no-op run', async () => {
await registerOpencodeTuiPlugin(tuiJson, pluginPath);
const before = readFileSync(tuiJson, 'utf-8');
expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: false });
expect(readFileSync(tuiJson, 'utf-8')).toBe(before);
});
it('preserves other TUI plugins and unrelated keys', async () => {
writeFileSync(tuiJson, JSON.stringify({ plugin: ['opencode-tui-utils'], theme: 'nord' }));
await registerOpencodeTuiPlugin(tuiJson, pluginPath);
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual(['opencode-tui-utils', pluginPath]);
expect(parsed.theme).toBe('nord');
});
it('drops a stale entry for an older install location of our own plugin', async () => {
// Left behind, opencode fails to load a file that no longer exists on
// every start.
writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/place/mcpctl-tui.tsx', 'other-plugin'] }));
await registerOpencodeTuiPlugin(tuiJson, pluginPath);
expect(JSON.parse(readFileSync(tuiJson, 'utf-8')).plugin).toEqual(['other-plugin', pluginPath]);
});
it('refuses to overwrite a corrupt tui.json instead of dropping the user’s plugins', async () => {
writeFileSync(tuiJson, '{ this is not json');
await expect(registerOpencodeTuiPlugin(tuiJson, pluginPath)).rejects.toThrow(/not valid JSON/);
expect(readFileSync(tuiJson, 'utf-8')).toBe('{ this is not json');
});
it('treats an empty file as a fresh start', async () => {
writeFileSync(tuiJson, ' \n');
expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true });
});
});
describe('opencode state file', () => {
let dir: string;
let statePath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-'));
statePath = join(dir, 'nested', 'opencode-state.json');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('creates the directory and writes project + gateway + token', async () => {
await writeOpencodeState({ project: 'docmost', gatewayUrl: 'https://gw', token: 'mcpctl_pat_a' }, statePath);
expect(await readOpencodeState(statePath)).toEqual({
project: 'docmost',
gatewayUrl: 'https://gw',
tokens: { docmost: 'mcpctl_pat_a' },
});
});
it('is written 0600 — it holds bearer tokens', async () => {
await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath);
expect(statSync(statePath).mode & 0o777).toBe(0o600);
});
it('keeps other projects’ tokens, so switching back needs no new mint', async () => {
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath);
await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'tok-b' }, statePath);
const state = await readOpencodeState(statePath);
expect(state.project).toBe('b');
expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-b' });
});
it('switching without a new token leaves the stored one alone', async () => {
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath);
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw2' }, statePath);
const state = await readOpencodeState(statePath);
expect(state.gatewayUrl).toBe('https://gw2');
expect(storedToken(state, 'a')).toBe('tok-a');
});
it('reads a missing or corrupt state as empty rather than throwing', async () => {
expect(await readOpencodeState(join(dir, 'nope.json'))).toEqual({});
mkdirSync(join(dir, 'x'), { recursive: true });
writeFileSync(join(dir, 'x', 's.json'), 'not json');
expect(await readOpencodeState(join(dir, 'x', 's.json'))).toEqual({});
});
it('storedToken ignores an empty or missing entry', () => {
expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull();
expect(storedToken({}, 'a')).toBeNull();
expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x');
});
});

View File

@@ -104,3 +104,48 @@ describe('sessionhook', () => {
expect(settings.hooks.SessionStart).toHaveLength(1);
});
});
describe('untagged duplicates of the managed hook', () => {
let tmp2: string;
let settings: string;
beforeEach(async () => {
tmp2 = await mkdtemp(join(tmpdir(), 'mcpctl-hook-dupe-'));
settings = join(tmp2, 'settings.json');
});
afterEach(async () => { await rm(tmp2, { recursive: true, force: true }); });
it('removes an identical row left behind before the marker existed', async () => {
// Exactly the shape found in a real ~/.claude: one tagged row, one not.
// Invisible in the UI; it just runs the sync twice every session.
await writeFile(settings, JSON.stringify({
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet' }] },
{ hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }] },
],
},
}));
const { updated } = await installManagedSessionHook('mcpctl skills sync --quiet', settings);
expect(updated).toBe(true);
const parsed = JSON.parse(await readFile(settings, 'utf-8')) as {
hooks: { SessionStart: Array<{ hooks: Array<Record<string, unknown>> }> };
};
const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks);
expect(rows).toEqual([{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }]);
});
it('leaves a hook the user wrote alone, even one that also calls mcpctl', async () => {
await writeFile(settings, JSON.stringify({
hooks: {
SessionStart: [{ hooks: [{ type: 'command', command: 'mcpctl skills sync --project mine' }] }],
},
}));
await installManagedSessionHook('mcpctl skills sync --quiet', settings);
const parsed = JSON.parse(await readFile(settings, 'utf-8')) as {
hooks: { SessionStart: Array<{ hooks: Array<{ command: string }> }> };
};
const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks).map((r) => r.command);
expect(rows).toContain('mcpctl skills sync --project mine');
});
});

View File

@@ -1,6 +1,13 @@
import { defineProject } from 'vitest/config';
export default defineProject({
// The opencode TUI plugin is a .tsx that ships as source; its pure helpers are
// unit-tested by importing that file directly, so the test run needs the same
// JSX runtime opencode transpiles it with.
esbuild: {
jsx: 'automatic',
jsxImportSource: '@opentui/solid',
},
test: {
name: 'cli',
include: ['tests/**/*.test.ts'],