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
[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
app — switching projects needs **no restart**.
its own MCP registry, so mcpctl mounts the project through the running app —
which means you can **switch projects from the TUI without restarting**.
```bash
mcpctl config opencode --project monitoring
```
That mints (or reuses) the project token, writes it to a 0600 state file
(`~/.mcpctl/opencode-state.json` — the token never lands in a mode-0644
`opencode.json` people paste into bug reports), installs a server plugin into
This mints (or reuses) the project's gateway token into
`~/.mcpctl/opencode-state.json` (0600 — the token never lands in
`opencode.json`), installs a server plugin into
`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into
`~/.config/opencode/mcpctl/`, registers the latter in `tui.json`, and syncs
skills into `~/.config/opencode/skill/`.
`~/.config/opencode/mcpctl/`, registers the latter in
`~/.config/opencode/tui.json`, and syncs skills into
`~/.config/opencode/skill/`.
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-skills` — re-sync this project's skills
- a `mcpctl:<project>` footer indicator right of the model name, above the
token counter
- the active project shows as `mcpctl:<project>` in the prompt footer, next to
the model name
Skip individual steps:
Skip individual steps as needed:
```bash
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
```
Re-sync skills later:
```bash
mcpctl skills sync --agent opencode --project monitoring
```
Re-sync skills on their own with
`mcpctl skills sync --agent opencode --project monitoring`.
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/mcpctl/mcpctl-tui.tsx TUI plugin ─┤
│ client.mcp.add({
name: "mcpctl", type: "remote", url, headers })
name: "mcpctl",
▼ type: "remote", url, headers })
opencode's MCP registry
@@ -84,10 +85,13 @@ TUI plugins). It adds:
| 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-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 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
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
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
@@ -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 |
| `app_bottom` | costs a whole extra terminal row |
There is no slot on the status-bar line itself. On the home screen the prompt
box is narrow, so a long project name wraps onto a second line; in a session
(where the prompt is full width) it always fits on one.
There is no slot on the status-bar line itself. The home prompt box is narrow
enough that the default wrap breaks `mcpctl:homeautomation` across two lines
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

View File

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

View File

