Compare commits

..

1 Commits

Author SHA1 Message Date
Michal
2022e584d1 feat(opencode): native opencode addon — /mcpctl switcher, live project switching, footer indicator
Some checks failed
CI/CD / lint (pull_request) Successful in 1m10s
CI/CD / test (pull_request) Successful in 1m22s
CI/CD / typecheck (pull_request) Successful in 3m8s
CI/CD / smoke (pull_request) Failing after 1m55s
CI/CD / build (pull_request) Successful in 2m8s
CI/CD / publish (pull_request) Has been skipped
Implement mcpctl as an opencode addon mirroring the pi/prime-agent
integrations. Mounts the active project's MCP gateway through opencode's own
live MCP API, so switching projects needs no restart and opencode.json is
never touched (the bearer token stays in a 0600 state file).

- server plugin (plugin/mcpctl.ts): headless mount under a stable 'mcpctl'
  name, re-asserted on first contact + before each turn; skips re-registering
  an unchanged mount so a gated project's begin_session state survives.
- TUI plugin (mcpctl/mcpctl-tui.tsx): /mcpctl filterable picker (live switch),
  /mcpctl-status, /mcpctl-skills, and a mcpctl:<project> footer indicator
  right of the model name, directly above the token counter.
- mcpctl config opencode: mint/reuse project mcptoken, install + register
  plugins, write 0600 state, write marker, sync skills.
- skills sync --agent opencode: new shared-tree target (XDG-aware).
- shared credential plumbing lifted from config prime-agent and parameterised
  by agent so both hosts share mint/reuse/retire logic.
- embedded-source generator + freshness test; typechecked against the real
  @opencode-ai/plugin types (1.18.15); unit tests for settings, order, embed.

typecheck (incl. opencode-ext) and the full cli suite (619 tests) pass.
2026-08-09 00:54:51 +01:00
18 changed files with 398 additions and 742 deletions

View File

