diff --git a/README.md b/README.md index 6ae99af..32f110d 100644 --- a/README.md +++ b/README.md @@ -164,12 +164,27 @@ The `/mcpctl` switcher runs with `--skip-extension --skip-marker`, so switching projects from inside prime-agent never re-scopes whichever repository prime-agent happened to be started in. +See [docs/prime-agent-extension.md](docs/prime-agent-extension.md) for full details. + Preview the change without writing anything: ```bash mcpctl config prime-agent --project monitoring --dry-run ``` +`config claude` registers in **user scope** by default — one `mcpctl` MCP server +in `~/.claude.json` that applies in every directory and window, so you wire it +once rather than per checkout. Use `--scope project` (or `-o`) for a repo that +wants its own pinned project. + +Inside Claude Code: + +- `/mcpctl [project]` — switch projects, then reconnect the `mcpctl` server from + `/mcp` (no restart) +- the active project shows in the status line at the bottom of the screen + +See [docs/claude-integration.md](docs/claude-integration.md) for full details. + ### Connect pi [pi](https://github.com/earendil-works/pi) does not support MCP, but it supports @@ -195,6 +210,69 @@ Inside pi: See [docs/pi-extension.md](docs/pi-extension.md) for full details. +### Connect opencode + +[opencode](https://opencode.ai) supports MCP natively *and* exposes an API for +its own MCP registry, so mcpctl mounts the project through the running app — +which means you can **switch projects from the TUI without restarting**. + +```bash +mcpctl config opencode --project monitoring +``` + +This mints (or reuses) the project's gateway token into +`~/.mcpctl/opencode-state.json` (0600 — the token never lands in +`opencode.json`), installs a server plugin into +`~/.config/opencode/plugin/mcpctl.ts` and a TUI plugin into +`~/.config/opencode/mcpctl/`, registers the latter in +`~/.config/opencode/tui.json`, and syncs skills into +`~/.config/opencode/skill/`. + +Inside opencode: + +- `/mcpctl` (or `m`) — **switch project** from a filterable picker; + takes effect on the next turn, no restart +- `/mcpctl-status` — active project, mount state, gateway URL +- `/mcpctl-skills` — re-sync this project's skills +- the active project shows as `mcpctl:` in the prompt footer, next to + the model name + +Skip individual steps as needed: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing +``` + +Re-sync skills on their own with +`mcpctl skills sync --agent opencode --project monitoring`. + +See [docs/opencode-extension.md](docs/opencode-extension.md) for full details. + +### Run Claude Code on the homelab LLM + +The homelab gateway (LiteLLM in front of vLLM) already serves the Anthropic +Messages API, so Claude Code can talk to it directly — no bridge: + +```bash +claude-vllm --init # write ~/.config/mcpctl/claude-vllm.jsonc (0600) +claude-vllm # use it +claude-vllm --model deepseek-v4-max +claude-vllm --list # show what it would use +claude-vllm -- -p "summarise this repo" # args after -- go to claude +``` + +It reads its own config (shaped like `opencode.jsonc`, but a separate file), +falling back to `~/.pi/agent` or `~/.prime/agent` so it works before you have +written one. The key may be a literal in the 0600 file, `${ENV_VAR}`, or an env +var name — it is never stored in the tool. + +See [docs/claude-vllm.md](docs/claude-vllm.md), including why routing this +through mcpctl would add surface without adding capability. + ## Declarative Configuration Everything can be defined in YAML and applied with `mcpctl apply`: @@ -319,7 +397,7 @@ name: home-automation proxyModel: default servers: - home-assistant - - node-red + - unifi-network ``` Via CLI: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 814d165..6d560a4 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" + local commands="status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server favourites" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" @@ -94,6 +94,9 @@ _mcpctl() { status) COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur")) return ;; + statusline) + COMPREPLY=($(compgen -W "-d --directory --prefix -h --help" -- "$cur")) + return ;; login) COMPREPLY=($(compgen -W "--mcpd-url -h --help" -- "$cur")) return ;; @@ -103,7 +106,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) @@ -119,13 +122,13 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --scope -o --output --inspect --stdout --skip-skills --skip-marker --skip-ui --claude-dir --dry-run -h --help" -- "$cur")) ;; pi) - COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --extension-dir --skip-skills --settings --pi-dir --dry-run -h --help" -- "$cur")) ;; prime-agent) COMPREPLY=($(compgen -W "-p --project -o --output --gateway-url --token --skip-skills --skip-extension --skip-marker --dry-run -h --help" -- "$cur")) @@ -133,6 +136,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")) ;; @@ -188,7 +194,7 @@ _mcpctl() { else case "$create_sub" in server) - COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --volume --from-template --env-from-secret --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --volume --health-check-tool --health-check-args --health-check-interval --health-check-timeout --health-check-failure-threshold --from-template --env-from-secret --force -h --help" -- "$cur")) ;; secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index cceb9ea..c3f9961 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate +set -l commands status statusline login logout config get describe delete logs create edit apply chat chat-llm patch passwd errors backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server favourites # Disable file completions by default @@ -221,6 +221,7 @@ end # Top-level commands (without --project) complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show mcpctl status and connectivity' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a statusline -d 'Print the active mcpctl project (for Claude Code\'s statusLine setting)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out and remove stored credentials' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage mcpctl configuration' @@ -267,7 +268,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 +278,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' @@ -285,17 +287,27 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config view" -s o -l output -d 'ou # config claude options complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l dry-run -d 'Print what would change without writing or syncing' # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' -complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l scope -d 'Where to register the MCP server: user (every directory) or project (this .mcp.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Project-scope output file path (implies --scope project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker next to the output file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-ui -d 'Do not install the status line or the /mcpctl slash command' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l claude-dir -d 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l dry-run -d 'Print what would change without writing or syncing' # config pi options complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' @@ -303,6 +315,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l extension-dir -d 'So complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l skip-skills -d 'Skip the initial skills sync' complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l settings -d 'pi settings.json path (default: ~/.pi/agent/settings.json)' -x complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l pi-dir -d 'Override the pi agent home (default: ~/.pi/agent)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l dry-run -d 'Print what would change without writing or syncing' # config prime-agent options complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' @@ -324,6 +337,16 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l sk complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l dry-run -d 'Print what would change without writing or syncing' +# config opencode options +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -s p -l project -d 'Project name to make active' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l token -d 'mcpctl project bearer token to use (skips auto-minting)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l opencode-dir -d 'Override opencode\'s config dir (default: ~/.config/opencode)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-plugin -d 'Do not (re)install or register the opencode plugins' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l skip-marker -d 'Do not write a .mcpctl-project marker in the current directory' +complete -c mcpctl -n "__mcpctl_subcmd_active config opencode" -l dry-run -d 'Print what would change without writing or syncing' + # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -358,6 +381,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l container-port - complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l replicas -d 'Number of replicas' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env -d 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l volume -d 'Persistent volume: NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] (repeat for multiple)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-tool -d 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-args -d 'Readiness probe: JSON object of arguments for the probe tool' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-interval -d 'Readiness probe interval in seconds (default 60)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-timeout -d 'Readiness probe timeout in seconds (default 10)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l health-check-failure-threshold -d 'Consecutive failures before the instance is marked unhealthy (default 3)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l from-template -d 'Create from template (name or name:version)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l env-from-secret -d 'Map template env vars from a secret' -x complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update if already exists' @@ -526,11 +554,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)' @@ -585,6 +613,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from rotate; and not __fish_seen_s # status options complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x +# statusline options +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -s d -l directory -d 'Directory to resolve the project for (default: from stdin, then cwd)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from statusline" -l prefix -d 'Text before the project name' -x + # login options complete -c mcpctl -n "__fish_seen_subcommand_from login" -l mcpd-url -d 'mcpd URL to authenticate against' -x diff --git a/docs/claude-integration.md b/docs/claude-integration.md new file mode 100644 index 0000000..425a65e --- /dev/null +++ b/docs/claude-integration.md @@ -0,0 +1,181 @@ +# mcpctl × Claude Code + +## What `mcpctl config claude --project X` wires up + +| Piece | Where | Purpose | +|-------|-------|---------| +| MCP entry | `~/.claude.json` (**user scope, every directory**) | one server, always named `mcpctl`, running the stdio bridge `mcpctl mcp -p X` | +| Project marker | `./.mcpctl-project` | what `skills sync` reads to know the scope (`--skip-marker` opts out) | +| Skills | `~/.claude/skills/` | the project's `SKILL.md` bundles | +| SessionStart hook | `~/.claude/settings.json` | `mcpctl skills sync --quiet` on every session | +| Status line | `~/.claude/settings.json` | the active project, bottom of the screen | +| `/mcpctl` | `~/.claude/commands/mcpctl.md` | switch projects from inside a session | + +`--claude-dir` (or Claude Code's own `CLAUDE_CONFIG_DIR`) redirects everything +under `~/.claude`. + +## User scope by default: wire it once, not per checkout + +Claude Code has two MCP scopes: **project** (`./.mcp.json`, that directory only, +and usually committed — so writing to it dirties the repo) and **user** +(`mcpServers` in `.claude.json`, every directory and every window). + +mcpctl now defaults to **user** scope. One active project everywhere is how the +pi, prime-agent and opencode integrations already behave; per-directory wiring +meant re-running `config claude` in every checkout you opened. Switching the +project is then one command from anywhere. + +```bash +mcpctl config claude --project homeautomation # applies everywhere +mcpctl config claude --project docmost --scope project # just this repo +mcpctl config claude --project docmost -o .mcp.json # same thing; -o implies project scope +``` + +User scope writes **no `.mcpctl-project` marker** — it scopes nothing to a +directory, and a marker beside `.claude.json` would sit in `$HOME` and scope +every repo under it. `--inspect` stays project-scope: it is a debugging server +you turn on for one checkout. + +> `.claude.json` also holds onboarding state, caches and a per-project map that +> Claude Code rewrites while running, so mcpctl merges into it and writes +> through a temp file + rename. A truncated write there costs far more than a +> stale MCP entry. +> +> Note the path asymmetry: with `CLAUDE_CONFIG_DIR` set the file is +> `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` — +> *beside* `~/.claude/`, not inside it. + +## One server named `mcpctl`, not one per project + +The MCP entry used to be named after the project. Because `.mcp.json` is +*merged*, configuring a second project left the first mounted too — every +project you had ever configured stayed connected, with duplicate tool names and +nothing marking which was active. + +There is now exactly one entry, `mcpctl`, and switching rewrites what sits +behind it: + +```jsonc +{ "mcpServers": { "mcpctl": { "command": "mcpctl", "args": ["mcp", "-p", "docmost"] } } } +``` + +Two things follow. The tool prefix is stable across switches, so the model never +sees a tool namespace disappear mid-conversation. And because Claude Code can +reconnect an existing MCP server from `/mcp`, a switch lands without restarting +the app. + +Entries an older CLI wrote are retired on the next run. They are recognised by +the pairing that makes retiring them safe — *our* command, named after the very +project it bridges to. A hand-configured server is never touched, even one +called `docmost`, unless it also runs `mcpctl mcp -p docmost`, at which point it +is the same entry anyway. + +## Switching: `/mcpctl` + +``` +❯ /mcpctl docmost +● Bash(mcpctl config claude --project docmost --skip-marker) + ⎿ Wrote .mcp.json (1 server(s)) +● Now on docmost. Reconnect the mcpctl server from /mcp for its tools to load. +``` + +With no argument it lists the projects and asks. + +Claude Code slash commands are **prompt files, not code**, so unlike opencode's +picker this drives the model through CLI calls — there is no keyboard picker to +be had. `allowed-tools` is scoped to the four exact `mcpctl` invocations it +needs, so accepting it does not hand the session a general shell. + +> Every `` !`…` `` block in a slash command is permission-checked against +> `allowed-tools` *before* the model runs. Omitting one fails the whole command +> with a permission error and no explanation. A test asserts every pre-executed +> command in ours is covered. + +`--skip-marker` is deliberate: the session's directory is whatever you happened +to open, and re-scoping it would silently change which skills sync into it. + +## The status line + +``` + mcpctl:docmost + ⏵⏵ bypass permissions on · ← for agents +``` + +`mcpctl statusline` reads the directory from the JSON Claude Code pipes in, so it +follows `/cwd` rather than reporting wherever the binary was launched, and prints +**nothing** when no project is active — an empty status line beats one saying +"none" on every unrelated repo. + +It then takes the project from the most deliberate source that names one: + +1. a canonical `mcpctl` entry in that directory's `.mcp.json` — a repo that + pinned itself wins, and it is the scope Claude Code itself prefers when both + define that server name; +2. the user-scope entry in `.claude.json` — what `config claude --project` + writes, so a switch takes effect everywhere it is not overridden; +3. a **legacy** project-named entry in `.mcp.json` (`homeautomation`, + `docmost`, …), left by an mcpctl older than the constant server name; +4. a `.mcpctl-project` marker up the tree, so a checkout that is scoped but not + yet wired still reports. + +> **Legacy entries rank below user scope on purpose.** They used to outrank it, +> which made switching look broken: a user-scope switch never rewrites a +> checkout's `.mcp.json`, so the leftover kept naming the old project for good. +> A pin is a decision; residue is not. + +A server Claude Code has switched off for that directory (`disabledMcpServers` / +`disabledMcpjsonServers`) is skipped at every step — a disabled server is not +mounted, so naming its project would be a lie. A `.mcp.json` server that is in +neither list is still awaiting its approval prompt and does count, since blanking +the status line on a fresh checkout is the more confusing failure. + +### When a directory contradicts a switch + +Claude Code merges the two scopes rather than picking one, so switching in user +scope cannot clean up what a directory declares. `config claude` says so rather +than reporting plain success: + +``` +Warning: /path/to/repo/.mcp.json still registers 'homeautomation' for this +directory — mounted alongside 'sre', not replaced by it. + Re-run with --scope project to retire it, or delete the entry by hand. +``` + +A canonical entry pinned to another project gets the stronger wording — it +*overrides* the switch in that directory rather than sitting beside it. + +### It is never installed over yours + +A status line is a single slot, so overwriting a custom one silently deletes +work. When `config claude` finds a foreign one it leaves it and prints the +snippet to add instead: + +``` +Left your existing status line alone (my-fancy-prompt). + To show the project too, append: $(mcpctl statusline) +``` + +> **Ownership is decided by the command string, not a marker.** Claude Code +> rewrites `settings.json` against its own schema and **strips unknown keys from +> `statusLine`** — a tagged entry comes back as a bare `{type, command}`. +> (Hooks keep their marker; `statusLine` does not.) Matching on the command is +> what stops us reporting our own line as foreign forever. A line that merely +> *composes* ours — `my-prompt && mcpctl statusline` — is yours, and is left +> alone. + +## Skills + +`mcpctl skills sync` installs into `~/.claude/skills/`. Claude Code is the only +target that also gets hooks, `postInstall` and `mcpServers` auto-attach; pi, +prime-agent and opencode share the simpler flat-tree semantics. + +The SessionStart hook keeps them current. It carries a `_mcpctl_managed` marker, +and an install now also drops **untagged duplicates of that exact command** — +rows left behind before the marker existed (or by a test suite that used to +write into a real `~/.claude`). They are invisible in the UI and just run the +sync twice per session. A hook you wrote is never touched, even one that also +calls `mcpctl skills sync` with different flags. + +## Running Claude Code on the homelab LLM + +See [claude-vllm.md](claude-vllm.md). diff --git a/docs/claude-vllm.md b/docs/claude-vllm.md new file mode 100644 index 0000000..30ee014 --- /dev/null +++ b/docs/claude-vllm.md @@ -0,0 +1,131 @@ +# `claude-vllm` — Claude Code on the homelab LLM + +## The short version + +```bash +claude-vllm # default provider + model +claude-vllm --model deepseek-v4-max +claude-vllm --list # what would it use? +claude-vllm --print-env # the exports, without running claude +claude-vllm -- -p "summarise this repo" # anything after -- goes to claude +``` + +## Why no bridge is needed + +The homelab gateway at `llm.ad.itaz.eu` is **LiteLLM in front of vLLM**, and +LiteLLM already serves the Anthropic Messages API: + +```console +$ curl -s https://llm.ad.itaz.eu/v1/messages -X POST \ + -H 'x-api-key: ' -H 'anthropic-version: 2023-06-01' \ + -d '{"model":"deepseek-v4-fast","max_tokens":32, + "messages":[{"role":"user","content":"say only: ok"}]}' +{"type":"message","role":"assistant","model":"deepseek-v4-fast", + "content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn", …} +``` + +That is the exact protocol Claude Code speaks. So pointing `ANTHROPIC_BASE_URL` +at the gateway is the whole integration — no translation layer, no proxy, no +mcpctl in the request path. + +### Why not route it through mcpctl + +mcpctl's LLM layer is a **client**, not a server: `mcpd` exposes +`/api/v1/llms` (management) and the adapters in +`src/mcpd/src/services/llm/adapters/` *call out* to Anthropic/OpenAI for gating, +prompt selection and agent chat. Nothing in mcpd or mcplocal serves +`/v1/messages`. A `claude-mcpctl` would therefore mean adding an +Anthropic-shaped passthrough to mcpd that re-wraps what LiteLLM already does — +new surface, new failure mode, no new capability. + +It would only be worth it if you wanted mcpctl *in* the LLM path for its own +sake: per-project gating of model calls, audit of prompts, or budget +enforcement. Those are real features, but they are a passthrough endpoint in +mcpd, not a wrapper script. + +## Its own config + +```bash +claude-vllm --init # prompts for the key, writes 0600 +claude-vllm --init --api-key '${MY_KEY}' # keep the secret in the environment instead +claude-vllm --init --base-url https://other-gateway/v1 +``` + +Written to `$XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc` (`~/.config/…`), shaped +like `opencode.jsonc` — a `provider` map with `options.baseURL` / +`options.apiKey` and a `models` map: + +```jsonc +{ + "model": "itaz/deepseek-v4-think", + "provider": { + "itaz": { + "options": { "baseURL": "https://llm.ad.itaz.eu/v1", "apiKey": "${MY_LLM_KEY}" }, + "models": { "deepseek-v4-fast": { "limit": { "context": 393216 } } } + } + } +} +``` + +**Same shape as opencode's config, but a separate file.** Reading opencode's own +config would mean a credential rotation there silently changing what Claude Code +authenticates with, and would couple two tools' configs for no reason. + +### Keys are never stored in the tool + +`apiKey` may be a literal (in a 0600 file), `${ENV_VAR}`, or a bare env var +NAME — so the secret can live in your environment or a password manager instead +of on disk. `--init` reads the key from **stdin** when `--api-key` is omitted, +keeping it out of shell history and out of the process table, where `--api-key` +would be visible to every user via `ps`. `--list` never prints more than a +10-character prefix. + +### Discovery order + +| Order | Source | Supplies | +|-------|--------|----------| +| 1 | environment (`ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`) | anything already set is respected | +| 2 | `--provider` / `--model` flags | provider, model | +| 3 | `~/.config/mcpctl/claude-vllm.jsonc` | its own config | +| 4 | `~/.pi/agent` | `settings.json` → default provider/model · `models.json` → base URL, context windows · `auth.json` → key | +| 5 | `~/.prime/agent` | same shape | + +The pi/prime homes remain a convenience fallback so `claude-vllm` works before +you have written a config at all. The base URL and the credential always come +from the *same* source, so one gateway's URL is never paired with another's key. + +`--init` records the context window for **every** model it can see, not just the +active one — otherwise `--model something-else` silently falls back to Claude +Code's assumed 200k. + +## What it sets, and why each one + +| Variable | Reason | +|----------|--------| +| `ANTHROPIC_BASE_URL` | The gateway. A stored OpenAI-style `…/v1` base has the suffix stripped — Claude Code appends `/v1/messages` itself, and `/v1/v1/messages` 404s. | +| `ANTHROPIC_AUTH_TOKEN` + `ANTHROPIC_API_KEY` | The gateway takes either; which one Claude Code sends has changed between releases. | +| `ANTHROPIC_MODEL` | The model to drive the session. | +| `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL` | Background/summarisation calls otherwise ask for a real Haiku the gateway does not serve, and every one 404s. | +| `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Claude Code assumes 200k for models it has no table for. `deepseek-v4-*` is 393k, so without this it auto-compacts at half capacity. Taken from `contextWindow` in the provider config. | +| `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS` | Beta headers the gateway does not implement make it reject otherwise-fine requests. | +| `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | No telemetry about a non-Anthropic endpoint. | + +## Expected noise + +``` +⚠ claude.ai connectors are disabled because ANTHROPIC_API_KEY or another auth + source is set and takes precedence over your claude.ai login +``` + +Unavoidable and harmless: it fires for *any* non-claude.ai auth, including +`ANTHROPIC_AUTH_TOKEN` alone (verified). It means your claude.ai org connectors +are not loaded for this session — which is the point of running against the +homelab. + +## Verified + +```console +$ claude-vllm --model deepseek-v4-fast -- -p "Reply with exactly: PARITY-OK" +claude-vllm: itaz · deepseek-v4-fast · https://llm.ad.itaz.eu +PARITY-OK +``` diff --git a/docs/opencode-extension.md b/docs/opencode-extension.md new file mode 100644 index 0000000..d8e4c7b --- /dev/null +++ b/docs/opencode-extension.md @@ -0,0 +1,210 @@ +# mcpctl × opencode — project switching from the TUI + +## Motivation + +`mcpctl config claude` and `mcpctl config prime-agent` both work the same way: +write the project's MCP server into the host's config file, then restart the +host so it picks the change up. That is fine when you pick a project once, and +tiresome when you switch between `homeautomation`, `sre` and `docmost` all day. + +[opencode](https://opencode.ai) can do better, because it exposes two things +the other hosts do not: + +- an **HTTP API for its own MCP registry** (`POST /mcp`, + `/mcp/{name}/disconnect`) — so a mount can be re-pointed while the app runs; +- a **TUI plugin API** (`tui.json`) with slash commands, dialogs and UI slots — + so the switcher can be a first-class part of the interface rather than a + shell command you run in another terminal. + +So the opencode integration does not write an MCP entry into `opencode.json` at +all. It ships two plugins, and switching projects takes effect on the next turn +with no restart. + +## How it works + +``` +~/.mcpctl/opencode-state.json (0600) + { project, gatewayUrl, tokens: { : } } + │ + │ read by both plugins + ▼ +~/.config/opencode/plugin/mcpctl.ts server plugin ─┐ +~/.config/opencode/mcpctl/mcpctl-tui.tsx TUI plugin ─┤ + │ client.mcp.add({ + │ name: "mcpctl", + ▼ type: "remote", url, headers }) + opencode's MCP registry + │ + ▼ + https:///projects//mcp +``` + +### The state file, not `opencode.json` + +1. **The restart.** A config file is read at startup. Re-pointing the mount + through the running server's MCP API is what makes `/mcpctl` instant. This is + the load-bearing reason. +2. **The token.** Having chosen the gateway, we need + `Authorization: Bearer ` somewhere. `opencode.json` is a mode-0644 + file people paste into bug reports; `~/.mcpctl/opencode-state.json` is 0600, + like every other mcpctl credential. + +> **Reason 2 is not an argument for this design over the alternative.** A +> `type: "local"` entry running `mcpctl mcp -p ` — the same stdio +> bridge `config claude` uses — needs no bearer token at all, because the bridge +> reads your own `~/.mcpctl/credentials`. So "no secret in a 0644 file" is not a +> point against that approach; it is just a consequence of having picked the +> HTTP gateway. +> +> The honest trade is: the gateway works against a remote mcpctl with no local +> `mcplocal` daemon, and mounts through an API that can be re-pointed live. The +> stdio bridge is simpler and credential-free, but requires `mcpctl` and a +> reachable mcplocal on the same machine. Both are defensible; this one was +> chosen for the remote case and for the live re-point, not for the token. + +Tokens are kept **per project**, so switching back to a project you have +already used needs no new mint — and a failed mint for project B cannot cost +you the credential for project A. + +### The server plugin (`plugin/mcpctl.ts`) + +Auto-discovered by opencode. It mounts the active project's gateway under the +fixed MCP server name `mcpctl`, on the first server event and again before +every user turn. + +Two details matter: + +- **The name is constant.** Tools keep a stable `mcpctl_*` prefix across + switches, and because opencode re-resolves the tool list per request, the + model simply sees the new project's tools on its next turn. (Contrast pi, + where the extension has to *tell* the model its old tool names are dead.) +- **It does not re-register an unchanged mount.** `mcp.add` rebuilds the + connection, and mcplocal binds a gated project's unlocked state to that + connection's `mcp-session-id` — re-adding every turn would re-lock a project + you had just opened with `begin_session`. + +It also exists so that **headless** runs (`opencode run …`), which load no TUI +plugins at all, still get the project's tools. + +> The mount is deliberately *not* performed during plugin setup. Setup runs +> before opencode's server accepts connections, and `client.mcp.add` calls back +> into that same server — awaiting it there hangs the app on a blank screen +> before the TUI ever draws. + +### The TUI plugin (`mcpctl/mcpctl-tui.tsx`) + +Registered in `~/.config/opencode/tui.json` (opencode does not auto-discover +TUI plugins). It adds: + +| Command | What it does | +|---------|--------------| +| `/mcpctl` (or `m`) | Filterable project picker; switches live | +| `/mcpctl-status` | Active project, mount state, gateway URL | +| `/mcpctl-skills` | Re-sync this project's skills | + +Switching is the thing you do repeatedly, so it gets a chord as well as a slash +command; the other two stay palette-only. + +and a `mcpctl:` indicator in the prompt footer, next to the model name +and one line above the token counter. + +Switching delegates to the CLI — +`mcpctl config opencode --project X --skip-plugin --skip-marker` — so token +minting, state and skills stay in one place and the plugin stays a UI shell. +`--skip-plugin` avoids rewriting the very file opencode has already loaded; +`--skip-marker` stops a switch from silently re-scoping whichever repository +opencode happened to be started in. + +The picker needs no pre-filter prompt (unlike the pi and prime-agent +switchers): opencode's select dialog filters as you type, so the plugin only +has to order the list — active project first, then alphabetical. + +A switch **disconnects before re-adding**. `mcp.add` under the same name does +re-point the tools on its own, but leaves it to opencode whether the previous +client is closed, and an abandoned one keeps its `mcp-session-id` alive on +mcplocal — the very thing that holds a gated project open. Best-effort: on a +first mount there is nothing to disconnect. + +The indicator is published through `api.kv`, which is a reactive store: writing +it re-renders the slot with no signal plumbing, and it persists across sessions +so the label is right on the first frame. + +#### Why the footer and not the status bar + +opencode exposes UI slots, not arbitrary layout. In the footer region the +options are: + +| Slot | Result | +|------|--------| +| `session_prompt_right` / `home_prompt_right` | **used** — renders on the prompt's bottom line, right of the model name, directly above the token counter | +| `home_footer` | sits on the counter's line, but *replaces* the cwd/version footer instead of adding to it | +| `app_bottom` | costs a whole extra terminal row | + +There is no slot on the status-bar line itself. The home prompt box is narrow +enough that the default wrap breaks `mcpctl:homeautomation` across two lines +mid-word, so the label renders `wrapMode="none" truncate` — clipping the tail of +a long name reads better than a two-line footer. In a session the prompt is full +width and it always fits. + +### Skills + +opencode implements the Agent Skills standard and loads `SKILL.md` trees from +`~/.config/opencode/skill/` (XDG-aware). `mcpctl skills sync --agent opencode` +syncs there, with the same shared-tree semantics as `--agent pi` and +`--agent prime-agent`: a flat tree with per-project ownership, its own state +file (`~/.mcpctl/skills-state-opencode.json`), no SessionStart hooks, no +`postInstall`, and untracked skill directories are never clobbered. + +## Usage + +```bash +mcpctl config opencode --project monitoring +``` + +That mints (or reuses) the project token, writes the 0600 state file, installs +both plugins, registers the TUI plugin in `tui.json`, and runs an initial +skills sync. Start opencode and the project's tools are there. + +Skip individual steps: + +```bash +mcpctl config opencode --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config opencode --project monitoring --skip-skills # don't sync skills +mcpctl config opencode --project monitoring --skip-plugin # state only, leave plugins alone +mcpctl config opencode --project monitoring --skip-marker # don't touch .mcpctl-project here +mcpctl config opencode --project monitoring --dry-run # print the plan, write nothing +mcpctl config opencode --project monitoring --opencode-dir /path # non-default opencode config dir +``` + +## Failure semantics + +- **No usable credential → the switch fails.** The command exits non-zero and + leaves the state file untouched, so the previously active project keeps + working rather than being replaced by a mount that 401s. `/mcpctl` reads that + exit code and reports the switch as failed instead of claiming success over a + project with no tools. +- **Gateway unreachable → no mcpctl tools.** Never a failed startup: the server + plugin swallows mount errors. +- **Corrupt `tui.json` → refuses to write.** One syntax error must not silently + drop every other TUI plugin you installed. + +## Files + +| Path | Purpose | +|------|---------| +| `src/opencode-ext/mcpctl-opencode.ts` | Server plugin source | +| `src/opencode-ext/mcpctl-opencode-tui.tsx` | TUI plugin source | +| `src/cli/src/config/opencode-extension.ts` | Generated embed of both (do not edit) | +| `scripts/generate-opencode-extension.ts` | Regenerates that embed | +| `src/cli/src/utils/opencode-settings.ts` | Install / register / state helpers | + +The plugin sources are **embedded** in the CLI so `mcpctl config opencode` +works from an installed binary with no source tree. After editing either +source, re-run: + +```bash +npx tsx scripts/generate-opencode-extension.ts +``` + +A test fails if you forget. They are typechecked against the real +`@opencode-ai/plugin` types via `pnpm typecheck:opencode-ext`. diff --git a/docs/pi-extension.md b/docs/pi-extension.md index fcf98fc..f10c6f7 100644 --- a/docs/pi-extension.md +++ b/docs/pi-extension.md @@ -101,9 +101,34 @@ src/pi-ext/ mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps) ``` -The extension imports only from pi-bundled packages -(`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it -loads standalone. +The extension imports only from pi-bundled packages, so it loads standalone. + +### `typebox` is the only bare runtime import + +pi does not let an extension resolve modules the ordinary way: it hands jiti a +hard-coded alias table built from its *own* dependencies, and that table is not +the same across pi distributions. The newer `@earendil-works/pi-coding-agent` +aliases both the `@earendil-works/*` and the legacy `@mariozechner/*` names; +older `@mariozechner/pi-coding-agent` installs (0.73.x and earlier) alias only +the `@mariozechner/*` ones. Neither resolves the other's namespace. + +So an import of anything outside the intersection kills the *whole* extension on +someone else's pi — every tool, the `/mcpctl` command, the status line — with: + +``` +Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai' +``` + +which is exactly what `import { StringEnum } from "@earendil-works/pi-ai"` did. +`typebox` is aliased by every published pi, so it is the only bare specifier +allowed at runtime. Everything else must be a `node:` builtin, a relative path, +an `import type` (erased before jiti resolves anything), or inlined — pi-ai's +`StringEnum` is now a six-line local `stringEnum`. The +`tests/config/pi-extension-embed.test.ts` guard fails the build on a reintroduced +runtime import. + +If a user does hit this error, check `type -a pi`: two installs on `$PATH` is the +usual cause, and the extension has to load under whichever one wins. ## Typechecking diff --git a/docs/prime-agent-extension.md b/docs/prime-agent-extension.md new file mode 100644 index 0000000..3d6b48b --- /dev/null +++ b/docs/prime-agent-extension.md @@ -0,0 +1,101 @@ +# mcpctl × prime-agent + +## What `mcpctl config prime-agent --project X` wires up + +| Piece | Where | Purpose | +|-------|-------|---------| +| Proxy MCP | `~/.prime/agent/settings.json` | `{ type: "http", url: "/projects/X/mcp" }` | +| Bearer credential | `~/.prime/agent/auth.json` | `mcp:X` → a minted mcpctl PAT | +| Skills | `~/.prime/agent/skills/` | the project's `SKILL.md` bundles | +| `/mcpctl` switcher | `~/.prime/agent/extensions/mcpctl-switch.ts` | switch projects, and the active-project indicator | + +Unlike Claude Code (a stdio bridge, no token) and pi (native tools over +JSON-RPC), prime-agent talks to the **HTTP gateway**, so the switch is really +three things: a credential, a settings entry, and a reload. + +## Credentials are the fragile part + +`config prime-agent` mints an `mcptoken` per project, stores it under +`mcp:`, and retires the one it replaced. Three rules make that safe: + +- **A key being present proves nothing.** A revoked token would short-circuit + provisioning and leave prime-agent unable to reach the gateway while the + command reported success. mcptokens are shown once, so the stored token's + 16-char `tokenPrefix` is compared against the project's *active* tokens rather + than sending the secret. +- **No credential means the switch fails.** Non-zero exit, `settings.json` + untouched, so the previously active project keeps working instead of being + replaced by a mount that 401s — and the `/mcpctl` switcher, which reads that + exit code, reports failure rather than success over a project with no tools. +- **Only the token we replaced is revoked.** Sweeping every `prime-agent` token + for a project would kill the one another machine is using. Anything else that + looks orphaned is reported, not deleted. + +This plumbing is shared with `config opencode`, parameterised by agent rather +than copied. + +## The `/mcpctl` switcher + +`pi.registerCommand('mcpctl', …)` opens a picker, shells out to +`mcpctl config prime-agent --project X --skip-extension --skip-marker`, then +calls `ctx.reload()` — which re-reads `settings.json` and `auth.json` and +rebuilds the MCP map, so the switch lands without restarting the app. + +`--skip-extension` stops it rewriting the very file it is running from; +`--skip-marker` stops it re-scoping whatever repository prime-agent was started +in, which Claude Code's own skills sync would then pick up. + +Above 20 projects it asks for a filter first: prime-agent's selector is an +arrow-key list with no search, and real installs run to hundreds of projects. +(opencode's dialog filters as you type, so its switcher needs no such prompt.) + +## The active-project indicator + +Published with `ctx.ui.setStatus('mcpctl', …)`, which prime-agent renders in its +tray line next to the model name. + +Two quirks worth knowing: + +- prime-agent emits `session_start` **only from `reload()`**, never at startup — + so the indicator is also published on `turn_start`, the earliest moment with a + real UI context bound. +- `resetExtensionUI()` clears extension statuses *after* `session_start`, so the + indicator set there is wiped before anyone sees it. It is re-published on a + short retry schedule to land after that reset. + +> The rendering itself only exists in prime-agent from +> `prime-agent-extension-status.patch` (upstream PR pending). On an unpatched +> build `setStatus` silently does nothing and no indicator appears. + +## The extension is real source now + +It used to exist **only** as a string literal inside +`src/cli/src/config/prime-agent-extension.ts` — so nothing typechecked or linted +it, which is precisely the gap that let a wrong `ctx.ui.select()` option shape +ship in the pi extension. + +It now lives at `src/prime-agent-ext/mcpctl-switch.ts`, checked against the real +`@earendil-works/pi-coding-agent` types: + +```bash +pnpm typecheck:prime-agent-ext +npx tsx scripts/generate-prime-agent-extension.ts # after editing it +``` + +A test fails if the embed goes stale. Extracting it found six lint problems in +code that had never been linted — all null-guard and return-type issues rather +than live bugs, but exactly the class of thing that ships silently when nothing +is looking. + +## What it deliberately does *not* do + +The MCP entry is still **named after the project**, not the constant `mcpctl` +that `config claude` and `config opencode` now use. prime-agent's switcher +already unmounts the previous project, so it never accumulates entries the way +`config claude` did — the bug the constant name fixes does not exist here. + +The remaining difference is tool-prefix stability: switching changes tool names, +so the model can hold stale ones. Moving prime-agent to a constant name would +also re-key `auth.json` from `mcp:` to `mcp:mcpctl`, giving up +per-project token caching and needing a migration. Worth doing, but as its own +change rather than folded into a parity pass. diff --git a/docs/project-summary.md b/docs/project-summary.md index 95552bd..5c0a163 100644 --- a/docs/project-summary.md +++ b/docs/project-summary.md @@ -884,6 +884,39 @@ All pushed to `mysources.co.uk/michal/` registry. source .env && bash scripts/release.sh ``` +**The build refuses to run from a branch that is behind `main`.** Everyone +branches off main, so a stale branch still builds and installs cleanly — it just +ships a binary missing whatever landed on main meanwhile, and `rpm -U --force` +overwrites the good one with it. That happened on 2026-08-10: a build from a +stale checkout replaced `/usr/bin/mcpctl` with one that had no `statusline` +command, months after the status line landed. `scripts/check-main-sync.sh` +fetches `main`, compares, and fails before any work happens, listing the commits +you are missing. It gates every path that produces something others consume: +`build-rpm.sh`, `build-deb.sh`, `build-mcpd.sh` (each is also run standalone, so +none can rely on another having checked) and `deploy-k8s.sh` — where a stale +branch would pin its sha in Pulumi and make it the cluster's source of truth. +`deploy-k8s.sh --dry-run` skips the check: it builds and cuts over nothing, and +blocking a read-only inspection only teaches people to export the escape hatch +permanently, disabling the gate for real deploys too. + +```bash +git merge main # the fix +MCPCTL_ALLOW_BEHIND_MAIN=1 bash scripts/release.sh # deliberate old-tree build +MCPCTL_BASE_BRANCH=release-2.x bash scripts/build-rpm.sh # compare to another branch +``` + +Offline it falls back to the last fetched `origin/main`, then to a local `main`, +and says which it used; outside a git checkout it skips entirely. + +**A failing smoke run fails the release.** It used to print +`WARNING: Smoke tests failed!` and exit 0 — which is exactly how four broken +readiness probes shipped unnoticed (see `docs/reliability.md`): the warning +scrolled past and the release reported success. Note what the gate does and does +not do — smoke runs *last*, against the installed binary, so the package is +already published and installed by the time it fails. It reports the breakage +rather than preventing it, so investigate the fleet rather than assuming the +artifact is bad. Override with `MCPCTL_ALLOW_SMOKE_FAILURE=1`. + Installs via nfpm: - `/usr/bin/mcpctl` — CLI binary (bun compiled) - `/usr/bin/mcpctl-local` — Local proxy binary (bun compiled) diff --git a/docs/reliability.md b/docs/reliability.md index 28a1c25..30423c8 100644 --- a/docs/reliability.md +++ b/docs/reliability.md @@ -28,6 +28,78 @@ Note: the gate's prompt-ranking uses the **heavy client provider's own model** it deliberately does *not* force the project's vLLM model onto it (doing so made every selection fail silently when the model wasn't anthropic-servable). +## Instance health: `live` is not `healthy` + +An MCP server answers `tools/list` from a **static, in-process table**. It costs +a few milliseconds, needs no credentials, and reaches no upstream — so it stays +green while the thing the server exists to talk to is unreachable. Treating that +as a health signal is how `mcpctl get instances` showed eight healthy servers +while the UniFi one had never once reached its controller. + +So the probe reports two different passes: + +| Status | Probe | Means | +|---|---|---| +| `healthy` | **readiness** — `tools/call` on `healthCheck.tool` | The upstream answered. The server can do its job. | +| `live` | **liveness** — `tools/list` only | The process is up and speaks MCP. Its upstream is **unverified**. | +| `degraded` | either, failing | Failing, but under `failureThreshold`. | +| `unhealthy` | either, failing | Failed `failureThreshold` times in a row. | + +`live` is the default for any server with no `healthCheck.tool`. It is not a +warning — it is an admission that nothing is watching that server's upstream. + +**Configure a readiness probe on every server.** Pick a read-only tool that +genuinely round-trips to the upstream, and verify it passes before configuring +it — a probe naming a local-only tool (`get_..._version`) or a tool the server +doesn't expose reproduces the same false green it was meant to remove. + +```bash +mcpctl create server unifi-network --health-check-tool list_sites \ + --health-check-interval 60 --health-check-timeout 15 --force +``` + +or declaratively — `healthCheck` round-trips through `get -o yaml | apply -f`: + +```yaml +healthCheck: + tool: list_sites + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 15 + failureThreshold: 3 +``` + +Omit `tool` to keep liveness while still tuning the timings. + +Latency is the tell: a probe answering in single-digit milliseconds is reading a +local table, not crossing a network. The UniFi probe went from 3ms (`tools/list`, +lying) to 1847ms on its first real `list_sites` — login, TLS, controller round +trip — and ~40ms once the session was warm. + +### Where a failing readiness probe usually points + +Turning these probes on for the first time took the fleet from "8/8 healthy" to +three genuine failures in under a minute. All three were network shape, not +code — check these before suspecting the server: + +1. **Egress port.** MCP server pods default to TCP 80/443 only + (`servers-allow-external-egress`). Any upstream on another port — the UniFi + controller on `:8443` — times out on every call. Declare it in Pulumi's + `mcpctl.serverEgressTargets`; don't widen the blanket rule. +2. **Ingress hairpin.** A co-located service reached over its *public* hostname + goes out and back through the per-host Envoy L7 policy, which doesn't + reliably carry the caller's identity and replies with a bare `Access denied`. + Grafana 403'd on every call this way while the identical token succeeded from + a laptop. The tell is the error *shape*: plain text, not the upstream's own + JSON error. Use the ClusterIP (`serverEgressTargets` with `namespace:`). +3. **Address reachability.** A pod cannot reach a **Tailscale** `100.64.0.0/10` + address. Config pointing at one connect-timeouts forever. Use LAN IPs. (This + one turned out to be a retired service, which is its own kind of answer.) + +Also check the *dialect*: UniFi's `controller_type` must be `classic` for a +self-hosted controller (login `/api/login`, no `/proxy/network` prefix). +`unifi_os` sends every request to a path that 404s. + ## LLM-*essential* operations — failover chain Chat needs *an* LLM but not a *specific* one. Instead of failing when the pinned diff --git a/docs/web-search.md b/docs/web-search.md index af8c389..ff39cf6 100644 --- a/docs/web-search.md +++ b/docs/web-search.md @@ -7,7 +7,7 @@ all three are deployed by mcpctl like any other server. |---|---|---| | `duckduckgo` | `duckduckgo-mcp-server` (python) | nothing | | `searxng` | `mcp-searxng` (node) | a SearXNG instance | -| `docs-mcp` | `ghcr.io/arabold/docs-mcp-server` | nothing (see the persistence caveat) | +| `docs-mcp` | `ghcr.io/arabold/docs-mcp-server` | nothing | Search and docs are different jobs, not competing options. A search engine will hand you a 2023 blog post with a stale method signature; a docs index cannot @@ -109,9 +109,11 @@ The backing PVC is named after the *server* (`mcpctl-docs-data`), not the instance, so editing the server or restarting the pod re-attaches to the same index rather than starting empty. See "Persistent volumes" in the README. -The template carries no `healthCheck`, because `search_docs` needs a library -argument that only exists after a scrape — a synthetic probe would report -unhealthy on a fresh instance. +The readiness probe is `list_libraries`: no arguments, read-only, and it reads +the SQLite store, so a pass proves the volume is mounted and readable rather +than merely that the process started. It answers "No libraries indexed yet." on +a fresh instance instead of erroring, which is why `search_docs` (which needs a +library that only exists after a scrape) cannot serve as the probe. ### Embeddings diff --git a/eslint.config.js b/eslint.config.js index 53f1093..946882a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tsparser from '@typescript-eslint/parser'; export default [ { - files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts'], + files: ['src/*/src/**/*.ts', 'src/pi-ext/*.ts', 'src/opencode-ext/*.ts', 'src/opencode-ext/*.tsx', 'src/prime-agent-ext/*.ts'], languageOptions: { parser: tsparser, parserOptions: { diff --git a/installlocal.sh b/installlocal.sh index 0418811..a68de85 100755 --- a/installlocal.sh +++ b/installlocal.sh @@ -28,6 +28,14 @@ fi echo "==> Detected package format: $PKG_FORMAT (arch: $NFPM_ARCH)" +# Any packaged input newer than $1. Mirrors nfpm.yaml's `contents:` — keep the +# two in step, or the build silently serves a stale binary again. +newer_inputs() { + find src/ -name '*.ts' -newer "$1" 2>/dev/null | head -1 + find stack/ completions/ deploy/ -type f -newer "$1" 2>/dev/null | head -1 + find nfpm.yaml -newer "$1" 2>/dev/null | head -1 +} + # Find package matching the target architecture # RPM uses x86_64/aarch64, DEB uses amd64/arm64 find_pkg() { @@ -38,8 +46,11 @@ find_pkg() { if [ "$PKG_FORMAT" = "rpm" ]; then PKG_FILE=$(find_pkg "dist/mcpctl-*.rpm") - # Build if no package exists or if source is newer - if [[ -z "$PKG_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$PKG_FILE" 2>/dev/null | head -1) ]]; then + # Build if no package exists or if any PACKAGED input is newer. + # Watching only src/*.ts silently shipped a stale package whenever a + # non-TypeScript artifact changed — stack/claude-vllm, the completions, the + # systemd unit are all in nfpm.yaml's contents and none of them are .ts. + if [[ -z "$PKG_FILE" ]] || [[ $(newer_inputs "$PKG_FILE") ]]; then echo "==> Building RPM..." bash scripts/build-rpm.sh PKG_FILE=$(find_pkg "dist/mcpctl-*.rpm") @@ -52,8 +63,11 @@ if [ "$PKG_FORMAT" = "rpm" ]; then else PKG_FILE=$(find_pkg "dist/mcpctl*.deb") - # Build if no package exists or if source is newer - if [[ -z "$PKG_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$PKG_FILE" 2>/dev/null | head -1) ]]; then + # Build if no package exists or if any PACKAGED input is newer. + # Watching only src/*.ts silently shipped a stale package whenever a + # non-TypeScript artifact changed — stack/claude-vllm, the completions, the + # systemd unit are all in nfpm.yaml's contents and none of them are .ts. + if [[ -z "$PKG_FILE" ]] || [[ $(newer_inputs "$PKG_FILE") ]]; then echo "==> Building DEB..." bash scripts/build-deb.sh PKG_FILE=$(find_pkg "dist/mcpctl*.deb") diff --git a/nfpm.yaml b/nfpm.yaml index 047ecc8..3280d1c 100644 --- a/nfpm.yaml +++ b/nfpm.yaml @@ -16,6 +16,10 @@ contents: dst: /usr/bin/mcpctl-local file_info: mode: 0755 + - src: ./stack/claude-vllm + dst: /usr/bin/claude-vllm + file_info: + mode: 0755 - src: ./deploy/mcplocal.service dst: /usr/lib/systemd/user/mcplocal.service file_info: diff --git a/package.json b/package.json index de493e0..ef00c0f 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 && pnpm run typecheck:prime-agent-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,8 @@ "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", + "typecheck:prime-agent-ext": "tsc -p src/prime-agent-ext/tsconfig.json", "smoke:clean": "tsx scripts/clean-smoke-resources.ts" }, "engines": { @@ -44,6 +46,10 @@ "devDependencies": { "@earendil-works/pi-ai": "^0.84.1", "@earendil-works/pi-coding-agent": "0.84.1", + "@opencode-ai/plugin": "1.18.15", + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -51,6 +57,7 @@ "eslint": "^10.0.1", "eslint-config-prettier": "^10.1.8", "rimraf": "^6.1.3", + "solid-js": "1.9.12", "tsx": "^4.21.0", "typebox": "1.3.11", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a6c99d..86a79db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,18 @@ importers: '@earendil-works/pi-coding-agent': specifier: 0.84.1 version: 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@opencode-ai/plugin': + specifier: 1.18.15 + version: 1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)) + '@opentui/core': + specifier: 0.4.5 + version: 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': + specifier: 0.4.5 + version: 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': + specifier: 0.4.5 + version: 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) '@types/node': specifier: ^25.3.0 version: 25.3.0 @@ -35,6 +47,9 @@ importers: rimraf: specifier: ^6.1.3 version: 6.1.3 + solid-js: + specifier: 1.9.12 + version: 1.9.12 tsx: specifier: ^4.21.0 version: 4.21.0 @@ -184,7 +199,7 @@ importers: version: link:../shared '@modelcontextprotocol/sdk': specifier: ^1.0.0 - version: 1.26.0(zod@3.25.76) + version: 1.26.0(zod@4.1.8) fastify: specifier: ^5.0.0 version: 5.7.4 @@ -280,10 +295,18 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@anthropic-ai/sdk@0.91.1': resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} hasBin: true @@ -408,10 +431,18 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} @@ -420,36 +451,98 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -463,6 +556,29 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx-self@7.27.1': resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} @@ -475,6 +591,18 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -483,14 +611,26 @@ packages: resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} @@ -1276,6 +1416,36 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} + cpu: [x64] + os: [win32] + '@next/env@16.2.5': resolution: {integrity: sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==} @@ -1385,6 +1555,23 @@ packages: resolution: {integrity: sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==} engines: {node: ^20.17.0 || >=22.9.0} + '@opencode-ai/plugin@1.18.15': + resolution: {integrity: sha512-AY10RtFbzLkf951dbLkSGJdBeddeS6ojbjvqCpWnINedqlj2cK/GF91xWjWnlHpqQZ4GABLPCuoSJx8mGmKvCw==} + peerDependencies: + '@opentui/core': '>=0.4.5' + '@opentui/keymap': '>=0.4.5' + '@opentui/solid': '>=0.4.5' + peerDependenciesMeta: + '@opentui/core': + optional: true + '@opentui/keymap': + optional: true + '@opentui/solid': + optional: true + + '@opencode-ai/sdk@1.18.15': + resolution: {integrity: sha512-8sfo9nGiVwesAZW9Wqkvynyn7w4wYaHx1O9qOHpYL65+Bs2XUpHP3kBbZe58gjQydFgk6I74kUF7sqCiPu2arQ==} + '@opentelemetry/api-logs@0.220.0': resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} engines: {node: '>=8.0.0'} @@ -1491,6 +1678,73 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@opentui/core-darwin-arm64@0.4.5': + resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.5': + resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-arm64@0.4.5': + resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} + cpu: [x64] + os: [linux] + + '@opentui/core-linux-x64@0.4.5': + resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.5': + resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.5': + resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.5': + resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/keymap@0.4.5': + resolution: {integrity: sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==} + peerDependencies: + '@opentui/react': 0.4.5 + '@opentui/solid': 0.4.5 + react: '>=19.2.0' + solid-js: 1.9.12 + peerDependenciesMeta: + '@opentui/react': + optional: true + '@opentui/solid': + optional: true + react: + optional: true + solid-js: + optional: true + + '@opentui/solid@0.4.5': + resolution: {integrity: sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==} + peerDependencies: + solid-js: 1.9.12 + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2198,6 +2452,23 @@ packages: react-native-b4a: optional: true + babel-plugin-jsx-dom-expressions@0.40.7: + resolution: {integrity: sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2313,6 +2584,11 @@ packages: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} + bun-ffi-structs@0.2.4: + resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==} + peerDependencies: + typescript: ^5 + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -2583,6 +2859,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + docker-modem@5.0.6: resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==} engines: {node: '>= 8.0'} @@ -2617,6 +2897,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + effect@4.0.0-beta.83: + resolution: {integrity: sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==} + electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} @@ -2641,6 +2924,14 @@ packages: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -2795,6 +3086,10 @@ packages: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} + fast-check@4.9.0: + resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} + engines: {node: '>=12.17.0'} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -2853,10 +3148,20 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + find-my-way@9.4.0: resolution: {integrity: sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w==} engines: {node: '>=20'} + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2981,6 +3286,11 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + google-auth-library@10.9.1: resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} engines: {node: '>=18'} @@ -3022,6 +3332,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + helmet@7.2.0: resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} engines: {node: '>=16.0.0'} @@ -3049,6 +3363,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -3125,6 +3442,10 @@ packages: resolution: {integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==} engines: {node: ^20.17.0 || >=22.9.0} + ini@7.0.0: + resolution: {integrity: sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + ink@6.8.0: resolution: {integrity: sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA==} engines: {node: '>=20'} @@ -3159,6 +3480,10 @@ packages: resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} engines: {node: '>= 10'} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3287,6 +3612,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3322,6 +3650,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kubernetes-types@1.30.0: + resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3399,6 +3730,10 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3451,6 +3786,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -3514,6 +3854,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -3545,6 +3889,10 @@ packages: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} @@ -3581,6 +3929,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.4: + resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} + hasBin: true + + msgpackr@2.0.5: + resolution: {integrity: sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==} + + multipasta@0.2.8: + resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3645,6 +4003,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + node-gyp@12.4.0: resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3762,10 +4124,18 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -3778,6 +4148,10 @@ packages: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -3790,6 +4164,9 @@ packages: resolution: {integrity: sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==} engines: {node: ^20.17.0 || >=22.9.0} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -3804,6 +4181,10 @@ packages: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3816,6 +4197,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -3853,6 +4241,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + postcss-selector-parser@7.1.4: resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} engines: {node: '>=4'} @@ -3924,6 +4316,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@8.4.2: + resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -4017,9 +4412,17 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4072,6 +4475,9 @@ packages: rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + s-js@0.4.9: + resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -4113,6 +4519,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -4188,6 +4604,9 @@ packages: resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4304,6 +4723,10 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -4387,6 +4810,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + toml@4.3.0: + resolution: {integrity: sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==} + engines: {node: '>=20'} + tough-cookie@6.0.1: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} @@ -4492,6 +4919,11 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} hasBin: true validate-npm-package-license@3.0.4: @@ -4591,6 +5023,14 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4724,17 +5164,29 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.8: + resolution: {integrity: sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==} + snapshots: '@acemir/cssom@0.9.31': {} '@adobe/css-tools@4.4.4': {} + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -4981,8 +5433,34 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} + '@babel/core@7.28.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5011,6 +5489,18 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -5019,8 +5509,34 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5028,6 +5544,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5037,12 +5560,47 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.29.2': @@ -5054,6 +5612,28 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5064,6 +5644,28 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.27.1(@babel/core@7.28.0)': + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.28.0) + transitivePeerDependencies: + - supports-color + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -5072,6 +5674,12 @@ snapshots: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -5084,11 +5692,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {} @@ -5819,6 +6444,29 @@ snapshots: zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color + optional: true + + '@modelcontextprotocol/sdk@1.26.0(zod@4.1.8)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.0) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.0 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.1.8 + zod-to-json-schema: 3.25.1(zod@4.1.8) + transitivePeerDependencies: + - supports-color '@monaco-editor/loader@1.7.0': dependencies: @@ -5831,6 +6479,24 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': + optional: true + '@next/env@16.2.5': {} '@next/swc-darwin-arm64@16.2.5': @@ -5975,6 +6641,21 @@ snapshots: node-gyp: 12.4.0 proc-log: 6.1.0 + '@opencode-ai/plugin@1.18.15(@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@opencode-ai/sdk': 1.18.15 + effect: 4.0.0-beta.83 + zod: 4.1.8 + optionalDependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + + '@opencode-ai/sdk@1.18.15': + dependencies: + cross-spawn: 7.0.6 + '@opentelemetry/api-logs@0.220.0': dependencies: '@opentelemetry/api': 1.9.1 @@ -6094,6 +6775,76 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentui/core-darwin-arm64@0.4.5': + optional: true + + '@opentui/core-darwin-x64@0.4.5': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.5': + optional: true + + '@opentui/core-linux-arm64@0.4.5': + optional: true + + '@opentui/core-linux-x64-musl@0.4.5': + optional: true + + '@opentui/core-linux-x64@0.4.5': + optional: true + + '@opentui/core-win32-arm64@0.4.5': + optional: true + + '@opentui/core-win32-x64@0.4.5': + optional: true + + '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.4(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.5 + '@opentui/core-darwin-x64': 0.4.5 + '@opentui/core-linux-arm64': 0.4.5 + '@opentui/core-linux-arm64-musl': 0.4.5 + '@opentui/core-linux-x64': 0.4.5 + '@opentui/core-linux-x64-musl': 0.4.5 + '@opentui/core-win32-arm64': 0.4.5 + '@opentui/core-win32-x64': 0.4.5 + transitivePeerDependencies: + - typescript + + '@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.5)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + optionalDependencies: + '@opentui/solid': 0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.5 + solid-js: 1.9.12 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opentui/solid@0.4.5(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0 + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0) + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.12(@babel/core@7.28.0)(solid-js@1.9.12) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.12 + transitivePeerDependencies: + - supports-color + - typescript + - web-tree-sitter + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -6426,7 +7177,7 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/runtime': 7.29.2 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -6459,7 +7210,7 @@ snapshots: '@tufjs/models@4.1.0': dependencies: '@tufjs/canonical-json': 2.0.0 - minimatch: 10.2.2 + minimatch: 10.2.5 '@types/aria-query@5.0.4': {} @@ -6817,6 +7568,30 @@ snapshots: b4a@1.8.0: {} + babel-plugin-jsx-dom-expressions@0.40.7(@babel/core@7.28.0): + dependencies: + '@babel/core': 7.28.0 + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-plugin-module-resolver@5.0.2: + dependencies: + find-babel-config: 2.1.2 + glob: 9.3.5 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.12 + + babel-preset-solid@1.9.12(@babel/core@7.28.0)(solid-js@1.9.12): + dependencies: + '@babel/core': 7.28.0 + babel-plugin-jsx-dom-expressions: 0.40.7(@babel/core@7.28.0) + optionalDependencies: + solid-js: 1.9.12 + balanced-match@1.0.2: {} balanced-match@4.0.3: {} @@ -6942,6 +7717,10 @@ snapshots: buildcheck@0.0.7: optional: true + bun-ffi-structs@0.2.4(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + bytes@3.1.2: {} c12@3.1.0: @@ -7161,6 +7940,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -7209,6 +7990,19 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + effect@4.0.0-beta.83: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 4.9.0 + find-my-way-ts: 0.1.6 + ini: 7.0.0 + kubernetes-types: 1.30.0 + msgpackr: 2.0.5 + multipasta: 0.2.8 + toml: 4.3.0 + uuid: 14.0.1 + yaml: 2.9.0 + electron-to-chromium@1.5.344: {} emoji-regex@10.6.0: {} @@ -7228,6 +8022,10 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + + entities@7.0.1: {} + entities@8.0.0: {} env-paths@2.2.1: {} @@ -7442,6 +8240,10 @@ snapshots: dependencies: pure-rand: 6.1.0 + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -7519,12 +8321,22 @@ snapshots: transitivePeerDependencies: - supports-color + find-babel-config@2.1.2: + dependencies: + json5: 2.2.3 + + find-my-way-ts@0.1.6: {} + find-my-way@9.4.0: dependencies: fast-deep-equal: 3.1.3 fast-querystring: 1.1.2 safe-regex2: 5.0.0 + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -7676,6 +8488,13 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + google-auth-library@10.9.1: dependencies: base64-js: 1.5.1 @@ -7711,6 +8530,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + helmet@7.2.0: {} highlight.js@10.7.3: {} @@ -7733,6 +8556,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -7805,6 +8630,8 @@ snapshots: ini@6.0.0: {} + ini@7.0.0: {} + ink@6.8.0(@types/react@19.2.14)(react@19.2.4): dependencies: '@alcalzone/ansi-tokenize': 0.2.5 @@ -7857,13 +8684,17 @@ snapshots: ipaddr.js@2.3.0: {} + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 is-glob@4.0.3: dependencies: @@ -7972,6 +8803,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stringify-nice@1.1.4: {} @@ -8005,6 +8838,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kubernetes-types@1.30.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8065,6 +8900,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -8124,6 +8964,8 @@ snapshots: marked@14.0.0: {} + marked@17.0.1: {} + marked@18.0.5: {} math-intrinsics@1.1.0: {} @@ -8166,6 +9008,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@8.0.7: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -8200,6 +9046,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.3: {} @@ -8230,6 +9078,24 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.4: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 + optional: true + + msgpackr@2.0.5: + optionalDependencies: + msgpackr-extract: 3.0.4 + + multipasta@0.2.8: {} + mute-stream@2.0.0: {} nan@2.25.0: @@ -8282,6 +9148,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 @@ -8415,10 +9286,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -8430,6 +9309,8 @@ snapshots: '@types/retry': 0.12.0 retry: 0.13.1 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} pacote@21.5.1: @@ -8460,6 +9341,10 @@ snapshots: just-diff: 6.0.2 just-diff-apply: 5.5.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -8470,12 +9355,21 @@ snapshots: patch-console@2.0.0: {} + path-exists@3.0.0: {} + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} path-key@3.1.1: {} + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.2.6 @@ -8519,6 +9413,10 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + postcss-selector-parser@7.1.4: dependencies: cssesc: 3.0.0 @@ -8600,6 +9498,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -8680,8 +9580,17 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@4.1.8: {} + resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@4.0.0: dependencies: onetime: 5.1.2 @@ -8755,6 +9664,8 @@ snapshots: dependencies: tslib: 2.8.1 + s-js@0.4.9: {} + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -8795,6 +9706,12 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + + seroval@1.5.6: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -8918,6 +9835,12 @@ snapshots: ip-address: 10.0.1 smart-buffer: 4.2.0 + solid-js@1.9.12: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -8998,7 +9921,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.5.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.1.2 string-width@8.2.0: @@ -9035,6 +9958,8 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} tagged-tag@1.0.0: {} @@ -9140,6 +10065,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -9227,6 +10154,8 @@ snapshots: uuid@10.0.0: {} + uuid@14.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -9299,6 +10228,8 @@ snapshots: web-streams-polyfill@3.3.3: {} + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -9405,4 +10336,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.1.8): + dependencies: + zod: 4.1.8 + zod@3.25.76: {} + + zod@4.1.8: {} diff --git a/scripts/build-deb.sh b/scripts/build-deb.sh index e8b1d07..5d0013d 100755 --- a/scripts/build-deb.sh +++ b/scripts/build-deb.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Same guard as build-rpm.sh: this script is also run on its own, so it cannot +# rely on that one having checked. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/build-mcpd.sh b/scripts/build-mcpd.sh index 08a7f3a..99ece3a 100755 --- a/scripts/build-mcpd.sh +++ b/scripts/build-mcpd.sh @@ -16,6 +16,11 @@ if [ -f .env ]; then set -a; source .env; set +a fi +# This pushes an image to the registry, so the same staleness gate as the package +# builds applies. Run standalone as well as from deploy-k8s.sh, hence its own copy. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Push directly to internal address (external proxy has body size limit) REGISTRY="10.0.0.194:3012" IMAGE="mcpd" diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh index f2db0f1..2f45a98 100755 --- a/scripts/build-rpm.sh +++ b/scripts/build-rpm.sh @@ -19,6 +19,11 @@ source "$SCRIPT_DIR/arch-helper.sh" resolve_arch "${MCPCTL_TARGET_ARCH:-}" # Sets: NFPM_ARCH, BUN_TARGET, ARCH_SUFFIX +# Before anything expensive: a branch behind main packages a binary missing +# whatever landed there, and installing it silently downgrades the machine. +source "$SCRIPT_DIR/check-main-sync.sh" +check_main_sync + # Check and install missing build dependencies source "$SCRIPT_DIR/ensure-deps.sh" ensure_build_deps diff --git a/scripts/check-main-sync.sh b/scripts/check-main-sync.sh new file mode 100755 index 0000000..05c0e3f --- /dev/null +++ b/scripts/check-main-sync.sh @@ -0,0 +1,101 @@ +#!/bin/bash +# Refuse to build a package from a branch that main has already moved past. +# +# WHY +# +# Everyone branches off main and builds from their own branch. A branch that is +# behind main still builds and installs perfectly — it just quietly ships a +# binary missing whatever landed on main in the meantime, and `rpm -U --force` +# overwrites the good one with it. +# +# That is not hypothetical: on 2026-08-10 a build from a stale checkout replaced +# /usr/bin/mcpctl with one that had no `statusline` command at all, months after +# the status line landed on main. Nothing reported an error — the release +# succeeded, the feature just vanished from the installed CLI. +# +# So this is a hard failure rather than a warning. A warning scrolls past in a +# build log; the whole point is to stop before the artifact exists. +# +# ESCAPE HATCH +# +# MCPCTL_ALLOW_BEHIND_MAIN=1 build anyway (deliberate build of an old tree) +# MCPCTL_BASE_BRANCH= compare against something other than main +# +# Skips itself entirely outside a git checkout, so tarball builds still work. + +# Resolve the ref to compare against, echoing it on stdout. Prefers a fresh +# fetch; falls back to whatever is already on disk so an offline build is +# degraded rather than blocked. Returns 1 when there is nothing to compare to. +_main_sync_ref() { + local base="$1" remote="$2" + + if [ -n "$remote" ] && git fetch --quiet "$remote" "$base" 2>/dev/null; then + # FETCH_HEAD rather than refs/remotes//: it is what this fetch + # just wrote, so it cannot be a stale opportunistic update. + echo "FETCH_HEAD" + return 0 + fi + + if [ -n "$remote" ] && git rev-parse --verify --quiet "refs/remotes/$remote/$base" >/dev/null; then + echo " (could not reach $remote — comparing against the last fetched $remote/$base)" >&2 + echo "refs/remotes/$remote/$base" + return 0 + fi + + if git rev-parse --verify --quiet "refs/heads/$base" >/dev/null; then + echo " (no reachable remote — comparing against local $base)" >&2 + echo "refs/heads/$base" + return 0 + fi + + return 1 +} + +check_main_sync() { + local base="${MCPCTL_BASE_BRANCH:-main}" + + if ! git rev-parse --git-dir >/dev/null 2>&1; then + return 0 # not a checkout; nothing to be behind + fi + + if [ "${MCPCTL_ALLOW_BEHIND_MAIN:-}" = "1" ]; then + echo "==> Skipping the '$base' sync check (MCPCTL_ALLOW_BEHIND_MAIN=1)" + return 0 + fi + + echo "==> Checking this branch is not behind '$base'..." + + local remote ref + remote="$(git remote | head -1)" + if ! ref="$(_main_sync_ref "$base" "$remote")"; then + echo " (no '$base' branch found anywhere — skipping)" + return 0 + fi + + local behind + behind="$(git rev-list --count "HEAD..$ref" 2>/dev/null || echo 0)" + if [ "$behind" -eq 0 ]; then + echo " up to date with $base" + return 0 + fi + + local branch + branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)" + echo "" >&2 + echo "ERROR: '$branch' is $behind commit(s) behind $base — refusing to build." >&2 + echo "" >&2 + # Deliberately artifact-agnostic: the same helper gates RPM/DEB packages, the + # mcpd image, and the k8s deploy. + echo " Building now would produce an artifact without these, and shipping it" >&2 + echo " would replace a good one with a version missing them:" >&2 + echo "" >&2 + git log --oneline --no-decorate "HEAD..$ref" | head -15 | sed 's/^/ /' >&2 + if [ "$behind" -gt 15 ]; then + echo " … and $((behind - 15)) more" >&2 + fi + echo "" >&2 + echo " Fix it: git merge $base # or: git rebase $base" >&2 + echo " Anyway: MCPCTL_ALLOW_BEHIND_MAIN=1 $0" >&2 + echo "" >&2 + return 1 +} diff --git a/scripts/deploy-k8s.sh b/scripts/deploy-k8s.sh index e41efad..eff872a 100755 --- a/scripts/deploy-k8s.sh +++ b/scripts/deploy-k8s.sh @@ -80,6 +80,20 @@ cat < Running smoke tests..." export PATH="$HOME/.npm-global/bin:$PATH" if pnpm test:smoke; then echo "==> Smoke tests passed!" +elif [ "${MCPCTL_ALLOW_SMOKE_FAILURE:-}" = "1" ]; then + echo "==> WARNING: Smoke tests failed, continuing (MCPCTL_ALLOW_SMOKE_FAILURE=1)." else - echo "==> WARNING: Smoke tests failed! Check mcplocal/mcpd are running." - echo " Continuing anyway — deployment is complete, but verify manually." + # This used to print a warning and exit 0. That is how four broken readiness + # probes shipped unnoticed on 2026-08-10: the warning scrolled past in the + # build log and the release reported success. A failing smoke run means + # something in the live fleet is genuinely broken — say so in the exit code. + # + # Note what this does and does not do: smoke runs LAST, against the installed + # binary, so the package is already published and installed by now. Failing + # here reports the breakage, it does not prevent it — investigate, do not + # assume the artifact is bad. + echo "" >&2 + echo "ERROR: smoke tests failed — the release is published and installed, but" >&2 + echo " something in the live fleet is broken. Investigate before relying" >&2 + echo " on this build; do not just re-run." >&2 + echo "" >&2 + echo " Common causes: mcplocal/mcpd not running, a readiness probe pointing at" >&2 + echo " a tool the upstream renamed, or an expired credential." >&2 + echo " Override: MCPCTL_ALLOW_SMOKE_FAILURE=1 $0" >&2 + echo "" >&2 + exit 1 fi echo "" diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 1fe74e1..d3bbb2a 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -5,7 +5,8 @@ import { z } from 'zod'; import type { ApiClient } from '../api-client.js'; const HealthCheckSchema = z.object({ - tool: z.string().min(1), + /** Omit for a liveness-only probe (reports `live`); set it for readiness (`healthy`). */ + tool: z.string().min(1).optional(), arguments: z.record(z.unknown()).default({}), intervalSeconds: z.number().int().min(5).max(3600).default(60), timeoutSeconds: z.number().int().min(1).max(120).default(10), diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 628642b..864cf46 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -70,7 +70,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command { } /** What the chat is bound to: a named Agent or a Project. */ -interface ChatSubject { +export interface ChatSubject { kind: 'agent' | 'project'; name: string; /** URL segment, e.g. `agents/reviewer` or `projects/sre` (name url-encoded). */ @@ -97,14 +97,17 @@ function resolveSubject(agent: string | undefined, opts: ChatOpts): ChatSubject * `personality` overlay (the project schema rejects unknown fields) and adds * `allowSecrets` when requested. */ -function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record { +export function chatBody(subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, stream?: boolean): Record { const o: Record = { ...overrides }; if (subject.kind === 'project') { delete o.personality; if (subject.allowSecrets) o.allowSecrets = true; } const body: Record = { message, ...o }; - if (threadId !== undefined) body.threadId = threadId; + // Guard the empty string, not just undefined: a turn that dies before its + // `final` frame yields no thread id, and sending `threadId: ""` trips mcpd's + // min(1) validation — bricking every later message in the REPL with a 400. + if (threadId !== undefined && threadId !== '') body.threadId = threadId; if (stream === true) body.stream = true; return body; } @@ -205,7 +208,11 @@ async function runOneShot( const bar = installStatusBar(); try { const finalThread = await streamOnce(deps, subject, message, threadId, overrides, bar); - process.stderr.write(`\n(thread: ${finalThread})\n`); + if (finalThread !== undefined) { + process.stderr.write(`\n(thread: ${finalThread})\n`); + } else { + process.stderr.write('\n'); + } } finally { bar?.teardown(); } @@ -262,7 +269,9 @@ async function runRepl( const answered = formatAnswered(res.llm, res.model, res.failedOver); if (answered !== '') process.stderr.write(`${styleStats(`(${answered})`)}\n`); } else { - threadId = await streamOnce(deps, subject, line, threadId, overrides, bar); + // A failed turn resolves undefined — keep the previous thread (or + // none) instead of overwriting it, so the next message still works. + threadId = await streamOnce(deps, subject, line, threadId, overrides, bar) ?? threadId; process.stdout.write('\n'); } } catch (err) { @@ -502,15 +511,21 @@ async function chatRequestNonStream( }); } -/** Stream a single chat call. Returns the resolved threadId. */ -async function streamOnce( +/** + * Stream a single chat call. Returns the resolved threadId, or undefined when + * the turn never produced a `final` frame (upstream error, early disconnect). + * Returning undefined — instead of the old '' — lets callers keep their + * previous thread state rather than poisoning the next request with an empty + * id that mcpd's validation rejects. + */ +export async function streamOnce( deps: ChatCommandDeps, subject: ChatSubject, message: string, threadId: string | undefined, overrides: Overrides, bar: StatusBar | null = null, -): Promise { +): Promise { const url = new URL(`${deps.baseUrl}/api/v1/${subject.path}/chat`); const body = JSON.stringify(chatBody(subject, message, threadId, overrides, true)); @@ -531,7 +546,7 @@ async function streamOnce( } } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const driver = url.protocol === 'https:' ? https : http; const req = driver.request({ hostname: url.hostname, @@ -552,7 +567,7 @@ async function streamOnce( return; } let buf = ''; - let resolvedThread = threadId ?? ''; + let resolvedThread: string | undefined = threadId; let answered = ''; res.setEncoding('utf-8'); res.on('data', (chunk: string) => { diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 676a5e7..917d551 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs'; +import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'; import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; @@ -34,6 +34,34 @@ import { isMcpctlToken, } from '../config/prime-agent.js'; import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js'; +import { + installStatusLine, + installSlashCommand, + claudeConfigDir, + STATUSLINE_COMMAND, +} from '../utils/claude-ui.js'; +import { + MCPCTL_SERVER_NAME, + mergeMcpctlServers, + mergeUserScopeServer, + userScopeProject, + activeProjectIn, + canonicalProjectIn, + legacyEntriesIn, + claudeJsonPath, + type McpJson, + type ClaudeJson, +} from '../config/claude-mcp.js'; +import { + opencodeConfigDir, + opencodeStatePath, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../utils/opencode-settings.js'; import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; /** @@ -44,8 +72,76 @@ import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js'; */ const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; -interface McpConfig { - mcpServers: Record }>; +/** Same, for the tokens `config opencode` mints. */ +const OPENCODE_TOKEN_PREFIX = 'opencode'; + +/** + * Read an existing `.mcp.json`. A missing or unparseable file yields null, and + * the caller starts fresh — the same behaviour as before, kept because a + * half-written file must not stop you re-provisioning. + */ +function readJsonFile(path: string): T | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as T; + } catch { + return null; + } +} + +/** Write JSON through a temp file + rename, so a crash cannot truncate it. */ +function writeJsonAtomicSync(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n'); + renameSync(tmp, path); +} + +function readMcpJson(path: string): McpJson | null { + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, 'utf-8')) as McpJson; + } catch { + return null; + } +} + +/** + * Warnings about a `.mcp.json` in `dir` that contradicts a user-scope switch to + * `project`. + * + * Claude Code merges the two scopes rather than picking one, so a + * directory-scoped entry does not go away when you switch globally: + * - a canonical `mcpctl` entry shares the name, and project scope wins — the + * switch has no effect in this directory at all; + * - a legacy project-named entry has a *different* name, so it is simply + * mounted alongside and the old project keeps answering here. + * Either way the user is owed the file path, because nothing else will tell + * them. Exported for tests. + */ +export function shadowWarnings(dir: string, project: string | undefined): string[] { + if (project === undefined || project === '') return []; + const path = join(dir, '.mcp.json'); + const parsed = readMcpJson(path); + if (parsed === null) return []; + + const pinned = canonicalProjectIn(parsed); + if (pinned !== null && pinned !== project) { + return [ + `Warning: ${path} pins '${MCPCTL_SERVER_NAME}' to '${pinned}' for this directory, which overrides the switch here.`, + ` Re-run with --scope project to repoint it, or delete the '${MCPCTL_SERVER_NAME}' entry to follow the user-scope project.`, + ]; + } + + const stale = legacyEntriesIn(parsed).filter((e) => e.project !== project); + if (stale.length > 0) { + const names = stale.map((e) => `'${e.server}'`).join(', '); + return [ + `Warning: ${path} still registers ${names} for this directory — mounted alongside '${project}', not replaced by it.`, + ` Re-run with --scope project to retire ${stale.length === 1 ? 'it' : 'them'}, or delete the ${stale.length === 1 ? 'entry' : 'entries'} by hand.`, + ]; + } + return []; } export interface ConfigCommandDeps { @@ -53,6 +149,8 @@ export interface ConfigCommandDeps { log: (...args: string[]) => void; /** API client for the skills sync side-effect of `config claude --project`. Optional so existing call sites work; without it we skip the sync step. */ apiClient?: ApiClient; + /** Working directory to check for a shadowing `.mcp.json`. Injectable so tests need not chdir. */ + cwd?: () => string; } export interface ConfigApiDeps { @@ -68,6 +166,7 @@ const defaultDeps: ConfigCommandDeps = { export function createConfigCommand(deps?: Partial, apiDeps?: ConfigApiDeps): Command { const { configDeps, log } = { ...defaultDeps, ...deps }; + const cwd = deps?.cwd ?? ((): string => process.cwd()); // PR-5: api client used by `mcpctl config claude --project` to run the // initial skills sync after wiring the .mcp.json. Threaded through from // index.ts; falls back to apiDeps.client when not explicitly passed (the @@ -76,6 +175,118 @@ export function createConfigCommand(deps?: Partial, apiDeps?: const config = new Command('config').description('Manage mcpctl configuration'); + // ── shared credential plumbing ───────────────────────────────────────────── + // `config prime-agent` and `config opencode` both provision an mcptoken for + // the project they mount and retire the one they replace. The rules are + // identical; only the token *name* differs, so the agent label is a + // parameter rather than a second copy of this logic. + + interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } + + /** The project's tokens, or null when the API can't be consulted. */ + async function listProjectTokens(project: string): Promise { + if (!skillsClient) return null; + try { + const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); + return Array.isArray(list) ? list as ProjectToken[] : null; + } catch { + return null; + } + } + + /** + * Is the credential we already have for this project still usable? + * + * A key being *present* proves nothing — a revoked or expired token would + * short-circuit provisioning and leave the agent silently unable to reach + * the gateway while the command reported success. mcptokens are only ever + * shown once, so we compare the stored token's 16-char `tokenPrefix` + * against the project's *active* tokens instead of sending the secret. + * + * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some + * other kind), or an unreachable API all mean "keep what's there" rather + * than minting a duplicate on every run. + */ + async function hasUsableCredential(project: string, key: string | null): Promise { + if (key === null) return false; + if (!skillsClient || !isMcpctlToken(key)) return true; + const tokens = await listProjectTokens(project); + if (tokens === null) return true; // can't check → don't churn credentials + const prefix = mcpTokenPrefixOf(key); + const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); + if (!live) { + log(`Stored credential for '${project}' is no longer active — minting a replacement`); + } + return live; + } + + /** + * Retire the token this install used to hold, now that `keepToken` has + * replaced it on disk. + * + * Scoped to that one credential on purpose. Sweeping every `` token + * for the project would revoke the one a *different* install is using — + * another machine, or this machine when the run targeted a custom output + * path. Anything else that looks orphaned is reported, not deleted: an + * unnecessary token costs nothing, a revoked one costs a broken install. + * Best-effort throughout, and only ever called once the replacement is + * safely stored. + */ + async function retireSupersededToken( + agent: string, + project: string, + staleKey: string | null, + keepToken: string, + ): Promise { + if (!skillsClient) return; + const keepPrefix = mcpTokenPrefixOf(keepToken); + const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; + if (stalePrefix === keepPrefix) return; + const tokens = await listProjectTokens(project); + if (tokens === null) return; + + const orphans: string[] = []; + for (const t of tokens) { + if (typeof t.id !== 'string' || t.status !== 'active') continue; + if (t.tokenPrefix === keepPrefix) continue; + // Only ever consider tokens minted for this purpose. + const name = t.name ?? ''; + if (name !== agent && !name.startsWith(`${agent}-`)) continue; + if (t.tokenPrefix === stalePrefix) { + try { + await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); + log(`Revoked the superseded '${name}' token for '${project}'`); + } catch { /* best-effort */ } + } else { + orphans.push(name); + } + } + if (orphans.length > 0) { + log(`Note: '${project}' still has other ${agent} token(s): ${orphans.join(', ')}. ` + + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); + } + } + + /** + * Mint a fresh mcptoken for `project`. + * + * A unique `-` name every time: `McpToken` is unique on + * (name, projectId) and revoke is a soft delete, so a fixed name could only + * ever be minted once per project. + */ + async function mintProjectToken(agent: string, project: string): Promise { + if (!skillsClient) return null; + const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; + const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { + name: `${agent}-${stamp}`, + projectName: project, + ttl: 'never', + description: `mcpctl proxy MCP credential for ${agent} (${new Date().toISOString()})`, + }); + return typeof minted?.token === 'string' && minted.token.length > 0 ? minted.token : null; + } + + config .command('view') .description('Show current configuration') @@ -132,67 +343,136 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .command(name) .description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook') .option('-p, --project ', 'Project name') - .option('-o, --output ', 'Output file path', '.mcp.json') + .option('--scope ', 'Where to register the MCP server: user (every directory) or project (this .mcp.json)', 'user') + .option('-o, --output ', 'Project-scope output file path (implies --scope project)', '.mcp.json') .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') - .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean }) => { + .option('--skip-marker', 'Do not write a .mcpctl-project marker next to the output file') + .option('--skip-ui', 'Do not install the status line or the /mcpctl slash command') + .option('--claude-dir ', 'Override Claude Code\'s config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { project?: string; scope: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean; skipMarker?: boolean; skipUi?: boolean; claudeDir?: string; dryRun?: boolean }, command: Command) => { + // Resolve Claude's config dir once: an explicit --claude-dir wins, then + // $CLAUDE_CONFIG_DIR, then ~/.claude. Threading it explicitly (rather + // than letting each helper default) is what keeps the test suite off the + // developer's real ~/.claude. + const claudeDir = opts.claudeDir !== undefined ? resolve(opts.claudeDir) : claudeConfigDir(); + const claudeSettings = join(claudeDir, 'settings.json'); + const claudeCommand = join(claudeDir, 'commands', 'mcpctl.md'); if (!opts.project && !opts.inspect) { log('Error: at least one of --project or --inspect is required'); process.exitCode = 1; return; } - const servers: McpConfig['mcpServers'] = {}; - if (opts.project) { - servers[opts.project] = { - command: 'mcpctl', - args: ['mcp', '-p', opts.project], - }; + // An explicit --output only makes sense for the per-directory file, so + // it selects project scope on its own — no need to pass both. + // Commander's source tracking, not process.argv: the latter is the test + // runner's command line when the command is driven in-process. + const explicitOutput = command.getOptionValueSource('output') === 'cli'; + const scope = explicitOutput ? 'project' : opts.scope; + if (scope !== 'user' && scope !== 'project') { + log(`Error: unknown --scope '${scope}' (expected 'user' or 'project')`); + process.exitCode = 1; + return; } - if (opts.inspect) { - servers['mcpctl-inspect'] = { - command: 'mcpctl', - args: ['console', '--stdin-mcp'], - }; + const userScope = scope === 'user'; + + const outputPath = userScope ? claudeJsonPath() : resolve(opts.output); + const existing = userScope + ? (readJsonFile(outputPath) ?? {}) + : readMcpJson(outputPath); + + // `--inspect` is a project-scope idea (a debugging server you turn on + // for one checkout), so it stays on .mcp.json even in user scope. + let finalConfig: McpJson | ClaudeJson; + let retired: string[]; + if (userScope) { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for user scope (--scope project for an --inspect-only .mcp.json)'); + process.exitCode = 1; + return; + } + ({ config: finalConfig, retired } = mergeUserScopeServer(existing as ClaudeJson, opts.project)); + } else { + ({ config: finalConfig, retired } = mergeMcpctlServers(existing as McpJson, { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.inspect !== undefined ? { inspect: opts.inspect } : {}), + })); } - if (opts.stdout) { - log(JSON.stringify({ mcpServers: servers }, null, 2)); + if (opts.stdout === true) { + log(JSON.stringify(finalConfig, null, 2)); return; } - const outputPath = resolve(opts.output); - let finalConfig: McpConfig = { mcpServers: servers }; - - // Always merge with existing .mcp.json — never overwrite other servers - if (existsSync(outputPath)) { - try { - const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig; - finalConfig = { - mcpServers: { - ...existing.mcpServers, - ...servers, - }, - }; - } catch { - // If existing file is invalid, start fresh - } + if (opts.dryRun === true) { + log(JSON.stringify({ + claude: { + scope, + output: outputPath, + previousProject: userScope + ? userScopeProject(existing as ClaudeJson) + : activeProjectIn(existing as McpJson), + server: MCPCTL_SERVER_NAME, + entry: finalConfig.mcpServers?.[MCPCTL_SERVER_NAME] ?? '', + retiredLegacyEntries: retired, + marker: opts.skipMarker === true || opts.project === undefined + ? '' + : join(dirname(outputPath), '.mcpctl-project'), + skills: opts.skipSkills === true ? '' : 'sync + SessionStart hook', + statusLine: opts.skipUi === true ? '' : `${claudeSettings} (${STATUSLINE_COMMAND})`, + slashCommand: opts.skipUi === true ? '' : claudeCommand, + }, + action: 'merge .mcp.json (one `mcpctl` entry, project behind it) + marker + skills sync + hook', + }, null, 2)); + return; } - writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); - const serverCount = Object.keys(finalConfig.mcpServers).length; - log(`Wrote ${outputPath} (${serverCount} server(s))`); + // Atomic: `.claude.json` also holds Claude Code's onboarding state and + // per-project map, and Claude Code rewrites it while running — a + // truncated write there costs far more than a stale MCP entry. + writeJsonAtomicSync(outputPath, finalConfig); + const serverCount = Object.keys(finalConfig.mcpServers ?? {}).length; + log(userScope + ? `Registered '${MCPCTL_SERVER_NAME}' for every directory in ${outputPath}` + : `Wrote ${outputPath} (${String(serverCount)} server(s))`); + if (retired.length > 0) { + // Before the constant name, every project you configured stayed + // mounted alongside the new one. + log(`Retired legacy per-project entr${retired.length === 1 ? 'y' : 'ies'}: ${retired.join(', ')}`); + } + if (opts.project !== undefined) { + log(`Reconnect the '${MCPCTL_SERVER_NAME}' server from /mcp to pick this up without restarting Claude Code.`); + } + if (userScope) { + // The whole point of user scope: you do this once, not per checkout. + log('This applies in every directory — no need to re-run it per repo.'); + // ...except where a directory-scoped entry contradicts it. That file + // is never rewritten by a user-scope switch, so staying silent is how + // a switch ends up looking like it did nothing: the status line keeps + // naming the old project, and its server keeps answering here. + for (const line of shadowWarnings(cwd(), opts.project)) log(line); + } // PR-5: write project marker, run initial skills sync, install // SessionStart hook. Skipped when --inspect-only or --skip-skills. if (opts.project && !opts.skipSkills) { const projectDir = dirname(outputPath); - try { - const markerPath = await writeProjectMarker(projectDir, opts.project); - log(`Wrote ${markerPath}`); - } catch (err: unknown) { - log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + if (userScope) { + // User scope deliberately scopes nothing to a directory; writing a + // marker into $HOME would silently scope every repo under it. + log('Skipped .mcpctl-project marker (user scope is not directory-specific)'); + } else if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else { + try { + const markerPath = await writeProjectMarker(projectDir, opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } } if (skillsClient) { @@ -211,12 +491,34 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } try { - const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet'); + const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet', claudeSettings); log(updated ? `Installed SessionStart hook in ${settingsPath}` : `SessionStart hook already up to date in ${settingsPath}`); } catch (err: unknown) { log(`Warning: failed to install SessionStart hook: ${err instanceof Error ? err.message : String(err)}`); } } + + // The UI bits are independent of --skip-skills: they are how you see + // and change the project, not how skills get there. + if (opts.project !== undefined && opts.skipUi !== true) { + try { + const outcome = await installStatusLine(claudeSettings); + if (outcome.status === 'installed') log(`Installed the active-project status line in ${claudeSettings}`); + else if (outcome.status === 'already') log('Status line already up to date'); + else { + // Never clobber a status line someone built. + log(`Left your existing status line alone (${outcome.command}).`); + log(` To show the project too, append: $(${STATUSLINE_COMMAND})`); + } + } catch (err: unknown) { + log(`Warning: failed to install the status line: ${err instanceof Error ? err.message : String(err)}`); + } + try { + log(`Installed the /mcpctl switcher: ${await installSlashCommand(claudeCommand)}`); + } catch (err: unknown) { + log(`Warning: failed to install the /mcpctl command: ${err instanceof Error ? err.message : String(err)}`); + } + } }); if (hidden) { // Commander shows empty-description commands but they won't clutter help output @@ -299,7 +601,7 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // Only when we actually replaced something: `--token` with a fresh // auth.json must stay entirely offline, as documented. if (staleKey !== null) { - await retireSupersededToken(opts.project, staleKey, opts.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, opts.token); } } else if (await hasUsableCredential(opts.project, staleKey)) { log(`Bearer credential for '${opts.project}' already present in ${authPath}`); @@ -309,18 +611,12 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // (name, projectId) and revoke is a soft delete, so reusing a fixed // name would collide with the revoked row forever. Retire the old // tokens only *after* the replacement is safely on disk. - const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`; - const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', { - name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`, - projectName: opts.project, - ttl: 'never', - description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`, - }); - if (typeof minted?.token === 'string' && minted.token.length > 0) { - await writePrimeAgentAuth(opts.project, minted.token, authPath); + const minted = await mintProjectToken(PRIME_AGENT_TOKEN_PREFIX, opts.project); + if (minted !== null) { + await writePrimeAgentAuth(opts.project, minted, authPath); log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); provisioned = true; - await retireSupersededToken(opts.project, staleKey, minted.token); + await retireSupersededToken(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, minted); } else { log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); } @@ -417,86 +713,6 @@ export function createConfigCommand(deps?: Partial, apiDeps?: void cmd; } - /** - * Is the credential already in auth.json still usable? - * - * A key being *present* proves nothing — a revoked or expired token would - * short-circuit provisioning and leave prime-agent silently unable to reach - * the gateway while the command reported success. mcptokens are only ever - * shown once, so we compare the stored token's 16-char `tokenPrefix` - * against the project's *active* tokens instead of sending the secret. - * - * Fails open: no client, a non-mcpctl token (a user-supplied PAT of some - * other kind), or an unreachable API all mean "keep what's there" rather - * than minting a duplicate on every run. - */ - async function hasUsableCredential(project: string, key: string | null): Promise { - if (key === null) return false; - if (!skillsClient || !isMcpctlToken(key)) return true; - const tokens = await listProjectTokens(project); - if (tokens === null) return true; // can't check → don't churn credentials - const prefix = mcpTokenPrefixOf(key); - const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix); - if (!live) { - log(`Stored credential for '${project}' is no longer active — minting a replacement`); - } - return live; - } - - /** - * Retire the token this auth.json used to hold, now that `keepToken` has - * replaced it on disk. - * - * Scoped to that one credential on purpose. Sweeping every `prime-agent` - * token for the project would revoke the one a *different* auth.json is - * using — another machine, or this machine when the run targeted a custom - * `--output`. Anything else that looks orphaned is reported, not deleted: - * an unnecessary token costs nothing, a revoked one costs a broken install. - * Best-effort throughout, and only ever called once the replacement is - * safely stored. - */ - async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise { - if (!skillsClient) return; - const keepPrefix = mcpTokenPrefixOf(keepToken); - const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null; - if (stalePrefix === keepPrefix) return; - const tokens = await listProjectTokens(project); - if (tokens === null) return; - - const orphans: string[] = []; - for (const t of tokens) { - if (typeof t.id !== 'string' || t.status !== 'active') continue; - if (t.tokenPrefix === keepPrefix) continue; - // Only ever consider tokens minted for this purpose. - const name = t.name ?? ''; - if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue; - if (t.tokenPrefix === stalePrefix) { - try { - await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); - log(`Revoked the superseded '${name}' token for '${project}'`); - } catch { /* best-effort */ } - } else { - orphans.push(name); - } - } - if (orphans.length > 0) { - log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. ` - + `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken --project ${project}\`.`); - } - } - - interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string } - - /** The project's tokens, or null when the API can't be consulted. */ - async function listProjectTokens(project: string): Promise { - if (!skillsClient) return null; - try { - const list = await skillsClient.get(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`); - return Array.isArray(list) ? list as ProjectToken[] : null; - } catch { - return null; - } - } } registerClaudeCommand('claude', false); @@ -511,7 +727,8 @@ export function createConfigCommand(deps?: Partial, apiDeps?: .option('--skip-skills', 'Skip the initial skills sync') .option('--settings ', 'pi settings.json path (default: ~/.pi/agent/settings.json)') .option('--pi-dir ', 'Override the pi agent home (default: ~/.pi/agent)') - .action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string }) => { + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { project?: string; extensionDir?: string; skipSkills?: boolean; settings?: string; piDir?: string; dryRun?: boolean }) => { if (!opts.project) { log('Error: --project is required for mcpctl config pi'); process.exitCode = 1; @@ -527,6 +744,20 @@ export function createConfigCommand(deps?: Partial, apiDeps?: // custom --pi-dir is used (also keeps tests off the real ~/.mcpctl). const statePath = piHome ? join(opts.piDir!, 'pi-state.json') : piStatePath(); + if (opts.dryRun === true) { + log(JSON.stringify({ + pi: { + settingsPath, + extensionDir: extDest, + statePath, + skillsDir: opts.skipSkills === true ? '' : skillsInstall, + source: opts.extensionDir ?? '', + }, + action: 'install extension files + register in settings.json (extensions + skills) + write active project + sync skills', + }, null, 2)); + return; + } + // 1. Install the extension files into ~/.pi/agent/extensions/mcpctl/. // Default: write the embedded sources (works from an installed binary // with no source tree). --extension-dir overrides with a source-tree @@ -590,6 +821,190 @@ export function createConfigCommand(deps?: Partial, apiDeps?: registerPrimeAgentCommand('prime-agent', false); registerPrimeAgentCommand('prime-agent-generate', true); // backward compat + // ── opencode: install the plugins + provision the gateway credential ── + // + // Unlike `config claude` / `config prime-agent`, this writes NO MCP entry into + // the host's own config: opencode gets a server plugin that mounts the gateway + // through the running server's MCP API, so the bearer token stays in a 0600 + // state file and switching projects needs no restart. See + // `utils/opencode-settings.ts` for the reasoning. + config + .command('opencode') + .description('Install the opencode plugins (/mcpctl switcher + footer indicator), provision the gateway token, sync skills') + .option('-p, --project ', 'Project name to make active') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--token ', 'mcpctl project bearer token to use (skips auto-minting)') + .option('--opencode-dir ', 'Override opencode\'s config dir (default: ~/.config/opencode)') + .option('--skip-skills', 'Skip the skills sync step') + .option('--skip-plugin', 'Do not (re)install or register the opencode plugins') + .option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory') + .option('--dry-run', 'Print what would change without writing or syncing') + .action(async (opts: { + project?: string; + gatewayUrl: string; + token?: string; + opencodeDir?: string; + skipSkills?: boolean; + skipPlugin?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required for mcpctl config opencode'); + process.exitCode = 1; + return; + } + const project = opts.project; + const configDir = opts.opencodeDir !== undefined ? resolve(opts.opencodeDir) : opencodeConfigDir(); + const paths = withOpencodeDir(configDir); + const gatewayBase = opts.gatewayUrl.replace(/\/+$/, ''); + // Isolate the state file under a custom --opencode-dir so tests (and + // side-by-side installs) never write the real ~/.mcpctl/opencode-state.json. + const statePath = opts.opencodeDir !== undefined + ? join(configDir, 'mcpctl-state.json') + : opencodeStatePath(); + + if (opts.dryRun === true) { + log(JSON.stringify({ + opencode: { + serverPlugin: opts.skipPlugin === true ? '' : paths.serverPluginPath(), + tuiPlugin: opts.skipPlugin === true ? '' : paths.tuiPluginPath(), + tuiJson: opts.skipPlugin === true ? '' : paths.tuiJsonPath(), + statePath, + mcpUrl: `${gatewayBase}/projects/${encodeURIComponent(project)}/mcp`, + skillsDir: opts.skipSkills === true ? '' : paths.skillsDir(), + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + }, + action: 'install plugins + register in tui.json + write 0600 state (project, gateway, bearer token) + sync skills', + }, null, 2)); + return; + } + + // 1. Provision the bearer credential the gateway needs. + // + // This runs FIRST and is fatal on failure: the state file is what the + // plugins mount from, so writing a project we have no usable token for + // would swap a working mount for a 401. Keeping the old state means the + // previously active project keeps working, and the `/mcpctl` switcher + // (which reads this command's exit code) reports the switch as failed + // instead of leaving the user staring at a project with no tools. + const priorState = await readOpencodeState(statePath); + const staleKey = storedToken(priorState, project); + let token: string | null = null; + try { + if (opts.token !== undefined && opts.token !== '') { + token = opts.token; + } else if (await hasUsableCredential(project, staleKey)) { + token = staleKey; + log(`Bearer credential for '${project}' already present in ${statePath}`); + } else { + token = await mintProjectToken(OPENCODE_TOKEN_PREFIX, project); + if (token === null) { + log(skillsClient + ? `Error: no token returned minting for '${project}'; pass --token to supply one` + : 'Error: no API client available to mint a project token — pass --token '); + } + } + } catch (err: unknown) { + log(`Error: could not provision bearer credential for '${project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (token === null || token === '') { + process.exitCode = 1; + log(`Aborted: leaving ${statePath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Write the state file (0600) — the single source of truth both + // plugins read for "which project, which gateway, which token". + try { + await writeOpencodeState({ project, gatewayUrl: gatewayBase, token }, statePath); + log(`Active project '${project}' → ${statePath} (${gatewayBase}/projects/${encodeURIComponent(project)}/mcp)`); + } catch (err: unknown) { + log(`Error: could not write ${statePath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + // Only once the replacement is safely on disk. + if (token !== staleKey) { + await retireSupersededToken(OPENCODE_TOKEN_PREFIX, project, staleKey, token); + } + + // 3. Install + register the plugins (skippable: the `/mcpctl` switcher + // re-runs this command in-process and must not rewrite the very file + // opencode has already loaded). + if (opts.skipPlugin !== true) { + try { + const written = await installOpencodePlugins(configDir); + log('Installed opencode plugins:'); + for (const w of written) log(` ${w}`); + } catch (err: unknown) { + log(`Error: could not install opencode plugins: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + try { + const { added } = await registerOpencodeTuiPlugin(paths.tuiJsonPath(), paths.tuiPluginPath()); + log(added + ? `Registered the TUI plugin in ${paths.tuiJsonPath()}` + : `TUI plugin already registered in ${paths.tuiJsonPath()}`); + } catch (err: unknown) { + // Non-fatal: without tui.json there is no /mcpctl command or footer + // indicator, but the server plugin still mounts the project's tools. + log(`Warning: could not update ${paths.tuiJsonPath()}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + // 4. Write the .mcpctl-project marker (same semantics as prime-agent: + // an explicit -p is authoritative, $HOME is never scoped, and the + // switcher opts out so it cannot re-scope an unrelated repo). + try { + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else if (process.cwd() !== homedir()) { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null && existing.project === project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${project}')` + : `Wrote ${markerPath}`); + } + } else { + log('Skipped .mcpctl-project marker (running from $HOME)'); + } + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // 5. Sync skills into opencode's skill dir (skippable). Best-effort: the + // mount is what determines whether the switch succeeded. + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runSkillsSync( + { project, target: 'opencode', installRoot: paths.skillsDir() }, + { client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + log(total > 0 + ? `Skills synced to ${paths.skillsDir()} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)` + : 'Skills: no changes (already up to date)'); + } catch (err: unknown) { + log(`Warning: skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else { + log('Warning: no API client available; skipping skills sync (run `mcpctl skills sync --agent opencode` separately)'); + } + } + + if (opts.skipPlugin !== true) { + log(''); + log('Next: restart opencode (or start a new session). Use /mcpctl to switch projects'); + log('without restarting; the active project shows in the prompt footer.'); + } + }); + config.addCommand(createConfigSetupCommand({ configDeps })); diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 1b252fe..cc5d720 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -42,6 +42,69 @@ export function buildFavouriteIndex( return result; } +export interface HealthCheckOpts { + healthCheckTool?: string; + healthCheckArgs?: string; + healthCheckInterval?: string; + healthCheckTimeout?: string; + healthCheckFailureThreshold?: string; +} + +export interface HealthCheckSpec { + tool?: string; + arguments?: Record; + intervalSeconds?: number; + timeoutSeconds?: number; + failureThreshold?: number; +} + +function parsePositiveInt(flag: string, value: string): number { + const n = Number(value); + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`Invalid ${flag} '${value}'. Expected a positive integer.`); + } + return n; +} + +/** + * Build a server `healthCheck` spec from `--health-check-*` flags, or undefined + * when none were given (so the field is only sent when intended). + * + * Mirrors the `healthCheck:` block accepted by `apply -f`, per the rule that + * everything applyable is also a create flag. + */ +export function buildHealthCheck(opts: HealthCheckOpts): HealthCheckSpec | undefined { + const { healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold } = opts; + const given = [healthCheckTool, healthCheckArgs, healthCheckInterval, healthCheckTimeout, healthCheckFailureThreshold] + .some((v) => v !== undefined); + if (!given) return undefined; + + if (healthCheckArgs !== undefined && healthCheckTool === undefined) { + throw new Error('--health-check-args requires --health-check-tool.'); + } + + const spec: HealthCheckSpec = {}; + if (healthCheckTool !== undefined) spec.tool = healthCheckTool; + if (healthCheckArgs !== undefined) { + let parsed: unknown; + try { + parsed = JSON.parse(healthCheckArgs); + } catch { + throw new Error(`Invalid --health-check-args: not valid JSON. Expected a JSON object, e.g. '{"site":"default"}'.`); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`Invalid --health-check-args: expected a JSON object, e.g. '{"site":"default"}'.`); + } + spec.arguments = parsed as Record; + } + if (healthCheckInterval !== undefined) spec.intervalSeconds = parsePositiveInt('--health-check-interval', healthCheckInterval); + if (healthCheckTimeout !== undefined) spec.timeoutSeconds = parsePositiveInt('--health-check-timeout', healthCheckTimeout); + if (healthCheckFailureThreshold !== undefined) { + spec.failureThreshold = parsePositiveInt('--health-check-failure-threshold', healthCheckFailureThreshold); + } + return spec; +} + /** * Parse a `--ttl` value. * @@ -184,6 +247,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--replicas ', 'Number of replicas') .option('--env ', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, []) .option('--volume ', 'Persistent volume: NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] (repeat for multiple)', collect, []) + .option('--health-check-tool ', 'Readiness probe: tool to call (without it the server only gets a liveness probe and reports "live", never "healthy")') + .option('--health-check-args ', 'Readiness probe: JSON object of arguments for the probe tool') + .option('--health-check-interval ', 'Readiness probe interval in seconds (default 60)') + .option('--health-check-timeout ', 'Readiness probe timeout in seconds (default 10)') + .option('--health-check-failure-threshold ', 'Consecutive failures before the instance is marked unhealthy (default 3)') .option('--from-template ', 'Create from template (name or name:version)') .option('--env-from-secret ', 'Map template env vars from a secret') .option('--force', 'Update if already exists') @@ -266,6 +334,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.externalUrl) body.externalUrl = opts.externalUrl; if (opts.command.length > 0) body.command = opts.command; if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10); + // Merge over any healthCheck inherited from --from-template so partial + // flags (e.g. only --health-check-interval) tune rather than replace it. + const healthCheck = buildHealthCheck(opts as HealthCheckOpts); + if (healthCheck) { + body.healthCheck = { ...(base.healthCheck as HealthCheckSpec | undefined), ...healthCheck }; + } if (opts.env.length > 0) { // Merge: CLI env entries override template env entries by name const cliEnv = parseServerEnv(opts.env); @@ -959,7 +1033,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const buf = await fs.readFile(full); // Reject non-UTF8 — v1 is text-only. const text = buf.toString('utf-8'); - if (text.includes('')) { + if (text.includes('\0')) { throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`); } files[rel] = text; diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 7bd6a62..a425e42 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -106,6 +106,12 @@ function formatInstanceDetail(instance: Record, inspect?: Recor lines.push('Health:'); lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`); if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`); + if (healthStatus === 'live') { + lines.push(` ${pad('Probe:', 16)}liveness (tools/list) — process is up, but nothing`); + lines.push(` ${pad('', 16)}calls the server's upstream. Configure a readiness`); + lines.push(` ${pad('', 16)}probe to reach 'healthy':`); + lines.push(` ${pad('', 16)} mcpctl edit server ${server?.name ?? ''} → healthCheck.tool`); + } } const metadata = instance.metadata as Record | undefined; diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts index 89f95e6..33708a9 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -33,6 +33,21 @@ import { parseMcpServerDeps, } from '../utils/mcpservers-materialiser.js'; import { ApiError } from '../api-client.js'; +import { opencodeSkillsDir } from '../utils/opencode-settings.js'; + +/** + * Every agent whose skill tree `mcpctl skills sync --agent` can write. + * + * `claude` is the only one with Claude-specific behaviour (SessionStart hooks, + * postInstall, mcpServers auto-attach); every other target is a shared flat + * tree with per-project ownership. + */ +export const SYNC_TARGETS = ['claude', 'prime-agent', 'pi', 'opencode'] as const; +export type SyncTarget = (typeof SYNC_TARGETS)[number]; + +export function isSyncTarget(value: string): value is SyncTarget { + return (SYNC_TARGETS as readonly string[]).includes(value); +} /** * `mcpctl skills sync` — materialise server-side skills onto disk under @@ -103,8 +118,10 @@ export interface SyncOpts { * configuring a second project never deletes the * first project's skills, and pre-existing * (untracked) skill dirs are preserved. + * 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in + * ~/.pi/agent/skills and opencode's `skill` dir. */ - target?: 'claude' | 'prime-agent' | 'pi'; + target?: SyncTarget; } export interface SyncResult { @@ -192,32 +209,23 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise (s.scope === 'global' ? null : (projectName ?? null)); - const statePath = opts.statePath ?? (isPrimeAgent - ? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json') - : isPi - ? join(homeDir, '.mcpctl', 'skills-state-pi.json') - : defaultStatePath()); + const statePath = opts.statePath + ?? (target === 'claude' ? defaultStatePath() : join(homeDir, '.mcpctl', `skills-state-${target}.json`)); const state = await loadState(statePath); // Which project last wrote this state file, captured before step 7 overwrites // it. Skills tracked by a CLI that predates ownership recording carry no // `project` field; this is the only evidence of who installed them. const priorSyncProject = state.lastSyncProject; - const installRoot = opts.installRoot ?? (isPrimeAgent - ? join(homeDir, '.prime', 'agent', 'skills') - : isPi - ? join(homeDir, '.pi', 'agent', 'skills') - : join(homeDir, '.claude', 'skills')); + const installRoot = opts.installRoot ?? agentInstallRoot(target, homeDir); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -566,17 +574,20 @@ export interface SkillsCommandDeps { * "claude" → ~/.claude/skills * "prime-agent" → ~/.prime/agent/skills * "pi" → ~/.pi/agent/skills - * Kept here so `mcpctl skills sync --agent` and `mcpctl config pi` agree. + * "opencode" → ~/.config/opencode/skill (XDG-aware) + * Kept here so `mcpctl skills sync --agent` and `mcpctl config ` agree. */ -export function agentInstallRoot(agent: string | undefined): string { +export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string { switch (agent) { case 'prime-agent': - return join(homedir(), '.prime', 'agent', 'skills'); + return join(homeDir, '.prime', 'agent', 'skills'); case 'pi': - return join(homedir(), '.pi', 'agent', 'skills'); + return join(homeDir, '.pi', 'agent', 'skills'); + case 'opencode': + return opencodeSkillsDir(process.env, homeDir); case 'claude': default: - return join(homedir(), '.claude', 'skills'); + return join(homeDir, '.claude', 'skills'); } } @@ -589,9 +600,9 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { const cmd = new Command('skills').description('Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)'); cmd.command('sync') - .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, or ~/.pi agent skill roots)') + .description('Sync skills from mcpd onto disk (~/.claude, ~/.prime, ~/.pi, or opencode skill roots)') .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') - .option('--agent ', 'Sync target: claude (default), prime-agent, or pi', 'claude') + .option('--agent ', 'Sync target: claude (default), prime-agent, pi, or opencode', 'claude') .option('--dry-run', 'Print what would change without writing anything') .option('--force', 'Overwrite locally-modified skills') .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') @@ -609,8 +620,8 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { // Validate --agent so an unknown value fails loudly instead of silently // running the default (Claude) sync. const agent = opts.agent ?? 'claude'; - if (agent !== 'claude' && agent !== 'prime-agent' && agent !== 'pi') { - warn(`mcpctl: unknown sync target '${agent}' (expected 'claude', 'prime-agent', or 'pi')`); + if (!isSyncTarget(agent)) { + warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`); process.exitCode = 1; return; } @@ -622,7 +633,7 @@ export function createSkillsCommand(deps: SkillsCommandDeps): Command { ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), - target: agent as 'claude' | 'prime-agent' | 'pi', + target: agent, installRoot: agentInstallRoot(agent), }, { client, log, warn }, diff --git a/src/cli/src/commands/statusline.ts b/src/cli/src/commands/statusline.ts new file mode 100644 index 0000000..9d04ef0 --- /dev/null +++ b/src/cli/src/commands/statusline.ts @@ -0,0 +1,163 @@ +import { Command } from 'commander'; +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { + MCPCTL_SERVER_NAME, + canonicalProjectIn, + claudeJsonPath, + disabledServersFor, + legacyEntriesIn, + userScopeProject, + type McpJson, + type ClaudeJson, +} from '../config/claude-mcp.js'; +import { findProjectMarker } from '../utils/project-marker.js'; + +/** + * `mcpctl statusline` — print the active mcpctl project, for Claude Code's + * `statusLine` setting. + * + * Claude Code has no plugin API that can draw into its UI, but it does run a + * command for the status line and render whatever that prints. This is that + * command: it is what gives Claude Code the same at-a-glance "which project am + * I in" that opencode gets from a footer slot and pi/prime-agent from + * `setStatus`. + * + * Claude Code pipes a JSON blob in on stdin (session id, model, workspace). We + * only need the directory; the project is then resolved from the most + * deliberate source that names one — see the ranking in the action below. + * Whatever it reports has to be a project that is genuinely mounted, so a + * server Claude Code has switched off for that directory is skipped. + * + * Prints nothing at all when no project is active: an empty status line is + * better than one that says "none" on every unrelated repo you open. + */ + +interface StatusLineInput { + workspace?: { current_dir?: string; project_dir?: string }; + cwd?: string; +} + +/** Read Claude Code's stdin payload. Absent or unparseable → no directory hint. */ +async function readStdinJson(): Promise { + if (process.stdin.isTTY === true) return {}; + const chunks: Buffer[] = []; + try { + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf-8').trim(); + if (raw.length === 0) return {}; + return JSON.parse(raw) as StatusLineInput; + } catch { + return {}; + } +} + +/** + * The directory whose project we should report. + * + * Claude Code's `current_dir` moves with `/cwd`, so it beats the process cwd + * (which is wherever the Claude Code binary was launched, often unrelated). + */ +export function resolveDirectory(input: StatusLineInput, fallback: string): string { + return input.workspace?.current_dir ?? input.workspace?.project_dir ?? input.cwd ?? fallback; +} + +/** Claude Code's user-scope config, or null if it is missing or unreadable. */ +export function readClaudeJson(path: string): ClaudeJson | null { + try { + return JSON.parse(readFileSync(path, 'utf-8')) as ClaudeJson; + } catch { + return null; + } +} + +/** The project Claude Code's user-scope config mounts, or null. */ +export function projectFromUserScope(doc: ClaudeJson | null): string | null { + return userScopeProject(doc); +} + +/** The `.mcp.json` in `dir`, or null if there isn't a readable one. */ +export function readDirMcpJson(dir: string): McpJson | null { + try { + return JSON.parse(readFileSync(join(dir, '.mcp.json'), 'utf-8')) as McpJson; + } catch { + return null; + } +} + +/** + * The project the canonical `mcpctl` entry in `dir`'s `.mcp.json` pins, or null + * — skipped when Claude Code has that server switched off for `dir`. + */ +export function projectFromDirPin(mcpJson: McpJson | null, disabled: Set): string | null { + if (disabled.has(MCPCTL_SERVER_NAME)) return null; + return canonicalProjectIn(mcpJson); +} + +/** + * The project a *legacy* project-named entry in `dir`'s `.mcp.json` mounts, or + * null. Disabled entries are skipped, so a leftover the user already turned off + * in `/mcp` stops being reported. + */ +export function projectFromDirLegacy(mcpJson: McpJson | null, disabled: Set): string | null { + return legacyEntriesIn(mcpJson).find((e) => !disabled.has(e.server))?.project ?? null; +} + +/** Format for the status line. Empty string means "render nothing". */ +export function formatStatus(project: string | null, prefix: string): string { + return project !== null && project !== '' ? `${prefix}${project}` : ''; +} + +export interface StatuslineDeps { + log: (line: string) => void; + cwd: () => string; + homeDir: () => string; +} + +export function createStatuslineCommand(deps?: Partial): Command { + const log = deps?.log ?? ((line: string): void => { process.stdout.write(line); }); + const cwd = deps?.cwd ?? ((): string => process.cwd()); + const homeDir = deps?.homeDir ?? homedir; + + return new Command('statusline') + .description('Print the active mcpctl project (for Claude Code\'s statusLine setting)') + .option('-d, --directory ', 'Directory to resolve the project for (default: from stdin, then cwd)') + .option('--prefix ', 'Text before the project name', 'mcpctl:') + .action(async (opts: { directory?: string; prefix: string }) => { + const input = opts.directory !== undefined ? {} : await readStdinJson(); + const dir = opts.directory !== undefined ? resolve(opts.directory) : resolveDirectory(input, cwd()); + + const claudeJson = readClaudeJson(claudeJsonPath()); + const mcpJson = readDirMcpJson(dir); + const disabled = disabledServersFor(claudeJson, dir); + + // Ranked by how deliberate each source is, because a switch has to be + // able to win: + // 1. a canonical `mcpctl` entry in this directory's .mcp.json — a + // deliberate pin, and the scope Claude Code itself prefers when both + // define the same server name; + // 2. user scope — what `config claude --project` writes, so switching + // projects must beat anything less deliberate than a pin; + // 3. a *legacy* project-named entry in .mcp.json. This used to outrank + // user scope, which made a switch look like it had done nothing: the + // residue an older mcpctl left in a checkout is not a pin, and never + // gets rewritten by a user-scope switch, so it reported the old + // project forever; + // 4. the .mcpctl-project marker — the other thing `config claude` + // writes, and what skills sync already trusts. + let project = + projectFromDirPin(mcpJson, disabled) + ?? projectFromUserScope(claudeJson) + ?? projectFromDirLegacy(mcpJson, disabled); + if (project === null) { + const marker = await findProjectMarker(dir, homeDir()).catch(() => null); + project = marker?.project ?? null; + } + + const line = formatStatus(project, opts.prefix); + // No trailing newline: Claude Code renders the output as one line, and a + // stray newline shows up as a blank second row. + if (line !== '') log(line); + }); +} diff --git a/src/cli/src/config/claude-mcp.ts b/src/cli/src/config/claude-mcp.ts new file mode 100644 index 0000000..2452e93 --- /dev/null +++ b/src/cli/src/config/claude-mcp.ts @@ -0,0 +1,248 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * `.mcp.json` shaping for `mcpctl config claude`. + * + * WHY A CONSTANT SERVER NAME + * + * The entry used to be named after the project (`homeautomation`, + * `docmost`, …). Because `.mcp.json` is *merged* rather than rewritten, running + * `config claude` for a second project left the first one mounted too: every + * project you had ever configured stayed connected, with duplicate tool names + * and no way to tell which one was "active". + * + * The entry is now always called `mcpctl`, and switching projects rewrites what + * is behind that name. Claude Code can reconnect an existing MCP server from + * inside a session (`/mcp`), so a switch takes effect without restarting the + * app — and the tool prefix (`mcpctl__*`) stays stable across switches, so the + * model never sees a tool namespace disappear. + * + * Legacy project-named entries this CLI wrote are retired on the next run; see + * `isLegacyMcpctlEntry` for what counts as ours. + */ + +/** + * WHERE THE ENTRY LIVES + * + * Claude Code has two MCP scopes: + * - **project** — `./.mcp.json`, which applies only in that directory (and is + * usually committed, so writing to it dirties the repo); + * - **user** — `mcpServers` in `.claude.json`, which applies in every + * directory and every window. + * + * mcpctl defaults to **user** scope, because one active project everywhere is + * how the pi, prime-agent and opencode integrations already behave — and + * because per-directory wiring means re-running `config claude` in every + * checkout you open. `--scope project` (or an explicit `--output`) keeps the + * old per-directory file for a repo that genuinely wants its own pinned + * project. + */ + +/** The one MCP server name mcpctl owns. */ +export const MCPCTL_SERVER_NAME = 'mcpctl'; + +/** Name of the optional traffic-inspection server (`--inspect`). */ +export const MCPCTL_INSPECT_SERVER_NAME = 'mcpctl-inspect'; + +export interface McpServerEntry { + command?: string; + args?: string[]; + url?: string; + env?: Record; + [key: string]: unknown; +} + +export interface McpJson { + mcpServers: Record; + [key: string]: unknown; +} + +/** The stdio-bridge entry that mounts `project`. */ +export function mcpctlStdioServer(project: string): McpServerEntry { + return { command: 'mcpctl', args: ['mcp', '-p', project] }; +} + +/** The `--inspect` traffic monitor entry. */ +export function mcpctlInspectServer(): McpServerEntry { + return { command: 'mcpctl', args: ['console', '--stdin-mcp'] }; +} + +/** + * The project an entry bridges to, or null if it is not an mcpctl stdio bridge. + * + * Reads it straight out of `args` rather than a bookkeeping key, so nothing + * non-standard is written into a file Claude Code owns. + */ +export function projectOfEntry(entry: unknown): string | null { + if (entry === null || typeof entry !== 'object') return null; + const rec = entry as McpServerEntry; + if (rec.command !== 'mcpctl' || !Array.isArray(rec.args)) return null; + const args = rec.args; + if (args[0] !== 'mcp') return null; + const flag = args.indexOf('-p') >= 0 ? args.indexOf('-p') : args.indexOf('--project'); + if (flag < 0) return null; + const project = args[flag + 1]; + return typeof project === 'string' && project !== '' ? project : null; +} + +/** + * Is `name` an entry an older mcpctl wrote — i.e. named after the very project + * its command bridges to? + * + * That pairing is what makes retiring it safe. A server someone configured by + * hand would have to be named exactly after the project it bridges to *and* run + * our command to be mistaken for one of ours, at which point it is functionally + * the same entry anyway. + */ +export function isLegacyMcpctlEntry(name: string, entry: unknown): boolean { + if (name === MCPCTL_SERVER_NAME) return false; + return projectOfEntry(entry) === name; +} + +/** The project the canonical `mcpctl` entry mounts, or null if there isn't one. */ +export function canonicalProjectIn(config: Pick | null | undefined): string | null { + return projectOfEntry(config?.mcpServers?.[MCPCTL_SERVER_NAME]); +} + +/** + * Legacy project-named entries still present, in file order. + * + * Kept separate from the canonical entry because the two mean different things + * to a reader: the canonical entry is a deliberate pin, a legacy entry is + * residue from an older mcpctl that nothing has cleaned up yet. Callers that + * rank sources (the status line) must be able to tell them apart. + */ +export function legacyEntriesIn(config: Pick | null | undefined): { server: string; project: string }[] { + const servers = config?.mcpServers; + if (!servers) return []; + return Object.entries(servers) + .filter(([name, entry]) => isLegacyMcpctlEntry(name, entry)) + .map(([name]) => ({ server: name, project: name })); +} + +/** The project currently mounted by `.mcp.json`, preferring the canonical entry. */ +export function activeProjectIn(config: Pick | null | undefined): string | null { + return canonicalProjectIn(config) ?? legacyEntriesIn(config)[0]?.project ?? null; +} + +export interface MergeResult { + config: McpJson; + /** Legacy project-named entries dropped by this merge. */ + retired: string[]; +} + +/** + * Merge mcpctl's entries into an existing `.mcp.json`. + * + * Every server the user configured is preserved; only our own legacy + * project-named entries are dropped, and only once the canonical entry replaces + * them. Passing no project leaves any existing mount alone (`--inspect` on its + * own must not unmount the project you are working in). + */ +export function mergeMcpctlServers( + existing: Partial | null | undefined, + opts: { project?: string; inspect?: boolean }, +): MergeResult { + const servers: Record = { ...(existing?.mcpServers ?? {}) }; + const retired: string[] = []; + + if (opts.project !== undefined && opts.project !== '') { + for (const name of Object.keys(servers)) { + if (isLegacyMcpctlEntry(name, servers[name])) { + delete servers[name]; + retired.push(name); + } + } + servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(opts.project); + } + if (opts.inspect === true) { + servers[MCPCTL_INSPECT_SERVER_NAME] = mcpctlInspectServer(); + } + + // Preserve any sibling top-level keys the file carried. + const rest = { ...(existing ?? {}) } as Partial; + delete rest.mcpServers; + return { config: { ...rest, mcpServers: servers }, retired }; +} + + +/** + * Path of Claude Code's user-scope config. + * + * NOTE the asymmetry: with `CLAUDE_CONFIG_DIR` set the file is + * `$CLAUDE_CONFIG_DIR/.claude.json`, but by default it is `$HOME/.claude.json` + * — *beside* `~/.claude/`, not inside it. Verified against a live Claude Code + * run with an isolated config dir. + */ +export function claudeJsonPath(env: NodeJS.ProcessEnv = process.env, homeDir?: string): string { + const override = env['CLAUDE_CONFIG_DIR']; + const home = homeDir ?? homedir(); + return override !== undefined && override !== '' + ? join(override, '.claude.json') + : join(home, '.claude.json'); +} + +/** Per-directory state Claude Code keeps in `.claude.json`'s `projects` map. */ +export interface ClaudeProjectEntry { + /** Servers switched off for this directory, whatever scope they came from. */ + disabledMcpServers?: string[]; + /** `.mcp.json` servers declined at the approval prompt. */ + disabledMcpjsonServers?: string[]; + [key: string]: unknown; +} + +/** Shape of the bits of `.claude.json` we touch. Everything else is preserved. */ +export interface ClaudeJson { + mcpServers?: Record; + projects?: Record; + [key: string]: unknown; +} + +/** + * Server names Claude Code has switched off in `dir`. + * + * A disabled server is not mounted, so naming its project as "active" is a + * plain lie — this is what lets the status line skip one. Only *explicit* + * disables count: a `.mcp.json` server in neither list is pending its approval + * prompt, and treating pending as off would blank the status line on a fresh + * checkout, which is the more confusing failure. + */ +export function disabledServersFor(doc: ClaudeJson | null | undefined, dir: string): Set { + const entry = doc?.projects?.[dir]; + return new Set([ + ...(Array.isArray(entry?.disabledMcpServers) ? entry.disabledMcpServers : []), + ...(Array.isArray(entry?.disabledMcpjsonServers) ? entry.disabledMcpjsonServers : []), + ]); +} + +/** + * Set the user-scope entry, returning the new document and any legacy + * project-named entries retired from it. + * + * `.claude.json` also holds onboarding state, caches and a per-project map that + * Claude Code rewrites constantly — so this merges into the document it was + * given and never reconstructs it. + */ +export function mergeUserScopeServer( + existing: ClaudeJson | null | undefined, + project: string, +): { config: ClaudeJson; retired: string[] } { + const doc: ClaudeJson = { ...(existing ?? {}) }; + const servers: Record = { ...(doc.mcpServers ?? {}) }; + const retired: string[] = []; + for (const name of Object.keys(servers)) { + if (isLegacyMcpctlEntry(name, servers[name])) { + delete servers[name]; + retired.push(name); + } + } + servers[MCPCTL_SERVER_NAME] = mcpctlStdioServer(project); + doc.mcpServers = servers; + return { config: doc, retired }; +} + +/** The project the user-scope entry mounts, or null. */ +export function userScopeProject(doc: ClaudeJson | null | undefined): string | null { + return activeProjectIn({ mcpServers: doc?.mcpServers ?? {} }); +} diff --git a/src/cli/src/config/opencode-extension.ts b/src/cli/src/config/opencode-extension.ts new file mode 100644 index 0000000..2e7a55e --- /dev/null +++ b/src/cli/src/config/opencode-extension.ts @@ -0,0 +1,27 @@ +/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-opencode-extension.ts` from + * `src/opencode-ext/mcpctl-opencode.ts` and + * `src/opencode-ext/mcpctl-opencode-tui.tsx`. + * + * `mcpctl config opencode` writes these verbatim into ~/.config/opencode/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered `plugin/` dir, + * where its basename is what shows up in `opencode plugin list`; + * - the TUI plugin lands beside it in `mcpctl/`, referenced by path from + * `tui.json`, and must keep a `.tsx` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = "/**\n * mcpctl opencode server plugin — mounts the active project's MCP gateway.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it.\n *\n * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json:\n * 1. The gateway needs an `Authorization: Bearer ` header. Putting\n * it in opencode.json means a secret in a mode-0644 config file that users\n * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest\n * of mcpctl's credentials.\n * 2. Switching projects has to work *without restarting opencode*. The server\n * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can\n * be re-pointed live — a config file can't do that.\n *\n * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that\n * headless runs (`opencode run ...`), which load no TUI plugins at all, still\n * get the active project's tools.\n *\n * Only Node builtins + the plugin API are imported, so the installed file needs\n * no dependencies of its own.\n */\nimport type { Plugin, PluginModule } from '@opencode-ai/plugin';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name we mount under. Constant on purpose — see `mount`. */\nconst SERVER_NAME = 'mcpctl';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8'));\n // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns\n // null, so the catch never fires and the next `state.project` throws a\n // TypeError that takes the plugin down. A truncated or hand-edited state\n // file must degrade to \"no project\", never to a broken opencode.\n return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {};\n } catch {\n return {};\n }\n}\n\n/** Proxy MCP URL for a project on the gateway. */\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\nconst server: Plugin = async ({ client }) => {\n /**\n * The (url, token) this process last registered.\n *\n * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal\n * binds a gated project's unlocked state to the `mcp-session-id` of that\n * connection. Re-adding an unchanged config every turn would therefore drop\n * the gate open by `begin_session` and re-lock the project mid-conversation.\n * So we only call `add` when the target actually changed — or when the mount\n * is not connected, where reconnecting is the whole point.\n */\n let mounted: string | null = null;\n\n /**\n * Mount (or re-point) the active project.\n *\n * The MCP server is always registered under the same name, so tools keep the\n * stable `mcpctl_*` prefix across switches and the model never sees a tool\n * namespace vanish mid-conversation. opencode resolves the tool list per\n * request, so a re-point is picked up on the next turn with no restart and no\n * \"the tools you were told about are gone\" announcement to the model.\n */\n async function mount(): Promise {\n const state = await readState();\n const project = state.project;\n const gatewayUrl = state.gatewayUrl;\n if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return;\n const token = state.tokens?.[project] ?? '';\n const url = projectUrl(gatewayUrl, project);\n const target = `${url}\\u0000${token}`;\n\n if (mounted === target && (await isConnected())) return;\n\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n await client.mcp.add({\n body: {\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url,\n headers,\n enabled: true,\n timeout: 120_000,\n },\n },\n });\n mounted = target;\n }\n\n /** Is our mount currently up? Unknown/unreachable counts as \"not connected\". */\n async function isConnected(): Promise {\n try {\n const res = await client.mcp.status();\n return res.data?.[SERVER_NAME]?.status === 'connected';\n } catch {\n return false;\n }\n }\n\n /**\n * `mount`, serialised and never throwing.\n *\n * Serialised because the two callers below can overlap — the event stream is\n * chatty and a message can land while a mount is still connecting — and two\n * concurrent `mcp.add` calls would race to register the same name.\n *\n * Never throwing because an unreachable gateway must degrade to \"no mcpctl\n * tools\", not to \"opencode fails to start\".\n */\n let inflight: Promise | null = null;\n function ensureMounted(): Promise {\n inflight ??= mount()\n .catch(() => { /* best-effort */ })\n .finally(() => { inflight = null; });\n return inflight;\n }\n\n // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is\n // accepting connections, and `client.mcp.add` calls back into that same\n // server — awaiting it at this point hangs opencode on a blank screen before\n // the TUI ever draws. Both hooks below fire only once the server is live.\n return {\n /**\n * First contact: mount as soon as the server is up, so a session that never\n * sends a message still shows the project's tools (and the sidebar shows\n * the mount as connected).\n */\n event: async (): Promise => {\n await ensureMounted();\n },\n\n /**\n * Re-assert the mount before every user turn.\n *\n * `mcpctl config opencode --project X` (run from a shell, or by the TUI\n * switcher in a *different* opencode window) rewrites the state file\n * underneath us. Re-reading here is what makes an external switch take\n * effect on the next message instead of on the next restart. When nothing\n * changed this is a state-file read and a status call — `mount` will not\n * re-register a mount that is already pointing at the right place.\n */\n 'chat.message': async (): Promise => {\n await ensureMounted();\n },\n };\n};\n\nexport default {\n id: 'mcpctl',\n server,\n} satisfies PluginModule & { id: string };\n"; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = "/** @jsxImportSource @opentui/solid */\n/**\n * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator.\n *\n * Installed by `mcpctl config opencode` into\n * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in\n * `~/.config/opencode/tui.json`.\n *\n * What it adds to opencode:\n * - `/mcpctl` — pick the active project from a filterable dialog\n * - `/mcpctl-status` — what is mounted, from where, as which user\n * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill\n * - a `mcpctl:` indicator in the prompt footer, next to the model\n * name and the token counter\n *\n * The switch itself is delegated to the `mcpctl` CLI (the same binary that\n * installed this file), so token minting, state and skills stay in one place\n * and this stays a UI shell. Once the CLI has rewritten the state file, the\n * mount is re-pointed live through opencode's own MCP API — no restart, unlike\n * every config-file-based integration.\n *\n * Only Node builtins + opencode's plugin API are imported.\n */\nimport type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui';\nimport type { JSX } from '@opentui/solid';\nimport { execFile } from 'node:child_process';\nimport { readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { homedir } from 'node:os';\n\n/** MCP server name the mount lives under — must match the server plugin. */\nconst SERVER_NAME = 'mcpctl';\n\n/**\n * kv key holding the label the footer renders.\n *\n * kv is a reactive store, so writing it here re-renders the slot with no\n * signal plumbing of our own; it also survives across sessions, so the label is\n * correct on the very first frame instead of after the state file is read.\n */\nconst KV_LABEL = 'mcpctl.project';\n\ninterface OpencodeState {\n project?: string;\n gatewayUrl?: string;\n tokens?: Record;\n}\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction statePath(): string {\n return join(homedir(), '.mcpctl', 'opencode-state.json');\n}\n\nasync function readState(): Promise {\n try {\n const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8'));\n // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns\n // null, so the catch never fires and the next `state.project` throws a\n // TypeError that takes the plugin down. A truncated or hand-edited state\n // file must degrade to \"no project\", never to a broken opencode.\n return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {};\n } catch {\n return {};\n }\n}\n\nfunction projectUrl(gatewayUrl: string, project: string): string {\n return `${gatewayUrl.replace(/\\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`;\n}\n\n/**\n * Run the `mcpctl` CLI and resolve its stdout.\n *\n * execFile, not a shell: project names come from the server and would otherwise\n * need quoting, and a shell buys nothing here.\n */\nfunction mcpctl(args: string[], timeoutMs = 120_000): Promise {\n return new Promise((resolve, reject) => {\n execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || err.message).trim() || String(err)));\n else resolve(stdout);\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000);\n const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>;\n return parsed\n .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string')\n .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined }));\n}\n\n/**\n * Active project first, then alphabetical.\n *\n * opencode's select dialog does its own fuzzy filtering as you type, so unlike\n * the pi and prime-agent switchers this needs no pre-filter prompt — only a\n * sensible starting order, since the most likely pick is \"the one I am on\".\n *\n * Exported so the ordering is unit-tested rather than eyeballed through a TUI.\n */\nexport function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] {\n return [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n}\n\n/** The footer label for a project (or the absence of one). */\nexport function indicatorLabel(project: string | null): string {\n return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none';\n}\n\nconst tui = async (api: TuiPluginApi): Promise => {\n /** Re-read the state file and publish the footer label. */\n async function refreshIndicator(): Promise {\n const state = await readState();\n const project = state.project ?? null;\n api.kv.set(KV_LABEL, indicatorLabel(project));\n return project !== null && project !== '' ? project : null;\n }\n\n /**\n * Point the live MCP mount at `project`.\n *\n * Registering under the same name every time keeps the tool prefix stable\n * (`mcpctl_*`), and because opencode re-resolves tools per request the model\n * simply sees the new project's tools on its next turn.\n */\n async function mount(project: string, state: OpencodeState): Promise {\n const gatewayUrl = state.gatewayUrl;\n if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `');\n const token = state.tokens?.[project] ?? '';\n const headers: Record = {};\n if (token !== '') headers['Authorization'] = `Bearer ${token}`;\n // Tear the outgoing mount down explicitly. `mcp.add` under the same name\n // does re-point the tools, but leaves it to opencode whether the previous\n // client is closed — and an abandoned one keeps its `mcp-session-id` alive\n // on mcplocal, which is what holds a gated project open. Best-effort: on a\n // first mount there is nothing to disconnect.\n try {\n await api.client.mcp.disconnect({ name: SERVER_NAME });\n } catch {\n /* not mounted yet */\n }\n await api.client.mcp.add({\n name: SERVER_NAME,\n config: {\n type: 'remote',\n url: projectUrl(gatewayUrl, project),\n headers,\n enabled: true,\n timeout: 120_000,\n },\n });\n }\n\n async function switchTo(project: string): Promise {\n api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' });\n try {\n // The CLI mints/reuses the project token, rewrites the state file and\n // syncs skills. --skip-plugin leaves this very file alone (rewriting a\n // loaded plugin mid-session buys nothing); --skip-marker stops us\n // silently re-scoping whatever repo opencode was started in, which\n // Claude Code's own skills sync would then pick up.\n await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']);\n } catch (err) {\n api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' });\n return;\n }\n try {\n await mount(project, await readState());\n } catch (err) {\n // The state file is already updated, so a restart would recover — say so\n // rather than reporting a success the tools do not back up.\n api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' });\n await refreshIndicator();\n return;\n }\n await refreshIndicator();\n api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' });\n }\n\n api.keymap.registerLayer({\n commands: [\n {\n name: 'mcpctl.switch',\n title: 'mcpctl: switch project',\n description: 'Mount another mcpctl project’s MCP servers and skills',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl',\n async run(): Promise {\n const active = await refreshIndicator();\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' });\n return;\n }\n if (projects.length === 0) {\n api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' });\n return;\n }\n const DialogSelect = api.ui.DialogSelect;\n api.ui.dialog.replace(() => (\n ({\n title: p.name,\n value: p.name,\n description: p.description,\n }))}\n onSelect={(option): void => {\n api.ui.dialog.clear();\n const picked = option.value;\n if (typeof picked !== 'string') return;\n if (picked === active) {\n api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' });\n return;\n }\n void switchTo(picked);\n }}\n />\n ));\n },\n },\n {\n name: 'mcpctl.status',\n title: 'mcpctl: status',\n description: 'Show the active mcpctl project and its MCP mount',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-status',\n async run(): Promise {\n const state = await readState();\n const project = await refreshIndicator();\n let mcpStatus = 'unknown';\n try {\n const res = await api.client.mcp.status();\n mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted';\n } catch {\n mcpStatus = 'unavailable';\n }\n const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a';\n api.ui.toast({\n message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`,\n variant: mcpStatus === 'connected' ? 'success' : 'warning',\n duration: 12_000,\n });\n },\n },\n {\n name: 'mcpctl.skills',\n title: 'mcpctl: sync skills',\n description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory',\n category: 'mcpctl',\n namespace: 'palette',\n slashName: 'mcpctl-skills',\n async run(): Promise {\n const project = await refreshIndicator();\n if (project === null) {\n api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' });\n return;\n }\n try {\n await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']);\n api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' });\n } catch (err) {\n api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' });\n }\n },\n },\n ],\n // Switching is the thing you do repeatedly, so it gets a chord as well as\n // `/mcpctl`. The other two commands stay palette-only — they are occasional.\n bindings: [\n { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' },\n ],\n });\n\n // The indicator. `session_prompt_right` and `home_prompt_right` are the only\n // slots in the footer cluster opencode exposes to plugins: they render on the\n // prompt's bottom line, immediately right of the model name and directly\n // above the token counter. (`home_footer` would sit on the counter's line but\n // *replaces* the cwd/version footer rather than adding to it, and\n // `app_bottom` costs a whole extra terminal row.)\n // wrapMode=\"none\" + truncate: the home prompt row is narrow, and the default\n // wrap breaks \"mcpctl:homeautomation\" across two lines mid-word. Clipping the\n // tail of a long name reads far better than a two-line footer.\n const Indicator = (): JSX.Element => (\n \n {api.kv.get(KV_LABEL, indicatorLabel(null))}\n \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/config/pi-extension.ts b/src/cli/src/config/pi-extension.ts index 35ccf26..eff525c 100644 --- a/src/cli/src/config/pi-extension.ts +++ b/src/cli/src/config/pi-extension.ts @@ -10,6 +10,6 @@ export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; export const PI_EXTENSION_FILES: Record = { - 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport { StringEnum } from \"@earendil-works/pi-ai\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return StringEnum(enumVals as string[]);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\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 name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /**\n * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()\n * renders *every* option with no windowing, so an uncapped list scrolls the\n * whole screen away. prime-agent's selector windows to ~20 itself; this\n * matches that height so both hosts feel the same.\n */\n const MAX_SHOWN = 20;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", + 'mcpctl-pi.ts': "/**\n * mcpctl pi extension — native integration (no MCP client, no Claude).\n *\n * Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers\n * each project tool as a native pi tool.\n *\n * KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a\n * `mcp-session-id`. A gated project only exposes `begin_session` until it is\n * called, after which the full tool set becomes callable — but only within\n * THAT session. So we keep ONE persistent `McpHttpSession` per active project\n * and route every tools/call through it, re-listing after each call to pick up\n * the post-begin_session tool additions.\n *\n * Features:\n * - project gating auto-ungates when begin_session is called\n * - switching the active project from the pi TUI (/mcpctl)\n * - a status line\n *\n * Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts\n * or via settings: \"extensions\": [\"/abs/path/to/mcpctl-pi.ts\"]\n *\n * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.\n *\n * RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers\n * through a fixed alias table in its own loader, and that table differs between\n * pi distributions — `@earendil-works/*` exists only in the newer packages,\n * while `@mariozechner/*` installs alias only the old names. `typebox` is the\n * one specifier every published pi aliases, so it is the ONLY runtime import\n * allowed here. Anything else must be `import type` (erased before jiti runs)\n * or inlined — see `stringEnum` below.\n */\nimport { Type, type TSchema } from \"typebox\";\nimport type { ExtensionAPI, ExtensionContext } from \"@earendil-works/pi-coding-agent\";\nimport {\n McpHttpSession,\n type ToolInfo,\n type McpHttpOptions,\n} from \"./mcp-http.js\";\nimport { readFile, writeFile, mkdir } from \"node:fs/promises\";\nimport { join, dirname } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ── config / state ───────────────────────────────────────────────────────────\nconst McpctlDir = join(homedir(), \".mcpctl\");\nconst ConfigPath = join(McpctlDir, \"config.json\");\nconst CredPath = join(McpctlDir, \"credentials\");\nconst PiStatePath = join(McpctlDir, \"pi-state.json\");\nconst ProjectMarker = \".mcpctl-project\";\n\ninterface PiState {\n project?: string;\n}\n\nasync function readJson(path: string): Promise {\n try {\n return JSON.parse(await readFile(path, \"utf-8\")) as T;\n } catch {\n return null;\n }\n}\n\nasync function readConfig(): Promise<{ mcplocalUrl: string }> {\n const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);\n return { mcplocalUrl: cfg?.mcplocalUrl ?? \"http://localhost:3200\" };\n}\n\nasync function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {\n try {\n const raw = await readFile(CredPath, \"utf-8\");\n const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };\n if (!parsed.token) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nasync function readPiState(): Promise {\n return (await readJson(PiStatePath)) ?? {};\n}\n\nasync function writePiState(state: PiState): Promise {\n await mkdir(McpctlDir, { recursive: true });\n await writeFile(PiStatePath, JSON.stringify(state, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Walk up directories looking for a `.mcpctl-project` marker. */\nasync function findProjectMarker(start = process.cwd()): Promise {\n const home = homedir();\n let dir = start;\n for (let i = 0; i < 50; i++) {\n try {\n const raw = await readFile(join(dir, ProjectMarker), \"utf-8\");\n const project = raw.split(\"\\n\")[0]?.trim() ?? \"\";\n if (project) return project;\n } catch {\n // continue walking\n }\n if (dir === home || dir === \"/\") break;\n const parent = dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/** Request the list of projects from mcpd (via mcplocal API passthrough). */\nasync function listProjects(mcplocalUrl: string, token?: string): Promise {\n const url = `${mcplocalUrl}/api/v1/projects`;\n const headers: Record = { Accept: \"application/json\" };\n if (token) headers.Authorization = `Bearer ${token}`;\n const res = await fetch(url, { headers });\n if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);\n const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };\n const arr = Array.isArray(body) ? body : (body.data ?? []);\n return arr.map((p) => p.name);\n}\n\n// ── JSON Schema → TypeBox ────────────────────────────────────────────────────\n\n/**\n * `{ type: \"string\", enum: [...] }` rather than a union of literals: Google's\n * API (and other providers that reject anyOf/const) only accept the flat form.\n *\n * Inlined from pi-ai's `StringEnum` on purpose — importing it dragged in\n * `@earendil-works/pi-ai`, which older pi installs cannot resolve, and the\n * whole extension then failed to load. See the import note at the top.\n */\nfunction stringEnum(values: string[], description?: string): TSchema {\n return Type.Unsafe({\n type: \"string\",\n enum: values,\n ...(description ? { description } : {}),\n });\n}\n\nfunction convertSchema(inputSchema: unknown): TSchema {\n if (!inputSchema || typeof inputSchema !== \"object\") {\n return Type.Object({});\n }\n const s = inputSchema as {\n type?: string;\n properties?: Record;\n required?: string[];\n items?: unknown;\n enum?: unknown[];\n };\n\n const required = new Set(Array.isArray(s.required) ? s.required : []);\n const props = s.properties ?? {};\n const fields: Record = {};\n\n for (const [name, raw] of Object.entries(props)) {\n const schema = convertProp(raw);\n fields[name] = required.has(name) ? schema : Type.Optional(schema);\n }\n return Type.Object(fields);\n}\n\nfunction convertProp(raw: unknown): TSchema {\n if (!raw || typeof raw !== \"object\") return Type.Unknown();\n const s = raw as {\n type?: string | string[];\n enum?: unknown[];\n description?: string;\n items?: unknown;\n properties?: Record;\n };\n\n const desc = typeof s.description === \"string\" ? s.description : undefined;\n const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;\n\n if (enumVals && enumVals.every((v) => typeof v === \"string\")) {\n return stringEnum(enumVals as string[], desc);\n }\n if (enumVals && enumVals.every((v) => typeof v === \"number\")) {\n const literals = enumVals.map((v) => Type.Literal(v));\n return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);\n }\n\n let type = s.type;\n if (Array.isArray(type)) type = type.find((t) => t !== \"null\");\n type = type ?? (s.properties ? \"object\" : s.items ? \"array\" : \"string\");\n if (type === \"integer\") type = \"number\";\n\n switch (type) {\n case \"string\":\n return desc ? Type.String({ description: desc }) : Type.String();\n case \"number\":\n return desc ? Type.Number({ description: desc }) : Type.Number();\n case \"boolean\":\n return desc ? Type.Boolean({ description: desc }) : Type.Boolean();\n case \"array\": {\n const inner = s.items ? convertProp(s.items) : Type.Unknown();\n return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);\n }\n case \"object\":\n return convertSchema(s as unknown);\n default:\n return desc ? Type.Unknown({ description: desc }) : Type.Unknown();\n }\n}\n\n/**\n * Order and filter the project list for the picker.\n *\n * Ordering: the active project first (most likely pick), then alphabetical.\n * Filtering: space-separated terms, ALL of which must appear as\n * case-insensitive substrings — so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n *\n * Exported so the behaviour is unit-tested rather than eyeballed through a TUI.\n */\nexport function filterProjects(projects: string[], query: string, active: string | null): string[] {\n const ordered = [...projects].sort((a, b) => {\n if (a === active) return -1;\n if (b === active) return 1;\n return a.localeCompare(b);\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 name = p.toLowerCase();\n return terms.every((t) => name.includes(t));\n });\n}\n\n/**\n * The message injected into the conversation after a project switch, telling\n * the model which mcpctl tools are live now.\n *\n * Exported so its wording is unit-tested; it is the only thing standing between\n * the model and a stale tool list it will otherwise keep calling.\n */\nexport function toolChangeAnnouncement(project: string, tools: string[]): string {\n const gate = tools.find((n) => n.endsWith(\"_begin_session\"));\n const parts = [\n `[mcpctl] The active project is now '${project}'.`,\n \"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.\",\n tools.length > 0\n ? `Currently available mcpctl tools: ${tools.join(\", \")}.`\n : \"No mcpctl tools are currently available for this project.\",\n ];\n if (gate !== undefined) {\n parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);\n }\n return parts.join(\" \");\n}\n\n/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */\nfunction safeSegment(name: string): string {\n return name.toLowerCase().replace(/[^a-z0-9_]+/g, \"_\").replace(/^_+|_+$/g, \"\") || \"x\";\n}\n\nfunction slugify(name: string): string {\n return safeSegment(name);\n}\n\n/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */\nfunction piToolName(project: string, mcpTool: string): string {\n const proj = slugify(project);\n const tool = mcpTool.replace(/^[a-z0-9_-]+\\//i, (m) => slugify(m.slice(0, -1)) + \"_\") || mcpTool;\n return `mc_${proj}_${safeSegment(tool)}`;\n}\n\n// ── the extension ────────────────────────────────────────────────────────────\nexport default function (pi: ExtensionAPI) {\n let mcplocalUrl = \"http://localhost:3200\";\n let token: string | undefined;\n let activeProject: string | null = null;\n /** Persistent session for the active project — MUST be reused to keep gate state. */\n let session: McpHttpSession | null = null;\n /** Registered pi tool name → the MCP tool it forwards to, for the active project. */\n const projectToolNames = new Map();\n let activeCtx: ExtensionContext | null = null;\n\n function endpointUrl(): string {\n return `${mcplocalUrl.replace(/\\/$/, \"\")}/projects/${encodeURIComponent(activeProject ?? \"\")}/mcp`;\n }\n\n /** Build (or reuse) the persistent session for the active project. */\n async function ensureSession(): Promise {\n if (session) return session;\n const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };\n const sess = new McpHttpSession(endpointUrl(), opts);\n await sess.initialize();\n await sess.sendNotification(\"notifications/initialized\");\n session = sess;\n return sess;\n }\n\n async function closeSession(): Promise {\n const s = session;\n session = null;\n if (s) await s.close();\n }\n\n /**\n * (Re)discover the active project's current tool set and register any new pi\n * tools. Called on load and after every tool call (to pick up ungating).\n */\n async function reconcileTools(project: string): Promise<{ tools: string[] }> {\n const sess = await ensureSession();\n const { tools } = await sess.listTools();\n const names: string[] = [];\n for (const t of tools) {\n const piName = uniqueToolName(project, t.name);\n if (piName === null) continue; // already registered for this same MCP tool\n pi.registerTool(createTool(piName, t, project));\n projectToolNames.set(piName, t.name);\n names.push(piName);\n }\n // Names already registered for tools still in the list stay active.\n for (const [piName, mcpName] of projectToolNames) {\n if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);\n }\n // Activate built-ins/other extensions + all tools discovered in this project.\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...new Set([...active, ...names])]);\n return { tools: names };\n }\n\n /**\n * The pi tool name to register `mcpTool` under, or null if it is already\n * registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.\n * `docs.search` and `docs-search`); without a suffix the second tool would\n * silently never be registered while still being reported as available, and\n * its calls would be forwarded to the first tool instead.\n */\n function uniqueToolName(project: string, mcpTool: string): string | null {\n const base = piToolName(project, mcpTool);\n if (projectToolNames.get(base) === mcpTool) return null;\n if (!projectToolNames.has(base)) return base;\n for (let i = 2; i < 100; i++) {\n const candidate = `${base}_${String(i)}`;\n if (projectToolNames.get(candidate) === mcpTool) return null;\n if (!projectToolNames.has(candidate)) return candidate;\n }\n return null;\n }\n\n function createTool(piName: string, t: ToolInfo, project: string) {\n const mcpName = t.name;\n return {\n name: piName,\n label: `${project} :: ${mcpName}`,\n description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,\n parameters: convertSchema(t.inputSchema),\n async execute(_toolCallId: string, params: Record) {\n const sess = await ensureSession();\n const res = await sess.callTool(mcpName, params ?? {});\n // Re-list after every call — picks up post-begin_session tool additions.\n try {\n await reconcileTools(project);\n } catch {\n // non-fatal\n }\n const text = (res.content ?? [])\n .filter((c) => c.type === \"text\")\n .map((c) => c.text ?? \"\")\n .join(\"\\n\");\n if (res.isError) {\n throw new Error(text || `${mcpName} returned an error`);\n }\n return {\n // `type` must be the literal \"text\" — pi's AgentToolResult content is\n // a TextContent | ImageContent union, not { type: string }.\n content: [{ type: \"text\" as const, text: text || \"(no text content)\" }],\n details: { mcpTool: mcpName, project, raw: res },\n };\n },\n };\n }\n\n function setStatus(msg?: string): void {\n try {\n activeCtx?.ui?.setStatus?.(\"mcpctl\", msg ?? (activeProject ? `project: ${activeProject}` : \"no project\"));\n } catch {\n /* non-fatal */\n }\n }\n\n // ── /mcpctl command ──\n /**\n * Menu labels → actions. `ctx.ui.select` takes plain strings and returns the\n * chosen string, so the mapping has to live here rather than in the options.\n */\n const MENU: ReadonlyArray = [\n [\"Status\", \"status\"],\n [\"Switch project\", \"switch\"],\n [\"Refresh tools\", \"refresh\"],\n [\"Sync skills into pi's skills dir\", \"sync\"],\n [\"Close\", \"close\"],\n ];\n\n pi.registerCommand(\"mcpctl\", {\n description: \"mcpctl integration: status, switch project, refresh tools\",\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n // A command handler resolves to void — pi ignores a returned string, so\n // headless status has to go through notify.\n ctx.ui.notify(\n `mcpctl: active project = ${activeProject ?? \"none\"}; mcplocal = ${mcplocalUrl}`,\n \"info\",\n );\n return;\n }\n const picked = await ctx.ui.select(\"mcpctl\", MENU.map(([label]) => label));\n if (!picked) return;\n const choice = MENU.find(([label]) => label === picked)?.[1];\n if (!choice || choice === \"close\") return;\n\n if (choice === \"status\") {\n const cs = await readConfig();\n const creds = await readCreds();\n const info =\n `mcplocal: ${cs.mcplocalUrl}\\n` +\n `user: ${creds?.mcpdUrl ?? \"not logged in\"}\\n` +\n `active project: ${activeProject ?? \"none\"}\\n` +\n `tools registered: ${projectToolNames.size}\\n` +\n ` ${[...projectToolNames].join(\"\\n \")}\\n`;\n await ctx.ui.editor(\"mcpctl status\", info);\n return;\n }\n\n if (choice === \"switch\") {\n await switchProject(ctx);\n return;\n }\n\n if (choice === \"refresh\") {\n if (!activeProject) {\n ctx.ui.notify(\"No active project — switch project first\", \"warning\");\n return;\n }\n try {\n const r = await reconcileTools(activeProject);\n ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, \"info\");\n setStatus();\n } catch (e) {\n ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, \"error\");\n }\n return;\n }\n\n if (choice === \"sync\") {\n try {\n // `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it\n // this syncs into ~/.claude/skills, which the whole point of this\n // integration is to avoid depending on.\n const args = [\"skills\", \"sync\", \"--agent\", \"pi\", \"--quiet\"];\n if (activeProject) args.push(\"-p\", activeProject);\n // execFile, not execSync+stdio:\"inherit\": a shell would need the\n // project name quoted, and inherited stdio writes raw output over\n // pi's TUI. Capture instead and report through notify.\n const { execFile } = await import(\"node:child_process\");\n const stderr = await new Promise((resolve, reject) => {\n execFile(\"mcpctl\", args, { timeout: 120_000 }, (err, _stdout, errOut) => {\n if (err) reject(new Error((errOut || err.message).trim()));\n else resolve(errOut);\n });\n });\n const detail = stderr.trim().split(\"\\n\").pop();\n ctx.ui.notify(\n `mcpctl skills synced into pi's skills dir${detail ? ` — ${detail}` : \"\"}. Run /reload to pick them up.`,\n \"info\",\n );\n } catch (e) {\n ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, \"error\");\n }\n }\n },\n });\n\n /** Above this many projects, arrowing through the list stops being usable. */\n const FILTER_THRESHOLD = 20;\n /**\n * Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()\n * renders *every* option with no windowing, so an uncapped list scrolls the\n * whole screen away. prime-agent's selector windows to ~20 itself; this\n * matches that height so both hosts feel the same.\n */\n const MAX_SHOWN = 20;\n\n /**\n * Choose a project, asking for a filter first when the list is long.\n *\n * pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no\n * search and `ExtensionSelectorComponent` ignores typed characters — so the\n * filtering has to happen before the list is handed over. Real installs run\n * to hundreds of projects (smoke-test leftovers included), where scrolling is\n * hopeless.\n *\n * Terms are space-separated and ALL must match, case-insensitively, as\n * substrings: `home auto` finds `homeautomation`. Blank shows everything.\n */\n async function pickProject(ctx: ExtensionContext, projects: string[]): Promise {\n let candidates = filterProjects(projects, \"\", activeProject);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n \"e.g. home auto\",\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, activeProject);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, \"warning\");\n return undefined;\n }\n }\n\n const truncated = candidates.length > MAX_SHOWN;\n const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;\n const title = truncated\n // Say what was dropped: a silently capped list reads as \"that's all of them\".\n ? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`\n : `Switch to project (${String(shown.length)})`;\n return ctx.ui.select(title, shown);\n }\n\n async function switchProject(ctx: ExtensionContext): Promise {\n let projects: string[] = [];\n try {\n projects = await listProjects(mcplocalUrl, token);\n } catch (e) {\n ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, \"error\");\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify(\"No projects returned by mcpd\", \"warning\");\n return;\n }\n const picked = await pickProject(ctx, projects);\n if (!picked) return;\n\n // Tear down old project's session + active tools.\n await closeSession();\n const active = pi.getActiveTools().filter((n) => !n.startsWith(\"mc_\"));\n pi.setActiveTools([...active]);\n projectToolNames.clear();\n\n activeProject = picked;\n await writePiState({ project: picked });\n try {\n const r = await reconcileTools(picked);\n const gate = r.tools.find((n) => n.endsWith(\"_begin_session\"));\n ctx.ui.notify(\n `Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : \"\"}`,\n \"info\",\n );\n setStatus();\n announceToolChange(picked, r.tools);\n } catch (e) {\n ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, \"error\");\n }\n }\n\n /**\n * Tell the *model* that the tool set changed.\n *\n * pi cannot unregister a tool — `registerTool` only ever adds to the\n * extension's tool map — so the previous project's `mc_*` tools stay\n * registered and merely go inactive. Meanwhile the conversation still\n * contains the old project's tool list, so the model keeps calling names that\n * now answer \"Tool ... not found\" and concludes it has lost MCP access\n * entirely, which is what happens in practice on the second switch.\n *\n * A custom message is converted to a user-role message and does reach the\n * LLM (unlike `appendEntry`, which is explicitly excluded from context), so\n * this corrects the stale context instead of leaving the model to guess.\n * `display: false` keeps it out of the transcript — the notify above is what\n * the human reads.\n */\n function announceToolChange(project: string, tools: string[]): void {\n try {\n pi.sendMessage(\n { customType: \"mcpctl-project-switch\", content: toolChangeAnnouncement(project, tools), display: false },\n { triggerTurn: false, deliverAs: \"nextTurn\" },\n );\n } catch {\n // Older pi without sendMessage — the notify above still informs the user.\n }\n }\n\n // ── session lifecycle ──\n pi.on(\"session_start\", async (_event, ctx) => {\n activeCtx = ctx;\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"connecting...\"); } catch { /* ignore */ }\n const cfg = await readConfig();\n mcplocalUrl = cfg.mcplocalUrl;\n const creds = await readCreds();\n token = creds?.token ?? undefined;\n\n const piState = await readPiState();\n const project = piState.project ?? (await findProjectMarker());\n if (!project) {\n try { ctx.ui?.setStatus?.(\"mcpctl\", \"no project (use /mcpctl)\"); } catch { /* ignore */ }\n return;\n }\n activeProject = project;\n try {\n const r = await reconcileTools(project);\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, \"info\");\n setStatus();\n } catch (e) {\n if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, \"error\");\n }\n });\n\n pi.on(\"session_shutdown\", () => {\n void closeSession();\n });\n}\n", 'mcp-http.ts': "/**\n * Self-contained Streamable-HTTP JSON-RPC client for the pi extension.\n *\n * Vendored (no `@mcpctl/*` imports) so the extension is loadable standalone\n * by pi (`pi -e ./mcpctl-pi.ts` / settings `extensions`). Talks directly to\n * mcplocal's project MCP endpoint — the same one `mcpctl mcp` bridges to.\n *\n * Only Node builtins are imported.\n */\nimport http from 'node:http';\nimport https from 'node:https';\n\nexport interface McpHttpOptions {\n bearer?: string;\n headers?: Record;\n timeoutMs?: number;\n}\n\nexport interface ToolInfo {\n name: string;\n description?: string;\n inputSchema?: unknown;\n}\n\nexport interface ToolCallResult {\n content: Array<{ type: string; text?: string }>;\n isError?: boolean;\n}\n\ninterface HttpArgs {\n url: string;\n method: string;\n headers?: Record;\n body?: string;\n timeoutMs?: number;\n}\n\ninterface HttpResult {\n status: number;\n headers: http.IncomingHttpHeaders;\n body: string;\n}\n\nfunction rawHttpRequest(opts: HttpArgs): Promise {\n return new Promise((resolve, reject) => {\n const parsed = new URL(opts.url);\n const driver = parsed.protocol === 'https:' ? https : http;\n const req = driver.request(\n {\n hostname: parsed.hostname,\n port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),\n path: parsed.pathname + parsed.search,\n method: opts.method,\n headers: opts.headers,\n timeout: opts.timeoutMs ?? 30_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on('data', (chunk: Buffer) => chunks.push(chunk));\n res.on('end', () => {\n resolve({\n status: res.statusCode ?? 0,\n headers: res.headers,\n body: Buffer.concat(chunks).toString('utf-8'),\n });\n });\n },\n );\n req.on('error', reject);\n req.on('timeout', () => {\n req.destroy();\n reject(new Error('MCP request timed out'));\n });\n if (opts.body) req.write(opts.body);\n req.end();\n });\n}\n\nfunction parseSse(body: string): unknown[] {\n const messages: unknown[] = [];\n for (const line of body.split('\\n')) {\n if (line.startsWith('data: ')) {\n try {\n messages.push(JSON.parse(line.slice(6)));\n } catch {\n // skip malformed SSE line\n }\n }\n }\n return messages;\n}\n\nexport class McpProtocolError extends Error {\n constructor(public readonly code: number, message: string) {\n super(`MCP error ${code}: ${message}`);\n this.name = 'McpProtocolError';\n }\n}\n\nexport class McpTransportError extends Error {\n constructor(public readonly status: number, public readonly body: string, message?: string) {\n super(message ?? `HTTP ${status}: ${body.slice(0, 200)}`);\n this.name = 'McpTransportError';\n }\n}\n\nexport interface McpResponse {\n result?: unknown;\n error?: { code: number; message: string };\n notifications: unknown[];\n}\n\nexport class McpHttpSession {\n private sessionId: string | undefined;\n private nextId = 1;\n\n constructor(\n public readonly url: string,\n private readonly options: McpHttpOptions = {},\n ) {}\n\n private buildHeaders(extra: Record = {}): Record {\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream',\n ...(this.options.headers ?? {}),\n ...extra,\n };\n if (this.sessionId) headers['mcp-session-id'] = this.sessionId;\n if (this.options.bearer) headers['Authorization'] = `Bearer ${this.options.bearer}`;\n return headers;\n }\n\n /**\n * Send a JSON-RPC request, return the matched result AND any notifications\n * that were multiplexed into the same body (e.g. tools/list_changed).\n */\n async sendFull(method: string, params: Record = {}): Promise {\n const id = this.nextId++;\n const request = { jsonrpc: '2.0', id, method, params };\n const result = await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(request),\n timeoutMs: this.options.timeoutMs,\n });\n\n if (!this.sessionId) {\n const sid = result.headers['mcp-session-id'];\n if (typeof sid === 'string') this.sessionId = sid;\n }\n\n if (result.status >= 400) {\n let message = `HTTP ${result.status}`;\n try {\n const body = JSON.parse(result.body) as { error?: string | { message?: string } };\n const errField = body.error;\n if (typeof errField === 'string') message = errField;\n else if (errField && typeof errField === 'object' && typeof errField.message === 'string') {\n message = errField.message;\n }\n } catch {\n message = `HTTP ${result.status}: ${result.body.slice(0, 200)}`;\n }\n throw new McpTransportError(result.status, result.body, message);\n }\n\n const messages = result.headers['content-type']?.includes('text/event-stream')\n ? parseSse(result.body)\n : [JSON.parse(result.body)];\n\n const notifications: unknown[] = [];\n const matched = messages.find((m) => {\n const msg = m as { id?: unknown; method?: unknown };\n if (msg.method !== undefined && (msg.id === undefined || msg.id === null)) {\n notifications.push(msg);\n return false;\n }\n return msg.id === id;\n }) as { result?: unknown; error?: { code: number; message: string } } | undefined;\n\n const parsed = matched ?? (messages[0] as { result?: unknown; error?: { code: number; message: string } } | undefined);\n if (!parsed) throw new Error(`No response for ${method}`);\n if (parsed.error) throw new McpProtocolError(parsed.error.code, parsed.error.message);\n return { result: parsed.result, error: undefined, notifications };\n }\n\n /** Convenience: return just the result, throwing on error. */\n async send(method: string, params: Record = {}): Promise {\n const res = await this.sendFull(method, params);\n return res.result;\n }\n\n async sendNotification(method: string, params: Record = {}): Promise {\n const notification = { jsonrpc: '2.0', method, params };\n await rawHttpRequest({\n url: this.url,\n method: 'POST',\n headers: this.buildHeaders(),\n body: JSON.stringify(notification),\n timeoutMs: this.options.timeoutMs,\n }).catch(() => { /* best-effort */ });\n }\n\n async initialize(): Promise {\n return this.send('initialize', {\n protocolVersion: '2024-11-05',\n capabilities: {},\n clientInfo: { name: 'mcpctl-pi', version: '1.0.0' },\n });\n }\n\n /** List tools exposed by the endpoint. Returns raw result so caller sees capabilities. */\n async listTools(): Promise<{ tools: ToolInfo[]; raw: unknown }> {\n const raw = await this.send('tools/list');\n const tools = (raw as { tools?: ToolInfo[] }).tools ?? [];\n return { tools, raw };\n }\n\n /** Call a tool. */\n async callTool(name: string, args: Record = {}): Promise {\n const result = await this.send('tools/call', { name, arguments: args });\n return result as ToolCallResult;\n }\n\n /** Close the session (DELETE). Safe when no session has been negotiated. */\n async close(): Promise {\n if (this.sessionId === undefined) return;\n await rawHttpRequest({\n url: this.url,\n method: 'DELETE',\n headers: this.buildHeaders(),\n timeoutMs: 5_000,\n }).catch(() => { /* best-effort */ });\n this.sessionId = undefined;\n }\n}\n", }; diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 39f7966..7231a71 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -3,8 +3,11 @@ * so `mcpctl config prime-agent` can install it into prime-agent's auto- * discovered extensions directory (`~/.prime/agent/extensions/`). * - * The installed file is this exact source (verbatim), so the extension shipped - * by the CLI is always the one that runs. + * DO NOT EDIT BY HAND. Generated by + * `npx tsx scripts/generate-prime-agent-extension.ts` from + * `src/prime-agent-ext/mcpctl-switch.ts` — edit that instead, so the change is + * typechecked before it ships. The installed file is this exact source + * (verbatim), so the extension the CLI writes is always the one that ran. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\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/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p !== null && typeof p === 'object' && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry !== undefined && entry !== null && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry !== undefined && entry !== null ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\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/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description !== undefined && p.description !== '' ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI): void {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (picked === undefined || picked === '') return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 7227068..47c2070 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -26,6 +26,7 @@ import { createMigrateCommand } from './commands/migrate.js'; import { createRotateCommand } from './commands/rotate.js'; import { createReviewCommand } from './commands/review.js'; import { createSkillsCommand } from './commands/skills.js'; +import { createStatuslineCommand } from './commands/statusline.js'; import { createPasswdCommand } from './commands/passwd.js'; import { createErrorsCommand } from './commands/errors.js'; import { ApiClient, ApiError } from './api-client.js'; @@ -44,6 +45,7 @@ export function createProgram(): Command { .option('-p, --project ', 'Target project for project commands'); program.addCommand(createStatusCommand()); + program.addCommand(createStatuslineCommand()); program.addCommand(createLoginCommand()); program.addCommand(createLogoutCommand()); diff --git a/src/cli/src/utils/claude-ui.ts b/src/cli/src/utils/claude-ui.ts new file mode 100644 index 0000000..e0a05d9 --- /dev/null +++ b/src/cli/src/utils/claude-ui.ts @@ -0,0 +1,201 @@ +/** + * The two pieces of Claude Code UI `mcpctl config claude` wires up: + * + * - a **status line** showing the active project, so Claude Code gets the + * same at-a-glance indicator opencode has in its footer and pi/prime-agent + * get from `setStatus`; + * - a **`/mcpctl` slash command** to switch projects from inside a session. + * + * Claude Code has no plugin API that can draw its own widget or open a picker, + * so neither is as native as the opencode switcher. The status line is a + * command Claude Code runs and renders; the slash command is a prompt file that + * drives the model through `mcpctl` CLI calls. That is the whole extension + * surface Claude Code offers, and it is enough for both jobs. + */ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { MCPCTL_SERVER_NAME } from '../config/claude-mcp.js'; + +/** Same marker the SessionStart hook installer uses to recognise its own rows. */ +export const MARKER_KEY = '_mcpctl_managed'; + +/** The command Claude Code runs to render the status line. */ +export const STATUSLINE_COMMAND = 'mcpctl statusline'; + +/** + * Claude Code's config directory. + * + * `CLAUDE_CONFIG_DIR` is Claude Code's own override, so honouring it is correct + * behaviour first and test isolation second — without it, anything that + * provisions Claude writes into the developer's real ~/.claude when the test + * suite runs. + */ +export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const override = env['CLAUDE_CONFIG_DIR']; + return override !== undefined && override !== '' ? override : join(homeDir, '.claude'); +} + +export function claudeSettingsPath(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return join(claudeConfigDir(env, homeDir), 'settings.json'); +} + +export function claudeCommandPath(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return join(claudeConfigDir(env, homeDir), 'commands', 'mcpctl.md'); +} + +interface StatusLine { + type?: string; + command?: string; + [k: string]: unknown; +} + +interface Settings { + statusLine?: StatusLine; + [k: string]: unknown; +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + // Same heuristic as the hook installer: strip line comments so a file an + // editor added notes to still parses. + return JSON.parse(raw.replace(/^\s*\/\/.*$/gm, '')) as Settings; + } 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)}`); + } +} + +async function writeSettings(path: string, settings: Settings): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Is this status line ours? + * + * The `_mcpctl_managed` marker alone is not enough: Claude Code rewrites + * settings.json against its own schema (on theme change, for instance) and + * **strips unknown keys from `statusLine`** — verified in a live session, where + * our tagged entry came back as a bare `{type, command}`. Hooks keep their + * marker; statusLine does not. Relying on the tag would mean reporting our own + * status line as somebody else's forever, and never upgrading the command. + * + * So the command string is the real evidence. It must *be* an `mcpctl + * statusline` invocation, not merely contain one — someone who composed ours + * into a bigger line (`my-prompt && mcpctl statusline`) owns that line, and we + * must not overwrite it. + */ +export function isOurStatusLine(current: StatusLine | null | undefined): boolean { + if (current === null || current === undefined) return false; + if (current[MARKER_KEY] === true) return true; + const command = current.command; + return typeof command === 'string' && /^\s*(\S*\/)?mcpctl\s+statusline(\s|$)/.test(command); +} + +export type StatusLineOutcome = + | { status: 'installed' } + | { status: 'already' } + | { status: 'foreign'; command: string }; + +/** + * Install the status line — but never over one the user already has. + * + * A status line is a single slot, so installing ours on top of a custom one + * silently deletes work someone put effort into. When we find a foreign one we + * leave it and report it, so the caller can print the one-line snippet to add + * instead. Ours is tagged, so re-running is idempotent and an upgrade of the + * command string still lands. + */ +export async function installStatusLine( + settingsPath: string = claudeSettingsPath(), + command: string = STATUSLINE_COMMAND, +): Promise { + const settings = await readSettings(settingsPath); + const current = settings.statusLine; + + if (current !== undefined && current !== null) { + if (!isOurStatusLine(current)) return { status: 'foreign', command: String(current.command ?? '') }; + if (current.command === command) return { status: 'already' }; + } + + settings.statusLine = { type: 'command', command, [MARKER_KEY]: true }; + await writeSettings(settingsPath, settings); + return { status: 'installed' }; +} + +/** Remove our status line, leaving a foreign one alone. */ +export async function removeStatusLine(settingsPath: string = claudeSettingsPath()): Promise { + const settings = await readSettings(settingsPath); + if (!isOurStatusLine(settings.statusLine)) return false; + delete settings.statusLine; + await writeSettings(settingsPath, settings); + return true; +} + +/** + * The `/mcpctl` slash command. + * + * Claude Code slash commands are prompt files, not code — so unlike opencode's + * picker this drives the model through CLI calls. `allowed-tools` is scoped to + * the exact `mcpctl` invocations it needs, so accepting the command does not + * hand it a general shell. + * + * Every `!`-prefixed block below is pre-executed by Claude Code and checked + * against that same list — including `statusline`, which is easy to forget + * because it is context-gathering rather than an action. Omitting one fails the + * whole command with a permission error before the model sees anything. + * + * `--skip-marker` matters here for the same reason it does in the opencode + * switcher: the session's directory is whatever you happened to open, and + * re-scoping it would silently change which skills sync into it. + */ +export const MCPCTL_SLASH_COMMAND = `--- +description: Switch the active mcpctl project (MCP servers + skills) +allowed-tools: Bash(mcpctl statusline:*), Bash(mcpctl get projects:*), Bash(mcpctl config claude:*), Bash(mcpctl skills sync:*) +--- + +# Switch the active mcpctl project + +The user wants to change which mcpctl project this session is connected to. +There is exactly one mcpctl MCP server, named \`${MCPCTL_SERVER_NAME}\`; switching +projects changes what sits behind that name. + +Requested project (may be empty): $ARGUMENTS + +## Steps + +1. Show the current project and the available ones: + + !\`mcpctl statusline --prefix 'current: ' --directory .\` + !\`mcpctl get projects -o json\` + +2. If \$ARGUMENTS names a project, use it. Otherwise list the projects + compactly (name — description) and ask which one. Do not guess. + +3. Switch, keeping this directory's scope unchanged: + + \`mcpctl config claude --project --skip-marker\` + +4. Report the switch as "now on ". Do not describe the project as + the server — the server is always \`${MCPCTL_SERVER_NAME}\`, only what sits + behind it changed. + +5. Tell the user, in one short line, that they must now **reconnect the + \`${MCPCTL_SERVER_NAME}\` server from \`/mcp\`** for the new project's tools to + load. The config on disk is already correct; the running session still holds + the old connection until it is reconnected. + +Keep the whole exchange to a few lines. This is a switcher, not a report. +`; + +/** Write the `/mcpctl` slash command into Claude Code's user commands dir. */ +export async function installSlashCommand(path: string = claudeCommandPath()): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, MCPCTL_SLASH_COMMAND, 'utf-8'); + return path; +} diff --git a/src/cli/src/utils/opencode-settings.ts b/src/cli/src/utils/opencode-settings.ts new file mode 100644 index 0000000..13f2d76 --- /dev/null +++ b/src/cli/src/utils/opencode-settings.ts @@ -0,0 +1,204 @@ +/** + * Wiring helpers for the opencode integration. + * + * `mcpctl config opencode --project X`: + * 1. writes the embedded plugin sources into opencode's config dir + * (`plugin/mcpctl.ts` — auto-discovered server plugin — and + * `mcpctl/mcpctl-tui.tsx` — the `/mcpctl` switcher + footer indicator), + * 2. registers the TUI plugin in `tui.json` (opencode does not auto-discover + * TUI plugins; they have to be listed), + * 3. writes `~/.mcpctl/opencode-state.json` (0600) with the active project, + * the gateway URL and the per-project bearer tokens. + * + * WHY THE STATE FILE AND NOT `opencode.json`: + * opencode's own `mcp` block is a fine way to mount a *static* server, but + * this integration needs two things it cannot give. The gateway wants an + * `Authorization` header, and opencode.json is a mode-0644 file users paste + * into bug reports; and switching projects has to work without restarting + * opencode, which a config file cannot do. The plugins read the state file + * and mount through opencode's live MCP API instead, so `opencode.json` is + * never touched at all. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `~/.pi/`. + */ +import { readFile, writeFile, mkdir, rename, chmod } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_SOURCE, +} from '../config/opencode-extension.js'; + +/** + * opencode's config directory. + * + * opencode is XDG-aware, so an install with `XDG_CONFIG_HOME` set keeps its + * config somewhere other than `~/.config`. Reading the same variable is what + * stops us provisioning a directory opencode will never look at. + */ +export function opencodeConfigDir(env: NodeJS.ProcessEnv = process.env, homeDir: string = homedir()): string { + const xdg = env['XDG_CONFIG_HOME']; + const base = xdg !== undefined && xdg !== '' ? xdg : join(homeDir, '.config'); + return join(base, 'opencode'); +} + +/** Resolve every path `config opencode` writes, under an explicit config dir. */ +export function withOpencodeDir(base: string): { + serverPluginPath: () => string; + tuiPluginDir: () => string; + tuiPluginPath: () => string; + tuiJsonPath: () => string; + skillsDir: () => string; +} { + return { + serverPluginPath: () => join(base, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME), + tuiPluginDir: () => join(base, 'mcpctl'), + tuiPluginPath: () => join(base, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME), + tuiJsonPath: () => join(base, 'tui.json'), + skillsDir: () => join(base, 'skill'), + }; +} + +/** Where `mcpctl skills sync --agent opencode` installs skill bundles. */ +export function opencodeSkillsDir(env?: NodeJS.ProcessEnv, homeDir?: string): string { + return withOpencodeDir(opencodeConfigDir(env, homeDir)).skillsDir(); +} + +/** Path of the state file both plugins read. */ +export function opencodeStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'opencode-state.json'); +} + +/** + * Write the embedded plugin sources into opencode's config dir. + * + * This is the production path — it needs no source tree, so it works from an + * installed binary. Returns the files written. + */ +export async function installOpencodePlugins(base: string): Promise { + const paths = withOpencodeDir(base); + const written: string[] = []; + for (const [path, source] of [ + [paths.serverPluginPath(), OPENCODE_SERVER_PLUGIN_SOURCE], + [paths.tuiPluginPath(), OPENCODE_TUI_PLUGIN_SOURCE], + ] as const) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, source, 'utf-8'); + written.push(path); + } + return written; +} + +interface TuiConfig { + $schema?: string; + plugin?: unknown[]; + [k: string]: unknown; +} + +/** + * Load tui.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it. One syntax + * error must not silently drop every other TUI plugin the user installed. + */ +async function readTuiConfig(path: string): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf-8'); + } catch (err: unknown) { + if ((err as { code?: string }).code === 'ENOENT') return {}; + throw new Error(`failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw.trim().length === 0) return {}; + try { + const parsed = JSON.parse(raw) as TuiConfig; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error( + `${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`, + ); + } +} + +async function writeJsonAtomic(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(value, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Register the TUI plugin in `tui.json`. + * + * Idempotent, and a merge rather than a rewrite: any other TUI plugin the user + * has installed keeps working. Returns whether anything changed, so a no-op run + * doesn't reformat a hand-maintained file. + */ +export async function registerOpencodeTuiPlugin(tuiJsonPath: string, pluginPath: string): Promise<{ added: boolean }> { + const config = await readTuiConfig(tuiJsonPath); + const plugins = Array.isArray(config.plugin) ? [...config.plugin] : []; + if (plugins.some((p) => p === pluginPath)) return { added: false }; + + // Drop any stale entry pointing at an older install location of *our* plugin + // (e.g. a rename between releases): leaving it behind makes opencode fail to + // load a file that no longer exists on every start. + const kept = plugins.filter((p) => !(typeof p === 'string' && p.endsWith(`/${OPENCODE_TUI_PLUGIN_FILENAME}`))); + kept.push(pluginPath); + + config.$schema = typeof config.$schema === 'string' ? config.$schema : 'https://opencode.ai/tui.json'; + config.plugin = kept; + await writeJsonAtomic(tuiJsonPath, config); + return { added: true }; +} + +export interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +/** Read the state file; missing or corrupt yields an empty state. */ +export async function readOpencodeState(path: string = opencodeStatePath()): Promise { + try { + const parsed = JSON.parse(await readFile(path, 'utf-8')) as OpencodeState; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +/** + * Set the active project (and optionally its bearer token) in the state file. + * + * Tokens for other projects are preserved so switching back to a project + * already provisioned on this machine needs no new mint — and so a mint failure + * for project B cannot cost you the credential for project A. + * + * Written 0600: this file holds bearer tokens. `writeFile`'s mode only applies + * when the file is created, so we chmod after writing too. + */ +export async function writeOpencodeState( + update: { project: string; gatewayUrl: string; token?: string }, + path: string = opencodeStatePath(), +): Promise { + const current = await readOpencodeState(path); + const tokens = { ...(current.tokens ?? {}) }; + if (update.token !== undefined && update.token !== '') tokens[update.project] = update.token; + const next: OpencodeState = { project: update.project, gatewayUrl: update.gatewayUrl, tokens }; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 }); + try { + await chmod(path, 0o600); + } catch { + /* best-effort: a credential written is better than one refused */ + } + return path; +} + +/** The token currently stored for `project`, if any. */ +export function storedToken(state: OpencodeState, project: string): string | null { + const token = state.tokens?.[project]; + return typeof token === 'string' && token !== '' ? token : null; +} diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts index 49a9479..47f9a54 100644 --- a/src/cli/src/utils/sessionhook.ts +++ b/src/cli/src/utils/sessionhook.ts @@ -39,7 +39,12 @@ interface Settings { } function defaultSettingsPath(): string { - return join(homedir(), '.claude', 'settings.json'); + // CLAUDE_CONFIG_DIR is Claude Code's own override. Honouring it also stops + // the test suite writing a hook into the developer's real ~/.claude — which + // is how an untagged duplicate of this very hook got there in the first place. + const override = process.env['CLAUDE_CONFIG_DIR']; + const base = override !== undefined && override !== '' ? override : join(homedir(), '.claude'); + return join(base, 'settings.json'); } async function readSettings(path: string): Promise { @@ -95,6 +100,32 @@ export async function installManagedSessionHook( } } + // Drop untagged copies of the very command we manage. + // + // Before this installer carried a marker — and, for a long while, whenever + // the test suite ran against the developer's real ~/.claude — an identical + // but untagged row could be left behind. It is invisible in the UI and simply + // runs the sync a second time on every session start. Scoped to an exact + // string match on our own command, so a hook someone wrote themselves (even + // one that also calls `mcpctl skills sync`, but with different flags) is + // never touched. + if (foundEntry) { + for (const group of groups) { + if (!Array.isArray(group?.hooks)) continue; + const kept = group.hooks.filter((e) => e[MARKER_KEY] === true || e.command !== command); + if (kept.length !== group.hooks.length) { + group.hooks = kept; + entryChanged = true; + } + } + // A group we emptied is noise in the file. + const nonEmpty = groups.filter((g) => !Array.isArray(g.hooks) || g.hooks.length > 0); + if (nonEmpty.length !== groups.length) { + settings.hooks.SessionStart = nonEmpty; + entryChanged = true; + } + } + if (!foundEntry) { groups.push({ hooks: [{ type: 'command', command, [MARKER_KEY]: true }], diff --git a/src/cli/tests/commands/chat-thread-brick.test.ts b/src/cli/tests/commands/chat-thread-brick.test.ts new file mode 100644 index 0000000..0e09392 --- /dev/null +++ b/src/cli/tests/commands/chat-thread-brick.test.ts @@ -0,0 +1,116 @@ +/** + * Regression: a failed first turn must not brick the REPL. + * + * Observed live: the first message died upstream (anthropic 429) before the + * stream's `final` frame, streamOnce resolved '' as the thread id, the REPL + * stored it, and every later message sent `threadId: ""` — which mcpd's + * `z.string().min(1)` rejects with HTTP 400. The session was permanently + * stuck: no turn could succeed again, so no `final` frame could ever repair + * the thread id. + * + * The fix has two independent layers, pinned separately below: + * 1. streamOnce resolves `undefined` (not '') when no `final` frame arrived, + * and the REPL keeps its previous thread state on undefined; + * 2. chatBody never serializes an empty threadId, even if one leaks in. + */ +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { describe, it, expect, afterEach } from 'vitest'; + +import { chatBody, streamOnce } from '../../src/commands/chat.js'; +import type { ChatCommandDeps, ChatSubject } from '../../src/commands/chat.js'; +import type { ApiClient } from '../../src/api-client.js'; + +const subject: ChatSubject = { + kind: 'agent', + name: 'reviewer', + path: 'agents/reviewer', + allowSecrets: false, +}; + +// streamOnce only touches baseUrl + token; the ApiClient is for the +// non-streaming path and never dereferenced here. +function depsFor(baseUrl: string): ChatCommandDeps { + return { client: null as unknown as ApiClient, baseUrl, log: () => {} }; +} + +let server: http.Server | null = null; + +afterEach(async () => { + if (server !== null) { + await new Promise((r) => server!.close(() => r())); + server = null; + } +}); + +/** Serve one SSE response body for any POST, return the base URL. */ +async function serveSse(frames: string[]): Promise { + server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const f of frames) res.write(`data: ${f}\n\n`); + res.end(); + }); + await new Promise((r) => server!.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${String(port)}`; +} + +describe('chatBody — threadId serialization', () => { + it('omits threadId when undefined', () => { + expect(chatBody(subject, 'hi', undefined, {})).not.toHaveProperty('threadId'); + }); + + it('omits threadId when empty — the exact payload that 400s against mcpd', () => { + expect(chatBody(subject, 'hi', '', {})).not.toHaveProperty('threadId'); + }); + + it('includes a real threadId', () => { + expect(chatBody(subject, 'hi', 'cthread123', {})).toHaveProperty('threadId', 'cthread123'); + }); +}); + +describe('streamOnce — thread id after a failed turn', () => { + it('resolves undefined when the stream errors before any final frame', async () => { + const base = await serveSse([ + '{"type":"error","message":"anthropic stream: HTTP 429"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {}); + expect(resolved).toBeUndefined(); + }); + + it('keeps the caller-supplied thread when the turn fails mid-conversation', async () => { + const base = await serveSse([ + '{"type":"error","message":"upstream died"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', 'cexisting1', {}); + expect(resolved).toBe('cexisting1'); + }); + + it('resolves the threadId announced by the final frame', async () => { + const base = await serveSse([ + '{"type":"text","delta":"pong"}', + '{"type":"final","threadId":"cfresh42"}', + '[DONE]', + ]); + const resolved = await streamOnce(depsFor(base), subject, 'hi', undefined, {}); + expect(resolved).toBe('cfresh42'); + }); + + it('REPL chain: failed turn 1 leaves turn 2 sendable (the brick)', async () => { + const base = await serveSse([ + '{"type":"error","message":"anthropic stream: HTTP 429"}', + '[DONE]', + ]); + // Mirrors runRepl's assignment: threadId = streamOnce(...) ?? threadId + let threadId: string | undefined = undefined; + threadId = (await streamOnce(depsFor(base), subject, 'hi', threadId, {})) ?? threadId; + + // Turn 2's body must be valid for mcpd: no threadId key at all. + const body = chatBody(subject, 'hi again', threadId, {}, true); + expect(body).not.toHaveProperty('threadId'); + expect(body).toHaveProperty('message', 'hi again'); + }); +}); diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 9eb0053..f29a00b 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createConfigCommand } from '../../src/commands/config.js'; @@ -21,14 +21,29 @@ describe('config claude', () => { let tmpDir: string; const log = (...args: string[]) => output.push(args.join(' ')); + /** + * Claude Code's config dir, redirected per test. + * + * Without this the suite writes a SessionStart hook, a status line and a + * slash command into the developer's real ~/.claude — which is exactly how an + * untagged duplicate of the skills-sync hook ended up there. + */ + let claudeDir: string; + let priorClaudeConfigDir: string | undefined; + beforeEach(() => { client = mockClient(); output = []; tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-')); + claudeDir = join(tmpDir, 'claude-home'); + priorClaudeConfigDir = process.env['CLAUDE_CONFIG_DIR']; + process.env['CLAUDE_CONFIG_DIR'] = claudeDir; }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); + if (priorClaudeConfigDir === undefined) delete process.env['CLAUDE_CONFIG_DIR']; + else process.env['CLAUDE_CONFIG_DIR'] = priorClaudeConfigDir; }); it('generates .mcp.json with mcpctl mcp bridge entry', async () => { @@ -46,7 +61,7 @@ describe('config claude', () => { expect(client.get).not.toHaveBeenCalled(); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['homeautomation']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'], }); @@ -61,7 +76,7 @@ describe('config claude', () => { await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' }); const parsed = JSON.parse(output[0]); - expect(parsed.mcpServers['myproj']).toEqual({ + expect(parsed.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'myproj'], }); @@ -81,7 +96,7 @@ describe('config claude', () => { const written = JSON.parse(readFileSync(outPath, 'utf-8')); expect(written.mcpServers['existing--server']).toBeDefined(); - expect(written.mcpServers['proj-1']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'proj-1'], }); @@ -113,7 +128,7 @@ describe('config claude', () => { await cmd.parseAsync(['claude', '--project', 'ha', '--inspect', '-o', outPath], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['ha']).toBeDefined(); + expect(written.mcpServers['mcpctl']).toBeDefined(); expect(written.mcpServers['mcpctl-inspect']).toBeDefined(); expect(output.join('\n')).toContain('2 server(s)'); }); @@ -127,21 +142,61 @@ describe('config claude', () => { await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['proj-1']).toEqual({ + expect(written.mcpServers['mcpctl']).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'proj-1'], }); }); - it('uses project name as the server key', async () => { + it('uses one constant server key, whatever the project is called', async () => { + // The key used to be the project name, so `config claude` for a second + // project left the first one mounted too — every project ever configured + // stayed connected, with duplicate tool names. const outPath = join(tmpDir, '.mcp.json'); - const cmd = createConfigCommand( - { configDeps: { configDir: tmpDir }, log }, - ); - await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' }); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath, '--skip-skills'], { from: 'user' }); const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']); + expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']); + expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'my-fancy-project']); + }); + + it('switching projects replaces the mount instead of stacking a second one', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'first', '-o', outPath, '--skip-skills'], { from: 'user' }); + await cmd.parseAsync(['claude', '--project', 'second', '-o', outPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(Object.keys(written.mcpServers)).toEqual(['mcpctl']); + expect(written.mcpServers['mcpctl'].args).toEqual(['mcp', '-p', 'second']); + }); + + it('retires a legacy project-named entry left by an older CLI', async () => { + const outPath = join(tmpDir, '.mcp.json'); + writeFileSync(outPath, JSON.stringify({ + mcpServers: { + homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] }, + 'my-own-server': { command: 'echo', args: [] }, + }, + })); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'docmost', '-o', outPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(Object.keys(written.mcpServers).sort()).toEqual(['mcpctl', 'my-own-server']); + expect(output.join('\n')).toContain('Retired legacy per-project entry: homeautomation'); + }); + + it('--dry-run reports the plan and writes nothing', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createConfigCommand({ configDeps: { configDir: tmpDir }, log }); + await cmd.parseAsync(['claude', '--project', 'p', '-o', outPath, '--dry-run'], { from: 'user' }); + + const plan = JSON.parse(output.join('\n')); + expect(plan.claude.server).toBe('mcpctl'); + expect(plan.claude.entry.args).toEqual(['mcp', '-p', 'p']); + expect(existsSync(outPath)).toBe(false); }); }); @@ -223,3 +278,127 @@ describe('config impersonate', () => { expect(output.join('\n')).toContain('No impersonation session to quit'); }); }); + +describe('config claude — user scope', () => { + let output: string[]; + let tmpDir: string; + let claudeDir: string; + let prior: string | undefined; + const log = (...args: string[]): void => { output.push(args.join(' ')); }; + const claudeJson = (): string => join(claudeDir, '.claude.json'); + + beforeEach(() => { + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-user-')); + claudeDir = join(tmpDir, 'claude-home'); + mkdirSync(claudeDir, { recursive: true }); + prior = process.env['CLAUDE_CONFIG_DIR']; + process.env['CLAUDE_CONFIG_DIR'] = claudeDir; + }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + if (prior === undefined) delete process.env['CLAUDE_CONFIG_DIR']; + else process.env['CLAUDE_CONFIG_DIR'] = prior; + }); + + const cmd = () => createConfigCommand({ configDeps: {}, log }); + + it('registers in .claude.json by default, not a per-directory .mcp.json', async () => { + // The whole point: wire it once, not in every checkout you open. + await cmd().parseAsync(['claude', '--project', 'homeautomation', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.mcpServers.mcpctl).toEqual({ command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] }); + expect(output.join('\n')).toContain('every directory'); + }); + + it('preserves everything else in .claude.json', async () => { + // That file also holds onboarding state, caches and the per-project map. + writeFileSync(claudeJson(), JSON.stringify({ + numStartups: 42, + mcpServers: { 'taskmaster-ai': { type: 'stdio', command: 'task-master-ai' } }, + projects: { '/some/repo': { allowedTools: [] } }, + })); + await cmd().parseAsync(['claude', '--project', 'p', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(parsed.numStartups).toBe(42); + expect(parsed.projects).toEqual({ '/some/repo': { allowedTools: [] } }); + expect(parsed.mcpServers['taskmaster-ai']).toBeDefined(); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'p']); + }); + + it('switching re-points the one entry', async () => { + await cmd().parseAsync(['claude', '--project', 'a', '--skip-skills', '--skip-ui'], { from: 'user' }); + await cmd().parseAsync(['claude', '--project', 'b', '--skip-skills', '--skip-ui'], { from: 'user' }); + const parsed = JSON.parse(readFileSync(claudeJson(), 'utf-8')); + expect(Object.keys(parsed.mcpServers)).toEqual(['mcpctl']); + expect(parsed.mcpServers.mcpctl.args).toEqual(['mcp', '-p', 'b']); + }); + + it('writes no .mcpctl-project marker — user scope is not directory-specific', async () => { + // A marker beside .claude.json would sit in $HOME and scope every repo under it. + const cwd = process.cwd(); + process.chdir(tmpDir); + try { + await cmd().parseAsync(['claude', '--project', 'p', '--skip-ui'], { from: 'user' }); + expect(existsSync(join(tmpDir, '.mcpctl-project'))).toBe(false); + expect(output.join('\n')).toContain('not directory-specific'); + } finally { process.chdir(cwd); } + }); + + it('an explicit --output still means the per-directory file', async () => { + const outPath = join(tmpDir, '.mcp.json'); + await cmd().parseAsync(['claude', '--project', 'p', '-o', outPath, '--skip-skills', '--skip-ui'], { from: 'user' }); + expect(existsSync(outPath)).toBe(true); + expect(existsSync(claudeJson())).toBe(false); + }); + + it('rejects an unknown scope instead of silently picking one', async () => { + const prevExit = process.exitCode; + await cmd().parseAsync(['claude', '--project', 'p', '--scope', 'global', '--skip-skills'], { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = prevExit; + expect(output.join('\n')).toContain("unknown --scope 'global'"); + }); + + // A user-scope switch never rewrites a directory's .mcp.json, so anything of + // ours left in one keeps answering in that directory. Saying so is the only + // way the user finds out — the switch otherwise reports plain success. + describe('warns when the working directory contradicts the switch', () => { + const switchTo = async (project: string): Promise => { + await createConfigCommand({ configDeps: {}, log, cwd: () => tmpDir }) + .parseAsync(['claude', '--project', project, '--skip-skills', '--skip-ui'], { from: 'user' }); + return output.join('\n'); + }; + + it('names a legacy entry that stays mounted alongside the new project', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { homeautomation: { command: 'mcpctl', args: ['mcp', '-p', 'homeautomation'] } }, + })); + const out = await switchTo('sre'); + expect(out).toContain(join(tmpDir, '.mcp.json')); + expect(out).toContain("'homeautomation'"); + expect(out).toContain('mounted alongside'); + }); + + it('says a canonical pin overrides the switch in that directory', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'docmost'] } }, + })); + expect(await switchTo('sre')).toContain('overrides the switch here'); + }); + + it('stays quiet when the directory already agrees, or wires nothing of ours', async () => { + writeFileSync(join(tmpDir, '.mcp.json'), JSON.stringify({ + mcpServers: { + mcpctl: { command: 'mcpctl', args: ['mcp', '-p', 'sre'] }, + 'their-server': { command: 'docker', args: ['run', 'x'] }, + }, + })); + expect(await switchTo('sre')).not.toContain('Warning:'); + }); + + it('stays quiet when there is no .mcp.json at all', async () => { + expect(await switchTo('sre')).not.toContain('Warning:'); + }); + }); +}); diff --git a/src/cli/tests/commands/config-opencode.test.ts b/src/cli/tests/commands/config-opencode.test.ts new file mode 100644 index 0000000..42d2685 --- /dev/null +++ b/src/cli/tests/commands/config-opencode.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; + +interface ClientCalls { posts: Array<{ path: string; body?: unknown }> } + +/** + * @param tokens what `GET /api/v1/mcptokens` reports as existing for a project + */ +function mockClient(calls: ClientCalls, tokens: unknown[] = []): ApiClient { + return { + get: vi.fn(async (path: string) => { + if (path.startsWith('/api/v1/mcptokens')) return tokens; + if (path.endsWith('/skills/visible')) return []; + return {}; + }), + post: vi.fn(async (path: string, body?: unknown) => { + calls.posts.push({ path, body }); + if (path === '/api/v1/mcptokens') return { token: 'mcpctl_pat_MINTED0000000000' }; + return {}; + }), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config opencode', () => { + let output: string[]; + let tmpDir: string; + let ocDir: string; + let calls: ClientCalls; + const log = (...args: string[]): void => { output.push(args.join(' ')); }; + const statePath = (): string => join(ocDir, 'mcpctl-state.json'); + + /** Pre-existing state, as a machine that has already run this command has. */ + function seedState(value: unknown): void { + mkdirSync(ocDir, { recursive: true }); + writeFileSync(statePath(), JSON.stringify(value)); + } + + function command(client: ApiClient) { + return createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + } + + beforeEach(() => { + output = []; + calls = { posts: [] }; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-oc-')); + ocDir = join(tmpDir, 'opencode'); + }); + afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); + + it('requires a project', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--opencode-dir', ocDir, '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(existsSync(statePath())).toBe(false); + }); + + it('installs both plugins, registers the TUI one and writes the state file', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'docmost', '--opencode-dir', ocDir, + '--gateway-url', 'https://gw.example', '--token', 'mcpctl_pat_SUPPLIED000000', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + + expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(true); + expect(existsSync(join(ocDir, 'mcpctl', 'mcpctl-tui.tsx'))).toBe(true); + + const tui = JSON.parse(readFileSync(join(ocDir, 'tui.json'), 'utf-8')); + expect(tui.plugin).toEqual([join(ocDir, 'mcpctl', 'mcpctl-tui.tsx')]); + + const state = JSON.parse(readFileSync(statePath(), 'utf-8')); + expect(state).toEqual({ + project: 'docmost', + gatewayUrl: 'https://gw.example', + tokens: { docmost: 'mcpctl_pat_SUPPLIED000000' }, + }); + }); + + it('strips a trailing slash from the gateway URL so the mount URL stays canonical', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--gateway-url', 'https://gw.example/', + '--token', 't', '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).gatewayUrl).toBe('https://gw.example'); + }); + + it('--token never mints', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0); + }); + + it('mints a uniquely-named opencode token when none is stored', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + const mint = calls.posts.find((c) => c.path === '/api/v1/mcptokens'); + expect(mint).toBeDefined(); + // A fixed name could only ever be minted once per project: McpToken is + // unique on (name, projectId) and revoke is a soft delete. + expect((mint?.body as { name: string }).name).toMatch(/^opencode-/); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000'); + }); + + it('reuses a stored token that the server still reports as active', async () => { + // 16-char prefix is what the server exposes; the secret is never re-sent. + const stored = 'mcpctl_pat_STORED000000000'; + seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } }); + const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'active', tokenPrefix: stored.slice(0, 16) }]); + await command(client).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(0); + expect(output.join('\n')).toContain('already present'); + }); + + it('re-mints when the stored token has been revoked server-side', async () => { + const stored = 'mcpctl_pat_REVOKED00000000'; + seedState({ project: 'p', gatewayUrl: 'https://gw', tokens: { p: stored } }); + const client = mockClient(calls, [{ id: '1', name: 'opencode-x', status: 'revoked', tokenPrefix: stored.slice(0, 16) }]); + await command(client).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(calls.posts.filter((c) => c.path === '/api/v1/mcptokens')).toHaveLength(1); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).tokens.p).toBe('mcpctl_pat_MINTED0000000000'); + }); + + it('leaves the previous project mounted when no credential can be provisioned', async () => { + // A switch with no usable credential is a FAILURE: exiting 0 here would + // have the /mcpctl switcher report success over a project with no tools. + seedState({ project: 'old', gatewayUrl: 'https://gw', tokens: { old: 'tok-old' } }); + const client = { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), // mint returns no token + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + const prevExit = process.exitCode; + await command(client).parseAsync( + ['opencode', '--project', 'new', '--opencode-dir', ocDir, '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(process.exitCode).toBe(1); + process.exitCode = prevExit; + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('old'); + expect(output.join('\n')).toContain('Aborted'); + }); + + it('--skip-plugin updates state without touching the loaded plugin files', async () => { + // This is the path the /mcpctl switcher takes: rewriting the very file + // opencode has already loaded buys nothing. + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-plugin', '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(existsSync(join(ocDir, 'plugin', 'mcpctl.ts'))).toBe(false); + expect(existsSync(join(ocDir, 'tui.json'))).toBe(false); + expect(JSON.parse(readFileSync(statePath(), 'utf-8')).project).toBe('p'); + }); + + it('--dry-run reports the plan and writes nothing', async () => { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--dry-run'], { from: 'user' }); + const plan = JSON.parse(output.join('\n')); + expect(plan.opencode.mcpUrl).toContain('/projects/p/mcp'); + expect(plan.opencode.statePath).toBe(statePath()); + expect(existsSync(ocDir)).toBe(false); + expect(calls.posts).toHaveLength(0); + }); + + it('--skip-marker leaves the working directory unscoped', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', + '--skip-skills', '--skip-marker'], + { from: 'user' }); + expect(existsSync(join(workDir, '.mcpctl-project'))).toBe(false); + } finally { + process.chdir(cwd); + } + }); + + it('writes a .mcpctl-project marker by default', async () => { + const cwd = process.cwd(); + const workDir = join(tmpDir, 'work2'); + mkdirSync(workDir, { recursive: true }); + process.chdir(workDir); + try { + await command(mockClient(calls)).parseAsync( + ['opencode', '--project', 'p', '--opencode-dir', ocDir, '--token', 't', '--skip-skills'], + { from: 'user' }); + expect(readFileSync(join(workDir, '.mcpctl-project'), 'utf-8')).toContain('p'); + } finally { + process.chdir(cwd); + } + }); +}); diff --git a/src/cli/tests/commands/statusline.test.ts b/src/cli/tests/commands/statusline.test.ts new file mode 100644 index 0000000..da6b40b --- /dev/null +++ b/src/cli/tests/commands/statusline.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStatuslineCommand } from '../../src/commands/statusline.js'; + +/** + * The status line is what tells you which project you are in, so the property + * under test throughout is: after a switch, does it name the project you + * switched to? + * + * These drive the real command rather than the resolution helpers, because the + * bug they cover was in the *ranking* of sources, not in any one source. + */ + +const bridge = (project: string): Record => ({ + command: 'mcpctl', + args: ['mcp', '-p', project], +}); + +let home: string; +let dir: string; + +/** Claude Code's user-scope config, at the path `claudeJsonPath()` resolves. */ +function writeClaudeJson(doc: unknown): void { + writeFileSync(join(home, '.claude.json'), JSON.stringify(doc)); +} + +function writeMcpJson(doc: unknown): void { + writeFileSync(join(dir, '.mcp.json'), JSON.stringify(doc)); +} + +/** Run `statusline` for `dir` and return exactly what it printed. */ +async function statusline(): Promise { + const out: string[] = []; + const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home }); + await cmd.parseAsync(['--directory', dir], { from: 'user' }); + return out.join(''); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-home-')); + dir = mkdtempSync(join(tmpdir(), 'mcpctl-statusline-dir-')); + // Point claudeJsonPath() at the fake home; the CLI reads $CLAUDE_CONFIG_DIR + // first, which keeps this off the developer's real ~/.claude.json. + process.env['CLAUDE_CONFIG_DIR'] = home; +}); + +afterEach(() => { + delete process.env['CLAUDE_CONFIG_DIR']; + rmSync(home, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('mcpctl statusline', () => { + it('reports the user-scope project when the directory wires nothing', async () => { + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('prints nothing at all when no project is active', async () => { + writeClaudeJson({ mcpServers: {} }); + expect(await statusline()).toBe(''); + }); + + it('lets a canonical .mcp.json pin override the user-scope project', async () => { + // Same server name in both scopes: Claude Code prefers project scope, so a + // deliberate pin is genuinely what is mounted here. + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } }); + expect(await statusline()).toBe('mcpctl:docmost'); + }); + + it('does not let a legacy project-named entry outrank a user-scope switch', async () => { + // The regression: an older mcpctl wrote `homeautomation` into a checkout, + // and a user-scope switch never rewrites that file — so the status line + // reported the old project forever and the switch looked like a no-op. + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('still reports a legacy entry when nothing more deliberate names a project', async () => { + writeClaudeJson({ mcpServers: {} }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe('mcpctl:homeautomation'); + }); + + it('skips a directory server Claude Code has switched off', async () => { + // A disabled server is not mounted, so naming its project is a lie. + writeClaudeJson({ + mcpServers: {}, + projects: { [dir]: { disabledMcpServers: ['homeautomation'] } }, + }); + writeMcpJson({ mcpServers: { homeautomation: bridge('homeautomation') } }); + expect(await statusline()).toBe(''); + }); + + it('skips a disabled pin and falls through to the user-scope project', async () => { + writeClaudeJson({ + mcpServers: { mcpctl: bridge('sre') }, + projects: { [dir]: { disabledMcpjsonServers: ['mcpctl'] } }, + }); + writeMcpJson({ mcpServers: { mcpctl: bridge('docmost') } }); + expect(await statusline()).toBe('mcpctl:sre'); + }); + + it('falls back to a .mcpctl-project marker when nothing is wired', async () => { + writeClaudeJson({ mcpServers: {} }); + writeFileSync(join(dir, '.mcpctl-project'), 'lab\n'); + expect(await statusline()).toBe('mcpctl:lab'); + }); + + it('honours a custom prefix', async () => { + writeClaudeJson({ mcpServers: { mcpctl: bridge('sre') } }); + const out: string[] = []; + const cmd = createStatuslineCommand({ log: (l) => out.push(l), cwd: () => dir, homeDir: () => home }); + await cmd.parseAsync(['--directory', dir, '--prefix', 'proj '], { from: 'user' }); + expect(out.join('')).toBe('proj sre'); + }); +}); diff --git a/src/cli/tests/config/claude-mcp.test.ts b/src/cli/tests/config/claude-mcp.test.ts new file mode 100644 index 0000000..d4541da --- /dev/null +++ b/src/cli/tests/config/claude-mcp.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { + MCPCTL_SERVER_NAME, + mergeMcpctlServers, + projectOfEntry, + isLegacyMcpctlEntry, + activeProjectIn, + canonicalProjectIn, + legacyEntriesIn, + disabledServersFor, +} from '../../src/config/claude-mcp.js'; + +const bridge = (project: string): Record => ({ + command: 'mcpctl', + args: ['mcp', '-p', project], +}); + +describe('projectOfEntry', () => { + it('reads the project out of the bridge args', () => { + expect(projectOfEntry(bridge('docmost'))).toBe('docmost'); + expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '--project', 'sre'] })).toBe('sre'); + }); + + it('ignores anything that is not our stdio bridge', () => { + expect(projectOfEntry({ command: 'echo', args: ['mcp', '-p', 'x'] })).toBeNull(); + expect(projectOfEntry({ command: 'mcpctl', args: ['console', '--stdin-mcp'] })).toBeNull(); + expect(projectOfEntry({ type: 'remote', url: 'https://x/projects/y/mcp' })).toBeNull(); + expect(projectOfEntry({ command: 'mcpctl', args: ['mcp', '-p'] })).toBeNull(); + expect(projectOfEntry(null)).toBeNull(); + expect(projectOfEntry('nope')).toBeNull(); + }); +}); + +describe('isLegacyMcpctlEntry', () => { + it('recognises an entry named after the very project it bridges to', () => { + expect(isLegacyMcpctlEntry('docmost', bridge('docmost'))).toBe(true); + }); + + it('never claims the canonical entry', () => { + expect(isLegacyMcpctlEntry(MCPCTL_SERVER_NAME, bridge('docmost'))).toBe(false); + }); + + it('leaves a hand-configured server alone', () => { + // Same name, different command — someone else's server. + expect(isLegacyMcpctlEntry('docmost', { command: 'docker', args: ['run', 'docmost'] })).toBe(false); + // Our command, but the name does not match the project: not something this + // CLI ever wrote, so it is the user's to keep. + expect(isLegacyMcpctlEntry('my-shortcut', bridge('docmost'))).toBe(false); + }); +}); + +describe('activeProjectIn', () => { + it('prefers the canonical entry', () => { + expect(activeProjectIn({ mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre') } })).toBe('sre'); + }); + + it('falls back to a legacy entry so pre-migration installs still report', () => { + expect(activeProjectIn({ mcpServers: { docmost: bridge('docmost') } })).toBe('docmost'); + }); + + it('is null when nothing of ours is mounted', () => { + expect(activeProjectIn({ mcpServers: { other: { command: 'echo' } } })).toBeNull(); + expect(activeProjectIn(null)).toBeNull(); + expect(activeProjectIn({ mcpServers: {} })).toBeNull(); + }); +}); + +describe('canonicalProjectIn / legacyEntriesIn', () => { + it('tells a deliberate pin apart from pre-migration residue', () => { + const config = { mcpServers: { [MCPCTL_SERVER_NAME]: bridge('sre'), homeautomation: bridge('homeautomation') } }; + expect(canonicalProjectIn(config)).toBe('sre'); + expect(legacyEntriesIn(config)).toEqual([{ server: 'homeautomation', project: 'homeautomation' }]); + }); + + it('reports no canonical entry when only legacy ones are present', () => { + const config = { mcpServers: { docmost: bridge('docmost') } }; + expect(canonicalProjectIn(config)).toBeNull(); + expect(legacyEntriesIn(config)).toEqual([{ server: 'docmost', project: 'docmost' }]); + }); + + it('leaves servers that are not ours out of both', () => { + const config = { mcpServers: { other: { command: 'echo' } } }; + expect(canonicalProjectIn(config)).toBeNull(); + expect(legacyEntriesIn(config)).toEqual([]); + expect(legacyEntriesIn(null)).toEqual([]); + }); +}); + +describe('disabledServersFor', () => { + const doc = { + projects: { + '/repo': { disabledMcpServers: ['homeautomation'], disabledMcpjsonServers: ['mcpctl'] }, + '/other': { disabledMcpServers: ['sre'] }, + }, + }; + + it('unions both of Claude Code\'s disable lists for that directory', () => { + expect([...disabledServersFor(doc, '/repo')].sort()).toEqual(['homeautomation', 'mcpctl']); + }); + + it('is scoped to the directory asked about', () => { + expect([...disabledServersFor(doc, '/other')]).toEqual(['sre']); + expect([...disabledServersFor(doc, '/unknown')]).toEqual([]); + expect([...disabledServersFor(null, '/repo')]).toEqual([]); + }); + + it('survives a malformed entry rather than throwing on the status line', () => { + expect([...disabledServersFor({ projects: { '/repo': { disabledMcpServers: 'nope' } } }, '/repo')]).toEqual([]); + }); +}); + +describe('mergeMcpctlServers', () => { + it('writes one constant entry regardless of project', () => { + const { config } = mergeMcpctlServers(null, { project: 'my-fancy-project' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('my-fancy-project')); + }); + + it('re-points rather than stacking on a second project', () => { + const first = mergeMcpctlServers(null, { project: 'a' }).config; + const { config } = mergeMcpctlServers(first, { project: 'b' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('b')); + }); + + it('retires legacy per-project entries and reports them', () => { + const existing = { mcpServers: { homeautomation: bridge('homeautomation'), sre: bridge('sre') } }; + const { config, retired } = mergeMcpctlServers(existing, { project: 'docmost' }); + expect(Object.keys(config.mcpServers)).toEqual([MCPCTL_SERVER_NAME]); + expect(retired.sort()).toEqual(['homeautomation', 'sre']); + }); + + it('preserves servers the user configured, and other top-level keys', () => { + const existing = { + mcpServers: { 'my-own': { command: 'echo', args: [] } }, + someOtherKey: { keep: true }, + }; + const { config, retired } = mergeMcpctlServers(existing, { project: 'p' }); + expect(config.mcpServers['my-own']).toEqual({ command: 'echo', args: [] }); + expect(config['someOtherKey']).toEqual({ keep: true }); + expect(retired).toEqual([]); + }); + + it('--inspect alone does not unmount the project you are working in', () => { + const existing = mergeMcpctlServers(null, { project: 'p' }).config; + const { config, retired } = mergeMcpctlServers(existing, { inspect: true }); + expect(config.mcpServers[MCPCTL_SERVER_NAME]).toEqual(bridge('p')); + expect(config.mcpServers['mcpctl-inspect']).toEqual({ command: 'mcpctl', args: ['console', '--stdin-mcp'] }); + expect(retired).toEqual([]); + }); + + it('does not mutate the config it was handed', () => { + const existing = { mcpServers: { homeautomation: bridge('homeautomation') } }; + mergeMcpctlServers(existing, { project: 'docmost' }); + expect(Object.keys(existing.mcpServers)).toEqual(['homeautomation']); + }); +}); 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..4e19a97 --- /dev/null +++ b/src/cli/tests/config/opencode-extension-embed.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + OPENCODE_SERVER_PLUGIN_SOURCE, + OPENCODE_TUI_PLUGIN_SOURCE, + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_FILENAME, +} from '../../src/config/opencode-extension.js'; + +/** + * `mcpctl config opencode` installs the *embedded* copy of the plugins, not the + * files in src/opencode-ext/. Editing the sources without re-running the + * generator therefore ships stale code to users while the repo looks correct — + * and the embedded copy is the one thing no typecheck covers. Same guarantee + * the completions check gives. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'opencode-ext'); + +describe('embedded opencode plugins', () => { + it('match the sources in src/opencode-ext (re-run scripts/generate-opencode-extension.ts)', () => { + expect(OPENCODE_SERVER_PLUGIN_SOURCE, 'server plugin is stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8')); + expect(OPENCODE_TUI_PLUGIN_SOURCE, 'TUI plugin is stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8')); + }); + + it('install under names opencode can actually load', () => { + // The server plugin is auto-discovered from plugin/*.ts; the TUI plugin is + // referenced by path from tui.json and must stay .tsx for its JSX to be + // transpiled. + expect(OPENCODE_SERVER_PLUGIN_FILENAME).toBe('mcpctl.ts'); + expect(OPENCODE_TUI_PLUGIN_FILENAME).toBe('mcpctl-tui.tsx'); + }); + + it('are self-contained — the installed files have no mcpctl imports to resolve', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).not.toMatch(/from '@mcpctl\//); + expect(src).not.toMatch(/from '\.\.\//); + } + }); + + it('keep the JSX pragma the TUI plugin needs to render its indicator', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE.startsWith('/** @jsxImportSource @opentui/solid */')).toBe(true); + }); + + it('agree on the MCP server name, so a switch re-points one mount instead of stacking two', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("const SERVER_NAME = 'mcpctl'"); + } + }); + + it('agree on the state file both read', () => { + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("join(homedir(), '.mcpctl', 'opencode-state.json')"); + } + }); + + it('spawn the CLI without a shell, so a project name is never interpolated into a command string', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("execFile('mcpctl', args"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexecSync\s*\(/); + expect(OPENCODE_TUI_PLUGIN_SOURCE).not.toMatch(/\bexec\(`/); + }); + + it('sync skills into opencode’s own tree, never Claude’s', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--agent', 'opencode'"); + }); + + it('bind the switcher to a chord as well as a slash command', () => { + // Switching is the repeated action; typing /mcpctl every time is friction. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("slashName: 'mcpctl'"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("key: 'm'"); + }); + + it('tear the outgoing mount down before re-pointing it', () => { + // An abandoned client keeps its mcp-session-id — and a gated project's + // unlocked state — alive on mcplocal. + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('mcp.disconnect({ name: SERVER_NAME })'); + }); + + it('clip rather than wrap the footer label on the narrow home prompt', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain('wrapMode="none"'); + }); + + it('switch without rewriting the plugin file opencode has already loaded', () => { + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-plugin'"); + expect(OPENCODE_TUI_PLUGIN_SOURCE).toContain("'--skip-marker'"); + }); +}); + +describe('embedded opencode plugins — state parsing', () => { + it('type-guard the parsed state, not just try/catch', () => { + // `JSON.parse('null')` succeeds and returns null, so a bare try/catch lets + // it through and the next `state.project` throws a TypeError that takes the + // plugin down. A hand-edited or truncated state file must degrade to "no + // project", never to a broken opencode. + for (const src of [OPENCODE_SERVER_PLUGIN_SOURCE, OPENCODE_TUI_PLUGIN_SOURCE]) { + expect(src).toContain("typeof parsed === 'object' && parsed !== null"); + expect(src).not.toMatch(/return JSON\.parse\(await readFile\([^)]*\)\) as OpencodeState;/); + } + }); +}); diff --git a/src/cli/tests/config/opencode-project-order.test.ts b/src/cli/tests/config/opencode-project-order.test.ts new file mode 100644 index 0000000..87aec71 --- /dev/null +++ b/src/cli/tests/config/opencode-project-order.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { orderProjects, indicatorLabel } from '../../../opencode-ext/mcpctl-opencode-tui.js'; + +/** + * opencode's select dialog filters as you type, so the picker only has to get + * the *order* right — the active project first, because "the one I am on" is + * the most likely pick and real installs run to hundreds of projects (smoke-test + * leftovers included). + */ +const PROJECTS = [ + { name: 'smoke-proj-none-mohimh46' }, + { name: 'homeautomation', description: 'house' }, + { name: 'docmost' }, + { name: 'copy-homeautomation' }, + { name: 'labctl' }, + { name: 'sre' }, +]; + +describe('orderProjects', () => { + it('puts the active project first, then sorts alphabetically', () => { + expect(orderProjects(PROJECTS, 'labctl').map((p) => p.name)).toEqual([ + 'labctl', + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('sorts alphabetically when nothing is active', () => { + expect(orderProjects(PROJECTS, null).map((p) => p.name)).toEqual([ + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'labctl', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('never drops or duplicates a project', () => { + expect(orderProjects(PROJECTS, 'sre')).toHaveLength(PROJECTS.length); + expect(orderProjects(PROJECTS, 'not-a-project')).toHaveLength(PROJECTS.length); + }); + + it('does not mutate the caller’s list', () => { + const input = [...PROJECTS]; + orderProjects(input, 'sre'); + expect(input.map((p) => p.name)).toEqual(PROJECTS.map((p) => p.name)); + }); + + it('keeps descriptions, which the dialog shows under each row', () => { + expect(orderProjects(PROJECTS, null).find((p) => p.name === 'homeautomation')?.description).toBe('house'); + }); +}); + +describe('indicatorLabel', () => { + it('names the active project', () => { + expect(indicatorLabel('docmost')).toBe('mcpctl:docmost'); + }); + + it('says so when there is none, rather than rendering a bare prefix', () => { + expect(indicatorLabel(null)).toBe('mcpctl:none'); + expect(indicatorLabel('')).toBe('mcpctl:none'); + }); +}); diff --git a/src/cli/tests/config/pi-extension-embed.test.ts b/src/cli/tests/config/pi-extension-embed.test.ts index 5453278..3280d0b 100644 --- a/src/cli/tests/config/pi-extension-embed.test.ts +++ b/src/cli/tests/config/pi-extension-embed.test.ts @@ -29,6 +29,34 @@ describe('embedded pi extension', () => { expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js'); }); + /** + * pi resolves an extension's bare specifiers through a hard-coded alias table + * in its own loader, and that table is not the same across pi distributions: + * `@earendil-works/*` exists only in the newer packages, `@mariozechner/*` + * installs alias only the old names, and neither resolves the other. An + * import of a package outside the intersection makes the whole extension fail + * to load with `Cannot find module` — every tool gone, on someone else's pi. + * + * `typebox` is aliased by every published pi, so it is the only safe bare + * runtime import. Type-only imports are erased before jiti resolves anything, + * so they may name whatever they like. + */ + it('imports nothing at runtime that some pi build cannot resolve', () => { + // `import x from "s"` / `import {..} from "s"` (but not `import type`), + // plus the side-effect form `import "s"`. + const runtimeImport = + /^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gm; + const allowed = /^(node:|\.\/|\.\.\/|typebox$|typebox\/)/; + + for (const name of PI_EXTENSION_FILENAMES) { + const src = PI_EXTENSION_FILES[name] ?? ''; + for (const match of src.matchAll(runtimeImport)) { + const specifier = match[1] ?? match[2] ?? ''; + expect(specifier, `${name} runtime-imports ${specifier}`).toMatch(allowed); + } + } + }); + it('carries the fixes the pi API requires', () => { const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? ''; // ctx.ui.select takes string[] and returns the chosen string. diff --git a/src/cli/tests/config/prime-agent-extension-embed.test.ts b/src/cli/tests/config/prime-agent-extension-embed.test.ts new file mode 100644 index 0000000..20a5a2c --- /dev/null +++ b/src/cli/tests/config/prime-agent-extension-embed.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + MCPCTL_SWITCH_EXTENSION, + MCPCTL_SWITCH_EXTENSION_FILENAME, +} from '../../src/config/prime-agent-extension.js'; + +/** + * `mcpctl config prime-agent` installs the *embedded* copy of the switcher, not + * the file in src/prime-agent-ext/. Editing the source without re-running the + * generator therefore ships stale code while the repo looks correct. + * + * This matters more here than for the pi and opencode extensions: until the + * source file existed, the switcher was only a string literal, so nothing + * typechecked or linted it at all. The guarantee only holds while the two stay + * in sync. + */ +const repoRoot = join(import.meta.dirname, '..', '..', '..', '..'); +const extDir = join(repoRoot, 'src', 'prime-agent-ext'); + +describe('embedded prime-agent switcher', () => { + it('matches the source in src/prime-agent-ext (re-run scripts/generate-prime-agent-extension.ts)', () => { + expect(MCPCTL_SWITCH_EXTENSION, 'stale — regenerate the embed') + .toBe(readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8')); + }); + + it('installs under the name prime-agent auto-discovers', () => { + expect(MCPCTL_SWITCH_EXTENSION_FILENAME).toBe('mcpctl-switch.ts'); + }); + + it('is self-contained — the installed file has no mcpctl imports to resolve', () => { + expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '@mcpctl\//); + expect(MCPCTL_SWITCH_EXTENSION).not.toMatch(/from '\.\.\//); + }); + + it('switches without re-installing itself or re-scoping the launch directory', () => { + // Rewriting the extension file prime-agent has already loaded buys nothing; + // writing a marker would silently re-scope whatever repo it was started in. + expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-extension'"); + expect(MCPCTL_SWITCH_EXTENSION).toContain("'--skip-marker'"); + }); + + it('publishes the active-project indicator, which is the only visible state', () => { + expect(MCPCTL_SWITCH_EXTENSION).toContain('setStatus'); + expect(MCPCTL_SWITCH_EXTENSION).toContain("'turn_start'"); + }); +}); diff --git a/src/cli/tests/health-check-opts.test.ts b/src/cli/tests/health-check-opts.test.ts new file mode 100644 index 0000000..0c02c03 --- /dev/null +++ b/src/cli/tests/health-check-opts.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { buildHealthCheck } from '../src/commands/create.js'; + +describe('buildHealthCheck — CLI flags → server healthCheck', () => { + it('returns undefined when no health-check flag is given', () => { + expect(buildHealthCheck({})).toBeUndefined(); + }); + + it('builds a readiness probe from --health-check-tool', () => { + expect(buildHealthCheck({ healthCheckTool: 'list_sites' })).toEqual({ tool: 'list_sites' }); + }); + + it('parses --health-check-args as a JSON object', () => { + expect(buildHealthCheck({ healthCheckTool: 'get_devices', healthCheckArgs: '{"site":"default"}' })) + .toEqual({ tool: 'get_devices', arguments: { site: 'default' } }); + }); + + it('carries the timing flags through', () => { + expect(buildHealthCheck({ + healthCheckTool: 'list_sites', + healthCheckInterval: '120', + healthCheckTimeout: '15', + healthCheckFailureThreshold: '2', + })).toEqual({ + tool: 'list_sites', + intervalSeconds: 120, + timeoutSeconds: 15, + failureThreshold: 2, + }); + }); + + it('allows tuning the liveness probe without a tool', () => { + // No `tool` → the probe stays liveness-only (reports `live`), but the + // interval is still configurable. + expect(buildHealthCheck({ healthCheckInterval: '300' })).toEqual({ intervalSeconds: 300 }); + }); + + it('rejects --health-check-args without a tool', () => { + expect(() => buildHealthCheck({ healthCheckArgs: '{}' })) + .toThrow(/--health-check-args requires --health-check-tool/); + }); + + it('rejects non-JSON args', () => { + expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: 'site=default' })) + .toThrow(/not valid JSON/); + }); + + it('rejects JSON args that are not an object', () => { + expect(() => buildHealthCheck({ healthCheckTool: 't', healthCheckArgs: '["a"]' })) + .toThrow(/expected a JSON object/); + }); + + it('rejects non-positive-integer timings', () => { + expect(() => buildHealthCheck({ healthCheckInterval: '0' })).toThrow(/--health-check-interval/); + expect(() => buildHealthCheck({ healthCheckTimeout: 'abc' })).toThrow(/--health-check-timeout/); + expect(() => buildHealthCheck({ healthCheckFailureThreshold: '-1' })) + .toThrow(/--health-check-failure-threshold/); + }); +}); diff --git a/src/cli/tests/utils/claude-ui.test.ts b/src/cli/tests/utils/claude-ui.test.ts new file mode 100644 index 0000000..9f56e10 --- /dev/null +++ b/src/cli/tests/utils/claude-ui.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + installStatusLine, + removeStatusLine, + installSlashCommand, + MCPCTL_SLASH_COMMAND, + STATUSLINE_COMMAND, + MARKER_KEY, +} from '../../src/utils/claude-ui.js'; + +describe('installStatusLine', () => { + let dir: string; + let settings: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-ui-')); + settings = join(dir, 'settings.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('installs into a missing settings file', async () => { + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + const parsed = JSON.parse(readFileSync(settings, 'utf-8')); + expect(parsed.statusLine).toEqual({ type: 'command', command: STATUSLINE_COMMAND, [MARKER_KEY]: true }); + }); + + it('is idempotent', async () => { + await installStatusLine(settings); + const before = readFileSync(settings, 'utf-8'); + expect(await installStatusLine(settings)).toEqual({ status: 'already' }); + expect(readFileSync(settings, 'utf-8')).toBe(before); + }); + + it('upgrades its own entry when the command changes', async () => { + await installStatusLine(settings, 'mcpctl statusline --prefix old:'); + expect(await installStatusLine(settings, STATUSLINE_COMMAND)).toEqual({ status: 'installed' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe(STATUSLINE_COMMAND); + }); + + it('still recognises its own line after Claude Code strips the marker', async () => { + // Claude Code rewrites settings.json against its own schema and drops + // unknown keys from statusLine — verified live. Without matching on the + // command we would call our own line foreign forever. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'already' }); + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'mcpctl statusline --prefix p:' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + }); + + it('does not claim a line that merely composes ours into a bigger one', async () => { + // That line is the user's work, even though our command appears in it. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-prompt && mcpctl statusline' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-prompt && mcpctl statusline' }); + }); + + it('never clobbers a status line the user built', async () => { + // A status line is a single slot; overwriting one silently deletes work. + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'my-fancy-prompt' } })); + expect(await installStatusLine(settings)).toEqual({ status: 'foreign', command: 'my-fancy-prompt' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('my-fancy-prompt'); + }); + + it('preserves every other setting', async () => { + writeFileSync(settings, JSON.stringify({ permissions: { allow: ['Bash'] }, hooks: { SessionStart: [] } })); + await installStatusLine(settings); + const parsed = JSON.parse(readFileSync(settings, 'utf-8')); + expect(parsed.permissions).toEqual({ allow: ['Bash'] }); + expect(parsed.hooks).toEqual({ SessionStart: [] }); + }); + + it('tolerates line comments an editor may have added', async () => { + writeFileSync(settings, '{\n // my notes\n "permissions": { "allow": [] }\n}\n'); + expect(await installStatusLine(settings)).toEqual({ status: 'installed' }); + expect(JSON.parse(readFileSync(settings, 'utf-8')).permissions).toEqual({ allow: [] }); + }); + + it('removes only its own entry', async () => { + writeFileSync(settings, JSON.stringify({ statusLine: { type: 'command', command: 'theirs' } })); + expect(await removeStatusLine(settings)).toBe(false); + expect(JSON.parse(readFileSync(settings, 'utf-8')).statusLine.command).toBe('theirs'); + + await installStatusLine(join(dir, 'ours.json')); + expect(await removeStatusLine(join(dir, 'ours.json'))).toBe(true); + expect(JSON.parse(readFileSync(join(dir, 'ours.json'), 'utf-8')).statusLine).toBeUndefined(); + }); +}); + +describe('the /mcpctl slash command', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-cmd-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('is written where Claude Code looks for user commands', async () => { + const path = join(dir, 'commands', 'mcpctl.md'); + expect(await installSlashCommand(path)).toBe(path); + expect(existsSync(path)).toBe(true); + }); + + it('scopes allowed-tools to mcpctl, not a general shell', async () => { + // Accepting the command must not hand it arbitrary Bash. + const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? ''; + expect(tools).not.toMatch(/Bash\(\*\)|Bash\)/); + for (const t of tools.split(', ')) expect(t).toMatch(/^Bash\(mcpctl /); + }); + + it('permits every command it pre-executes', () => { + // A `!`-block missing from allowed-tools fails the whole command with a + // permission error before the model sees anything — which is exactly what + // happened live when `statusline` was omitted. + const tools = /^allowed-tools: (.+)$/m.exec(MCPCTL_SLASH_COMMAND)?.[1] ?? ''; + const permitted = tools.split(', ').map((t) => /^Bash\((.+?):?\*?\)$/.exec(t)?.[1] ?? ''); + const preExecuted = [...MCPCTL_SLASH_COMMAND.matchAll(/!`([^`]+)`/g)].map((m) => m[1] ?? ''); + expect(preExecuted.length).toBeGreaterThan(0); + for (const cmd of preExecuted) { + expect(permitted.some((p) => p !== '' && cmd.startsWith(p)), `"${cmd}" is not covered by allowed-tools`).toBe(true); + } + }); + + it('switches without re-scoping the directory the session opened in', () => { + expect(MCPCTL_SLASH_COMMAND).toContain('--skip-marker'); + }); + + it('tells the user to reconnect, since the running session holds the old connection', () => { + expect(MCPCTL_SLASH_COMMAND).toMatch(/reconnect/i); + expect(MCPCTL_SLASH_COMMAND).toContain('/mcp'); + }); + + it('refers to the one constant server name', () => { + expect(MCPCTL_SLASH_COMMAND).toContain('`mcpctl`'); + }); +}); diff --git a/src/cli/tests/utils/opencode-settings.test.ts b/src/cli/tests/utils/opencode-settings.test.ts new file mode 100644 index 0000000..1dc6893 --- /dev/null +++ b/src/cli/tests/utils/opencode-settings.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + opencodeConfigDir, + opencodeSkillsDir, + withOpencodeDir, + installOpencodePlugins, + registerOpencodeTuiPlugin, + readOpencodeState, + writeOpencodeState, + storedToken, +} from '../../src/utils/opencode-settings.js'; +import { + OPENCODE_SERVER_PLUGIN_FILENAME, + OPENCODE_TUI_PLUGIN_FILENAME, +} from '../../src/config/opencode-extension.js'; + +describe('opencodeConfigDir', () => { + it('defaults to ~/.config/opencode', () => { + expect(opencodeConfigDir({}, '/home/u')).toBe('/home/u/.config/opencode'); + }); + + it('honours XDG_CONFIG_HOME — provisioning ~/.config would be invisible to opencode', () => { + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '/xdg' }, '/home/u')).toBe('/xdg/opencode'); + }); + + it('ignores an empty XDG_CONFIG_HOME rather than resolving against ""', () => { + expect(opencodeConfigDir({ XDG_CONFIG_HOME: '' }, '/home/u')).toBe('/home/u/.config/opencode'); + }); + + it('puts skills where opencode looks for them', () => { + expect(opencodeSkillsDir({}, '/home/u')).toBe('/home/u/.config/opencode/skill'); + }); +}); + +describe('installOpencodePlugins', () => { + let dir: string; + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-install-')); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('writes the server plugin where opencode auto-discovers it and the TUI plugin beside it', async () => { + const written = await installOpencodePlugins(dir); + const paths = withOpencodeDir(dir); + expect(written).toEqual([paths.serverPluginPath(), paths.tuiPluginPath()]); + expect(existsSync(join(dir, 'plugin', OPENCODE_SERVER_PLUGIN_FILENAME))).toBe(true); + expect(existsSync(join(dir, 'mcpctl', OPENCODE_TUI_PLUGIN_FILENAME))).toBe(true); + }); + + it('keeps the .tsx extension — opencode transpiles the TUI plugin by extension', async () => { + await installOpencodePlugins(dir); + expect(withOpencodeDir(dir).tuiPluginPath().endsWith('.tsx')).toBe(true); + }); + + it('is idempotent (re-running overwrites in place)', async () => { + await installOpencodePlugins(dir); + const first = readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8'); + await installOpencodePlugins(dir); + expect(readFileSync(withOpencodeDir(dir).serverPluginPath(), 'utf-8')).toBe(first); + }); +}); + +describe('registerOpencodeTuiPlugin', () => { + let dir: string; + let tuiJson: string; + const pluginPath = '/cfg/opencode/mcpctl/mcpctl-tui.tsx'; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-tui-')); + tuiJson = join(dir, 'tui.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates tui.json with the plugin and a $schema', async () => { + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true }); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual([pluginPath]); + expect(parsed.$schema).toBe('https://opencode.ai/tui.json'); + }); + + it('is idempotent and leaves the file untouched on a no-op run', async () => { + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + const before = readFileSync(tuiJson, 'utf-8'); + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: false }); + expect(readFileSync(tuiJson, 'utf-8')).toBe(before); + }); + + it('preserves other TUI plugins and unrelated keys', async () => { + writeFileSync(tuiJson, JSON.stringify({ plugin: ['opencode-tui-utils'], theme: 'nord' })); + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + const parsed = JSON.parse(readFileSync(tuiJson, 'utf-8')); + expect(parsed.plugin).toEqual(['opencode-tui-utils', pluginPath]); + expect(parsed.theme).toBe('nord'); + }); + + it('drops a stale entry for an older install location of our own plugin', async () => { + // Left behind, opencode fails to load a file that no longer exists on + // every start. + writeFileSync(tuiJson, JSON.stringify({ plugin: ['/old/place/mcpctl-tui.tsx', 'other-plugin'] })); + await registerOpencodeTuiPlugin(tuiJson, pluginPath); + expect(JSON.parse(readFileSync(tuiJson, 'utf-8')).plugin).toEqual(['other-plugin', pluginPath]); + }); + + it('refuses to overwrite a corrupt tui.json instead of dropping the user’s plugins', async () => { + writeFileSync(tuiJson, '{ this is not json'); + await expect(registerOpencodeTuiPlugin(tuiJson, pluginPath)).rejects.toThrow(/not valid JSON/); + expect(readFileSync(tuiJson, 'utf-8')).toBe('{ this is not json'); + }); + + it('treats an empty file as a fresh start', async () => { + writeFileSync(tuiJson, ' \n'); + expect(await registerOpencodeTuiPlugin(tuiJson, pluginPath)).toEqual({ added: true }); + }); +}); + +describe('opencode state file', () => { + let dir: string; + let statePath: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'mcpctl-oc-state-')); + statePath = join(dir, 'nested', 'opencode-state.json'); + }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + it('creates the directory and writes project + gateway + token', async () => { + await writeOpencodeState({ project: 'docmost', gatewayUrl: 'https://gw', token: 'mcpctl_pat_a' }, statePath); + expect(await readOpencodeState(statePath)).toEqual({ + project: 'docmost', + gatewayUrl: 'https://gw', + tokens: { docmost: 'mcpctl_pat_a' }, + }); + }); + + it('is written 0600 — it holds bearer tokens', async () => { + await writeOpencodeState({ project: 'p', gatewayUrl: 'https://gw', token: 't' }, statePath); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + }); + + it('keeps other projects’ tokens, so switching back needs no new mint', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath); + await writeOpencodeState({ project: 'b', gatewayUrl: 'https://gw', token: 'tok-b' }, statePath); + const state = await readOpencodeState(statePath); + expect(state.project).toBe('b'); + expect(state.tokens).toEqual({ a: 'tok-a', b: 'tok-b' }); + }); + + it('switching without a new token leaves the stored one alone', async () => { + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw', token: 'tok-a' }, statePath); + await writeOpencodeState({ project: 'a', gatewayUrl: 'https://gw2' }, statePath); + const state = await readOpencodeState(statePath); + expect(state.gatewayUrl).toBe('https://gw2'); + expect(storedToken(state, 'a')).toBe('tok-a'); + }); + + it('reads a missing or corrupt state as empty rather than throwing', async () => { + expect(await readOpencodeState(join(dir, 'nope.json'))).toEqual({}); + mkdirSync(join(dir, 'x'), { recursive: true }); + writeFileSync(join(dir, 'x', 's.json'), 'not json'); + expect(await readOpencodeState(join(dir, 'x', 's.json'))).toEqual({}); + }); + + it('storedToken ignores an empty or missing entry', () => { + expect(storedToken({ tokens: { a: '' } }, 'a')).toBeNull(); + expect(storedToken({}, 'a')).toBeNull(); + expect(storedToken({ tokens: { a: 'x' } }, 'a')).toBe('x'); + }); +}); diff --git a/src/cli/tests/utils/sessionhook.test.ts b/src/cli/tests/utils/sessionhook.test.ts index 052e8b3..26d72ad 100644 --- a/src/cli/tests/utils/sessionhook.test.ts +++ b/src/cli/tests/utils/sessionhook.test.ts @@ -104,3 +104,48 @@ describe('sessionhook', () => { expect(settings.hooks.SessionStart).toHaveLength(1); }); }); + +describe('untagged duplicates of the managed hook', () => { + let tmp2: string; + let settings: string; + beforeEach(async () => { + tmp2 = await mkdtemp(join(tmpdir(), 'mcpctl-hook-dupe-')); + settings = join(tmp2, 'settings.json'); + }); + afterEach(async () => { await rm(tmp2, { recursive: true, force: true }); }); + + it('removes an identical row left behind before the marker existed', async () => { + // Exactly the shape found in a real ~/.claude: one tagged row, one not. + // Invisible in the UI; it just runs the sync twice every session. + await writeFile(settings, JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet' }] }, + { hooks: [{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }] }, + ], + }, + })); + const { updated } = await installManagedSessionHook('mcpctl skills sync --quiet', settings); + expect(updated).toBe(true); + + const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { + hooks: { SessionStart: Array<{ hooks: Array> }> }; + }; + const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks); + expect(rows).toEqual([{ type: 'command', command: 'mcpctl skills sync --quiet', [MARKER_KEY]: true }]); + }); + + it('leaves a hook the user wrote alone, even one that also calls mcpctl', async () => { + await writeFile(settings, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'mcpctl skills sync --project mine' }] }], + }, + })); + await installManagedSessionHook('mcpctl skills sync --quiet', settings); + const parsed = JSON.parse(await readFile(settings, 'utf-8')) as { + hooks: { SessionStart: Array<{ hooks: Array<{ command: string }> }> }; + }; + const rows = parsed.hooks.SessionStart.flatMap((g) => g.hooks).map((r) => r.command); + expect(rows).toContain('mcpctl skills sync --project mine'); + }); +}); diff --git a/src/cli/vitest.config.ts b/src/cli/vitest.config.ts index 1cbc8c8..beac373 100644 --- a/src/cli/vitest.config.ts +++ b/src/cli/vitest.config.ts @@ -1,6 +1,13 @@ import { defineProject } from 'vitest/config'; export default defineProject({ + // The opencode TUI plugin is a .tsx that ships as source; its pure helpers are + // unit-tested by importing that file directly, so the test run needs the same + // JSX runtime opencode transpiles it with. + esbuild: { + jsx: 'automatic', + jsxImportSource: '@opentui/solid', + }, test: { name: 'cli', include: ['tests/**/*.test.ts'], diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index 50e8ea7..492c6af 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -29,9 +29,10 @@ export interface SeedTemplate { description: string; packageName?: string; /** - * Package runtime (node, python, ...). Selects the runner image and the - * spawn command — `uvx` vs `npx`. Dropping it silently defaults the template - * to node, which makes every PyPI-backed template fail at first start. + * Package runner: 'node' (npx) or 'python' (uvx). McpTemplate has had this + * column all along, but the upsert below never wrote it, so a template + * declaring `runtime: python` seeded as null and every server created from + * it silently got the node runner. */ runtime?: string; dockerImage?: string; diff --git a/src/mcpd/src/services/health-probe.service.ts b/src/mcpd/src/services/health-probe.service.ts index 8bd4192..8196f29 100644 --- a/src/mcpd/src/services/health-probe.service.ts +++ b/src/mcpd/src/services/health-probe.service.ts @@ -19,10 +19,24 @@ export const DEFAULT_HEALTH_CHECK: HealthCheckSpec = { failureThreshold: 3, }; +/** + * Which probe produced a result. + * + * - `readiness` — a real `tools/call` against the configured probe tool. It + * traverses the server's upstream dependency (controller API, database, + * remote service), so a pass means the server can actually do its job. + * - `liveness` — `tools/list` only. MCP servers answer that from a static + * in-process table, so it proves the process is up and speaking MCP and + * *nothing else*. A server whose upstream is unreachable still answers it. + */ +export type ProbeKind = 'readiness' | 'liveness'; + export interface ProbeResult { healthy: boolean; latencyMs: number; message: string; + /** Set by probeInstance from the healthCheck spec; probe helpers don't fill it. */ + probe?: ProbeKind; } interface ProbeState { @@ -118,6 +132,7 @@ export class HealthProbeRunner { const failureThreshold = healthCheck.failureThreshold ?? 3; const now = new Date(); const start = Date.now(); + const probeKind: ProbeKind = healthCheck.tool === undefined ? 'liveness' : 'readiness'; let result: ProbeResult; @@ -151,6 +166,8 @@ export class HealthProbeRunner { }; } + result.probe = probeKind; + // Update probe state const state = this.probeStates.get(instance.id) ?? { consecutiveFailures: 0, lastProbeAt: 0 }; state.lastProbeAt = Date.now(); @@ -162,18 +179,28 @@ export class HealthProbeRunner { } this.probeStates.set(instance.id, state); - // Determine health status + // Determine health status. + // + // A passing *liveness* probe reports `live`, not `healthy`. `tools/list` + // is answered from a static in-process table, so it stays green while the + // server's upstream is completely unreachable — which is exactly how a + // UniFi server whose controller port was firewalled off sat at "healthy" + // for months. Only a readiness probe (`tools/call` against a real tool) + // earns `healthy`. `live` means "process up, function unverified". const healthStatus = result.healthy - ? 'healthy' + ? (probeKind === 'readiness' ? 'healthy' : 'live') : state.consecutiveFailures >= failureThreshold ? 'unhealthy' : 'degraded'; // Build event + const probeLabel = probeKind === 'readiness' + ? `Readiness check (${healthCheck.tool})` + : 'Liveness check (tools/list)'; const eventType = result.healthy ? 'Normal' : 'Warning'; const eventMessage = result.healthy - ? `Health check passed (${result.latencyMs}ms)` - : `Health check failed: ${result.message}`; + ? `${probeLabel} passed (${result.latencyMs}ms)` + : `${probeLabel} failed: ${result.message}`; const existingEvents = (instance.events as Array<{ timestamp: string; type: string; message: string }>) ?? []; // Keep last 50 events diff --git a/src/mcpd/src/validation/template.schema.ts b/src/mcpd/src/validation/template.schema.ts index 73a01a6..af60967 100644 --- a/src/mcpd/src/validation/template.schema.ts +++ b/src/mcpd/src/validation/template.schema.ts @@ -24,7 +24,13 @@ export const VolumeSpecSchema = z.object({ export type VolumeSpecInput = z.infer; export const HealthCheckSchema = z.object({ - tool: z.string().min(1), + /** + * Readiness probe tool. Omit it to keep the liveness-only default + * (`tools/list`) while still tuning interval/timeout/failureThreshold — + * a liveness pass reports `live`, not `healthy`, because `tools/list` is + * answered in-process and never touches the server's upstream. + */ + tool: z.string().min(1).optional(), arguments: z.record(z.unknown()).default({}), intervalSeconds: z.number().int().min(5).max(3600).default(60), timeoutSeconds: z.number().int().min(1).max(120).default(10), diff --git a/src/mcpd/tests/services/health-probe.test.ts b/src/mcpd/tests/services/health-probe.test.ts index 072bef9..8c33ccb 100644 --- a/src/mcpd/tests/services/health-probe.test.ts +++ b/src/mcpd/tests/services/health-probe.test.ts @@ -123,13 +123,76 @@ describe('HealthProbeRunner', () => { // No exec fallback — liveness goes through mcpProxyService expect(orchestrator.execInContainer).not.toHaveBeenCalled(); expect(mcpProxyService.execute).toHaveBeenCalledWith({ serverId: 'srv-1', method: 'tools/list' }); + // A passing liveness probe is `live`, never `healthy` — `tools/list` is + // answered in-process and proves nothing about the server's upstream. expect(instanceRepo.updateStatus).toHaveBeenCalledWith( 'inst-1', 'RUNNING', - expect.objectContaining({ healthStatus: 'healthy' }), + expect.objectContaining({ healthStatus: 'live' }), ); }); + it('reports `live` (not `healthy`) even when the upstream is dead, and says so in the event', async () => { + // The regression this guards: a UniFi server whose controller port was + // firewalled off sat at "healthy" for months because `tools/list` kept + // answering from the in-process tool table. + const instance = makeInstance(); + const server = makeServer({ healthCheck: null }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + + const result = await runner.probeInstance(instance, server, { intervalSeconds: 0 }); + + expect(result.healthy).toBe(true); + expect(result.probe).toBe('liveness'); + + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('live'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Liveness check (tools/list) passed'); + }); + + it('a passing readiness probe earns `healthy` and names the tool in the event', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'list_sites', intervalSeconds: 0 } as McpServer['healthCheck'], + }); + + vi.mocked(instanceRepo.findAll).mockResolvedValue([instance]); + vi.mocked(serverRepo.findById).mockResolvedValue(server); + vi.mocked(mcpProxyService.execute).mockResolvedValue({ jsonrpc: '2.0', id: 1, result: {} }); + + const result = await runner.probeInstance(instance, server, { tool: 'list_sites' }); + + expect(result.probe).toBe('readiness'); + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('healthy'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Readiness check (list_sites) passed'); + }); + + it('a readiness probe whose tool call fails reports the upstream error, not `live`', async () => { + const instance = makeInstance(); + const server = makeServer({ + healthCheck: { tool: 'list_sites', failureThreshold: 1 } as McpServer['healthCheck'], + }); + + vi.mocked(mcpProxyService.execute).mockResolvedValue({ + jsonrpc: '2.0', + id: 1, + error: { code: -32000, message: 'connect ETIMEDOUT 192.168.1.5:8443' }, + }); + + await runner.probeInstance(instance, server, { tool: 'list_sites', failureThreshold: 1 }); + + const fields = vi.mocked(instanceRepo.updateStatus).mock.calls[0]?.[2]; + expect(fields?.healthStatus).toBe('unhealthy'); + const events = fields?.events as Array<{ message: string }>; + expect(events[events.length - 1]?.message).toContain('Readiness check (list_sites) failed'); + expect(events[events.length - 1]?.message).toContain('ETIMEDOUT'); + }); + it('default liveness probe marks unhealthy when tools/list returns JSON-RPC error', async () => { const instance = makeInstance(); const server = makeServer({ diff --git a/src/mcpd/tests/templates.test.ts b/src/mcpd/tests/templates.test.ts new file mode 100644 index 0000000..a597a47 --- /dev/null +++ b/src/mcpd/tests/templates.test.ts @@ -0,0 +1,79 @@ +/** + * The shipped `templates/*.yaml` are seeded into mcpd and are what `mcpctl + * create server --from-template` builds from, so drift there ships broken + * servers. The unifi-network template had drifted on every field that + * mattered — python runtime for an npm package, an env contract + * (UNIFI_HOST/USERNAME/PASSWORD) the package doesn't read, and a comment + * disabling its health check for a reason that had stopped being true — and + * nothing caught it because no test ever read the files. + */ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; +import { CreateTemplateSchema } from '../src/validation/template.schema.js'; + +const TEMPLATES_DIR = fileURLToPath(new URL('../../../templates', import.meta.url)); + +const files = readdirSync(TEMPLATES_DIR).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); + +interface RawTemplate { + name?: string; + runtime?: string; + packageName?: string; + dockerImage?: string; + externalUrl?: string; + healthCheck?: { tool?: string }; + env?: Array<{ name?: string }>; +} + +function load(file: string): RawTemplate { + return yaml.load(readFileSync(join(TEMPLATES_DIR, file), 'utf-8')) as RawTemplate; +} + +describe('shipped templates', () => { + it('ships at least one template', () => { + expect(files.length).toBeGreaterThan(0); + }); + + it.each(files)('%s validates against CreateTemplateSchema', (file) => { + const parsed = CreateTemplateSchema.safeParse(load(file)); + expect(parsed.success ? null : parsed.error.issues).toBeNull(); + }); + + it.each(files)('%s declares a runner the orchestrator knows', (file) => { + const tpl = load(file); + // `runtime` only means anything for package-based servers, and only + // 'node' (npx) and 'python' (uvx) are wired in buildRuntimeSpawnCmd. + if (tpl.runtime !== undefined) { + expect(['node', 'python']).toContain(tpl.runtime); + } + }); + + it.each(files)('%s says how to actually run the server', (file) => { + const tpl = load(file); + const runnable = tpl.packageName !== undefined + || tpl.dockerImage !== undefined + || tpl.externalUrl !== undefined; + expect(runnable, `${file} has no packageName, dockerImage, or externalUrl`).toBe(true); + }); + + it.each(files)('%s names a readiness probe tool, not a bare liveness probe', (file) => { + const tpl = load(file); + // Without a `tool`, an instance from this template can only ever report + // `live` — nothing would ever check its upstream. See docs/reliability.md. + expect(tpl.healthCheck?.tool, `${file} has no healthCheck.tool`).toBeTruthy(); + }); + + it.each(files)('%s declares uniquely-named env entries', (file) => { + const names = (load(file).env ?? []).map((e) => e.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('has no template for a retired server', () => { + // node-red was retired 2026-08-09: it answered on neither its Tailscale + // nor its LAN address and had no deployment anywhere. + expect(files).not.toContain('node-red.yaml'); + }); +}); diff --git a/src/mcplocal/src/http/index.ts b/src/mcplocal/src/http/index.ts index 274a655..ffdb7d4 100644 --- a/src/mcplocal/src/http/index.ts +++ b/src/mcplocal/src/http/index.ts @@ -2,7 +2,7 @@ export { createHttpServer } from './server.js'; export type { HttpServerDeps } from './server.js'; export { loadHttpConfig } from './config.js'; export type { HttpConfig } from './config.js'; -export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js'; +export { McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError } from './mcpd-client.js'; export { registerProxyRoutes } from './routes/proxy.js'; export { registerMcpEndpoint } from './mcp-endpoint.js'; export { registerProjectMcpEndpoint } from './project-mcp-endpoint.js'; diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index 53511e5..8e755e4 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -20,9 +20,41 @@ export class ConnectionError extends Error { } } +/** + * Thrown when mcpd was reachable but did not finish in time. + * + * Deliberately NOT a ConnectionError. Folding timeouts into "cannot connect" + * is what made this class of failure so expensive to diagnose: mcpd answered + * /healthz in 32ms while the proxy insisted the daemon was down. A timeout and + * an unreachable daemon need different messages and different status codes. + */ +export class UpstreamTimeoutError extends Error { + constructor(readonly url: string, readonly timeoutMs: number) { + super(`mcpd did not respond within ${String(timeoutMs)}ms: ${url}`); + this.name = 'UpstreamTimeoutError'; + } +} + +/** True when `err` is an AbortSignal.timeout() firing. */ +function isTimeout(err: unknown): boolean { + return err instanceof DOMException && err.name === 'TimeoutError'; +} + /** Default timeout for mcpd requests (ms). Prevents indefinite hangs on slow upstream tool calls. */ export const DEFAULT_TIMEOUT_MS = 30_000; +/** + * Budget for routes that are *expected* to run long: agent/project chat and + * raw inference. An agent turn is a multi-turn tool-use loop and legitimately + * runs for minutes, so the 30s default is not a safety net there — it is a + * guaranteed failure. Matches `STREAM_TIMEOUT_MS` in the CLI's chat command + * (src/cli/src/commands/chat.ts), which already allowed 10 minutes; mcplocal + * sitting in the middle with 30s was the binding constraint. + * + * Override with `MCPLOCAL_LONG_TIMEOUT_MS`. + */ +export const LONG_RUNNING_TIMEOUT_MS = Number(process.env['MCPLOCAL_LONG_TIMEOUT_MS']) || 600_000; + /** * Discovery-class operations (tools/list, resources/list, prompts/list) should not share * the full tool-call timeout budget — a single dead upstream would stall session init for @@ -121,9 +153,7 @@ export class McpdClient { try { res = await fetch(url, init); } catch (err: unknown) { - if (err instanceof DOMException && err.name === 'TimeoutError') { - throw new ConnectionError(this.baseUrl, new Error(`Request timed out after ${this.timeoutMs}ms`)); - } + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); throw new ConnectionError(this.baseUrl, err); } @@ -131,7 +161,18 @@ export class McpdClient { throw new AuthenticationError(); } - const text = await res.text(); + // The body read MUST be inside a try. mcpd writes SSE headers immediately + // on chat routes, so fetch() resolves long before the turn finishes and the + // abort lands here instead — previously escaping as a raw DOMException and + // surfacing to the user as an opaque `500 code:23`. + let text: string; + try { + text = await res.text(); + } catch (err: unknown) { + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + let parsed: unknown; try { parsed = JSON.parse(text); @@ -142,6 +183,51 @@ export class McpdClient { return { status: res.status, body: parsed }; } + /** + * Forward a request and hand back the raw Response, body unread. + * + * `forward()` buffers through `res.text()`, which is fine for CRUD but + * defeats streaming entirely: an SSE chat arrives at the client as one blob + * after the turn ends, so the token-by-token output the CLI draws never + * appears. Streaming routes use this instead and pipe the body straight + * through. + */ + async forwardStream( + method: string, + path: string, + query: string, + body: unknown | undefined, + authOverride?: string, + ): Promise { + const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; + const headers: Record = { + ...this.extraHeaders, + 'Authorization': `Bearer ${authOverride ?? this.token}`, + // Accept both: mcpd picks SSE or JSON based on the request's `stream` flag. + 'Accept': 'text/event-stream, application/json', + }; + + const init: RequestInit = { + method, + headers, + signal: AbortSignal.timeout(this.timeoutMs), + }; + if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + try { + const res = await fetch(url, init); + if (res.status === 401) throw new AuthenticationError(); + return res; + } catch (err: unknown) { + if (err instanceof AuthenticationError) throw err; + if (isTimeout(err)) throw new UpstreamTimeoutError(this.baseUrl, this.timeoutMs); + throw new ConnectionError(this.baseUrl, err); + } + } + private async request(method: string, path: string, body?: unknown): Promise { const result = await this.forward(method, path, '', body); diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index 985f6e6..9b2bebd 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -1,10 +1,62 @@ /** * Catch-all proxy route that forwards /api/v1/* requests to mcpd. */ -import type { FastifyInstance } from 'fastify'; -import { AuthenticationError, ConnectionError } from '../mcpd-client.js'; +import { Readable } from 'node:stream'; + +import type { FastifyInstance, FastifyReply } from 'fastify'; + +import { AuthenticationError, ConnectionError, UpstreamTimeoutError, LONG_RUNNING_TIMEOUT_MS } from '../mcpd-client.js'; import type { McpdClient } from '../mcpd-client.js'; +/** + * Routes that are expected to run long and/or stream. + * + * An agent turn is a multi-turn tool-use loop — minutes, not seconds — so the + * 30s default budget guarantees failure rather than guarding against it. These + * also stream SSE, which must be piped rather than buffered or the client sees + * one blob at the end instead of live output. + */ +const LONG_RUNNING = [ + /^\/api\/v1\/agents\/[^/]+\/chat\b/, + /^\/api\/v1\/projects\/[^/]+\/chat\b/, + /^\/api\/v1\/llms\/[^/]+\/infer\b/, + /^\/api\/v1\/inference-tasks\/[^/]+\/stream\b/, +]; + +function isLongRunning(path: string): boolean { + return LONG_RUNNING.some((re) => re.test(path)); +} + +/** Headers worth preserving from mcpd; everything else is re-derived by Fastify. */ +const PASSTHROUGH_HEADERS = ['content-type', 'cache-control', 'x-accel-buffering']; + +function sendUpstreamError(reply: FastifyReply, err: unknown): FastifyReply | undefined { + if (err instanceof AuthenticationError) { + return reply.code(401).send({ + error: 'unauthorized', + message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', + }); + } + if (err instanceof UpstreamTimeoutError) { + // 504, not 503 — mcpd was reachable, it just did not finish. Reporting this + // as "cannot reach mcpd" sent a previous debugging session chasing a + // network fault while /healthz answered in 32ms. + return reply.code(504).send({ + error: 'upstream_timeout', + message: + `mcpd did not respond within ${String(err.timeoutMs)}ms. The daemon is reachable — the ` + + 'request itself ran long. Raise MCPLOCAL_LONG_TIMEOUT_MS if this is a legitimately slow turn.', + }); + } + if (err instanceof ConnectionError) { + return reply.code(503).send({ + error: 'service_unavailable', + message: 'Cannot reach mcpd daemon. Is it running?', + }); + } + return undefined; +} + export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): void { app.all('/api/v1/*', async (request, reply) => { const path = (request.url.split('?')[0]) ?? '/'; @@ -19,25 +71,78 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v // Forward the user's auth token to mcpd so RBAC applies per-user. // If no user token is present, mcpd will use its auth hook to reject. const authHeader = request.headers['authorization'] as string | undefined; - const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + const userToken = authHeader !== undefined && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : undefined; + + if (isLongRunning(path)) { + return proxyStreaming(reply, client, request.method, path, querystring, body, userToken); + } try { const result = await client.forward(request.method, path, querystring, body, userToken); return reply.code(result.status).send(result.body); } catch (err: unknown) { - if (err instanceof AuthenticationError) { - return reply.code(401).send({ - error: 'unauthorized', - message: 'Authentication with mcpd failed. Run `mcpctl login` to refresh your token.', - }); - } - if (err instanceof ConnectionError) { - return reply.code(503).send({ - error: 'service_unavailable', - message: 'Cannot reach mcpd daemon. Is it running?', - }); - } + const handled = sendUpstreamError(reply, err); + if (handled) return handled; throw err; } }); } + +/** + * Pipe a long-running response straight through, headers and all. + * + * Hijacks the reply so Fastify does not try to serialize a stream, then copies + * mcpd's status and content-type before piping. `x-accel-buffering` matters: + * mcpd sets it to `no` so intermediaries don't buffer SSE, and dropping it here + * would reintroduce the exact stall we are fixing. + */ +async function proxyStreaming( + reply: FastifyReply, + client: McpdClient, + method: string, + path: string, + querystring: string, + body: unknown, + userToken: string | undefined, +): Promise { + const longClient = client.withTimeout(LONG_RUNNING_TIMEOUT_MS); + + let res: Response; + try { + res = await longClient.forwardStream(method, path, querystring, body, userToken); + } catch (err: unknown) { + const handled = sendUpstreamError(reply, err); + if (handled) return; + throw err; + } + + const headers: Record = {}; + for (const name of PASSTHROUGH_HEADERS) { + const value = res.headers.get(name); + if (value !== null) headers[name] = value; + } + + reply.hijack(); + reply.raw.writeHead(res.status, headers); + + if (res.body === null) { + reply.raw.end(); + return; + } + + try { + // Node's Readable.fromWeb bridges the fetch ReadableStream onto the socket. + await new Promise((resolve, reject) => { + const upstream = Readable.fromWeb(res.body as Parameters[0]); + upstream.on('error', reject); + reply.raw.on('close', () => { upstream.destroy(); resolve(); }); + upstream.pipe(reply.raw).on('finish', resolve).on('error', reject); + }); + } catch { + // Headers are already on the wire, so there is no status left to change. + // Close the socket; the client surfaces the truncated stream. + if (!reply.raw.writableEnded) reply.raw.end(); + } +} diff --git a/src/mcplocal/src/index.ts b/src/mcplocal/src/index.ts index 489e0ba..dbc01d4 100644 --- a/src/mcplocal/src/index.ts +++ b/src/mcplocal/src/index.ts @@ -11,7 +11,7 @@ export type { MainResult } from './main.js'; export { ProviderRegistry } from './providers/index.js'; export type { LlmProvider, CompletionOptions, CompletionResult, ChatMessage } from './providers/index.js'; export { OpenAiProvider, AnthropicProvider, OllamaProvider, GeminiCliProvider, DeepSeekProvider } from './providers/index.js'; -export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, registerProxyRoutes } from './http/index.js'; +export { createHttpServer, loadHttpConfig, McpdClient, AuthenticationError, ConnectionError, UpstreamTimeoutError, registerProxyRoutes } from './http/index.js'; export type { HttpConfig, HttpServerDeps } from './http/index.js'; export type { JsonRpcRequest, diff --git a/src/mcplocal/tests/mcpd-client.test.ts b/src/mcplocal/tests/mcpd-client.test.ts index c9a50d4..7dac059 100644 --- a/src/mcplocal/tests/mcpd-client.test.ts +++ b/src/mcplocal/tests/mcpd-client.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterAll, afterEach } from 'vitest'; import http from 'node:http'; -import { McpdClient, ConnectionError } from '../src/http/mcpd-client.js'; +import { McpdClient, ConnectionError, UpstreamTimeoutError } from '../src/http/mcpd-client.js'; /** * Create a local HTTP server for testing McpdClient behavior. @@ -85,7 +85,7 @@ describe('McpdClient', () => { // ── Timeout behavior ── - it('times out on slow responses and throws ConnectionError', async () => { + it('times out on slow responses and throws UpstreamTimeoutError', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond — simulates a hanging upstream tool call }); @@ -96,7 +96,7 @@ describe('McpdClient', () => { const start = Date.now(); await expect(client.post('/api/v1/mcp/proxy', { serverId: 's1' })).rejects.toThrow( - /timed out/, + /did not respond within/, ); const elapsed = Date.now() - start; @@ -105,7 +105,7 @@ describe('McpdClient', () => { expect(elapsed).toBeLessThan(3000); }); - it('timeout error is a ConnectionError with descriptive message', async () => { + it('timeout is NOT a ConnectionError — a slow daemon is not an absent one', async () => { const { server, url } = await createTestServer((_req, _res) => { // Never respond }); @@ -117,8 +117,12 @@ describe('McpdClient', () => { await client.get('/test'); expect.unreachable('Should have thrown'); } catch (err) { - expect(err).toBeInstanceOf(ConnectionError); - expect((err as Error).message).toContain('Request timed out after 200ms'); + // Reporting a timeout as "cannot connect" is what sent a previous + // debugging session chasing a network fault that did not exist. + expect(err).toBeInstanceOf(UpstreamTimeoutError); + expect(err).not.toBeInstanceOf(ConnectionError); + expect((err as UpstreamTimeoutError).timeoutMs).toBe(200); + expect((err as Error).message).toContain('did not respond within 200ms'); } }); @@ -146,7 +150,7 @@ describe('McpdClient', () => { const derived = client.withHeaders({ 'X-Custom': 'val' }); const start = Date.now(); - await expect(derived.get('/test')).rejects.toThrow(/timed out/); + await expect(derived.get('/test')).rejects.toThrow(/did not respond within/); const elapsed = Date.now() - start; expect(elapsed).toBeLessThan(2000); }); diff --git a/src/mcplocal/tests/proxy-long-running.test.ts b/src/mcplocal/tests/proxy-long-running.test.ts new file mode 100644 index 0000000..a793084 --- /dev/null +++ b/src/mcplocal/tests/proxy-long-running.test.ts @@ -0,0 +1,255 @@ +import http from 'node:http'; + +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { + McpdClient, + UpstreamTimeoutError, + ConnectionError, + LONG_RUNNING_TIMEOUT_MS, + DEFAULT_TIMEOUT_MS, +} from '../src/http/mcpd-client.js'; +import { registerProxyRoutes } from '../src/http/routes/proxy.js'; + +/** + * Regression cover for the 30s proxy timeout that made `mcpctl chat` fail with + * a misleading "Cannot reach mcpd daemon" 503 while mcpd was answering + * /healthz in 32ms. + * + * Three separate defects are pinned here: + * 1. chat routes inherited the 30s CRUD budget, so any turn longer than 30s + * failed — and an agent turn is a tool-use loop that routinely exceeds it; + * 2. a timeout was reported as a connection failure, sending diagnosis after + * a network fault that did not exist; + * 3. SSE was buffered through res.text(), so streaming never reached the + * client even when the turn finished in time. + */ +let app: FastifyInstance | null = null; +let upstream: FastifyInstance | null = null; + +afterEach(async () => { + if (app) { await app.close(); app = null; } + if (upstream) { await upstream.close(); upstream = null; } +}); + +/** A stand-in mcpd. Returns its base URL. */ +async function startUpstream(register: (a: FastifyInstance) => void): Promise { + upstream = Fastify(); + register(upstream); + await upstream.listen({ port: 0, host: '127.0.0.1' }); + const addr = upstream.server.address(); + if (addr === null || typeof addr === 'string') throw new Error('no address'); + return `http://127.0.0.1:${String(addr.port)}`; +} + +async function startProxy(baseUrl: string, timeoutMs?: number): Promise { + app = Fastify(); + registerProxyRoutes(app, new McpdClient(baseUrl, 'test-token', {}, timeoutMs)); + await app.ready(); + return app; +} + +describe('proxy — long-running route budget', () => { + it('gives chat routes the long budget, not the 30s CRUD default', () => { + // The constants themselves are the contract: a 30s cap on an agent turn is + // a guaranteed failure, not a safety net. + expect(DEFAULT_TIMEOUT_MS).toBe(30_000); + expect(LONG_RUNNING_TIMEOUT_MS).toBeGreaterThanOrEqual(600_000); + }); + + it('does not abort an agent chat that outlives the CRUD budget', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async () => { + // Longer than the (deliberately tiny) CRUD budget below. Before the + // fix this inherited that budget and 503'd. + await new Promise((r) => setTimeout(r, 250)); + return { answer: 'pong' }; + }); + }); + // CRUD budget of 50ms — a chat route must NOT inherit it. + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ answer: 'pong' }); + }); + + it('still applies the short budget to ordinary CRUD routes', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + // Times out — and is now reported honestly as a timeout, not a connection fault. + expect(res.statusCode).toBe(504); + expect(res.json().error).toBe('upstream_timeout'); + }); + + it('reports a timeout as 504, never as "cannot reach mcpd"', async () => { + const base = await startUpstream((a) => { + a.get('/api/v1/servers', async () => { + await new Promise((r) => setTimeout(r, 300)); + return []; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + const body = res.json(); + expect(body.message).toMatch(/did not respond within/); + expect(body.message).not.toMatch(/Cannot reach mcpd/); + expect(body.message).toMatch(/reachable/); + }); + + it('streams SSE through instead of buffering it', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + }); + reply.raw.write('data: {"type":"text","delta":"po"}\n\n'); + reply.raw.write('data: {"type":"text","delta":"ng"}\n\n'); + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); + return reply; + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi', stream: true }, + }); + + expect(res.statusCode).toBe(200); + // Content-type must survive — a client that gets application/json will not + // parse the event stream. + expect(res.headers['content-type']).toMatch(/text\/event-stream/); + // x-accel-buffering=no must survive too, or intermediaries re-buffer the + // stream and reintroduce the stall. + expect(res.headers['x-accel-buffering']).toBe('no'); + expect(res.body).toContain('"delta":"po"'); + expect(res.body).toContain('"delta":"ng"'); + expect(res.body).toContain('[DONE]'); + }); + + it('delivers each SSE frame while the upstream is still generating', async () => { + // The buffering regression is invisible to the pass-through test above: + // `inject()` collects the whole body, so a proxy that buffers via + // res.text() still passes it. This test proves *progressive* delivery by + // making the upstream withhold its final frame until the client has + // observed the first one. A buffering proxy can never satisfy that + // ordering — the 3s guard resolves the gate so the run fails cleanly + // instead of deadlocking. + let openGate: (seen: boolean) => void = () => {}; + const clientSawFirstFrame = new Promise((r) => { openGate = r; }); + const guard = setTimeout(() => openGate(false), 3_000); + + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream' }); + reply.raw.write('data: {"type":"text","delta":"live"}\n\n'); + await clientSawFirstFrame; + reply.raw.write('data: {"type":"final"}\n\n'); + reply.raw.write('data: [DONE]\n\n'); + reply.raw.end(); + return reply; + }); + }); + const proxy = await startProxy(base, 50); + await proxy.listen({ port: 0, host: '127.0.0.1' }); + const addr = proxy.server.address(); + if (addr === null || typeof addr === 'string') throw new Error('no address'); + + const body = await new Promise((resolve, reject) => { + const req = http.request({ + hostname: '127.0.0.1', + port: addr.port, + path: '/api/v1/agents/reviewer/chat', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, (res) => { + let acc = ''; + res.setEncoding('utf-8'); + res.on('data', (chunk: string) => { + acc += chunk; + if (acc.includes('"delta":"live"')) openGate(true); + }); + res.on('end', () => resolve(acc)); + res.on('error', reject); + }); + req.on('error', reject); + req.end(JSON.stringify({ message: 'hi', stream: true })); + }); + clearTimeout(guard); + + // The ordering proof: the first frame reached the client while the + // upstream was still holding the stream open. + await expect(clientSawFirstFrame).resolves.toBe(true); + expect(body).toContain('"type":"final"'); + expect(body).toContain('[DONE]'); + }); + + it('relays a non-200 status from a streaming route', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => { + return reply.code(404).send({ error: 'Agent not found' }); + }); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/ghost/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(404); + expect(res.body).toContain('Agent not found'); + }); + + it('still reports a genuinely unreachable daemon as 503', async () => { + // Port 1 is reserved and refuses instantly. + const proxy = await startProxy('http://127.0.0.1:1', 500); + const res = await proxy.inject({ method: 'GET', url: '/api/v1/servers' }); + + expect(res.statusCode).toBe(503); + expect(res.json().error).toBe('service_unavailable'); + }); + + it('propagates 401 from a streaming route so login guidance still fires', async () => { + const base = await startUpstream((a) => { + a.post('/api/v1/agents/:name/chat', async (_req, reply) => reply.code(401).send({})); + }); + const proxy = await startProxy(base, 50); + + const res = await proxy.inject({ + method: 'POST', + url: '/api/v1/agents/reviewer/chat', + payload: { message: 'hi' }, + }); + expect(res.statusCode).toBe(401); + expect(res.json().message).toMatch(/mcpctl login/); + }); +}); + +describe('error taxonomy', () => { + it('keeps timeout and unreachable as distinct types', () => { + const timeout = new UpstreamTimeoutError('http://mcpd', 30_000); + expect(timeout).not.toBeInstanceOf(ConnectionError); + expect(timeout.timeoutMs).toBe(30_000); + expect(timeout.message).toMatch(/did not respond within 30000ms/); + }); +}); diff --git a/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts b/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts index e445511..3261c43 100644 --- a/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts +++ b/src/mcplocal/tests/smoke/agent-chat.smoke.test.ts @@ -18,8 +18,12 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import http from 'node:http'; import https from 'node:https'; import { spawnSync, execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const MCPLOCAL_URL = process.env.MCPLOCAL_URL ?? 'http://localhost:3200'; const LLM_URL = process.env.MCPCTL_SMOKE_LLM_URL; const LLM_MODEL = process.env.MCPCTL_SMOKE_LLM_MODEL ?? 'qwen3-thinking'; const LLM_KEY = process.env.MCPCTL_SMOKE_LLM_KEY; @@ -27,6 +31,10 @@ const SUFFIX = Date.now().toString(36); const SECRET_NAME = `smoke-chat-sec-${SUFFIX}`; const LLM_NAME = `smoke-chat-llm-${SUFFIX}`; const AGENT_NAME = `smoke-chat-agent-${SUFFIX}`; +// Dedicated agent for the streaming-timing test: the shared agent's system +// prompt pins the reply to a single token, which is too short to distinguish +// live streaming from an end-of-turn buffer dump. +const STREAM_AGENT_NAME = `smoke-stream-agent-${SUFFIX}`; interface CliResult { code: number; stdout: string; stderr: string } @@ -99,6 +107,7 @@ describe('agent chat smoke (live LLM)', () => { afterAll(() => { if (!liveLlmConfigured || !mcpdUp) return; run(`delete agent ${AGENT_NAME}`); + run(`delete agent ${STREAM_AGENT_NAME}`); run(`delete llm ${LLM_NAME}`); run(`delete secret ${SECRET_NAME}`); }); @@ -139,6 +148,92 @@ describe('agent chat smoke (live LLM)', () => { expect(result.stderr).toMatch(/thread:\s+c[a-z0-9]+/); }); + it('streams progressively THROUGH mcplocal — frames arrive during generation, not in one burst', async () => { + if (!liveLlmConfigured || !mcpdUp) return; + // The regression this pins: mcplocal's /api/v1/* proxy buffered SSE via + // res.text(), so the CLI showed nothing until the turn finished and then + // dumped the whole answer at once. The --direct tests above bypass + // mcplocal entirely and cannot catch that. This one posts to the local + // proxy (the path `mcpctl chat` actually takes) and asserts frames are + // spread across the generation window: with buffering, everything lands + // within a few ms of stream end. + if (!(await healthz(MCPLOCAL_URL))) { + // eslint-disable-next-line no-console + console.warn(`\n ○ mcplocal streaming smoke: skipped — ${MCPLOCAL_URL}/healthz unreachable.\n`); + return; + } + let token = ''; + try { + const credsPath = join(homedir(), '.mcpctl', 'credentials'); + if (existsSync(credsPath)) { + const creds = JSON.parse(readFileSync(credsPath, 'utf-8')) as { token?: string }; + if (creds.token !== undefined) token = creds.token; + } + } catch { /* unauthenticated — the request will 401 and fail loudly */ } + + run(`delete agent ${STREAM_AGENT_NAME}`); + const agent = run([ + `create agent ${STREAM_AGENT_NAME}`, + `--llm ${LLM_NAME}`, + `--description "mcplocal streaming smoke"`, + `--system-prompt "You are a smoke test. Follow the user's instructions exactly."`, + '--default-temperature 0', + '--default-max-tokens 512', + ].join(' ')); + expect(agent.code, agent.stderr).toBe(0); + + const url = new URL(`${MCPLOCAL_URL.replace(/\/$/, '')}/api/v1/agents/${STREAM_AGENT_NAME}/chat`); + const deltaTimes: number[] = []; + let endTime = 0; + let status = 0; + let raw = ''; + + await new Promise((resolve, reject) => { + const req = http.request({ + hostname: url.hostname, + port: url.port || 80, + path: url.pathname, + method: 'POST', + timeout: 120_000, + headers: { + 'Content-Type': 'application/json', + ...(token !== '' ? { Authorization: `Bearer ${token}` } : {}), + }, + }, (res) => { + status = res.statusCode ?? 0; + res.setEncoding('utf-8'); + let buf = ''; + res.on('data', (chunk: string) => { + raw += chunk; + buf += chunk; + let nl: number; + while ((nl = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, nl); + buf = buf.slice(nl + 2); + if (/"type":"(text|thinking)"/.test(frame)) deltaTimes.push(Date.now()); + } + }); + res.on('end', () => { endTime = Date.now(); resolve(); }); + res.on('error', reject); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('stream timed out')); }); + req.end(JSON.stringify({ + message: 'Count from 1 to 40, one number per line. No other text.', + stream: true, + max_tokens: 400, + })); + }); + + expect(status, raw.slice(0, 500)).toBe(200); + expect(deltaTimes.length).toBeGreaterThanOrEqual(2); + // The buffering signature: every frame lands in the same final burst as + // stream end. Live streaming puts the first delta well before the end — + // a 40-line generation spans seconds; 300ms is a conservative floor. + const firstDelta = deltaTimes[0]!; + expect(endTime - firstDelta).toBeGreaterThanOrEqual(300); + }, 150_000); + it('streaming `mcpctl chat` emits text deltas', () => { if (!liveLlmConfigured || !mcpdUp) return; // Default mode is streaming. Pipe stdout/stderr separately. diff --git a/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml b/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml index faf22e9..6a69da1 100644 --- a/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml +++ b/src/mcplocal/tests/smoke/fixtures/smoke-data.yaml @@ -12,6 +12,19 @@ servers: env: - name: FASTMCP_LOG_LEVEL value: "ERROR" + # Mirrors the production `aws-docs` probe. Without it this fixture is a + # RUNNING server with no readiness probe, so it fails the very assertion in + # health-readiness.smoke.test.ts that the fixture exists to support — the + # suite reporting its own scaffolding as a fleet regression. + # `search_documentation` needs a phrase; the 300s interval matches aws-docs, + # since the call leaves the cluster. + healthCheck: + tool: search_documentation + arguments: + search_phrase: "s3 bucket" + timeoutSeconds: 20 + intervalSeconds: 300 + failureThreshold: 3 projects: - name: smoke-data diff --git a/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts b/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts new file mode 100644 index 0000000..82e266c --- /dev/null +++ b/src/mcplocal/tests/smoke/health-readiness.smoke.test.ts @@ -0,0 +1,185 @@ +/** + * Smoke tests: readiness probes actually exercise a server's upstream. + * + * The bug these guard: every instance read `healthy` forever because the + * default probe is `tools/list`, which MCP servers answer from a static + * in-process table. The UniFi server sat green for months while every call to + * its controller timed out (pod egress was capped at 80/443, controller on + * :8443) and while its `controller_type` pointed at the wrong API dialect. + * + * So these tests assert the probe is a real round trip, not a self-report: + * 1. Servers with a `healthCheck.tool` really do reach their upstream when + * that tool is called through the production proxy path. + * 2. A server with no `healthCheck.tool` reports `live`, never `healthy` — + * "process up, function unverified" must not read as "working". + * 3. `tools/list` alone cannot distinguish the two, which is why (2) matters. + * + * Prerequisites: + * - mcplocal running on localhost:3200 + * - mcpd reachable (k8s), servers deployed with readiness probes configured + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +const CONFIG_PATH = join(homedir(), '.mcpctl', 'config.json'); +const CREDS_PATH = join(homedir(), '.mcpctl', 'credentials'); + +function loadConfig(): { mcpdUrl: string; token: string } { + let mcpdUrl = 'http://localhost:3100'; + let token = ''; + try { + if (existsSync(CONFIG_PATH)) { + const cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as { mcpdUrl?: string }; + if (cfg.mcpdUrl) mcpdUrl = cfg.mcpdUrl; + } + if (existsSync(CREDS_PATH)) { + const creds = JSON.parse(readFileSync(CREDS_PATH, 'utf-8')) as { token?: string }; + if (creds.token) token = creds.token; + } + } catch { /* use defaults */ } + return { mcpdUrl, token }; +} + +const { mcpdUrl, token } = loadConfig(); + +function mcpdRequest(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> { + return new Promise((resolve, reject) => { + const url = new URL(path, mcpdUrl); + const transport = url.protocol === 'https:' ? https : http; + + const headers: Record = { Accept: 'application/json' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (token) headers['Authorization'] = `Bearer ${token}`; + const bodyStr = body !== undefined ? JSON.stringify(body) : undefined; + if (bodyStr) headers['Content-Length'] = String(Buffer.byteLength(bodyStr)); + + const req = transport.request(url, { method, timeout: 60_000, headers, rejectUnauthorized: false }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString(); + try { + resolve({ status: res.statusCode ?? 500, data: raw ? JSON.parse(raw) as T : (undefined as T) }); + } catch { + resolve({ status: res.statusCode ?? 500, data: raw as unknown as T }); + } + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); + if (bodyStr) req.write(bodyStr); + req.end(); + }); +} + +interface HealthCheck { + tool?: string; + arguments?: Record; +} + +interface Server { + id: string; + name: string; + healthCheck: HealthCheck | null; +} + +interface Instance { + id: string; + serverId: string; + status: string; + healthStatus: string | null; + server?: { name: string }; +} + +interface ProxyResult { + result?: { tools?: Array<{ name: string }>; isError?: boolean; content?: Array<{ text?: string }> }; + error?: { code: number; message: string }; +} + +let servers: Server[] = []; +let instances: Instance[] = []; + +beforeAll(async () => { + const s = await mcpdRequest('GET', '/api/v1/servers'); + expect(s.status, `GET /api/v1/servers returned ${s.status}`).toBe(200); + servers = s.data; + + const i = await mcpdRequest('GET', '/api/v1/instances'); + expect(i.status, `GET /api/v1/instances returned ${i.status}`).toBe(200); + instances = i.data; +}, 120_000); + +describe('readiness probes reach the upstream', () => { + it('every RUNNING server has a readiness probe configured', () => { + const running = instances.filter((i) => i.status === 'RUNNING'); + expect(running.length, 'no RUNNING instances to check').toBeGreaterThan(0); + + const withoutProbe = running + .map((i) => servers.find((s) => s.id === i.serverId)) + .filter((s): s is Server => s !== undefined) + .filter((s) => s.healthCheck?.tool === undefined) + .map((s) => s.name); + + // A server with no readiness probe can only ever report `live`. That is + // honest, but it means nothing is watching its upstream — so the fleet + // should not accumulate them silently. + expect(withoutProbe, `servers with no healthCheck.tool: ${withoutProbe.join(', ')}`).toEqual([]); + }); + + it('each configured probe tool really answers through the proxy', async () => { + const probed = servers.filter((s) => s.healthCheck?.tool !== undefined); + expect(probed.length, 'no servers have readiness probes').toBeGreaterThan(0); + + const failures: string[] = []; + for (const server of probed) { + const hc = server.healthCheck!; + const res = await mcpdRequest('POST', '/api/v1/mcp/proxy', { + serverId: server.id, + method: 'tools/call', + params: { name: hc.tool, arguments: hc.arguments ?? {} }, + }); + if (res.status !== 200) { + failures.push(`${server.name}/${hc.tool}: HTTP ${res.status}`); + continue; + } + if (res.data.error) { + failures.push(`${server.name}/${hc.tool}: ${res.data.error.message}`); + continue; + } + if (res.data.result?.isError === true) { + failures.push(`${server.name}/${hc.tool}: ${res.data.result.content?.[0]?.text ?? 'isError'}`); + } + } + + // When this fails, the server is genuinely broken — fix the environment + // (credentials, egress, upstream address), never the assertion. + expect(failures, `readiness probe tools failing: ${failures.join(' | ')}`).toEqual([]); + }, 300_000); + + it('a probe tool is a different call from tools/list, and both are reachable', async () => { + const server = servers.find((s) => s.healthCheck?.tool !== undefined); + expect(server, 'need at least one probed server').toBeDefined(); + + const list = await mcpdRequest('POST', '/api/v1/mcp/proxy', { + serverId: server!.id, + method: 'tools/list', + }); + expect(list.status).toBe(200); + const toolNames = (list.data.result?.tools ?? []).map((t) => t.name); + // The probe must name a tool the server actually exposes, otherwise the + // readiness check fails for a bookkeeping reason rather than a real one. + expect(toolNames).toContain(server!.healthCheck!.tool); + }, 120_000); + + it('no RUNNING instance is stuck at an unknown health status', () => { + const stuck = instances + .filter((i) => i.status === 'RUNNING') + .filter((i) => i.healthStatus === null || i.healthStatus === 'unknown') + .map((i) => i.server?.name ?? i.serverId); + expect(stuck, `instances with no health verdict: ${stuck.join(', ')}`).toEqual([]); + }); +}); diff --git a/src/mcplocal/tests/smoke/security.test.ts b/src/mcplocal/tests/smoke/security.test.ts index 19a4848..3e7ecbe 100644 --- a/src/mcplocal/tests/smoke/security.test.ts +++ b/src/mcplocal/tests/smoke/security.test.ts @@ -32,6 +32,18 @@ function httpRequest(opts: { headers?: Record; body?: string; timeout?: number; + /** + * Resolve as soon as the response headers arrive, then hang up, instead of + * waiting for the body to end. + * + * Required for a streaming endpoint: SSE responses never end, so the normal + * path can only settle via the socket's *inactivity* timeout — which never + * fires while the stream is busy. `/inspect` relays every project's MCP + * traffic, so during a full smoke run it is never idle, and the request hung + * until vitest killed the test. Alone it looked flaky; under load it failed + * every time. Reading the status does not need the body anyway. + */ + headersOnly?: boolean; }): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { return new Promise((resolve, reject) => { const parsed = new URL(opts.url); @@ -46,6 +58,12 @@ function httpRequest(opts: { timeout: opts.timeout ?? 10_000, }, (res) => { + if (opts.headersOnly === true) { + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: '' }); + res.destroy(); + req.destroy(); + return; + } const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { @@ -93,17 +111,15 @@ describe('Smoke: Security — mcplocal unauthenticated endpoints', () => { // /inspect streams ALL MCP traffic (tool calls, arguments, responses) // for ALL projects to any unauthenticated local client + // headersOnly: the stream never ends, and waiting for it to go idle is what + // made this hang whenever other suites were generating traffic. The status + // line is all this assertion needs. const res = await httpRequest({ url: `${MCPLOCAL_URL}/inspect`, method: 'GET', headers: { 'Accept': 'text/event-stream' }, timeout: 3_000, - }).catch((err) => { - // Timeout is expected (SSE keeps connection open) — still means endpoint is accessible - if ((err as Error).message.includes('timed out')) { - return { status: 200, headers: {} as http.IncomingHttpHeaders, body: '' }; - } - throw err; + headersOnly: true, }); // Should be accessible without auth (documenting the vulnerability) diff --git a/src/opencode-ext/mcpctl-opencode-tui.tsx b/src/opencode-ext/mcpctl-opencode-tui.tsx new file mode 100644 index 0000000..16664c1 --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode-tui.tsx @@ -0,0 +1,323 @@ +/** @jsxImportSource @opentui/solid */ +/** + * mcpctl opencode TUI plugin — `/mcpctl` project switcher + status indicator. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/mcpctl/mcpctl-tui.tsx` and registered in + * `~/.config/opencode/tui.json`. + * + * What it adds to opencode: + * - `/mcpctl` — pick the active project from a filterable dialog + * - `/mcpctl-status` — what is mounted, from where, as which user + * - `/mcpctl-skills` — re-sync this project's skills into ~/.config/opencode/skill + * - a `mcpctl:` indicator in the prompt footer, next to the model + * name and the token counter + * + * The switch itself is delegated to the `mcpctl` CLI (the same binary that + * installed this file), so token minting, state and skills stay in one place + * and this stays a UI shell. Once the CLI has rewritten the state file, the + * mount is re-pointed live through opencode's own MCP API — no restart, unlike + * every config-file-based integration. + * + * Only Node builtins + opencode's plugin API are imported. + */ +import type { TuiPluginApi, TuiPluginModule } from '@opencode-ai/plugin/tui'; +import type { JSX } from '@opentui/solid'; +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name the mount lives under — must match the server plugin. */ +const SERVER_NAME = 'mcpctl'; + +/** + * kv key holding the label the footer renders. + * + * kv is a reactive store, so writing it here re-renders the slot with no + * signal plumbing of our own; it also survives across sessions, so the label is + * correct on the very first frame instead of after the state file is read. + */ +const KV_LABEL = 'mcpctl.project'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +interface ProjectInfo { + name: string; + description?: string; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8')); + // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns + // null, so the catch never fires and the next `state.project` throws a + // TypeError that takes the plugin down. A truncated or hand-edited state + // file must degrade to "no project", never to a broken opencode. + return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {}; + } catch { + return {}; + } +} + +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +/** + * Run the `mcpctl` CLI and resolve its stdout. + * + * execFile, not a shell: project names come from the server and would otherwise + * need quoting, and a shell buys nothing here. + */ +function mcpctl(args: string[], timeoutMs = 120_000): Promise { + return new Promise((resolve, reject) => { + execFile('mcpctl', args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || err.message).trim() || String(err))); + else resolve(stdout); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl(['get', 'projects', '-o', 'json'], 60_000); + const parsed = JSON.parse(out || '[]') as Array<{ name?: unknown; description?: unknown }>; + return parsed + .filter((p): p is { name: string; description?: string } => typeof p?.name === 'string') + .map((p) => ({ name: p.name, description: typeof p.description === 'string' && p.description !== '' ? p.description : undefined })); +} + +/** + * Active project first, then alphabetical. + * + * opencode's select dialog does its own fuzzy filtering as you type, so unlike + * the pi and prime-agent switchers this needs no pre-filter prompt — only a + * sensible starting order, since the most likely pick is "the one I am on". + * + * Exported so the ordering is unit-tested rather than eyeballed through a TUI. + */ +export function orderProjects(projects: ProjectInfo[], active: string | null): ProjectInfo[] { + return [...projects].sort((a, b) => { + if (a.name === active) return -1; + if (b.name === active) return 1; + return a.name.localeCompare(b.name); + }); +} + +/** The footer label for a project (or the absence of one). */ +export function indicatorLabel(project: string | null): string { + return project !== null && project !== '' ? `mcpctl:${project}` : 'mcpctl:none'; +} + +const tui = async (api: TuiPluginApi): Promise => { + /** Re-read the state file and publish the footer label. */ + async function refreshIndicator(): Promise { + const state = await readState(); + const project = state.project ?? null; + api.kv.set(KV_LABEL, indicatorLabel(project)); + return project !== null && project !== '' ? project : null; + } + + /** + * Point the live MCP mount at `project`. + * + * Registering under the same name every time keeps the tool prefix stable + * (`mcpctl_*`), and because opencode re-resolves tools per request the model + * simply sees the new project's tools on its next turn. + */ + async function mount(project: string, state: OpencodeState): Promise { + const gatewayUrl = state.gatewayUrl; + if (gatewayUrl === undefined || gatewayUrl === '') throw new Error('no gatewayUrl in ~/.mcpctl/opencode-state.json — run `mcpctl config opencode --project `'); + const token = state.tokens?.[project] ?? ''; + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + // Tear the outgoing mount down explicitly. `mcp.add` under the same name + // does re-point the tools, but leaves it to opencode whether the previous + // client is closed — and an abandoned one keeps its `mcp-session-id` alive + // on mcplocal, which is what holds a gated project open. Best-effort: on a + // first mount there is nothing to disconnect. + try { + await api.client.mcp.disconnect({ name: SERVER_NAME }); + } catch { + /* not mounted yet */ + } + await api.client.mcp.add({ + name: SERVER_NAME, + config: { + type: 'remote', + url: projectUrl(gatewayUrl, project), + headers, + enabled: true, + timeout: 120_000, + }, + }); + } + + async function switchTo(project: string): Promise { + api.ui.toast({ message: `mcpctl: switching to '${project}'…`, variant: 'info' }); + try { + // The CLI mints/reuses the project token, rewrites the state file and + // syncs skills. --skip-plugin leaves this very file alone (rewriting a + // loaded plugin mid-session buys nothing); --skip-marker stops us + // silently re-scoping whatever repo opencode was started in, which + // Claude Code's own skills sync would then pick up. + await mcpctl(['config', 'opencode', '--project', project, '--skip-plugin', '--skip-marker']); + } catch (err) { + api.ui.toast({ message: `mcpctl: switch to '${project}' failed — ${errText(err)}`, variant: 'error' }); + return; + } + try { + await mount(project, await readState()); + } catch (err) { + // The state file is already updated, so a restart would recover — say so + // rather than reporting a success the tools do not back up. + api.ui.toast({ message: `mcpctl: '${project}' configured but not mounted — ${errText(err)}`, variant: 'error' }); + await refreshIndicator(); + return; + } + await refreshIndicator(); + api.ui.toast({ message: `mcpctl: switched to '${project}'`, variant: 'success' }); + } + + api.keymap.registerLayer({ + commands: [ + { + name: 'mcpctl.switch', + title: 'mcpctl: switch project', + description: 'Mount another mcpctl project’s MCP servers and skills', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl', + async run(): Promise { + const active = await refreshIndicator(); + let projects: ProjectInfo[]; + try { + projects = await listProjects(); + } catch (err) { + api.ui.toast({ message: `mcpctl: could not list projects — ${errText(err)}`, variant: 'error' }); + return; + } + if (projects.length === 0) { + api.ui.toast({ message: 'mcpctl: no projects found (is mcpctl logged in?)', variant: 'warning' }); + return; + } + const DialogSelect = api.ui.DialogSelect; + api.ui.dialog.replace(() => ( + ({ + title: p.name, + value: p.name, + description: p.description, + }))} + onSelect={(option): void => { + api.ui.dialog.clear(); + const picked = option.value; + if (typeof picked !== 'string') return; + if (picked === active) { + api.ui.toast({ message: `mcpctl: already on '${picked}'`, variant: 'info' }); + return; + } + void switchTo(picked); + }} + /> + )); + }, + }, + { + name: 'mcpctl.status', + title: 'mcpctl: status', + description: 'Show the active mcpctl project and its MCP mount', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-status', + async run(): Promise { + const state = await readState(); + const project = await refreshIndicator(); + let mcpStatus = 'unknown'; + try { + const res = await api.client.mcp.status(); + mcpStatus = res.data?.[SERVER_NAME]?.status ?? 'not mounted'; + } catch { + mcpStatus = 'unavailable'; + } + const url = project !== null && state.gatewayUrl !== undefined ? projectUrl(state.gatewayUrl, project) : 'n/a'; + api.ui.toast({ + message: `mcpctl — project: ${project ?? 'none'} · mount: ${mcpStatus} · ${url}`, + variant: mcpStatus === 'connected' ? 'success' : 'warning', + duration: 12_000, + }); + }, + }, + { + name: 'mcpctl.skills', + title: 'mcpctl: sync skills', + description: 'Re-sync this project’s mcpctl skills into opencode’s skill directory', + category: 'mcpctl', + namespace: 'palette', + slashName: 'mcpctl-skills', + async run(): Promise { + const project = await refreshIndicator(); + if (project === null) { + api.ui.toast({ message: 'mcpctl: no active project — run /mcpctl first', variant: 'warning' }); + return; + } + try { + await mcpctl(['skills', 'sync', '--agent', 'opencode', '-p', project, '--quiet']); + api.ui.toast({ message: `mcpctl: skills synced for '${project}'`, variant: 'success' }); + } catch (err) { + api.ui.toast({ message: `mcpctl: skills sync failed — ${errText(err)}`, variant: 'error' }); + } + }, + }, + ], + // Switching is the thing you do repeatedly, so it gets a chord as well as + // `/mcpctl`. The other two commands stay palette-only — they are occasional. + bindings: [ + { key: 'm', group: 'mcpctl', desc: 'switch mcpctl project', cmd: 'mcpctl.switch' }, + ], + }); + + // The indicator. `session_prompt_right` and `home_prompt_right` are the only + // slots in the footer cluster opencode exposes to plugins: they render on the + // prompt's bottom line, immediately right of the model name and directly + // above the token counter. (`home_footer` would sit on the counter's line but + // *replaces* the cwd/version footer rather than adding to it, and + // `app_bottom` costs a whole extra terminal row.) + // wrapMode="none" + truncate: the home prompt row is narrow, and the default + // wrap breaks "mcpctl:homeautomation" across two lines mid-word. Clipping the + // tail of a long name reads far better than a two-line footer. + const Indicator = (): JSX.Element => ( + + {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..2fb4c6d --- /dev/null +++ b/src/opencode-ext/mcpctl-opencode.ts @@ -0,0 +1,170 @@ +/** + * mcpctl opencode server plugin — mounts the active project's MCP gateway. + * + * Installed by `mcpctl config opencode` into + * `~/.config/opencode/plugin/mcpctl.ts`, where opencode auto-discovers it. + * + * WHY A PLUGIN AND NOT A `mcp` BLOCK IN opencode.json: + * 1. The gateway needs an `Authorization: Bearer ` header. Putting + * it in opencode.json means a secret in a mode-0644 config file that users + * paste into issues; `~/.mcpctl/opencode-state.json` is 0600 like the rest + * of mcpctl's credentials. + * 2. Switching projects has to work *without restarting opencode*. The server + * exposes `POST /mcp` (add) and `/mcp/{name}/disconnect`, so the mount can + * be re-pointed live — a config file can't do that. + * + * The TUI plugin (`mcpctl-tui.tsx`) drives the switch; this one exists so that + * headless runs (`opencode run ...`), which load no TUI plugins at all, still + * get the active project's tools. + * + * Only Node builtins + the plugin API are imported, so the installed file needs + * no dependencies of its own. + */ +import type { Plugin, PluginModule } from '@opencode-ai/plugin'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +/** MCP server name we mount under. Constant on purpose — see `mount`. */ +const SERVER_NAME = 'mcpctl'; + +interface OpencodeState { + project?: string; + gatewayUrl?: string; + tokens?: Record; +} + +function statePath(): string { + return join(homedir(), '.mcpctl', 'opencode-state.json'); +} + +async function readState(): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(statePath(), 'utf-8')); + // Type-guard, not just try/catch: `JSON.parse('null')` succeeds and returns + // null, so the catch never fires and the next `state.project` throws a + // TypeError that takes the plugin down. A truncated or hand-edited state + // file must degrade to "no project", never to a broken opencode. + return typeof parsed === 'object' && parsed !== null ? (parsed as OpencodeState) : {}; + } catch { + return {}; + } +} + +/** Proxy MCP URL for a project on the gateway. */ +function projectUrl(gatewayUrl: string, project: string): string { + return `${gatewayUrl.replace(/\/+$/, '')}/projects/${encodeURIComponent(project)}/mcp`; +} + +const server: Plugin = async ({ client }) => { + /** + * The (url, token) this process last registered. + * + * Re-registering is NOT free: `mcp.add` rebuilds the connection, and mcplocal + * binds a gated project's unlocked state to the `mcp-session-id` of that + * connection. Re-adding an unchanged config every turn would therefore drop + * the gate open by `begin_session` and re-lock the project mid-conversation. + * So we only call `add` when the target actually changed — or when the mount + * is not connected, where reconnecting is the whole point. + */ + let mounted: string | null = null; + + /** + * Mount (or re-point) the active project. + * + * The MCP server is always registered under the same name, so tools keep the + * stable `mcpctl_*` prefix across switches and the model never sees a tool + * namespace vanish mid-conversation. opencode resolves the tool list per + * request, so a re-point is picked up on the next turn with no restart and no + * "the tools you were told about are gone" announcement to the model. + */ + async function mount(): Promise { + const state = await readState(); + const project = state.project; + const gatewayUrl = state.gatewayUrl; + if (project === undefined || project === '' || gatewayUrl === undefined || gatewayUrl === '') return; + const token = state.tokens?.[project] ?? ''; + const url = projectUrl(gatewayUrl, project); + const target = `${url}\u0000${token}`; + + if (mounted === target && (await isConnected())) return; + + const headers: Record = {}; + if (token !== '') headers['Authorization'] = `Bearer ${token}`; + await client.mcp.add({ + body: { + name: SERVER_NAME, + config: { + type: 'remote', + url, + headers, + enabled: true, + timeout: 120_000, + }, + }, + }); + mounted = target; + } + + /** Is our mount currently up? Unknown/unreachable counts as "not connected". */ + async function isConnected(): Promise { + try { + const res = await client.mcp.status(); + return res.data?.[SERVER_NAME]?.status === 'connected'; + } catch { + return false; + } + } + + /** + * `mount`, serialised and never throwing. + * + * Serialised because the two callers below can overlap — the event stream is + * chatty and a message can land while a mount is still connecting — and two + * concurrent `mcp.add` calls would race to register the same name. + * + * Never throwing because an unreachable gateway must degrade to "no mcpctl + * tools", not to "opencode fails to start". + */ + let inflight: Promise | null = null; + function ensureMounted(): Promise { + inflight ??= mount() + .catch(() => { /* best-effort */ }) + .finally(() => { inflight = null; }); + return inflight; + } + + // NOTE: deliberately NOT mounted here. Plugin setup runs before the server is + // accepting connections, and `client.mcp.add` calls back into that same + // server — awaiting it at this point hangs opencode on a blank screen before + // the TUI ever draws. Both hooks below fire only once the server is live. + return { + /** + * First contact: mount as soon as the server is up, so a session that never + * sends a message still shows the project's tools (and the sidebar shows + * the mount as connected). + */ + event: async (): Promise => { + await ensureMounted(); + }, + + /** + * Re-assert the mount before every user turn. + * + * `mcpctl config opencode --project X` (run from a shell, or by the TUI + * switcher in a *different* opencode window) rewrites the state file + * underneath us. Re-reading here is what makes an external switch take + * effect on the next message instead of on the next restart. When nothing + * changed this is a state-file read and a status call — `mount` will not + * re-register a mount that is already pointing at the right place. + */ + 'chat.message': async (): Promise => { + await ensureMounted(); + }, + }; +}; + +export default { + id: 'mcpctl', + server, +} satisfies PluginModule & { id: string }; diff --git a/src/opencode-ext/tsconfig.json b/src/opencode-ext/tsconfig.json new file mode 100644 index 0000000..542bb09 --- /dev/null +++ b/src/opencode-ext/tsconfig.json @@ -0,0 +1,28 @@ +{ + "//": [ + "The opencode plugins are shipped as source (embedded in the CLI, then", + "written into ~/.config/opencode/) and are therefore never compiled by the", + "CLI's own build. Without this project they would be typechecked by nothing.", + "", + "They are checked against the REAL @opencode-ai/plugin types (a dev", + "dependency, pinned to the opencode release they target) rather than a", + "hand-written shim, because a shim drifting from the published API is the", + "exact failure mode this guards against — see src/pi-ext/tsconfig.json." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "types": ["node"], + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-opencode.ts", "mcpctl-opencode-tui.tsx"] +} diff --git a/src/pi-ext/mcpctl-pi.ts b/src/pi-ext/mcpctl-pi.ts index d895720..b42d22d 100644 --- a/src/pi-ext/mcpctl-pi.ts +++ b/src/pi-ext/mcpctl-pi.ts @@ -20,9 +20,16 @@ * or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"] * * Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude. + * + * RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers + * through a fixed alias table in its own loader, and that table differs between + * pi distributions — `@earendil-works/*` exists only in the newer packages, + * while `@mariozechner/*` installs alias only the old names. `typebox` is the + * one specifier every published pi aliases, so it is the ONLY runtime import + * allowed here. Anything else must be `import type` (erased before jiti runs) + * or inlined — see `stringEnum` below. */ import { Type, type TSchema } from "typebox"; -import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { McpHttpSession, @@ -110,6 +117,23 @@ async function listProjects(mcplocalUrl: string, token?: string): Promise({ + type: "string", + enum: values, + ...(description ? { description } : {}), + }); +} + function convertSchema(inputSchema: unknown): TSchema { if (!inputSchema || typeof inputSchema !== "object") { return Type.Object({}); @@ -147,7 +171,7 @@ function convertProp(raw: unknown): TSchema { const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined; if (enumVals && enumVals.every((v) => typeof v === "string")) { - return StringEnum(enumVals as string[]); + return stringEnum(enumVals as string[], desc); } if (enumVals && enumVals.every((v) => typeof v === "number")) { const literals = enumVals.map((v) => Type.Literal(v)); diff --git a/src/prime-agent-ext/mcpctl-switch.ts b/src/prime-agent-ext/mcpctl-switch.ts new file mode 100644 index 0000000..9239412 --- /dev/null +++ b/src/prime-agent-ext/mcpctl-switch.ts @@ -0,0 +1,275 @@ +/** + * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/. + * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy + * MCP + skills) from inside prime-agent, then reloads the session. + * + * It shells out to the `mcpctl` CLI (same binary that wrote the config) to + * list projects and apply the switch, then asks the running TUI to reload so + * the new project's MCP servers, credentials and skills take effect without an + * app restart. Keeping the logic in the CLI means this UI shell stays in + * lock-step with the machinery in the mcpctl repo. + */ +import { exec } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const AGENT_DIR = join(homedir(), '.prime', 'agent'); + +interface ProjectInfo { + name: string; + description?: string; +} + +function mcpctl(...args: string[]): Promise { + const quoted = args.map((a) => `'${String(a).replace(/'/g, "'\\''")}'`).join(' '); + return new Promise((resolve, reject) => { + exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => { + if (err) reject(new Error((stderr || String(err)).trim() || String(err))); + else resolve(stdout || ''); + }); + }); +} + +async function listProjects(): Promise { + const out = await mcpctl('get', 'projects', '-o', 'json'); + const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>; + return parsed.filter((p) => p !== null && typeof p === 'object' && typeof p.name === 'string').map((p) => ({ + name: p.name as string, + description: p.description, + })); +} + +/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */ +async function credentialedProjects(): Promise> { + const out = new Set(); + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8'); + const parsed = JSON.parse(raw) as Record; + for (const [k, v] of Object.entries(parsed)) { + if (!k.startsWith('mcp:')) continue; + const key = v?.key; + if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4)); + } + } catch { + // no auth.json (or unreadable) — nothing to adopt + } + return out; +} + +/** + * The single *active* mcpctl project. Entries this CLI wrote carry an + * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an + * untagged entry also counts when its URL is the canonical + * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl + * PAT. A hand-configured server has no such credential and is never mistaken + * for the active project. + */ +async function activeProject(): Promise { + try { + const { readFile } = await import('node:fs/promises'); + const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8'); + const settings = JSON.parse(raw) as { mcpServers?: Record> }; + if (!settings.mcpServers) return null; + const names = Object.keys(settings.mcpServers); + for (const name of names) { + const entry = settings.mcpServers[name]; + if (entry !== undefined && entry !== null && entry['mcpctlManaged'] === true) return name; + } + const credentialed = await credentialedProjects(); + for (const name of names) { + const entry = settings.mcpServers[name]; + const url = entry !== undefined && entry !== null ? entry['url'] : undefined; + if (typeof url !== 'string' || !credentialed.has(name)) continue; + if (url.replace(/\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name; + } + return null; + } catch { + return null; + } +} + +/** Key our indicator is stored under (both the widget and the footer status). */ +const STATUS_KEY = 'mcpctl'; + +interface StatusCapableContext { + hasUI?: boolean; + ui: { setStatus(key: string, text: string | undefined): void }; +} + +/** + * Show the active project in the UI, so it is visible at a glance instead of + * something you run a command to discover. + * + * Published via `setStatus`, which both hosts render next to the model name: + * pi in its footer, prime-agent in the tray line built by + * `getTrayLocationLabel()`. + * + * NOTE: prime-agent only grew that rendering in + * `prime-agent-extension-status.patch` (upstream PR pending) — before it, + * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this + * call silently did nothing. An unpatched build shows no indicator; a widget + * would render there but scrolls away with the transcript, so it is not a + * substitute for a status line. + */ +async function publishStatus(ctx: StatusCapableContext): Promise { + // Before the TUI binds its UI context the runtime hands extensions a no-op + // one, where every setter silently discards. Publishing then would cache a + // label that never rendered. + if (ctx.hasUI === false) return; + let active: string | null = null; + try { + active = await activeProject(); + } catch { + active = null; + } + // Deliberately not skipped when the value is unchanged: prime-agent clears + // extension statuses on reset (see the retries in session_start), so a cached + // "nothing changed" short-circuit would leave the indicator permanently blank. + ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined); +} + +/** Above this many projects, offer a filter before opening the list. */ +const FILTER_THRESHOLD = 20; + +/** + * Order and filter the project list for the picker. + * + * Active project first (most likely pick), then alphabetical. Terms are + * space-separated and ALL must match as case-insensitive substrings against + * the name or description, so `home auto` finds `homeautomation`. A blank + * query keeps everything. + */ +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)); + }); +} + +/** + * Choose a project, asking for a filter first when the list is long. + * + * The host's selector is a plain arrow-key list with no search, so filtering + * has to happen before the list is handed over. Real installs run to hundreds + * of projects (smoke-test leftovers included), where scrolling is hopeless. + */ +async function pickProject( + ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } }, + projects: ProjectInfo[], + active: string | null, +): Promise { + let candidates = filterProjects(projects, '', active); + if (candidates.length > FILTER_THRESHOLD) { + const query = await ctx.ui.input( + `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`, + 'e.g. home auto', + ); + if (query === undefined) return undefined; // cancelled + candidates = filterProjects(projects, query, active); + if (candidates.length === 0) { + ctx.ui.notify(`No project matches '${query}'`, 'warning'); + return undefined; + } + } + + // No client-side cap: prime-agent's selector windows long lists itself and + // shows a true "(20/356)" counter, so truncating here would only replace an + // accurate total with a misleading one. + const items = candidates.map((p) => (p.description !== undefined && p.description !== '' ? `${p.name} — ${p.description}` : p.name)); + const picked = await ctx.ui.select( + (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project') + + ` (${String(candidates.length)})`, + items, + ); + if (picked === undefined) return undefined; + return picked.split(' — ')[0]?.trim(); +} + +export default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI): void { + // prime-agent emits `session_start` ONLY from reload() — never at startup — + // so this alone would leave the indicator blank until the first switch. + // `turn_start` fires on every user turn with a real UI context bound, which + // is the earliest reliable moment; publishStatus is a no-op when the label + // has not changed, so calling it per turn costs nothing. + pi.on('session_start', async (_event, ctx) => { + await publishStatus(ctx); + // prime-agent wipes extension state shortly after startup: + // resetExtensionUI() calls clearExtensionStatuses() (and + // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the + // connection-state-snapshot handler, both of which land *after* + // session_start. The indicator set above is therefore cleared before it is + // ever seen. Re-publish a few times to land after that reset; setStatus is + // idempotent, so an unnecessary retry costs one re-render. + for (const delay of [1_000, 3_000, 6_000]) { + setTimeout(() => { void publishStatus(ctx); }, delay); + } + }); + pi.on('turn_start', async (_event, ctx) => { + await publishStatus(ctx); + }); + + pi.registerCommand('mcpctl', { + description: 'Switch the active mcpctl project (proxy MCP + skills) and reload', + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify('/mcpctl needs an interactive session', 'error'); + return; + } + // Running the command is itself proof of a real UI, and the "already on + // X" path below returns without reloading — so publish here too. + await publishStatus(ctx); + let projects: ProjectInfo[]; + try { + projects = await listProjects(); + } catch (err) { + ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error'); + return; + } + if (projects.length === 0) { + ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info'); + return; + } + + const active = await activeProject(); + const picked = await pickProject(ctx, projects, active); + if (picked === undefined || picked === '') return; + + const name = picked; + if (name === active) { + ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info'); + return; + } + + ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info'); + try { + // Mint the project token (if needed), write settings.json + auth.json, + // and sync skills. --skip-extension stops re-installing this very file; + // --skip-marker stops us writing a .mcpctl-project into whatever + // directory prime-agent was launched from, which would silently + // re-scope that repo for Claude Code's own skills sync. + await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker'); + } catch (err) { + ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error'); + return; + } + + // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP + // integration map from scratch, so the old project's gateway is dropped + // and the new one mounted without restarting the app. + await ctx.reload(); + // reload re-emits session_start, which refreshes the footer — but this + // command's context outlives that, so set it here too rather than relying + // on ordering. + await publishStatus(ctx); + ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info'); + }, + }); +} diff --git a/src/prime-agent-ext/tsconfig.json b/src/prime-agent-ext/tsconfig.json new file mode 100644 index 0000000..215ecbd --- /dev/null +++ b/src/prime-agent-ext/tsconfig.json @@ -0,0 +1,29 @@ +{ + "//": [ + "The prime-agent `/mcpctl` switcher is shipped as source (embedded in the", + "CLI, then written into ~/.prime/agent/extensions/) and is therefore never", + "compiled by the CLI's own build.", + "", + "Until this project existed it lived only as a string literal inside", + "src/cli/src/config/prime-agent-extension.ts, which means nothing typechecked", + "it at all — the same gap that let a `ctx.ui.select()` call with the wrong", + "option shape ship in the pi extension (see src/pi-ext/tsconfig.json).", + "", + "It is checked against the REAL @earendil-works/pi-coding-agent types, the", + "ExtensionAPI prime-agent implements, rather than a hand-written shim." + ], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "files": ["mcpctl-switch.ts"] +} diff --git a/stack/claude-vllm b/stack/claude-vllm new file mode 100755 index 0000000..aebb04e --- /dev/null +++ b/stack/claude-vllm @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# claude-vllm — run Claude Code against the homelab LLM gateway instead of api.anthropic.com. +# +# The gateway (LiteLLM in front of vLLM) already speaks the Anthropic Messages +# API on /v1/messages, so no bridge or translation layer is needed — Claude Code +# talks to it directly once ANTHROPIC_BASE_URL points there. This script exists +# only to find the endpoint, credential and model you have already configured +# for another agent, instead of making you paste four exports every time. +# +# Discovery order (first hit wins, per field): +# 1. environment already set (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / ANTHROPIC_MODEL) +# 2. flags (--provider, --model) +# 3. its own config: $XDG_CONFIG_HOME/mcpctl/claude-vllm.jsonc (~/.config/...) +# 4. ~/.pi/agent settings.json + models.json + auth.json +# 5. ~/.prime/agent settings.json + models.json + auth.json +# +# The config file is shaped like opencode's — a `provider` map with +# `options.baseURL` / `options.apiKey` and a `models` map — but it is OUR file. +# Reading opencode's own config would mean a credential rotation there silently +# changing what Claude Code authenticates with, and would tie two tools' configs +# together for no reason. The pi/prime homes remain as a convenience fallback so +# `claude-vllm` works before you have written a config at all. +# +# NO KEY IS EVER STORED IN THIS SCRIPT. `apiKey` may be a literal (in a 0600 +# config), or `\${ENV_VAR}` / a bare env var NAME, so the secret can live in your +# environment or a password manager instead of on disk. +# +# Usage: +# claude-vllm # default provider + model, then exec claude +# claude-vllm --model deepseek-v4-max +# claude-vllm --provider itaz --model deepseek-v4-fast -- -p "summarise this repo" +# claude-vllm --list # show what it would use (never prints the key) +# claude-vllm --print-env # print the exports and exit (don't run claude) +# claude-vllm --init --api-key ... # write the config file (0600); reads stdin if omitted +set -euo pipefail + +PROVIDER="" +MODEL="" +LIST=0 +INIT=0 +PRINT_ENV=0 +API_KEY_ARG="" +BASE_URL_ARG="" +CLAUDE_ARGS=() + +while [ $# -gt 0 ]; do + case "$1" in + --provider) PROVIDER="${2:?--provider needs a value}"; shift 2 ;; + --model) MODEL="${2:?--model needs a value}"; shift 2 ;; + --list) LIST=1; shift ;; + --init) INIT=1; shift ;; + --api-key) API_KEY_ARG="${2:?--api-key needs a value}"; shift 2 ;; + --base-url) BASE_URL_ARG="${2:?--base-url needs a value}"; shift 2 ;; + --print-env) PRINT_ENV=1; shift ;; + -h|--help) sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + --) shift; CLAUDE_ARGS+=("$@"); break ;; + *) CLAUDE_ARGS+=("$1"); shift ;; + esac +done + +command -v jq >/dev/null || { echo "claude-vllm: jq is required" >&2; exit 1; } + +PI_HOME="${PI_AGENT_HOME:-$HOME/.pi/agent}" +PRIME_HOME="${PRIME_AGENT_HOME:-$HOME/.prime/agent}" +CONFIG="${CLAUDE_VLLM_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/mcpctl/claude-vllm.jsonc}" + +# jq tolerates the // comments opencode.jsonc may contain only after we strip +# them; harmless for strict JSON. +read_json() { [ -f "$1" ] && sed 's://[^"]*$::' "$1" | jq -c . 2>/dev/null || echo '{}'; } + +# Resolve an apiKey field that may be a literal, \${ENV_VAR}, or a bare env var +# NAME (the form pi's models.json uses). Keeping the indirection means the +# secret can live in the environment rather than on disk. +resolve_key() { + local raw="$1" + [ -n "$raw" ] || return 0 + case "$raw" in + '${'*'}') local n="${raw#\$\{}"; n="${n%\}}"; printf '%s' "${!n:-}" ;; + # A bare ALL_CAPS token that names a set variable is an env var reference, + # not a key: no real API key looks like that. + [A-Z_][A-Z0-9_]*) if [ -n "${!raw:-}" ]; then printf '%s' "${!raw}"; else printf '%s' "$raw"; fi ;; + *) printf '%s' "$raw" ;; + esac +} + +# ── discover ───────────────────────────────────────────────────────────────── +# Each agent home is tried in turn; the first one that yields a base URL wins, +# and the credential is taken from that same home so we never pair one gateway's +# URL with another's key. +discover_from_agent_home() { + local home="$1" settings models auth provider base key model + settings=$(read_json "$home/settings.json") + models=$(read_json "$home/models.json") + auth=$(read_json "$home/auth.json") + + provider="$PROVIDER" + [ -n "$provider" ] || provider=$(jq -r '.defaultProvider // empty' <<<"$settings") + [ -n "$provider" ] || return 1 + + base=$(jq -r --arg p "$provider" '.providers[$p].baseUrl // empty' <<<"$models") + [ -n "$base" ] || return 1 + # `apiKey` in models.json names an env var; the secret itself lives in auth.json. + key=$(jq -r --arg p "$provider" '.[$p].key // empty' <<<"$auth") + + model="$MODEL" + [ -n "$model" ] || model=$(jq -r '.defaultModel // empty' <<<"$settings") + + FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key"; FOUND_MODEL="$model" + FOUND_MODELS=$(jq -r --arg p "$provider" '.providers[$p].models[]?.id' <<<"$models") + # model -> context for ALL models, so --init records every limit rather than + # only the active one (Claude Code assumes 200k for anything it lacks). + FOUND_MODEL_LIMITS=$(jq -c --arg p "$provider" \ + '[.providers[$p].models[]? | select(.contextWindow) | {(.id): {limit:{context:.contextWindow}}}] | add // {}' <<<"$models") + FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$model" \ + '.providers[$p].models[]? | select(.id==$m) | .contextWindow // empty' <<<"$models") + FOUND_SOURCE="$home" + return 0 +} + +# Our own config, shaped like opencode's but deliberately a separate file. +discover_from_config() { + local cfg provider base key + [ -f "$CONFIG" ] || return 1 + cfg=$(read_json "$CONFIG") + provider="$PROVIDER" + [ -n "$provider" ] || provider=$(jq -r '(.model // "") | split("/")[0] // empty' <<<"$cfg") + [ -n "$provider" ] || provider=$(jq -r '.provider | keys[0] // empty' <<<"$cfg") + [ -n "$provider" ] || return 1 + + base=$(jq -r --arg p "$provider" '.provider[$p].options.baseURL // empty' <<<"$cfg") + [ -n "$base" ] || return 1 + key=$(resolve_key "$(jq -r --arg p "$provider" '.provider[$p].options.apiKey // empty' <<<"$cfg")") + + FOUND_PROVIDER="$provider"; FOUND_BASE="$base"; FOUND_KEY="$key" + FOUND_MODEL="${MODEL:-$(jq -r '(.model // "") | split("/")[1] // empty' <<<"$cfg")}" + FOUND_MODELS=$(jq -r --arg p "$provider" '.provider[$p].models | keys[]?' <<<"$cfg") + FOUND_MODEL_LIMITS=$(jq -c --arg p "$provider" '.provider[$p].models // {}' <<<"$cfg") + FOUND_CONTEXT=$(jq -r --arg p "$provider" --arg m "$FOUND_MODEL" \ + '.provider[$p].models[$m].limit.context // empty' <<<"$cfg") + FOUND_SOURCE="$CONFIG" + return 0 +} + +FOUND_PROVIDER=""; FOUND_BASE=""; FOUND_KEY=""; FOUND_MODEL=""; FOUND_MODELS=""; FOUND_MODEL_LIMITS=""; FOUND_CONTEXT=""; FOUND_SOURCE="" +discover_from_config \ + || discover_from_agent_home "$PI_HOME" \ + || discover_from_agent_home "$PRIME_HOME" \ + || true + +# ── --init: write the config file ──────────────────────────────────────────── +if [ "$INIT" = 1 ]; then + init_base="${BASE_URL_ARG:-${FOUND_BASE:-}}" + if [ -z "$init_base" ]; then + echo "claude-vllm --init: no endpoint known. Pass --base-url https://your-gateway/v1" >&2 + exit 1 + fi + init_key="$API_KEY_ARG" + if [ -z "$init_key" ]; then + # Read from stdin so the key never lands in shell history or the process + # table (where --api-key is visible to every user via `ps`). + if [ -t 0 ]; then + printf 'API key (input hidden, or pass ${ENV_VAR} to keep it out of the file): ' >&2 + read -rs init_key; echo >&2 + else + read -r init_key || true + fi + fi + [ -n "$init_key" ] || { echo "claude-vllm --init: no API key given" >&2; exit 1; } + + init_provider="${PROVIDER:-${FOUND_PROVIDER:-default}}" + init_model="${MODEL:-${FOUND_MODEL:-}}" + # Every model's limit, not just the active one — switching with --model must + # not silently drop back to Claude Code's assumed 200k. + init_models_json=$( + if [ -n "$FOUND_MODELS" ]; then + known="${FOUND_MODEL_LIMITS:-{\}}" + for m in $FOUND_MODELS; do jq -n --arg m "$m" '{($m): {}}'; done \ + | jq -s --argjson known "$known" 'add // {} | . * $known' + else + jq -n --arg m "$init_model" 'if $m == "" then {} else {($m): {}} end' + fi + ) + + mkdir -p "$(dirname "$CONFIG")" + umask 077 + jq -n --arg p "$init_provider" --arg model "$init_model" --arg base "$init_base" \ + --arg key "$init_key" --argjson models "$init_models_json" ' + { + "//": "mcpctl claude-vllm config. Shaped like opencode.jsonc, but its own file. apiKey may be a literal, \"${ENV_VAR}\", or a bare env var NAME.", + model: (if $model == "" then null else "\($p)/\($model)" end), + provider: { ($p): { options: { baseURL: $base, apiKey: $key }, models: $models } } + } | del(..|nulls)' > "$CONFIG.tmp.$$" + mv "$CONFIG.tmp.$$" "$CONFIG" + chmod 600 "$CONFIG" + echo "Wrote $CONFIG (0600)" >&2 + echo " provider: $init_provider endpoint: $init_base model: ${init_model:-}" >&2 + case "$init_key" in + '${'*'}'|[A-Z_][A-Z0-9_]*) echo " apiKey: kept as an environment reference, not a literal" >&2 ;; + *) echo " apiKey: stored in the file — readable only by you" >&2 ;; + esac + exit 0 +fi + +BASE="${ANTHROPIC_BASE_URL:-$FOUND_BASE}" +KEY="${ANTHROPIC_AUTH_TOKEN:-${ANTHROPIC_API_KEY:-$FOUND_KEY}}" +MODEL_ID="${MODEL:-${ANTHROPIC_MODEL:-$FOUND_MODEL}}" + +if [ "$LIST" = 1 ]; then + echo "provider: ${FOUND_PROVIDER:-} (from ${FOUND_SOURCE:-nowhere})" + echo "config: $CONFIG $([ -f "$CONFIG" ] && echo '(present)' || echo '(absent — using fallback; run --init)')" + echo "endpoint: ${BASE:-}" + echo "credential: $([ -n "$KEY" ] && echo "found (${KEY:0:10}…)" || echo "")" + echo "default model: ${MODEL_ID:-}${FOUND_CONTEXT:+ (context ${FOUND_CONTEXT})}" + echo "models:" + [ -n "$FOUND_MODELS" ] && printf ' %s\n' $FOUND_MODELS || echo " " + exit 0 +fi + +if [ -z "$BASE" ]; then + echo "claude-vllm: no LLM endpoint found." >&2 + echo " Looked in $PI_HOME, $PRIME_HOME and $OC_CONFIG." >&2 + echo " Set ANTHROPIC_BASE_URL, or configure a provider in one of those." >&2 + exit 1 +fi +if [ -z "$KEY" ]; then + echo "claude-vllm: found $BASE but no credential for '${FOUND_PROVIDER}'." >&2 + echo " Set ANTHROPIC_AUTH_TOKEN, or add the key to ${FOUND_SOURCE}/auth.json." >&2 + exit 1 +fi + +# Claude Code appends /v1/messages itself, so the stored provider baseUrl's +# trailing /v1 (an OpenAI-style base) has to come off or requests go to +# /v1/v1/messages. +BASE="${BASE%/}"; BASE="${BASE%/v1}" + +export ANTHROPIC_BASE_URL="$BASE" +# ANTHROPIC_AUTH_TOKEN only. Setting ANTHROPIC_API_KEY as well makes Claude Code +# warn that "auth may not work as expected" — it wants exactly one. AUTH_TOKEN +# is the right one for a third-party gateway (sent as `Authorization: Bearer`), +# and an inherited ANTHROPIC_API_KEY would otherwise take precedence over it, so +# clear it for the child process. +export ANTHROPIC_AUTH_TOKEN="$KEY" +unset ANTHROPIC_API_KEY +[ -n "$MODEL_ID" ] && export ANTHROPIC_MODEL="$MODEL_ID" +# Without a substitute, the background/summarisation calls ask the gateway for a +# real Haiku it does not serve, and every one of them 404s. +export ANTHROPIC_SMALL_FAST_MODEL="${ANTHROPIC_SMALL_FAST_MODEL:-${CLAUDE_VLLM_FAST_MODEL:-$MODEL_ID}}" +export ANTHROPIC_DEFAULT_HAIKU_MODEL="${ANTHROPIC_DEFAULT_HAIKU_MODEL:-$ANTHROPIC_SMALL_FAST_MODEL}" +# Claude Code only knows the context window of models it ships a table for, and +# assumes 200k for anything else — so a 393k model would auto-compact at half +# its capacity. Tell it the real number when the provider config states one. +if [ -n "${FOUND_CONTEXT:-}" ] && [ -z "${CLAUDE_CODE_MAX_CONTEXT_TOKENS:-}" ]; then + export CLAUDE_CODE_MAX_CONTEXT_TOKENS="$FOUND_CONTEXT" +fi +# Beta headers the gateway does not implement make it reject otherwise fine +# requests. +export CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS="${CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS:-1}" +# Stops Claude Code phoning home about a non-Anthropic endpoint. +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="${CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC:-1}" + +if [ "$PRINT_ENV" = 1 ]; then + for v in ANTHROPIC_BASE_URL ANTHROPIC_MODEL ANTHROPIC_SMALL_FAST_MODEL \ + ANTHROPIC_DEFAULT_HAIKU_MODEL CLAUDE_CODE_MAX_CONTEXT_TOKENS \ + CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS \ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC; do + [ -n "${!v:-}" ] || continue + printf 'export %s=%q\n' "$v" "${!v}" + done + # Never printed: the credential. Use --list to confirm one was found. + echo 'export ANTHROPIC_AUTH_TOKEN=' + echo 'unset ANTHROPIC_API_KEY' + exit 0 +fi + +command -v claude >/dev/null || { echo "claude-vllm: claude is not on PATH" >&2; exit 1; } +echo "claude-vllm: ${FOUND_PROVIDER:-custom} · ${ANTHROPIC_MODEL:-} · $ANTHROPIC_BASE_URL" >&2 +exec claude "${CLAUDE_ARGS[@]}" diff --git a/templates/docmost.yaml b/templates/docmost.yaml index 10d9df1..e608d1c 100644 --- a/templates/docmost.yaml +++ b/templates/docmost.yaml @@ -4,8 +4,15 @@ description: Docmost MCP server for wiki/documentation page management and searc dockerImage: "mysources.co.uk/michal/docmost-mcp:latest" transport: STDIO repositoryUrl: https://github.com/MrMartiniMo/docmost-mcp -# Health check disabled: STDIO health probe requires packageName (npm-based servers). -# This server uses a custom dockerImage. Probe support for dockerImage STDIO servers is TODO. +healthCheck: + # get_workspace calls the Docmost API, so a pass proves URL + login. The old + # "probe requires packageName" caveat here was true of the long-gone + # docker-exec probe; readiness now goes through the MCP proxy, which works + # the same for image-based STDIO servers. Verified against the live server. + tool: get_workspace + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: DOCMOST_API_URL description: Docmost API URL (e.g. http://100.88.157.6:3000/api) diff --git a/templates/docs-mcp.yaml b/templates/docs-mcp.yaml index ddb5693..6d3ddd8 100644 --- a/templates/docs-mcp.yaml +++ b/templates/docs-mcp.yaml @@ -18,9 +18,14 @@ command: # (mcpctl--data), not the instance, so a server edit re-attaches to the # same data instead of starting empty. # -# Health check omitted deliberately: `search_docs` requires a library argument, -# so a synthetic probe would need a library that is only present after a scrape -# and would report unhealthy on a fresh instance. +# `list_libraries` takes no arguments and reads the SQLite store, so a pass +# proves the volume is actually mounted and readable — not just that the process +# is up. It answers "No libraries indexed yet." on a fresh instance rather than +# erroring, which is why it works as a probe where `search_docs` (needs a +# library that only exists after a scrape) does not. +healthCheck: + tool: list_libraries + arguments: {} volumes: - name: data mountPath: /data diff --git a/templates/filesystem.yaml b/templates/filesystem.yaml index 4c14e7b..cb0c1c9 100644 --- a/templates/filesystem.yaml +++ b/templates/filesystem.yaml @@ -1,6 +1,23 @@ name: filesystem -version: "1.0.0" +version: "2.0.0" description: Filesystem MCP server for reading and writing files -packageName: "@anthropic/filesystem-mcp" +# Was "@anthropic/filesystem-mcp", which 404s on the npm registry — creating a +# server from this template failed at install. This is the real package. +packageName: "@modelcontextprotocol/server-filesystem" +runtime: node transport: STDIO repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem +healthCheck: + # Zero-arg and proves the server resolved its configured roots, which is the + # only thing it can be misconfigured about. + tool: list_allowed_directories + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 +env: + - name: ALLOWED_DIRECTORIES + description: >- + Space-separated directories the server may access. Passed as the + package's positional arguments; without at least one the server exposes + nothing. + required: true diff --git a/templates/gitea.yaml b/templates/gitea.yaml index 9ded49c..c41dbd9 100644 --- a/templates/gitea.yaml +++ b/templates/gitea.yaml @@ -7,7 +7,18 @@ repositoryUrl: https://gitea.com/gitea/gitea-mcp # No command: the image's entrypoint IS the MCP server. mcpd attaches to PID 1 # stdin/stdout (attach mode) rather than exec-ing a new process. The image is # distroless and has no node/shell, so exec-based STDIO would fail. -# Health check disabled: STDIO health probe requires node in the container. +healthCheck: + # search_repos is a real Gitea API call, deliberately chosen over get_me: + # get_me needs the `read:user` token scope, which a repo-scoped token won't + # have, so it would fail for a bookkeeping reason rather than a real one. + # (The "probe requires node in the container" caveat that used to sit here + # described the old docker-exec probe; readiness goes through the MCP proxy + # now, so a distroless image is fine.) Verified against the live server. + tool: search_repos + arguments: + query: mcpctl + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: GITEA_HOST description: Gitea instance URL (e.g. https://gitea.example.com) diff --git a/templates/grafana.yaml b/templates/grafana.yaml index 2bf6992..f8445b7 100644 --- a/templates/grafana.yaml +++ b/templates/grafana.yaml @@ -1,16 +1,28 @@ name: grafana -version: "1.0.0" +version: "1.1.0" description: Grafana MCP server for dashboards, datasources, and alerts packageName: "@leval/mcp-grafana" +runtime: node transport: STDIO repositoryUrl: https://github.com/levalhq/mcp-grafana healthCheck: + # Hits the Grafana API, so a pass proves URL + token + reachability. A + # liveness probe (tools/list) cannot: it answers from the server's own tool + # table and stays green while every Grafana call 403s. tool: list_datasources arguments: {} + intervalSeconds: 60 + timeoutSeconds: 10 env: - name: GRAFANA_URL - description: Grafana instance URL (e.g. https://grafana.example.com) + description: >- + Grafana base URL. For a Grafana in this cluster use its ClusterIP + (http://grafana..svc.cluster.local:3000) — NOT its public + hostname. Reaching it over the public ingress hairpins the request back + through the per-host Envoy L7 policy, which drops the caller's identity + and answers a bare `Access denied` 403 even when the token is valid. required: true + defaultValue: http://grafana.home-automation.svc.cluster.local:3000 - name: GRAFANA_SERVICE_ACCOUNT_TOKEN description: Grafana service account token (glsa_...) required: true diff --git a/templates/node-red.yaml b/templates/node-red.yaml deleted file mode 100644 index d81b749..0000000 --- a/templates/node-red.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: node-red -version: "1.0.0" -description: Node-RED MCP server for flow management and automation -packageName: "mcp-node-red" -transport: STDIO -repositoryUrl: https://github.com/fx/mcp-node-red -healthCheck: - tool: get_settings - arguments: {} -env: - - name: NODE_RED_URL - description: Node-RED instance URL (e.g. http://nodered.local:1880) - required: true - - name: NODE_RED_TOKEN - description: Node-RED access token (optional if no auth) - required: false diff --git a/templates/terraform.yaml b/templates/terraform.yaml deleted file mode 100644 index 9fd4049..0000000 --- a/templates/terraform.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: terraform -version: "1.0.0" -description: Terraform MCP server for infrastructure documentation and state -packageName: "@anthropic/terraform-mcp" -transport: STDIO -repositoryUrl: https://github.com/modelcontextprotocol/servers/tree/main/src/terraform diff --git a/templates/unifi-network.yaml b/templates/unifi-network.yaml index 8d5c14a..242309d 100644 --- a/templates/unifi-network.yaml +++ b/templates/unifi-network.yaml @@ -1,25 +1,42 @@ name: unifi-network -version: "1.0.0" +version: "2.0.0" description: UniFi Network MCP server for managing UniFi network devices, clients, and configuration packageName: "unifi-network-mcp" -runtime: python +runtime: node transport: STDIO repositoryUrl: https://github.com/sirkirby/unifi-mcp -# Health check disabled: STDIO health probe requires packageName (npm-based servers). -# This server uses the Python runner. Probe support for Python runner STDIO servers is TODO. +healthCheck: + # list_sites calls the controller (/api/self/sites), so a pass proves the + # whole path: egress to the controller port, TLS, login, session. The old + # template disabled the probe entirely on the belief that STDIO probes only + # worked for npm packages — that stopped being true once readiness probes + # started going through the MCP proxy, and the gap let this server sit at + # "healthy" for months without ever reaching the controller. + tool: list_sites + arguments: {} + intervalSeconds: 60 + timeoutSeconds: 15 env: - - name: UNIFI_HOST - description: UniFi controller hostname or IP (e.g. unifi.example.com — without https://) + - name: UNIFI_TARGETS + description: >- + JSON array of controllers. One object per controller: + {"id", "base_url", "controller_type", "default_site", "auth": + {"username","password"}, "verify_ssl"}. + + controller_type is "classic" for a self-hosted UniFi Network controller + (login /api/login, no path prefix) or "unifi_os" for a UDM/UniFi OS + console (login /api/auth/login, API under /proxy/network). Choosing the + wrong one sends every request to a path that 404s while the server still + starts cleanly. + + base_url must carry the real controller port — a self-hosted controller + is usually :8443, and :443 on the same host is often an unrelated + service. Note that MCP server pods only egress 80/443 by default, so any + other port needs an explicit NetworkPolicy (Pulumi + `mcpctl.serverEgressTargets`). required: true - - name: UNIFI_USERNAME - description: UniFi local admin username - required: true - - name: UNIFI_PASSWORD - description: UniFi admin password - required: true - - name: UNIFI_NETWORK_PORT - description: UniFi controller port (default 443, use 8443 for standalone UniFi Controller) - required: false - - name: UNIFI_NETWORK_VERIFY_SSL - description: Verify SSL certificate (true/false, default true — set false for self-signed certs) - required: false + defaultValue: >- + [{"id": "home", "base_url": "https://unifi.example.com:8443", + "controller_type": "classic", "default_site": "default", + "auth": {"username": "CHANGE_ME", "password": "CHANGE_ME"}, + "verify_ssl": false}]