Compare commits

..

2 Commits

Author SHA1 Message Date
Michal
99f881dd67 feat(opencode): leader keybind, explicit unmount on switch, non-wrapping indicator
Three improvements taken from reading the sibling opencode branches
(feat/opencode-extension-abhishek in particular):

- `<leader>m` opens the project picker. Switching is the repeated action and
  typing `/mcpctl` every time is friction; the other two commands stay
  palette-only.

- A switch disconnects before re-adding. `mcp.add` under the same name does
  re-point the tools on its own, but leaves it to opencode whether the previous
  client is closed — and an abandoned one keeps its `mcp-session-id` alive on
  mcplocal, which is exactly what holds a gated project open. Best-effort, so a
  first mount still works.

- The footer label renders `wrapMode="none" truncate`. The home prompt row is
  narrow enough that the default wrap broke `mcpctl:homeautomation` across two
  lines mid-word; clipping the tail of a long name reads far better.

Verified against opencode 1.18.15: ctrl-x m opens the picker, the home footer is
now one line, and a disconnect-then-add switch still lands — the model called
`mcpctl_begin_session` and listed the new project's tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVwuCjuMoA13gmzYEfcrNP
2026-08-09 18:02:52 +01:00
Michal
be2a5cb189 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
2026-08-08 21:03:53 +01:00
18 changed files with 740 additions and 396 deletions

View File

@@ -198,29 +198,31 @@ See [docs/pi-extension.md](docs/pi-extension.md) for full details.
### Connect opencode ### Connect opencode
[opencode](https://opencode.ai) supports MCP natively *and* exposes an API for [opencode](https://opencode.ai) supports MCP natively *and* exposes an API for
its own MCP registry, so mcpctl mounts the project's gateway through the running its own MCP registry, so mcpctl mounts the project through the running app —
app — switching projects needs **no restart**. which means you can **switch projects from the TUI without restarting**.
```bash ```bash
mcpctl config opencode --project monitoring mcpctl config opencode --project monitoring
``` ```
That mints (or reuses) the project token, writes it to a 0600 state file This mints (or reuses) the project's gateway token into
(`~/.mcpctl/opencode-state.json` — the token never lands in a mode-0644 `~/.mcpctl/opencode-state.json` (0600 — the token never lands in
`opencode.json` people paste into bug reports), installs a server plugin into `opencode.json`), installs a server plugin into
`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into `~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into
`~/.config/opencode/mcpctl/`, registers the latter in `tui.json`, and syncs `~/.config/opencode/mcpctl/`, registers the latter in
skills into `~/.config/opencode/skill/`. `~/.config/opencode/tui.json`, and syncs skills into
`~/.config/opencode/skill/`.
Inside opencode: Inside opencode:
- `/mcpctl` — filterable project picker; switches **live, no restart** - `/mcpctl` (or `<leader>m`) — **switch project** from a filterable picker;
takes effect on the next turn, no restart
- `/mcpctl-status` — active project, mount state, gateway URL - `/mcpctl-status` — active project, mount state, gateway URL
- `/mcpctl-skills` — re-sync this project's skills - `/mcpctl-skills` — re-sync this project's skills
- a `mcpctl:<project>` footer indicator right of the model name, above the - the active project shows as `mcpctl:<project>` in the prompt footer, next to
token counter the model name
Skip individual steps: Skip individual steps as needed:
```bash ```bash
mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint
@@ -230,11 +232,8 @@ mcpctl config opencode --project monitoring --skip-marker # don't touc
mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing
``` ```
Re-sync skills later: Re-sync skills on their own with
`mcpctl skills sync --agent opencode --project monitoring`.
```bash
mcpctl skills sync --agent opencode --project monitoring
```
See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. See [docs/opencode-extension.md](docs/opencode-extension.md) for full details.

View File

@@ -31,7 +31,8 @@ with no restart.
~/.config/opencode/plugin/mcpctl.ts server plugin ─┐ ~/.config/opencode/plugin/mcpctl.ts server plugin ─┐
~/.config/opencode/mcpctl/mcpctl-tui.tsx TUI plugin ─┤ ~/.config/opencode/mcpctl/mcpctl-tui.tsx TUI plugin ─┤
│ client.mcp.add({ │ client.mcp.add({
name: "mcpctl", type: "remote", url, headers }) name: "mcpctl",
▼ type: "remote", url, headers })
opencode's MCP registry opencode's MCP registry
@@ -84,10 +85,13 @@ TUI plugins). It adds:
| Command | What it does | | Command | What it does |
|---------|--------------| |---------|--------------|
| `/mcpctl` | Filterable project picker; switches live | | `/mcpctl` (or `<leader>m`) | Filterable project picker; switches live |
| `/mcpctl-status` | Active project, mount state, gateway URL | | `/mcpctl-status` | Active project, mount state, gateway URL |
| `/mcpctl-skills` | Re-sync this project's skills | | `/mcpctl-skills` | Re-sync this project's skills |
Switching is the thing you do repeatedly, so it gets a chord as well as a slash
command; the other two stay palette-only.
and a `mcpctl:<project>` indicator in the prompt footer, next to the model name and a `mcpctl:<project>` indicator in the prompt footer, next to the model name
and one line above the token counter. and one line above the token counter.
@@ -102,9 +106,15 @@ The picker needs no pre-filter prompt (unlike the pi and prime-agent
switchers): opencode's select dialog filters as you type, so the plugin only switchers): opencode's select dialog filters as you type, so the plugin only
has to order the list — active project first, then alphabetical. has to order the list — active project first, then alphabetical.
A switch **disconnects before re-adding**. `mcp.add` under the same name does
re-point the tools on its own, but leaves it to opencode whether the previous
client is closed, and an abandoned one keeps its `mcp-session-id` alive on
mcplocal — the very thing that holds a gated project open. Best-effort: on a
first mount there is nothing to disconnect.
The indicator is published through `api.kv`, which is a reactive store: writing The indicator is published through `api.kv`, which is a reactive store: writing
it re-renders the slot with no signal plumbing, and it persists across sessions it re-renders the slot with no signal plumbing, and it persists across sessions
so the label is correct on the very first frame. so the label is right on the first frame.
#### Why the footer and not the status bar #### Why the footer and not the status bar
@@ -117,9 +127,11 @@ options are:
| `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | | `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it |
| `app_bottom` | costs a whole extra terminal row | | `app_bottom` | costs a whole extra terminal row |
There is no slot on the status-bar line itself. On the home screen the prompt There is no slot on the status-bar line itself. The home prompt box is narrow
box is narrow, so a long project name wraps onto a second line; in a session enough that the default wrap breaks `mcpctl:homeautomation` across two lines
(where the prompt is full width) it always fits on one. mid-word, so the label renders `wrapMode="none" truncate` — clipping the tail of
a long name reads better than a two-line footer. In a session the prompt is full
width and it always fits.
### Skills ### Skills

