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