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
[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 —
which means you can **switch projects from the TUI without restarting**.
its own MCP registry, so mcpctl mounts the project's gateway through the running
app — switching projects needs **no restart**.
```bash
mcpctl config opencode --project monitoring
```
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
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
`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into
`~/.config/opencode/mcpctl/`, registers the latter in
`~/.config/opencode/tui.json`, and syncs skills into
`~/.config/opencode/skill/`.
`~/.config/opencode/mcpctl/`, registers the latter in `tui.json`, and syncs
skills into `~/.config/opencode/skill/`.
Inside opencode:
- `/mcpctl` (or `<leader>m`) — **switch project** from a filterable picker;
takes effect on the next turn, no restart
- `/mcpctl` — filterable project picker; switches **live, no restart**
- `/mcpctl-status` — active project, mount state, gateway URL
- `/mcpctl-skills` — re-sync this project's skills
- the active project shows as `mcpctl:<project>` in the prompt footer, next to
the model name
- a `mcpctl:<project>` footer indicator right of the model name, above the
token counter
Skip individual steps as needed:
Skip individual steps:
```bash
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
```
Re-sync skills on their own with
`mcpctl skills sync --agent opencode --project monitoring`.
Re-sync skills later:
```bash
mcpctl skills sync --agent opencode --project monitoring
```
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/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
@@ -85,13 +84,10 @@ TUI plugins). It adds:
| 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-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.
@@ -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
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 right on the first frame.
so the label is correct on the very first frame.
#### 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 |
| `app_bottom` | costs a whole extra terminal row |
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.
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.
### Skills

View File

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

View File

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

View File

@@ -34,6 +34,7 @@ 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,
@@ -44,7 +45,6 @@ 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,6 +134,25 @@ 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.
@@ -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
.command('view')
.description('Show current configuration')
@@ -535,7 +534,6 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
if (hidden) {
void cmd;
}
}
registerClaudeCommand('claude', false);
@@ -629,13 +627,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
registerPrimeAgentCommand('prime-agent', false);
registerPrimeAgentCommand('prime-agent-generate', true); // backward compat
// ── opencode: install the plugins + provision the gateway credential ──
//
// Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into
// the host's own config: opencode gets a server plugin that mounts the gateway
// through the running server's MCP API, so the bearer token stays in a 0600
// state file and switching projects needs no restart. See
// `utils/opencode-settings.ts` for the reasoning.
// ── opencode: plugins + state (live MCP, never touches opencode.json) ──
config
.command('opencode')
.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 { opencodeSkillsDir } from '../utils/opencode-settings.js';
/**
* Every agent whose skill tree `mcpctl skills sync --agent` can write.
*
* `claude` is the only one with Claude-specific behaviour (SessionStart hooks,
* postInstall, mcpServers auto-attach); every other target is a shared flat
* tree with per-project ownership.
*/
export const SYNC_TARGETS = ['claude', 'prime-agent', 'pi', 'opencode'] as const;
export type SyncTarget = (typeof SYNC_TARGETS)[number];
export function isSyncTarget(value: string): value is SyncTarget {
return (SYNC_TARGETS as readonly string[]).includes(value);
}
/** The agent skill trees `skills sync` / `config <agent>` can target. */
export type SyncTarget = 'claude' | 'prime-agent' | 'pi' | 'opencode';
/**
* `mcpctl skills sync` — materialise server-side skills onto disk under
@@ -118,8 +107,6 @@ 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;
}
@@ -208,10 +195,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
@@ -575,16 +562,17 @@ 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()): string {
export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir(), env: NodeJS.ProcessEnv = process.env): string {
switch (agent) {
case 'prime-agent':
return join(homeDir, '.prime', 'agent', 'skills');
case 'pi':
return join(homeDir, '.pi', 'agent', 'skills');
case 'opencode':
return opencodeSkillsDir(process.env, homeDir);
return opencodeSkillsDir(env, homeDir);
case 'claude':
default:
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
// running the default (Claude) sync.
const agent = opts.agent ?? 'claude';
if (!isSyncTarget(agent)) {
warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`);
if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi' && agent !== 'opencode') {
warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', 'pi', or 'opencode')`);
process.exitCode = 1;
return;
}
@@ -633,7 +621,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,
target: agent as SyncTarget,
installRoot: agentInstallRoot(agent),
},
{ 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
* 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,210 +1,101 @@
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 { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js';
interface ClientCalls { posts: Array<{ path: string; body?: unknown }> }
/**
* @param tokens what `GET /api/v1/mcptokens` reports as existing for a project
*/
function mockClient(calls: ClientCalls, tokens: unknown[] = []): ApiClient {
function mockClient(visibleSkills: unknown[] = []): ApiClient {
return {
get: vi.fn(async (path: string) => {
if (path.startsWith('/api/v1/mcptokens')) return tokens;
if (path.endsWith('/skills/visible')) return [];
return {};
}),
post: vi.fn(async (path: string, body?: unknown) => {
calls.posts.push({ path, body });
if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' };
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,
};
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;
let ocDir: string;
let calls: ClientCalls;
const log = (...args: string[]): void => { output.push(args.join(' ')); };
const statePath = (): string => join(ocDir, 'mcpctl-state.json');
const log = (...args: string[]) => output.push(args.join(' '));
/** 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));
}
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-opencode-'));
});
function command(client: ApiClient) {
return createConfigCommand(
afterEach(() => {
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 },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
}
beforeEach(() => {
output = [];
calls = { posts: [] };
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-oc-'));
ocDir = join(tmpDir, 'opencode');
});
afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); });
await cmd.parseAsync(
['opencode', '--project', 'monitoring', '--opencode-dir', ocDir, '--dry-run'],
{ from: 'user' },
);
it('requires a project', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--opencode-dir', ocDir, '--skip-skills'], { from: 'user' });
expect(output.join('\n')).toContain('--project is required');
expect(existsSync(statePath())).toBe(false);
});
it('installs both plugins, registers the TUI one and writes the state file', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'docmost', '--opencode-dir', ocDir,
'--gateway-url', 'https://gw.example', '--token', 'mcpctl_pat_SUPPLIED000000',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(true);
expect(existsSync(join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'))).toBe(true);
const tui = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8'));
expect(tui.plugin).toEqual([join(ocDir, 'mcpctl', 'mcpctl-tui.tsx')]);
const state = JSON.parse(readFileSync(statePath(), 'utf-8'));
expect(state).toEqual({
project: 'docmost',
gatewayUrl: 'https://gw.example',
tokens: { docmost: 'mcpctl_pat_SUPPLIED000000' },
});
});
it('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/',
'--token', 't', '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example');
});
it('--token never mints', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
});
it('mints a uniquely-named opencode token when none is stored', async () => {
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
const mint = calls.posts.find((c) => c.path === '/api/v1/mcptokens');
expect(mint).toBeDefined();
// A fixed name could only ever be minted once per project: McpToken is
// unique on (name, projectId) and revoke is a soft delete.
expect((mint?.body as { name: string }).name).toMatch(/^opencode-/);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000');
});
it('reuses a stored token that the server still reports as active', async () => {
// 16-char prefix is what the server exposes; the secret is never re-sent.
const stored = 'mcpctl_pat_STORED000000000';
seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } });
const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'active', tokenPrefix: stored.slice(0, 16) }]);
await command(client).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0);
expect(output.join('\n')).toContain('already present');
});
it('re-mints when the stored token has been revoked server-side', async () => {
const stored = 'mcpctl_pat_REVOKED00000000';
seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } });
const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'revoked', tokenPrefix: stored.slice(0, 16) }]);
await command(client).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(1);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000');
});
it('leaves the previous project mounted when no credential can be provisioned', async () => {
// A switch with no usable credential is a FAILURE: exiting 0 here would
// have the /mcpctl switcher report success over a project with no tools.
seedState({ project: 'old', gatewayUrl: 'https://gw', tokens: { old: 'tok-old' } });
const client = {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})), // mint returns no token
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
const prevExit = process.exitCode;
await command(client).parseAsync(
['opencode', '--project', 'new', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(process.exitCode).toBe(1);
process.exitCode = prevExit;
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('old');
expect(output.join('\n')).toContain('Aborted');
});
it('--skip-plugin updates state without touching the loaded plugin files', async () => {
// This is the path the /mcpctl switcher takes: rewriting the very file
// opencode has already loaded buys nothing.
await command(mockClient(calls)).parseAsync(
['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't',
'--skip-plugin', '--skip-skills', '--skip-marker'],
{ from: 'user' });
expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false);
expect(existsSync(join(ocDir, 'tui.json'))).toBe(false);
expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('p');
expect(existsSync(join(ocDir, 'mcpctl-state.json'))).toBe(false);
expect(client.get).not.toHaveBeenCalled();
});
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('fails fast with no project', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
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);
}
await cmd.parseAsync(['opencode'], { from: 'user' });
expect(process.exitCode).toBe(1);
process.exitCode = 0;
});
});

View File

@@ -3,8 +3,8 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
OPENCODE_SERVER_PLUGIN_SOURCE,
OPENCODE_TUI_PLUGIN_SOURCE,
OPENCODE_SERVER_PLUGIN_FILENAME,
OPENCODE_TUI_PLUGIN_SOURCE,
OPENCODE_TUI_PLUGIN_FILENAME,
} from '../../src/config/opencode-extension.js';
@@ -13,78 +13,37 @@ 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 gives.
* the completions check and the pi embed test give.
*/
const repoRoot = join(import.meta.dirname, '..', '..', '..', '..');
const extDir = join(repoRoot, 'src', 'opencode-ext');
describe('embedded opencode plugins', () => {
it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => {
expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'));
expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed')
.toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'));
it('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('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.
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', () => {
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('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'");
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');
});
});

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 { 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 { tmpdir } from 'node:os';
import { tmpdir, homedir } from 'node:os';
import {
opencodeConfigDir,
opencodeSkillsDir,
opencodeStatePath,
withOpencodeDir,
installOpencodePlugins,
registerOpencodeTuiPlugin,
@@ -12,158 +13,110 @@ 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('opencodeConfigDir', () => {
it('defaults to ~/.config/opencode', () => {
describe('opencode-settings paths', () => {
it('resolves the config dir XDG-aware', () => {
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', () => {
expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/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('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', () => {
it('skills dir is under the config dir', () => {
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 dir: string;
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); });
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
let tmp: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
it('writes the server plugin where opencode auto-discovers it and the TUI plugin beside it', async () => {
const written = await installOpencodePlugins(dir);
const paths = withOpencodeDir(dir);
expect(written).toEqual([paths.serverPluginPath(), paths.tuiPluginPath()]);
expect(existsSync(join(dir, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME))).toBe(true);
expect(existsSync(join(dir, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME))).toBe(true);
});
it('keeps the .tsx extension — opencode transpiles the TUI plugin by extension', async () => {
await installOpencodePlugins(dir);
expect(withOpencodeDir(dir).tuiPluginPath().endsWith('.tsx')).toBe(true);
});
it('is idempotent (re-running overwrites in place)', async () => {
await installOpencodePlugins(dir);
const first = readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8');
await installOpencodePlugins(dir);
expect(readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8')).toBe(first);
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');
});
});
describe('registerOpencodeTuiPlugin', () => {
let dir: string;
let tuiJson: string;
const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx';
let tmp: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: 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 });
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);
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual([pluginPath]);
expect(parsed.$schema).toBe('https://opencode.ai/tui.json');
expect(parsed.plugin).toContain('/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('preserves other TUI plugins and unrelated keys', async () => {
writeFileSync(tuiJson, JSON.stringify({ plugin: ['opencode-tui-utils'], theme: 'nord' }));
await registerOpencodeTuiPlugin(tuiJson, pluginPath);
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(['opencode-tui-utils', pluginPath]);
expect(parsed.theme).toBe('nord');
expect(parsed.plugin).toEqual(['/other/plugin.ts', '/cfg/mcpctl/mcpctl-tui.tsx']);
});
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('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');
const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8'));
expect(parsed.plugin).toEqual(['/new/mcpctl/mcpctl-tui.tsx']);
});
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 });
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();
});
});
describe('opencode state file', () => {
let dir: string;
let statePath: string;
let tmp: string;
beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); });
afterEach(() => { rmSync(tmp, { recursive: true, force: true }); });
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-'));
statePath = join(dir, 'nested', 'opencode-state.json');
});
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
const statePath = (): string => join(tmp, 'opencode-state.json');
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('reads an empty state when the file is absent', async () => {
expect(await readOpencodeState(statePath())).toEqual({});
});
it('is written 0600 — it holds bearer tokens', async () => {
await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath);
expect(statSync(statePath).mode & 0o777).toBe(0o600);
});
it('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('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);
const state = await readOpencodeState(statePath());
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 () => {
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('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('reads a missing or corrupt state as empty rather than throwing', async () => {
expect(await readOpencodeState(join(dir, 'nope.json'))).toEqual({});
mkdirSync(join(dir, 'x'), { recursive: true });
writeFileSync(join(dir, 'x', 's.json'), 'not json');
expect(await readOpencodeState(join(dir, 'x', 's.json'))).toEqual({});
});
it('storedToken ignores an empty or missing entry', () => {
expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull();
expect(storedToken({}, 'a')).toBeNull();
it('storedToken returns the project token or null', () => {
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';
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,23 +1,21 @@
/** @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
* `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in
* `~/.config/opencode/tui.json`.
* `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in `tui.json`.
*
* What it adds to opencode:
* - `/mcpctl` — pick the active project from a filterable dialog
* - `/mcpctl-status` — what is mounted, from where, as which user
* - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill
* - a `mcpctl:<project>` indicator in the prompt footer, next to the model
* name and the token counter
* 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.
*
* 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.
* 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.
*
* 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 { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { join } from 'node:path';
/** MCP server name the mount lives under — must match the server plugin. */
const SERVER_NAME = 'mcpctl';
/**
* kv key holding the label the footer renders.
*
* kv is a reactive store, so writing it here re-renders the slot with no
* signal plumbing of our own; it also survives across sessions, so the label is
* correct on the very first frame instead of after the state file is read.
*/
/** kv key holding the label the footer renders. */
const KV_LABEL = 'mcpctl.project';
interface OpencodeState {
@@ -57,7 +49,8 @@ function statePath(): string {
async function readState(): Promise<OpencodeState> {
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 {
return {};
}
@@ -87,15 +80,16 @@ 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,
}));
}
/**
* 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".
* 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.
*
* 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> {
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: {
@@ -162,8 +148,7 @@ 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, which
// Claude Code's own skills sync would then pick up.
// silently re-scoping whatever repo opencode was started in.
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' });
@@ -172,8 +157,6 @@ 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;
@@ -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
@@ -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
// *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} wrapMode="none" truncate>
{api.kv.get(KV_LABEL, indicatorLabel(null))}
</text>
<text fg={api.theme.current.textMuted}>{api.kv.get(KV_LABEL, indicatorLabel(null))}</text>
);
api.slots.register({
order: 100,

View File

@@ -1,31 +1,33 @@
/**
* mcpctl opencode server plugin — mounts the active project's MCP gateway.
*
* Installed by `mcpctl config opencode` into
* `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.
* 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_*`).
*
* 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.
* 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.
*
* 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.
* 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.
*
* Only Node builtins + the plugin API are imported, so the installed file needs
* no dependencies of its own.
* Only Node builtins + the plugin API are imported.
*/
import type { Plugin, PluginModule } from '@opencode-ai/plugin';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
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';
interface OpencodeState {
@@ -40,7 +42,8 @@ function statePath(): string {
async function readState(): Promise<OpencodeState> {
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 {
return {};
}
@@ -53,26 +56,17 @@ function projectUrl(gatewayUrl: string, project: string): string {
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
* binds a gated project's unlocked state to the `mcp-session-id` of that
* connection. Re-adding an unchanged config every turn would therefore drop
* the gate open by `begin_session` and re-lock the project mid-conversation.
* 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
* 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;
@@ -129,16 +123,12 @@ const server: Plugin = async ({ client }) => {
return inflight;
}
// NOTE: deliberately NOT mounted here. Plugin setup runs before the server is
// accepting connections, and `client.mcp.add` calls back into that same
// server — awaiting it at this point hangs opencode on a blank screen before
// the TUI ever draws. Both hooks below fire only once the server is live.
// 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.
return {
/**
* First contact: mount as soon as the server is up, so a session that never
* sends a message still shows the project's tools (and the sidebar shows
* the mount as connected).
*/
/** First contact: mount as soon as the server is up. */
event: async (): Promise<void> => {
await ensureMounted();
},
@@ -147,11 +137,10 @@ 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 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.
* 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.
*/
'chat.message': async (): Promise<void> => {
await ensureMounted();