View File

@@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser';
export default [ export default [
{ {
files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'], files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts'],
languageOptions: { languageOptions: {
parser: tsparser, parser: tsparser,
parserOptions: { parserOptions: {

View File

@@ -46,7 +46,9 @@
"@earendil-works/pi-ai": "^0.84.1", "@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-coding-agent": "0.84.1", "@earendil-works/pi-coding-agent": "0.84.1",
"@opencode-ai/plugin": "1.18.15", "@opencode-ai/plugin": "1.18.15",
"@opentui/solid": "^0.5.1", "@opentui/core": "0.4.5",
"@opentui/keymap": "0.4.5",
"@opentui/solid": "0.4.5",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
"@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0", "@typescript-eslint/parser": "^8.56.0",
@@ -54,6 +56,7 @@
"eslint": "^10.0.1", "eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"rimraf": "^6.1.3", "rimraf": "^6.1.3",
"solid-js": "1.9.12",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typebox": "1.3.11", "typebox": "1.3.11",
"typescript": "^5.9.3", "typescript": "^5.9.3",

169
pnpm-lock.yaml generated
View File

@@ -16,10 +16,16 @@ importers:
version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)
'@opencode-ai/plugin': '@opencode-ai/plugin':
specifier: 1.18.15 specifier: 1.18.15
version: 1.18.15(@opentui/core@0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)) version: 1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))
'@opentui/core':
specifier: 0.4.5
version: 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)
'@opentui/keymap':
specifier: 0.4.5
version: 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)
'@opentui/solid': '@opentui/solid':
specifier: ^0.5.1 specifier: 0.4.5
version: 0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) version: 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)
'@types/node': '@types/node':
specifier: ^25.3.0 specifier: ^25.3.0
version: 25.3.0 version: 25.3.0
@@ -41,6 +47,9 @@ importers:
rimraf: rimraf:
specifier: ^6.1.3 specifier: ^6.1.3
version: 6.1.3 version: 6.1.3
solid-js:
specifier: 1.9.12
version: 1.9.12
tsx: tsx:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
@@ -1669,53 +1678,70 @@ packages:
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@opentui/core-darwin-arm64@0.5.1': '@opentui/core-darwin-arm64@0.4.5':
resolution: {integrity: sha512-Yl3JBLYRrBN+SxXY/gYaqCT/JNrN50K4xO7hYC+/Si8/FgOrBlbRmfJIUNQdZMMLUvOMA+I813+hDw3xfarBzQ==} resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@opentui/core-darwin-x64@0.5.1': '@opentui/core-darwin-x64@0.4.5':
resolution: {integrity: sha512-kqMVu+LGuHSCxYFkVJtmuyLLLTMztILSNnlx1eSpHHUiDV4PMc+zkxwRIXO+o0TFTW3gNUKleKUkggriYje7Vw==} resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@opentui/core-linux-arm64-musl@0.5.1': '@opentui/core-linux-arm64-musl@0.4.5':
resolution: {integrity: sha512-rmFMtiCm8I0fESB834sTN/ewoI+QDSber588ZO+i08JR6mbv7hkiKW2H/MhiAY1GxGK4nXApleBMyGVOlVDvgQ==} resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@opentui/core-linux-arm64@0.5.1': '@opentui/core-linux-arm64@0.4.5':
resolution: {integrity: sha512-PpE1nCHRkxEvSYyZFMToPHjQoVh50A7+BbgetlTX/5ImXzo6iSO83a+7M/1WgZnNu+uZJf5GZKAAcLoRrvQl3Q==} resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@opentui/core-linux-x64-musl@0.5.1': '@opentui/core-linux-x64-musl@0.4.5':
resolution: {integrity: sha512-WO8RjhqKyqW/7P0xHdEVT8JGfU2MO7RlK0kdkNnRSnAEVwsTNd2ibhmKDPLGpo/DKaLuA00CsnrNiLGZZiQJKQ==} resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@opentui/core-linux-x64@0.5.1': '@opentui/core-linux-x64@0.4.5':
resolution: {integrity: sha512-/CxFxFv+ffMof2nYQrpgEfNkWKkKxYUSfwdt2RdDN5fZRhcxjE949743rV0Oovw5Az63qxPgbyfcZVNVO2HVNg==} resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@opentui/core-win32-arm64@0.5.1': '@opentui/core-win32-arm64@0.4.5':
resolution: {integrity: sha512-AgeTjZbdMxSiuBjyLvcug91qd1Ds6Dlg5z4lCInqL7mPQicDEnKZs5lF2FAaktcU7RPi2wLybbQ/vM0NbpXYmw==} resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@opentui/core-win32-x64@0.5.1': '@opentui/core-win32-x64@0.4.5':
resolution: {integrity: sha512-VttbQHVoZQ5uW5IcQeUHPEx/WFQ2mMflukhhbBjpNSdZOPdzmmC4QGFPQznJVwuzXTnjQ2Nll4AY0ROJ/Q3nkw==} resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@opentui/core@0.5.1': '@opentui/core@0.4.5':
resolution: {integrity: sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg==} resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==}
peerDependencies: peerDependencies:
web-tree-sitter: 0.25.10 web-tree-sitter: 0.25.10
'@opentui/solid@0.5.1': '@opentui/keymap@0.4.5':
resolution: {integrity: sha512-eynJILdvxmprr7oou3cqAiAzZ6zzRU0m1y42/L9GZObTKr76tVtPFQpLvQl6ZJTWMoswpwx1pJWqcUN+ZAI8Rg==} resolution: {integrity: sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==}
peerDependencies:
'@opentui/react': 0.4.5
'@opentui/solid': 0.4.5
react: '>=19.2.0'
solid-js: 1.9.12
peerDependenciesMeta:
'@opentui/react':
optional: true
'@opentui/solid':
optional: true
react:
optional: true
solid-js:
optional: true
'@opentui/solid@0.4.5':
resolution: {integrity: sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==}
peerDependencies: peerDependencies:
solid-js: 1.9.12 solid-js: 1.9.12
@@ -2558,8 +2584,8 @@ packages:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
bun-ffi-structs@0.3.1: bun-ffi-structs@0.2.4:
resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==} resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==}
peerDependencies: peerDependencies:
typescript: ^5 typescript: ^5
@@ -5418,15 +5444,15 @@ snapshots:
'@babel/core@7.28.0': '@babel/core@7.28.0':
dependencies: dependencies:
'@ampproject/remapping': 2.3.0 '@ampproject/remapping': 2.3.0
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.7
'@babel/generator': 7.29.1 '@babel/generator': 7.29.8
'@babel/helper-compilation-targets': 7.28.6 '@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0)
'@babel/helpers': 7.29.2 '@babel/helpers': 7.29.2
'@babel/parser': 7.29.0 '@babel/parser': 7.29.8
'@babel/template': 7.28.6 '@babel/template': 7.29.7
'@babel/traverse': 7.29.0 '@babel/traverse': 7.29.8
'@babel/types': 7.29.0 '@babel/types': 7.29.8
convert-source-map: 2.0.0 convert-source-map: 2.0.0
debug: 4.4.3 debug: 4.4.3
gensync: 1.0.0-beta.2 gensync: 1.0.0-beta.2
@@ -5509,7 +5535,7 @@ snapshots:
'@babel/helper-module-imports@7.18.6': '@babel/helper-module-imports@7.18.6':
dependencies: dependencies:
'@babel/types': 7.29.0 '@babel/types': 7.29.8
'@babel/helper-module-imports@7.28.6': '@babel/helper-module-imports@7.28.6':
dependencies: dependencies:
@@ -5525,15 +5551,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.28.0)':
dependencies:
'@babel/core': 7.28.0
'@babel/helper-module-imports': 7.28.6
'@babel/helper-validator-identifier': 7.28.5
'@babel/traverse': 7.29.0
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@@ -5641,7 +5658,7 @@ snapshots:
'@babel/preset-typescript@7.27.1(@babel/core@7.28.0)': '@babel/preset-typescript@7.27.1(@babel/core@7.28.0)':
dependencies: dependencies:
'@babel/core': 7.28.0 '@babel/core': 7.28.0
'@babel/helper-plugin-utils': 7.28.6 '@babel/helper-plugin-utils': 7.29.7
'@babel/helper-validator-option': 7.27.1 '@babel/helper-validator-option': 7.27.1
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0)
'@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0)
@@ -6624,15 +6641,16 @@ snapshots:
node-gyp: 12.4.0 node-gyp: 12.4.0
proc-log: 6.1.0 proc-log: 6.1.0
'@opencode-ai/plugin@1.18.15(@opentui/core@0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))': '@opencode-ai/plugin@1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))':
dependencies: dependencies:
'@ai-sdk/provider': 3.0.8 '@ai-sdk/provider': 3.0.8
'@opencode-ai/sdk': 1.18.15 '@opencode-ai/sdk': 1.18.15
effect: 4.0.0-beta.83 effect: 4.0.0-beta.83
zod: 4.1.8 zod: 4.1.8
optionalDependencies: optionalDependencies:
'@opentui/core': 0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)
'@opentui/solid': 0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/keymap': 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)
'@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)
'@opencode-ai/sdk@1.18.15': '@opencode-ai/sdk@1.18.15':
dependencies: dependencies:
@@ -6757,55 +6775,66 @@ snapshots:
'@opentelemetry/semantic-conventions@1.43.0': {} '@opentelemetry/semantic-conventions@1.43.0': {}
'@opentui/core-darwin-arm64@0.5.1': '@opentui/core-darwin-arm64@0.4.5':
optional: true optional: true
'@opentui/core-darwin-x64@0.5.1': '@opentui/core-darwin-x64@0.4.5':
optional: true optional: true
'@opentui/core-linux-arm64-musl@0.5.1': '@opentui/core-linux-arm64-musl@0.4.5':
optional: true optional: true
'@opentui/core-linux-arm64@0.5.1': '@opentui/core-linux-arm64@0.4.5':
optional: true optional: true
'@opentui/core-linux-x64-musl@0.5.1': '@opentui/core-linux-x64-musl@0.4.5':
optional: true optional: true
'@opentui/core-linux-x64@0.5.1': '@opentui/core-linux-x64@0.4.5':
optional: true optional: true
'@opentui/core-win32-arm64@0.5.1': '@opentui/core-win32-arm64@0.4.5':
optional: true optional: true
'@opentui/core-win32-x64@0.5.1': '@opentui/core-win32-x64@0.4.5':
optional: true optional: true
'@opentui/core@0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10)': '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)':
dependencies: dependencies:
bun-ffi-structs: 0.3.1(typescript@5.9.3) bun-ffi-structs: 0.2.4(typescript@5.9.3)
diff: 9.0.0 diff: 9.0.0
marked: 17.0.1 marked: 17.0.1
string-width: 7.2.0 string-width: 7.2.0
strip-ansi: 7.1.2 strip-ansi: 7.1.2
web-tree-sitter: 0.25.10 web-tree-sitter: 0.25.10
optionalDependencies: optionalDependencies:
'@opentui/core-darwin-arm64': 0.5.1 '@opentui/core-darwin-arm64': 0.4.5
'@opentui/core-darwin-x64': 0.5.1 '@opentui/core-darwin-x64': 0.4.5
'@opentui/core-linux-arm64': 0.5.1 '@opentui/core-linux-arm64': 0.4.5
'@opentui/core-linux-arm64-musl': 0.5.1 '@opentui/core-linux-arm64-musl': 0.4.5
'@opentui/core-linux-x64': 0.5.1 '@opentui/core-linux-x64': 0.4.5
'@opentui/core-linux-x64-musl': 0.5.1 '@opentui/core-linux-x64-musl': 0.4.5
'@opentui/core-win32-arm64': 0.5.1 '@opentui/core-win32-arm64': 0.4.5
'@opentui/core-win32-x64': 0.5.1 '@opentui/core-win32-x64': 0.4.5
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': '@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)':
dependencies:
'@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)
optionalDependencies:
'@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)
react: 19.2.5
solid-js: 1.9.12
transitivePeerDependencies:
- typescript
- web-tree-sitter
'@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)':
dependencies: dependencies:
'@babel/core': 7.28.0 '@babel/core': 7.28.0
'@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0)
'@opentui/core': 0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)
babel-plugin-module-resolver: 5.0.2 babel-plugin-module-resolver: 5.0.2
babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12) babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12)
entities: 7.0.1 entities: 7.0.1
@@ -7148,7 +7177,7 @@ snapshots:
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.7
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
'@types/aria-query': 5.0.4 '@types/aria-query': 5.0.4
aria-query: 5.3.0 aria-query: 5.3.0
@@ -7544,7 +7573,7 @@ snapshots:
'@babel/core': 7.28.0 '@babel/core': 7.28.0
'@babel/helper-module-imports': 7.18.6 '@babel/helper-module-imports': 7.18.6
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0)
'@babel/types': 7.29.0 '@babel/types': 7.29.8
html-entities: 2.3.3 html-entities: 2.3.3
parse5: 7.3.0 parse5: 7.3.0
@@ -7688,7 +7717,7 @@ snapshots:
buildcheck@0.0.7: buildcheck@0.0.7:
optional: true optional: true
bun-ffi-structs@0.3.1(typescript@5.9.3): bun-ffi-structs@0.2.4(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -8665,7 +8694,7 @@ snapshots:
is-fullwidth-code-point@5.1.0: is-fullwidth-code-point@5.1.0:
dependencies: dependencies:
get-east-asian-width: 1.5.0 get-east-asian-width: 1.6.0
is-glob@4.0.3: is-glob@4.0.3:
dependencies: dependencies:

View File

@@ -23,7 +23,7 @@ const extDir = join(root, 'src', 'opencode-ext');
const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8');
const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8');
function embed(src: string): string { function embed(src) {
// JSON.stringify yields a quoted string literal we can inline directly. // JSON.stringify yields a quoted string literal we can inline directly.
return JSON.stringify(src); return JSON.stringify(src);
} }
@@ -35,8 +35,15 @@ const out = `/**
* \`src/opencode-ext/mcpctl-opencode-tui.tsx\`. * \`src/opencode-ext/mcpctl-opencode-tui.tsx\`.
* *
* \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/ * \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/
* so an installed binary with no source tree can still provision a working * (and registers them), so an installed binary with no source tree can still
* opencode integration. * provision a working opencode integration.
*
* The install file names differ from the source file names on purpose:
* - the server plugin lands in opencode's auto-discovered \`plugin/\` dir,
* where its basename is what shows up in \`opencode plugin list\`;
* - the TUI plugin lands beside it in \`mcpctl/\`, referenced by path from
* \`tui.json\`, and must keep a \`.tsx\` extension for opencode to transpile
* its JSX.
*/ */
/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ /** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */

View File

@@ -34,7 +34,6 @@ import {
isMcpctlToken, isMcpctlToken,
} from '../config/prime-agent.js'; } from '../config/prime-agent.js';
import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
import { import {
opencodeConfigDir, opencodeConfigDir,
opencodeStatePath, opencodeStatePath,
@@ -45,6 +44,7 @@ import {
writeOpencodeState, writeOpencodeState,
storedToken, storedToken,
} from '../utils/opencode-settings.js'; } from '../utils/opencode-settings.js';
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
/** /**
* Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint
@@ -92,8 +92,8 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
// ── shared credential plumbing ───────────────────────────────────────────── // ── shared credential plumbing ─────────────────────────────────────────────
// `config prime-agent` and `config opencode` both provision an mcptoken for // `config prime-agent` and `config opencode` both provision an mcptoken for
// the project they mount and retire the one they replace. The rules are // 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 // identical; only the token *name* differs, so the agent label is a
// rather than a second copy of this logic. // parameter rather than a second copy of this logic.
interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string }
@@ -134,25 +134,6 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
return live; return live;
} }
/**
* 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;
}
/** /**
* Retire the token this install used to hold, now that `keepToken` has * Retire the token this install used to hold, now that `keepToken` has
* replaced it on disk. * replaced it on disk.
@@ -200,6 +181,26 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
} }
} }
/**
* 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 config
.command('view') .command('view')
.description('Show current configuration') .description('Show current configuration')
@@ -534,6 +535,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (hidden) { if (hidden) {
void cmd; void cmd;
} }
} }
registerClaudeCommand('claude', false); registerClaudeCommand('claude', false);
@@ -627,7 +629,13 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
registerPrimeAgentCommand('prime-agent', false); registerPrimeAgentCommand('prime-agent', false);
registerPrimeAgentCommand('prime-agent-generate', true); // backward compat registerPrimeAgentCommand('prime-agent-generate', true); // backward compat
// ── opencode: plugins + state (live MCP, never touches opencode.json) ── // ── 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 config
.command('opencode') .command('opencode')
.description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills') .description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills')

View File

@@ -35,8 +35,19 @@ import {
import { ApiError } from '../api-client.js'; import { ApiError } from '../api-client.js';
import { opencodeSkillsDir } from '../utils/opencode-settings.js'; import { opencodeSkillsDir } from '../utils/opencode-settings.js';
/** The agent skill trees `skills sync` / `config <agent>` can target. */ /**
export type SyncTarget = 'claude' | 'prime-agent' | 'pi' | 'opencode'; * 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 * `mcpctl skills sync` — materialise server-side skills onto disk under
@@ -107,6 +118,8 @@ export interface SyncOpts {
* configuring a second project never deletes the * configuring a second project never deletes the
* first project's skills, and pre-existing * first project's skills, and pre-existing
* (untracked) skill dirs are preserved. * (untracked) skill dirs are preserved.
* 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in
* ~/.pi/agent/skills and opencode's `skill` dir.
*/ */
target?: SyncTarget; target?: SyncTarget;
} }
@@ -195,9 +208,9 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
// 3. Load state. Defaults depend on the sync target: Claude Code gets // 3. Load state. Defaults depend on the sync target: Claude Code gets
// ~/.claude/skills + the shared state file; prime-agent gets its own // ~/.claude/skills + the shared state file; prime-agent gets its own
// tree + separate state file so the two never collide. prime-agent, pi // tree + separate state file so the two never collide.
// and opencode share the same skill semantics: their own flat skill tree + // prime-agent, pi and opencode share the same skill semantics: their own flat
// separate state file, no SessionStart hooks / postInstall, no // skill tree + separate state file, no SessionStart hooks / postInstall, no
// mcpServers auto-attach. Only claude gets Claude-specific behaviour. // mcpServers auto-attach. Only claude gets Claude-specific behaviour.
const isSharedTree = target !== 'claude'; const isSharedTree = target !== 'claude';
// Canonical ownership scope for a *skill*: null when the skill is global // Canonical ownership scope for a *skill*: null when the skill is global
@@ -562,17 +575,16 @@ export interface SkillsCommandDeps {
* "prime-agent" → ~/.prime/agent/skills * "prime-agent" → ~/.prime/agent/skills
* "pi" → ~/.pi/agent/skills * "pi" → ~/.pi/agent/skills
* "opencode" → ~/.config/opencode/skill (XDG-aware) * "opencode" → ~/.config/opencode/skill (XDG-aware)
*
* Kept here so `mcpctl skills sync --agent` and `mcpctl config <agent>` agree. * Kept here so `mcpctl skills sync --agent` and `mcpctl config <agent>` agree.
*/ */
export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir(), env: NodeJS.ProcessEnv = process.env): string { export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string {
switch (agent) { switch (agent) {
case 'prime-agent': case 'prime-agent':
return join(homeDir, '.prime', 'agent', 'skills'); return join(homeDir, '.prime', 'agent', 'skills');
case 'pi': case 'pi':
return join(homeDir, '.pi', 'agent', 'skills'); return join(homeDir, '.pi', 'agent', 'skills');
case 'opencode': case 'opencode':
return opencodeSkillsDir(env, homeDir); return opencodeSkillsDir(process.env, homeDir);
case 'claude': case 'claude':
default: default:
return join(homeDir, '.claude', 'skills'); return join(homeDir, '.claude', 'skills');
@@ -608,8 +620,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
// Validate --agent so an unknown value fails loudly instead of silently // Validate --agent so an unknown value fails loudly instead of silently
// running the default (Claude) sync. // running the default (Claude) sync.
const agent = opts.agent ?? 'claude'; const agent = opts.agent ?? 'claude';
if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi' && agent !== 'opencode') { if (!isSyncTarget(agent)) {
warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', 'pi', or 'opencode')`); warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -621,7 +633,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
target: agent as SyncTarget, target: agent,
installRoot: agentInstallRoot(agent), installRoot: agentInstallRoot(agent),
}, },
{ client, log, warn }, { client, log, warn },

