From 2022e584d1d3b71dbabea7fe14ef2fdb36ce6b0a Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 9 Aug 2026 00:54:51 +0100 Subject: [PATCH] =?UTF-8?q?feat(opencode):=20native=20opencode=20addon=20?= =?UTF-8?q?=E2=80=94=20/mcpctl=20switcher,=20live=20project=20switching,?= =?UTF-8?q?=20footer=20indicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement mcpctl as an opencode addon mirroring the pi/prime-agent integrations. Mounts the active project's MCP gateway through opencode's own live MCP API, so switching projects needs no restart and opencode.json is never touched (the bearer token stays in a 0600 state file). - server plugin (plugin/mcpctl.ts): headless mount under a stable 'mcpctl' name, re-asserted on first contact + before each turn; skips re-registering an unchanged mount so a gated project's begin_session state survives. - TUI plugin (mcpctl/mcpctl-tui.tsx): /mcpctl filterable picker (live switch), /mcpctl-status, /mcpctl-skills, and a mcpctl: footer indicator right of the model name, directly above the token counter. - mcpctl config opencode: mint/reuse project mcptoken, install + register plugins, write 0600 state, write marker, sync skills. - skills sync --agent opencode: new shared-tree target (XDG-aware). - shared credential plumbing lifted from config prime-agent and parameterised by agent so both hosts share mint/reuse/retire logic. - embedded-source generator + freshness test; typechecked against the real @opencode-ai/plugin types (1.18.15); unit tests for settings, order, embed. typecheck (incl. opencode-ext) and the full cli suite (619 tests) pass. --- README.md | 43 + completions/mcpctl.bash | 5 +- completions/mcpctl.fish | 17 +- docs/opencode-extension.md | 185 ++++ package.json | 5 +- pnpm-lock.yaml | 914 +++++++++++++++++- scripts/generate-opencode-extension.ts | 55 ++ src/cli/src/commands/config.ts | 399 ++++++-- src/cli/src/commands/skills.ts | 55 +- src/cli/src/config/opencode-extension.ts | 20 + src/cli/src/utils/opencode-settings.ts | 208 ++++ .../tests/commands/config-opencode.test.ts | 101 ++ .../config/opencode-extension-embed.test.ts | 49 + .../tests/opencode/opencode-tui-order.test.ts | 38 + src/cli/tests/utils/opencode-settings.test.ts | 122 +++ src/opencode-ext/mcpctl-opencode-tui.tsx | 291 ++++++ src/opencode-ext/mcpctl-opencode.ts | 154 +++ src/opencode-ext/tsconfig.json | 28 + 18 files changed, 2561 insertions(+), 128 deletions(-) create mode 100644 docs/opencode-extension.md create mode 100644 scripts/generate-opencode-extension.ts create mode 100644 src/cli/src/config/opencode-extension.ts create mode 100644 src/cli/src/utils/opencode-settings.ts create mode 100644 src/cli/tests/commands/config-opencode.test.ts create mode 100644 src/cli/tests/config/opencode-extension-embed.test.ts create mode 100644 src/cli/tests/opencode/opencode-tui-order.test.ts create mode 100644 src/cli/tests/utils/opencode-settings.test.ts create mode 100644 src/opencode-ext/mcpctl-opencode-tui.tsx create mode 100644 src/opencode-ext/mcpctl-opencode.ts create mode 100644 src/opencode-ext/tsconfig.json diff --git a/README.md b/README.md index 3f7f839..988ad61 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,49 @@ 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's gateway through the running +app — switching projects needs **no restart**. + +```bash +mcpctl config opencode --project monitoring +``` + +That mints (or reuses) the project token, writes it to a 0600 state file +(`~/.mcpctl/opencode-state.json` — the token never lands in a mode-0644 +`opencode.json` people paste into bug reports), installs a server plugin into +`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into +`~/.config/opencode/mcpctl/`, registers the latter in `tui.json`, and syncs +skills into `~/.config/opencode/skill/`. + +Inside opencode: + +- `/mcpctl` — filterable project picker; switches **live, no restart** +- `/mcpctl-status` — active project, mount state, gateway URL +- `/mcpctl-skills` — re-sync this project's skills +- a `mcpctl:` footer indicator right of the model name, above the + token counter + +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 +``` + +Re-sync skills later: + +```bash +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..ad85d16 --- /dev/null +++ b/docs/opencode-extension.md @@ -0,0 +1,185 @@ +# 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 correct on the very 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/package.json b/package.json index de493e0..3446fcc 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,8 @@ "devDependencies": { "@earendil-works/pi-ai": "^0.84.1", "@earendil-works/pi-coding-agent": "0.84.1", + "@opencode-ai/plugin": "1.18.15", + "@opentui/solid": "^0.5.1", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6c99d..854a1ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ 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.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)) + '@opentui/solid': + specifier: ^0.5.1 + version: 0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -184,7 +190,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 +286,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 +422,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 +442,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 +547,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 +582,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 +602,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 +1407,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 +1546,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 +1669,56 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@opentui/core-darwin-arm64@0.5.1': + resolution: {integrity: sha512-Yl3JBLYRrBN+SxXY/gYaqCT/JNrN50K4xO7hYC+/Si8/FgOrBlbRmfJIUNQdZMMLUvOMA+I813+hDw3xfarBzQ==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.5.1': + resolution: {integrity: sha512-kqMVu+LGuHSCxYFkVJtmuyLLLTMztILSNnlx1eSpHHUiDV4PMc+zkxwRIXO+o0TFTW3gNUKleKUkggriYje7Vw==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.5.1': + resolution: {integrity: sha512-rmFMtiCm8I0fESB834sTN/ewoI+QDSber588ZO+i08JR6mbv7hkiKW2H/MhiAY1GxGK4nXApleBMyGVOlVDvgQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-arm64@0.5.1': + resolution: {integrity: sha512-PpE1nCHRkxEvSYyZFMToPHjQoVh50A7+BbgetlTX/5ImXzo6iSO83a+7M/1WgZnNu+uZJf5GZKAAcLoRrvQl3Q==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.5.1': + resolution: {integrity: sha512-WO8RjhqKyqW/7P0xHdEVT8JGfU2MO7RlK0kdkNnRSnAEVwsTNd2ibhmKDPLGpo/DKaLuA00CsnrNiLGZZiQJKQ==} + cpu: [x64] + os: [linux] + + '@opentui/core-linux-x64@0.5.1': + resolution: {integrity: sha512-/CxFxFv+ffMof2nYQrpgEfNkWKkKxYUSfwdt2RdDN5fZRhcxjE949743rV0Oovw5Az63qxPgbyfcZVNVO2HVNg==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.5.1': + resolution: {integrity: sha512-AgeTjZbdMxSiuBjyLvcug91qd1Ds6Dlg5z4lCInqL7mPQicDEnKZs5lF2FAaktcU7RPi2wLybbQ/vM0NbpXYmw==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.5.1': + resolution: {integrity: sha512-VttbQHVoZQ5uW5IcQeUHPEx/WFQ2mMflukhhbBjpNSdZOPdzmmC4QGFPQznJVwuzXTnjQ2Nll4AY0ROJ/Q3nkw==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.5.1': + resolution: {integrity: sha512-mIBFyqIP4rkhQ35uldLXWawWQ6S9tvNWvmxGmDJ7W9cLXjegG6gKEfZ/4NyIMma755ERs/sqO/pIh3Ytf3DDFg==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/solid@0.5.1': + resolution: {integrity: sha512-eynJILdvxmprr7oou3cqAiAzZ6zzRU0m1y42/L9GZObTKr76tVtPFQpLvQl6ZJTWMoswpwx1pJWqcUN+ZAI8Rg==} + peerDependencies: + solid-js: 1.9.12 + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2198,6 +2426,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 +2558,11 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} + bun-ffi-structs@0.3.1: + resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==} + peerDependencies: + typescript: ^5 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2583,6 +2833,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 +2871,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 +2898,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 +3060,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 +3122,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 +3260,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 +3306,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 +3337,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 +3416,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 +3454,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 +3586,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 +3624,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 +3704,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 +3760,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 +3828,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 +3863,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 +3903,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 +3977,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 +4098,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 +4122,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 +4138,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 +4155,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 +4171,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 +4215,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 +4290,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 +4386,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 +4449,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 +4493,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 +4578,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 +4697,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 +4784,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 +4893,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 +4997,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 +5138,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 +5407,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.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + 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 +5463,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 +5483,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.0 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5028,6 +5518,22 @@ 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.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5037,12 +5543,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 +5595,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 +5627,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.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) + '@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 +5657,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 +5675,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 +6427,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 +6462,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 +6624,20 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 + '@opencode-ai/plugin@1.18.15(@opentui/core@0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@opencode-ai/sdk': 1.18.15 + effect: 4.0.0-beta.83 + zod: 4.1.8 + optionalDependencies: + '@opentui/core': 0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + + '@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 +6757,65 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentui/core-darwin-arm64@0.5.1': + optional: true + + '@opentui/core-darwin-x64@0.5.1': + optional: true + + '@opentui/core-linux-arm64-musl@0.5.1': + optional: true + + '@opentui/core-linux-arm64@0.5.1': + optional: true + + '@opentui/core-linux-x64-musl@0.5.1': + optional: true + + '@opentui/core-linux-x64@0.5.1': + optional: true + + '@opentui/core-win32-arm64@0.5.1': + optional: true + + '@opentui/core-win32-x64@0.5.1': + optional: true + + '@opentui/core@0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.3.1(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.5.1 + '@opentui/core-darwin-x64': 0.5.1 + '@opentui/core-linux-arm64': 0.5.1 + '@opentui/core-linux-arm64-musl': 0.5.1 + '@opentui/core-linux-x64': 0.5.1 + '@opentui/core-linux-x64-musl': 0.5.1 + '@opentui/core-win32-arm64': 0.5.1 + '@opentui/core-win32-x64': 0.5.1 + transitivePeerDependencies: + - typescript + + '@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12) + entities: 7.0.1 + 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)': @@ -6459,7 +7181,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 +7539,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.0 + 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 +7688,10 @@ snapshots: buildcheck@0.0.7: optional: true + bun-ffi-structs@0.3.1(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + bytes@3.1.2: {} c12@3.1.0: @@ -7161,6 +7911,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -7209,6 +7961,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 +7993,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 +8211,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 +8292,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 +8459,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 +8501,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 +8527,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -7805,6 +8601,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,6 +8655,10 @@ 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: {} @@ -7972,6 +8774,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 +8809,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 +8871,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 +8935,8 @@ snapshots: marked@14.0.0: {} + marked@17.0.1: {} + marked@18.0.5: {} math-intrinsics@1.1.0: {} @@ -8166,6 +8979,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 +9017,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.3: {} @@ -8230,6 +9049,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 +9119,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 +9257,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 +9280,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 +9312,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 +9326,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 +9384,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 +9469,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -8680,8 +9551,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 +9635,8 @@ snapshots: dependencies: tslib: 2.8.1 + s-js@0.4.9: {} + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -8795,6 +9677,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 +9806,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 +9892,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 +9929,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 +10036,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -9227,6 +10125,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 +10199,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 +10307,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..0bfb893 --- /dev/null +++ b/scripts/generate-opencode-extension.ts @@ -0,0 +1,55 @@ +#!/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: string): string { + // 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/ + * so an installed binary with no source tree can still provision a working + * opencode integration. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +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..f5c1f08 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -35,6 +35,16 @@ import { } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; +import { + opencodeConfigDir, + opencodeStatePath, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../utils/opencode-settings.js'; /** * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint @@ -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,117 @@ 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; + } + + /** + * 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; + } + + /** + * 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}\`.`); + } + } + config .command('view') .description('Show current configuration') @@ -299,7 +423,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 +433,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`); } @@ -416,87 +534,6 @@ export function createConfigCommand(deps?: Partial, apiDeps?: if (hidden) { 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 +627,184 @@ export function createConfigCommand(deps?: Partial, apiDeps?: registerPrimeAgentCommand('prime-agent', false); registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + // ── opencode: plugins + state (live MCP, never touches opencode.json) ── + config + .command('opencode') + .description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills') + .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..9727c5f 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -33,6 +33,10 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { opencodeSkillsDir } from '../utils/opencode-settings.js'; + +/** The agent skill trees `skills sync` / `config ` can target. */ +export type SyncTarget = 'claude' | 'prime-agent' | 'pi' | 'opencode'; /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -104,7 +108,7 @@ export interface SyncOpts { * first project's skills, and pre-existing * (untracked) skill dirs are preserved. */ - target?: 'claude' | 'prime-agent' | 'pi'; + target?: SyncTarget; } export interface SyncResult { @@ -191,33 +195,24 @@ 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 +561,21 @@ 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(), env: NodeJS.ProcessEnv = process.env): 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(env, homeDir); case 'claude': default: - return join(homedir(), '.claude', 'skills'); + return join(homeDir, '.claude', 'skills'); } } @@ -589,9 +588,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 +608,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { // Validate --agent so an unknown value fails loudly instead of silently // running the default (Claude) sync. const agent = opts.agent ?? 'claude'; - if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') { - warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`); + if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi' && agent !== 'opencode') { + warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', 'pi', or 'opencode')`); process.exitCode = 1; return; } @@ -622,7 +621,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - target: agent as 'claude' | 'prime-agent' | 'pi', + target: agent as SyncTarget, 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..f0af2e9 --- /dev/null +++ b/src/cli/src/config/opencode-extension.ts @@ -0,0 +1,20 @@ +/** + * 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/ + * so an installed binary with no source tree can still provision a working + * opencode integration. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +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 opencode's auto-discovered\n * `plugin/` directory (`~/.config/opencode/plugin/mcpctl.ts`), where it is\n * loaded at startup. It mounts the ACTIVE mcpctl project's MCP gateway as a\n * remote MCP server, so opencode's own MCP client exposes the project's tools\n * natively (`mcpctl_*`).\n *\n * WHY A PLUGIN AND NOT AN `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header — a\n * secret that does not belong in a mode-0644 config file users paste into\n * bug reports. `~/.mcpctl/opencode-state.json` is 0600 like every other\n * mcpctl credential.\n * 2. Switching projects must work WITHOUT restarting opencode. The server\n * exposes `POST /mcp` (add) and `POST /mcp/{name}/disconnect`, so the\n * mount can be re-pointed live; an `mcp` block in opencode.json cannot.\n *\n * It exists so headless runs (`opencode run ...`), which load no TUI plugins at\n * all, still get the active project's tools; the TUI plugin\n * (`mcpctl-opencode-tui.tsx`) drives the switch and the on-screen indicator.\n *\n * Only Node builtins + the plugin API are imported.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\n/** MCP server name the active project is mounted under. Constant on purpose. */\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 const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n return typeof parsed === 'object' && parsed !== null ? parsed : {};\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) target 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 opened 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 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 // Deliberately NOT mounted here. Plugin setup runs before the server accepts\n // connections, and `client.mcp.add` calls back into that same server —\n // awaiting it at this point hangs opencode on a blank screen before the TUI\n // ever draws. Both hooks below fire only once the server is live.\n return {\n /** First contact: mount as soon as the server is up. */\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) rewrites the state file underneath us. Re-reading here is what\n * makes an external switch take effect on the next message instead of on\n * the next restart. When nothing changed this is one state-file read — the\n * mount hook won't re-register an unchanged mount.\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 + footer indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in `tui.json`.\n *\n * Ships the same UX the pi and prime-agent integrations do, for opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — show the active project, mount state and gateway URL\n * - `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir\n * - a `mcpctl:` footer indicator, rendered right of the model name on\n * the prompt's bottom line, one row above the token counter.\n *\n * The switch is handed to the `mcpctl` CLI (the same binary that installed this\n * file) so token minting, credential state and skills stay in one place; the\n * plugin stays a UI shell. Once the CLI has rewritten the state file, the mount\n * is re-pointed live through opencode's own MCP API — no restart.\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 { homedir } from 'node:os';\nimport { join } from 'node:path';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/** kv key holding the label the footer renders. */\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 const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n return typeof parsed === 'object' && parsed !== null ? parsed : {};\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) => ({\n name: p.name,\n description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined,\n }));\n}\n\n/**\n * Order the project list for the picker: the active project first (most likely\n * pick), then alphabetical. opencode's DialogSelect already filters as you\n * type, so no pre-filter prompt is needed — only a sensible starting order.\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 === '') {\n throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n }\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.\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 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..270d46e --- /dev/null +++ b/src/cli/src/utils/opencode-settings.ts @@ -0,0 +1,208 @@ +/** + * 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. + * + * The plugin sources are embedded in the CLI (see + * `config/opencode-extension.ts`) so an installed binary with no source tree + * can still provision a working opencode integration. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`. + */ +import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises'; +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..b3323e4 --- /dev/null +++ b/src/cli/tests/commands/config-opencode.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(visibleSkills: unknown[] = []): ApiClient { + return { + get: vi.fn(async (path: string) => { + if (path.endsWith('/skills/visible')) return visibleSkills; + if (path.startsWith('/api/v1/skills/')) return { + id: 's1', name: 'demo', description: 'd', semver: '1.0.0', + content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null, + }; + return {}; + }), + post: vi.fn(async () => ({ token: 'mcpctl_pat_test1234567'} as unknown)), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config opencode', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-opencode-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('installs both plugins, registers the TUI plugin, writes state, syncs skills', async () => { + const ocDir = join(tmpDir, 'opencode-cfg'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: mockClient([{ + id: 's1', name: 'demo', scope: 'project', projectId: 'proj', agentId: null, + contentHash: 'abc', updatedAt: new Date().toISOString(), description: 'd', + }]), credentialsDeps: { configDir: tmpDir }, log }, + ); + + await cmd.parseAsync( + ['opencode', '--project', 'monitoring', '--opencode-dir', ocDir], + { from: 'user' }, + ); + + // Server plugin in the auto-discovered plugin/ dir. + const serverPath = join(ocDir, 'plugin', 'mcpctl.ts'); + expect(existsSync(serverPath)).toBe(true); + expect(readFileSync(serverPath, 'utf-8')).toContain('client.mcp.add'); + + // TUI plugin + registration in tui.json. + const tuiPath = join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'); + expect(existsSync(tuiPath)).toBe(true); + const tuiJson = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8')); + expect(tuiJson.plugin).toContain(tuiPath); + + // Active project persisted with a bearer token. + const statePath = join(ocDir, 'mcpctl-state.json'); + expect(existsSync(statePath)).toBe(true); + const state = JSON.parse(readFileSync(statePath, 'utf-8')); + expect(state.project).toBe('monitoring'); + expect(state.tokens?.monitoring).toBe('mcpctl_pat_test1234567'); + }); + + it('dry-run writes nothing', async () => { + const ocDir = join(tmpDir, 'opencode-cfg2'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + + await cmd.parseAsync( + ['opencode', '--project', 'monitoring', '--opencode-dir', ocDir, '--dry-run'], + { from: 'user' }, + ); + + expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false); + expect(existsSync(join(ocDir, 'mcpctl-state.json'))).toBe(false); + expect(client.get).not.toHaveBeenCalled(); + }); + + it('fails fast with no project', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + + await cmd.parseAsync(['opencode'], { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); +}); 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..06f01d1 --- /dev/null +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_SOURCE, + 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 and the pi embed test give. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'opencode-ext'); + +describe('embedded opencode plugins', () => { + it('server plugin matches src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { + const onDisk = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); + expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed').toBe(onDisk); + }); + + it('TUI plugin matches src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { + const onDisk = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); + expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed').toBe(onDisk); + }); + + it('ships both plugins and wires the TUI one into tui.json', () => { + expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); + expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); + // The TUI plugin registers slash commands + a footer indicator. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('slashName:'); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('session_prompt_right'); + }); + + it('ever-mounts the gateway through opencode, never a secret in opencode.json', () => { + // The server plugin mounts via client.mcp.add and reads a 0600 state file. + expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('client.mcp.add'); + expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('opencode-state.json'); + // The bearer token must never be written into a mode-0644 config: the + // mount reads it from the state file, and the URL is the project gateway. + expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('Authorization'); + expect(OPENCODE_SERVER_PLUGIN_SOURCE).not.toContain('headers in opencode.json'); + }); +}); diff --git a/src/cli/tests/opencode/opencode-tui-order.test.ts b/src/cli/tests/opencode/opencode-tui-order.test.ts new file mode 100644 index 0000000..b5e40f7 --- /dev/null +++ b/src/cli/tests/opencode/opencode-tui-order.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { orderProjects, indicatorLabel } from '../../src/opencode-ext/mcpctl-opencode-tui.js' ; + +describe('opencode TUI project ordering', () => { + it('puts the active project first, then alphabetical', () => { + const projects = [ + { name: 'docmost' }, + { name: 'homeautomation' }, + { name: 'sre' }, + ]; + expect(orderProjects(projects, 'sre').map((p) => p.name)).toEqual(['sre', 'docmost', 'homeautomation']); + }); + + it('is alphabetical with no active project', () => { + const projects = [ + { name: 'sre' }, + { name: 'docmost' }, + ]; + expect(orderProjects(projects, null).map((p) => p.name)).toEqual(['docmost', 'sre']); + }); + + it('does not mutate the input', () => { + const projects = [{ name: 'b' }, { name: 'a' }]; + const copy = [...projects]; + orderProjects(projects, 'b'); + expect(projects).toEqual(copy); + }); +}); + +describe('opencode TUI footer label', () => { + it('renders the project name', () => { + expect(indicatorLabel('monitoring')).toBe('mcpctl:monitoring'); + }); + it('renders none when there is no project', () => { + expect(indicatorLabel(null)).toBe('mcpctl:none'); + expect(indicatorLabel('')).toBe('mcpctl:none'); + }); +}); 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..dc6a59c --- /dev/null +++ b/src/cli/tests/utils/opencode-settings.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { + opencodeConfigDir, + opencodeSkillsDir, + opencodeStatePath, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../../src/utils/opencode-settings.js'; + +describe('opencode-settings paths', () => { + it('resolves the config dir XDG-aware', () => { + expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode'); + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/custom' }, '/home/u')).toBe('/custom/opencode'); + }); + + it('exposes every path config opencode writes under a base dir', () => { + const p = withOpencodeDir('/base'); + expect(p.serverPluginPath()).toBe(join('/base', 'plugin', 'mcpctl.ts')); + expect(p.tuiPluginPath()).toBe(join('/base', 'mcpctl', 'mcpctl-tui.tsx')); + expect(p.tuiJsonPath()).toBe(join('/base', 'tui.json')); + expect(p.skillsDir()).toBe(join('/base', 'skill')); + }); + + it('skills dir is under the config dir', () => { + expect(opencodeSkillsDir({}, '/home/u')).toBe('/home/u/.config/opencode/skill'); + }); + + it('state file lives under ~/.mcpctl', () => { + expect(opencodeStatePath('/home/u')).toBe('/home/u/.mcpctl/opencode-state.json'); + }); +}); + +describe('installOpencodePlugins', () => { + let tmp: string; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('writes both plugins into the config dir', async () => { + const written = await installOpencodePlugins(tmp); + expect(written).toContain(join(tmp, 'plugin', 'mcpctl.ts')); + expect(written).toContain(join(tmp, 'mcpctl', 'mcpctl-tui.tsx')); + // The server plugin lands where opencode auto-discovers it. + expect(readFileSync(join(tmp, 'plugin', 'mcpctl.ts'), 'utf-8')).toContain('mcpctl'); + }); +}); + +describe('registerOpencodeTuiPlugin', () => { + let tmp: string; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('creates tui.json and registers the plugin', async () => { + const tuiJson = join(tmp, 'tui.json'); + const res = await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx'); + expect(res.added).toBe(true); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toContain('/cfg/mcpctl/mcpctl-tui.tsx'); + }); + + it('is idempotent and merges with other plugins', async () => { + const tuiJson = join(tmp, 'tui.json'); + writeFileSync(tuiJson, JSON.stringify({ plugin: ['/other/plugin.ts'] })); + await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx'); + await registerOpencodeTuiPlugin(tuiJson, '/cfg/mcpctl/mcpctl-tui.tsx'); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual(['/other/plugin.ts', '/cfg/mcpctl/mcpctl-tui.tsx']); + }); + + it('drops a stale entry pointing at an older install location', async () => { + const tuiJson = join(tmp, 'tui.json'); + writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/mcpctl/mcpctl-tui.tsx'] })); + await registerOpencodeTuiPlugin(tuiJson, '/new/mcpctl/mcpctl-tui.tsx'); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual(['/new/mcpctl/mcpctl-tui.tsx']); + }); + + it('refuses to overwrite corrupt tui.json', async () => { + const tuiJson = join(tmp, 'tui.json'); + writeFileSync(tuiJson, '{not json'); + await expect(registerOpencodeTuiPlugin(tuiJson, '/p.tsx')).rejects.toThrow(); + }); +}); + +describe('opencode state file', () => { + let tmp: string; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + const statePath = (): string => join(tmp, 'opencode-state.json'); + + it('reads an empty state when the file is absent', async () => { + expect(await readOpencodeState(statePath())).toEqual({}); + }); + + it('writes project + gateway + token, preserved across switches', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'pat_a' }, statePath()); + await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'pat_b' }, statePath()); + + const state = await readOpencodeState(statePath()); + expect(state.project).toBe('b'); + expect(state.tokens).toEqual({ a: 'pat_a', b: 'pat_b' }); + }); + + it('writes the file 0600', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'pat_a' }, statePath()); + const mode = statSync(statePath()).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('storedToken returns the project token or null', () => { + expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x'); + expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull(); + expect(storedToken({}, 'b')).toBeNull(); + }); +}); diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx new file mode 100644 index 0000000..0675a3d --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -0,0 +1,291 @@ +/** @jsxImportSource @opentui/solid */ +/** + * mcpctl opencode TUI plugin — `/mcpctl` project switcher + footer indicator. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in `tui.json`. + * + * Ships the same UX the pi and prime-agent integrations do, for opencode: + * - `/mcpctl` — pick the active project from a filterable dialog + * - `/mcpctl-status` — show the active project, mount state and gateway URL + * - `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir + * - a `mcpctl:` footer indicator, rendered right of the model name on + * the prompt's bottom line, one row above the token counter. + * + * The switch is handed to the `mcpctl` CLI (the same binary that installed this + * file) so token minting, credential state and skills stay in one place; the + * plugin stays a UI shell. Once the CLI has rewritten the state file, the mount + * is re-pointed live through opencode's own MCP API — no restart. + * + * 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 { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** MCP server name the mount lives under — must match the server plugin. */ +const SERVER_NAME = 'mcpctl'; + +/** kv key holding the label the footer renders. */ +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 { + const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } 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, + })); +} + +/** + * Order the project list for the picker: the active project first (most likely + * pick), then alphabetical. opencode's DialogSelect already filters as you + * type, so no pre-filter prompt is needed — only a sensible starting order. + * + * Exported so the ordering is unit-tested rather than eyeballed through a TUI. + */ +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. + 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) { + 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..9baf496 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -0,0 +1,154 @@ +/** + * mcpctl opencode server plugin — mounts the active project's MCP gateway. + * + * Installed by `mcpctl config opencode` into opencode's auto-discovered + * `plugin/` directory (`~/.config/opencode/plugin/mcpctl.ts`), where it is + * loaded at startup. It mounts the ACTIVE mcpctl project's MCP gateway as a + * remote MCP server, so opencode's own MCP client exposes the project's tools + * natively (`mcpctl_*`). + * + * WHY A PLUGIN AND NOT AN `mcp` BLOCK IN opencode.json: + * 1. The gateway needs an `Authorization: Bearer ` header — a + * secret that does not belong in a mode-0644 config file users paste into + * bug reports. `~/.mcpctl/opencode-state.json` is 0600 like every other + * mcpctl credential. + * 2. Switching projects must work WITHOUT restarting opencode. The server + * exposes `POST /mcp` (add) and `POST /mcp/{name}/disconnect`, so the + * mount can be re-pointed live; an `mcp` block in opencode.json cannot. + * + * It exists so headless runs (`opencode run ...`), which load no TUI plugins at + * all, still get the active project's tools; the TUI plugin + * (`mcpctl-opencode-tui.tsx`) drives the switch and the on-screen indicator. + * + * Only Node builtins + the plugin API are imported. + */ +import type { Plugin, PluginModule } from '@opencode-ai/plugin'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** MCP server name the active project is mounted under. Constant on purpose. */ +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 { + const parsed = JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } 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) target this process last registered. + * + * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal + * binds a gated project's unlocked state to the `mcp-session-id` of that + * connection. Re-adding an unchanged config every turn would therefore drop + * the gate opened by `begin_session` and re-lock the project mid-conversation. + * So we only call `add` when the target actually changed — or when the mount + * is not connected, where reconnecting is the whole point. + */ + let mounted: string | null = null; + + 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; + } + + // Deliberately NOT mounted here. Plugin setup runs before the server accepts + // connections, and `client.mcp.add` calls back into that same server — + // awaiting it at this point hangs opencode on a blank screen before the TUI + // ever draws. Both hooks below fire only once the server is live. + return { + /** First contact: mount as soon as the server is up. */ + 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) rewrites the state file underneath us. Re-reading here is what + * makes an external switch take effect on the next message instead of on + * the next restart. When nothing changed this is one state-file read — the + * mount hook won't re-register an unchanged mount. + */ + 'chat.message': async (): Promise => { + 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"] +}