@@ -198,31 +198,29 @@ 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 through the running app — its own MCP registry, so mcpctl mounts the project's gateway through the running
which means you can **switch projects from the TUI without restarting**. app — switching projects needs **no restart**.
```bash ```bash
mcpctl config opencode --project monitoring mcpctl config opencode --project monitoring
``` ```
This mints (or reuses) the project's gateway token into That mints (or reuses) the project token, writes it to a 0600 state file
`~/.mcpctl/opencode-state.json` (0600 — the token never lands in (`~/.mcpctl/opencode-state.json` — the token never lands in a mode-0644
`opencode.json`), installs a server plugin into `opencode.json` people paste into bug reports), 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 `~/.config/opencode/mcpctl/`, registers the latter in `tui.json`, and syncs
`~/.config/opencode/tui.json`, and syncs skills into skills into `~/.config/opencode/skill/`.
`~/.config/opencode/skill/`.
Inside opencode: Inside opencode:
- `/mcpctl` (or `<leader>m`) — **switch project** from a filterable picker; - `/mcpctl` — filterable project picker; switches **live, no restart**
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
- the active project shows as `mcpctl:<project>` in the prompt footer, next to - a `mcpctl:<project>` footer indicator right of the model name, above the
the model name token counter
Skip individual steps as needed: Skip individual steps:
```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
@@ -232,8 +230,11 @@ 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 on their own with Re-sync skills later:
`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,8 +31,7 @@ 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", name: "mcpctl", type: "remote", url, headers })
▼ type: "remote", url, headers })
opencode's MCP registry opencode's MCP registry
@@ -85,13 +84,10 @@ TUI plugins). It adds:
| Command | What it does | | Command | What it does |
|---------|--------------| |---------|--------------|
| `/mcpctl` (or `<leader>m`) | Filterable project picker; switches live | | `/mcpctl` | 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.
@@ -106,15 +102,9 @@ 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 right on the first frame. so the label is correct on the very first frame.
#### Why the footer and not the status bar #### Why the footer and not the status bar
@@ -127,11 +117,9 @@ 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. The home prompt box is narrow There is no slot on the status-bar line itself. On the home screen the prompt
enough that the default wrap breaks `mcpctl:homeautomation` across two lines box is narrow, so a long project name wraps onto a second line; in a session
mid-word, so the label renders `wrapMode="none" truncate` — clipping the tail of (where the prompt is full width) it always fits on one.
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', 'src/opencode-ext/*.ts'], files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'],
languageOptions: { languageOptions: {
parser: tsparser, parser: tsparser,
parserOptions: { parserOptions: {

View File

@@ -46,9 +46,7 @@
"@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/core": "0.4.5", "@opentui/solid": "^0.5.1",
"@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",
@@ -56,7 +54,6 @@
"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,16 +16,10 @@ 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.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)) 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))
'@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.4.5 specifier: ^0.5.1
version: 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) version: 0.5.1(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
@@ -47,9 +41,6 @@ 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
@@ -1678,70 +1669,53 @@ packages:
resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@opentui/core-darwin-arm64@0.4.5': '@opentui/core-darwin-arm64@0.5.1':
resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} resolution: {integrity: sha512-Yl3JBLYRrBN+SxXY/gYaqCT/JNrN50K4xO7hYC+/Si8/FgOrBlbRmfJIUNQdZMMLUvOMA+I813+hDw3xfarBzQ==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@opentui/core-darwin-x64@0.4.5': '@opentui/core-darwin-x64@0.5.1':
resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} resolution: {integrity: sha512-kqMVu+LGuHSCxYFkVJtmuyLLLTMztILSNnlx1eSpHHUiDV4PMc+zkxwRIXO+o0TFTW3gNUKleKUkggriYje7Vw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@opentui/core-linux-arm64-musl@0.4.5': '@opentui/core-linux-arm64-musl@0.5.1':
resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} resolution: {integrity: sha512-rmFMtiCm8I0fESB834sTN/ewoI+QDSber588ZO+i08JR6mbv7hkiKW2H/MhiAY1GxGK4nXApleBMyGVOlVDvgQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@opentui/core-linux-arm64@0.4.5': '@opentui/core-linux-arm64@0.5.1':
resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} resolution: {integrity: sha512-PpE1nCHRkxEvSYyZFMToPHjQoVh50A7+BbgetlTX/5ImXzo6iSO83a+7M/1WgZnNu+uZJf5GZKAAcLoRrvQl3Q==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@opentui/core-linux-x64-musl@0.4.5': '@opentui/core-linux-x64-musl@0.5.1':
resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} resolution: {integrity: sha512-WO8RjhqKyqW/7P0xHdEVT8JGfU2MO7RlK0kdkNnRSnAEVwsTNd2ibhmKDPLGpo/DKaLuA00CsnrNiLGZZiQJKQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@opentui/core-linux-x64@0.4.5': '@opentui/core-linux-x64@0.5.1':
resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} resolution: {integrity: sha512-/CxFxFv+ffMof2nYQrpgEfNkWKkKxYUSfwdt2RdDN5fZRhcxjE949743rV0Oovw5Az63qxPgbyfcZVNVO2HVNg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@opentui/core-win32-arm64@0.4.5': '@opentui/core-win32-arm64@0.5.1':
resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} resolution: {integrity: sha512-AgeTjZbdMxSiuBjyLvcug91qd1Ds6Dlg5z4lCInqL7mPQicDEnKZs5lF2FAaktcU7RPi2wLybbQ/vM0NbpXYmw==}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@opentui/core-win32-x64@0.4.5': '@opentui/core-win32-x64@0.5.1':
resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} resolution: {integrity: sha512-VttbQHVoZQ5uW5IcQeUHPEx/WFQ2mMflukhhbBjpNSdZOPdzmmC4QGFPQznJVwuzXTnjQ2Nll4AY0ROJ/Q3nkw==}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@opentui/core@0.4.5': '@opentui/core@0.5.1':
resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} resolution: {integrity: sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg==}
peerDependencies: peerDependencies:
web-tree-sitter: 0.25.10 web-tree-sitter: 0.25.10
'@opentui/keymap@0.4.5': '@opentui/solid@0.5.1':
resolution: {integrity: sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==} resolution: {integrity: sha512-eynJILdvxmprr7oou3cqAiAzZ6zzRU0m1y42/L9GZObTKr76tVtPFQpLvQl6ZJTWMoswpwx1pJWqcUN+ZAI8Rg==}
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
@@ -2584,8 +2558,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.2.4: bun-ffi-structs@0.3.1:
resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==} resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==}
peerDependencies: peerDependencies:
typescript: ^5 typescript: ^5
@@ -5444,15 +5418,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.7 '@babel/code-frame': 7.29.0
'@babel/generator': 7.29.8 '@babel/generator': 7.29.1
'@babel/helper-compilation-targets': 7.28.6 '@babel/helper-compilation-targets': 7.28.6
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0)
'@babel/helpers': 7.29.2 '@babel/helpers': 7.29.2
'@babel/parser': 7.29.8 '@babel/parser': 7.29.0
'@babel/template': 7.29.7 '@babel/template': 7.28.6
'@babel/traverse': 7.29.8 '@babel/traverse': 7.29.0
'@babel/types': 7.29.8 '@babel/types': 7.29.0
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
@@ -5535,7 +5509,7 @@ snapshots:
'@babel/helper-module-imports@7.18.6': '@babel/helper-module-imports@7.18.6':
dependencies: dependencies:
'@babel/types': 7.29.8 '@babel/types': 7.29.0
'@babel/helper-module-imports@7.28.6': '@babel/helper-module-imports@7.28.6':
dependencies: dependencies:
@@ -5551,6 +5525,15 @@ 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
@@ -5658,7 +5641,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.29.7 '@babel/helper-plugin-utils': 7.28.6
'@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)
@@ -6641,16 +6624,15 @@ 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.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/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))':
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.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/core': 0.5.1(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.5.1(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:
@@ -6775,66 +6757,55 @@ snapshots:
'@opentelemetry/semantic-conventions@1.43.0': {} '@opentelemetry/semantic-conventions@1.43.0': {}
'@opentui/core-darwin-arm64@0.4.5': '@opentui/core-darwin-arm64@0.5.1':
optional: true optional: true
'@opentui/core-darwin-x64@0.4.5': '@opentui/core-darwin-x64@0.5.1':
optional: true optional: true
'@opentui/core-linux-arm64-musl@0.4.5': '@opentui/core-linux-arm64-musl@0.5.1':
optional: true optional: true
'@opentui/core-linux-arm64@0.4.5': '@opentui/core-linux-arm64@0.5.1':
optional: true optional: true
'@opentui/core-linux-x64-musl@0.4.5': '@opentui/core-linux-x64-musl@0.5.1':
optional: true optional: true
'@opentui/core-linux-x64@0.4.5': '@opentui/core-linux-x64@0.5.1':
optional: true optional: true
'@opentui/core-win32-arm64@0.4.5': '@opentui/core-win32-arm64@0.5.1':
optional: true optional: true
'@opentui/core-win32-x64@0.4.5': '@opentui/core-win32-x64@0.5.1':
optional: true optional: true
'@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': '@opentui/core@0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10)':
dependencies: dependencies:
bun-ffi-structs: 0.2.4(typescript@5.9.3) bun-ffi-structs: 0.3.1(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.4.5 '@opentui/core-darwin-arm64': 0.5.1
'@opentui/core-darwin-x64': 0.4.5 '@opentui/core-darwin-x64': 0.5.1
'@opentui/core-linux-arm64': 0.4.5 '@opentui/core-linux-arm64': 0.5.1
'@opentui/core-linux-arm64-musl': 0.4.5 '@opentui/core-linux-arm64-musl': 0.5.1
'@opentui/core-linux-x64': 0.4.5 '@opentui/core-linux-x64': 0.5.1
'@opentui/core-linux-x64-musl': 0.4.5 '@opentui/core-linux-x64-musl': 0.5.1
'@opentui/core-win32-arm64': 0.4.5 '@opentui/core-win32-arm64': 0.5.1
'@opentui/core-win32-x64': 0.4.5 '@opentui/core-win32-x64': 0.5.1
transitivePeerDependencies: transitivePeerDependencies:
- typescript - typescript
'@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.5.1(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.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) '@opentui/core': 0.5.1(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
@@ -7177,7 +7148,7 @@ snapshots:
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.0
'@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
@@ -7573,7 +7544,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.8 '@babel/types': 7.29.0
html-entities: 2.3.3 html-entities: 2.3.3
parse5: 7.3.0 parse5: 7.3.0
@@ -7717,7 +7688,7 @@ snapshots:
buildcheck@0.0.7: buildcheck@0.0.7:
optional: true optional: true
bun-ffi-structs@0.2.4(typescript@5.9.3): bun-ffi-structs@0.3.1(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -8694,7 +8665,7 @@ snapshots:
is-fullwidth-code-point@5.1.0: is-fullwidth-code-point@5.1.0:
dependencies: dependencies:
get-east-asian-width: 1.6.0 get-east-asian-width: 1.5.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) { function embed(src: string): string {
// 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,15 +35,8 @@ 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/
* (and registers them), so an installed binary with no source tree can still * so an installed binary with no source tree can still provision a working
* provision a working opencode integration. * 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,6 +34,7 @@ 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,
@@ -44,7 +45,6 @@ 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 // identical; only the token *name* differs, so the agent label is a parameter
// parameter rather than a second copy of this logic. // 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,6 +134,25 @@ 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.
@@ -181,26 +200,6 @@ 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')
@@ -535,7 +534,6 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (hidden) { if (hidden) {
void cmd; void cmd;
} }
} }
registerClaudeCommand('claude', false); registerClaudeCommand('claude', false);
@@ -629,13 +627,7 @@ 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: install the plugins + provision the gateway credential ── // ── opencode: plugins + state (live MCP, never touches opencode.json) ──
//
// 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,19 +35,8 @@ 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. */
* Every agent whose skill tree `mcpctl skills sync --agent` can write. export type SyncTarget = 'claude' | 'prime-agent' | 'pi' | 'opencode';
*
* `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
@@ -118,8 +107,6 @@ 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;
} }
@@ -208,9 +195,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. // tree + separate state file so the two never collide. prime-agent, pi
// prime-agent, pi and opencode share the same skill semantics: their own flat // and opencode share the same skill semantics: their own flat skill tree +
// skill tree + separate state file, no SessionStart hooks / postInstall, no // 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
@@ -575,16 +562,17 @@ 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()): string { export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir(), env: NodeJS.ProcessEnv = process.env): 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(process.env, homeDir); return opencodeSkillsDir(env, homeDir);
case 'claude': case 'claude':
default: default:
return join(homeDir, '.claude', 'skills'); return join(homeDir, '.claude', 'skills');
@@ -620,8 +608,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 (!isSyncTarget(agent)) { if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi' && agent !== 'opencode') {
warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`); warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', 'pi', or 'opencode')`);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -633,7 +621,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, target: agent as SyncTarget,
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,6 +19,10 @@
* 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,210 +1,101 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } 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';
interface ClientCalls { posts: Array<{ path: string; body?: unknown }> } function mockClient(visibleSkills: unknown[] = []): ApiClient {
/**
* @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.startsWith('/api/v1/mcptokens')) return tokens; if (path.endsWith('/skills/visible')) return visibleSkills;
if (path.endsWith('/skills/visible')) return []; if (path.startsWith('/api/v1/skills/')) return {
return {}; id: 's1', name: 'demo', description: 'd', semver: '1.0.0',
}), 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;
let ocDir: string; const log = (...args: string[]) => output.push(args.join(' '));
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. */ beforeEach(() => {
function seedState(value: unknown): void { client = mockClient();
mkdirSync(ocDir, { recursive: true }); output = [];
writeFileSync(statePath(), JSON.stringify(value)); tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-opencode-'));
} });
function command(client: ApiClient) { afterEach(() => {
return createConfigCommand( rmSync(tmpDir, { recursive: true, force: true });
});
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('dry-run writes nothing', async () => {
const ocDir = join(tmpDir, 'opencode-cfg2');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log }, { configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log },
); );
}
beforeEach(() => { await cmd.parseAsync(
output = []; ['opencode', '--project', 'monitoring', '--opencode-dir', ocDir, '--dry-run'],
calls = { posts: [] }; { from: 'user' },
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-oc-')); );
ocDir = join(tmpDir, 'opencode');
});
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
it('requires a project', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--opencode-dir', ocDir, '--skip-skills'], { from: 'user' });
expect(output.join('\n')).toContain('--project is required');
expect(existsSync(statePath())).toBe(false);
});
it('installs both plugins, registers the TUI one and writes the state file', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'docmost', '--opencode-dir', ocDir,
'--gateway-url', 'https://gw.example', '--token', 'mcpctl_pat_SUPPLIED000000',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(true);
expect(existsSync(join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'))).toBe(true);
const tui = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8'));
expect(tui.plugin).toEqual([join(ocDir, 'mcpctl', 'mcpctl-tui.tsx')]);
const state = JSON.parse(readFileSync(statePath(), 'utf-8'));
expect(state).toEqual({
project: 'docmost',
gatewayUrl: 'https://gw.example',
tokens: { docmost: 'mcpctl_pat_SUPPLIED000000' },
});
});
it('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/',
'--token', 't', '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example');
});
it('--token never mints', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
});
it('mints a uniquely-named opencode token when none is stored', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
const mint = calls.posts.find((c) => c.path === '/api/v1/mcptokens');
expect(mint).toBeDefined();
// A fixed name could only ever be minted once per project: McpToken is
// unique on (name, projectId) and revoke is a soft delete.
expect((mint?.body as { name: string }).name).toMatch(/^opencode-/);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000');
});
it('reuses a stored token that the server still reports as active', async () => {
// 16-char prefix is what the server exposes; the secret is never re-sent.
const stored = 'mcpctl_pat_STORED000000000';
seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } });
const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'active', tokenPrefix: stored.slice(0, 16) }]);
await command(client).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
expect(output.join('\n')).toContain('already present');
});
it('re-mints when the stored token has been revoked server-side', async () => {
const stored = 'mcpctl_pat_REVOKED00000000';
seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } });
const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'revoked', tokenPrefix: stored.slice(0, 16) }]);
await command(client).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(1);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000');
});
it('leaves the previous project mounted when no credential can be provisioned', async () => {
// A switch with no usable credential is a FAILURE: exiting 0 here would
// have the /mcpctl switcher report success over a project with no tools.
seedState({ project: 'old', gatewayUrl: 'https://gw', tokens: { old: 'tok-old' } });
const client = {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})), // mint returns no token
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
const prevExit = process.exitCode;
await command(client).parseAsync(
['opencode', '--project', 'new', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(process.exitCode).toBe(1);
process.exitCode = prevExit;
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('old');
expect(output.join('\n')).toContain('Aborted');
});
it('--skip-plugin updates state without touching the loaded plugin files', async () => {
// This is the path the /mcpctl switcher takes: rewriting the very file
// opencode has already loaded buys nothing.
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-plugin', '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false); expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false);
expect(existsSync(join(ocDir, 'tui.json'))).toBe(false); expect(existsSync(join(ocDir, 'mcpctl-state.json'))).toBe(false);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('p'); expect(client.get).not.toHaveBeenCalled();
}); });
it('--dry-run reports the plan and writes nothing', async () => { it('fails fast with no project', async () => {
await command(mockClient(calls)).parseAsync( const cmd = createConfigCommand(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--dry-run'], { from: 'user' }); { configDeps: { configDir: tmpDir }, log },
const plan = JSON.parse(output.join('\n')); { client, credentialsDeps: { configDir: tmpDir }, log },
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 () => { await cmd.parseAsync(['opencode'], { from: 'user' });
const cwd = process.cwd(); expect(process.exitCode).toBe(1);
const workDir = join(tmpDir, 'work'); process.exitCode = 0;
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_TUI_PLUGIN_SOURCE,
OPENCODE_SERVER_PLUGIN_FILENAME, OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_SOURCE,
OPENCODE_TUI_PLUGIN_FILENAME, OPENCODE_TUI_PLUGIN_FILENAME,
} from '../../src/config/opencode-extension.js'; } from '../../src/config/opencode-extension.js';
@@ -13,78 +13,37 @@ 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 gives. * the completions check and the pi embed test give.
*/ */
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('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { it('server plugin matches src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed') const onDisk = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8');
.toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8')); expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed').toBe(onDisk);
expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'));
}); });
it('install under names opencode can actually load', () => { it('TUI plugin matches src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
// The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is const onDisk = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8');
// referenced by path from tui.json and must stay .tsx for its JSX to be expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed').toBe(onDisk);
// 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('are self-contained — the installed files have no mcpctl imports to resolve', () => { it('ever-mounts the gateway through opencode, never a secret in opencode.json', () => {
for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { // The server plugin mounts via client.mcp.add and reads a 0600 state file.
expect(src).not.toMatch(/from '@mcpctl\//); expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('client.mcp.add');
expect(src).not.toMatch(/from '\.\.\//); 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');
it('keep the JSX pragma the TUI plugin needs to render its indicator', () => { expect(OPENCODE_SERVER_PLUGIN_SOURCE).not.toContain('headers in opencode.json');
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

@@ -1,67 +0,0 @@
import { describe, it, expect } from 'vitest';
import { orderProjects, indicatorLabel } from '../../../opencode-ext/mcpctl-opencode-tui.js';
/**
* opencode's select dialog filters as you type, so the picker only has to get
* the *order* right — the active project first, because "the one I am on" is
* the most likely pick and real installs run to hundreds of projects (smoke-test
* leftovers included).
*/
const PROJECTS = [
{ name: 'smoke-proj-none-mohimh46' },
{ name: 'homeautomation', description: 'house' },
{ name: 'docmost' },
{ name: 'copy-homeautomation' },
{ name: 'labctl' },
{ name: 'sre' },
];
describe('orderProjects', () => {
it('puts the active project first, then sorts alphabetically', () => {
expect(orderProjects(PROJECTS, 'labctl').map((p) => p.name)).toEqual([
'labctl',
'copy-homeautomation',
'docmost',
'homeautomation',
'smoke-proj-none-mohimh46',
'sre',
]);
});
it('sorts alphabetically when nothing is active', () => {
expect(orderProjects(PROJECTS, null).map((p) => p.name)).toEqual([
'copy-homeautomation',
'docmost',
'homeautomation',
'labctl',
'smoke-proj-none-mohimh46',
'sre',
]);
});
it('never drops or duplicates a project', () => {
expect(orderProjects(PROJECTS, 'sre')).toHaveLength(PROJECTS.length);
expect(orderProjects(PROJECTS, 'not-a-project')).toHaveLength(PROJECTS.length);
});
it('does not mutate the callers list', () => {
const input = [...PROJECTS];
orderProjects(input, 'sre');
expect(input.map((p) => p.name)).toEqual(PROJECTS.map((p) => p.name));
});
it('keeps descriptions, which the dialog shows under each row', () => {
expect(orderProjects(PROJECTS, null).find((p) => p.name === 'homeautomation')?.description).toBe('house');
});
});
describe('indicatorLabel', () => {
it('names the active project', () => {
expect(indicatorLabel('docmost')).toBe('mcpctl:docmost');
});
it('says so when there is none, rather than rendering a bare prefix', () => {
expect(indicatorLabel(null)).toBe('mcpctl:none');
expect(indicatorLabel('')).toBe('mcpctl:none');
});
});

View File

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

View File

@@ -1,13 +1,6 @@
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,23 +1,21 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
/** /**
* mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator. * mcpctl opencode TUI plugin — `/mcpctl` project switcher + footer indicator.
* *
* Installed by `mcpctl config opencode` into * Installed by `mcpctl config opencode` into
* `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in `tui.json`.
* `~/.config/opencode/tui.json`.
* *
* What it adds to opencode: * Ships the same UX the pi and prime-agent integrations do, for opencode:
* - `/mcpctl` — pick the active project from a filterable dialog * - `/mcpctl` — pick the active project from a filterable dialog
* - `/mcpctl-status` — what is mounted, from where, as which user * - `/mcpctl-status` show the active project, mount state and gateway URL
* - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill * - `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir
* - a `mcpctl:<project>` indicator in the prompt footer, next to the model * - a `mcpctl:<project>` footer indicator, rendered right of the model name on
* name and the token counter * the prompt's bottom line, one row above the token counter.
* *
* The switch itself is delegated to the `mcpctl` CLI (the same binary that * The switch is handed to the `mcpctl` CLI (the same binary that installed this
* installed this file), so token minting, state and skills stay in one place * file) so token minting, credential state and skills stay in one place; the
* and this stays a UI shell. Once the CLI has rewritten the state file, the * plugin stays a UI shell. Once the CLI has rewritten the state file, the mount
* mount is re-pointed live through opencode's own MCP API — no restart, unlike * is re-pointed live through opencode's own MCP API — no restart.
* every config-file-based integration.
* *
* Only Node builtins + opencode's plugin API are imported. * Only Node builtins + opencode's plugin API are imported.
*/ */
@@ -25,19 +23,13 @@ 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 { join } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { join } from 'node:path';
/** 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 {
@@ -57,7 +49,8 @@ function statePath(): string {
async function readState(): Promise<OpencodeState> { async function readState(): Promise<OpencodeState> {
try { try {
return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch { } catch {
return {}; return {};
} }
@@ -87,15 +80,16 @@ 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) => ({ 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,
}));
} }
/** /**
* Active project first, then alphabetical. * Order the project list for the picker: the active project first (most likely
* * pick), then alphabetical. opencode's DialogSelect already filters as you
* opencode's select dialog does its own fuzzy filtering as you type, so unlike * type, so no pre-filter prompt is needed — only a sensible starting order.
* 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.
*/ */
@@ -130,20 +124,12 @@ 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 === '') 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 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: {
@@ -162,8 +148,7 @@ 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, which // silently re-scoping whatever repo opencode was started in.
// 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' });
@@ -172,8 +157,6 @@ 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;
@@ -276,11 +259,6 @@ 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
@@ -289,13 +267,8 @@ 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} wrapMode="none" truncate> <text fg={api.theme.current.textMuted}>{api.kv.get(KV_LABEL, indicatorLabel(null))}</text>
{api.kv.get(KV_LABEL, indicatorLabel(null))}
</text>
); );
api.slots.register({ api.slots.register({
order: 100, order: 100,

View File

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