Compare commits
1 Commits
feat/openc
...
feat/openc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2022e584d1 |
31
README.md
31
README.md
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
169
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -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/. */
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,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
|
||||
// ~/.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
|
||||
// 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
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 opencode’s own tree, never Claude’s', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 caller’s 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');
|
||||
});
|
||||
});
|
||||
38
src/cli/tests/opencode/opencode-tui-order.test.ts
Normal file
38
src/cli/tests/opencode/opencode-tui-order.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 user’s 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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:
|
||||
* Ships the same UX the pi and prime-agent integrations do, for 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
|
||||
* - `/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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user