diff --git a/README.md b/README.md index 3f7f839..be632b2 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,51 @@ Inside pi: See [docs/pi-extension.md](docs/pi-extension.md) for full details. +### Connect opencode + +[opencode](https://opencode.ai) is a native MCP client, so mcpctl ships an +opencode integration that mounts the active project's proxy MCP gateway as an +MCP server — no extra MCP library, and no `~/.claude` dependency. + +```bash +mcpctl config opencode --project monitoring +``` + +This installs two plugins into `~/.config/opencode/`: + +- **server plugin** (`plugin/mcpctl.ts`) — auto-discovered; mounts the active + project's MCP gateway through opencode's own live MCP API, so headless runs + (`opencode run ...`) see the project's tools too; +- **TUI plugin** (`mcpctl/mcpctl-tui.tsx`, registered in `tui.json`) — the + `/mcpctl` switcher and a `mcpctl:` indicator in the prompt footer. + +It also mints/reuses the project's bearer token (stored 0600 in +`~/.mcpctl/opencode-state.json`), and runs an initial skills sync into +`~/.config/opencode/skill/`. + +Inside opencode: + +- `/mcpctl` — **switch project** from a filterable picker +- `/mcpctl-status` — show the active project + MCP mount state +- `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir +- the active project shows up as `mcpctl:` next to the model name, + just above the token counter — switch it without restarting opencode + +Options (mirror the prime-agent command): + +```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 # don't (re)install the plugins +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --opencode-dir /path # custom opencode config dir +mcpctl config opencode --project monitoring --dry-run # preview only +``` + +The `/mcpctl` switcher runs with `--skip-plugin --skip-marker`, so switching +projects from inside opencode never re-scopes whichever repository opencode +happened to be started in. + ## 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..a73a9ca 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 store (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 initial skills sync' +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..1804924 --- /dev/null +++ b/docs/opencode-extension.md @@ -0,0 +1,116 @@ +# mcpctl × opencode — native MCP integration + +## Motivation + +opencode is a native MCP client, so mcpctl can mount a project's MCP servers +without any bridge process. `mcpctl config opencode --project X`: + +1. installs two plugins into opencode's config directory, and +2. provisions the bearer credential the gateway needs. + +Switching projects stays **live** — no restarting opencode — which is the +difference between this and a static `mcp` block in `opencode.json`. + +## How it works + +Both plugins read `~/.mcpctl/opencode-state.json` (0600): + +```jsonc +{ + "project": "monitoring", + "gatewayUrl": "https://mcp.ad.itaz.eu", + "tokens": { + "monitoring": "mcpctl_pat_..." + } +} +``` + +`opencode.json` is **never touched**. The gateway needs an +`Authorization: Bearer ` header — a secret that does not belong in a +mode-0644 config file users paste into bug reports — and the mount has to be +re-pointable at runtime, which a config file cannot do. + +### Server plugin (`plugin/mcpctl.ts`) + +Auto-discovered by opencode; covers **headless** runs (`opencode run ...`), +which load no TUI plugins. It mounts the active project as a remote MCP server +under the constant name `mcpctl` via opencode's own live MCP API: + +- on first server contact (`event`), and +- before every user turn (`chat.message`), re-reading the state file so a + switch made externally takes effect on the next message. + +The mount is only re-registered when the `(url, token)` target actually changed +— re-adding an unchanged config would rebuild the connection and drop mcplocal's +gated `begin_session` state mid-conversation. Never throws: an unreachable +gateway degrades to "no mcpctl tools", never to "opencode fails to start". + +### TUI plugin (`mcpctl/mcpctl-tui.tsx`, registered in `tui.json`) + +Adds to the opencode TUI: + +- **`/mcpctl`** — switch the active project from a filterable picker (active + project first; terms match name **or** description; a pre-filter prompt is + offered when the list is long), matching the prime-agent switcher UX; +- **`/mcpctl-status`** — show the active project and its MCP mount state; +- **`/mcpctl-skills`** — re-sync this project's skills into opencode's skill dir; +- a **`mcpctl:`** indicator in the prompt footer, immediately right of + the model name and just above the token counter. + +The switch is delegated to the `mcpctl` CLI (`--skip-plugin --skip-marker`), so +token minting, state and skills stay in the CLI; once the state file is updated +the TUI re-points the live MCP mount through `api.client.mcp.add`. + +## Deliverables + +| Artifact | Purpose | +|----------|---------| +| `src/opencode-ext/mcpctl-opencode.ts` | Server plugin (headless mount) | +| `src/opencode-ext/mcpctl-opencode-tui.tsx` | TUI plugin (`/mcpctl` switcher + footer indicator) | +| `src/cli/src/utils/opencode-settings.ts` | CLI wiring: paths, `tui.json` registration, state file | +| `mcpctl config opencode` | Provision token + install/register plugins + skill sync | +| `docs/opencode-extension.md` | This document | + +## Skills + +`mcpctl config opencode` (when given a project and an API client) syncs the +project's skills into `~/.config/opencode/skill/`. Re-sync later with: + +```bash +mcpctl skills sync --agent opencode --project +``` + +`opencode` is a shared-tree target (like `prime-agent`/`pi`): its own flat skill +tree and its own state file (`~/.mcpctl/skills-state-opencode.json`), no Claude +integration. + +## Layout & typechecking + +The plugins ship as *source*: they are embedded into the CLI +(`src/cli/src/config/opencode-extension.ts`, generated by +`scripts/generate-opencode-extension.ts`) and written verbatim into +`~/.config/opencode/`. The CLI's own build never compiles them, so +`src/opencode-ext/tsconfig.json` checks them against the **real** +`@opencode-ai/plugin` types (dev dependency pinned to the opencode release they +target) — the same guarantee `src/pi-ext/` gives. + +```bash +pnpm run typecheck # includes typecheck:opencode-ext +``` + +After editing `src/opencode-ext/*`, regenerate the embedded copy or the CLI will +keep installing old sources: + +```bash +npx tsx scripts/generate-opencode-extension.ts +``` + +A test (`tests/config/opencode-extension-embed.test.ts`) fails if you forget. + +## Authentication caveat + +The server and TUI plugins send the stored `mcpctl_pat_` project token as the +bearer. A locally running `mcpctl-local` daemon does not authenticate +`/projects/*`, so the header is simply ignored; an authenticated +`mcplocal serve` accepts only `mcpctl_pat_` bearers (`mcpctl config opencode` +mints one per project). diff --git a/eslint.config.js b/eslint.config.js index 53f1093..88a6c16 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,11 +3,11 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { - project: ['./src/*/tsconfig.json'], + project: ['./src/*/tsconfig.json', './src/opencode-ext/tsconfig.json'], tsconfigRootDir: import.meta.dirname, }, }, diff --git a/package.json b/package.json index de493e0..6b980e7 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "clean": "pnpm -r run clean && rimraf node_modules", "db:up": "docker compose -f deploy/docker-compose.yml up -d", "db:down": "docker compose -f deploy/docker-compose.yml down", - "typecheck": "tsc --build && pnpm run typecheck:pi-ext", + "typecheck": "tsc --build && pnpm run typecheck:pi-ext && pnpm run typecheck:opencode-ext", "completions:generate": "tsx scripts/generate-completions.ts --write", "completions:check": "tsx scripts/generate-completions.ts --check", "rpm:build": "bash scripts/build-rpm.sh", @@ -34,6 +34,7 @@ "mcpd:deploy-dry": "bash deploy.sh --dry-run", "mcpd:logs": "bash logs.sh", "typecheck:pi-ext": "tsc -p src/pi-ext/tsconfig.json", + "typecheck:opencode-ext": "tsc -p src/opencode-ext/tsconfig.json", "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { @@ -44,6 +45,10 @@ "devDependencies": { "@earendil-works/pi-ai": "^0.84.1", "@earendil-works/pi-coding-agent": "0.84.1", + "@opencode-ai/plugin": "1.18.15", + "@opentui/core": "0.5.1", + "@opentui/keymap": "0.5.1", + "@opentui/solid": "0.5.1", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -51,6 +56,7 @@ "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", "rimraf": "^6.1.3", + "solid-js": "1.9.12", "tsx": "^4.21.0", "typebox": "1.3.11", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6c99d..a5df386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,18 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.84.1 version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@opencode-ai/plugin': + specifier: 1.18.15 + version: 1.18.15(@opentui/core@0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.5.1(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)) + '@opentui/core': + specifier: 0.5.1 + version: 0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': + specifier: 0.5.1 + version: 0.5.1(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': + specifier: 0.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 @@ -35,6 +47,9 @@ importers: rimraf: specifier: ^6.1.3 version: 6.1.3 + solid-js: + specifier: 1.9.12 + version: 1.9.12 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -184,7 +199,7 @@ importers: version: link:../shared '@modelcontextprotocol/sdk': specifier: ^1.0.0 - version: 1.26.0(zod@3.25.76) + version: 1.26.0(zod@4.1.8) fastify: specifier: ^5.0.0 version: 5.7.4 @@ -280,10 +295,18 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -408,10 +431,18 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} @@ -420,36 +451,98 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -463,6 +556,29 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -475,6 +591,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -483,14 +611,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -1276,6 +1416,36 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@next/env@16.2.5': resolution: {integrity: sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==} @@ -1385,6 +1555,23 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} + '@opencode-ai/plugin@1.18.15': + resolution: {integrity: sha512-AY10RtFbzLkf951dbLkSGJdBeddeS6ojbjvqCpWnINedqlj2cK/GF91xWjWnlHpqQZ4GABLPCuoSJx8mGmKvCw==} + peerDependencies: + '@opentui/core': '>=0.4.5' + '@opentui/keymap': '>=0.4.5' + '@opentui/solid': '>=0.4.5' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.18.15': + resolution: {integrity: sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ==} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -1491,6 +1678,73 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@opentui/core-darwin-arm64@0.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/keymap@0.5.1': + resolution: {integrity: sha512-JmnueekbkzVVdYGuDjdS8m1pS7hEJy7RArxZil31S9LlybNY42M/KR66tWzjwP22UjyAimn7HmlfvC7ZVyPQqA==} + peerDependencies: + '@opentui/react': 0.5.1 + '@opentui/solid': 0.5.1 + react: '>=19.2.0' + solid-js: 1.9.12 + peerDependenciesMeta: + '@opentui/react': + optional: true + '@opentui/solid': + optional: true + react: + optional: true + solid-js: + optional: true + + '@opentui/solid@0.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 +2452,23 @@ packages: react-native-b4a: optional: true + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2313,6 +2584,11 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} + bun-ffi-structs@0.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 +2859,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + docker-modem@5.0.6: resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} engines: {node: '>= 8.0'} @@ -2617,6 +2897,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + effect@4.0.0-beta.83: + resolution: {integrity: sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==} + electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} @@ -2641,6 +2924,14 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2795,6 +3086,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -2853,10 +3148,20 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-my-way@9.4.0: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2981,6 +3286,11 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -3022,6 +3332,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + helmet@7.2.0: resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} engines: {node: '>=16.0.0'} @@ -3049,6 +3363,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3125,6 +3442,10 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ink@6.8.0: resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} engines: {node: '>=20'} @@ -3159,6 +3480,10 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3287,6 +3612,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3322,6 +3650,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3399,6 +3730,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3451,6 +3786,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -3514,6 +3854,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -3545,6 +3889,10 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} @@ -3581,6 +3929,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3645,6 +4003,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3762,10 +4124,18 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -3778,6 +4148,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3790,6 +4164,9 @@ packages: resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} engines: {node: ^20.17.0 || >=22.9.0} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3804,6 +4181,10 @@ packages: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3816,6 +4197,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3853,6 +4241,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -3924,6 +4316,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4017,9 +4412,17 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4072,6 +4475,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + s-js@0.4.9: + resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4113,6 +4519,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -4188,6 +4604,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4304,6 +4723,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4387,6 +4810,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4492,6 +4919,11 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true validate-npm-package-license@3.0.4: @@ -4591,6 +5023,14 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4724,17 +5164,29 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + snapshots: '@acemir/cssom@0.9.31': {} '@adobe/css-tools@4.4.4': {} + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -4981,8 +5433,34 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.29.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 +5489,18 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -5019,8 +5509,34 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.0 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5028,6 +5544,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 +5569,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 +5621,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 +5653,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 +5683,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 +5701,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 +6453,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 +6488,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 +6650,21 @@ 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/keymap@0.5.1(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@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/keymap': 0.5.1(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + + '@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 +6784,76 @@ 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/keymap@0.5.1(@opentui/solid@0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.5.1(typescript@5.9.3)(web-tree-sitter@0.25.10) + optionalDependencies: + '@opentui/solid': 0.5.1(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.5 + solid-js: 1.9.12 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opentui/solid@0.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 +7219,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 +7577,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 +7726,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 +7949,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -7209,6 +7999,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 +8031,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 +8249,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 +8330,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 +8497,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 +8539,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 +8565,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -7805,6 +8639,8 @@ snapshots: ini@6.0.0: {} + ini@7.0.0: {} + ink@6.8.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@alcalzone/ansi-tokenize': 0.2.5 @@ -7857,13 +8693,17 @@ snapshots: ipaddr.js@2.3.0: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-glob@4.0.3: dependencies: @@ -7972,6 +8812,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 +8847,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 +8909,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 +8973,8 @@ snapshots: marked@14.0.0: {} + marked@17.0.1: {} + marked@18.0.5: {} math-intrinsics@1.1.0: {} @@ -8166,6 +9017,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 +9055,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.3: {} @@ -8230,6 +9087,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 +9157,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 +9295,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 +9318,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 +9350,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 +9364,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 +9422,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 +9507,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -8680,8 +9589,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 +9673,8 @@ snapshots: dependencies: tslib: 2.8.1 + s-js@0.4.9: {} + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -8795,6 +9715,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 +9844,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 +9930,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 +9967,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 +10074,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -9227,6 +10163,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 +10237,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 +10345,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..f42ceed --- /dev/null +++ b/scripts/generate-opencode-extension.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/opencode-extension.ts`, which embeds the two + * opencode plugin sources as string constants — mirroring how the pi extension + * (`pi-extension.ts`) and prime-agent's `/mcpctl` switcher + * (`prime-agent-extension.ts`) are embedded. + * + * Embedding matters: `mcpctl config opencode` must work from an installed + * binary that has no access to the source tree. The installed plugin files are + * this exact embedded source, so what the CLI ships is always what opencode + * runs. + * + * Regenerate after editing the plugin sources: + * npx tsx scripts/generate-opencode-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const extDir = join(root, 'src', 'opencode-ext'); + +const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); +const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); + +function embed(src: 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/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered \`plugin/\` dir, + * where its basename is what shows up in \`opencode plugin list\`; + * - the TUI plugin lands beside it in \`mcpctl/\`, referenced by path from + * \`tui.json\`, and must keep a \`.tsx\` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = ${embed(SERVER)}; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = ${embed(TUI)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out); +console.log('wrote src/cli/src/config/opencode-extension.ts'); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 676a5e7..f8e239a 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -35,6 +35,17 @@ 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, + withOpencodeDir, + opencodeStatePath, + readOpencodeState, + writeOpencodeState, + storedToken, + installOpencodePlugins, + registerOpencodeTuiPlugin, + opencodeSkillsDir, +} from '../utils/opencode-settings.js'; /** * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint @@ -44,6 +55,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 }>; } @@ -590,6 +604,171 @@ export function createConfigCommand(deps?: Partial, apiDeps?: registerPrimeAgentCommand('prime-agent', false); registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + // ── opencode: install the plugins + provision the gateway credential ── + // The plugins (server + TUI) mount the active project's proxy MCP gateway + // through opencode's own live MCP API, so no MCP client is needed and + // `opencode.json` is never touched. See `utils/opencode-settings.ts`. + 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 store (skips auto-minting)') + .option('--opencode-dir ', 'Override opencode\'s config dir (default: ~/.config/opencode)') + .option('--skip-skills', 'Skip the initial skills sync') + .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 configDir = opts.opencodeDir !== undefined ? resolve(opts.opencodeDir) : opencodeConfigDir(); + const dirs = withOpencodeDir(configDir); + // Isolate the persisted state (and skills) under a custom --opencode-dir + // so tests (and side-by-side installs) never touch the real ~/.mcpctl or + // ~/.config/opencode. + const statePath = opts.opencodeDir !== undefined + ? join(configDir, 'opencode-state.json') + : opencodeStatePath(); + const skillsInstall = opts.opencodeDir !== undefined ? dirs.skillsDir() : opencodeSkillsDir(); + + if (opts.dryRun === true) { + const dry = JSON.stringify({ + opencode: { + configDir, + serverPlugin: opts.skipPlugin === true ? '' : dirs.serverPluginPath(), + tuiPlugin: opts.skipPlugin === true ? '' : dirs.tuiPluginPath(), + tuiJson: opts.skipPlugin === true ? '' : dirs.tuiJsonPath(), + state: statePath, + skills: opts.skipSkills === true ? '' : skillsInstall, + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + gateway: `${opts.gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(opts.project)}/mcp`, + }, + action: 'mint/reuse project token + write opencode-state.json + install plugins + register tui.json + sync skills', + }, null, 2); + log(dry); + return; + } + + // 1. Provision the bearer credential this project needs on the gateway. + // Blocks until a usable token is on disk: registering the project as + // active without a credential would leave opencode mounted but unable + // to call any tool. + let provisioned = false; + const state = await readOpencodeState(statePath); + try { + if (opts.token !== undefined && opts.token !== '') { + await writeOpencodeState({ project: opts.project, gatewayUrl: opts.gatewayUrl, token: opts.token }, statePath); + log(`Stored bearer credential for '${opts.project}' in ${statePath}`); + provisioned = true; + } else if (storedToken(state, opts.project) !== null) { + log(`Bearer credential for '${opts.project}' already present in ${statePath}`); + await writeOpencodeState({ project: opts.project, gatewayUrl: opts.gatewayUrl }, statePath); + provisioned = true; + } else if (skillsClient) { + const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; + const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { + name: `${OPENCODE_TOKEN_PREFIX}-${stamp}`, + projectName: opts.project, + ttl: 'never', + description: `mcpctl proxy MCP credential for opencode (${new Date().toISOString()})`, + }); + if (typeof minted?.token === 'string' && minted.token.length > 0) { + await writeOpencodeState({ project: opts.project, gatewayUrl: opts.gatewayUrl, token: minted.token }, statePath); + log(`Minted + stored bearer credential for '${opts.project}' in ${statePath}`); + provisioned = true; + } else { + log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); + } + } else { + log('Error: no API client available to mint a project token — pass --token to provision credentials'); + } + } catch (err: unknown) { + log(`Error: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (!provisioned) { + process.exitCode = 1; + log(`Aborted: leaving ${statePath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Install + register the plugins (skippable). + if (opts.skipPlugin !== true) { + try { + const written = await installOpencodePlugins(configDir); + const tuiJsonPath = dirs.tuiJsonPath(); + const { added } = await registerOpencodeTuiPlugin(tuiJsonPath, dirs.tuiPluginPath()); + log('Installed opencode plugins:'); + for (const w of written) log(` ${w}`); + log(added ? `Registered TUI plugin in ${tuiJsonPath}` : `TUI plugin already registered in ${tuiJsonPath}`); + } catch (err: unknown) { + log(`Warning: failed to install opencode plugins: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // 3. Write the .mcpctl-project marker so `skills sync` can resolve the + // project later. An explicit -p is authoritative; --skip-marker opts + // out entirely (the TUI switcher runs this from whatever directory + // opencode was started in and must not 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 === opts.project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.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)}`); + } + + // 4. Sync skills into opencode's skill dir (skippable, best-effort). + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runSkillsSync( + { project: opts.project, target: 'opencode', installRoot: skillsInstall }, + { 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; + if (total > 0) { + log(`Skills synced to ${skillsInstall} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } else { + log('Skills: no changes (already up to date)'); + } + } catch (err: unknown) { + log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: skipping initial skills sync (no API client provided)'); + } + } + + log('Next: the active project\'s mcpctl tools mount automatically once opencode'); + log('reconnects (it re-reads state on the next message). Use /mcpctl within opencode to switch projects.'); + }); + config.addCommand(createConfigSetupCommand({ configDeps })); diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 89f95e6..aa7e9c9 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -33,6 +33,7 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { opencodeSkillsDir } from '../utils/opencode-settings.js'; /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -104,7 +105,7 @@ export interface SyncOpts { * first project's skills, and pre-existing * (untracked) skill dirs are preserved. */ - target?: 'claude' | 'prime-agent' | 'pi'; + target?: 'claude' | 'prime-agent' | 'pi' | 'opencode'; } export interface SyncResult { @@ -194,10 +195,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise [s.name, s])); @@ -566,17 +572,20 @@ export interface SkillsCommandDeps { * "claude" → ~/.claude/skills * "prime-agent" → ~/.prime/agent/skills * "pi" → ~/.pi/agent/skills - * Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree. + * "opencode" → XDG-aware ~/.config/opencode/skill + * Kept here so `mcpctl skills sync --agent` and `mcpctl config ` agree. */ -export function agentInstallRoot(agent: string | undefined): string { +export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string { switch (agent) { case 'prime-agent': - return join(homedir(), '.prime', 'agent', 'skills'); + return join(homeDir, '.prime', 'agent', 'skills'); case 'pi': - return join(homedir(), '.pi', 'agent', 'skills'); + return join(homeDir, '.pi', 'agent', 'skills'); + case 'opencode': + return opencodeSkillsDir(process.env, homeDir); case 'claude': default: - return join(homedir(), '.claude', 'skills'); + return join(homeDir, '.claude', 'skills'); } } @@ -589,9 +598,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 +618,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 +631,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 'claude' | 'prime-agent' | 'pi' | 'opencode', 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..c252782 --- /dev/null +++ b/src/cli/src/config/opencode-extension.ts @@ -0,0 +1,27 @@ +/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-opencode-extension.ts` from + * `src/opencode-ext/mcpctl-opencode.ts` and + * `src/opencode-ext/mcpctl-opencode-tui.tsx`. + * + * `mcpctl config opencode` writes these verbatim into ~/.config/opencode/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered `plugin/` dir, + * where its basename is what shows up in `opencode plugin list`; + * - the TUI plugin lands beside it in `mcpctl/`, referenced by path from + * `tui.json`, and must keep a `.tsx` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin.\n *\n * Installed by `mcpctl config opencode` into opencode's auto-discovered\n * `plugin/` directory as `mcpctl.ts`, where it is loaded automatically at\n * startup. It mounts the ACTIVE mcpctl project's MCP gateway as a remote MCP\n * server, so opencode's own MCP client exposes the project's tools natively\n * (`mcpctl_*`), exactly the way the prime-agent integration registers the proxy\n * gateway as an MCP server.\n *\n * WHY A PLUGIN AND NOT AN `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. That\n * header is a secret: opencode.json is a mode-0644 file users paste into\n * bug reports, whereas `~/.mcpctl/opencode-state.json` is 0600 like every\n * other mcpctl credential.\n * 2. Switching projects must work WITHOUT restarting opencode. The server\n * exposes `POST /mcp` (add) and `POST /mcp/{name}/connect`, so the mount\n * can be re-pointed live; a config file cannot.\n *\n * Headless runs (`opencode run ...`) load no TUI plugins, so this server plugin\n * is what makes those see the active project's tools. The TUI plugin drives the\n * swtich and the on-screen indicator.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name 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 return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is not free: `mcp.add` rebuilds the connection and mcplocal\n * binds a gated project's unlocked state to that connection's `mcp-session-id`.\n * Re-adding an unchanged config every turn would drop the gate opened by\n * `begin_session` and quietly re-lock the project mid-conversation. So we only\n * call `add` when the target actually changed — or when the mount is no longer\n * connected, where reconnecting is the entire point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace disappear mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn — no restart, no stale\n * \"the tools you were told about are gone\" announcement.\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 === '') {\n return;\n }\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 /** Whether our mount is currently up. Unknown/unreachable counts as down. */\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 can overlap — the event stream is chatty\n * and a message can land while a mount is still connecting, and two concurrent\n * `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\", never to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => {\n /* best-effort, non-fatal */\n })\n .finally(() => {\n inflight = null;\n });\n return inflight;\n }\n\n // Deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point can hang opencode before the TUI draws.\n // Both hooks 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 in a different opencode window) rewrites the state file beneath\n * us. Re-reading it here is what makes an external switch take effect on\n * the next message instead of the next restart. When nothing changed this\n * is a state-file read plus a status call — `mount` will not re-register a\n * mount already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json` (opencode does not auto-discover TUI plugins).\n *\n * It mirrors the prime-agent `/mcpctl` switcher:\n * - a visible active-project indicator (`mcpctl:`) rendered in the\n * prompt footer, immediately right of the model name and above the token\n * counter — prime-agent's `setStatus` -> tray line, for opencode;\n * - `/mcpctl` — filterable project picker that switches the active\n * project (prime-agent's filter-then-select flow);\n * - `/mcpctl-status` — what is mounted, from where, as which project;\n * - `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir.\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file): it mints/reuses the project token, rewrites the state\n * file and syncs skills. Once the state file is updated, the live MCP mount is\n * re-pointed 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 { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/** kv key holding the label the footer renders. */\nconst KV_LABEL = 'mcpctl.project';\n\n/** Above this many projects, ask for a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nexport interface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState;\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({\n name: p.name,\n description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined,\n }));\n}\n\n/**\n * Order and filter the project list for the picker — same semantics as the\n * prime-agent switch. Ordering: the active project first (most likely pick),\n * then alphabetical. Filtering: space-separated terms, ALL of which must match\n * as case-insensitive substrings against the name OR description, so `home\n * auto` finds `homeautomation`. A blank query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...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 const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\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; returns the project. */\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;\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 /** Filter phase for large lists (prime-agent's behaviour), then the picker. */\n function openPicker(projects: ProjectInfo[], active: string | null): void {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n api.ui.dialog.replace(() => (\n {\n const filtered = filterProjects(projects, query.trim(), active);\n if (filtered.length === 0) {\n api.ui.dialog.clear();\n api.ui.toast({ message: `mcpctl: no project matches '${query.trim()}'`, variant: 'warning' });\n return;\n }\n openSelect(filtered, active);\n }}\n onCancel={(): void => api.ui.dialog.clear()}\n />\n ));\n return;\n }\n openSelect(candidates, active);\n }\n\n /** The actual picker, with active-first ordering and a live count. */\n function openSelect(candidates: ProjectInfo[], active: string | null): void {\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.description ? `${p.name} — ${p.description}` : 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 async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // Mint/reuse the project token, rewrite the state file and sync skills.\n // --skip-plugin leaves this very file alone (rewriting a loaded plugin\n // mid-session buys nothing); --skip-marker stops us silently re-scoping\n // whatever repo opencode was started in, which Claude Code's own skills\n // sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n run: async (): 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 openPicker(projects, active);\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 run: async (): 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 run: async (): 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 prompt footer cluster opencode exposes to plugins: they render\n // on the prompt's bottom line, immediately right of the model name and just\n // above the token counter — opencode's equivalent of prime-agent's tray line.\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..5864568 --- /dev/null +++ b/src/cli/src/utils/opencode-settings.ts @@ -0,0 +1,204 @@ +/** + * Wiring helpers for the opencode integration. + * + * `mcpctl config opencode --project X`: + * 1. writes the embedded plugin sources into opencode's config directory + * (`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 auto-discovers server + * plugins, but TUI plugins have to be listed explicitly), + * 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 can mount a *static* server, but this + * integration needs two things it cannot give. The gateway wants an + * `Authorization` header, and opencode.json is a mode-0644 file users paste + * into bug reports; and switching projects has to work without restarting + * opencode, which a config file cannot do. The plugins read the state file + * and mount through opencode's live MCP API instead, so `opencode.json` is + * never touched at all. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`. + */ +import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_SOURCE, +} from '../config/opencode-extension.js'; + +/** + * opencode's config directory. + * + * opencode is XDG-aware, so an install with `XDG_CONFIG_HOME` set keeps its + * config somewhere other than `~/.config`. Reading the same variable is what + * stops us provisioning a directory opencode will never look at. + */ +export function opencodeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const xdg = env['XDG_CONFIG_HOME']; + const base = xdg !== undefined && xdg !== '' ? xdg : join(homeDir, '.config'); + return join(base, 'opencode'); +} + +/** Resolve every path `config opencode` writes, under an explicit config dir. */ +export function withOpencodeDir(base: string): { + serverPluginPath: () => string; + tuiPluginDir: () => string; + tuiPluginPath: () => string; + tuiJsonPath: () => string; + skillsDir: () => string; +} { + return { + serverPluginPath: () => join(base, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME), + tuiPluginDir: () => join(base, 'mcpctl'), + tuiPluginPath: () => join(base, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME), + tuiJsonPath: () => join(base, 'tui.json'), + skillsDir: () => join(base, 'skill'), + }; +} + +/** Where `mcpctl skills sync --agent opencode` installs skill bundles. */ +export function opencodeSkillsDir(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return withOpencodeDir(opencodeConfigDir(env, homeDir)).skillsDir(); +} + +/** Path of the state file both plugins read. */ +export function opencodeStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'opencode-state.json'); +} + +/** + * Write the embedded plugin sources into opencode's config dir. + * + * This is the production path — it needs no source tree, so it works from an + * installed binary. Returns the files written. + */ +export async function installOpencodePlugins(base: string): Promise { + const paths = withOpencodeDir(base); + const written: string[] = []; + for (const [path, source] of [ + [paths.serverPluginPath(), OPENCODE_SERVER_PLUGIN_SOURCE], + [paths.tuiPluginPath(), OPENCODE_TUI_PLUGIN_SOURCE], + ] as const) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source, 'utf-8'); + written.push(path); + } + return written; +} + +interface TuiConfig { + $schema?: string; + plugin?: unknown[]; + [k: string]: unknown; +} + +/** + * Load tui.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it. One syntax + * error must not silently drop every other TUI plugin the user installed. + */ +async function readTuiConfig(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw.trim().length === 0) return {}; + try { + const parsed = JSON.parse(raw) as TuiConfig; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error( + `${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Register the TUI plugin in `tui.json`. + * + * Idempotent, and a merge rather than a rewrite: any other TUI plugin the user + * has installed keeps working. Returns whether anything changed, so a no-op run + * doesn't reformat a hand-maintained file. + */ +export async function registerOpencodeTuiPlugin(tuiJsonPath: string, pluginPath: string): Promise<{ added: boolean }> { + const config = await readTuiConfig(tuiJsonPath); + const plugins = Array.isArray(config.plugin) ? [...config.plugin] : []; + if (plugins.some((p) => p === pluginPath)) return { added: false }; + + // Drop any stale entry pointing at an older install location of *our* plugin + // (e.g. a rename between releases): leaving it behind makes opencode fail to + // load a file that no longer exists on every start. + const kept = plugins.filter((p) => !(typeof p === 'string' && p.endsWith(`/${OPENCODE_TUI_PLUGIN_FILENAME}`))); + kept.push(pluginPath); + + config.$schema = typeof config.$schema === 'string' ? config.$schema : 'https://opencode.ai/tui.json'; + config.plugin = kept; + await writeJsonAtomic(tuiJsonPath, config); + return { added: true }; +} + +export interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +/** Read the state file; missing or corrupt yields an empty state. */ +export async function readOpencodeState(path: string = opencodeStatePath()): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf-8')) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Set the active project (and optionally its bearer token) in the state file. + * + * Tokens for other projects are preserved so switching back to a project + * already provisioned on this machine needs no new mint — and so a mint failure + * for project B cannot cost you the credential for project A. + * + * Written 0600: this file holds bearer tokens. `writeFile`'s mode only applies + * when the file is created, so we chmod after writing too. + */ +export async function writeOpencodeState( + update: { project: string; gatewayUrl: string; token?: string }, + path: string = opencodeStatePath(), +): Promise { + const current = await readOpencodeState(path); + const tokens = { ...(current.tokens ?? {}) }; + if (update.token !== undefined && update.token !== '') tokens[update.project] = update.token; + const next: OpencodeState = { project: update.project, gatewayUrl: update.gatewayUrl, tokens }; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 }); + try { + await chmod(path, 0o600); + } catch { + /* best-effort: a credential written is better than one refused */ + } + return path; +} + +/** The token currently stored for `project`, if any. */ +export function storedToken(state: OpencodeState, project: string): string | null { + const token = state.tokens?.[project]; + return typeof token === 'string' && token !== '' ? token : null; +} diff --git a/src/cli/tests/commands/config-opencode.test.ts b/src/cli/tests/commands/config-opencode.test.ts new file mode 100644 index 0000000..de98845 --- /dev/null +++ b/src/cli/tests/commands/config-opencode.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { readFileSync, mkdtempSync, rmSync, 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(): ApiClient { + return { + get: vi.fn(async () => ({})), + post: vi.fn(async (_path: string, _body?: unknown) => ({ token: 'mint-token-123' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config opencode', () => { + let client: ApiClient; + 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 }); + }); + + function cmd() { + return createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + } + + const opencodeDir = () => join(tmpDir, 'opencode'); + + it('installs both plugins, registers tui.json, and writes the state (isolated dir)', async () => { + await cmd().parseAsync([ + 'opencode', + '--project', 'docmost', + '--opencode-dir', opencodeDir(), + '--gateway-url', 'https://gw', + '--skip-skills', + '--skip-marker', + ], { from: 'user' }); + + const pluginDir = opencodeDir(); + const serverPlugin = join(pluginDir, 'plugin', 'mcpctl.ts'); + const tuiPlugin = join(pluginDir, 'mcpctl', 'mcpctl-tui.tsx'); + expect(existsSync(serverPlugin)).toBe(true); + expect(existsSync(tuiPlugin)).toBe(true); + + // Server plugin ships the live MCP mount, TUI plugin ships the JSX footer. + expect(readFileSync(serverPlugin, 'utf-8')).toContain('client.mcp.add'); + expect(readFileSync(tuiPlugin, 'utf-8')).toContain('session_prompt_right'); + + // tui.json registers the TUI plugin by path. + const tui = JSON.parse(readFileSync(join(pluginDir, 'tui.json'), 'utf-8')); + expect(tui.plugin).toContain(tuiPlugin); + + // State file records active project + minted token. + const state = JSON.parse(readFileSync(join(pluginDir, 'opencode-state.json'), 'utf-8')); + expect(state.project).toBe('docmost'); + expect(state.gatewayUrl).toBe('https://gw'); + expect(state.tokens.docmost).toBe('mint-token-123'); + }); + + it('reuses an existing token when one is stored', async () => { + const statePath = join(opencodeDir(), 'opencode-state.json'); + const { writeOpencodeState } = await import('../../src/utils/opencode-settings.js'); + await writeOpencodeState({ project: 'docmost', gatewayUrl: 'https://gw', token: 'stored-token' }, statePath); + + await cmd().parseAsync([ + 'opencode', '--project', 'docmost', + '--opencode-dir', opencodeDir(), + '--skip-skills', '--skip-marker', + ], { from: 'user' }); + + const state = JSON.parse(readFileSync(statePath, 'utf-8')); + expect(state.tokens.docmost).toBe('stored-token'); // not re-minted + expect(output.join('\n')).toContain('already present'); + }); + + it('honours --token without minting', async () => { + await cmd().parseAsync([ + 'opencode', '--project', 'docmost', + '--opencode-dir', opencodeDir(), + '--token', 'manual-pat', + '--skip-skills', '--skip-marker', + ], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const state = JSON.parse(readFileSync(join(opencodeDir(), 'opencode-state.json'), 'utf-8')); + expect(state.tokens.docmost).toBe('manual-pat'); + }); + + it('--dry-run writes nothing', async () => { + await cmd().parseAsync([ + 'opencode', '--project', 'docmost', + '--opencode-dir', opencodeDir(), + '--dry-run', + ], { from: 'user' }); + + expect(existsSync(join(opencodeDir(), 'plugin', 'mcpctl.ts'))).toBe(false); + expect(existsSync(join(opencodeDir(), 'opencode-state.json'))).toBe(false); + }); + + it('--skip-plugin does not install or register plugins, but still writes state', async () => { + await cmd().parseAsync([ + 'opencode', '--project', 'docmost', + '--opencode-dir', opencodeDir(), + '--skip-plugin', '--skip-skills', '--skip-marker', + ], { from: 'user' }); + + expect(existsSync(join(opencodeDir(), 'plugin', 'mcpctl.ts'))).toBe(false); + expect(existsSync(join(opencodeDir(), 'tui.json'))).toBe(false); + expect(existsSync(join(opencodeDir(), 'opencode-state.json'))).toBe(true); + }); + + it('requires a project', async () => { + await cmd().parseAsync(['opencode', '--opencode-dir', opencodeDir(), '--skip-marker'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + }); +}); 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..b054804 --- /dev/null +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_SOURCE, + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_FILENAME, +} from '../../src/config/opencode-extension.js'; + +/** + * `mcpctl config opencode` installs the *embedded* copies 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 pi extension and completions checks give. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'opencode-ext'); + +describe('embedded opencode plugins', () => { + it('matches the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { + const server = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); + const tui = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); + expect(OPENCODE_SERVER_PLUGIN_SOURCE).toBe(server); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toBe(tui); + }); + + it('installs under the names opencode expects (.ts server, .tsx TUI)', () => { + expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); + expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); + }); + + it('server plugin mounts through the live MCP API', () => { + // The server plugin must call the v1 SDK add with a body, and never touch + // the user's opencode.json (which can't hold a secret header). + expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain('client.mcp.add'); + expect(OPENCODE_SERVER_PLUGIN_SOURCE).toContain("type: 'remote'"); + }); + + it('TUI plugin exposes the footer indicator + /mcpctl switch', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('session_prompt_right'); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('slashName: \'mcpctl\''); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('--skip-marker'); + }); +}); diff --git a/src/cli/tests/config/opencode-project-order.test.ts b/src/cli/tests/config/opencode-project-order.test.ts new file mode 100644 index 0000000..0aeb18f --- /dev/null +++ b/src/cli/tests/config/opencode-project-order.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { filterProjects, indicatorLabel, type ProjectInfo } from '../../../opencode-ext/mcpctl-opencode-tui.tsx'; + +const projects: ProjectInfo[] = [ + { name: 'homeautomation' }, + { name: 'docmost', description: 'Docs platform' }, + { name: 'zeta' }, + { name: 'alpine' }, +]; + +describe('opencode project ordering + filtering (prime-agent semantics)', () => { + it('puts the active project first, then alphabetical', () => { + expect(filterProjects(projects, '', 'zeta').map((p) => p.name)).toEqual([ + 'zeta', 'alpine', 'docmost', 'homeautomation', + ]); + }); + + it('is a no-op filter without an active project', () => { + expect(filterProjects(projects, '', null).map((p) => p.name)).toEqual([ + 'alpine', 'docmost', 'homeautomation', 'zeta', + ]); + }); + + it('filters on name OR description, all terms required, case-insensitive', () => { + expect(filterProjects(projects, 'docs', null).map((p) => p.name)).toEqual(['docmost']); + // name substring + expect(filterProjects(projects, 'HOME auto', null).map((p) => p.name)).toEqual(['homeautomation']); + // description substring + expect(filterProjects(projects, 'PLATFORM', null).map((p) => p.name)).toEqual(['docmost']); + // no match + expect(filterProjects(projects, 'nope', null)).toEqual([]); + }); + + it('active stays first even with a filter', () => { + // 'a' matches every project (zeta/alpine/homeautomation by name, docmost via + // its 'platform' description); the active one still leads the ordering. + expect(filterProjects(projects, 'a', 'zeta').map((p) => p.name)).toEqual([ + 'zeta', 'alpine', 'docmost', 'homeautomation', + ]); + }); + + it('indicatorLabel renders project or none', () => { + expect(indicatorLabel('docmost')).toBe('mcpctl:docmost'); + 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..60982f4 --- /dev/null +++ b/src/cli/tests/utils/opencode-settings.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, mkdtempSync, rmSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + opencodeConfigDir, + withOpencodeDir, + opencodeStatePath, + readOpencodeState, + writeOpencodeState, + storedToken, + registerOpencodeTuiPlugin, +} from '../../src/utils/opencode-settings.js'; + +describe('opencode-settings', () => { + let tmpDir: string; + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'opencode-settings-')); + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves the config dir XDG-aware', () => { + expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode'); + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/opencode'); + }); + + it('withOpencodeDir lays out the expected plugin/skill paths', () => { + const d = withOpencodeDir('/base'); + expect(d.serverPluginPath()).toBe('/base/plugin/mcpctl.ts'); + expect(d.tuiPluginPath()).toBe('/base/mcpctl/mcpctl-tui.tsx'); + expect(d.tuiJsonPath()).toBe('/base/tui.json'); + expect(d.skillsDir()).toBe('/base/skill'); + }); + + it('state path lives under ~/.mcpctl', () => { + expect(opencodeStatePath('/home/u')).toBe('/home/u/.mcpctl/opencode-state.json'); + }); + + it('write then read the state file, preserving other projects tokens', async () => { + const p = join(tmpDir, 'opencode-state.json'); + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, p); + await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'tok-b' }, p); + + const state = await readOpencodeState(p); + expect(state.project).toBe('b'); + expect(state.gatewayUrl).toBe('https://gw'); + expect(storedToken(state, 'a')).toBe('tok-a'); // preserved across switches + expect(storedToken(state, 'b')).toBe('tok-b'); + + // Written 0600: it holds bearer tokens. + expect(statSync(p).mode & 0o777).toBe(0o600); + }); + + it('writing a token for the active project overrides that project token', async () => { + const p = join(tmpDir, 'opencode-state.json'); + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, p); + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a2' }, p); + expect(storedToken(await readOpencodeState(p), 'a')).toBe('tok-a2'); + }); + + it('readState tolerates a missing/corrupt file', async () => { + const { writeFileSync } = await import('node:fs'); + expect(await readOpencodeState(join(tmpDir, 'does-not-exist.json'))).toEqual({}); + const corrupt = join(tmpDir, 'corrupt.json'); + writeFileSync(corrupt, '{ not json'); + expect(await readOpencodeState(corrupt)).toEqual({}); + }); + + it('registerTuiPlugin merges into tui.json and is idempotent', async () => { + const tuiPath = join(tmpDir, 'tui.json'); + const plg = join(tmpDir, 'mcpctl', 'mcpctl-tui.tsx'); + + const first = await registerOpencodeTuiPlugin(tuiPath, plg); + expect(first.added).toBe(true); + const second = await registerOpencodeTuiPlugin(tuiPath, plg); + expect(second.added).toBe(false); + + const raw = JSON.parse(readFileSync(tuiPath, 'utf-8')); + expect(raw.plugin).toContain(plg); + expect(raw['$schema']).toBe('https://opencode.ai/tui.json'); + }); + + it('stale same-basename plugin entries are replaced, others kept', async () => { + const tuiPath = join(tmpDir, 'tui.json'); + const oldPath = join(tmpDir, 'older', 'mcpctl-tui.tsx'); + const otherPath = join(tmpDir, 'plugin-a.tsx'); + const newPath = join(tmpDir, 'mcpctl', 'mcpctl-tui.tsx'); + // Simulate a prior install at the old location. + await registerOpencodeTuiPlugin(tuiPath, oldPath); + await registerOpencodeTuiPlugin(tuiPath, otherPath); + + const res = await registerOpencodeTuiPlugin(tuiPath, newPath); + expect(res.added).toBe(true); + const raw = JSON.parse(readFileSync(tuiPath, 'utf-8')); + expect(raw.plugin).toContain(newPath); + expect(raw.plugin).not.toContain(oldPath); // stale entry dropped + expect(raw.plugin).toContain(otherPath); // unrelated plugin preserved + }); +}); diff --git a/src/cli/vitest.config.ts b/src/cli/vitest.config.ts index 1cbc8c8..ea641f5 100644 --- a/src/cli/vitest.config.ts +++ b/src/cli/vitest.config.ts @@ -1,8 +1,15 @@ import { defineProject } from 'vitest/config'; export default defineProject({ + // The opencode TUI plugin is a .tsx that ships as source; its pure helpers are + // unit-tested by importing that file directly, so the test run needs the same + // JSX runtime opencode transpiles it with. + esbuild: { + jsx: 'automatic', + jsxImportSource: '@opentui/solid', + }, test: { name: 'cli', - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], }, }); diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx new file mode 100644 index 0000000..bd296ee --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -0,0 +1,335 @@ +/** @jsxImportSource @opentui/solid */ +/** + * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in + * `~/.config/opencode/tui.json` (opencode does not auto-discover TUI plugins). + * + * It mirrors the prime-agent `/mcpctl` switcher: + * - a visible active-project indicator (`mcpctl:`) rendered in the + * prompt footer, immediately right of the model name and above the token + * counter — prime-agent's `setStatus` -> tray line, for opencode; + * - `/mcpctl` — filterable project picker that switches the active + * project (prime-agent's filter-then-select flow); + * - `/mcpctl-status` — what is mounted, from where, as which project; + * - `/mcpctl-skills` — re-sync this project's skills into opencode's skill dir. + * + * The switch itself is delegated to the `mcpctl` CLI (the same binary that + * installed this file): it mints/reuses the project token, rewrites the state + * file and syncs skills. Once the state file is updated, the live MCP mount is + * re-pointed 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 { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name the mount lives under — must match the server plugin. */ +const SERVER_NAME = 'mcpctl'; + +/** kv key holding the label the footer renders. */ +const KV_LABEL = 'mcpctl.project'; + +/** Above this many projects, ask for a filter before opening the list. */ +const FILTER_THRESHOLD = 20; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +export interface ProjectInfo { + name: string; + description?: string; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +/** + * Run the `mcpctl` CLI and resolve its stdout. + * + * execFile, not a shell: project names come from the server and would otherwise + * need quoting, and a shell buys nothing here. + */ +function mcpctl(args: string[], timeoutMs = 120_000): Promise { + return new Promise((resolve, reject) => { + execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || err.message).trim() || String(err))); + else resolve(stdout); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000); + const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>; + return parsed + .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string') + .map((p) => ({ + name: p.name, + description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined, + })); +} + +/** + * Order and filter the project list for the picker — same semantics as the + * prime-agent switch. Ordering: the active project first (most likely pick), + * then alphabetical. Filtering: space-separated terms, ALL of which must match + * as case-insensitive substrings against the name OR description, so `home + * auto` finds `homeautomation`. A blank query keeps everything. + * + * Exported so the behaviour is unit-tested rather than eyeballed through a TUI. + */ +export function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] { + const ordered = [...projects].sort((a, b) => { + if (a.name === active) return -1; + if (b.name === active) return 1; + return a.name.localeCompare(b.name); + }); + const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0); + if (terms.length === 0) return ordered; + return ordered.filter((p) => { + const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase(); + return terms.every((t) => haystack.includes(t)); + }); +} + +/** 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; returns the project. */ + async function refreshIndicator(): Promise { + const state = await readState(); + const project = state.project ?? null; + api.kv.set(KV_LABEL, indicatorLabel(project)); + return project; + } + + /** + * 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, + }, + }); + } + + /** Filter phase for large lists (prime-agent's behaviour), then the picker. */ + function openPicker(projects: ProjectInfo[], active: string | null): void { + let candidates = filterProjects(projects, '', active); + if (candidates.length > FILTER_THRESHOLD) { + api.ui.dialog.replace(() => ( + { + const filtered = filterProjects(projects, query.trim(), active); + if (filtered.length === 0) { + api.ui.dialog.clear(); + api.ui.toast({ message: `mcpctl: no project matches '${query.trim()}'`, variant: 'warning' }); + return; + } + openSelect(filtered, active); + }} + onCancel={(): void => api.ui.dialog.clear()} + /> + )); + return; + } + openSelect(candidates, active); + } + + /** The actual picker, with active-first ordering and a live count. */ + function openSelect(candidates: ProjectInfo[], active: string | null): void { + const DialogSelect = api.ui.DialogSelect; + api.ui.dialog.replace(() => ( + ({ + title: p.description ? `${p.name} — ${p.description}` : 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); + }} + /> + )); + } + + async function switchTo(project: string): Promise { + api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' }); + try { + // Mint/reuse the project token, rewrite the state file and sync skills. + // --skip-plugin leaves this very file alone (rewriting a loaded plugin + // mid-session buys nothing); --skip-marker stops us silently re-scoping + // whatever repo opencode was started in, which Claude Code's own skills + // sync would then pick up. + await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']); + } catch (err) { + api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' }); + return; + } + try { + await mount(project, await readState()); + } catch (err) { + // The state file is already updated, so a restart would recover — say so + // rather than reporting a success the tools do not back up. + api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' }); + await refreshIndicator(); + return; + } + await refreshIndicator(); + api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' }); + } + + api.keymap.registerLayer({ + commands: [ + { + name: 'mcpctl.switch', + title: 'mcpctl: switch project', + description: 'Mount another mcpctl project’s MCP servers and skills', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl', + run: async (): 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; + } + openPicker(projects, active); + }, + }, + { + name: 'mcpctl.status', + title: 'mcpctl: status', + description: 'Show the active mcpctl project and its MCP mount', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-status', + run: async (): 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', + run: async (): 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 prompt footer cluster opencode exposes to plugins: they render + // on the prompt's bottom line, immediately right of the model name and just + // above the token counter — opencode's equivalent of prime-agent's tray line. + 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..6d09b73 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -0,0 +1,171 @@ +/** + * mcpctl opencode server plugin. + * + * Installed by `mcpctl config opencode` into opencode's auto-discovered + * `plugin/` directory as `mcpctl.ts`, where it is loaded automatically 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_*`), exactly the way the prime-agent integration registers the proxy + * gateway as an MCP server. + * + * WHY A PLUGIN AND NOT AN `mcp` BLOCK IN opencode.json: + * 1. The gateway needs an `Authorization: Bearer ` header. That + * header is a secret: opencode.json is a mode-0644 file users paste into + * bug reports, whereas `~/.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}/connect`, so the mount + * can be re-pointed live; a config file cannot. + * + * Headless runs (`opencode run ...`) load no TUI plugins, so this server plugin + * is what makes those see the active project's tools. The TUI plugin drives the + * swtich and the on-screen indicator. + * + * Only Node builtins + the plugin API are imported, so the installed file needs + * no dependencies of its own. + */ +import type { Plugin, PluginModule } from '@opencode-ai/plugin'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name 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 { + return JSON.parse(await readFile(statePath(), 'utf-8')) as OpencodeState; + } catch { + return {}; + } +} + +/** Proxy MCP URL for a project on the gateway. */ +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +const server: Plugin = async ({ client }) => { + /** + * The (url, token) this process last registered. + * + * Re-registering is not free: `mcp.add` rebuilds the connection and mcplocal + * binds a gated project's unlocked state to that connection's `mcp-session-id`. + * Re-adding an unchanged config every turn would drop the gate opened by + * `begin_session` and quietly re-lock the project mid-conversation. So we only + * call `add` when the target actually changed — or when the mount is no longer + * connected, where reconnecting is the entire point. + */ + let mounted: string | null = null; + + /** + * Mount (or re-point) the active project. + * + * The MCP server is always registered under the same name, so tools keep the + * stable `mcpctl_*` prefix across switches and the model never sees a tool + * namespace disappear mid-conversation. opencode resolves the tool list per + * request, so a re-point is picked up on the next turn — no restart, no stale + * "the tools you were told about are gone" announcement. + */ + 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; + } + + /** Whether our mount is currently up. Unknown/unreachable counts as down. */ + 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 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", never to "opencode fails to start". + */ + let inflight: Promise | null = null; + function ensureMounted(): Promise { + inflight ??= mount() + .catch(() => { + /* best-effort, non-fatal */ + }) + .finally(() => { + inflight = null; + }); + return inflight; + } + + // Deliberately NOT mounted here. Plugin setup runs before the server is + // accepting connections, and `client.mcp.add` calls back into that same + // server — awaiting it at this point can hang opencode before the TUI draws. + // Both hooks 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 in a different opencode window) rewrites the state file beneath + * us. Re-reading it here is what makes an external switch take effect on + * the next message instead of the next restart. When nothing changed this + * is a state-file read plus a status call — `mount` will not re-register a + * mount already pointing at the right place. + */ + 'chat.message': async (): Promise => { + await ensureMounted(); + }, + }; +}; + +export default { + id: 'mcpctl', + server, +} satisfies PluginModule & { id: string }; diff --git a/src/opencode-ext/tsconfig.json b/src/opencode-ext/tsconfig.json new file mode 100644 index 0000000..542bb09 --- /dev/null +++ b/src/opencode-ext/tsconfig.json @@ -0,0 +1,28 @@ +{ + "//": [ + "The opencode plugins are shipped as source (embedded in the CLI, then", + "written into ~/.config/opencode/) and are therefore never compiled by the", + "CLI's own build. Without this project they would be typechecked by nothing.", + "", + "They are checked against the REAL @opencode-ai/plugin types (a dev", + "dependency, pinned to the opencode release they target) rather than a", + "hand-written shim, because a shim drifting from the published API is the", + "exact failure mode this guards against — see src/pi-ext/tsconfig.json." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-opencode.ts", "mcpctl-opencode-tui.tsx"] +}