diff --git a/README.md b/README.md index 3f7f839..60fa274 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,48 @@ Inside pi: 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**. + +```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 +`~/.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/`. + +Inside opencode: + +- `/mcpctl` — **switch project** from a filterable picker; takes effect on the + next turn, no restart +- `/mcpctl-status` — active project, mount state, gateway URL +- `/mcpctl-skills` — re-sync this project's skills +- the active project shows as `mcpctl:` in the prompt footer, next to + the model name + +Skip individual steps as needed: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +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`. + +See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 8dbfce5..f9719fc 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -103,7 +103,7 @@ _mcpctl() { config) local config_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$config_sub" ]]; then - COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate help" -- "$cur")) + COMPREPLY=($(compgen -W "view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate help" -- "$cur")) else case "$config_sub" in view) @@ -133,6 +133,9 @@ _mcpctl() { prime-agent-generate) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) ;; + opencode) + COMPREPLY=($(compgen -W "-p --project --gateway-url --token --opencode-dir --skip-skills --skip-plugin --skip-marker --dry-run -h --help" -- "$cur")) + ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index b4b8da1..92b6c96 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -267,7 +267,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_r complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit patch approve; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name' # config subcommands -set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate setup impersonate +set -l config_cmds view set path reset claude claude-generate pi prime-agent prime-agent-generate opencode setup impersonate complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show current configuration' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' @@ -277,6 +277,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a pi -d 'Install the pi extension + sync skills (native, no MCP client, no Claude)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent -d 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a prime-agent-generate -d '' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a opencode -d 'Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -324,6 +325,16 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l sk complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print what would change without writing or syncing' +# config opencode options +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l token -d 'mcpctl project bearer token to use (skips auto-minting)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l opencode-dir -d 'Override opencode\'s config dir (default: ~/.config/opencode)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-plugin -d 'Do not (re)install or register the opencode plugins' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l dry-run -d 'Print what would change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -525,11 +536,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Revie # skills subcommands set -l skills_cmds sync -complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)' +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)' # skills sync options complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, or pi' -x +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l agent -d 'Sync target: claude (default), prime-agent, pi, or opencode' -x complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by session-start hooks)' diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md new file mode 100644 index 0000000..5a5430d --- /dev/null +++ b/docs/opencode-extension.md @@ -0,0 +1,186 @@ +# mcpctl × opencode — project switching from the TUI + +## Motivation + +`mcpctl config claude` and `mcpctl config prime-agent` both work the same way: +write the project's MCP server into the host's config file, then restart the +host so it picks the change up. That is fine when you pick a project once, and +tiresome when you switch between `homeautomation`, `sre` and `docmost` all day. + +[opencode](https://opencode.ai) can do better, because it exposes two things +the other hosts do not: + +- an **HTTP API for its own MCP registry** (`POST /mcp`, + `/mcp/{name}/disconnect`) — so a mount can be re-pointed while the app runs; +- a **TUI plugin API** (`tui.json`) with slash commands, dialogs and UI slots — + so the switcher can be a first-class part of the interface rather than a + shell command you run in another terminal. + +So the opencode integration does not write an MCP entry into `opencode.json` at +all. It ships two plugins, and switching projects takes effect on the next turn +with no restart. + +## How it works + +``` +~/.mcpctl/opencode-state.json (0600) + { project, gatewayUrl, tokens: { : } } + │ + │ read by both plugins + ▼ +~/.config/opencode/plugin/mcpctl.ts server plugin ─┐ +~/.config/opencode/mcpctl/mcpctl-tui.tsx TUI plugin ─┤ + │ client.mcp.add({ + │ name: "mcpctl", + ▼ type: "remote", url, headers }) + opencode's MCP registry + │ + ▼ + https:///projects//mcp +``` + +### The state file, not `opencode.json` + +Two reasons the project does not live in opencode's own config: + +1. **The token.** The gateway needs `Authorization: Bearer `. + `opencode.json` is a mode-0644 file people paste into bug reports; + `~/.mcpctl/opencode-state.json` is 0600, like every other mcpctl credential. +2. **The restart.** A config file is read at startup. Re-pointing the mount + through the running server's MCP API is what makes `/mcpctl` instant. + +Tokens are kept **per project**, so switching back to a project you have +already used needs no new mint — and a failed mint for project B cannot cost +you the credential for project A. + +### The server plugin (`plugin/mcpctl.ts`) + +Auto-discovered by opencode. It mounts the active project's gateway under the +fixed MCP server name `mcpctl`, on the first server event and again before +every user turn. + +Two details matter: + +- **The name is constant.** Tools keep a stable `mcpctl_*` prefix across + switches, and because opencode re-resolves the tool list per request, the + model simply sees the new project's tools on its next turn. (Contrast pi, + where the extension has to *tell* the model its old tool names are dead.) +- **It does not re-register an unchanged mount.** `mcp.add` rebuilds the + connection, and mcplocal binds a gated project's unlocked state to that + connection's `mcp-session-id` — re-adding every turn would re-lock a project + you had just opened with `begin_session`. + +It also exists so that **headless** runs (`opencode run …`), which load no TUI +plugins at all, still get the project's tools. + +> The mount is deliberately *not* performed during plugin setup. Setup runs +> before opencode's server accepts connections, and `client.mcp.add` calls back +> into that same server — awaiting it there hangs the app on a blank screen +> before the TUI ever draws. + +### The TUI plugin (`mcpctl/mcpctl-tui.tsx`) + +Registered in `~/.config/opencode/tui.json` (opencode does not auto-discover +TUI plugins). It adds: + +| Command | What it does | +|---------|--------------| +| `/mcpctl` | Filterable project picker; switches live | +| `/mcpctl-status` | Active project, mount state, gateway URL | +| `/mcpctl-skills` | Re-sync this project's skills | + +and a `mcpctl:` indicator in the prompt footer, next to the model name +and one line above the token counter. + +Switching delegates to the CLI — +`mcpctl config opencode --project X --skip-plugin --skip-marker` — so token +minting, state and skills stay in one place and the plugin stays a UI shell. +`--skip-plugin` avoids rewriting the very file opencode has already loaded; +`--skip-marker` stops a switch from silently re-scoping whichever repository +opencode happened to be started in. + +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. + +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. + +#### Why the footer and not the status bar + +opencode exposes UI slots, not arbitrary layout. In the footer region the +options are: + +| Slot | Result | +|------|--------| +| `session_prompt_right` / `home_prompt_right` | **used** — renders on the prompt's bottom line, right of the model name, directly above the token counter | +| `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | +| `app_bottom` | costs a whole extra terminal row | + +There is no slot on the status-bar line itself. On the home screen the prompt +box is narrow, so a long project name wraps onto a second line; in a session +(where the prompt is full width) it always fits on one. + +### Skills + +opencode implements the Agent Skills standard and loads `SKILL.md` trees from +`~/.config/opencode/skill/` (XDG-aware). `mcpctl skills sync --agent opencode` +syncs there, with the same shared-tree semantics as `--agent pi` and +`--agent prime-agent`: a flat tree with per-project ownership, its own state +file (`~/.mcpctl/skills-state-opencode.json`), no SessionStart hooks, no +`postInstall`, and untracked skill directories are never clobbered. + +## Usage + +```bash +mcpctl config opencode --project monitoring +``` + +That mints (or reuses) the project token, writes the 0600 state file, installs +both plugins, registers the TUI plugin in `tui.json`, and runs an initial +skills sync. Start opencode and the project's tools are there. + +Skip individual steps: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing +mcpctl config opencode --project monitoring --opencode-dir /path # non-default opencode config dir +``` + +## Failure semantics + +- **No usable credential → the switch fails.** The command exits non-zero and + leaves the state file untouched, so the previously active project keeps + working rather than being replaced by a mount that 401s. `/mcpctl` reads that + exit code and reports the switch as failed instead of claiming success over a + project with no tools. +- **Gateway unreachable → no mcpctl tools.** Never a failed startup: the server + plugin swallows mount errors. +- **Corrupt `tui.json` → refuses to write.** One syntax error must not silently + drop every other TUI plugin you installed. + +## Files + +| Path | Purpose | +|------|---------| +| `src/opencode-ext/mcpctl-opencode.ts` | Server plugin source | +| `src/opencode-ext/mcpctl-opencode-tui.tsx` | TUI plugin source | +| `src/cli/src/config/opencode-extension.ts` | Generated embed of both (do not edit) | +| `scripts/generate-opencode-extension.ts` | Regenerates that embed | +| `src/cli/src/utils/opencode-settings.ts` | Install / register / state helpers | + +The plugin sources are **embedded** in the CLI so `mcpctl config opencode` +works from an installed binary with no source tree. After editing either +source, re-run: + +```bash +npx tsx scripts/generate-opencode-extension.ts +``` + +A test fails if you forget. They are typechecked against the real +`@opencode-ai/plugin` types via `pnpm typecheck:opencode-ext`. diff --git a/eslint.config.js b/eslint.config.js index 53f1093..b247282 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/package.json b/package.json index de493e0..5d8ab67 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean": "pnpm -r run clean && rimraf node_modules", "db:up": "docker compose -f deploy/docker-compose.yml up -d", "db:down": "docker compose -f deploy/docker-compose.yml down", - "typecheck": "tsc --build && pnpm run typecheck:pi-ext", + "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext", "completions:generate": "tsx scripts/generate-completions.ts --write", "completions:check": "tsx scripts/generate-completions.ts --check", "rpm:build": "bash scripts/build-rpm.sh", @@ -34,6 +34,7 @@ "mcpd:deploy-dry": "bash deploy.sh --dry-run", "mcpd:logs": "bash logs.sh", "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json", + "typecheck:opencode-ext": "tsc -p src/opencode-ext/tsconfig.json", "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { @@ -44,6 +45,10 @@ "devDependencies": { "@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", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -51,6 +56,7 @@ "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", "rimraf": "^6.1.3", + "solid-js": "1.9.12", "tsx": "^4.21.0", "typebox": "1.3.11", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6c99d..86a79db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,18 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.84.1 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) + '@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) '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -35,6 +47,9 @@ importers: rimraf: specifier: ^6.1.3 version: 6.1.3 + solid-js: + specifier: 1.9.12 + version: 1.9.12 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -184,7 +199,7 @@ importers: version: link:../shared '@modelcontextprotocol/sdk': specifier: ^1.0.0 - version: 1.26.0(zod@3.25.76) + version: 1.26.0(zod@4.1.8) fastify: specifier: ^5.0.0 version: 5.7.4 @@ -280,10 +295,18 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -408,10 +431,18 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} @@ -420,36 +451,98 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -463,6 +556,29 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -475,6 +591,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -483,14 +611,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -1276,6 +1416,36 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@next/env@16.2.5': resolution: {integrity: sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==} @@ -1385,6 +1555,23 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} + '@opencode-ai/plugin@1.18.15': + resolution: {integrity: sha512-AY10RtFbzLkf951dbLkSGJdBeddeS6ojbjvqCpWnINedqlj2cK/GF91xWjWnlHpqQZ4GABLPCuoSJx8mGmKvCw==} + peerDependencies: + '@opentui/core': '>=0.4.5' + '@opentui/keymap': '>=0.4.5' + '@opentui/solid': '>=0.4.5' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.18.15': + resolution: {integrity: sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ==} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -1491,6 +1678,73 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@opentui/core-darwin-arm64@0.4.5': + resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.5': + resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-arm64@0.4.5': + resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} + cpu: [x64] + os: [linux] + + '@opentui/core-linux-x64@0.4.5': + resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.5': + resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.5': + resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.5': + resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} + 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==} + peerDependencies: + solid-js: 1.9.12 + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2198,6 +2452,23 @@ packages: react-native-b4a: optional: true + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2313,6 +2584,11 @@ 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==} + peerDependencies: + typescript: ^5 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2583,6 +2859,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + docker-modem@5.0.6: resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} engines: {node: '>= 8.0'} @@ -2617,6 +2897,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + effect@4.0.0-beta.83: + resolution: {integrity: sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==} + electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} @@ -2641,6 +2924,14 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2795,6 +3086,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -2853,10 +3148,20 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-my-way@9.4.0: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2981,6 +3286,11 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -3022,6 +3332,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + helmet@7.2.0: resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} engines: {node: '>=16.0.0'} @@ -3049,6 +3363,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3125,6 +3442,10 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ink@6.8.0: resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} engines: {node: '>=20'} @@ -3159,6 +3480,10 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3287,6 +3612,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3322,6 +3650,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3399,6 +3730,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3451,6 +3786,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -3514,6 +3854,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -3545,6 +3889,10 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} @@ -3581,6 +3929,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3645,6 +4003,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3762,10 +4124,18 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -3778,6 +4148,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3790,6 +4164,9 @@ packages: resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} engines: {node: ^20.17.0 || >=22.9.0} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3804,6 +4181,10 @@ packages: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3816,6 +4197,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3853,6 +4241,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -3924,6 +4316,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4017,9 +4412,17 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4072,6 +4475,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + s-js@0.4.9: + resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4113,6 +4519,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -4188,6 +4604,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4304,6 +4723,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4387,6 +4810,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4492,6 +4919,11 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true validate-npm-package-license@3.0.4: @@ -4591,6 +5023,14 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4724,17 +5164,29 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + snapshots: '@acemir/cssom@0.9.31': {} '@adobe/css-tools@4.4.4': {} + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -4981,8 +5433,34 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.29.7(@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 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5011,6 +5489,18 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -5019,8 +5509,34 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5028,6 +5544,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5037,12 +5560,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -5054,6 +5612,28 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5064,6 +5644,28 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + + '@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-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) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -5072,6 +5674,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5084,11 +5692,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {} @@ -5819,6 +6444,29 @@ snapshots: zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color + optional: true + + '@modelcontextprotocol/sdk@1.26.0(zod@4.1.8)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.0 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.1.8 + zod-to-json-schema: 3.25.1(zod@4.1.8) + transitivePeerDependencies: + - supports-color '@monaco-editor/loader@1.7.0': dependencies: @@ -5831,6 +6479,24 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@next/env@16.2.5': {} '@next/swc-darwin-arm64@16.2.5': @@ -5975,6 +6641,21 @@ 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))': + 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) + + '@opencode-ai/sdk@1.18.15': + dependencies: + cross-spawn: 7.0.6 + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -6094,6 +6775,76 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentui/core-darwin-arm64@0.4.5': + optional: true + + '@opentui/core-darwin-x64@0.4.5': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.5': + optional: true + + '@opentui/core-linux-arm64@0.4.5': + optional: true + + '@opentui/core-linux-x64-musl@0.4.5': + optional: true + + '@opentui/core-linux-x64@0.4.5': + optional: true + + '@opentui/core-win32-arm64@0.4.5': + optional: true + + '@opentui/core-win32-x64@0.4.5': + optional: true + + '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.4(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.5 + '@opentui/core-darwin-x64': 0.4.5 + '@opentui/core-linux-arm64': 0.4.5 + '@opentui/core-linux-arm64-musl': 0.4.5 + '@opentui/core-linux-x64': 0.4.5 + '@opentui/core-linux-x64-musl': 0.4.5 + '@opentui/core-win32-arm64': 0.4.5 + '@opentui/core-win32-x64': 0.4.5 + transitivePeerDependencies: + - typescript + + '@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + optionalDependencies: + '@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.5 + solid-js: 1.9.12 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.12 + transitivePeerDependencies: + - supports-color + - typescript + - web-tree-sitter + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -6426,7 +7177,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -6459,7 +7210,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.2 + minimatch: 10.2.5 '@types/aria-query@5.0.4': {} @@ -6817,6 +7568,30 @@ snapshots: b4a@1.8.0: {} + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.0): + dependencies: + '@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 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-plugin-module-resolver@5.0.2: + dependencies: + find-babel-config: 2.1.2 + glob: 9.3.5 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.12 + + babel-preset-solid@1.9.12(@babel/core@7.28.0)(solid-js@1.9.12): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.28.0) + optionalDependencies: + solid-js: 1.9.12 + balanced-match@1.0.2: {} balanced-match@4.0.3: {} @@ -6942,6 +7717,10 @@ snapshots: buildcheck@0.0.7: optional: true + bun-ffi-structs@0.2.4(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + bytes@3.1.2: {} c12@3.1.0: @@ -7161,6 +7940,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -7209,6 +7990,19 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + effect@4.0.0-beta.83: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 14.0.1 + yaml: 2.9.0 + electron-to-chromium@1.5.344: {} emoji-regex@10.6.0: {} @@ -7228,6 +8022,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + + entities@7.0.1: {} + entities@8.0.0: {} env-paths@2.2.1: {} @@ -7442,6 +8240,10 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -7519,12 +8321,22 @@ snapshots: transitivePeerDependencies: - supports-color + find-babel-config@2.1.2: + dependencies: + json5: 2.2.3 + + find-my-way-ts@0.1.6: {} + find-my-way@9.4.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.0.0 + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7676,6 +8488,13 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 @@ -7711,6 +8530,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + helmet@7.2.0: {} highlight.js@10.7.3: {} @@ -7733,6 +8556,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -7805,6 +8630,8 @@ snapshots: ini@6.0.0: {} + ini@7.0.0: {} + ink@6.8.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@alcalzone/ansi-tokenize': 0.2.5 @@ -7857,13 +8684,17 @@ snapshots: ipaddr.js@2.3.0: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-glob@4.0.3: dependencies: @@ -7972,6 +8803,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-nice@1.1.4: {} @@ -8005,6 +8838,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kubernetes-types@1.30.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8065,6 +8900,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -8124,6 +8964,8 @@ snapshots: marked@14.0.0: {} + marked@17.0.1: {} + marked@18.0.5: {} math-intrinsics@1.1.0: {} @@ -8166,6 +9008,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@8.0.7: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -8200,6 +9046,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.3: {} @@ -8230,6 +9078,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + mute-stream@2.0.0: {} nan@2.25.0: @@ -8282,6 +9148,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 @@ -8415,10 +9286,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -8430,6 +9309,8 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pacote@21.5.1: @@ -8460,6 +9341,10 @@ snapshots: just-diff: 6.0.2 just-diff-apply: 5.5.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -8470,12 +9355,21 @@ snapshots: patch-console@2.0.0: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} path-key@3.1.1: {} + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -8519,6 +9413,10 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 @@ -8600,6 +9498,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -8680,8 +9580,17 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@4.1.8: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@4.0.0: dependencies: onetime: 5.1.2 @@ -8755,6 +9664,8 @@ snapshots: dependencies: tslib: 2.8.1 + s-js@0.4.9: {} + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -8795,6 +9706,12 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -8918,6 +9835,12 @@ snapshots: ip-address: 10.0.1 smart-buffer: 4.2.0 + solid-js@1.9.12: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -8998,7 +9921,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.1.2 string-width@8.2.0: @@ -9035,6 +9958,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} tagged-tag@1.0.0: {} @@ -9140,6 +10065,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -9227,6 +10154,8 @@ snapshots: uuid@10.0.0: {} + uuid@14.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -9299,6 +10228,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -9405,4 +10336,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.1.8): + dependencies: + zod: 4.1.8 + zod@3.25.76: {} + + zod@4.1.8: {} diff --git a/scripts/generate-opencode-extension.ts b/scripts/generate-opencode-extension.ts new file mode 100644 index 0000000..09d409d --- /dev/null +++ b/scripts/generate-opencode-extension.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/opencode-extension.ts`, which embeds the two + * opencode plugin sources as string constants — mirroring how the pi extension + * (`pi-extension.ts`) and prime-agent's `/mcpctl` switcher + * (`prime-agent-extension.ts`) are embedded. + * + * Embedding matters: `mcpctl config opencode` must work from an installed + * binary that has no access to the source tree. The installed plugin files are + * this exact embedded source, so what the CLI ships is always what opencode + * runs. + * + * Regenerate after editing the plugin sources: + * npx tsx scripts/generate-opencode-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +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) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(src); +} + +const out = `/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-opencode-extension.ts\` from + * \`src/opencode-ext/mcpctl-opencode.ts\` and + * \`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. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = ${embed(SERVER)}; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = ${embed(TUI)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out); +console.log('wrote src/cli/src/config/opencode-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 676a5e7..a57e359 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -34,6 +34,16 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + opencodeConfigDir, + opencodeStatePath, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../utils/opencode-settings.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** @@ -44,6 +54,9 @@ import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; */ const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; +/** Same, for the tokens `config opencode` mints. */ +const OPENCODE_TOKEN_PREFIX = 'opencode'; + interface McpConfig { mcpServers: Record }>; } @@ -76,6 +89,118 @@ export function createConfigCommand(deps?: Partial, apiDeps?: const config = new Command('config').description('Manage mcpctl configuration'); + // ── 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. + + interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } + + /** The project's tokens, or null when the API can't be consulted. */ + async function listProjectTokens(project: string): Promise { + if (!skillsClient) return null; + try { + const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); + return Array.isArray(list) ? list as ProjectToken[] : null; + } catch { + return null; + } + } + + /** + * Is the credential we already have for this project still usable? + * + * A key being *present* proves nothing — a revoked or expired token would + * short-circuit provisioning and leave the agent silently unable to reach + * the gateway while the command reported success. mcptokens are only ever + * shown once, so we compare the stored token's 16-char `tokenPrefix` + * against the project's *active* tokens instead of sending the secret. + * + * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some + * other kind), or an unreachable API all mean "keep what's there" rather + * than minting a duplicate on every run. + */ + async function hasUsableCredential(project: string, key: string | null): Promise { + if (key === null) return false; + if (!skillsClient || !isMcpctlToken(key)) return true; + const tokens = await listProjectTokens(project); + if (tokens === null) return true; // can't check → don't churn credentials + const prefix = mcpTokenPrefixOf(key); + const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); + if (!live) { + log(`Stored credential for '${project}' is no longer active — minting a replacement`); + } + return live; + } + + /** + * Retire the token this install used to hold, now that `keepToken` has + * replaced it on disk. + * + * Scoped to that one credential on purpose. Sweeping every `` token + * for the project would revoke the one a *different* install is using — + * another machine, or this machine when the run targeted a custom output + * path. Anything else that looks orphaned is reported, not deleted: an + * unnecessary token costs nothing, a revoked one costs a broken install. + * Best-effort throughout, and only ever called once the replacement is + * safely stored. + */ + async function retireSupersededToken( + agent: string, + project: string, + staleKey: string | null, + keepToken: string, + ): Promise { + if (!skillsClient) return; + const keepPrefix = mcpTokenPrefixOf(keepToken); + const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; + if (stalePrefix === keepPrefix) return; + const tokens = await listProjectTokens(project); + if (tokens === null) return; + + const orphans: string[] = []; + for (const t of tokens) { + if (typeof t.id !== 'string' || t.status !== 'active') continue; + if (t.tokenPrefix === keepPrefix) continue; + // Only ever consider tokens minted for this purpose. + const name = t.name ?? ''; + if (name !== agent && !name.startsWith(`${agent}-`)) continue; + if (t.tokenPrefix === stalePrefix) { + try { + await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); + log(`Revoked the superseded '${name}' token for '${project}'`); + } catch { /* best-effort */ } + } else { + orphans.push(name); + } + } + if (orphans.length > 0) { + log(`Note: '${project}' still has other ${agent} token(s): ${orphans.join(', ')}. ` + + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); + } + } + + /** + * Mint a fresh mcptoken for `project`. + * + * A unique `-` 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 { + 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') @@ -299,7 +424,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // Only when we actually replaced something: `--token` with a fresh // auth.json must stay entirely offline, as documented. if (staleKey !== null) { - await retireSupersededToken(opts.project, staleKey, opts.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, opts.token); } } else if (await hasUsableCredential(opts.project, staleKey)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); @@ -309,18 +434,12 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // (name, projectId) and revoke is a soft delete, so reusing a fixed // name would collide with the revoked row forever. Retire the old // tokens only *after* the replacement is safely on disk. - const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; - const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { - name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`, - projectName: opts.project, - ttl: 'never', - description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, - }); - if (typeof minted?.token === 'string' && minted.token.length > 0) { - await writePrimeAgentAuth(opts.project, minted.token, authPath); + const minted = await mintProjectToken(PRIME_AGENT_TOKEN_PREFIX, opts.project); + if (minted !== null) { + await writePrimeAgentAuth(opts.project, minted, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; - await retireSupersededToken(opts.project, staleKey, minted.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, minted); } else { log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } @@ -417,86 +536,6 @@ export function createConfigCommand(deps?: Partial, apiDeps?: void cmd; } - /** - * Is the credential already in auth.json still usable? - * - * A key being *present* proves nothing — a revoked or expired token would - * short-circuit provisioning and leave prime-agent silently unable to reach - * the gateway while the command reported success. mcptokens are only ever - * shown once, so we compare the stored token's 16-char `tokenPrefix` - * against the project's *active* tokens instead of sending the secret. - * - * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some - * other kind), or an unreachable API all mean "keep what's there" rather - * than minting a duplicate on every run. - */ - async function hasUsableCredential(project: string, key: string | null): Promise { - if (key === null) return false; - if (!skillsClient || !isMcpctlToken(key)) return true; - const tokens = await listProjectTokens(project); - if (tokens === null) return true; // can't check → don't churn credentials - const prefix = mcpTokenPrefixOf(key); - const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); - if (!live) { - log(`Stored credential for '${project}' is no longer active — minting a replacement`); - } - return live; - } - - /** - * Retire the token this auth.json used to hold, now that `keepToken` has - * replaced it on disk. - * - * Scoped to that one credential on purpose. Sweeping every `prime-agent` - * token for the project would revoke the one a *different* auth.json is - * using — another machine, or this machine when the run targeted a custom - * `--output`. Anything else that looks orphaned is reported, not deleted: - * an unnecessary token costs nothing, a revoked one costs a broken install. - * Best-effort throughout, and only ever called once the replacement is - * safely stored. - */ - async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise { - if (!skillsClient) return; - const keepPrefix = mcpTokenPrefixOf(keepToken); - const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; - if (stalePrefix === keepPrefix) return; - const tokens = await listProjectTokens(project); - if (tokens === null) return; - - const orphans: string[] = []; - for (const t of tokens) { - if (typeof t.id !== 'string' || t.status !== 'active') continue; - if (t.tokenPrefix === keepPrefix) continue; - // Only ever consider tokens minted for this purpose. - const name = t.name ?? ''; - if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue; - if (t.tokenPrefix === stalePrefix) { - try { - await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); - log(`Revoked the superseded '${name}' token for '${project}'`); - } catch { /* best-effort */ } - } else { - orphans.push(name); - } - } - if (orphans.length > 0) { - log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. ` - + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); - } - } - - interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } - - /** The project's tokens, or null when the API can't be consulted. */ - async function listProjectTokens(project: string): Promise { - if (!skillsClient) return null; - try { - const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); - return Array.isArray(list) ? list as ProjectToken[] : null; - } catch { - return null; - } - } } registerClaudeCommand('claude', false); @@ -590,6 +629,190 @@ export function createConfigCommand(deps?: Partial, 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. + config + .command('opencode') + .description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills') + .option('-p, --project ', 'Project name to make active') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--token ', 'mcpctl project bearer token to use (skips auto-minting)') + .option('--opencode-dir ', 'Override opencode\'s config dir (default: ~/.config/opencode)') + .option('--skip-skills', 'Skip the skills sync step') + .option('--skip-plugin', 'Do not (re)install or register the opencode plugins') + .option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { + project?: string; + gatewayUrl: string; + token?: string; + opencodeDir?: string; + skipSkills?: boolean; + skipPlugin?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for mcpctl config opencode'); + process.exitCode = 1; + return; + } + const project = opts.project; + const configDir = opts.opencodeDir !== undefined ? resolve(opts.opencodeDir) : opencodeConfigDir(); + const paths = withOpencodeDir(configDir); + const gatewayBase = opts.gatewayUrl.replace(/\/+$/, ''); + // Isolate the state file under a custom --opencode-dir so tests (and + // side-by-side installs) never write the real ~/.mcpctl/opencode-state.json. + const statePath = opts.opencodeDir !== undefined + ? join(configDir, 'mcpctl-state.json') + : opencodeStatePath(); + + if (opts.dryRun === true) { + log(JSON.stringify({ + opencode: { + serverPlugin: opts.skipPlugin === true ? '' : paths.serverPluginPath(), + tuiPlugin: opts.skipPlugin === true ? '' : paths.tuiPluginPath(), + tuiJson: opts.skipPlugin === true ? '' : paths.tuiJsonPath(), + statePath, + mcpUrl: `${gatewayBase}/projects/${encodeURIComponent(project)}/mcp`, + skillsDir: opts.skipSkills === true ? '' : paths.skillsDir(), + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + }, + action: 'install plugins + register in tui.json + write 0600 state (project, gateway, bearer token) + sync skills', + }, null, 2)); + return; + } + + // 1. Provision the bearer credential the gateway needs. + // + // This runs FIRST and is fatal on failure: the state file is what the + // plugins mount from, so writing a project we have no usable token for + // would swap a working mount for a 401. Keeping the old state means the + // previously active project keeps working, and the `/mcpctl` switcher + // (which reads this command's exit code) reports the switch as failed + // instead of leaving the user staring at a project with no tools. + const priorState = await readOpencodeState(statePath); + const staleKey = storedToken(priorState, project); + let token: string | null = null; + try { + if (opts.token !== undefined && opts.token !== '') { + token = opts.token; + } else if (await hasUsableCredential(project, staleKey)) { + token = staleKey; + log(`Bearer credential for '${project}' already present in ${statePath}`); + } else { + token = await mintProjectToken(OPENCODE_TOKEN_PREFIX, project); + if (token === null) { + log(skillsClient + ? `Error: no token returned minting for '${project}'; pass --token to supply one` + : 'Error: no API client available to mint a project token — pass --token '); + } + } + } catch (err: unknown) { + log(`Error: could not provision bearer credential for '${project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (token === null || token === '') { + process.exitCode = 1; + log(`Aborted: leaving ${statePath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Write the state file (0600) — the single source of truth both + // plugins read for "which project, which gateway, which token". + try { + await writeOpencodeState({ project, gatewayUrl: gatewayBase, token }, statePath); + log(`Active project '${project}' → ${statePath} (${gatewayBase}/projects/${encodeURIComponent(project)}/mcp)`); + } catch (err: unknown) { + log(`Error: could not write ${statePath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + // Only once the replacement is safely on disk. + if (token !== staleKey) { + await retireSupersededToken(OPENCODE_TOKEN_PREFIX, project, staleKey, token); + } + + // 3. Install + register the plugins (skippable: the `/mcpctl` switcher + // re-runs this command in-process and must not rewrite the very file + // opencode has already loaded). + if (opts.skipPlugin !== true) { + try { + const written = await installOpencodePlugins(configDir); + log('Installed opencode plugins:'); + for (const w of written) log(` ${w}`); + } catch (err: unknown) { + log(`Error: could not install opencode plugins: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + try { + const { added } = await registerOpencodeTuiPlugin(paths.tuiJsonPath(), paths.tuiPluginPath()); + log(added + ? `Registered the TUI plugin in ${paths.tuiJsonPath()}` + : `TUI plugin already registered in ${paths.tuiJsonPath()}`); + } catch (err: unknown) { + // Non-fatal: without tui.json there is no /mcpctl command or footer + // indicator, but the server plugin still mounts the project's tools. + log(`Warning: could not update ${paths.tuiJsonPath()}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // 4. Write the .mcpctl-project marker (same semantics as prime-agent: + // an explicit -p is authoritative, $HOME is never scoped, and the + // switcher opts out so it cannot re-scope an unrelated repo). + try { + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else if (process.cwd() !== homedir()) { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null && existing.project === project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${project}')` + : `Wrote ${markerPath}`); + } + } else { + log('Skipped .mcpctl-project marker (running from $HOME)'); + } + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // 5. Sync skills into opencode's skill dir (skippable). Best-effort: the + // mount is what determines whether the switch succeeded. + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runSkillsSync( + { project, target: 'opencode', installRoot: paths.skillsDir() }, + { client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + log(total > 0 + ? `Skills synced to ${paths.skillsDir()} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)` + : 'Skills: no changes (already up to date)'); + } catch (err: unknown) { + log(`Warning: skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent opencode` separately)'); + } + } + + if (opts.skipPlugin !== true) { + log(''); + log('Next: restart opencode (or start a new session). Use /mcpctl to switch projects'); + log('without restarting; the active project shows in the prompt footer.'); + } + }); + config.addCommand(createConfigSetupCommand({ configDeps })); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 89f95e6..33708a9 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -33,6 +33,21 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; 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); +} /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -103,8 +118,10 @@ 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?: 'claude' | 'prime-agent' | 'pi'; + target?: SyncTarget; } export interface SyncResult { @@ -192,32 +209,23 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise (s.scope === 'global' ? null : (projectName ?? null)); - const statePath = opts.statePath ?? (isPrimeAgent - ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') - : isPi - ? join(homeDir, '.mcpctl', 'skills-state-pi.json') - : defaultStatePath()); + const statePath = opts.statePath + ?? (target === 'claude' ? defaultStatePath() : join(homeDir, '.mcpctl', `skills-state-${target}.json`)); const state = await loadState(statePath); // Which project last wrote this state file, captured before step 7 overwrites // it. Skills tracked by a CLI that predates ownership recording carry no // `project` field; this is the only evidence of who installed them. const priorSyncProject = state.lastSyncProject; - const installRoot = opts.installRoot ?? (isPrimeAgent - ? join(homeDir, '.prime', 'agent', 'skills') - : isPi - ? join(homeDir, '.pi', 'agent', 'skills') - : join(homeDir, '.claude', 'skills')); + const installRoot = opts.installRoot ?? agentInstallRoot(target, homeDir); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -566,17 +574,20 @@ export interface SkillsCommandDeps { * "claude" → ~/.claude/skills * "prime-agent" → ~/.prime/agent/skills * "pi" → ~/.pi/agent/skills - * Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree. + * "opencode" → ~/.config/opencode/skill (XDG-aware) + * Kept here so `mcpctl skills sync --agent` and `mcpctl config ` agree. */ -export function agentInstallRoot(agent: string | undefined): string { +export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string { switch (agent) { case 'prime-agent': - return join(homedir(), '.prime', 'agent', 'skills'); + return join(homeDir, '.prime', 'agent', 'skills'); case 'pi': - return join(homedir(), '.pi', 'agent', 'skills'); + return join(homeDir, '.pi', 'agent', 'skills'); + case 'opencode': + return opencodeSkillsDir(process.env, homeDir); case 'claude': default: - return join(homedir(), '.claude', 'skills'); + return join(homeDir, '.claude', 'skills'); } } @@ -589,9 +600,9 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)') + .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') - .option('--agent ', 'Sync target: claude (default), prime-agent, or pi', 'claude') + .option('--agent ', 'Sync target: claude (default), prime-agent, pi, or opencode', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') @@ -609,8 +620,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { // Validate --agent so an unknown value fails loudly instead of silently // running the default (Claude) sync. const agent = opts.agent ?? 'claude'; - if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') { - warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`); + if (!isSyncTarget(agent)) { + warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`); process.exitCode = 1; return; } @@ -622,7 +633,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - target: agent as 'claude' | 'prime-agent' | 'pi', + target: agent, installRoot: agentInstallRoot(agent), }, { client, log, warn }, diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts new file mode 100644 index 0000000..4f60f81 --- /dev/null +++ b/src/cli/src/config/opencode-extension.ts @@ -0,0 +1,27 @@ +/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-opencode-extension.ts` from + * `src/opencode-ext/mcpctl-opencode.ts` and + * `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. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n const Indicator = (): JSX.Element => (\n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n );\n api.slots.register({\n order: 100,\n slots: {\n session_prompt_right: () => ,\n home_prompt_right: () => ,\n },\n });\n\n await refreshIndicator();\n};\n\nfunction errText(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport default {\n id: 'mcpctl',\n tui,\n} satisfies TuiPluginModule & { id: string };\n"; diff --git a/src/cli/src/utils/opencode-settings.ts b/src/cli/src/utils/opencode-settings.ts new file mode 100644 index 0000000..13f2d76 --- /dev/null +++ b/src/cli/src/utils/opencode-settings.ts @@ -0,0 +1,204 @@ +/** + * Wiring helpers for the opencode integration. + * + * `mcpctl config opencode --project X`: + * 1. writes the embedded plugin sources into opencode's config dir + * (`plugin/mcpctl.ts` — auto-discovered server plugin — and + * `mcpctl/mcpctl-tui.tsx` — the `/mcpctl` switcher + footer indicator), + * 2. registers the TUI plugin in `tui.json` (opencode does not auto-discover + * TUI plugins; they have to be listed), + * 3. writes `~/.mcpctl/opencode-state.json` (0600) with the active project, + * the gateway URL and the per-project bearer tokens. + * + * WHY THE STATE FILE AND NOT `opencode.json`: + * opencode's own `mcp` block is a fine way to mount a *static* server, but + * this integration needs two things it cannot give. The gateway wants an + * `Authorization` header, and opencode.json is a mode-0644 file users paste + * into bug reports; and switching projects has to work without restarting + * opencode, which a config file cannot do. The plugins read the state file + * and mount through opencode's live MCP API instead, so `opencode.json` is + * never touched at all. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`. + */ +import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_SOURCE, +} from '../config/opencode-extension.js'; + +/** + * opencode's config directory. + * + * opencode is XDG-aware, so an install with `XDG_CONFIG_HOME` set keeps its + * config somewhere other than `~/.config`. Reading the same variable is what + * stops us provisioning a directory opencode will never look at. + */ +export function opencodeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const xdg = env['XDG_CONFIG_HOME']; + const base = xdg !== undefined && xdg !== '' ? xdg : join(homeDir, '.config'); + return join(base, 'opencode'); +} + +/** Resolve every path `config opencode` writes, under an explicit config dir. */ +export function withOpencodeDir(base: string): { + serverPluginPath: () => string; + tuiPluginDir: () => string; + tuiPluginPath: () => string; + tuiJsonPath: () => string; + skillsDir: () => string; +} { + return { + serverPluginPath: () => join(base, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME), + tuiPluginDir: () => join(base, 'mcpctl'), + tuiPluginPath: () => join(base, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME), + tuiJsonPath: () => join(base, 'tui.json'), + skillsDir: () => join(base, 'skill'), + }; +} + +/** Where `mcpctl skills sync --agent opencode` installs skill bundles. */ +export function opencodeSkillsDir(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return withOpencodeDir(opencodeConfigDir(env, homeDir)).skillsDir(); +} + +/** Path of the state file both plugins read. */ +export function opencodeStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'opencode-state.json'); +} + +/** + * Write the embedded plugin sources into opencode's config dir. + * + * This is the production path — it needs no source tree, so it works from an + * installed binary. Returns the files written. + */ +export async function installOpencodePlugins(base: string): Promise { + const paths = withOpencodeDir(base); + const written: string[] = []; + for (const [path, source] of [ + [paths.serverPluginPath(), OPENCODE_SERVER_PLUGIN_SOURCE], + [paths.tuiPluginPath(), OPENCODE_TUI_PLUGIN_SOURCE], + ] as const) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source, 'utf-8'); + written.push(path); + } + return written; +} + +interface TuiConfig { + $schema?: string; + plugin?: unknown[]; + [k: string]: unknown; +} + +/** + * Load tui.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it. One syntax + * error must not silently drop every other TUI plugin the user installed. + */ +async function readTuiConfig(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw.trim().length === 0) return {}; + try { + const parsed = JSON.parse(raw) as TuiConfig; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error( + `${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Register the TUI plugin in `tui.json`. + * + * Idempotent, and a merge rather than a rewrite: any other TUI plugin the user + * has installed keeps working. Returns whether anything changed, so a no-op run + * doesn't reformat a hand-maintained file. + */ +export async function registerOpencodeTuiPlugin(tuiJsonPath: string, pluginPath: string): Promise<{ added: boolean }> { + const config = await readTuiConfig(tuiJsonPath); + const plugins = Array.isArray(config.plugin) ? [...config.plugin] : []; + if (plugins.some((p) => p === pluginPath)) return { added: false }; + + // Drop any stale entry pointing at an older install location of *our* plugin + // (e.g. a rename between releases): leaving it behind makes opencode fail to + // load a file that no longer exists on every start. + const kept = plugins.filter((p) => !(typeof p === 'string' && p.endsWith(`/${OPENCODE_TUI_PLUGIN_FILENAME}`))); + kept.push(pluginPath); + + config.$schema = typeof config.$schema === 'string' ? config.$schema : 'https://opencode.ai/tui.json'; + config.plugin = kept; + await writeJsonAtomic(tuiJsonPath, config); + return { added: true }; +} + +export interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +/** Read the state file; missing or corrupt yields an empty state. */ +export async function readOpencodeState(path: string = opencodeStatePath()): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf-8')) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Set the active project (and optionally its bearer token) in the state file. + * + * Tokens for other projects are preserved so switching back to a project + * already provisioned on this machine needs no new mint — and so a mint failure + * for project B cannot cost you the credential for project A. + * + * Written 0600: this file holds bearer tokens. `writeFile`'s mode only applies + * when the file is created, so we chmod after writing too. + */ +export async function writeOpencodeState( + update: { project: string; gatewayUrl: string; token?: string }, + path: string = opencodeStatePath(), +): Promise { + const current = await readOpencodeState(path); + const tokens = { ...(current.tokens ?? {}) }; + if (update.token !== undefined && update.token !== '') tokens[update.project] = update.token; + const next: OpencodeState = { project: update.project, gatewayUrl: update.gatewayUrl, tokens }; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 }); + try { + await chmod(path, 0o600); + } catch { + /* best-effort: a credential written is better than one refused */ + } + return path; +} + +/** The token currently stored for `project`, if any. */ +export function storedToken(state: OpencodeState, project: string): string | null { + const token = state.tokens?.[project]; + return typeof token === 'string' && token !== '' ? token : null; +} diff --git a/src/cli/tests/commands/config-opencode.test.ts b/src/cli/tests/commands/config-opencode.test.ts new file mode 100644 index 0000000..42d2685 --- /dev/null +++ b/src/cli/tests/commands/config-opencode.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; + +interface ClientCalls { posts: Array<{ path: string; body?: unknown }> } + +/** + * @param tokens what `GET /api/v1/mcptokens` reports as existing for a project + */ +function mockClient(calls: ClientCalls, tokens: unknown[] = []): ApiClient { + return { + get: vi.fn(async (path: string) => { + if (path.startsWith('/api/v1/mcptokens')) return tokens; + if (path.endsWith('/skills/visible')) return []; + return {}; + }), + post: vi.fn(async (path: string, body?: unknown) => { + calls.posts.push({ path, body }); + if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' }; + return {}; + }), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config opencode', () => { + 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'); + + /** Pre-existing state, as a machine that has already run this command has. */ + function seedState(value: unknown): void { + mkdirSync(ocDir, { recursive: true }); + writeFileSync(statePath(), JSON.stringify(value)); + } + + function command(client: ApiClient) { + return createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + } + + beforeEach(() => { + output = []; + calls = { posts: [] }; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-oc-')); + ocDir = join(tmpDir, 'opencode'); + }); + afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); + + it('requires a project', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--opencode-dir', ocDir, '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(existsSync(statePath())).toBe(false); + }); + + 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'); + }); + + it('--dry-run reports the plan and writes nothing', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--dry-run'], { from: 'user' }); + const plan = JSON.parse(output.join('\n')); + expect(plan.opencode.mcpUrl).toContain('/projects/p/mcp'); + expect(plan.opencode.statePath).toBe(statePath()); + expect(existsSync(ocDir)).toBe(false); + expect(calls.posts).toHaveLength(0); + }); + + it('--skip-marker leaves the working directory unscoped', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(existsSync(join(workDir, '.mcpctl-project'))).toBe(false); + } finally { + process.chdir(cwd); + } + }); + + it('writes a .mcpctl-project marker by default', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work2'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', '--skip-skills'], + { from: 'user' }); + expect(readFileSync(join(workDir, '.mcpctl-project'), 'utf-8')).toContain('p'); + } finally { + process.chdir(cwd); + } + }); +}); diff --git a/src/cli/tests/config/opencode-extension-embed.test.ts b/src/cli/tests/config/opencode-extension-embed.test.ts new file mode 100644 index 0000000..422ac8c --- /dev/null +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +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_FILENAME, +} from '../../src/config/opencode-extension.js'; + +/** + * `mcpctl config opencode` installs the *embedded* copy of the plugins, not the + * files in src/opencode-ext/. Editing the sources without re-running the + * generator therefore ships stale code to users while the repo looks correct — + * and the embedded copy is the one thing no typecheck covers. Same guarantee + * the completions check gives. + */ +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('install under names opencode can actually load', () => { + // The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is + // referenced by path from tui.json and must stay .tsx for its JSX to be + // transpiled. + expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); + expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); + }); + + 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('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'"); + }); +}); diff --git a/src/cli/tests/config/opencode-project-order.test.ts b/src/cli/tests/config/opencode-project-order.test.ts new file mode 100644 index 0000000..87aec71 --- /dev/null +++ b/src/cli/tests/config/opencode-project-order.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { orderProjects, indicatorLabel } from '../../../opencode-ext/mcpctl-opencode-tui.js'; + +/** + * opencode's select dialog filters as you type, so the picker only has to get + * the *order* right — the active project first, because "the one I am on" is + * the most likely pick and real installs run to hundreds of projects (smoke-test + * leftovers included). + */ +const PROJECTS = [ + { name: 'smoke-proj-none-mohimh46' }, + { name: 'homeautomation', description: 'house' }, + { name: 'docmost' }, + { name: 'copy-homeautomation' }, + { name: 'labctl' }, + { name: 'sre' }, +]; + +describe('orderProjects', () => { + it('puts the active project first, then sorts alphabetically', () => { + expect(orderProjects(PROJECTS, 'labctl').map((p) => p.name)).toEqual([ + 'labctl', + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('sorts alphabetically when nothing is active', () => { + expect(orderProjects(PROJECTS, null).map((p) => p.name)).toEqual([ + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'labctl', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('never drops or duplicates a project', () => { + expect(orderProjects(PROJECTS, 'sre')).toHaveLength(PROJECTS.length); + expect(orderProjects(PROJECTS, 'not-a-project')).toHaveLength(PROJECTS.length); + }); + + it('does not mutate the 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'); + }); +}); diff --git a/src/cli/tests/utils/opencode-settings.test.ts b/src/cli/tests/utils/opencode-settings.test.ts new file mode 100644 index 0000000..1dc6893 --- /dev/null +++ b/src/cli/tests/utils/opencode-settings.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + opencodeConfigDir, + opencodeSkillsDir, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + 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', () => { + expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/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('ignores an empty XDG_CONFIG_HOME rather than resolving against ""', () => { + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '' }, '/home/u')).toBe('/home/u/.config/opencode'); + }); + + it('puts skills where opencode looks for them', () => { + expect(opencodeSkillsDir({}, '/home/u')).toBe('/home/u/.config/opencode/skill'); + }); +}); + +describe('installOpencodePlugins', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); }); + afterEach(() => { rmSync(dir, { 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); + }); +}); + +describe('registerOpencodeTuiPlugin', () => { + let dir: string; + let tuiJson: string; + const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx'; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); + tuiJson = join(dir, 'tui.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates tui.json with the plugin and a $schema', async () => { + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true }); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual([pluginPath]); + expect(parsed.$schema).toBe('https://opencode.ai/tui.json'); + }); + + 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); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual(['opencode-tui-utils', pluginPath]); + expect(parsed.theme).toBe('nord'); + }); + + it('drops a stale entry for an older install location of our own plugin', async () => { + // Left behind, opencode fails to load a file that no longer exists on + // every start. + writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/place/mcpctl-tui.tsx', 'other-plugin'] })); + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + expect(JSON.parse(readFileSync(tuiJson, 'utf-8')).plugin).toEqual(['other-plugin', pluginPath]); + }); + + it('refuses to overwrite a corrupt tui.json instead of dropping the 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 }); + }); +}); + +describe('opencode state file', () => { + let dir: string; + let statePath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); + statePath = join(dir, 'nested', 'opencode-state.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates the directory and writes project + gateway + token', async () => { + 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('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('keeps other projects’ tokens, so switching back needs no new mint', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath); + await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'tok-b' }, statePath); + const state = await readOpencodeState(statePath); + expect(state.project).toBe('b'); + expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-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('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(); + expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x'); + }); +}); diff --git a/src/cli/vitest.config.ts b/src/cli/vitest.config.ts index 1cbc8c8..beac373 100644 --- a/src/cli/vitest.config.ts +++ b/src/cli/vitest.config.ts @@ -1,6 +1,13 @@ import { defineProject } from 'vitest/config'; export default defineProject({ + // The opencode TUI plugin is a .tsx that ships as source; its pure helpers are + // unit-tested by importing that file directly, so the test run needs the same + // JSX runtime opencode transpiles it with. + esbuild: { + jsx: 'automatic', + jsxImportSource: '@opentui/solid', + }, test: { name: 'cli', include: ['tests/**/*.test.ts'], diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx new file mode 100644 index 0000000..bd067f7 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -0,0 +1,298 @@ +/** @jsxImportSource @opentui/solid */ +/** + * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in + * `~/.config/opencode/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:` indicator in the prompt footer, next to the model + * name and 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. + * + * Only Node builtins + opencode's plugin API are imported. + */ +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'; + +/** 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. + */ +const KV_LABEL = 'mcpctl.project'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +interface ProjectInfo { + name: string; + description?: string; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +/** + * Run the `mcpctl` CLI and resolve its stdout. + * + * execFile, not a shell: project names come from the server and would otherwise + * need quoting, and a shell buys nothing here. + */ +function mcpctl(args: string[], timeoutMs = 120_000): Promise { + return new Promise((resolve, reject) => { + execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || err.message).trim() || String(err))); + else resolve(stdout); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000); + 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 })); +} + +/** + * Active project first, then alphabetical. + * + * opencode's select dialog does its own fuzzy filtering as you type, so unlike + * the pi and prime-agent switchers this needs no pre-filter prompt — only a + * sensible starting order, since the most likely pick is "the one I am on". + * + * Exported so the ordering is unit-tested rather than eyeballed through a TUI. + */ +export function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] { + return [...projects].sort((a, b) => { + if (a.name === active) return -1; + if (b.name === active) return 1; + return a.name.localeCompare(b.name); + }); +} + +/** The footer label for a project (or the absence of one). */ +export function indicatorLabel(project: string | null): string { + return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none'; +} + +const tui = async (api: TuiPluginApi): Promise => { + /** Re-read the state file and publish the footer label. */ + async function refreshIndicator(): Promise { + const state = await readState(); + const project = state.project ?? null; + api.kv.set(KV_LABEL, indicatorLabel(project)); + return project !== null && project !== '' ? project : null; + } + + /** + * Point the live MCP mount at `project`. + * + * Registering under the same name every time keeps the tool prefix stable + * (`mcpctl_*`), and because opencode re-resolves tools per request the model + * simply sees the new project's tools on its next turn. + */ + async function mount(project: string, state: OpencodeState): Promise { + const gatewayUrl = state.gatewayUrl; + if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `'); + const token = state.tokens?.[project] ?? ''; + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + await api.client.mcp.add({ + name: SERVER_NAME, + config: { + type: 'remote', + url: projectUrl(gatewayUrl, project), + headers, + enabled: true, + timeout: 120_000, + }, + }); + } + + async function switchTo(project: string): Promise { + api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' }); + try { + // 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. + 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' }); + return; + } + 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; + } + await refreshIndicator(); + api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' }); + } + + api.keymap.registerLayer({ + commands: [ + { + name: 'mcpctl.switch', + title: 'mcpctl: switch project', + description: 'Mount another mcpctl project’s MCP servers and skills', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl', + async run(): Promise { + const active = await refreshIndicator(); + let projects: ProjectInfo[]; + try { + projects = await listProjects(); + } catch (err) { + api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' }); + return; + } + if (projects.length === 0) { + api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' }); + return; + } + const DialogSelect = api.ui.DialogSelect; + api.ui.dialog.replace(() => ( + ({ + title: p.name, + value: p.name, + description: p.description, + }))} + onSelect={(option): void => { + api.ui.dialog.clear(); + const picked = option.value; + if (typeof picked !== 'string') return; + if (picked === active) { + api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' }); + return; + } + void switchTo(picked); + }} + /> + )); + }, + }, + { + name: 'mcpctl.status', + title: 'mcpctl: status', + description: 'Show the active mcpctl project and its MCP mount', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-status', + async run(): Promise { + const state = await readState(); + const project = await refreshIndicator(); + let mcpStatus = 'unknown'; + try { + const res = await api.client.mcp.status(); + mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted'; + } catch { + mcpStatus = 'unavailable'; + } + const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a'; + api.ui.toast({ + message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`, + variant: mcpStatus === 'connected' ? 'success' : 'warning', + duration: 12_000, + }); + }, + }, + { + name: 'mcpctl.skills', + title: 'mcpctl: sync skills', + description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-skills', + async run(): Promise { + const project = await refreshIndicator(); + if (project === null) { + api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' }); + return; + } + try { + await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']); + api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' }); + } catch (err) { + api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' }); + } + }, + }, + ], + }); + + // The indicator. `session_prompt_right` and `home_prompt_right` are the only + // slots in the footer cluster opencode exposes to plugins: they render on the + // prompt's bottom line, immediately right of the model name and directly + // 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.) + const Indicator = (): JSX.Element => ( + {api.kv.get(KV_LABEL, indicatorLabel(null))} + ); + api.slots.register({ + order: 100, + slots: { + session_prompt_right: () => , + home_prompt_right: () => , + }, + }); + + await refreshIndicator(); +}; + +function errText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export default { + id: 'mcpctl', + tui, +} satisfies TuiPluginModule & { id: string }; diff --git a/src/opencode-ext/mcpctl-opencode.ts b/src/opencode-ext/mcpctl-opencode.ts new file mode 100644 index 0000000..7fa6558 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -0,0 +1,165 @@ +/** + * 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. + * + * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json: + * 1. The gateway needs an `Authorization: Bearer ` 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. + * + * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that + * headless runs (`opencode run ...`), which load no TUI plugins at all, still + * get the active project's tools. + * + * Only Node builtins + the plugin API are imported, so the installed file needs + * no dependencies of its own. + */ +import type { Plugin, PluginModule } from '@opencode-ai/plugin'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name we mount under. Constant on purpose — see `mount`. */ +const SERVER_NAME = 'mcpctl'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +/** Proxy MCP URL for a project on the gateway. */ +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +const server: Plugin = async ({ client }) => { + /** + * The (url, token) this process last registered. + * + * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal + * binds a gated project's unlocked state to the `mcp-session-id` of that + * connection. Re-adding an unchanged config every turn would therefore drop + * the gate open by `begin_session` and re-lock the project mid-conversation. + * So we only call `add` when the target actually changed — or when the mount + * is not connected, where reconnecting is the whole point. + */ + let mounted: string | null = null; + + /** + * Mount (or re-point) the active project. + * + * The MCP server is always registered under the same name, so tools keep the + * stable `mcpctl_*` prefix across switches and the model never sees a tool + * namespace vanish mid-conversation. opencode resolves the tool list per + * request, so a re-point is picked up on the next turn with no restart and no + * "the tools you were told about are gone" announcement to the model. + */ + async function mount(): Promise { + const state = await readState(); + const project = state.project; + const gatewayUrl = state.gatewayUrl; + if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return; + const token = state.tokens?.[project] ?? ''; + const url = projectUrl(gatewayUrl, project); + const target = `${url}\u0000${token}`; + + if (mounted === target && (await isConnected())) return; + + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + await client.mcp.add({ + body: { + name: SERVER_NAME, + config: { + type: 'remote', + url, + headers, + enabled: true, + timeout: 120_000, + }, + }, + }); + mounted = target; + } + + /** Is our mount currently up? Unknown/unreachable counts as "not connected". */ + async function isConnected(): Promise { + try { + const res = await client.mcp.status(); + return res.data?.[SERVER_NAME]?.status === 'connected'; + } catch { + return false; + } + } + + /** + * `mount`, serialised and never throwing. + * + * Serialised because the two callers below can overlap — the event stream is + * chatty and a message can land while a mount is still connecting — and two + * concurrent `mcp.add` calls would race to register the same name. + * + * Never throwing because an unreachable gateway must degrade to "no mcpctl + * tools", not to "opencode fails to start". + */ + let inflight: Promise | null = null; + function ensureMounted(): Promise { + inflight ??= mount() + .catch(() => { /* best-effort */ }) + .finally(() => { inflight = null; }); + 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. + 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). + */ + event: async (): Promise => { + await ensureMounted(); + }, + + /** + * 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. + */ + 'chat.message': async (): Promise => { + await ensureMounted(); + }, + }; +}; + +export default { + id: 'mcpctl', + server, +} satisfies PluginModule & { id: string }; diff --git a/src/opencode-ext/tsconfig.json b/src/opencode-ext/tsconfig.json new file mode 100644 index 0000000..542bb09 --- /dev/null +++ b/src/opencode-ext/tsconfig.json @@ -0,0 +1,28 @@ +{ + "//": [ + "The opencode plugins are shipped as source (embedded in the CLI, then", + "written into ~/.config/opencode/) and are therefore never compiled by the", + "CLI's own build. Without this project they would be typechecked by nothing.", + "", + "They are checked against the REAL @opencode-ai/plugin types (a dev", + "dependency, pinned to the opencode release they target) rather than a", + "hand-written shim, because a shim drifting from the published API is the", + "exact failure mode this guards against — see src/pi-ext/tsconfig.json." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-opencode.ts", "mcpctl-opencode-tui.tsx"] +}