File diff suppressed because one or more lines are too long

View File

@@ -19,10 +19,6 @@
* and mount through opencode's live MCP API instead, so `opencode.json` is * and mount through opencode's live MCP API instead, so `opencode.json` is
* never touched at all. * never touched at all.
* *
* The plugin sources are embedded in the CLI (see
* `config/opencode-extension.ts`) so an installed binary with no source tree
* can still provision a working opencode integration.
*
* Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`. * Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`.
*/ */
import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises'; import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises';

View File

@@ -1,101 +1,210 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs'; import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js'; import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js'; import type { ApiClient } from '../../src/api-client.js';
function mockClient(visibleSkills: unknown[] = []): ApiClient { 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 { return {
get: vi.fn(async (path: string) => { get: vi.fn(async (path: string) => {
if (path.endsWith('/skills/visible')) return visibleSkills; if (path.startsWith('/api/v1/mcptokens')) return tokens;
if (path.startsWith('/api/v1/skills/')) return { if (path.endsWith('/skills/visible')) return [];
id: 's1', name: 'demo', description: 'd', semver: '1.0.0', return {};
content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null, }),
}; post: vi.fn(async (path: string, body?: unknown) => {
calls.posts.push({ path, body });
if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' };
return {}; return {};
}), }),
post: vi.fn(async () => ({ token: 'mcpctl_pat_test1234567'} as unknown)),
put: vi.fn(async () => ({})), put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}), delete: vi.fn(async () => {}),
} as unknown as ApiClient; } as unknown as ApiClient;
} }
describe('config opencode', () => { describe('config opencode', () => {
let client: ReturnType<typeof mockClient>;
let output: string[]; let output: string[];
let tmpDir: string; let tmpDir: string;
const log = (...args: string[]) => output.push(args.join(' ')); 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(() => { beforeEach(() => {
client = mockClient();
output = []; output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-opencode-')); 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);
}); });
afterEach(() => { it('installs both plugins, registers the TUI one and writes the state file', async () => {
rmSync(tmpDir, { recursive: true, force: true }); 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('installs both plugins, registers the TUI plugin, writes state, syncs skills', async () => { it('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => {
const ocDir = join(tmpDir, 'opencode-cfg'); await command(mockClient(calls)).parseAsync(
const cmd = createConfigCommand( ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/',
{ configDeps: { configDir: tmpDir }, log }, '--token', 't', '--skip-skills', '--skip-marker'],
{ client: mockClient([{ { from: 'user' });
id: 's1', name: 'demo', scope: 'project', projectId: 'proj', agentId: null, expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example');
contentHash: 'abc', updatedAt: new Date().toISOString(), description: 'd',
}]), credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(
['opencode', '--project', 'monitoring', '--opencode-dir', ocDir],
{ from: 'user' },
);
// Server plugin in the auto-discovered plugin/ dir.
const serverPath = join(ocDir, 'plugin', 'mcpctl.ts');
expect(existsSync(serverPath)).toBe(true);
expect(readFileSync(serverPath, 'utf-8')).toContain('client.mcp.add');
// TUI plugin + registration in tui.json.
const tuiPath = join(ocDir, 'mcpctl', 'mcpctl-tui.tsx');
expect(existsSync(tuiPath)).toBe(true);
const tuiJson = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8'));
expect(tuiJson.plugin).toContain(tuiPath);
// Active project persisted with a bearer token.
const statePath = join(ocDir, 'mcpctl-state.json');
expect(existsSync(statePath)).toBe(true);
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
expect(state.project).toBe('monitoring');
expect(state.tokens?.monitoring).toBe('mcpctl_pat_test1234567');
}); });
it('dry-run writes nothing', async () => { it('--token never mints', async () => {
const ocDir = join(tmpDir, 'opencode-cfg2'); await command(mockClient(calls)).parseAsync(
const cmd = createConfigCommand( ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
{ configDeps: { configDir: tmpDir }, log }, '--skip-skills', '--skip-marker'],
{ client, credentialsDeps: { configDir: tmpDir }, log }, { from: 'user' });
); expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
await cmd.parseAsync(
['opencode', '--project', 'monitoring', '--opencode-dir', ocDir, '--dry-run'],
{ from: 'user' },
);
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false);
expect(existsSync(join(ocDir, 'mcpctl-state.json'))).toBe(false);
expect(client.get).not.toHaveBeenCalled();
}); });
it('fails fast with no project', async () => { it('mints a uniquely-named opencode token when none is stored', async () => {
const cmd = createConfigCommand( await command(mockClient(calls)).parseAsync(
{ configDeps: { configDir: tmpDir }, log }, ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ client, credentialsDeps: { configDir: tmpDir }, log }, { 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');
});
await cmd.parseAsync(['opencode'], { from: 'user' }); 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); expect(process.exitCode).toBe(1);
process.exitCode = 0; 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

@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_SERVER_PLUGIN_SOURCE,
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE,
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_FILENAME, OPENCODE_TUI_PLUGIN_FILENAME,
} from '../../src/config/opencode-extension.js'; } from '../../src/config/opencode-extension.js';
@@ -13,37 +13,78 @@ import {
* files in src/opencode-ext/. Editing the sources without re-running 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 — * 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 * and the embedded copy is the one thing no typecheck covers. Same guarantee
* the completions check and the pi embed test give. * the completions check gives.
*/ */
const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); const repoRoot = join(import.meta.dirname, '..', '..', '..', '..');
const extDir = join(repoRoot, 'src', 'opencode-ext'); const extDir = join(repoRoot, 'src', 'opencode-ext');
describe('embedded opencode plugins', () => { describe('embedded opencode plugins', () => {
it('server plugin matches src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
const onDisk = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed')
expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed').toBe(onDisk); .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('TUI plugin matches src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { it('install under names opencode can actually load', () => {
const onDisk = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); // The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is
expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed').toBe(onDisk); // referenced by path from tui.json and must stay .tsx for its JSX to be
}); // transpiled.
it('ships both plugins and wires the TUI one into tui.json', () => {
expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts');
expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx');
// The TUI plugin registers slash commands + a footer indicator.
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('slashName:');
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('session_prompt_right');
}); });
it('ever-mounts the gateway through opencode, never a secret in opencode.json', () => { it('are self-contained — the installed files have no mcpctl imports to resolve', () => {
// The server plugin mounts via client.mcp.add and reads a 0600 state file. for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('client.mcp.add'); expect(src).not.toMatch(/from '@mcpctl\//);
expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('opencode-state.json'); expect(src).not.toMatch(/from '\.\.\//);
// The bearer token must never be written into a mode-0644 config: the }
// mount reads it from the state file, and the URL is the project gateway. });
expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('Authorization');
expect(OPENCODE_SERVER_PLUGIN_SOURCE).not.toContain('headers in opencode.json'); 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('bind the switcher to a chord as well as a slash command', () => {
// Switching is the repeated action; typing /mcpctl every time is friction.
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'");
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: '<leader>m'");
});
it('tear the outgoing mount down before re-pointing it', () => {
// An abandoned client keeps its mcp-session-id — and a gated project's
// unlocked state — alive on mcplocal.
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })');
});
it('clip rather than wrap the footer label on the narrow home prompt', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"');
});
it('switch without rewriting the plugin file opencode has already loaded', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'");
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'");
}); });
}); });

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

@@ -1,38 +0,0 @@
import { describe, it, expect } from 'vitest';
import { orderProjects, indicatorLabel } from '../../src/opencode-ext/mcpctl-opencode-tui.js' ;
describe('opencode TUI project ordering', () => {
it('puts the active project first, then alphabetical', () => {
const projects = [
{ name: 'docmost' },
{ name: 'homeautomation' },
{ name: 'sre' },
];
expect(orderProjects(projects, 'sre').map((p) => p.name)).toEqual(['sre', 'docmost', 'homeautomation']);
});
it('is alphabetical with no active project', () => {
const projects = [
{ name: 'sre' },
{ name: 'docmost' },
];
expect(orderProjects(projects, null).map((p) => p.name)).toEqual(['docmost', 'sre']);
});
it('does not mutate the input', () => {
const projects = [{ name: 'b' }, { name: 'a' }];
const copy = [...projects];
orderProjects(projects, 'b');
expect(projects).toEqual(copy);
});
});
describe('opencode TUI footer label', () => {
it('renders the project name', () => {
expect(indicatorLabel('monitoring')).toBe('mcpctl:monitoring');
});
it('renders none when there is no project', () => {
expect(indicatorLabel(null)).toBe('mcpctl:none');
expect(indicatorLabel('')).toBe('mcpctl:none');
});
});

View File

@@ -1,11 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'node:fs'; import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os'; import { tmpdir } from 'node:os';
import { import {
opencodeConfigDir, opencodeConfigDir,
opencodeSkillsDir, opencodeSkillsDir,
opencodeStatePath,
withOpencodeDir, withOpencodeDir,
installOpencodePlugins, installOpencodePlugins,
registerOpencodeTuiPlugin, registerOpencodeTuiPlugin,
@@ -13,110 +12,158 @@ import {
writeOpencodeState, writeOpencodeState,
storedToken, storedToken,
} from '../../src/utils/opencode-settings.js'; } from '../../src/utils/opencode-settings.js';
import {
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_FILENAME,
} from '../../src/config/opencode-extension.js';
describe('opencode-settings paths', () => { describe('opencodeConfigDir', () => {
it('resolves the config dir XDG-aware', () => { it('defaults to ~/.config/opencode', () => {
expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode'); expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode');
expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/custom' }, '/home/u')).toBe('/custom/opencode');
}); });
it('exposes every path config opencode writes under a base dir', () => { it('honours XDG_CONFIG_HOME — provisioning ~/.config would be invisible to opencode', () => {
const p = withOpencodeDir('/base'); expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/opencode');
expect(p.serverPluginPath()).toBe(join('/base', 'plugin', 'mcpctl.ts'));
expect(p.tuiPluginPath()).toBe(join('/base', 'mcpctl', 'mcpctl-tui.tsx'));
expect(p.tuiJsonPath()).toBe(join('/base', 'tui.json'));
expect(p.skillsDir()).toBe(join('/base', 'skill'));
}); });
it('skills dir is under the config dir', () => { 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'); expect(opencodeSkillsDir({}, '/home/u')).toBe('/home/u/.config/opencode/skill');
}); });
it('state file lives under ~/.mcpctl', () => {
expect(opencodeStatePath('/home/u')).toBe('/home/u/.mcpctl/opencode-state.json');
});
}); });
describe('installOpencodePlugins', () => { describe('installOpencodePlugins', () => {
let tmp: string; let dir: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); }); beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('writes both plugins into the config dir', async () => { it('writes the server plugin where opencode auto-discovers it and the TUI plugin beside it', async () => {
const written = await installOpencodePlugins(tmp); const written = await installOpencodePlugins(dir);
expect(written).toContain(join(tmp, 'plugin', 'mcpctl.ts')); const paths = withOpencodeDir(dir);
expect(written).toContain(join(tmp, 'mcpctl', 'mcpctl-tui.tsx')); expect(written).toEqual([paths.serverPluginPath(), paths.tuiPluginPath()]);
// The server plugin lands where opencode auto-discovers it. expect(existsSync(join(dir, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME))).toBe(true);
expect(readFileSync(join(tmp, 'plugin', 'mcpctl.ts'), 'utf-8')).toContain('mcpctl'); 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', () => { describe('registerOpencodeTuiPlugin', () => {
let tmp: string; let dir: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); }); let tuiJson: string;
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx';
it('creates tui.json and registers the plugin', async () => { beforeEach(() => {
const tuiJson = join(tmp, 'tui.json'); dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-'));
const res = await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx'); tuiJson = join(dir, 'tui.json');
expect(res.added).toBe(true); });
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')); const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toContain('/cfg/mcpctl/mcpctl-tui.tsx'); expect(parsed.plugin).toEqual([pluginPath]);
expect(parsed.$schema).toBe('https://opencode.ai/tui.json');
}); });
it('is idempotent and merges with other plugins', async () => { it('is idempotent and leaves the file untouched on a no-op run', async () => {
const tuiJson = join(tmp, 'tui.json'); await registerOpencodeTuiPlugin(tuiJson, pluginPath);
writeFileSync(tuiJson, JSON.stringify({ plugin: ['/other/plugin.ts'] })); const before = readFileSync(tuiJson, 'utf-8');
await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx'); expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: false });
await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx'); expect(readFileSync(tuiJson, 'utf-8')).toBe(before);
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual(['/other/plugin.ts', '/cfg/mcpctl/mcpctl-tui.tsx']);
}); });
it('drops a stale entry pointing at an older install location', async () => { it('preserves other TUI plugins and unrelated keys', async () => {
const tuiJson = join(tmp, 'tui.json'); writeFileSync(tuiJson, JSON.stringify({ plugin: ['opencode-tui-utils'], theme: 'nord' }));
writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/mcpctl/mcpctl-tui.tsx'] })); await registerOpencodeTuiPlugin(tuiJson, pluginPath);
await registerOpencodeTuiPlugin(tuiJson, '/new/mcpctl/mcpctl-tui.tsx');
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual(['/new/mcpctl/mcpctl-tui.tsx']); expect(parsed.plugin).toEqual(['opencode-tui-utils', pluginPath]);
expect(parsed.theme).toBe('nord');
}); });
it('refuses to overwrite corrupt tui.json', async () => { it('drops a stale entry for an older install location of our own plugin', async () => {
const tuiJson = join(tmp, 'tui.json'); // Left behind, opencode fails to load a file that no longer exists on
writeFileSync(tuiJson, '{not json'); // every start.
await expect(registerOpencodeTuiPlugin(tuiJson, '/p.tsx')).rejects.toThrow(); 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', () => { describe('opencode state file', () => {
let tmp: string; let dir: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); }); let statePath: string;
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
const statePath = (): string => join(tmp, 'opencode-state.json'); beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-'));
statePath = join(dir, 'nested', 'opencode-state.json');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('reads an empty state when the file is absent', async () => { it('creates the directory and writes project + gateway + token', async () => {
expect(await readOpencodeState(statePath())).toEqual({}); 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('writes project + gateway + token, preserved across switches', async () => { it('is written 0600 — it holds bearer tokens', async () => {
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'pat_a' }, statePath()); await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath);
await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'pat_b' }, statePath()); expect(statSync(statePath).mode & 0o777).toBe(0o600);
});
const state = await readOpencodeState(statePath()); 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.project).toBe('b');
expect(state.tokens).toEqual({ a: 'pat_a', b: 'pat_b' }); expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-b' });
}); });
it('writes the file 0600', async () => { it('switching without a new token leaves the stored one alone', async () => {
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'pat_a' }, statePath()); await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath);
const mode = statSync(statePath()).mode & 0o777; await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw2' }, statePath);
expect(mode).toBe(0o600); const state = await readOpencodeState(statePath);
expect(state.gatewayUrl).toBe('https://gw2');
expect(storedToken(state, 'a')).toBe('tok-a');
}); });
it('storedToken returns the project token or null', () => { it('reads a missing or corrupt state as empty rather than throwing', async () => {
expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x'); 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({ tokens: { a: '' } }, 'a')).toBeNull();
expect(storedToken({}, 'b')).toBeNull(); expect(storedToken({}, 'a')).toBeNull();
expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x');
}); });
}); });

