From e2a1d907932e30bd707e2eb74cb7eeb640b8626d Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 21:23:12 +0100 Subject: [PATCH] feat(opencode): add mcpctl config opencode + /mcpctl TUI switcher with bottom project indicator --- README.md | 43 ++++ completions/mcpctl.bash | 5 +- completions/mcpctl.fish | 14 +- docs/opencode-extension.md | 123 +++++++++++ package.json | 3 +- scripts/generate-opencode-extension.ts | 38 ++++ src/cli/src/commands/config.ts | 165 ++++++++++++++ src/cli/src/commands/skills.ts | 44 ++-- src/cli/src/config/opencode-extension.ts | 12 ++ src/cli/src/config/opencode.ts | 223 +++++++++++++++++++ src/cli/src/utils/opencode-skills.ts | 51 +++++ src/cli/tests/commands/opencode.test.ts | 167 ++++++++++++++ src/cli/tests/commands/skills.test.ts | 22 +- src/cli/tests/config/opencode-embed.test.ts | 30 +++ src/cli/tests/utils/opencode-skills.test.ts | 12 ++ src/opencode-ext/mcpctl-opencode.tsx | 228 ++++++++++++++++++++ 16 files changed, 1154 insertions(+), 26 deletions(-) create mode 100644 docs/opencode-extension.md create mode 100644 scripts/generate-opencode-extension.ts create mode 100644 src/cli/src/config/opencode-extension.ts create mode 100644 src/cli/src/config/opencode.ts create mode 100644 src/cli/src/utils/opencode-skills.ts create mode 100644 src/cli/tests/commands/opencode.test.ts create mode 100644 src/cli/tests/config/opencode-embed.test.ts create mode 100644 src/cli/tests/utils/opencode-skills.test.ts create mode 100644 src/opencode-ext/mcpctl-opencode.tsx diff --git a/README.md b/README.md index 28d99d1..a29cd84 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,49 @@ Preview the change without writing anything: mcpctl config prime-agent --project monitoring --dry-run ``` + +### Connect opencode + +[opencode](https://opencode.ai) consumes MCP natively and loads Agent Skills +`SKILL.md` trees, but — unlike Claude — it has no `.mcp.json` convention. This +addon wires a project's MCP stdio bridge into opencode's global config and adds +a small TUI plugin so you can switch mcpctl projects from inside opencode: + +```bash +mcpctl config opencode --project monitoring +``` + +This: + +1. Registers the project's MCP **stdio bridge** in + `~/.config/opencode/opencode.json` as + `mcp.servers.monitoring = { "type": "local", "command": ["mcpctl","mcp","-p","monitoring"] }` + (merging with any existing servers/settings), and unmounts the previously + active mcpctl project so exactly one is live. Hand-configured servers are + never touched. +2. Installs a `/mcpctl` TUI plugin into + `~/.config/opencode/plugin/mcpctl-switch.tsx` and registers it in + opencode.json's `plugin` array (a `.tsx` plugin isn't auto-discovered — + opencode globs `.ts`/`.js` only). Skip with `--skip-plugin`. +3. Persists the active project to `~/.mcpctl/opencode-state.json`. +4. Writes a `.mcpctl-project` marker (unless running from `$HOME`; skip with + `--skip-marker`). +5. Syncs the project's skills into `~/.config/opencode/skills//`, the + tree opencode reads for Agent Skills. + +The plugin shows the **current project in the bottom status bar** (next to +where opencode prints the model and token counter) and gives you a +**`/mcpctl`** slash command — a filterable project picker that switches and lets +opencode's config watcher reload MCP. Re-sync skills later with: + +```bash +mcpctl skills sync --agent opencode --project monitoring +``` + +A custom config dir is fine: `mcpctl config opencode --project monitoring --config-dir ~/.config/opencode-custom`. +See `docs/opencode-extension.md` for details. + + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 0e2be01..8da41d6 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -103,7 +103,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate prime-agent prime-agent-generate opencode setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -130,6 +130,9 @@ _mcpctl() { prime-agent-generate) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; + opencode) + COMPREPLY=($(compgen -W "-p --project --config-dir --state --skip-skills --skip-plugin --skip-marker --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 325719f..622b2fa 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -239,7 +239,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; prime-agent/opencode with --agent)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -267,7 +267,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate prime-agent prime-agent-generate setup impersonate +set -l config_cmds view set path reset claude claude-generate prime-agent prime-agent-generate opencode setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' @@ -276,6 +276,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a opencode -d 'Register the mcpctl project MCP bridge + /mcpctl switcher + skills for opencode (~/.config/opencode)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -316,6 +317,15 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l sk complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print what would change without writing or syncing' +# config opencode options +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l config-dir -d 'opencode global config dir (default: ~/.config/opencode)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l state -d 'path to persist the active-project state (default: ~/.mcpctl/opencode-state.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-plugin -d 'Do not install/register the /mcpctl TUI plugin' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l dry-run -d 'Print what would change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md new file mode 100644 index 0000000..216c119 --- /dev/null +++ b/docs/opencode-extension.md @@ -0,0 +1,123 @@ +# mcpctl × opencode — native integration (TUI plugin + MCP bridge) + +## Motivation + +With Claude Code, `mcpctl config claude --project X` wires a project's MCP +servers into the agent two ways: + +1. a `.mcp.json` entry running `mcpctl mcp -p X` (an MCP **stdio bridge**), and +2. a `SessionStart` hook + `mcpctl skills sync` that materialises server-side + **skills** under `~/.claude/skills/`. + +**opencode** speaks MCP natively (like Claude), consumes Agent Skills `SKILL.md` +trees (like pi), and lets plugins render into its TUI. This addon mirrors the +prime-agent/pi integration so mcpctl "just works" from inside opencode: + +- `mcpctl config opencode --project X` registers the project's MCP **stdio + bridge** (`mcpctl mcp -p X`) as an opencode `local` MCP server, installs a + small **TUI plugin**, and syncs the project's skills. +- The TUI plugin gives you a `/mcpctl` slash command to **switch projects** from + the opencode prompt, and shows the **current project in the bottom status bar** + — next to where opencode prints the model and the token counter — so you always + know which project's MCP servers are live. + +## How it works + +### MCP over stdio +opencode has native MCP client support, so unlike pi there is no JSON-RPC client +to vendor. `mcpctl config opencode --project X` writes into opencode's global +config (`~/.config/opencode/opencode.json`): + +```json +{ + "mcp": { + "servers": { + "homeautomation": { + "type": "local", + "command": ["mcpctl", "mcp", "-p", "homeautomation"], + "mcpctlManaged": true + } + } + } +} +``` + +`mcpctlManaged` is a tag opencode ignores (its schema tolerates unknown keys); +mcpctl uses it to recognise — and retire — the single *active* entry when you +switch, so exactly one mcpctl project is mounted at a time and hand-configured +servers are never touched. + +### The TUI plugin (footer indicator + /mcpctl) +opencode loads plugins from `~/.config/opencode/plugin/`, but its auto-discovery +glob is `*.{ts,js}` — it won't pick up a JSX `.tsx` file. So `config opencode` +writes `plugin/mcpctl-switch.tsx` **and** references it from `opencode.json`'s +`plugin` array as a relative path: + +```json +{ + "plugin": ["./plugin/mcpctl-switch.tsx"] +} +``` + +The plugin is a **TUI plugin** (module `default` exports `{ id, tui }`). It: + +- registers a `/mcpctl` slash command (surfaces in the prompt's `/` autocomplete) + that opens a **filterable** project picker (opencode's `DialogSelect` filters + as you type), and +- renders the active project in the **`app_bottom`** slot — the bottom strip + where opencode shows the model and token counter. + +Switching runs the CLI: + +``` +mcpctl config opencode --project --skip-plugin --skip-marker --config-dir +``` + +which rewrites `opencode.json` (section above). opencode's config watcher +reloads MCP so the new project's tools come up — if a running session doesn't +pick it up, a restart does. + +## Deliverables + +| Artifact | Purpose | +|----------|---------| +| `src/opencode-ext/mcpctl-opencode.tsx` | Real, reviewable TUI plugin source | +| `scripts/generate-opencode-extension.ts` | Embeds that source into the CLI (`config/opencode-extension.ts`) | +| `src/cli/src/config/opencode.ts` | opencode.json read/merge/write + state helpers | +| `mcpctl config opencode` | CLI wiring: MCP + plugin + state + marker + skills | +| `mcpctl skills sync --agent opencode` | Sync skills into `~/.config/opencode/skills/` | +| `docs/opencode-extension.md` | This document | + +## Regenerating the embedded plugin + +The plugin ships as *source* embedded into the CLI, so an installed binary can +still provision a working addon. After editing `src/opencode-ext/*.tsx`, +regenerate the embedded copy or the CLI will keep installing the old source: + +```bash +npx tsx scripts/generate-opencode-extension.ts +``` + +A test (`tests/config/opencode-embed.test.ts`) fails if the embed drifts from +the source. + +## Skills + +`mcpctl config opencode` (and `mcpctl skills sync --agent opencode`) materialise +the active project's skills under `~/.config/opencode/skills//`, the tree +opencode reads for Agent Skills. Sync state is tracked separately +(`~/.mcpctl/skills-state-opencode.json`), so opencode, prime-agent and Claude +never fight over bookkeeping. + +## Switching with `--config-dir` + +The `/mcpctl` plugin shells out with `--config-dir` pointing at the config dir it +was installed into, so a non-default opencode config dir keeps working too. + +## Project switching / auth + +Like the Claude path, this uses the **stdio bridge** (`mcpctl mcp -p X`) and a +locally-running `mcplocal` daemon, so there is no HTTP gateway / bearer-token +provisioning dance (that's specific to prime-agent's remote gateway). Pointing it +at an authenticated `mcplocal serve` needs a project mcptoken wired through +`mcpctl mcp` — outside this addon's scope for now. diff --git a/package.json b/package.json index a238b35..5a16755 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "mcpd:build": "bash scripts/build-mcpd.sh", "mcpd:deploy": "bash deploy.sh", "mcpd:deploy-dry": "bash deploy.sh --dry-run", - "mcpd:logs": "bash logs.sh" + "mcpd:logs": "bash logs.sh", + "generate:opencode-extension": "tsx scripts/generate-opencode-extension.ts" }, "engines": { "node": ">=20.0.0", diff --git a/scripts/generate-opencode-extension.ts b/scripts/generate-opencode-extension.ts new file mode 100644 index 0000000..2db34b1 --- /dev/null +++ b/scripts/generate-opencode-extension.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/opencode-extension.ts`, embedding the opencode + * TUI plugin source as a string constant so `mcpctl config opencode` keeps + * working from an installed binary with no source tree. + * + * Regenerate after editing the plugin source: + * npx tsx scripts/generate-opencode-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const src = readFileSync(join(root, 'src', 'opencode-ext', 'mcpctl-opencode.tsx'), 'utf-8'); + +function embed(s: string) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(s); +} + +const out = `/** + * Embedded source of the mcpctl opencode plugin — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-opencode-extension.ts\` from + * \`src/opencode-ext/mcpctl-opencode.tsx\`. + * + * \`mcpctl config opencode\` writes it verbatim into the opencode plugin dir and + * registers it in opencode.json, so an installed binary with no source tree can + * still provision a working opencode integration. + */ +export const OPENCODE_PLUGIN_FILENAME = 'mcpctl-switch.tsx' as const; + +export const OPENCODE_PLUGIN = ${embed(src)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out); +console.log('wrote src/cli/src/config/opencode-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 140803f..01b7a3d 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -23,6 +23,19 @@ import { } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; +import { + opencodeConfigDir, + opencodeConfigPath, + opencodePluginDir, + opencodeStatePath, + opencodeSkillsDir, + opencodePluginSpec, + registerOpencodeMcp, + registerOpencodePlugin, + writeOpencodeState, +} from '../config/opencode.js'; +import { OPENCODE_PLUGIN, OPENCODE_PLUGIN_FILENAME } from '../config/opencode-extension.js'; +import { runOpencodeSkillsSync } from '../utils/opencode-skills.js'; /** * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint @@ -493,6 +506,158 @@ export function createConfigCommand(deps?: Partial, apiDeps?: registerPrimeAgentCommand('prime-agent', false); registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + // ── opencode: register our proxy MCP stdio bridge + /mcpctl TUI plugin ── + config + .command('opencode') + .description('Register the mcpctl project MCP bridge + /mcpctl switcher + skills for opencode (~/.config/opencode)') + .option('-p, --project ', 'Project name to make active') + .option('--config-dir ', 'opencode global config dir (default: ~/.config/opencode)') + .option('--state ', 'path to persist the active-project state (default: ~/.mcpctl/opencode-state.json)') + .option('--skip-skills', 'Skip the skills sync step') + .option('--skip-plugin', 'Do not install/register the /mcpctl TUI plugin') + .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; + configDir?: string; + state?: string; + skipSkills?: boolean; + skipPlugin?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required'); + process.exitCode = 1; + return; + } + + const configDir = opts.configDir !== undefined && opts.configDir.length > 0 + ? resolve(opts.configDir) + : opencodeConfigDir(); + const configPath = opencodeConfigPath(configDir); + const pluginPath = join(opencodePluginDir(configDir), OPENCODE_PLUGIN_FILENAME); + const statePath = opts.state !== undefined && opts.state.length > 0 + ? resolve(opts.state) + : opencodeStatePath(); + const skillsInstall = opencodeSkillsDir(configDir); + + if (opts.dryRun === true) { + const dry = JSON.stringify({ + opencode: { + configDir, + configPath, + mcpServers: { [opts.project]: { + type: 'local', + command: ['mcpctl', 'mcp', '-p', opts.project], + mcpctlManaged: true, + } }, + plugin: opts.skipPlugin === true ? '' : opencodePluginSpec(), + state: statePath, + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + }, + action: 'write opencode.json (mcp.servers + plugin array) + install plugin source + write state + sync skills', + }, null, 2); + log(dry); + return; + } + + // 1. Install the /mcpctl TUI plugin source (auto-discovery is .ts/.js + // only, so a .tsx plugin is referenced explicitly from opencode.json). + try { + if (opts.skipPlugin === true) { + log('Skipped /mcpctl plugin install (--skip-plugin)'); + } else { + mkdirSync(opencodePluginDir(configDir), { recursive: true }); + writeFileSync(pluginPath, OPENCODE_PLUGIN, 'utf-8'); + log(`Installed /mcpctl plugin: ${pluginPath}`); + } + } catch (err: unknown) { + log(`Warning: failed to install /mcpctl plugin: ${err instanceof Error ? err.message : String(err)}`); + } + + // 2. Register the project's MCP bridge in opencode.json (merge; never + // destroy other servers) and reference the plugin so it loads. + try { + const reg = await registerOpencodeMcp(opts.project, configPath); + log(reg.created + ? `Created ${configPath} and registered '${reg.addedServer}' MCP bridge` + : `Registered '${reg.addedServer}' MCP bridge in ${configPath} (${String(reg.totalServers)} server(s) total)`); + if (reg.removed.length > 0) { + log(`Unmounted previously active mcpctl project(s): ${reg.removed.join(', ')}`); + } + if (opts.skipPlugin === true) { + log('Skipped /mcpctl plugin registration (--skip-plugin)'); + } else { + const added = await registerOpencodePlugin(configPath, opencodePluginSpec()); + log(added + ? `Registered /mcpctl plugin in ${configPath} (${opencodePluginSpec()})` + : `Plugin already registered in ${configPath}`); + } + } catch (err: unknown) { + log(`Error: failed to write ${configPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // 3. Persist the active project so the footer / subsequent switch reads + // one source of truth that survives cwd changes. + try { + const p = await writeOpencodeState(opts.project, statePath); + log(`Wrote active project to ${p}`); + } catch (err: unknown) { + log(`Warning: could not write active-project state: ${err instanceof Error ? err.message : String(err)}`); + } + + // 4. Write the .mcpctl-project marker so other mcpctl commands (skills + // sync, etc) resolve the project. `--skip-marker` opts out (the + // /mcpctl switcher uses it so it never re-scopes 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 === opts.project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.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 skills tree (skippable). Best-effort: + // MCP (steps 1–2) is what determines whether the switch succeeded. + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runOpencodeSkillsSync( + { project: opts.project, installRoot: skillsInstall }, + { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0 || result.errors.length > 0) { + log(`Opencode skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`); + } + } catch (err: unknown) { + log(`Warning: opencode 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)'); + } + } + + log(''); + log('Next: restart opencode (or create a new session) — the mcpctl MCP server for the'); + log(`active project will be mounted. Use /mcpctl in the opencode prompt to switch projects.`); + }); + config.addCommand(createConfigSetupCommand({ configDeps })); if (apiDeps) { diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index f0f9770..86af284 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -103,7 +103,7 @@ export interface SyncOpts { * first project's skills, and pre-existing * (untracked) skill dirs are preserved. */ - target?: 'claude' | 'prime-agent'; + target?: 'claude' | 'prime-agent' | 'opencode'; } export interface SyncResult { @@ -191,23 +191,31 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise (s.scope === 'global' ? null : (projectName ?? null)); - const statePath = opts.statePath ?? (isPrimeAgent + const statePath = opts.statePath ?? (target === 'prime-agent' ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') - : defaultStatePath()); + : target === 'opencode' + ? join(homeDir, '.mcpctl', 'skills-state-opencode.json') + : defaultStatePath()); 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 + const installRoot = opts.installRoot ?? (target === 'prime-agent' ? join(homeDir, '.prime', 'agent', 'skills') - : join(homeDir, '.claude', 'skills')); + : target === 'opencode' + ? join(homeDir, '.config', 'opencode', 'skills') + : join(homeDir, '.claude', 'skills')); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -248,7 +256,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise so // each skill's hooks can be cleanly added/updated/removed without // trampling other skills or user-added hooks. No-op when absent. - if (!isPrimeAgent) { + if (!sharedFlatTree) { if (meta.hooks && typeof meta.hooks === 'object') { try { const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); @@ -422,7 +430,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 @@ -524,7 +532,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise)); }; - const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; prime-agent with --agent prime-agent)'); + const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; prime-agent/opencode with --agent)'); cmd.command('sync') .description('Sync skills from mcpd onto disk (~/.claude/skills/ or ~/.prime/agent/skills/)') @@ -571,8 +579,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') { - warn(`mcpctl: unknown sync target '${agent}' (expected 'claude' or 'prime-agent')`); + if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'opencode') { + warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent' or 'opencode')`); process.exitCode = 1; return; } @@ -584,7 +592,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', + target: agent as 'claude' | 'prime-agent' | 'opencode', }, { client, log, warn }, ); diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts new file mode 100644 index 0000000..79549a6 --- /dev/null +++ b/src/cli/src/config/opencode-extension.ts @@ -0,0 +1,12 @@ +/** + * Embedded source of the mcpctl opencode plugin — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-opencode-extension.ts` from + * `src/opencode-ext/mcpctl-opencode.tsx`. + * + * `mcpctl config opencode` writes it verbatim into the opencode plugin dir and + * registers it in opencode.json, so an installed binary with no source tree can + * still provision a working opencode integration. + */ +export const OPENCODE_PLUGIN_FILENAME = 'mcpctl-switch.tsx' as const; + +export const OPENCODE_PLUGIN = "/**\n * mcpctl × opencode — project switcher + footer indicator.\n *\n * Installed by `mcpctl config opencode` into ~/.config/opencode/plugin/ and\n * wired into opencode.json's `plugin` array. It is a **TUI plugin**: it\n * registers a `/mcpctl` slash command to switch the active mcpctl project from\n * inside the opencode interface, and shows the *current* project in the bottom\n * status bar (the same strip where opencode shows the model and token counter),\n * so you can see at a glance which project's MCP servers are live.\n *\n * Loaded from opencode.json's `plugin` array (as a `.tsx` path plugin), because\n * opencode's auto-discovery glob is `.ts`/`.js` only and this file uses JSX.\n *\n * How switching works\n * The switch itself is performed by the `mcpctl` CLI\n * (`mcpctl config opencode --project `), which rewrites opencode.json's\n * `mcp.servers` to mount that project's MCP stdio bridge and unmount the\n * previous one. Keeping the machinery in the CLI means this UI shell stays in\n * lock-step with the rest of the mcpctl repo. After the switch, opencode's\n * config watcher reloads MCP, so the new project's tools come up without an\n * app restart.\n *\n * The MCP bridge itself is a plain opencode `local` MCP server:\n * mcpctl mcp -p \n * — the same stdio bridge `mcpctl config claude` wires into .mcp.json.\n *\n * Only imports public opencode TUI plugin pieces + Node builtins, and ships as\n * source (embedded into the CLI, then written into the plugin dir), so an\n * installed binary with no source tree can still provision a working addon.\n */\nimport { createSignal } from \"solid-js\";\nimport { join } from \"node:path\";\nimport { readFile } from \"node:fs/promises\";\nimport { execFile } from \"node:child_process\";\nimport type { TuiPluginApi, TuiDialogSelectOption } from \"@opencode-ai/plugin/tui\";\n\n/** Key used to tag the mcpctl-managed MCP server in opencode.json. */\nconst MANAGED_KEY = \"mcpctlManaged\";\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\n// ── opencode.json helpers ────────────────────────────────────────────────────\n/** Resolve the opencode global config dir (honours OPENCODE_CONFIG_DIR, else ~/.config/opencode). */\nfunction configDirOf(api: TuiPluginApi): string {\n const fromEnv = process.env.OPENCODE_CONFIG_DIR;\n if (fromEnv && fromEnv.length > 0) return fromEnv;\n return join(api.state.path.home, \".config\", \"opencode\");\n}\n\n/** The project the config dir's opencode.json is currently scoped to, or null. */\nasync function readActiveProject(configDir: string): Promise {\n try {\n const raw = await readFile(join(configDir, \"opencode.json\"), \"utf-8\");\n const parsed = JSON.parse(raw) as { mcp?: { servers?: Record> } };\n const servers = parsed?.mcp?.servers;\n if (!servers || typeof servers !== \"object\") return null;\n\n // A tagged entry is authoritative — mcpctl writes exactly one.\n for (const [name, entry] of Object.entries(servers)) {\n if (entry && typeof entry === \"object\" && entry[MANAGED_KEY] === true) return name;\n }\n // Fall back to any server whose command runs `mcpctl mcp -p `.\n for (const [name, entry] of Object.entries(servers)) {\n const command = entry && typeof entry === \"object\" ? entry[\"command\"] : undefined;\n if (\n Array.isArray(command) &&\n command[0] === \"mcpctl\" &&\n command.includes(\"mcp\") &&\n command.includes(\"-p\") &&\n command[command.indexOf(\"-p\") + 1] === name\n ) {\n return name;\n }\n }\n return null;\n } catch {\n return null;\n }\n}\n\n// ── CLI bridge ───────────────────────────────────────────────────────────────\nfunction mcpctl(...args: string[]): Promise {\n return new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => {\n if (err) reject(new Error((err as Error & { stderr?: string }).stderr?.trim() || (err as Error).message));\n else resolve(stdout || \"\");\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(\"get\", \"projects\", \"-o\", \"json\");\n const parsed = JSON.parse(out || \"[]\") as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === \"string\").map((p) => ({ name: p.name as string, description: p.description }));\n}\n\n/**\n * The project picker. opencode's DialogSelect has a built-in type-to-filter,\n * so real installs (hundreds of projects) stay usable without us reinventing\n * one.\n */\nfunction Picker(props: {\n api: TuiPluginApi;\n configDir: string;\n projects: ProjectInfo[];\n active: string | null;\n onDone: () => void;\n}) {\n const { api, configDir, projects, active, onDone } = props;\n const [busy, setBusy] = createSignal(false);\n const [current, setCurrent] = createSignal(active ?? undefined);\n\n const options = (): TuiDialogSelectOption[] =>\n projects.map((p) => ({\n title: p.description ? `${p.name} — ${p.description}` : p.name,\n value: p.name,\n description: p.name === active ? \"(current)\" : undefined,\n disabled: p.name === active,\n }));\n\n async function doSwitch(name: string) {\n if (busy()) return;\n if (name === active) {\n api.ui.toast({ variant: \"info\", message: `Already on mcpctl project '${name}'` });\n return;\n }\n setBusy(true);\n try {\n await mcpctl(\"config\", \"opencode\", \"--project\", name, \"--skip-plugin\", \"--skip-marker\", \"--config-dir\", configDir);\n } catch (err) {\n api.ui.toast({ variant: \"error\", message: `mcpctl: switch to '${name}' failed — ${(err as Error).message}` });\n setBusy(false);\n return;\n }\n setBusy(false);\n onDone();\n api.ui.toast({ variant: \"success\", message: `Switched to mcpctl project '${name}'.` });\n }\n\n return (\n opt && setCurrent(opt.value)}\n onSelect={(opt) => opt && void doSwitch(opt.value)}\n skipFilter={false}\n />\n );\n}\n\n/** Footer indicator — the project name shown next to the model / token strip. */\nfunction Indicator(props: { api: TuiPluginApi; active: () => string | null }) {\n const theme = () => props.api.theme.current;\n return (\n \n {props.active() ? `mcpctl:${props.active()}` : \"mcpctl:off\"}\n \n );\n}\n\n// ── the plugin ────────────────────────────────────────────────────────────────\nexport default {\n id: \"mcpctl\",\n tui: async (api: TuiPluginApi) => {\n const configDir = configDirOf(api);\n const [active, setActive] = createSignal(null);\n\n async function refresh() {\n setActive(await readActiveProject(configDir));\n }\n\n // Keep the footer in sync: on load, then poll (so an external\n // `mcpctl config opencode` is reflected without an app restart).\n void refresh();\n const timer = setInterval(() => void refresh(), 10_000);\n api.lifecycle.onDispose(() => clearInterval(timer));\n\n function showPicker() {\n api.ui.dialog.replace(() => (\n \n mcpctl — loading projects…\n \n ));\n listProjects()\n .then((projects) => {\n api.ui.dialog.replace(() => (\n void refresh()} />\n ));\n })\n .catch((err) => {\n api.ui.toast({ variant: \"error\", message: `mcpctl: could not list projects — ${(err as Error).message}` });\n api.ui.dialog.clear();\n });\n }\n\n // /mcpctl slash command (surfaces in the prompt's `/` autocomplete).\n api.keymap.registerLayer({\n commands: [\n {\n name: \"mcpctl.switch\",\n title: \"Switch mcpctl project\",\n desc: \"Switch the active mcpctl project (MCP) from the opencode interface\",\n category: \"mcpctl\",\n namespace: \"palette\",\n slashName: \"mcpctl\",\n run: () => showPicker(),\n },\n ],\n bindings: [],\n });\n\n // Bottom status bar: the project name sits alongside the model and the\n // token counter.\n api.slots.register({\n order: 100,\n slots: {\n app_bottom() {\n return ;\n },\n },\n });\n },\n};\n"; diff --git a/src/cli/src/config/opencode.ts b/src/cli/src/config/opencode.ts new file mode 100644 index 0000000..8f5222f --- /dev/null +++ b/src/cli/src/config/opencode.ts @@ -0,0 +1,223 @@ +/** + * Read/merge/write helpers for opencode's own configuration, used by + * `mcpctl config opencode`. + * + * opencode keeps its (global) configuration under `~/.config/opencode/`: + * - `opencode.json` — `mcp.servers` entries (`{ type: "local", command: + * ["mcpctl","mcp","-p",] }`), plus providers, plugins, etc. This + * is where we register our project MCP **stdio bridge** — the exact same + * bridge `config claude` wires into `.mcp.json`, which opencode consumes + * natively (unlike prime-agent there is no HTTP gateway/auth dance). + * - `plugin/` — opencode auto-loads TUI/server plugins from here. A `.tsx` + * file is *not* auto-globbed (opencode scans `.ts`/`.js` only), so we also + * add it to `opencode.json`'s `plugin` array as a relative path. + * - `skills/` — opencode loads Agent Skills `SKILL.md` trees from here. + * + * Safety invariants (mirror the prime-agent helpers): + * - We only ever *merge* `mcp.servers` and `plugin`, preserving every other + * key, server and plugin the user already configured. + * - A corrupt `opencode.json` fails loudly instead of being rewritten (which + * would destroy every other setting). + * - Exactly one mcpctl project is active at a time. We tag the entry we own + * with `mcpctlManaged: true` (opencode ignores unknown keys), and on a new + * `--project` we unmount any *other* entry carrying that tag — never a + * hand-configured server. + */ +import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +/** Name of the opencode TUI plugin we install. */ +export const OPENCODE_PLUGIN_FILENAME = 'mcpctl-switch.tsx'; + +/** Resolve the opencode global config dir. */ +export function opencodeConfigDir(homeDir: string = homedir()): string { + return join(homeDir, '.config', 'opencode'); +} + +/** Resolve `opencode.json` under a config dir. */ +export function opencodeConfigPath(configDir: string): string { + return join(configDir, 'opencode.json'); +} + +/** Resolve the opencode plugin dir under a config dir. */ +export function opencodePluginDir(configDir: string): string { + return join(configDir, 'plugin'); +} + +/** Resolve the skills tree opencode reads (SKILL.md trees) under a config dir. */ +export function opencodeSkillsDir(configDir: string): string { + return join(configDir, 'skills'); +} + +/** Path we persist the single active mcpctl project under (survives reboots). */ +export function opencodeStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'opencode-state.json'); +} + +/** Relative plugin spec we write into opencode.json's `plugin` array. */ +export function opencodePluginSpec(): string { + return `./plugin/${OPENCODE_PLUGIN_FILENAME}`; +} + +export interface OpencodeConfig { + mcp?: { servers?: Record> }; + plugin?: Array]>; + [key: string]: unknown; +} + +/** + * Load opencode.json. + * - Missing file → `{}` (a brand-new file about to be created). + * - Unreadable/corrupt → throws, so the caller refuses to overwrite it. + */ +export async function loadOpencodeConfig(path: string): Promise { + 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 OpencodeConfig; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error(`config file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`); + } +} + +/** The opencode `local` MCP server that bridges to `mcpctl mcp -p `. */ +export function mcpLocalServerFor(project: string): Record { + return { + type: 'local', + command: ['mcpctl', 'mcp', '-p', project], + // opencode ignores unknown keys; we use it to recognise (and retire) the + // entries this CLI installs when switching projects. + mcpctlManaged: true, + }; +} + +/** + * Is the `mcp.servers` entry named `name` one that mcpctl installed? + * Either the `mcpctlManaged` tag, or a command that runs `mcpctl mcp -p `. + */ +export function isMcpctlManagedEntry(name: string, entry: unknown): boolean { + if (entry === null || typeof entry !== 'object') return false; + const rec = entry as Record; + if (rec['mcpctlManaged'] === true) return true; + const command = rec['command']; + if (!Array.isArray(command)) return false; + const p = command.indexOf('-p'); + return command[0] === 'mcpctl' && command.includes('mcp') && p >= 0 && command[p + 1] === name; +} + +export interface RegisterMcpResult { + configPath: string; + created: boolean; // true if opencode.json did not previously exist + addedServer: string; + newServer: boolean; // true if the project's MCP entry was not already present + totalServers: number; + /** Previously-managed mcpctl project entries removed so `addedServer` is the sole active one. */ + removed: string[]; +} + +/** + * Merge the mcpctl stdio MCP entry for `project` into opencode.json, keeping + * the single-active-project invariant. Preserves every other setting/server. + */ +export async function registerOpencodeMcp(project: string, configPath: string): Promise { + const existed = await pathExists(configPath); + const config = await loadOpencodeConfig(configPath); + const servers = + config.mcp && config.mcp.servers !== undefined && config.mcp.servers !== null && typeof config.mcp.servers === 'object' + ? config.mcp.servers + : {}; + if (config.mcp !== undefined && config.mcp !== null && typeof config.mcp !== 'object') { + throw new Error(`invalid mcp block in ${configPath} — refusing to overwrite it`); + } + + const existing = servers[project]; + const newServer = existing === undefined; + servers[project] = { ...(existing ?? {}), ...mcpLocalServerFor(project) }; + + // Only one mcpctl project should be active: drop any *other* mcpctl-managed + // entries, preserving hand-configured servers. + const removed: string[] = []; + for (const k of Object.keys(servers)) { + if (k === project) continue; + if (isMcpctlManagedEntry(k, servers[k])) { + delete servers[k]; + removed.push(k); + } + } + const totalServers = Object.keys(servers).length; + + config.mcp = { ...(config.mcp ?? {}), servers }; + + await mkdir(dirname(configPath), { recursive: true }); + await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + + return { configPath, created: !existed, addedServer: project, newServer, totalServers, removed }; +} + +/** The currently-active mcpctl project per opencode.json, or null. */ +export function activeOpencodeProject(config: OpencodeConfig): string | null { + const servers = config?.mcp?.servers; + if (!servers || typeof servers !== 'object') return null; + for (const [k, v] of Object.entries(servers)) { + if (isMcpctlManagedEntry(k, v)) return k; + } + return null; +} + +export interface OpencodeState { + project?: string; +} + +/** Read the persisted active-project state. */ +export async function readOpencodeState(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** Persist the active-project state (survives across sessions/cwd). */ +export async function writeOpencodeState(project: string, path: string): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ project }, null, 2) + '\n', 'utf-8'); + return path; +} + +async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} + +/** + * Add `spec` to `opencode.json`'s `plugin` array (deduped), so a `.tsx` + * plugin (which opencode's auto-discovery glob of `.ts`/`.js` won't pick up) + * actually loads. Preserves `plugin` entries the user already declared. + * Returns true if the spec was newly added. + */ +export async function registerOpencodePlugin(configPath: string, spec: string): Promise { + const config = await loadOpencodeConfig(configPath); + const plugin = Array.isArray(config.plugin) + ? config.plugin.map((p) => (Array.isArray(p) ? p[0] : p)) + : []; + if (!plugin.includes(spec)) { + config.plugin = [...plugin, spec]; + await writeFile(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + return true; + } + return false; +} diff --git a/src/cli/src/utils/opencode-skills.ts b/src/cli/src/utils/opencode-skills.ts new file mode 100644 index 0000000..e74009b --- /dev/null +++ b/src/cli/src/utils/opencode-skills.ts @@ -0,0 +1,51 @@ +/** + * Opencode skill sync for `mcpctl config opencode` / `skills sync --agent opencode`. + * + * Thin convenience wrapper around the shared [`runSkillsSync`] implementation + * in `commands/skills.ts`, invoked with `target: 'opencode'`. All diffing, + * atomic install, preservation and orphan logic lives there; this module only + * resolves the opencode install root and state file paths and exposes a stable + * entrypoint. + * + * Target behaviour (handled by the shared implementation): + * - installs markdown skills under `~/.config/opencode/skills//` + * - never touches `~/.claude/settings.json` (no hooks / postInstall) + * - records per-project ownership and preserves untracked / cross-project + * skill dirs so the shared, hand-editable tree is never silently wiped + */ +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import { runSkillsSync, type SyncOpts, type SyncResult, type SyncDeps } from '../commands/skills.js'; + +/** Root of opencode's skills tree (opencode reads SKILL.md trees here). */ +export function opencodeSkillsRoot(homeDir: string = homedir()): string { + return join(homeDir, '.config', 'opencode', 'skills'); +} + +/** opencode keeps its own state file so it never collides with Claude/prime-agent. */ +export function opencodeStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'skills-state-opencode.json'); +} + +export type OpencodeSyncOpts = Pick< + SyncOpts, + 'project' | 'dryRun' | 'force' | 'quiet' | 'keepOrphans' | 'cwd' | 'installRoot' | 'statePath' | 'homeDir' +>; +export type OpencodeSyncResult = SyncResult; +export type OpencodeSyncDeps = SyncDeps; + +/** + * Sync the active project's skills into opencode's skills tree. + * Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2 + * disk/state error. + */ +export async function runOpencodeSkillsSync( + opts: OpencodeSyncOpts, + deps: OpencodeSyncDeps, +): Promise { + return runSkillsSync({ ...opts, target: 'opencode' }, deps); +} + +// Re-export for callers that prefer to use the shared function directly. +export { runSkillsSync }; diff --git a/src/cli/tests/commands/opencode.test.ts b/src/cli/tests/commands/opencode.test.ts new file mode 100644 index 0000000..f8ab3f9 --- /dev/null +++ b/src/cli/tests/commands/opencode.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; +import { OPENCODE_PLUGIN, OPENCODE_PLUGIN_FILENAME } from '../../src/config/opencode-extension.js'; +import { opencodePluginSpec } from '../../src/config/opencode.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({})), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config opencode', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + let prevCwd: string; + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-opencode-')); + prevCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + process.exitCode = 0; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + /** Build the command with opencode-specific defaults pointing at tmpDir. */ + function cmd() { + return createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + } + + /** OpenCode args: config dir + state isolated under tmpDir + no skills. */ + function base(opts: Record = {}) { + const args = ['--config-dir', tmpDir, '--state', join(tmpDir, 'state.json'), '--skip-skills']; + for (const [k, v] of Object.entries(opts)) args.push(v === '' ? k : `${k} ${v}`); + return args.flatMap((a) => a.split(' ')); + } + + it('requires --project', async () => { + await cmd().parseAsync(['opencode', ...base(), '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(process.exitCode).toBe(1); + }); + + it('writes the MCP bridge + plugin into opencode.json and installs the plugin source', async () => { + await cmd().parseAsync(['opencode', '--project', 'homeautomation', ...base()], { from: 'user' }); + + const written = JSON.parse(readFileSync(join(tmpDir, 'opencode.json'), 'utf-8')); + expect(written.mcp.servers['homeautomation']).toEqual({ + type: 'local', + command: ['mcpctl', 'mcp', '-p', 'homeautomation'], + mcpctlManaged: true, + }); + expect(written.plugin).toContain(opencodePluginSpec()); + + const pluginPath = join(tmpDir, 'plugin', OPENCODE_PLUGIN_FILENAME); + expect(existsSync(pluginPath)).toBe(true); + expect(readFileSync(pluginPath, 'utf-8')).toBe(OPENCODE_PLUGIN); + }); + + it('preserves other settings and servers when merged', async () => { + const configPath = join(tmpDir, 'opencode.json'); + writeFileSync(configPath, JSON.stringify({ + model: 'itaz/deepseek-v4-think', + mcp: { servers: { sre: { type: 'local', command: ['some', 'server'] } } }, + plugin: ['some-other-plugin'], + })); + + await cmd().parseAsync(['opencode', '--project', 'proj-1', ...base()], { from: 'user' }); + + const written = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(written.model).toBe('itaz/deepseek-v4-think'); // untouched + expect(written.mcp.servers['sre']).toBeDefined(); // preserved (hand-set) + expect(written.plugin).toContain('some-other-plugin'); // preserved + expect(written.plugin).toContain(opencodePluginSpec()); // added + expect(written.mcp.servers['proj-1'].mcpctlManaged).toBe(true); + }); + + it('keeps a single active project: switching unmounts the previous mcpctl server', async () => { + const configPath = join(tmpDir, 'opencode.json'); + writeFileSync(configPath, JSON.stringify({ + mcp: { servers: { + homeautomation: { type: 'local', command: ['mcpctl', 'mcp', '-p', 'homeautomation'], mcpctlManaged: true }, + sre: { type: 'local', command: ['some', 'server'] }, + } }, + })); + + await cmd().parseAsync(['opencode', '--project', 'labctl', ...base()], { from: 'user' }); + + const written = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(written.mcp.servers['labctl'].mcpctlManaged).toBe(true); + expect(written.mcp.servers['homeautomation']).toBeUndefined(); // old mcpctl removed + expect(written.mcp.servers['sre']).toBeDefined(); // hand-set preserved + }); + + it('--skip-plugin neither installs nor registers the plugin', async () => { + await cmd().parseAsync(['opencode', '--project', 'ha', ...base(), '--skip-plugin'], { from: 'user' }); + + const written = JSON.parse(readFileSync(join(tmpDir, 'opencode.json'), 'utf-8')); + expect(written.mcp.servers['ha']).toBeDefined(); + expect(existsSync(join(tmpDir, 'plugin', OPENCODE_PLUGIN_FILENAME))).toBe(false); + expect(Array.isArray(written.plugin) ? written.plugin.includes(opencodePluginSpec()) : false).toBe(false); + expect(output.join('\n')).toContain('--skip-plugin'); + }); + + it('writes the active-project state file', async () => { + await cmd().parseAsync(['opencode', '--project', 'docmost', ...base()], { from: 'user' }); + expect(JSON.parse(readFileSync(join(tmpDir, 'state.json'), 'utf-8')).project).toBe('docmost'); + }); + + it('writes a .mcpctl-project marker for later skills sync', async () => { + await cmd().parseAsync(['opencode', '--project', 'sre', ...base()], { from: 'user' }); + expect(readFileSync(join(tmpDir, '.mcpctl-project'), 'utf-8').trim()).toBe('sre'); + }); + + it('--skip-marker leaves the directory alone', async () => { + await cmd().parseAsync(['opencode', '--project', 'sre', ...base(), '--skip-marker'], { from: 'user' }); + expect(existsSync(join(tmpDir, '.mcpctl-project'))).toBe(false); + }); + + it('--dry-run prints the change without writing', async () => { + await cmd().parseAsync(['opencode', '--project', 'proj-2', ...base(), '--dry-run'], { from: 'user' }); + expect(output.join('\n')).toContain('proj-2'); + expect(existsSync(join(tmpDir, 'opencode.json'))).toBe(false); + }); + + it('refuses to overwrite a corrupt opencode.json', async () => { + writeFileSync(join(tmpDir, 'opencode.json'), '{ not valid json'); + await cmd().parseAsync(['opencode', '--project', 'x', ...base()], { from: 'user' }); + expect(output.join('\n')).toContain('refusing to overwrite'); + expect(readFileSync(join(tmpDir, 'opencode.json'), 'utf-8')).toBe('{ not valid json'); + }); + + it('the installed plugin exports a TUI plugin with a /mcpctl slash command and footer slot', async () => { + await cmd().parseAsync(['opencode', '--project', 'ha', ...base()], { from: 'user' }); + const plugin = readFileSync(join(tmpDir, 'plugin', OPENCODE_PLUGIN_FILENAME), 'utf-8'); + expect(plugin).toContain('slashName: "mcpctl"'); + expect(plugin).toContain('app_bottom'); + expect(plugin).toContain('`mcpctl:${'); + expect(plugin).toContain('--skip-plugin'); + }); +}); + +function exceptionSafeRead(path: string): string | null { + try { + return readFileSync(path, 'utf-8'); + } catch { + return null; + } +} diff --git a/src/cli/tests/commands/skills.test.ts b/src/cli/tests/commands/skills.test.ts index 1556667..43ff37f 100644 --- a/src/cli/tests/commands/skills.test.ts +++ b/src/cli/tests/commands/skills.test.ts @@ -47,10 +47,24 @@ describe('skills sync --agent', () => { expect(output.join('\n')).toContain('prime-agent'); }); - it('rejects an unknown --agent value with a non-zero exit', async () => { + it('routes --agent opencode to the opencode target', async () => { const cmd = createSkillsCommand({ client, log }); - await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'bogus'], { from: 'user' }); - expect(process.exitCode).toBe(1); - expect(client.get).not.toHaveBeenCalled(); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'opencode'], { from: 'user' }); + // opencode hits the project visible endpoint and the summary names the target. + expect(String(client.get.mock.calls[0]?.[0])).toContain('/skills/visible'); + expect(output.join('\n')).toContain('opencode'); + }); + + it('rejects an unknown --agent value with a non-zero exit', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'bogus'], { from: 'user' }); + expect(process.exitCode).toBe(1); + expect(client.get).not.toHaveBeenCalled(); + expect(spy.mock.calls.map((c) => c.join(' ')).join('\n')).toContain("expected 'claude', 'prime-agent' or 'opencode'"); + } finally { + spy.mockRestore(); + } }); }); diff --git a/src/cli/tests/config/opencode-embed.test.ts b/src/cli/tests/config/opencode-embed.test.ts new file mode 100644 index 0000000..6264edd --- /dev/null +++ b/src/cli/tests/config/opencode-embed.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { OPENCODE_PLUGIN, OPENCODE_PLUGIN_FILENAME } from '../../src/config/opencode-extension.js'; + +const cliDir = dirname(fileURLToPath(import.meta.url)); +// tests/config/ -> src/cli/tests/config ; source lives at repo root/src/opencode-ext +const repoRoot = join(cliDir, '..', '..', '..', '..'); +const SOURCE = readFileSync(join(repoRoot, 'src', 'opencode-ext', 'mcpctl-opencode.tsx'), 'utf-8'); + +describe('opencode plugin embed (config/opencode-extension.ts)', () => { + it('matches the source in src/opencode-ext/mcpctl-opencode.tsx', () => { + // The embedded copy must stay in lock-step with the reviewable source. Run + // `npx tsx scripts/generate-opencode-extension.ts` after editing it. + expect(OPENCODE_PLUGIN).toBe(SOURCE); + }); + + it('exports the installed filename', () => { + expect(OPENCODE_PLUGIN_FILENAME).toBe('mcpctl-switch.tsx'); + }); + + it('is a path-plugin TUI plugin (.tsx + default.tui) since auto-discovery only globs .ts/.js', () => { + // opencode's plugin glob is `{plugin,plugins}/*.{ts,js}`, so a JSX TUI + // plugin must be referenced explicitly from the `plugin` array. + expect(OPENCODE_PLUGIN_FILENAME.endsWith('.tsx')).toBe(true); + expect(OPENCODE_PLUGIN).toContain('id: "mcpctl"'); + expect(OPENCODE_PLUGIN).toContain('tui: async'); + }); +}); diff --git a/src/cli/tests/utils/opencode-skills.test.ts b/src/cli/tests/utils/opencode-skills.test.ts new file mode 100644 index 0000000..cc08958 --- /dev/null +++ b/src/cli/tests/utils/opencode-skills.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { opencodeSkillsRoot, opencodeStatePath } from '../../src/utils/opencode-skills.js'; + +describe('opencode skills paths', () => { + it('installs under ~/.config/opencode/skills (what opencode reads)', () => { + expect(opencodeSkillsRoot('/home/u')).toBe(join('/home/u', '.config', 'opencode', 'skills')); + }); + it('tracks its own state file so it never collides with Claude/prime-agent', () => { + expect(opencodeStatePath('/home/u')).toBe(join('/home/u', '.mcpctl', 'skills-state-opencode.json')); + }); +}); diff --git a/src/opencode-ext/mcpctl-opencode.tsx b/src/opencode-ext/mcpctl-opencode.tsx new file mode 100644 index 0000000..3bcc885 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode.tsx @@ -0,0 +1,228 @@ +/** + * mcpctl × opencode — project switcher + footer indicator. + * + * Installed by `mcpctl config opencode` into ~/.config/opencode/plugin/ and + * wired into opencode.json's `plugin` array. It is a **TUI plugin**: it + * registers a `/mcpctl` slash command to switch the active mcpctl project from + * inside the opencode interface, and shows the *current* project in the bottom + * status bar (the same strip where opencode shows the model and token counter), + * so you can see at a glance which project's MCP servers are live. + * + * Loaded from opencode.json's `plugin` array (as a `.tsx` path plugin), because + * opencode's auto-discovery glob is `.ts`/`.js` only and this file uses JSX. + * + * How switching works + * The switch itself is performed by the `mcpctl` CLI + * (`mcpctl config opencode --project `), which rewrites opencode.json's + * `mcp.servers` to mount that project's MCP stdio bridge and unmount the + * previous one. Keeping the machinery in the CLI means this UI shell stays in + * lock-step with the rest of the mcpctl repo. After the switch, opencode's + * config watcher reloads MCP, so the new project's tools come up without an + * app restart. + * + * The MCP bridge itself is a plain opencode `local` MCP server: + * mcpctl mcp -p + * — the same stdio bridge `mcpctl config claude` wires into .mcp.json. + * + * Only imports public opencode TUI plugin pieces + Node builtins, and ships as + * source (embedded into the CLI, then written into the plugin dir), so an + * installed binary with no source tree can still provision a working addon. + */ +import { createSignal } from "solid-js"; +import { join } from "node:path"; +import { readFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import type { TuiPluginApi, TuiDialogSelectOption } from "@opencode-ai/plugin/tui"; + +/** Key used to tag the mcpctl-managed MCP server in opencode.json. */ +const MANAGED_KEY = "mcpctlManaged"; + +interface ProjectInfo { + name: string; + description?: string; +} + +// ── opencode.json helpers ──────────────────────────────────────────────────── +/** Resolve the opencode global config dir (honours OPENCODE_CONFIG_DIR, else ~/.config/opencode). */ +function configDirOf(api: TuiPluginApi): string { + const fromEnv = process.env.OPENCODE_CONFIG_DIR; + if (fromEnv && fromEnv.length > 0) return fromEnv; + return join(api.state.path.home, ".config", "opencode"); +} + +/** The project the config dir's opencode.json is currently scoped to, or null. */ +async function readActiveProject(configDir: string): Promise { + try { + const raw = await readFile(join(configDir, "opencode.json"), "utf-8"); + const parsed = JSON.parse(raw) as { mcp?: { servers?: Record> } }; + const servers = parsed?.mcp?.servers; + if (!servers || typeof servers !== "object") return null; + + // A tagged entry is authoritative — mcpctl writes exactly one. + for (const [name, entry] of Object.entries(servers)) { + if (entry && typeof entry === "object" && entry[MANAGED_KEY] === true) return name; + } + // Fall back to any server whose command runs `mcpctl mcp -p `. + for (const [name, entry] of Object.entries(servers)) { + const command = entry && typeof entry === "object" ? entry["command"] : undefined; + if ( + Array.isArray(command) && + command[0] === "mcpctl" && + command.includes("mcp") && + command.includes("-p") && + command[command.indexOf("-p") + 1] === name + ) { + return name; + } + } + return null; + } catch { + return null; + } +} + +// ── CLI bridge ─────────────────────────────────────────────────────────────── +function mcpctl(...args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile("mcpctl", args, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout) => { + if (err) reject(new Error((err as Error & { stderr?: string }).stderr?.trim() || (err as Error).message)); + else resolve(stdout || ""); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl("get", "projects", "-o", "json"); + const parsed = JSON.parse(out || "[]") as Array<{ name?: string; description?: string }>; + return parsed.filter((p) => p && typeof p.name === "string").map((p) => ({ name: p.name as string, description: p.description })); +} + +/** + * The project picker. opencode's DialogSelect has a built-in type-to-filter, + * so real installs (hundreds of projects) stay usable without us reinventing + * one. + */ +function Picker(props: { + api: TuiPluginApi; + configDir: string; + projects: ProjectInfo[]; + active: string | null; + onDone: () => void; +}) { + const { api, configDir, projects, active, onDone } = props; + const [busy, setBusy] = createSignal(false); + const [current, setCurrent] = createSignal(active ?? undefined); + + const options = (): TuiDialogSelectOption[] => + projects.map((p) => ({ + title: p.description ? `${p.name} — ${p.description}` : p.name, + value: p.name, + description: p.name === active ? "(current)" : undefined, + disabled: p.name === active, + })); + + async function doSwitch(name: string) { + if (busy()) return; + if (name === active) { + api.ui.toast({ variant: "info", message: `Already on mcpctl project '${name}'` }); + return; + } + setBusy(true); + try { + await mcpctl("config", "opencode", "--project", name, "--skip-plugin", "--skip-marker", "--config-dir", configDir); + } catch (err) { + api.ui.toast({ variant: "error", message: `mcpctl: switch to '${name}' failed — ${(err as Error).message}` }); + setBusy(false); + return; + } + setBusy(false); + onDone(); + api.ui.toast({ variant: "success", message: `Switched to mcpctl project '${name}'.` }); + } + + return ( + opt && setCurrent(opt.value)} + onSelect={(opt) => opt && void doSwitch(opt.value)} + skipFilter={false} + /> + ); +} + +/** Footer indicator — the project name shown next to the model / token strip. */ +function Indicator(props: { api: TuiPluginApi; active: () => string | null }) { + const theme = () => props.api.theme.current; + return ( + + {props.active() ? `mcpctl:${props.active()}` : "mcpctl:off"} + + ); +} + +// ── the plugin ──────────────────────────────────────────────────────────────── +export default { + id: "mcpctl", + tui: async (api: TuiPluginApi) => { + const configDir = configDirOf(api); + const [active, setActive] = createSignal(null); + + async function refresh() { + setActive(await readActiveProject(configDir)); + } + + // Keep the footer in sync: on load, then poll (so an external + // `mcpctl config opencode` is reflected without an app restart). + void refresh(); + const timer = setInterval(() => void refresh(), 10_000); + api.lifecycle.onDispose(() => clearInterval(timer)); + + function showPicker() { + api.ui.dialog.replace(() => ( + + mcpctl — loading projects… + + )); + listProjects() + .then((projects) => { + api.ui.dialog.replace(() => ( + void refresh()} /> + )); + }) + .catch((err) => { + api.ui.toast({ variant: "error", message: `mcpctl: could not list projects — ${(err as Error).message}` }); + api.ui.dialog.clear(); + }); + } + + // /mcpctl slash command (surfaces in the prompt's `/` autocomplete). + api.keymap.registerLayer({ + commands: [ + { + name: "mcpctl.switch", + title: "Switch mcpctl project", + desc: "Switch the active mcpctl project (MCP) from the opencode interface", + category: "mcpctl", + namespace: "palette", + slashName: "mcpctl", + run: () => showPicker(), + }, + ], + bindings: [], + }); + + // Bottom status bar: the project name sits alongside the model and the + // token counter. + api.slots.register({ + order: 100, + slots: { + app_bottom() { + return ; + }, + }, + }); + }, +};