@@ -46,7 +46,9 @@
"@earendil-works/pi-ai": "^0.84.1",
"@earendil-works/pi-coding-agent": "0.84.1",
"@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",
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
@@ -54,6 +56,7 @@
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.1.8",
"rimraf": "^6.1.3",
"solid-js": "1.9.12",
"tsx": "^4.21.0",
"typebox": "1.3.11",
"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)
'@opencode-ai/plugin':
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':
specifier: ^0.5.1
version: 0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)
specifier: 0.4.5
version: 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)
'@types/node':
specifier: ^25.3.0
version: 25.3.0
@@ -41,6 +47,9 @@ importers:
rimraf:
specifier: ^6.1.3
version: 6.1.3
solid-js:
specifier: 1.9.12
version: 1.9.12
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -1669,53 +1678,70 @@ packages:
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'}
'@opentui/core-darwin-arm64@0.5.1':
resolution: {integrity: sha512-Yl3JBLYRrBN+SxXY/gYaqCT/JNrN50K4xO7hYC+/Si8/FgOrBlbRmfJIUNQdZMMLUvOMA+I813+hDw3xfarBzQ==}
'@opentui/core-darwin-arm64@0.4.5':
resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==}
cpu: [arm64]
os: [darwin]
'@opentui/core-darwin-x64@0.5.1':
resolution: {integrity: sha512-kqMVu+LGuHSCxYFkVJtmuyLLLTMztILSNnlx1eSpHHUiDV4PMc+zkxwRIXO+o0TFTW3gNUKleKUkggriYje7Vw==}
'@opentui/core-darwin-x64@0.4.5':
resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==}
cpu: [x64]
os: [darwin]
'@opentui/core-linux-arm64-musl@0.5.1':
resolution: {integrity: sha512-rmFMtiCm8I0fESB834sTN/ewoI+QDSber588ZO+i08JR6mbv7hkiKW2H/MhiAY1GxGK4nXApleBMyGVOlVDvgQ==}
'@opentui/core-linux-arm64-musl@0.4.5':
resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==}
cpu: [arm64]
os: [linux]
'@opentui/core-linux-arm64@0.5.1':
resolution: {integrity: sha512-PpE1nCHRkxEvSYyZFMToPHjQoVh50A7+BbgetlTX/5ImXzo6iSO83a+7M/1WgZnNu+uZJf5GZKAAcLoRrvQl3Q==}
'@opentui/core-linux-arm64@0.4.5':
resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==}
cpu: [arm64]
os: [linux]
'@opentui/core-linux-x64-musl@0.5.1':
resolution: {integrity: sha512-WO8RjhqKyqW/7P0xHdEVT8JGfU2MO7RlK0kdkNnRSnAEVwsTNd2ibhmKDPLGpo/DKaLuA00CsnrNiLGZZiQJKQ==}
'@opentui/core-linux-x64-musl@0.4.5':
resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==}
cpu: [x64]
os: [linux]
'@opentui/core-linux-x64@0.5.1':
resolution: {integrity: sha512-/CxFxFv+ffMof2nYQrpgEfNkWKkKxYUSfwdt2RdDN5fZRhcxjE949743rV0Oovw5Az63qxPgbyfcZVNVO2HVNg==}
'@opentui/core-linux-x64@0.4.5':
resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==}
cpu: [x64]
os: [linux]
'@opentui/core-win32-arm64@0.5.1':
resolution: {integrity: sha512-AgeTjZbdMxSiuBjyLvcug91qd1Ds6Dlg5z4lCInqL7mPQicDEnKZs5lF2FAaktcU7RPi2wLybbQ/vM0NbpXYmw==}
'@opentui/core-win32-arm64@0.4.5':
resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==}
cpu: [arm64]
os: [win32]
'@opentui/core-win32-x64@0.5.1':
resolution: {integrity: sha512-VttbQHVoZQ5uW5IcQeUHPEx/WFQ2mMflukhhbBjpNSdZOPdzmmC4QGFPQznJVwuzXTnjQ2Nll4AY0ROJ/Q3nkw==}
'@opentui/core-win32-x64@0.4.5':
resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==}
cpu: [x64]
os: [win32]
'@opentui/core@0.5.1':
resolution: {integrity: sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg==}
'@opentui/core@0.4.5':
resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==}
peerDependencies:
web-tree-sitter: 0.25.10
'@opentui/solid@0.5.1':
resolution: {integrity: sha512-eynJILdvxmprr7oou3cqAiAzZ6zzRU0m1y42/L9GZObTKr76tVtPFQpLvQl6ZJTWMoswpwx1pJWqcUN+ZAI8Rg==}
'@opentui/keymap@0.4.5':
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:
solid-js: 1.9.12
@@ -2558,8 +2584,8 @@ packages:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
bun-ffi-structs@0.3.1:
resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==}
bun-ffi-structs@0.2.4:
resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==}
peerDependencies:
typescript: ^5
@@ -5418,15 +5444,15 @@ snapshots:
'@babel/core@7.28.0':
dependencies:
'@ampproject/remapping': 2.3.0
'@babel/code-frame': 7.29.0
'@babel/generator': 7.29.1
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@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/parser': 7.29.0
'@babel/template': 7.28.6
'@babel/traverse': 7.29.0
'@babel/types': 7.29.0
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/traverse': 7.29.8
'@babel/types': 7.29.8
convert-source-map: 2.0.0
debug: 4.4.3
gensync: 1.0.0-beta.2
@@ -5509,7 +5535,7 @@ snapshots:
'@babel/helper-module-imports@7.18.6':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.8
'@babel/helper-module-imports@7.28.6':
dependencies:
@@ -5525,15 +5551,6 @@ snapshots:
transitivePeerDependencies:
- 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)':
dependencies:
'@babel/core': 7.29.0
@@ -5641,7 +5658,7 @@ snapshots:
'@babel/preset-typescript@7.27.1(@babel/core@7.28.0)':
dependencies:
'@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/plugin-syntax-jsx': 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
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:
'@ai-sdk/provider': 3.0.8
'@opencode-ai/sdk': 1.18.15
effect: 4.0.0-beta.83
zod: 4.1.8
optionalDependencies:
'@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)
'@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)
'@opencode-ai/sdk@1.18.15':
dependencies:
@@ -6757,55 +6775,66 @@ snapshots:
'@opentelemetry/semantic-conventions@1.43.0': {}
'@opentui/core-darwin-arm64@0.5.1':
'@opentui/core-darwin-arm64@0.4.5':
optional: true
'@opentui/core-darwin-x64@0.5.1':
'@opentui/core-darwin-x64@0.4.5':
optional: true
'@opentui/core-linux-arm64-musl@0.5.1':
'@opentui/core-linux-arm64-musl@0.4.5':
optional: true
'@opentui/core-linux-arm64@0.5.1':
'@opentui/core-linux-arm64@0.4.5':
optional: true
'@opentui/core-linux-x64-musl@0.5.1':
'@opentui/core-linux-x64-musl@0.4.5':
optional: true
'@opentui/core-linux-x64@0.5.1':
'@opentui/core-linux-x64@0.4.5':
optional: true
'@opentui/core-win32-arm64@0.5.1':
'@opentui/core-win32-arm64@0.4.5':
optional: true
'@opentui/core-win32-x64@0.5.1':
'@opentui/core-win32-x64@0.4.5':
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:
bun-ffi-structs: 0.3.1(typescript@5.9.3)
bun-ffi-structs: 0.2.4(typescript@5.9.3)
diff: 9.0.0
marked: 17.0.1
string-width: 7.2.0
strip-ansi: 7.1.2
web-tree-sitter: 0.25.10
optionalDependencies:
'@opentui/core-darwin-arm64': 0.5.1
'@opentui/core-darwin-x64': 0.5.1
'@opentui/core-linux-arm64': 0.5.1
'@opentui/core-linux-arm64-musl': 0.5.1
'@opentui/core-linux-x64': 0.5.1
'@opentui/core-linux-x64-musl': 0.5.1
'@opentui/core-win32-arm64': 0.5.1
'@opentui/core-win32-x64': 0.5.1
'@opentui/core-darwin-arm64': 0.4.5
'@opentui/core-darwin-x64': 0.4.5
'@opentui/core-linux-arm64': 0.4.5
'@opentui/core-linux-arm64-musl': 0.4.5
'@opentui/core-linux-x64': 0.4.5
'@opentui/core-linux-x64-musl': 0.4.5
'@opentui/core-win32-arm64': 0.4.5
'@opentui/core-win32-x64': 0.4.5
transitivePeerDependencies:
- 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:
'@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-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12)
entities: 7.0.1
@@ -7148,7 +7177,7 @@ snapshots:
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/code-frame': 7.29.7
'@babel/runtime': 7.29.2
'@types/aria-query': 5.0.4
aria-query: 5.3.0
@@ -7544,7 +7573,7 @@ snapshots:
'@babel/core': 7.28.0
'@babel/helper-module-imports': 7.18.6
'@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
parse5: 7.3.0
@@ -7688,7 +7717,7 @@ snapshots:
buildcheck@0.0.7:
optional: true
bun-ffi-structs@0.3.1(typescript@5.9.3):
bun-ffi-structs@0.2.4(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -8665,7 +8694,7 @@ snapshots:
is-fullwidth-code-point@5.1.0:
dependencies:
get-east-asian-width: 1.5.0
get-east-asian-width: 1.6.0
is-glob@4.0.3:
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 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.
return JSON.stringify(src);
}
@@ -35,8 +35,15 @@ const out = `/**
* \`src/opencode-ext/mcpctl-opencode-tui.tsx\`.
*
* \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/
* so an installed binary with no source tree can still provision a working
* opencode integration.
* (and registers them), so an installed binary with no source tree can still
* 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/. */

View File

@@ -34,7 +34,6 @@ import {
isMcpctlToken,
} 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,
opencodeStatePath,
@@ -45,6 +44,7 @@ import {
writeOpencodeState,
storedToken,
} 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
@@ -92,8 +92,8 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
// ── shared credential plumbing ─────────────────────────────────────────────
// `config prime-agent` and `config opencode` both provision an mcptoken for
// the project they mount and retire the one they replace. The rules are
// identical; only the token *name* differs, so the agent label is a parameter
// rather than a second copy of this logic.
// identical; only the token *name* differs, so the agent label is a
// parameter rather than a second copy of this logic.
interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string }
@@ -134,25 +134,6 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
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
* 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
.command('view')
.description('Show current configuration')
@@ -534,6 +535,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (hidden) {
void cmd;
}
}
registerClaudeCommand('claude', false);
@@ -627,7 +629,13 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
registerPrimeAgentCommand('prime-agent', false);
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
.command('opencode')
.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 { 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
@@ -107,6 +118,8 @@ export interface SyncOpts {
* configuring a second project never deletes the
* first project's skills, and pre-existing
* (untracked) skill dirs are preserved.
* 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in
* ~/.pi/agent/skills and opencode's `skill` dir.
*/
target?: SyncTarget;
}
@@ -195,10 +208,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
// 3. Load state. Defaults depend on the sync target: Claude Code gets
// ~/.claude/skills + the shared state file; prime-agent gets its own
// tree + separate state file so the two never collide. prime-agent, pi
// and opencode share the same skill semantics: their own flat skill tree +
// separate state file, no SessionStart hooks / postInstall, no
// mcpServers auto-attach. Only claude gets Claude-specific behaviour.
// tree + separate state file so the two never collide.
// prime-agent, pi and opencode share the same skill semantics: their own flat
// skill tree + separate state file, no SessionStart hooks / postInstall, no
// mcpServers auto-attach. Only claude gets Claude-specific behaviour.
const isSharedTree = target !== 'claude';
// Canonical ownership scope for a *skill*: null when the skill is global
// (globals are visible from every project, so pinning one to whichever
@@ -562,17 +575,16 @@ export interface SkillsCommandDeps {
* "prime-agent" → ~/.prime/agent/skills
* "pi" → ~/.pi/agent/skills
* "opencode" → ~/.config/opencode/skill (XDG-aware)
*
* 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) {
case 'prime-agent':
return join(homeDir, '.prime', 'agent', 'skills');
case 'pi':
return join(homeDir, '.pi', 'agent', 'skills');
case 'opencode':
return opencodeSkillsDir(env, homeDir);
return opencodeSkillsDir(process.env, homeDir);
case 'claude':
default:
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
// running the default (Claude) sync.
const agent = opts.agent ?? 'claude';
if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi' && agent !== 'opencode') {
warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', 'pi', or 'opencode')`);
if (!isSyncTarget(agent)) {
warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`);
process.exitCode = 1;
return;
}
@@ -621,7 +633,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command {
...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}),
...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}),
...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}),
target: agent as SyncTarget,
target: agent,
installRoot: agentInstallRoot(agent),
},
{ 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
* 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/`.
*/
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 { 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 { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.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 {
get: vi.fn(async (path: string) => {
if (path.endsWith('/skills/visible')) return visibleSkills;
if (path.startsWith('/api/v1/skills/')) return {
id: 's1', name: 'demo', description: 'd', semver: '1.0.0',
content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null,
};
if (path.startsWith('/api/v1/mcptokens')) return tokens;
if (path.endsWith('/skills/visible')) return [];
return {};
}),
post: vi.fn(async (path: string, body?: unknown) => {
calls.posts.push({ path, body });
if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' };
return {};
}),
post: vi.fn(async () => ({ token: 'mcpctl_pat_test1234567'} as unknown)),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('config opencode', () => {
let client: ReturnType<typeof mockClient>;
let output: 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(() => {
client = mockClient();
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(() => {
rmSync(tmpDir, { recursive: true, force: true });
it('installs both plugins, registers the TUI one and writes the state file', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'docmost', '--opencode-dir', ocDir,
'--gateway-url', 'https://gw.example', '--token', 'mcpctl_pat_SUPPLIED000000',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(true);
expect(existsSync(join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'))).toBe(true);
const tui = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8'));
expect(tui.plugin).toEqual([join(ocDir, 'mcpctl', 'mcpctl-tui.tsx')]);
const state = JSON.parse(readFileSync(statePath(), 'utf-8'));
expect(state).toEqual({
project: 'docmost',
gatewayUrl: 'https://gw.example',
tokens: { docmost: 'mcpctl_pat_SUPPLIED000000' },
});
});
it('installs both plugins, registers the TUI plugin, writes state, syncs skills', async () => {
const ocDir = join(tmpDir, 'opencode-cfg');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client: mockClient([{
id: 's1', name: 'demo', scope: 'project', projectId: 'proj', agentId: null,
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('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/',
'--token', 't', '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example');
});
it('dry-run writes nothing', async () => {
const ocDir = join(tmpDir, 'opencode-cfg2');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
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('--token never mints', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
});
it('fails fast with no project', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
it('mints a uniquely-named opencode token when none is stored', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
const mint = calls.posts.find((c) => c.path === '/api/v1/mcptokens');
expect(mint).toBeDefined();
// A fixed name could only ever be minted once per project: McpToken is
// unique on (name, projectId) and revoke is a soft delete.
expect((mint?.body as { name: string }).name).toMatch(/^opencode-/);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000');
});
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);
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 {
OPENCODE_SERVER_PLUGIN_SOURCE,
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_SOURCE,
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_FILENAME,
} from '../../src/config/opencode-extension.js';
@@ -13,37 +13,78 @@ import {
* files in src/opencode-ext/. Editing the sources without re-running the
* generator therefore ships stale code to users while the repo looks correct —
* and the embedded copy is the one thing no typecheck covers. Same guarantee
* the completions check and the pi embed test give.
* the completions check gives.
*/
const repoRoot = join(import.meta.dirname, '..', '..', '..', '..');
const extDir = join(repoRoot, 'src', 'opencode-ext');
describe('embedded opencode plugins', () => {
it('server plugin matches 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').toBe(onDisk);
it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'));
expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'));
});
it('TUI plugin matches src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
const onDisk = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8');
expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed').toBe(onDisk);
});
it('ships both plugins and wires the TUI one into tui.json', () => {
it('install under names opencode can actually load', () => {
// The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is
// referenced by path from tui.json and must stay .tsx for its JSX to be
// transpiled.
expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts');
expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx');
// 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', () => {
// The server plugin mounts via client.mcp.add and reads a 0600 state file.
expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('client.mcp.add');
expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('opencode-state.json');
// 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('are self-contained — the installed files have no mcpctl imports to resolve', () => {
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).not.toMatch(/from '@mcpctl\//);
expect(src).not.toMatch(/from '\.\.\//);
}
});
it('keep the JSX pragma the TUI plugin needs to render its indicator', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE.startsWith('/** @jsxImportSource @opentui/solid */')).toBe(true);
});
it('agree on the MCP server name, so a switch re-points one mount instead of stacking two', () => {
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).toContain("const SERVER_NAME = 'mcpctl'");
}
});
it('agree on the state file both read', () => {
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) {
expect(src).toContain("join(homedir(), '.mcpctl', 'opencode-state.json')");
}
});
it('spawn the CLI without a shell, so a project name is never interpolated into a command string', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("execFile('mcpctl', args");
expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexecSync\s*\(/);
expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexec\(`/);
});
it('sync skills into opencodes own tree, never Claudes', () => {
expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'");
});
it('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 { 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 { tmpdir, homedir } from 'node:os';
import { tmpdir } from 'node:os';
import {
opencodeConfigDir,
opencodeSkillsDir,
opencodeStatePath,
withOpencodeDir,
installOpencodePlugins,
registerOpencodeTuiPlugin,
@@ -13,110 +12,158 @@ import {
writeOpencodeState,
storedToken,
} 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', () => {
it('resolves the config dir XDG-aware', () => {
describe('opencodeConfigDir', () => {
it('defaults to ~/.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', () => {
const p = withOpencodeDir('/base');
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('honours XDG_CONFIG_HOME — provisioning ~/.config would be invisible to opencode', () => {
expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/opencode');
});
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');
});
it('state file lives under ~/.mcpctl', () => {
expect(opencodeStatePath('/home/u')).toBe('/home/u/.mcpctl/opencode-state.json');
});
});
describe('installOpencodePlugins', () => {
let tmp: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
let dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('writes both plugins into the config dir', async () => {
const written = await installOpencodePlugins(tmp);
expect(written).toContain(join(tmp, 'plugin', 'mcpctl.ts'));
expect(written).toContain(join(tmp, 'mcpctl', 'mcpctl-tui.tsx'));
// The server plugin lands where opencode auto-discovers it.
expect(readFileSync(join(tmp, 'plugin', 'mcpctl.ts'), 'utf-8')).toContain('mcpctl');
it('writes the server plugin where opencode auto-discovers it and the TUI plugin beside it', async () => {
const written = await installOpencodePlugins(dir);
const paths = withOpencodeDir(dir);
expect(written).toEqual([paths.serverPluginPath(), paths.tuiPluginPath()]);
expect(existsSync(join(dir, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME))).toBe(true);
expect(existsSync(join(dir, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME))).toBe(true);
});
it('keeps the .tsx extension — opencode transpiles the TUI plugin by extension', async () => {
await installOpencodePlugins(dir);
expect(withOpencodeDir(dir).tuiPluginPath().endsWith('.tsx')).toBe(true);
});
it('is idempotent (re-running overwrites in place)', async () => {
await installOpencodePlugins(dir);
const first = readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8');
await installOpencodePlugins(dir);
expect(readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8')).toBe(first);
});
});
describe('registerOpencodeTuiPlugin', () => {
let tmp: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
let dir: string;
let tuiJson: string;
const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx';
it('creates tui.json and registers the plugin', async () => {
const tuiJson = join(tmp, 'tui.json');
const res = await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx');
expect(res.added).toBe(true);
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-'));
tuiJson = join(dir, 'tui.json');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
it('creates tui.json with the plugin and a $schema', async () => {
expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true });
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).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 () => {
const tuiJson = join(tmp, 'tui.json');
writeFileSync(tuiJson, JSON.stringify({ plugin: ['/other/plugin.ts'] }));
await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx');
await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx');
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual(['/other/plugin.ts', '/cfg/mcpctl/mcpctl-tui.tsx']);
it('is idempotent and leaves the file untouched on a no-op run', async () => {
await registerOpencodeTuiPlugin(tuiJson, pluginPath);
const before = readFileSync(tuiJson, 'utf-8');
expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: false });
expect(readFileSync(tuiJson, 'utf-8')).toBe(before);
});
it('drops a stale entry pointing at an older install location', async () => {
const tuiJson = join(tmp, 'tui.json');
writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/mcpctl/mcpctl-tui.tsx'] }));
await registerOpencodeTuiPlugin(tuiJson, '/new/mcpctl/mcpctl-tui.tsx');
it('preserves other TUI plugins and unrelated keys', async () => {
writeFileSync(tuiJson, JSON.stringify({ plugin: ['opencode-tui-utils'], theme: 'nord' }));
await registerOpencodeTuiPlugin(tuiJson, pluginPath);
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual(['/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 () => {
const tuiJson = join(tmp, 'tui.json');
writeFileSync(tuiJson, '{not json');
await expect(registerOpencodeTuiPlugin(tuiJson, '/p.tsx')).rejects.toThrow();
it('drops a stale entry for an older install location of our own plugin', async () => {
// Left behind, opencode fails to load a file that no longer exists on
// every start.
writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/place/mcpctl-tui.tsx', 'other-plugin'] }));
await registerOpencodeTuiPlugin(tuiJson, pluginPath);
expect(JSON.parse(readFileSync(tuiJson, 'utf-8')).plugin).toEqual(['other-plugin', pluginPath]);
});
it('refuses to overwrite a corrupt tui.json instead of dropping the users plugins', async () => {
writeFileSync(tuiJson, '{ this is not json');
await expect(registerOpencodeTuiPlugin(tuiJson, pluginPath)).rejects.toThrow(/not valid JSON/);
expect(readFileSync(tuiJson, 'utf-8')).toBe('{ this is not json');
});
it('treats an empty file as a fresh start', async () => {
writeFileSync(tuiJson, ' \n');
expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true });
});
});
describe('opencode state file', () => {
let tmp: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
let dir: string;
let statePath: string;
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 () => {
expect(await readOpencodeState(statePath())).toEqual({});
it('creates the directory and writes project + gateway + token', async () => {
await writeOpencodeState({ project: 'docmost', gatewayUrl: 'https://gw', token: 'mcpctl_pat_a' }, statePath);
expect(await readOpencodeState(statePath)).toEqual({
project: 'docmost',
gatewayUrl: 'https://gw',
tokens: { docmost: 'mcpctl_pat_a' },
});
});
it('writes project + gateway + token, preserved across switches', async () => {
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'pat_a' }, statePath());
await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'pat_b' }, statePath());
it('is written 0600 — it holds bearer tokens', async () => {
await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath);
expect(statSync(statePath).mode & 0o777).toBe(0o600);
});
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.tokens).toEqual({ a: 'pat_a', b: 'pat_b' });
expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-b' });
});
it('writes the file 0600', async () => {
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'pat_a' }, statePath());
const mode = statSync(statePath()).mode & 0o777;
expect(mode).toBe(0o600);
it('switching without a new token leaves the stored one alone', async () => {
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath);
await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw2' }, statePath);
const state = await readOpencodeState(statePath);
expect(state.gatewayUrl).toBe('https://gw2');
expect(storedToken(state, 'a')).toBe('tok-a');
});
it('storedToken returns the project token or null', () => {
expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x');
it('reads a missing or corrupt state as empty rather than throwing', async () => {
expect(await readOpencodeState(join(dir, 'nope.json'))).toEqual({});
mkdirSync(join(dir, 'x'), { recursive: true });
writeFileSync(join(dir, 'x', 's.json'), 'not json');
expect(await readOpencodeState(join(dir, 'x', 's.json'))).toEqual({});
});
it('storedToken ignores an empty or missing entry', () => {
expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull();
expect(storedToken({}, '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';
export default defineProject({
// The opencode TUI plugin is a .tsx that ships as source; its pure helpers are
// unit-tested by importing that file directly, so the test run needs the same
// JSX runtime opencode transpiles it with.
esbuild: {
jsx: 'automatic',
jsxImportSource: '@opentui/solid',
},
test: {
name: 'cli',
include: ['tests/**/*.test.ts'],

View File

@@ -1,21 +1,23 @@
/** @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
* `~/.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:
* - `/mcpctl` — pick the active project from a filterable dialog
* - `/mcpctl-status` show the active project, mount state and gateway URL
* - `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir
* - a `mcpctl:<project>` footer indicator, rendered right of the model name on
* the prompt's bottom line, one row above the token counter.
* What it adds to opencode:
* - `/mcpctl` — pick the active project from a filterable dialog
* - `/mcpctl-status` — what is mounted, from where, as which user
* - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill
* - a `mcpctl:<project>` indicator in the prompt footer, next to the model
* name and the token counter
*
* The switch is handed to the `mcpctl` CLI (the same binary that installed this
* file) so token minting, credential state and skills stay in one place; the
* plugin stays a UI shell. Once the CLI has rewritten the state file, the mount
* is re-pointed live through opencode's own MCP API — no restart.
* The switch itself is delegated to the `mcpctl` CLI (the same binary that
* installed this file), so token minting, state and skills stay in one place
* and this stays a UI shell. Once the CLI has rewritten the state file, the
* mount is re-pointed live through opencode's own MCP API — no restart, unlike
* every config-file-based integration.
*
* Only Node builtins + opencode's plugin API are imported.
*/
@@ -23,13 +25,19 @@ import type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';
import type { JSX } from '@opentui/solid';
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { homedir } from 'node:os';
/** MCP server name the mount lives under — must match the server plugin. */
const SERVER_NAME = 'mcpctl';
/** kv key holding the label the footer renders. */
/**
* kv key holding the label the footer renders.
*
* kv is a reactive store, so writing it here re-renders the slot with no
* signal plumbing of our own; it also survives across sessions, so the label is
* correct on the very first frame instead of after the state file is read.
*/
const KV_LABEL = 'mcpctl.project';
interface OpencodeState {
@@ -49,8 +57,7 @@ function statePath(): string {
async function readState(): Promise<OpencodeState> {
try {
const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
} catch {
return {};
}
@@ -80,16 +87,15 @@ async function listProjects(): Promise<ProjectInfo[]> {
const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;
return parsed
.filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')
.map((p) => ({
name: p.name,
description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined,
}));
.map((p) => ({ 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
* pick), then alphabetical. opencode's DialogSelect already filters as you
* type, so no pre-filter prompt is needed — only a sensible starting order.
* Active project first, then alphabetical.
*
* opencode's select dialog does its own fuzzy filtering as you type, so unlike
* the pi and prime-agent switchers this needs no pre-filter prompt — only a
* sensible starting order, since the most likely pick is "the one I am on".
*
* Exported so the ordering is unit-tested rather than eyeballed through a TUI.
*/
@@ -124,12 +130,20 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
*/
async function mount(project: string, state: OpencodeState): Promise<void> {
const gatewayUrl = state.gatewayUrl;
if (gatewayUrl === undefined || gatewayUrl === '') {
throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project <name>`');
}
if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project <name>`');
const token = state.tokens?.[project] ?? '';
const headers: Record<string, string> = {};
if (token !== '') headers['Authorization'] = `Bearer ${token}`;
// 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({
name: SERVER_NAME,
config: {
@@ -148,7 +162,8 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
// The CLI mints/reuses the project token, rewrites the state file and
// syncs skills. --skip-plugin leaves this very file alone (rewriting a
// loaded plugin mid-session buys nothing); --skip-marker stops us
// silently re-scoping whatever repo opencode was started in.
// silently re-scoping whatever repo opencode was started in, which
// Claude Code's own skills sync would then pick up.
await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);
} catch (err) {
api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });
@@ -157,6 +172,8 @@ const tui = async (api: TuiPluginApi): Promise<void> => {
try {
await mount(project, await readState());
} catch (err) {
// The state file is already updated, so a restart would recover — say so
// rather than reporting a success the tools do not back up.
api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });
await refreshIndicator();
return;
@@ -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
@@ -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
// *replaces* the cwd/version footer rather than adding to it, and
// `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 => (
<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({
order: 100,

View File

@@ -1,33 +1,31 @@
/**
* mcpctl opencode server plugin — mounts the active project's MCP gateway.
*
* Installed by `mcpctl config opencode` into opencode's auto-discovered
* `plugin/` directory (`~/.config/opencode/plugin/mcpctl.ts`), where it is
* 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_*`).
* Installed by `mcpctl config opencode` into
* `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.
*
* WHY A PLUGIN AND NOT AN `mcp` BLOCK IN opencode.json:
* 1. The gateway needs an `Authorization: Bearer <mcpctl PAT>` header — a
* secret that does not belong in a mode-0644 config file users paste into
* bug reports. `~/.mcpctl/opencode-state.json` is 0600 like every other
* mcpctl credential.
* 2. Switching projects must work WITHOUT restarting opencode. The server
* exposes `POST /mcp` (add) and `POST /mcp/{name}/disconnect`, so the
* mount can be re-pointed live; an `mcp` block in opencode.json cannot.
* WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:
* 1. The gateway needs an `Authorization: Bearer <mcpctl PAT>` header. Putting
* it in opencode.json means a secret in a mode-0644 config file that users
* paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest
* of mcpctl's credentials.
* 2. Switching projects has to work *without restarting opencode*. The server
* exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can
* be re-pointed live — a config file can't do that.
*
* It exists so headless runs (`opencode run ...`), which load no TUI plugins at
* all, still get the active project's tools; the TUI plugin
* (`mcpctl-opencode-tui.tsx`) drives the switch and the on-screen indicator.
* The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that
* headless runs (`opencode run ...`), which load no TUI plugins at all, still
* get the active project's tools.
*
* Only Node builtins + the plugin API are imported.
* Only Node builtins + the plugin API are imported, so the installed file needs
* no dependencies of its own.
*/
import type { Plugin, PluginModule } from '@opencode-ai/plugin';
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
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';
interface OpencodeState {
@@ -42,8 +40,7 @@ function statePath(): string {
async function readState(): Promise<OpencodeState> {
try {
const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
} catch {
return {};
}
@@ -56,17 +53,26 @@ function projectUrl(gatewayUrl: string, project: string): string {
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
* binds a gated project's unlocked state to the `mcp-session-id` of that
* connection. Re-adding an unchanged config every turn would therefore drop
* the gate 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
* is not connected, where reconnecting is the whole point.
*/
let mounted: string | null = null;
/**
* Mount (or re-point) the active project.
*
* The MCP server is always registered under the same name, so tools keep the
* stable `mcpctl_*` prefix across switches and the model never sees a tool
* namespace vanish mid-conversation. opencode resolves the tool list per
* request, so a re-point is picked up on the next turn with no restart and no
* "the tools you were told about are gone" announcement to the model.
*/
async function mount(): Promise<void> {
const state = await readState();
const project = state.project;
@@ -123,12 +129,16 @@ const server: Plugin = async ({ client }) => {
return inflight;
}
// Deliberately NOT mounted here. Plugin setup runs before the server accepts
// connections, and `client.mcp.add` calls back into that same server —
// awaiting it at this point hangs opencode on a blank screen before the TUI
// ever draws. Both hooks below fire only once the server is live.
// NOTE: deliberately NOT mounted here. Plugin setup runs before the server is
// accepting connections, and `client.mcp.add` calls back into that same
// server — awaiting it at this point hangs opencode on a blank screen before
// the TUI ever draws. Both hooks below fire only once the server is live.
return {
/** First contact: mount as soon as the server is up. */
/**
* First contact: mount as soon as the server is up, so a session that never
* sends a message still shows the project's tools (and the sidebar shows
* the mount as connected).
*/
event: async (): Promise<void> => {
await ensureMounted();
},
@@ -137,10 +147,11 @@ const server: Plugin = async ({ client }) => {
* Re-assert the mount before every user turn.
*
* `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
* makes an external switch take effect on the next message instead of on
* the next restart. When nothing changed this is one state-file read — the
* mount hook won't re-register an unchanged mount.
* switcher in a *different* opencode window) rewrites the state file
* underneath us. Re-reading here is what makes an external switch take
* effect on the next message instead of on the next restart. When nothing
* changed this is a state-file read and a status call — `mount` will not
* re-register a mount that is already pointing at the right place.
*/
'chat.message': async (): Promise<void> => {
await ensureMounted();