View File

@@ -1,6 +1,13 @@
import { defineProject } from 'vitest/config'; import { defineProject } from 'vitest/config';
export default defineProject({ 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: { test: {
name: 'cli', name: 'cli',
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts'],

View File

@@ -1,21 +1,23 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
/** /**
* mcpctl opencode TUI plugin — `/mcpctl` project switcher + footer indicator. * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.
* *
* Installed by `mcpctl config opencode` into * Installed by `mcpctl config opencode` into
* `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in `tui.json`. * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in
* `~/.config/opencode/tui.json`.
* *
* Ships the same UX the pi and prime-agent integrations do, for opencode: * What it adds to opencode:
* - `/mcpctl` — pick the active project from a filterable dialog * - `/mcpctl` — pick the active project from a filterable dialog
* - `/mcpctl-status` show the active project, mount state and gateway URL * - `/mcpctl-status` — what is mounted, from where, as which user
* - `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill
* - a `mcpctl:<project>` footer indicator, rendered right of the model name on * - a `mcpctl:<project>` indicator in the prompt footer, next to the model
* the prompt's bottom line, one row above the token counter. * name and the token counter
* *
* The switch is handed to the `mcpctl` CLI (the same binary that installed this * The switch itself is delegated to the `mcpctl` CLI (the same binary that
* file) so token minting, credential state and skills stay in one place; the * installed this file), so token minting, state and skills stay in one place
* plugin stays a UI shell. Once the CLI has rewritten the state file, the mount * and this stays a UI shell. Once the CLI has rewritten the state file, the
* is re-pointed live through opencode's own MCP API — no restart. * 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. * Only Node builtins + opencode's plugin API are imported.
*/ */
@@ -23,13 +25,19 @@ import type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';
import type { JSX } from '@opentui/solid'; import type { JSX } from '@opentui/solid';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir } from 'node:os';
/** MCP server name the mount lives under — must match the server plugin. */ /** MCP server name the mount lives under — must match the server plugin. */
const SERVER_NAME = 'mcpctl'; const SERVER_NAME = 'mcpctl';
/** kv key holding the label the footer renders. */ /**
* 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'; const KV_LABEL = 'mcpctl.project';
interface OpencodeState { interface OpencodeState {
@@ -49,8 +57,7 @@ function statePath(): string {
async function readState(): Promise<OpencodeState> { async function readState(): Promise<OpencodeState> {
try { try {
const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch { } catch {
return {}; return {};
} }
@@ -80,16 +87,15 @@ async function listProjects(): Promise<ProjectInfo[]> {
const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>; const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;
return parsed return parsed
.filter((p): p is { name: string; description?: string } => typeof p?.name === 'string') .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')
.map((p) => ({ .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));
name: p.name,
description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined,
}));
} }
/** /**
* Order the project list for the picker: the active project first (most likely * Active project first, then alphabetical.
* pick), then alphabetical. opencode's DialogSelect already filters as you *
* type, so no pre-filter prompt is needed — only a sensible starting order. * 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. * Exported so the ordering is unit-tested rather than eyeballed through a TUI.
*/ */
@@ -124,12 +130,20 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
*/ */
async function mount(project: string, state: OpencodeState): Promise<void> { async function mount(project: string, state: OpencodeState): Promise<void> {
const gatewayUrl = state.gatewayUrl; const gatewayUrl = state.gatewayUrl;
if (gatewayUrl === undefined || gatewayUrl === '') { if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project <name>`');
throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project <name>`');
}
const token = state.tokens?.[project] ?? ''; const token = state.tokens?.[project] ?? '';
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (token !== '') headers['Authorization'] = `Bearer ${token}`; if (token !== '') headers['Authorization'] = `Bearer ${token}`;
// Tear the outgoing mount down explicitly. `mcp.add` under the same name
// does re-point the tools, but leaves it to opencode whether the previous
// client is closed — and an abandoned one keeps its `mcp-session-id` alive
// on mcplocal, which is what holds a gated project open. Best-effort: on a
// first mount there is nothing to disconnect.
try {
await api.client.mcp.disconnect({ name: SERVER_NAME });
} catch {
/* not mounted yet */
}
await api.client.mcp.add({ await api.client.mcp.add({
name: SERVER_NAME, name: SERVER_NAME,
config: { config: {
@@ -148,7 +162,8 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
// The CLI mints/reuses the project token, rewrites the state file and // The CLI mints/reuses the project token, rewrites the state file and
// syncs skills. --skip-plugin leaves this very file alone (rewriting a // syncs skills. --skip-plugin leaves this very file alone (rewriting a
// loaded plugin mid-session buys nothing); --skip-marker stops us // loaded plugin mid-session buys nothing); --skip-marker stops us
// silently re-scoping whatever repo opencode was started in. // 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']); await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);
} catch (err) { } catch (err) {
api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' }); api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });
@@ -157,6 +172,8 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
try { try {
await mount(project, await readState()); await mount(project, await readState());
} catch (err) { } 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' }); api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });
await refreshIndicator(); await refreshIndicator();
return; return;
@@ -259,6 +276,11 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
}, },
}, },
], ],
// Switching is the thing you do repeatedly, so it gets a chord as well as
// `/mcpctl`. The other two commands stay palette-only — they are occasional.
bindings: [
{ key: '<leader>m', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },
],
}); });
// The indicator. `session_prompt_right` and `home_prompt_right` are the only // The indicator. `session_prompt_right` and `home_prompt_right` are the only
@@ -267,8 +289,13 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
// above the token counter. (`home_footer` would sit on the counter's line but // 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 // *replaces* the cwd/version footer rather than adding to it, and
// `app_bottom` costs a whole extra terminal row.) // `app_bottom` costs a whole extra terminal row.)
// wrapMode="none" + truncate: the home prompt row is narrow, and the default
// wrap breaks "mcpctl:homeautomation" across two lines mid-word. Clipping the
// tail of a long name reads far better than a two-line footer.
const Indicator = (): JSX.Element => ( const Indicator = (): JSX.Element => (
<text fg={api.theme.current.textMuted}>{api.kv.get(KV_LABEL, indicatorLabel(null))}</text> <text fg={api.theme.current.textMuted} wrapMode="none" truncate>
{api.kv.get(KV_LABEL, indicatorLabel(null))}
</text>
); );
api.slots.register({ api.slots.register({
order: 100, order: 100,

View File

@@ -1,33 +1,31 @@
/** /**
* mcpctl opencode server plugin — mounts the active project's MCP gateway. * mcpctl opencode server plugin — mounts the active project's MCP gateway.
* *
* Installed by `mcpctl config opencode` into opencode's auto-discovered * Installed by `mcpctl config opencode` into
* `plugin/` directory (`~/.config/opencode/plugin/mcpctl.ts`), where it is * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.
* loaded at startup. It mounts the ACTIVE mcpctl project's MCP gateway as a
* remote MCP server, so opencode's own MCP client exposes the project's tools
* natively (`mcpctl_*`).
* *
* WHY A PLUGIN AND NOT AN `mcp` BLOCK IN opencode.json: * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:
* 1. The gateway needs an `Authorization: Bearer <mcpctl PAT>` header — a * 1. The gateway needs an `Authorization: Bearer <mcpctl PAT>` header. Putting
* secret that does not belong in a mode-0644 config file users paste into * it in opencode.json means a secret in a mode-0644 config file that users
* bug reports. `~/.mcpctl/opencode-state.json` is 0600 like every other * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest
* mcpctl credential. * of mcpctl's credentials.
* 2. Switching projects must work WITHOUT restarting opencode. The server * 2. Switching projects has to work *without restarting opencode*. The server
* exposes `POST /mcp` (add) and `POST /mcp/{name}/disconnect`, so the * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can
* mount can be re-pointed live; an `mcp` block in opencode.json cannot. * be re-pointed live — a config file can't do that.
* *
* It exists so headless runs (`opencode run ...`), which load no TUI plugins at * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that
* all, still get the active project's tools; the TUI plugin * headless runs (`opencode run ...`), which load no TUI plugins at all, still
* (`mcpctl-opencode-tui.tsx`) drives the switch and the on-screen indicator. * get the active project's tools.
* *
* Only Node builtins + the plugin API are imported. * 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 type { Plugin, PluginModule } from '@opencode-ai/plugin';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { homedir } from 'node:os';
/** MCP server name the active project is mounted under. Constant on purpose. */ /** MCP server name we mount under. Constant on purpose — see `mount`. */
const SERVER_NAME = 'mcpctl'; const SERVER_NAME = 'mcpctl';
interface OpencodeState { interface OpencodeState {
@@ -42,8 +40,7 @@ function statePath(): string {
async function readState(): Promise<OpencodeState> { async function readState(): Promise<OpencodeState> {
try { try {
const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch { } catch {
return {}; return {};
} }
@@ -56,17 +53,26 @@ function projectUrl(gatewayUrl: string, project: string): string {
const server: Plugin = async ({ client }) => { const server: Plugin = async ({ client }) => {
/** /**
* The (url, token) target this process last registered. * The (url, token) this process last registered.
* *
* Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal * 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 * 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 * connection. Re-adding an unchanged config every turn would therefore drop
* the gate opened by `begin_session` and re-lock the project mid-conversation. * 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 * So we only call `add` when the target actually changed — or when the mount
* is not connected, where reconnecting is the whole point. * is not connected, where reconnecting is the whole point.
*/ */
let mounted: string | null = null; 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> { async function mount(): Promise<void> {
const state = await readState(); const state = await readState();
const project = state.project; const project = state.project;
@@ -123,12 +129,16 @@ const server: Plugin = async ({ client }) => {
return inflight; return inflight;
} }
// Deliberately NOT mounted here. Plugin setup runs before the server accepts // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is
// connections, and `client.mcp.add` calls back into that same server — // accepting connections, and `client.mcp.add` calls back into that same
// awaiting it at this point hangs opencode on a blank screen before the TUI // server — awaiting it at this point hangs opencode on a blank screen before
// ever draws. Both hooks below fire only once the server is live. // the TUI ever draws. Both hooks below fire only once the server is live.
return { return {
/** First contact: mount as soon as the server is up. */ /**
* 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> => { event: async (): Promise<void> => {
await ensureMounted(); await ensureMounted();
}, },
@@ -137,10 +147,11 @@ const server: Plugin = async ({ client }) => {
* Re-assert the mount before every user turn. * Re-assert the mount before every user turn.
* *
* `mcpctl config opencode --project X` (run from a shell, or by the TUI * `mcpctl config opencode --project X` (run from a shell, or by the TUI
* switcher) rewrites the state file underneath us. Re-reading here is what * switcher in a *different* opencode window) rewrites the state file
* makes an external switch take effect on the next message instead of on * underneath us. Re-reading here is what makes an external switch take
* the next restart. When nothing changed this is one state-file read — the * effect on the next message instead of on the next restart. When nothing
* mount hook won't re-register an unchanged mount. * 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> => { 'chat.message': async (): Promise<void> => {
await ensureMounted(); await ensureMounted();