feat(opencode): native opencode integration — /mcpctl switcher, live project switching, footer indicator

Adds `mcpctl config opencode`, two opencode plugins and an `opencode` skills
sync target, so an mcpctl project can be switched from inside opencode's TUI
and the active one is visible at a glance.

Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into
the host's config. opencode exposes an HTTP API for its own MCP registry
(`POST /mcp`), so the project is mounted through the running app:

  - the token stays in ~/.mcpctl/opencode-state.json (0600) instead of a
    mode-0644 opencode.json users paste into bug reports;
  - switching projects takes effect on the next turn, with no restart.

Inside opencode:
  /mcpctl         filterable project picker; switches live
  /mcpctl-status  active project, mount state, gateway URL
  /mcpctl-skills  re-sync this project's skills
  plus a `mcpctl:<project>` indicator in the prompt footer, next to the model
  name and one line above the token counter.

Design notes:
  - the MCP server is registered under a constant name, so tools keep a stable
    `mcpctl_*` prefix and opencode's per-request tool resolution shows the new
    project's tools by itself — no "your old tool names are dead" message to
    the model, unlike the pi extension;
  - an unchanged mount is never re-registered: mcp.add rebuilds the connection
    and mcplocal binds a gated project's unlocked state to that connection's
    mcp-session-id, so re-adding would re-lock a project begin_session had just
    opened;
  - the server plugin does not mount during setup — setup runs before the
    server accepts connections and mcp.add calls back into it, which hangs
    opencode on a blank screen before the TUI draws;
  - the switcher shells out to this CLI (--skip-plugin --skip-marker) so token
    minting, state and skills stay in one place;
  - no usable credential aborts non-zero with the state file untouched, so a
    failed switch leaves the previous project working rather than swapping it
    for a mount that 401s.

`skills sync --agent opencode` installs into ~/.config/opencode/skill (XDG
aware) with the same shared-tree semantics as pi and prime-agent. The
credential plumbing shared with `config prime-agent` is lifted to one place and
parameterised by agent rather than copied.

The plugin sources are embedded in the CLI (generated, freshness-tested) so an
installed binary with no source tree can provision them, and are typechecked
against the real @opencode-ai/plugin types.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
This commit is contained in:
Michal
2026-08-08 21:03:53 +01:00
parent 2513da33c3
commit be2a5cb189
20 changed files with 2858 additions and 128 deletions

View File

@@ -34,6 +34,16 @@ import {
isMcpctlToken,
} from '../config/prime-agent.js';
import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js';
import {
opencodeConfigDir,
opencodeStatePath,
withOpencodeDir,
installOpencodePlugins,
registerOpencodeTuiPlugin,
readOpencodeState,
writeOpencodeState,
storedToken,
} from '../utils/opencode-settings.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
/**
@@ -44,6 +54,9 @@ import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
*/
const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent';
/** Same, for the tokens `config opencode` mints. */
const OPENCODE_TOKEN_PREFIX = 'opencode';
interface McpConfig {
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
}
@@ -76,6 +89,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')
@@ -299,7 +424,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 +434,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 +536,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);
@@ -590,6 +629,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

@@ -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 },

File diff suppressed because one or more lines are too long

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

@@ -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,74 @@
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 opencodes own tree, never Claudes', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'");
});
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'");
});
});

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 callers 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

@@ -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 users 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

@@ -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'],

View File

@@ -0,0 +1,298 @@
/** @jsxImportSource @opentui/solid */
/**
* mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.
*
* Installed by `mcpctl config opencode` into
* `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in
* `~/.config/opencode/tui.json`.
*
* What it adds to opencode:
* - `/mcpctl` — pick the active project from a filterable dialog
* - `/mcpctl-status` — what is mounted, from where, as which user
* - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill
* - a `mcpctl:<project>` indicator in the prompt footer, next to the model
* name and the token counter
*
* The switch itself is delegated to the `mcpctl` CLI (the same binary that
* installed this file), so token minting, state and skills stay in one place
* and this stays a UI shell. Once the CLI has rewritten the state file, the
* mount is re-pointed live through opencode's own MCP API — no restart, unlike
* every config-file-based integration.
*
* Only Node builtins + opencode's plugin API are imported.
*/
import type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';
import type { JSX } from '@opentui/solid';
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
/** MCP server name the mount lives under — must match the server plugin. */
const SERVER_NAME = 'mcpctl';
/**
* kv key holding the label the footer renders.
*
* kv is a reactive store, so writing it here re-renders the slot with no
* signal plumbing of our own; it also survives across sessions, so the label is
* correct on the very first frame instead of after the state file is read.
*/
const KV_LABEL = 'mcpctl.project';
interface OpencodeState {
project?: string;
gatewayUrl?: string;
tokens?: Record<string, string>;
}
interface ProjectInfo {
name: string;
description?: string;
}
function statePath(): string {
return join(homedir(), '.mcpctl', 'opencode-state.json');
}
async function readState(): Promise<OpencodeState> {
try {
return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
} catch {
return {};
}
}
function projectUrl(gatewayUrl: string, project: string): string {
return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;
}
/**
* Run the `mcpctl` CLI and resolve its stdout.
*
* execFile, not a shell: project names come from the server and would otherwise
* need quoting, and a shell buys nothing here.
*/
function mcpctl(args: string[], timeoutMs = 120_000): Promise<string> {
return new Promise((resolve, reject) => {
execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
if (err) reject(new Error((stderr || err.message).trim() || String(err)));
else resolve(stdout);
});
});
}
async function listProjects(): Promise<ProjectInfo[]> {
const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);
const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;
return parsed
.filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')
.map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));
}
/**
* Active project first, then alphabetical.
*
* opencode's select dialog does its own fuzzy filtering as you type, so unlike
* the pi and prime-agent switchers this needs no pre-filter prompt — only a
* sensible starting order, since the most likely pick is "the one I am on".
*
* Exported so the ordering is unit-tested rather than eyeballed through a TUI.
*/
export function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {
return [...projects].sort((a, b) => {
if (a.name === active) return -1;
if (b.name === active) return 1;
return a.name.localeCompare(b.name);
});
}
/** The footer label for a project (or the absence of one). */
export function indicatorLabel(project: string | null): string {
return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';
}
const tui = async (api: TuiPluginApi): Promise<void> => {
/** Re-read the state file and publish the footer label. */
async function refreshIndicator(): Promise<string | null> {
const state = await readState();
const project = state.project ?? null;
api.kv.set(KV_LABEL, indicatorLabel(project));
return project !== null && project !== '' ? project : null;
}
/**
* Point the live MCP mount at `project`.
*
* Registering under the same name every time keeps the tool prefix stable
* (`mcpctl_*`), and because opencode re-resolves tools per request the model
* simply sees the new project's tools on its next turn.
*/
async function mount(project: string, state: OpencodeState): Promise<void> {
const gatewayUrl = state.gatewayUrl;
if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project <name>`');
const token = state.tokens?.[project] ?? '';
const headers: Record<string, string> = {};
if (token !== '') headers['Authorization'] = `Bearer ${token}`;
await api.client.mcp.add({
name: SERVER_NAME,
config: {
type: 'remote',
url: projectUrl(gatewayUrl, project),
headers,
enabled: true,
timeout: 120_000,
},
});
}
async function switchTo(project: string): Promise<void> {
api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });
try {
// The CLI mints/reuses the project token, rewrites the state file and
// syncs skills. --skip-plugin leaves this very file alone (rewriting a
// loaded plugin mid-session buys nothing); --skip-marker stops us
// silently re-scoping whatever repo opencode was started in, which
// Claude Code's own skills sync would then pick up.
await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);
} catch (err) {
api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });
return;
}
try {
await mount(project, await readState());
} catch (err) {
// The state file is already updated, so a restart would recover — say so
// rather than reporting a success the tools do not back up.
api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });
await refreshIndicator();
return;
}
await refreshIndicator();
api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });
}
api.keymap.registerLayer({
commands: [
{
name: 'mcpctl.switch',
title: 'mcpctl: switch project',
description: 'Mount another mcpctl projects MCP servers and skills',
category: 'mcpctl',
namespace: 'palette',
slashName: 'mcpctl',
async run(): Promise<void> {
const active = await refreshIndicator();
let projects: ProjectInfo[];
try {
projects = await listProjects();
} catch (err) {
api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });
return;
}
if (projects.length === 0) {
api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });
return;
}
const DialogSelect = api.ui.DialogSelect;
api.ui.dialog.replace(() => (
<DialogSelect
title={active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project'}
placeholder="type to filter…"
current={active ?? undefined}
options={orderProjects(projects, active).map((p) => ({
title: p.name,
value: p.name,
description: p.description,
}))}
onSelect={(option): void => {
api.ui.dialog.clear();
const picked = option.value;
if (typeof picked !== 'string') return;
if (picked === active) {
api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });
return;
}
void switchTo(picked);
}}
/>
));
},
},
{
name: 'mcpctl.status',
title: 'mcpctl: status',
description: 'Show the active mcpctl project and its MCP mount',
category: 'mcpctl',
namespace: 'palette',
slashName: 'mcpctl-status',
async run(): Promise<void> {
const state = await readState();
const project = await refreshIndicator();
let mcpStatus = 'unknown';
try {
const res = await api.client.mcp.status();
mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';
} catch {
mcpStatus = 'unavailable';
}
const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';
api.ui.toast({
message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,
variant: mcpStatus === 'connected' ? 'success' : 'warning',
duration: 12_000,
});
},
},
{
name: 'mcpctl.skills',
title: 'mcpctl: sync skills',
description: 'Re-sync this projects mcpctl skills into opencodes skill directory',
category: 'mcpctl',
namespace: 'palette',
slashName: 'mcpctl-skills',
async run(): Promise<void> {
const project = await refreshIndicator();
if (project === null) {
api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });
return;
}
try {
await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);
api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });
} catch (err) {
api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });
}
},
},
],
});
// The indicator. `session_prompt_right` and `home_prompt_right` are the only
// slots in the footer cluster opencode exposes to plugins: they render on the
// prompt's bottom line, immediately right of the model name and directly
// above the token counter. (`home_footer` would sit on the counter's line but
// *replaces* the cwd/version footer rather than adding to it, and
// `app_bottom` costs a whole extra terminal row.)
const Indicator = (): JSX.Element => (
<text fg={api.theme.current.textMuted}>{api.kv.get(KV_LABEL, indicatorLabel(null))}</text>
);
api.slots.register({
order: 100,
slots: {
session_prompt_right: () => <Indicator />,
home_prompt_right: () => <Indicator />,
},
});
await refreshIndicator();
};
function errText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export default {
id: 'mcpctl',
tui,
} satisfies TuiPluginModule & { id: string };

View File

@@ -0,0 +1,165 @@
/**
* mcpctl opencode server plugin — mounts the active project's MCP gateway.
*
* Installed by `mcpctl config opencode` into
* `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.
*
* WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:
* 1. The gateway needs an `Authorization: Bearer <mcpctl PAT>` header. Putting
* it in opencode.json means a secret in a mode-0644 config file that users
* paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest
* of mcpctl's credentials.
* 2. Switching projects has to work *without restarting opencode*. The server
* exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can
* be re-pointed live — a config file can't do that.
*
* The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that
* headless runs (`opencode run ...`), which load no TUI plugins at all, still
* get the active project's tools.
*
* Only Node builtins + the plugin API are imported, so the installed file needs
* no dependencies of its own.
*/
import type { Plugin, PluginModule } from '@opencode-ai/plugin';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
/** MCP server name we mount under. Constant on purpose — see `mount`. */
const SERVER_NAME = 'mcpctl';
interface OpencodeState {
project?: string;
gatewayUrl?: string;
tokens?: Record<string, string>;
}
function statePath(): string {
return join(homedir(), '.mcpctl', 'opencode-state.json');
}
async function readState(): Promise<OpencodeState> {
try {
return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
} catch {
return {};
}
}
/** Proxy MCP URL for a project on the gateway. */
function projectUrl(gatewayUrl: string, project: string): string {
return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;
}
const server: Plugin = async ({ client }) => {
/**
* The (url, token) this process last registered.
*
* Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal
* binds a gated project's unlocked state to the `mcp-session-id` of that
* connection. Re-adding an unchanged config every turn would therefore drop
* the gate open by `begin_session` and re-lock the project mid-conversation.
* So we only call `add` when the target actually changed — or when the mount
* is not connected, where reconnecting is the whole point.
*/
let mounted: string | null = null;
/**
* Mount (or re-point) the active project.
*
* The MCP server is always registered under the same name, so tools keep the
* stable `mcpctl_*` prefix across switches and the model never sees a tool
* namespace vanish mid-conversation. opencode resolves the tool list per
* request, so a re-point is picked up on the next turn with no restart and no
* "the tools you were told about are gone" announcement to the model.
*/
async function mount(): Promise<void> {
const state = await readState();
const project = state.project;
const gatewayUrl = state.gatewayUrl;
if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;
const token = state.tokens?.[project] ?? '';
const url = projectUrl(gatewayUrl, project);
const target = `${url}\u0000${token}`;
if (mounted === target && (await isConnected())) return;
const headers: Record<string, string> = {};
if (token !== '') headers['Authorization'] = `Bearer ${token}`;
await client.mcp.add({
body: {
name: SERVER_NAME,
config: {
type: 'remote',
url,
headers,
enabled: true,
timeout: 120_000,
},
},
});
mounted = target;
}
/** Is our mount currently up? Unknown/unreachable counts as "not connected". */
async function isConnected(): Promise<boolean> {
try {
const res = await client.mcp.status();
return res.data?.[SERVER_NAME]?.status === 'connected';
} catch {
return false;
}
}
/**
* `mount`, serialised and never throwing.
*
* Serialised because the two callers below can overlap — the event stream is
* chatty and a message can land while a mount is still connecting — and two
* concurrent `mcp.add` calls would race to register the same name.
*
* Never throwing because an unreachable gateway must degrade to "no mcpctl
* tools", not to "opencode fails to start".
*/
let inflight: Promise<void> | null = null;
function ensureMounted(): Promise<void> {
inflight ??= mount()
.catch(() => { /* best-effort */ })
.finally(() => { inflight = null; });
return inflight;
}
// NOTE: deliberately NOT mounted here. Plugin setup runs before the server is
// accepting connections, and `client.mcp.add` calls back into that same
// server — awaiting it at this point hangs opencode on a blank screen before
// the TUI ever draws. Both hooks below fire only once the server is live.
return {
/**
* First contact: mount as soon as the server is up, so a session that never
* sends a message still shows the project's tools (and the sidebar shows
* the mount as connected).
*/
event: async (): Promise<void> => {
await ensureMounted();
},
/**
* Re-assert the mount before every user turn.
*
* `mcpctl config opencode --project X` (run from a shell, or by the TUI
* switcher in a *different* opencode window) rewrites the state file
* underneath us. Re-reading here is what makes an external switch take
* effect on the next message instead of on the next restart. When nothing
* changed this is a state-file read and a status call — `mount` will not
* re-register a mount that is already pointing at the right place.
*/
'chat.message': async (): Promise<void> => {
await ensureMounted();
},
};
};
export default {
id: 'mcpctl',
server,
} satisfies PluginModule & { id: string };

View File

@@ -0,0 +1,28 @@
{
"//": [
"The opencode plugins are shipped as source (embedded in the CLI, then",
"written into ~/.config/opencode/) and are therefore never compiled by the",
"CLI's own build. Without this project they would be typechecked by nothing.",
"",
"They are checked against the REAL @opencode-ai/plugin types (a dev",
"dependency, pinned to the opencode release they target) rather than a",
"hand-written shim, because a shim drifting from the published API is the",
"exact failure mode this guards against — see src/pi-ext/tsconfig.json."
],
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": false,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"files": ["mcpctl-opencode.ts", "mcpctl-opencode-tui.tsx"]
}