diff --git a/.gitignore b/.gitignore index ccd4173..15f14ec 100644 --- a/.gitignore +++ b/.gitignore @@ -48,5 +48,5 @@ test-mcp.sh # Claude Code local state .claude/scheduled_tasks.lock -# Agent worktrees — transient checkouts, never part of the repo +# Agent worktrees — scratch checkouts, never part of the tree .claude/worktrees/ diff --git a/README.md b/README.md index 1ccf887..7b8db4b 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,166 @@ This writes a `.mcp.json` that tells Claude Code to connect through mcplocal. Re mcpctl console monitoring # Preview what Claude sees ``` +### Connect prime-agent + +Prime-agent (Claude's open-source counterpart) talks to the same proxy MCP +gateway over HTTP rather than stdio. Register a project and sync its skills +into `~/.prime/agent/`: + +```bash +mcpctl config prime-agent --project monitoring +``` + +This: + +1. Provisions the project's bearer credential in `~/.prime/agent/auth.json` + (`mcp:monitoring`, written 0600) — either from `--token `, an existing + entry that is still active server-side, or a freshly minted project token. + This happens first: if no credential can be provisioned the command stops + here with a non-zero exit and leaves `settings.json` alone, so the project + you are currently on keeps working. +2. Registers the proxy MCP gateway in `~/.prime/agent/settings.json` as + `mcpServers.monitoring = { "type": "http", "url": "https://mcp.ad.itaz.eu/projects/monitoring/mcp" }` + (merging with any existing servers and preserving all other settings), and + unmounts the previously active mcpctl project so exactly one is live. + Servers you configured by hand are never touched. +3. Writes a `.mcpctl-project` marker (only if none exists higher up, and never + from `$HOME`) so later syncs resolve the project. Skip with `--skip-marker`. +4. Syncs the project's skills into `~/.prime/agent/skills//` as markdown + skills. The shared tree is ownership-tracked per project: it never deletes + another project's skills or an untracked hand-authored skill. +5. Installs a `/mcpctl` project-switcher extension into + `~/.prime/agent/extensions/` so you can switch mcpctl projects from inside + the prime-agent UI (skip with `--skip-extension`). + +Re-sync later with: + +```bash +mcpctl skills sync --agent prime-agent --project monitoring +``` + +Skip individual steps as needed: + +```bash +mcpctl config prime-agent --project monitoring --token mcpctl_pat_xxx # provide token, don't mint +mcpctl config prime-agent --project monitoring --skip-skills # don't sync skills +mcpctl config prime-agent --project monitoring --skip-extension # don't install /mcpctl switcher +mcpctl config prime-agent --project monitoring --skip-marker # don't touch .mcpctl-project here +``` + +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 +**extensions** and **skills**. mcpctl ships a native pi integration that talks +JSON-RPC directly to mcplocal over HTTP — no MCP library, no `~/.claude` +dependency, so it keeps working even if you drop Claude entirely. + +```bash +mcpctl config pi --project monitoring +``` + +This installs the `mcpctl` pi extension into `~/.pi/agent/extensions/mcpctl/`, +registers it (plus a pi skills dir) in `~/.pi/agent/settings.json`, persists the +active project in `~/.mcpctl/pi-state.json`, and runs an initial skills sync into +`~/.pi/agent/skills/`. Then start pi and the project's MCP tools are registered as +native pi tools (`mc__`). + +Inside pi: + +- `/mcpctl` — status, **switch project** (from the GUI), refresh tools, sync skills +- gated projects auto-ungate: call `begin_session` once and the full tool set opens +- re-sync with `mcpctl skills sync --agent pi --project monitoring` + +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`: @@ -237,7 +397,7 @@ name: home-automation proxyModel: default servers: - home-assistant - - node-red + - unifi-network ``` Via CLI: @@ -672,6 +832,66 @@ mcpctl create server my-ha \ --env-from-secret ha-secrets ``` +### Persistent volumes + +Instances are immutable — any server edit recreates them — so by default +anything an MCP server writes to its container filesystem is lost at that point. +A server (or template) can declare volumes that outlive its instances: + +```bash +mcpctl create server docs \ + --from-template docs-mcp \ + --volume data:/data:20:longhorn # NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]] +``` + +```yaml +# ...or declaratively +volumes: + - name: data + mountPath: /data + sizeGb: 20 + storageClass: longhorn +``` + +The backing store is named after the **server**, not the instance — +`mcpctl--` — which is what lets the data survive. On Kubernetes +that is a PVC created in the servers namespace on first start and left in place +afterwards; on Docker it is a named volume. Neither is deleted when an instance +or the server goes away, so reclaiming the space is a deliberate +`kubectl delete pvc` / `docker volume rm`. + +Notes: + +- Claims are `ReadWriteOnce`, so a server with volumes is limited to one + replica. Asking for more is rejected at validation rather than leaving the + extra replicas unschedulable. +- A fresh claim mounts `root:root`, so mcpctl sets the pod's `fsGroup` to + **1000** — the conventional non-root uid in node/python images. Override with + `fsGroup` on the volume if an image uses a different one; containers running + as root are unaffected either way. +- `storageClass` defaults to `MCPD_VOLUME_STORAGE_CLASS`, and is omitted + entirely when neither is set. **Set it explicitly on any cluster with more + than one default StorageClass**, where an omitted class binds + nondeterministically. +- Growing a volume is an explicit `kubectl edit pvc` (the class must allow + expansion). mcpctl never resizes an existing claim, because most PVC fields + are immutable after binding. +- Backups capture the volume *declaration*, not its contents. + +### Web search and docs lookup + +The `duckduckgo`, `searxng` and `docs-mcp` templates give an agent web search and +version-pinned library documentation. All are self-hosted and none needs an API key. +`duckduckgo` needs no backing service at all: + +```bash +mcpctl create server websearch --from-template duckduckgo +``` + +See [docs/web-search.md](docs/web-search.md) for the SearXNG engine setup (including +the `json` format setting, without which every search silently returns nothing) and +the `docs-mcp` index-persistence caveat. + ## Gated Sessions Projects using the `default` or `gate` plugin are **gated**. When Claude connects to a gated project: diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 4aaae54..50da079 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,8 +5,8 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config repos tasks 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 attach-agent detach-agent favourites" + local commands="status statusline login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate" + local project_commands="get describe delete logs create edit trace attach-server detach-server attach-agent detach-agent 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 epics epictasks tasks repos gitproviders memberships all" local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks epics epictasks tasks repos gitproviders memberships all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm inference-task itask itasks epic epictask et task repo repository repositories gitprovider forge gp membership team" @@ -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 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,10 +122,22 @@ _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 --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")) + ;; + 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")) @@ -239,7 +254,7 @@ _mcpctl() { COMPREPLY=($(compgen -W "--project --url --branch --path --secret --key -h --help" -- "$cur")) ;; server) - COMPREPLY=($(compgen -W "-d --description --package-name --runtime --docker-image --transport --repository-url --external-url --command --container-port --replicas --env --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 --tool-call-timeout --health-check-failure-threshold --secret-delivery --entrypoint --from-template --env-from-secret --force -h --help" -- "$cur")) ;; secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) @@ -334,6 +349,9 @@ _mcpctl() { errors) COMPREPLY=($(compgen -W "-n --limit -h --help" -- "$cur")) return ;; + trace) + COMPREPLY=($(compgen -W "-o --output --strict -h --help" -- "$cur")) + return ;; backup) local backup_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$backup_sub" ]]; then @@ -463,7 +481,7 @@ _mcpctl() { else case "$skills_sub" in sync) - COMPREPLY=($(compgen -W "-p --project --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --agent --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) ;; *) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 9876f3d..777ce22 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,8 +4,8 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config repos tasks 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 attach-agent detach-agent favourites +set -l commands status statusline login logout config repos tasks get describe delete logs create edit apply chat chat-llm patch passwd errors trace backup approve review skills console cache provider test migrate rotate +set -l project_commands get describe delete logs create edit trace attach-server detach-server attach-agent detach-agent favourites # Disable file completions by default complete -c mcpctl -f @@ -272,6 +272,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' @@ -289,10 +290,11 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a passwd -d 'Change a user password (your own when called without an argument)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a errors -d 'Show recent mcpd error/fatal log entries' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a trace -d 'Show the stage-by-stage timeline for one MCP request' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Manage Claude Code skill bundles synced from mcpd' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Sync skill bundles synced from mcpd (Claude Code by default; others with --agent)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -307,6 +309,7 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt, gitprovider, epic, epictask, task, repo)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a trace -d 'Show the stage-by-stage timeline for one MCP request' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-agent -d 'Add an agent to a project team (requires --project)' @@ -322,13 +325,17 @@ 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 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' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration to defaults' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' +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' @@ -337,17 +344,65 @@ 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)' +complete -c mcpctl -n "__mcpctl_subcmd_active config pi" -l extension-dir -d 'Source-tree src/pi-ext dir (dev only; default: embedded sources)' -x +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)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent" -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" -l dry-run -d 'Print what would change without writing or syncing' + +# config prime-agent-generate options +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -s o -l output -d 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l gateway-url -d 'mcpctl HTTP MCP gateway base URL' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l token -d 'mcpctl project bearer token to store in auth.json (skips auto-minting)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-skills -d 'Skip the skills sync step' +complete -c mcpctl -n "__mcpctl_subcmd_active config prime-agent-generate" -l skip-extension -d 'Do not install the /mcpctl project-switcher extension' +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' @@ -464,6 +519,15 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l command -d 'Comm complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l container-port -d 'Container port number' -x 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 tool-call-timeout -d 'Per-server tool-call deadline in seconds (default: mcplocal\'s global deadline)' -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 secret-delivery -d 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l entrypoint -d 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect' -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' @@ -632,13 +696,14 @@ 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 under ~/.claude/skills/' +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, 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 SessionStart hook)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by session-start hooks)' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l skip-postinstall -d 'Do not run metadata.postInstall scripts (no-op in v1; reserved)' complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l keep-orphans -d 'Do not remove skills that are no longer in the server set' @@ -690,6 +755,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 @@ -749,6 +818,10 @@ complete -c mcpctl -n "__fish_seen_subcommand_from chat-llm" -l async -d 'Enqueu # errors options complete -c mcpctl -n "__fish_seen_subcommand_from errors" -s n -l limit -d 'max entries to show (default 50)' -x +# trace options +complete -c mcpctl -n "__fish_seen_subcommand_from trace" -s o -l output -d 'table (default) or json' -x +complete -c mcpctl -n "__fish_seen_subcommand_from trace" -l strict -d 'exit non-zero if the trace contains an error or a degraded step' + # attach-agent options complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l role -d 'Persona on this team: developer|qa|security|sre' -x complete -c mcpctl -n "__fish_seen_subcommand_from attach-agent" -l llm -d 'Per-membership brain override (wins over the agent\'s pinned Llm in this project)' -x diff --git a/deploy/Dockerfile.gitea-mcp b/deploy/Dockerfile.gitea-mcp new file mode 100644 index 0000000..758f67a --- /dev/null +++ b/deploy/Dockerfile.gitea-mcp @@ -0,0 +1,43 @@ +# gitea-mcp-server, rebuilt on a shell-bearing base. +# +# WHY THIS EXISTS +# --------------- +# Upstream `docker.gitea.com/gitea-mcp-server` is distroless: `Cmd` is +# ["/app/gitea-mcp"] and there is no /bin/sh at any path (verified by exec'ing +# every candidate against the running pod). +# +# That is fine until the server needs `secretDelivery: injector`. The OpenBao +# agent renders secrets to a FILE, so mcpd wraps the container command as +# `sh -c '. /vault/secrets/; exec "$0" "$@"'` — which needs a shell. With +# no shell the pod cannot source its own credentials, and gitea was the single +# server in the fleet blocked on this. +# +# Copying one static Go binary onto debian:stable-slim is cheaper than building +# and maintaining a static "envexec" shim, and follows the precedent already set +# by deploy/Dockerfile.docmost-mcp — this repo already rebuilds third-party MCP +# servers when it needs to change how they run. +# +# ca-certificates is required, not incidental: the binary talks HTTPS to +# https://mysources.co.uk and a distroless base ships its own trust store which +# we are leaving behind. +FROM debian:stable-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Pinned by DIGEST, not :latest. `latest` moves, and a rebuild that silently +# ships a different server version is indistinguishable from a broken rebuild — +# chased exactly that here when a probe started failing after a rebuild. +# This digest is gitea-mcp-server 1.6.0 (label org.opencontainers.image.version), +# the build that was running when this image was introduced. +# To bump: skopeo inspect docker://docker.gitea.com/gitea-mcp-server:latest +COPY --from=docker.gitea.com/gitea-mcp-server@sha256:dda8d56e6a91fa89cad186becc27c7aa83d74acdd5dc69f89af840d7bb78a631 /app/gitea-mcp /usr/local/bin/gitea-mcp + +WORKDIR /app + +# CMD, not ENTRYPOINT — matching upstream, which sets Cmd ["/app/gitea-mcp"] and +# no entrypoint. mcpd maps a server's `command` to k8s `args`, which REPLACES +# Cmd but only appends to an ENTRYPOINT; keeping the same form means the plain +# (non-injected) path behaves byte-identically to upstream. +CMD ["/usr/local/bin/gitea-mcp"] diff --git a/deploy/mcplocal.service b/deploy/mcplocal.service index fc26b40..8d3216e 100644 --- a/deploy/mcplocal.service +++ b/deploy/mcplocal.service @@ -7,7 +7,11 @@ Type=simple ExecStart=/usr/bin/mcpctl-local Restart=on-failure RestartSec=5 -Environment=MCPLOCAL_MCPD_URL=http://10.0.0.194:3100 +# mcpd now runs on Kubernetes behind this ingress. The previous default, +# http://10.0.0.194:3100, is dead — a fresh install pointed at it and every +# machine that worked did so via a hand-written drop-in. Override per host with: +# systemctl --user edit mcplocal -> Environment=MCPLOCAL_MCPD_URL=... +Environment=MCPLOCAL_MCPD_URL=https://mcpctl.ad.itaz.eu Environment=MCPLOCAL_HTTP_PORT=3200 Environment=MCPLOCAL_HTTP_HOST=127.0.0.1 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 new file mode 100644 index 0000000..f10c6f7 --- /dev/null +++ b/docs/pi-extension.md @@ -0,0 +1,174 @@ +# mcpctl × pi — native integration (no MCP, no Claude) + +## Motivation + +With Claude Code, `mcpctl config claude --project X` wires a project's MCP +servers into the agent two ways: + +1. a `.mcp.json` entry running `mcpctl mcp -p X` (an MCP **stdio bridge**), and +2. a `SessionStart` hook + `mcpctl skills sync` that materialises server-side + **skills** under `~/.claude/skills/`. + +Claude Code supports MCP natively, so tools travel over MCP. **pi does not +support MCP.** But pi has two native mechanisms that cover the same ground: + +- **Extensions** — TypeScript modules that can register tools, commands, and + custom UI, and can talk HTTP directly. +- **Skills** — pi implements the [Agent Skills standard](https://agentskills.io/specification) + and loads `SKILL.md` trees (the exact format mcpctl already syncs). + +This addon exploits both so mcpctl works with **pi only**, with **no MCP** and +**no dependency on `~/.claude/`** (so it keeps working if you drop Claude). + +## How it works + +The MCP layer is not magic. `mcpctl mcp` is just a JSON-RPC ↔ stdio bridge that +forwards to mcplocal's Streamable-HTTP endpoint: + +``` +POST {mcplocalUrl}/projects//mcp +Accepts: application/json, text/event-stream +Authorization: Bearer +``` + +The protocol is JSON-RPC over HTTP: +- `initialize` → returns an `mcp-session-id` header +- `notifications/initialized` +- `tools/list` → `{ tools: [{ name, description, inputSchema }] }` +- `tools/call` → `{ name, arguments }` → `{ content, isError }` + +The pi extension is a **direct JSON-RPC client** to that same endpoint. It +replaces the MCP transport with a plain function call, so pi never needs an MCP +client library. + +### Gating + +Some projects are *gated*: until `begin_session` is called, `tools/list` returns +only `begin_session`. After it is called the full tool set appears. + +**Critical subtlety:** mcplocal's gate state is bound to a `mcp-session-id`. A +fresh session is gated again even after another session was ungated — so the +extension keeps **one persistent `McpHttpSession` per active project** and +routes every `tools/call` through it. After each call it re-runs `tools/list` +and reconciles the active pi tool set, so calling `begin_session` automatically +opens the gate and reveals the rest — no push listener required (mcplocal's HTTP +responses are request/response, not a persistent push stream). + +## Deliverables + +| Artifact | Purpose | +|----------|---------| +| `src/pi-ext/mcpctl-pi.ts` | Self-contained pi extension (load via `pi -e` or settings `extensions`) | +| `mcpctl config pi` | CLI wiring: registers the extension with pi + initial skill sync | +| `docs/pi-extension.md` | This document | + +## Project switching + +The active project is persisted in `~/.mcpctl/pi-state.json` (settable, immune to +your shell's cwd) and is also inferred from a `.mcpctl-project` marker walk-up. +`/mcpctl` opens an interactive menu; **switch project** lists projects via +`ctx.ui.select()` and reloads the tool set for the new project, namespaced so +projects never collide. + +### Tool naming + +To allow multiple projects and avoid schema collisions, registered pi tools are +namespaced: `mc__`. Only the **current project's** +tools are in the active set at any time. + +## Skills + +Skills already sync in the Agent Skills `SKILL.md` format. `mcpctl config pi` +(when given a project) writes them under pi's own directory +`~/.pi/agent/skills/` and registers that directory in `~/.pi/agent/settings.json` +— no Claude involvement. Re-sync later with: + +```bash +mcpctl skills sync --agent pi --project +``` + +`--agent pi` is what keeps skills out of `~/.claude/skills`; the default target +is still Claude Code. The `/mcpctl` menu's **sync skills** action runs exactly +this command. Sync state is tracked separately per agent +(`~/.mcpctl/skills-state-pi.json`), so pi and Claude never fight over the same +bookkeeping. + +## Layout + +``` +src/pi-ext/ + mcpctl-pi.ts # the extension (self-contained) + mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps) +``` + +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 + +The extension ships as *source*: it is embedded into the CLI +(`src/cli/src/config/pi-extension.ts`, generated by +`scripts/generate-pi-extension.ts`) and written verbatim into +`~/.pi/agent/extensions/mcpctl/`. The CLI's own build never compiles it, so +without a dedicated project nothing would check it against pi's API — which is +how a `ctx.ui.select()` call with the wrong option shape shipped in the first +place. + +`src/pi-ext/tsconfig.json` closes that gap, checking against the **real** +published `@earendil-works/pi-coding-agent` types (a dev dependency, not a +hand-written shim — a shim that drifts from the published API is the exact +failure this guards against): + +```bash +pnpm run typecheck # includes typecheck:pi-ext +``` + +After editing `src/pi-ext/*.ts`, regenerate the embedded copy or the CLI will +keep installing the old sources: + +```bash +npx tsx scripts/generate-pi-extension.ts +``` + +A test (`tests/config/pi-extension-embed.test.ts`) fails if you forget. + +## Authentication caveat + +The extension sends the mcpd session token from `~/.mcpctl/credentials` as its +bearer. A locally running `mcpctl-local` daemon does not authenticate +`/projects/*`, so this works — the header is simply ignored. + +`mcplocal serve` is different: it registers a token-auth preHandler that accepts +**only** `mcpctl_pat_` bearers (project mcptokens), and rejects a session token +with `401 Only mcpctl_pat_ bearers are accepted on this endpoint`. Pointing the +extension at an authenticated `mcplocal serve` therefore needs a project +mcptoken (`mcpctl create mcptoken --project `, printed once). +Wiring that token through to the extension is not yet implemented — the +`config prime-agent` path does the equivalent by storing it in +`~/.prime/agent/auth.json`. 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..0eae89f 100644 --- a/docs/reliability.md +++ b/docs/reliability.md @@ -19,15 +19,124 @@ and report the degradation — never hang and never degrade silently.** ()…` and sets `degraded: true` + `degradedReason` on the audit `gate_decision` event. -Applied in: the gate's `begin_session` prompt selection -(`proxymodel/plugins/gate.ts`, cap `MCPCTL_GATE_LLM_TIMEOUT_MS`, default 8s) and -pagination's smart index (`llm/pagination.ts`, `MCPCTL_PAGINATION_LLM_TIMEOUT_MS`, -default 10s). `read_prompts` is LLM-free by design. +**One implementation:** [`util/degrade.ts`](../src/mcplocal/src/util/degrade.ts)'s +`bounded()`. It cannot throw — the caller always gets a value or a reason — so +the deterministic fallback is unconditional rather than something a `catch` +block has to remember. `degradationNotice()` produces the `⚠ … unavailable +(reason). hint` wording, and `degradationAudit()` the `degraded`/`degradedReason` +payload, so every degradation reads the same whichever subsystem produced it. + +**Nothing optional is unbounded by construction.** The budget lives in +`LLMProviderAdapter.complete()` (`proxymodel/llm-adapter.ts`), not at each call +site, so a stage that passes no options is still bounded and a stage written +next year inherits the guarantee. One budget spans the whole failover chain — +a per-provider timeout would make the worst case N × timeout. + +| Knob | Default | Bounds | +|---|---|---| +| `MCPCTL_LLM_CALL_BUDGET_MS` | 20s | one `ctx.llm.complete()`, failover included | +| `MCPCTL_LLM_PROVIDER_TIMEOUT_MS` | 10s | a single provider attempt inside that budget | +| `MCPCTL_STAGE_LLM_BUDGET_MS` | 30s | all LLM work in one stage invocation | +| `MCPCTL_GATE_LLM_TIMEOUT_MS` | 8s | the gate's `begin_session` prompt selection | +| `MCPCTL_PAGINATION_LLM_TIMEOUT_MS` | 10s | pagination's smart index (`llm/pagination.ts`) | + +The **stage** budget exists because a per-call timeout multiplies rather than +bounds when a stage loops: `summarize-tree` recurses to `maxDepth` (3) and loops +per section at every level, so hundreds of sequential calls are reachable. One +budget is shared across the whole recursion; when it is gone the stage switches +to first-line excerpts and says so. A **warm cache is never budget-gated** — a +cached summary costs nothing, so an exhausted budget must not degrade a result +we already hold. + +`read_prompts` is LLM-free by design. + +### Why this is written down twice + +This document stated the principle while `proxymodel/stages/paginate.ts` awaited +`ctx.llm.complete()` with no timeout at all. When a provider hung rather than +erroring, the promise never settled, the tool call never returned, and the +client waited out its own 1800s timeout — three such requests in production, +misdiagnosed twice as an upstream "transport fault". The doc named the gate and +`llm/pagination.ts` as the compliant sites, and the newer stages simply never +joined the list. Naming **one** helper here, rather than a list of call sites, +is what stops that drift recurring. 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/secret-backends.md b/docs/secret-backends.md index d53d358..78df3bf 100644 --- a/docs/secret-backends.md +++ b/docs/secret-backends.md @@ -118,8 +118,110 @@ That's the whole point of keeping plaintext around — it's the trust root: token itself. DB access is now equivalent to OpenBao token access (a single key), not equivalent to all API keys in the system. -Follow-up work (not shipped yet) replaces static token auth with Kubernetes -ServiceAccount auth so no bootstrap token is needed at all. +#### Kubernetes ServiceAccount auth (no bootstrap token) + +`auth: kubernetes` removes the chicken-and-egg entirely: mcpd exchanges its +projected ServiceAccount JWT for an OpenBao token at +`auth//role/`, so there is no static credential in the database +at all. The token is cached for its lease and re-minted lazily with a 60s grace +window. + +```yaml +kind: secretbackend +name: bao-k8s +type: openbao +isDefault: true +config: + url: https://bao.example + auth: kubernetes + role: mcpctl + authMount: kubernetes-worker0 # defaults to `kubernetes` +``` + +Note that the daily **rotator does not apply** to these backends — there is no +stored token to rotate. That has a consequence for monitoring, see below. + +## Reliability + +Remote backends are network dependencies on the critical path of nearly +everything: server env resolution, LLM api keys, chat, git providers, code +repos, webhooks. Three mechanisms keep an outage from cascading. + +### Request hardening + +Every call carries a timeout (default 5s) and retries `5xx`/`429`/network +failures with full-jitter exponential backoff (3 attempts). A **sealed** OpenBao +answers `503`, so this covers unseal windows and failovers. + +The `403` path is separate and deliberately single-shot: the driver purges its +cached token, re-authenticates and retries **once**. That is a credential +refresh, not a backend-unavailable condition — looping on it would hide a +genuinely revoked grant. + +### Value cache with stale-while-error + +Resolved values are cached per backend (default TTL 5 minutes, LRU-bounded). +Past the TTL the backend is always consulted; if it fails *as a transport +failure*, the last known-good value is served instead of throwing. + +| Failure | Behaviour | +|---|---| +| Backend unreachable / timeout / exhausted 5xx | Serve last known-good, mark degraded, log `BACKEND_UNREACHABLE` once | +| Secret deleted (404) | **Evict and throw.** Never served stale — that would resurrect a revoked credential | +| 403 after a token refresh | Throw. Revoked grants must stay loud | +| Nothing cached yet | Throw | + +The stale window is unbounded on purpose: a cap would mean a long outage +eventually takes mcpd down anyway. + +`plaintext` backends are not cached — their `read()` is an identity function +over the row the caller already supplied. + +**Cold cache is the known gap.** If mcpd restarts *while* the backend is +unreachable, nothing has a last-known-good value and secret-bearing servers fail +to start. That is deliberate: booting a server with an empty credential is worse +(gitea-mcp once ran for weeks with an empty `GITEA_ACCESS_TOKEN`, answering +`tools/list` and reporting healthy while every authenticated call failed). mcpd +mitigates it by warming the cache at boot — one read per referenced secret — so +an outage that starts *after* startup is fully absorbed. + +### Health: `live` vs `ready` + +```bash +curl $MCPD/api/v1/secretbackends//health +``` + +```json +{ "live": true, "liveDetail": "active", + "ready": false, "readyDetail": "OpenBao list: HTTP 403 permission denied", + "cache": { "entries": 9, "servingStale": 0 }, + "rotation": { "rotatable": false, "lastRotationError": null } } +``` + +- **`live`** — unauthenticated `sys/health`. Distinguishes *down* from *sealed* + from *standby*. +- **`ready`** — a real read with our credentials. + +The two are separate because `live && !ready` is a distinct, important state: a +re-initialised OpenBao hands back valid-looking tokens that grant nothing. +Collapsing them into one boolean is what let that go unnoticed for four days. + +`mcpctl status` renders the probe directly: + +``` +Secrets: bao-k8s* ✓ reachable, default ✓ reachable +Secrets: bao-k8s* ⚠ degraded — serving 7 cached secret(s) +Secrets: bao-k8s* ✗ unreachable: sealed +Secrets: bao-k8s* ✗ auth failed: HTTP 403 permission denied +Secrets: bao-k8s* ? unknown +``` + +> **Historical note.** This verdict used to come solely from +> `tokenMeta.lastRotationError`, which only the rotator writes — and the rotator +> skips `auth: kubernetes` backends. The Secrets line was therefore *incapable* +> of going red for a k8s-auth backend, and reported OpenBao healthy while it was +> unreachable. `?` (probe failed) renders yellow, never green: not knowing is not +> health. ## Migration — `mcpctl migrate secrets` diff --git a/docs/web-search.md b/docs/web-search.md new file mode 100644 index 0000000..ff39cf6 --- /dev/null +++ b/docs/web-search.md @@ -0,0 +1,148 @@ +# Web search and documentation lookup + +Three templates. All self-hosted, none needs an API key or a vendor account, and +all three are deployed by mcpctl like any other server. + +| Template | Package / image | Needs | +|---|---|---| +| `duckduckgo` | `duckduckgo-mcp-server` (python) | nothing | +| `searxng` | `mcp-searxng` (node) | a SearXNG instance | +| `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 +tell you why a daemon is crash-looping. Attach both to a project that does real +engineering work. + +`docs-mcp` is the open-source replacement for Context7 / Ref.tools — same job, +but the index lives on your infrastructure and can include private repos. + +## Start here: `duckduckgo` + +The only one with no infrastructure behind it. It scrapes DuckDuckGo's HTML +endpoint directly, so there is no engine to run and no key to hold. + +```bash +mcpctl create server websearch --from-template duckduckgo --env DDG_SAFE_SEARCH=OFF +mcpctl get instances | grep websearch # RUNNING / healthy within ~a minute +``` + +Tools: `search` (`query`, `max_results`, `region`) and `fetch_content` (`url`, +`start_index`, `max_length`) for pulling a result as markdown. + +The tradeoff is honest: scraping has no SLA. DuckDuckGo can change its markup or +rate-limit you, and the server caps itself at 30 searches/min. Set +`DDG_SEARCH_BACKEND=curl` if bot checks start biting. When it becomes a problem, +move to `searxng`. + +## Better results: `searxng` + +SearXNG aggregates ~25 engines and gives you real filtering, which is what keeps +search results from flooding a context window: + +- `min_score` (0.0–1.0) — relevance floor. The most useful knob; start at `0.3`. +- `time_range` — `day` / `week` / `month` / `year`. Essential for "did this break + in the last release" questions. +- `language`, `safesearch`, `pageno`. + +`web_url_read` then takes `section`, `paragraphRange`, `startChar`/`maxLength` +and `readHeadings`, so you pull one section of a long page rather than all of it. +Bounded PDF extraction is included, which covers most vendor documentation. + +The cost is that SearXNG is a service you have to run. It is plain +infrastructure, not an MCP server, so mcpctl has nothing to manage it with — it +belongs in your cluster or in the compose stack: + +```bash +cd stack +cp .env.example .env # set SEARXNG_SECRET +docker compose --profile websearch up -d +``` + +Then point the MCP server at it: + +```bash +mcpctl create secret searxng-conf --data SEARXNG_URL=http://mcpctl-searxng:8080 +mcpctl create server searxng --from-template searxng --env-from-secret searxng-conf +``` + +### The one SearXNG gotcha + +SearXNG ships with `search.formats: [html]`. Every `format=json` request against +a stock instance returns **403**, and `mcp-searxng` comes back empty with no +useful error. `stack/searxng/settings.yml` exists only to add `json` to that +list — it is the one setting with no environment-variable override. + +This is also why pointing the template at a *public* SearXNG instance usually +fails: nearly all of them leave the JSON API off. + +The compose healthcheck probes `format=json` specifically, so a misconfigured +instance shows up as unhealthy rather than as silently empty search results. + +## Documentation: `docs-mcp` + +```bash +mcpctl create server docs --from-template docs-mcp +``` + +Tools: `scrape_docs` (index a site, GitHub repo, npm or PyPI package, or local +files), `search_docs` (query, optionally pinned to a version), and `fetch_url`. + +Index the things you actually run — Pulumi, the Kubernetes API, Grafana, the +Terraform provider docs — rather than everything. + +### Persistence + +The scraped index is a SQLite file (`better-sqlite3` + `sqlite-vec`) under +`/data`. There is **no external-database mode** — no `DATABASE_URL`, no +pgvector — so a Postgres cluster cannot help here. The template declares a +volume instead: + +```yaml +volumes: + - name: data + mountPath: /data + sizeGb: 20 +``` + +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 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 + +Keyword search works out of the box. Semantic search is noticeably better and +needs an embedding model. To keep it local, point the server at Ollama: + +```bash +ollama pull nomic-embed-text +mcpctl create server docs --from-template docs-mcp \ + --env DOCS_MCP_EMBEDDING_MODEL=openai:nomic-embed-text \ + --env OPENAI_API_BASE=http://ollama:11434/v1 \ + --env OPENAI_API_KEY=ollama +``` + +Ollama's OpenAI-compatible endpoint is why the provider prefix is `openai:` — +the key is a placeholder and never leaves the network. + +Changing the embedding model invalidates the index: embeddings from different +models are not comparable, so everything has to be re-scraped. + +## Alternatives considered + +- **Tavily / Brave / Exa / Perplexity** — better formatted results, all require + an API key and send every query to a vendor. +- **Context7** — the popular docs MCP, but a cloud index of public-docs snippets + only. `docs-mcp` covers the same ground locally and takes private sources. +- **agent-search** — bundles SearXNG and a 10-strategy extraction cascade in one + deploy. Attractive, but at ~67 stars it is too young to build the stack on. + Worth revisiting. +- **Writing our own** — the hard parts are extraction heuristics and the + embedding pipeline, and all three projects above already solved them under + MIT. diff --git a/eslint.config.js b/eslint.config.js index d57ac82..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'], + 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 a238b35..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", + "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", @@ -32,7 +32,11 @@ "mcpd:build": "bash scripts/build-mcpd.sh", "mcpd:deploy": "bash deploy.sh", "mcpd:deploy-dry": "bash deploy.sh --dry-run", - "mcpd:logs": "bash logs.sh" + "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": { "node": ">=20.0.0", @@ -40,6 +44,12 @@ }, "packageManager": "pnpm@9.15.0", "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", @@ -47,7 +57,9 @@ "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", "vitest": "^4.0.18" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10ef364..86a79db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,36 +8,60 @@ importers: .: devDependencies: + '@earendil-works/pi-ai': + 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) + '@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 '@typescript-eslint/eslint-plugin': specifier: ^8.56.0 - version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.56.0 - version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + version: 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) eslint: specifier: ^10.0.1 - version: 10.0.1(jiti@2.6.1) + version: 10.0.1(jiti@2.7.0) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.0.1(jiti@2.6.1)) + version: 10.1.8(eslint@10.0.1(jiti@2.7.0)) 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 + typebox: + specifier: 1.3.11 + version: 1.3.11 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) src/cli: dependencies: @@ -175,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 @@ -234,7 +258,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.1.16 - version: 4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@testing-library/jest-dom': specifier: ^6.7.0 version: 6.9.1 @@ -252,7 +276,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^5.1.0 - version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) jsdom: specifier: ^28.0.0 version: 28.1.0 @@ -261,7 +285,7 @@ importers: version: 4.2.4 vite: specifier: ^7.2.0 - version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + version: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) packages: @@ -271,10 +295,27 @@ 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 + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@asamuzakjp/css-color@5.1.11': resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -289,14 +330,119 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.6': + resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.67': + resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.69': + resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.12': + resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.74': + resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.78': + resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.67': + resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.11': + resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.73': + resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.31': + resolution: {integrity: sha512-/BRzvkp46mF6eXBL/l9WKPQQfifLlUPaWli6n9/T/WDLUg8he7TCyuNFnk6RvHP5j5W/kMj5Gxw7W778LJaXDA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.26': + resolution: {integrity: sha512-2eIvouTZoxPu5ClHY6ij13De1yhY8Rmllt0dlGeBNXX3wmR7fU1pvMCGb50fKm1GxcuntWK0t1T0cMjTyDoUQA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.49': + resolution: {integrity: sha512-wGYJvu1/stdWuzGcNyh44u8aY7ArU8XFrMesBlzIYn70HWVCRBNEngQexDuPIMu0NEgsKGKmTc0uvnS/bF7KWw==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.41': + resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1103.0': + resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.8': + resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': 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'} @@ -305,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'} @@ -348,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'} @@ -360,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'} @@ -368,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==} @@ -423,6 +678,36 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@earendil-works/pi-agent-core@0.84.1': + resolution: {integrity: sha512-evyzXYWCLQGmcaBYHlmSku02r8qoN4SGI60GZABo6iV+H+nqX+P9ud8fEZ4GmRq9mUSREvvfX+w9dA9ThF9C6w==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.84.1': + resolution: {integrity: sha512-wMsAdJMxuNri08vLqTyYVI201DQQezGhPSTkzYsHdw5dYX3rCNwEmSvpaAwhi7ELKI/2tE/CEgSWg/6iRxSgdQ==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-client@0.84.1': + resolution: {integrity: sha512-/V5hGHE4Zq+jG0GtwIB9PyBUOGd6gBLZ7lkQYFKchKnxYHeH3rmWC5xw4kpnZKKBuBuFTdLVbU9vEjlAGMMb2A==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-coding-agent@0.84.1': + resolution: {integrity: sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-protocol@0.84.1': + resolution: {integrity: sha512-Ox1pciyeSPGEEUcxvR0/dJcrY7C6hrEGA8y71rOsvSIUlXN1Cbp/be/eoL71OGDBk5O97TeQPfWN6Ju/2Ehjww==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-telemetry@0.84.1': + resolution: {integrity: sha512-180/xGJtsq7IoR3p9EKWjRd0e9M4DkxInhlo9xyD7prDC7Qrhqq+nhvwrW0lFjPfXcEI2FSHmGCSyvSJE9GsaQ==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-tui@0.84.1': + resolution: {integrity: sha512-udeXFbgEhJ6JiB0uguwNVNkDy2FENfmtQwPcY+/iJ8GWeq18wkal1tKqa5YyeH0IqtX1vG0cGh8zfSYzyzVuLA==} + engines: {node: '>=22.19.0'} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -661,6 +946,15 @@ packages: resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} engines: {node: ^20.17.0 || >=22.9.0} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -1031,6 +1325,77 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} + engines: {node: '>= 10'} + + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@modelcontextprotocol/sdk@1.26.0': resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} @@ -1051,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==} @@ -1160,10 +1555,31 @@ 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'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} @@ -1262,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==} @@ -1489,6 +1972,49 @@ packages: resolution: {integrity: sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==} engines: {node: ^20.17.0 || >=22.9.0} + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1688,6 +2214,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -1923,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==} @@ -1989,6 +2535,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bin-links@6.0.2: resolution: {integrity: sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==} engines: {node: ^20.17.0 || >=22.9.0} @@ -2000,6 +2549,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2010,11 +2562,18 @@ packages: resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} engines: {node: 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + browserslist@4.28.2: resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -2025,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'} @@ -2227,6 +2791,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@7.0.0: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -2287,6 +2855,14 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + diff@8.0.4: + 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'} @@ -2312,12 +2888,18 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} 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==} @@ -2342,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'} @@ -2489,10 +3079,17 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@3.23.2: 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==} @@ -2535,6 +3132,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -2547,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'} @@ -2570,6 +3181,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2605,6 +3220,14 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + gaxios@7.3.0: + resolution: {integrity: sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + geist@1.7.0: resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} peerDependencies: @@ -2622,6 +3245,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2659,6 +3286,19 @@ 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'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + google-protobuf@3.21.4: resolution: {integrity: sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==} @@ -2669,6 +3309,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grok-mermaid@0.2.2: + resolution: {integrity: sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==} + engines: {node: '>=18'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2688,10 +3332,17 @@ 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'} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + hono@4.12.0: resolution: {integrity: sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==} engines: {node: '>=16.9.0'} @@ -2712,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==} @@ -2788,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'} @@ -2822,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'} @@ -2889,6 +3551,10 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -2920,6 +3586,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2930,6 +3599,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2939,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==} @@ -2965,9 +3641,18 @@ packages: just-diff@6.0.2: resolution: {integrity: sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + 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'} @@ -3045,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'} @@ -3097,6 +3786,16 @@ 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'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3148,9 +3847,17 @@ packages: resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} engines: {node: 18 || 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + 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'} @@ -3182,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'} @@ -3218,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} @@ -3261,6 +3982,11 @@ packages: node-addon-api@5.1.0: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -3273,6 +3999,14 @@ packages: encoding: optional: true + node-fetch@3.3.2: + 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} @@ -3371,6 +4105,18 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openid-client@6.8.2: resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==} @@ -3378,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'} @@ -3390,6 +4144,14 @@ packages: resolution: {integrity: sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==} engines: {node: '>=18'} + p-retry@4.6.2: + 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==} @@ -3402,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==} @@ -3409,10 +4174,17 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + patch-console@2.0.0: 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'} @@ -3425,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} @@ -3462,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'} @@ -3512,6 +4295,9 @@ packages: promise-call-limit@3.0.2: resolution: {integrity: sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} @@ -3530,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'} @@ -3623,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} @@ -3634,6 +4431,14 @@ packages: resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} engines: {node: '>=10'} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3670,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==} @@ -3702,10 +4510,25 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: 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'} @@ -3781,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==} @@ -3897,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==} @@ -3980,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'} @@ -3995,6 +4829,9 @@ packages: resolution: {integrity: sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -4028,6 +4865,12 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typebox@1.3.11: + resolution: {integrity: sha512-tZGKIS02Opbh4EYMmAEVuXl+y3EQJ9ZlkitLZ1CmFCY4ZOqr/xWR9dDSCFmIjNDICGMWkOqMh6WxGTyRXqcSGQ==} + + typebox@1.3.7: + resolution: {integrity: sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4050,6 +4893,10 @@ packages: resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} engines: {node: '>=20.18.1'} + undici@8.9.0: + resolution: {integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==} + engines: {node: '>=22.19.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -4072,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: @@ -4167,6 +5019,18 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + web-streams-polyfill@3.3.3: + 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==} @@ -4268,6 +5132,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4295,17 +5164,35 @@ 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 + optionalDependencies: + zod: 3.25.76 + '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 @@ -4326,14 +5213,254 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.2 + '@aws-sdk/util-locate-window': 3.965.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.2 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-node': 3.972.78 + '@aws-sdk/eventstream-handler-node': 3.972.31 + '@aws-sdk/middleware-eventstream': 3.972.26 + '@aws-sdk/middleware-websocket': 3.972.49 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.6': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.12': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-login': 3.972.74 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.78': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-ini': 3.973.12 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.11': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/token-providers': 3.1103.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.73': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.31': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.26': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.49': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.41': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1103.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.8': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 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 @@ -4362,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 @@ -4370,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 @@ -4379,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 @@ -4388,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': @@ -4405,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 @@ -4415,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': @@ -4423,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 @@ -4435,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': {} @@ -4472,6 +5746,92 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@earendil-works/pi-agent-core@0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-telemetry': 0.84.1 + diff: 8.0.4 + ignore: 7.0.5 + typebox: 1.3.7 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@earendil-works/pi-telemetry': 0.84.1 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.19.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.3.7 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-client@0.84.1': + dependencies: + '@earendil-works/pi-protocol': 0.84.1 + + '@earendil-works/pi-coding-agent@0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))(ws@8.19.0)(zod@3.25.76) + '@earendil-works/pi-client': 0.84.1 + '@earendil-works/pi-protocol': 0.84.1 + '@earendil-works/pi-tui': 0.84.1 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + grok-mermaid: 0.2.2 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.3.7 + undici: 8.9.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-protocol@0.84.1': + dependencies: + typebox: 1.3.7 + + '@earendil-works/pi-telemetry@0.84.1': {} + + '@earendil-works/pi-tui@0.84.1': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -4555,9 +5915,9 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.1(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.1(jiti@2.7.0))': dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -4647,6 +6007,19 @@ snapshots: '@gar/promise-retry@1.0.3': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.26.0(zod@3.25.76))': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 @@ -4994,6 +6367,62 @@ snapshots: - encoding - supports-color + '@mariozechner/clipboard-darwin-arm64@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard@0.3.9': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 + optional: true + + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.43.0 + ws: 8.19.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.0) @@ -5015,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: @@ -5027,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': @@ -5171,10 +6641,27 @@ 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 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} '@opentelemetry/context-async-hooks@2.9.0(@opentelemetry/api@1.9.1)': @@ -5288,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)': @@ -5489,6 +7046,61 @@ snapshots: '@sigstore/core': 3.2.1 '@sigstore/protobuf-specs': 0.5.1 + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -5556,16 +7168,16 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@tailwindcss/vite@4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) '@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 @@ -5598,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': {} @@ -5686,6 +7298,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/retry@0.12.0': {} + '@types/semver@7.7.1': {} '@types/ssh2@1.15.5': @@ -5699,15 +7313,15 @@ snapshots: '@types/trusted-types@2.0.7': optional: true - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -5715,14 +7329,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5745,13 +7359,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3) debug: 4.4.3 - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -5774,13 +7388,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.56.0(eslint@10.0.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.7.0)) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5790,7 +7404,7 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -5798,11 +7412,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -5814,7 +7428,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/expect@4.0.18': dependencies: @@ -5825,13 +7439,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -5954,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: {} @@ -6010,6 +7648,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + bignumber.js@9.3.1: {} + bin-links@6.0.2: dependencies: cmd-shim: 8.0.0 @@ -6038,6 +7678,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -6051,6 +7693,10 @@ snapshots: dependencies: balanced-match: 4.0.3 + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.3 + browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.23 @@ -6059,6 +7705,8 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -6069,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: @@ -6247,6 +7899,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0: dependencies: whatwg-mimetype: 5.0.0 @@ -6284,6 +7938,10 @@ snapshots: diff@8.0.3: {} + diff@8.0.4: {} + + diff@9.0.0: {} + docker-modem@5.0.6: dependencies: debug: 4.4.3 @@ -6321,6 +7979,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.18.4: @@ -6328,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: {} @@ -6347,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: {} @@ -6411,9 +8090,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.6.1)): + eslint-config-prettier@10.1.8(eslint@10.0.1(jiti@2.7.0)): dependencies: - eslint: 10.0.1(jiti@2.6.1) + eslint: 10.0.1(jiti@2.7.0) eslint-scope@9.1.1: dependencies: @@ -6426,9 +8105,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.0.1(jiti@2.6.1): + eslint@10.0.1(jiti@2.7.0): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1(jiti@2.7.0)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.2 '@eslint/config-helpers': 0.5.2 @@ -6459,7 +8138,7 @@ snapshots: natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 2.7.0 transitivePeerDependencies: - supports-color @@ -6555,10 +8234,16 @@ snapshots: exsolve@1.0.8: {} + extend@3.0.2: {} + fast-check@3.23.2: 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: {} @@ -6612,6 +8297,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -6631,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 @@ -6662,6 +8362,10 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -6695,6 +8399,22 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + gaxios@7.3.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.0 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + geist@1.7.0(next@16.2.5(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): dependencies: next: 16.2.5(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -6705,6 +8425,8 @@ snapshots: get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6753,7 +8475,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -6766,12 +8488,34 @@ 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 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.0 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + google-protobuf@3.21.4: {} gopd@1.2.0: {} graceful-fs@4.2.11: {} + grok-mermaid@0.2.2: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -6786,8 +8530,14 @@ 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: {} + hono@4.12.0: {} hosted-git-info@7.0.2: @@ -6806,6 +8556,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + html-escaper@2.0.2: {} http-cache-semantics@4.2.0: {} @@ -6878,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 @@ -6930,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: @@ -6979,6 +8737,8 @@ snapshots: jiti@2.6.1: {} + jiti@2.7.0: {} + jose@6.1.3: {} js-tokens@10.0.0: {} @@ -7020,6 +8780,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-even-better-errors@5.0.0: {} @@ -7028,12 +8792,19 @@ snapshots: dependencies: dequal: 2.0.3 + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.2 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} 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: {} @@ -7052,10 +8823,23 @@ snapshots: just-diff@6.0.2: {} + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + kubernetes-types@1.30.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -7116,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 @@ -7175,6 +8964,10 @@ snapshots: marked@14.0.0: {} + marked@17.0.1: {} + + marked@18.0.5: {} + math-intrinsics@1.1.0: {} mdn-data@2.27.1: {} @@ -7207,10 +9000,18 @@ snapshots: dependencies: brace-expansion: 5.0.2 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.9 + minimatch@3.1.2: 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 @@ -7245,6 +9046,8 @@ snapshots: dependencies: yallist: 4.0.0 + minipass@4.2.8: {} + minipass@5.0.0: {} minipass@7.1.3: {} @@ -7275,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: @@ -7313,12 +9134,25 @@ snapshots: node-addon-api@5.1.0: {} + node-domexception@1.0.0: {} + node-fetch-native@1.6.7: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + 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 @@ -7433,6 +9267,11 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openai@6.26.0(ws@8.19.0)(zod@3.25.76): + optionalDependencies: + ws: 8.19.0 + zod: 3.25.76 + openid-client@6.8.2: dependencies: jose: 6.1.3 @@ -7447,16 +9286,31 @@ 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 p-map@7.0.5: {} + p-retry@4.6.2: + dependencies: + '@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: @@ -7487,20 +9341,35 @@ 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 parseurl@1.3.3: {} + partial-json@0.1.7: {} + 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 @@ -7544,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 @@ -7590,6 +9463,12 @@ snapshots: promise-call-limit@3.0.2: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -7619,6 +9498,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@8.4.2: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -7699,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 @@ -7708,6 +9598,10 @@ snapshots: ret@0.5.0: {} + retry@0.12.0: {} + + retry@0.13.1: {} + reusify@1.1.0: {} rfc4648@1.5.4: {} @@ -7770,6 +9664,8 @@ snapshots: dependencies: tslib: 2.8.1 + s-js@0.4.9: {} + safe-buffer@5.2.1: {} safe-regex2@5.0.0: @@ -7792,6 +9688,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.0: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -7808,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 @@ -7827,7 +9731,7 @@ snapshots: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.7.4 + semver: 7.8.0 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -7931,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 @@ -8011,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: @@ -8048,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: {} @@ -8153,6 +10065,8 @@ snapshots: toidentifier@1.0.1: {} + toml@4.3.0: {} + tough-cookie@6.0.1: dependencies: tldts: 7.0.28 @@ -8165,6 +10079,8 @@ snapshots: treeverse@3.0.0: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8202,6 +10118,10 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typebox@1.3.11: {} + + typebox@1.3.7: {} + typescript@5.9.3: {} undici-types@5.26.5: {} @@ -8214,6 +10134,8 @@ snapshots: undici@7.25.0: {} + undici@8.9.0: {} + unpipe@1.0.0: {} upath@1.2.0: {} @@ -8232,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 @@ -8241,7 +10165,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -8252,15 +10176,15 @@ snapshots: optionalDependencies: '@types/node': 25.3.0 fsevents: 2.3.3 - jiti: 2.6.1 + jiti: 2.7.0 lightningcss: 1.32.0 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.9.0 - vitest@4.0.18(@opentelemetry/api@1.9.1)(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(jiti@2.7.0)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -8277,10 +10201,10 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.1 + '@opentelemetry/api': 1.9.0 '@types/node': 25.3.0 jsdom: 28.1.0 transitivePeerDependencies: @@ -8302,6 +10226,10 @@ snapshots: walk-up-path@4.0.0: {} + web-streams-polyfill@3.3.3: {} + + web-tree-sitter@0.25.10: {} + webidl-conversions@3.0.1: {} webidl-conversions@8.0.1: {} @@ -8384,6 +10312,8 @@ snapshots: yaml@2.8.2: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -8406,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-gitea-mcp.sh b/scripts/build-gitea-mcp.sh new file mode 100755 index 0000000..c5a1664 --- /dev/null +++ b/scripts/build-gitea-mcp.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Build gitea-mcp Docker image and push to Gitea container registry +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" + +# Load .env for GITEA_TOKEN +if [ -f .env ]; then + set -a; source .env; set +a +fi + +# Push directly to internal address (external proxy has body size limit) +REGISTRY="10.0.0.194:3012" +IMAGE="gitea-mcp" +TAG="${1:-latest}" + +echo "==> Building gitea-mcp image..." +podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.gitea-mcp . + +echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..." +podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG" + +echo "==> Logging in to $REGISTRY..." +podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY" + +echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..." +podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG" + +# Ensure package is linked to the repository +source "$SCRIPT_DIR/link-package.sh" +link_package "container" "$IMAGE" + +echo "==> Done!" +echo " Image: $REGISTRY/michal/$IMAGE:$TAG" 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/clean-smoke-resources.ts b/scripts/clean-smoke-resources.ts new file mode 100644 index 0000000..238ebe3 --- /dev/null +++ b/scripts/clean-smoke-resources.ts @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Sweep per-run smoke-test resources off the shared mcpd. + * + * The suites clean up in `afterAll`, which covers assertion failures but not a + * killed process (CI timeout, Ctrl-C, OOM). Over time that leaked hundreds of + * `smoke-*` projects and never-expiring `smoke-*` mcptokens onto the shared + * server, to the point where the project picker was unusable. + * + * Run after a smoke run, or any time the server looks cluttered: + * pnpm smoke:clean # show what would go + * pnpm smoke:clean --yes # actually delete + * + * Only names matching SMOKE_PREFIX are ever considered, and the shared fixture + * (`smoke-data` / `smoke-aws-docs`, provisioned from + * tests/smoke/fixtures/smoke-data.yaml and depended on by five suites) is + * protected — deleting it mid-run would break a concurrent suite. + */ +import { execFileSync } from 'node:child_process'; + +const SMOKE_PREFIX = /^smoke-/; + +/** Shared, intentionally long-lived fixture — never swept. */ +const PROTECTED = new Set(['smoke-data', 'smoke-aws-docs']); + +const APPLY = process.argv.includes('--yes') || process.argv.includes('-y'); + +interface Named { id?: string; name?: string; projectName?: string; status?: string } + +function mcpctl(args: string[]): string { + return execFileSync('mcpctl', args, { encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 }); +} + +function list(resource: string, extra: string[] = []): Named[] { + try { + const out = mcpctl(['get', resource, '-o', 'json', ...extra]); + const parsed: unknown = JSON.parse(out || '[]'); + return Array.isArray(parsed) ? (parsed as Named[]) : []; + } catch { + return []; + } +} + +function sweepable(rows: Named[]): Named[] { + return rows.filter((r) => typeof r.name === 'string' && SMOKE_PREFIX.test(r.name) && !PROTECTED.has(r.name)); +} + +function remove(resource: string, row: Named, extra: string[] = []): boolean { + const name = row.name ?? row.id ?? ''; + if (!SMOKE_PREFIX.test(name) || PROTECTED.has(name)) return false; // belt and braces + try { + mcpctl(['delete', resource, name, ...extra]); + return true; + } catch (err) { + process.stderr.write(` ! ${resource} ${name}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}\n`); + return false; + } +} + +const projects = sweepable(list('projects')); +// Tokens live on real projects too (the suites mint smoke-* tokens against +// mcpctl-development and sre), so they need sweeping independently of projects. +const tokens = sweepable(list('mcptoken')); +const servers = sweepable(list('servers')); +const llms = sweepable(list('llms')); +const agents = sweepable(list('agents')); + +const groups: Array<[string, Named[], string[]]> = [ + ['project', projects, []], + ['mcptoken', tokens, []], + ['server', servers, []], + ['llm', llms, []], + ['agent', agents, []], +]; + +let total = 0; +for (const [, rows] of groups) total += rows.length; + +if (total === 0) { + process.stdout.write('smoke:clean — nothing to sweep\n'); + process.exit(0); +} + +for (const [kind, rows] of groups) { + if (rows.length === 0) continue; + process.stdout.write(`${kind}: ${String(rows.length)}\n`); + for (const r of rows.slice(0, 5)) process.stdout.write(` ${r.name ?? ''}${r.projectName ? ` (in ${r.projectName})` : ''}\n`); + if (rows.length > 5) process.stdout.write(` … and ${String(rows.length - 5)} more\n`); +} + +if (!APPLY) { + process.stdout.write(`\n${String(total)} resource(s) would be deleted. Re-run with --yes to apply.\n`); + process.exit(0); +} + +let deleted = 0; +for (const [kind, rows, extra] of groups) { + for (const r of rows) if (remove(kind, r, extra)) deleted++; +} +process.stdout.write(`\nsmoke:clean — deleted ${String(deleted)}/${String(total)}\n`); +process.exit(deleted === total ? 0 : 1); diff --git a/scripts/deploy-k8s.sh b/scripts/deploy-k8s.sh index e41efad..189c93e 100755 --- a/scripts/deploy-k8s.sh +++ b/scripts/deploy-k8s.sh @@ -80,6 +80,20 @@ cat < /tmp/deploy-smoke.log 2>&1; then - grep -E "Tests |passed" /tmp/deploy-smoke.log | tail -2 +# release.sh already restarts mcplocal and runs the smoke suite against the +# binary it just installed. This step used to restart and re-run it a second +# time, which put two full suites inside a minute and tripped mcpd's rate +# limiter: the second run failed with 429s and printed a false +# "SMOKE TESTS FAILED — consider rollback" over a perfectly healthy deploy. +# One run, one verdict. +if bash scripts/release.sh; then say "Deploy complete — $TAG live. Rollback tag: $ROLLBACK_TAG" else - tail -40 /tmp/deploy-smoke.log - warn "SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:" + warn "RELEASE OR SMOKE TESTS FAILED — system may be unhealthy. Consider rollback:" rollback_recipe exit 1 fi diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 21c268b..3ee67f3 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -76,7 +76,7 @@ const PROJECT_ONLY_COMMANDS = new Set([ /** Commands that appear in BOTH project and non-project context. */ const PROJECT_SCOPED_COMMANDS = new Set([ - 'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help', + 'get', 'describe', 'delete', 'logs', 'create', 'edit', 'help', 'trace', ]); /** Completely hidden commands (never shown in completions). */ diff --git a/scripts/generate-opencode-extension.ts b/scripts/generate-opencode-extension.ts new file mode 100644 index 0000000..09d409d --- /dev/null +++ b/scripts/generate-opencode-extension.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/opencode-extension.ts`, which embeds the two + * opencode plugin sources as string constants — mirroring how the pi extension + * (`pi-extension.ts`) and prime-agent's `/mcpctl` switcher + * (`prime-agent-extension.ts`) are embedded. + * + * Embedding matters: `mcpctl config opencode` must work from an installed + * binary that has no access to the source tree. The installed plugin files are + * this exact embedded source, so what the CLI ships is always what opencode + * runs. + * + * Regenerate after editing the plugin sources: + * npx tsx scripts/generate-opencode-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const extDir = join(root, 'src', 'opencode-ext'); + +const SERVER = readFileSync(join(extDir, 'mcpctl-opencode.ts'), 'utf-8'); +const TUI = readFileSync(join(extDir, 'mcpctl-opencode-tui.tsx'), 'utf-8'); + +function embed(src) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(src); +} + +const out = `/** + * Embedded source of the mcpctl opencode plugins — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-opencode-extension.ts\` from + * \`src/opencode-ext/mcpctl-opencode.ts\` and + * \`src/opencode-ext/mcpctl-opencode-tui.tsx\`. + * + * \`mcpctl config opencode\` writes these verbatim into ~/.config/opencode/ + * (and registers them), so an installed binary with no source tree can still + * provision a working opencode integration. + * + * The install file names differ from the source file names on purpose: + * - the server plugin lands in opencode's auto-discovered \`plugin/\` dir, + * where its basename is what shows up in \`opencode plugin list\`; + * - the TUI plugin lands beside it in \`mcpctl/\`, referenced by path from + * \`tui.json\`, and must keep a \`.tsx\` extension for opencode to transpile + * its JSX. + */ + +/** Install name of the server plugin, relative to ~/.config/opencode/plugin/. */ +export const OPENCODE_SERVER_PLUGIN_FILENAME = 'mcpctl.ts'; + +/** Install name of the TUI plugin, relative to ~/.config/opencode/mcpctl/. */ +export const OPENCODE_TUI_PLUGIN_FILENAME = 'mcpctl-tui.tsx'; + +export const OPENCODE_SERVER_PLUGIN_SOURCE: string = ${embed(SERVER)}; + +export const OPENCODE_TUI_PLUGIN_SOURCE: string = ${embed(TUI)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'opencode-extension.ts'), out); +console.log('wrote src/cli/src/config/opencode-extension.ts'); diff --git a/scripts/generate-pi-extension.ts b/scripts/generate-pi-extension.ts new file mode 100644 index 0000000..1cd3e19 --- /dev/null +++ b/scripts/generate-pi-extension.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/pi-extension.ts` which embeds the pi extension + * sources (`mcpctl-pi.ts` + `mcp-http.ts`) as string constants, mirroring how + * prime-agent's `/mcpctl` switcher is embedded (`prime-agent-extension.ts`). + * + * Embedding matters: `mcpctl config pi` must work from an installed binary + * that has no access to the source tree. The installed extension files are this + * exact embedded source, so what the CLI ships is always what pi runs. + * + * Regenerate after editing the extension sources: + * npx tsx scripts/generate-pi-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const piExtDir = join(root, 'src', 'pi-ext'); + +const MAIN = readFileSync(join(piExtDir, 'mcpctl-pi.ts'), 'utf-8'); +const HTTP = readFileSync(join(piExtDir, 'mcp-http.ts'), 'utf-8'); + +function embed(src) { + // JSON.stringify yields a quoted string literal we can inline directly. + return JSON.stringify(src); +} + +const out = `/** + * Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND. + * Generated by \`npx tsx scripts/generate-pi-extension.ts\` from + * \`src/pi-ext/mcpctl-pi.ts\` and \`src/pi-ext/mcp-http.ts\`. + * + * \`mcpctl config pi\` writes these verbatim into ~/.pi/agent/extensions/mcpctl/ + * (and registers them), so an installed binary with no source tree can still + * provision a working pi integration. + */ +export const PI_EXTENSION_FILENAMES = ['mcpctl-pi.ts', 'mcp-http.ts'] as const; + +export const PI_EXTENSION_FILES: Record = { + 'mcpctl-pi.ts': ${embed(MAIN)}, + 'mcp-http.ts': ${embed(HTTP)}, +}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'pi-extension.ts'), out); +console.log('wrote src/cli/src/config/pi-extension.ts'); diff --git a/scripts/generate-prime-agent-extension.ts b/scripts/generate-prime-agent-extension.ts new file mode 100644 index 0000000..6dcc224 --- /dev/null +++ b/scripts/generate-prime-agent-extension.ts @@ -0,0 +1,41 @@ +#!/usr/bin/env node +/** + * Generates `src/cli/src/config/prime-agent-extension.ts`, which embeds the + * prime-agent `/mcpctl` switcher as a string constant — the same arrangement + * the pi and opencode extensions use. + * + * The switcher used to live *only* as that string literal, with no source file + * behind it, so nothing typechecked or linted it. Now `src/prime-agent-ext/` + * holds the real source and this script produces the embed, so the file the CLI + * writes into ~/.prime/agent/extensions/ is always exactly what was checked. + * + * Regenerate after editing the extension source: + * npx tsx scripts/generate-prime-agent-extension.ts + */ +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const scriptsDir = import.meta.dirname; +const root = join(scriptsDir, '..'); +const extDir = join(root, 'src', 'prime-agent-ext'); + +const SOURCE = readFileSync(join(extDir, 'mcpctl-switch.ts'), 'utf-8'); + +const out = `/** + * The source of the \`/mcpctl\` project-switcher extension, exported as a string + * so \`mcpctl config prime-agent\` can install it into prime-agent's auto- + * discovered extensions directory (\`~/.prime/agent/extensions/\`). + * + * 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 = ${JSON.stringify(SOURCE)}; +`; + +mkdirSync(dirname(join(root, 'src', 'cli', 'src', 'config')), { recursive: true }); +writeFileSync(join(root, 'src', 'cli', 'src', 'config', 'prime-agent-extension.ts'), out); +console.log('wrote src/cli/src/config/prime-agent-extension.ts'); diff --git a/scripts/release.sh b/scripts/release.sh index fc6cfc8..16bbdd4 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -75,9 +75,28 @@ echo "==> 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 4c906a2..8899719 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), @@ -20,6 +21,14 @@ const ServerEnvEntrySchema = z.object({ }).optional(), }); +const VolumeSpecSchema = z.object({ + name: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/), + mountPath: z.string().min(1).regex(/^\//, 'mountPath must be absolute'), + sizeGb: z.number().int().min(1).max(1024).default(10), + storageClass: z.string().optional(), + fsGroup: z.number().int().min(0).max(65535).optional(), +}); + const ServerSpecSchema = z.object({ name: z.string().min(1), description: z.string().default(''), @@ -34,6 +43,12 @@ const ServerSpecSchema = z.object({ replicas: z.number().int().min(0).max(10).default(1), env: z.array(ServerEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), + volumes: z.array(VolumeSpecSchema).default([]), + secretDelivery: z.enum(['env', 'injector']).optional(), + entrypoint: z.array(z.string()).optional(), + // nullable: `get server -o yaml` emits null for servers with no override, + // and that YAML must apply back unchanged. + toolCallTimeoutSeconds: z.number().int().min(1).max(3600).nullable().optional(), }); const SecretSpecSchema = z.object({ @@ -125,6 +140,12 @@ const TemplateSpecSchema = z.object({ replicas: z.number().int().min(0).max(10).default(1), env: z.array(TemplateEnvEntrySchema).default([]), healthCheck: HealthCheckSchema.optional(), + volumes: z.array(VolumeSpecSchema).default([]), + secretDelivery: z.enum(['env', 'injector']).optional(), + entrypoint: z.array(z.string()).optional(), + // nullable: `get server -o yaml` emits null for servers with no override, + // and that YAML must apply back unchanged. + toolCallTimeoutSeconds: z.number().int().min(1).max(3600).nullable().optional(), }); const UserSpecSchema = z.object({ diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 8a4307a..b56bd9f 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -72,7 +72,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). */ @@ -111,7 +111,7 @@ 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; @@ -122,7 +122,10 @@ function chatBody(subject: ChatSubject, message: string, threadId: string | unde o.project = subject.project; } 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; } @@ -223,7 +226,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(); } @@ -280,7 +287,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) { @@ -524,15 +533,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)); @@ -553,7 +568,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, @@ -574,7 +589,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 44330ee..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 } 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'; @@ -9,12 +9,139 @@ import { saveCredentials, loadCredentials } from '../auth/index.js'; import { createConfigSetupCommand } from './config-setup.js'; import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; import type { ApiClient } from '../api-client.js'; -import { writeProjectMarker } from '../utils/project-marker.js'; +import { findProjectMarker, writeProjectMarker } from '../utils/project-marker.js'; import { installManagedSessionHook } from '../utils/sessionhook.js'; +import { + installExtensionFiles, + installEmbeddedExtension, + registerWithPi, + piSkillsDir, + piSettingsPath, + piExtensionDir, + piExtensionMainPath, + piStatePath, + withPiAgentDir, + writePiProjectState, +} from '../utils/pi-settings.js'; import { runSkillsSync } from './skills.js'; +import { + registerPrimeAgentMcp, + primeAgentSettingsPath, + DEFAULT_MCPCTL_GATEWAY_URL, + writePrimeAgentAuth, + readPrimeAgentAuthKey, + mcpTokenPrefixOf, + 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'; -interface McpConfig { - mcpServers: Record }>; +/** + * Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint + * gets a unique `-` name because `McpToken` is unique on + * (name, projectId) and revoke is a soft delete — a fixed name could only ever + * be minted once per project. + */ +const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent'; + +/** 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 { @@ -22,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 { @@ -37,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 @@ -45,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') @@ -101,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) { @@ -180,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 @@ -193,9 +526,486 @@ export function createConfigCommand(deps?: Partial, apiDeps?: } } + // prime-agent: register our proxy MCP gateway in prime-agent's settings.json + // + sync the project's skills into prime-agent's skills tree. Mirror of the + // claude command above, but targeting ~/.prime/agent/ instead of .mcp.json. + function registerPrimeAgentCommand(name: string, hidden: boolean): void { + const cmd = config + .command(name) + .description(hidden ? '' : 'Register mcpctl proxy MCP + auth + skills + /mcpctl switcher for prime-agent (~/.prime/agent)') + .option('-p, --project ', 'Project name') + .option('-o, --output ', 'prime-agent settings.json path (default: ~/.prime/agent/settings.json)') + .option('--gateway-url ', 'mcpctl HTTP MCP gateway base URL', DEFAULT_MCPCTL_GATEWAY_URL) + .option('--token ', 'mcpctl project bearer token to store in auth.json (skips auto-minting)') + .option('--skip-skills', 'Skip the skills sync step') + .option('--skip-extension', 'Do not install the /mcpctl project-switcher extension') + .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; + output?: string; + gatewayUrl: string; + token?: string; + skipSkills?: boolean; + skipExtension?: boolean; + skipMarker?: boolean; + dryRun?: boolean; + }) => { + if (opts.project === undefined || opts.project === '') { + log('Error: --project is required'); + process.exitCode = 1; + return; + } + + const settingsPath = resolve(opts.output ?? primeAgentSettingsPath()); + const agentDir = dirname(settingsPath); + const authPath = join(agentDir, 'auth.json'); + const extPath = join(agentDir, 'extensions', MCPCTL_SWITCH_EXTENSION_FILENAME); + const gatewayBase = opts.gatewayUrl.replace(/\/+$/, ''); + const url = `${gatewayBase}/projects/${encodeURIComponent(opts.project)}/mcp`; + + if (opts.dryRun === true) { + const dry = JSON.stringify({ + primeAgent: { + settingsPath, + authPath, + mcpServers: { [opts.project]: { type: 'http', url } }, + extension: opts.skipExtension === true ? '' : extPath, + marker: opts.skipMarker === true ? '' : join(process.cwd(), '.mcpctl-project'), + }, + action: 'provision auth.json credential + write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/', + }, null, 2); + log(dry); + return; + } + + // 1. Provision the bearer credential prime-agent needs for this project. + // mcpctl's stdio bridge supplied auth implicitly; over HTTP we must + // store an mcp: token in auth.json. Use --token if given, + // keep a still-valid existing one, otherwise mint it via the API. + // + // This runs BEFORE settings.json is touched: registering the new + // project unmounts the previously active one, so a mint failure must + // not be able to leave prime-agent with no working project at all. + // A switch with no usable credential is a FAILURE (exit != 0) so the + // /mcpctl extension does not report success over a bare project. + let provisioned = false; + try { + // Whatever this auth.json held before we touched it — the only token + // this run is entitled to retire once it has a replacement. + const staleKey = await readPrimeAgentAuthKey(opts.project, authPath); + if (opts.token !== undefined && opts.token !== '') { + await writePrimeAgentAuth(opts.project, opts.token, authPath); + log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`); + provisioned = true; + // Only when we actually replaced something: `--token` with a fresh + // auth.json must stay entirely offline, as documented. + if (staleKey !== null) { + 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}`); + provisioned = true; + } else if (skillsClient) { + // Mint under a fresh, unique name. `McpToken` is unique on + // (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 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(PRIME_AGENT_TOKEN_PREFIX, opts.project, staleKey, minted); + } else { + log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`); + } + } else { + log('Error: no API client available to mint a project token — pass --token to provision auth.json'); + } + } catch (err: unknown) { + log(`Error: could not provision bearer credential for '${opts.project}': ${err instanceof Error ? err.message : String(err)}`); + } + if (!provisioned) { + process.exitCode = 1; + log(`Aborted: leaving ${settingsPath} unchanged so the currently active project keeps working`); + return; + } + + // 2. Register the proxy MCP gateway (merge; never destroy settings). + try { + const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl, { authPath }); + log(reg.created + ? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})` + : `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`); + if (reg.removed.length > 0) { + log(`Unmounted previously active mcpctl project(s): ${reg.removed.join(', ')}`); + } + } catch (err: unknown) { + log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // 3. Write the .mcpctl-project marker so later `skills sync` calls can + // resolve the project. An explicit -p is authoritative: it updates a + // differing up-tree marker (so the scope doesn't silently revert on + // the next sync), is a no-op when it already matches, and never + // scopes $HOME itself. `--skip-marker` opts out entirely: the + // /mcpctl switcher runs this command from whatever directory + // prime-agent happens to be started in, and must not silently + // re-scope an unrelated repo that Claude Code's own sync reads. + try { + if (opts.skipMarker === true) { + log('Skipped .mcpctl-project marker (--skip-marker)'); + } else if (process.cwd() !== homedir()) { + const existing = await findProjectMarker(process.cwd(), homedir()); + if (existing !== null && existing.project === opts.project) { + log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`); + } else { + const markerPath = await writeProjectMarker(process.cwd(), opts.project); + log(existing !== null + ? `Updated project marker ${markerPath} ('${existing.project}' → '${opts.project}')` + : `Wrote ${markerPath}`); + } + } else { + log('Skipped .mcpctl-project marker (running from $HOME)'); + } + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + // 4. Sync skills into prime-agent's skills tree (skippable). + // Best-effort: settings + auth (steps 1–2) determine whether the + // switch succeeded. A skills error is reported but must not flip the + // /mcpctl switch to "failed" when MCP access is already provisioned. + if (opts.skipSkills !== true) { + if (skillsClient) { + try { + const result = await runPrimeAgentSkillsSync( + { project: opts.project }, + { client: skillsClient, log: (...a: unknown[]) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0 || result.errors.length > 0) { + log(`Prime-agent skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed, ${String(result.errors.length)} errors)`); + } + } catch (err: unknown) { + log(`Warning: prime-agent 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 prime-agent` separately)'); + } + } + + // 5. Install the /mcpctl project-switcher extension (skippable). + if (opts.skipExtension !== true) { + try { + mkdirSync(dirname(extPath), { recursive: true }); + writeFileSync(extPath, MCPCTL_SWITCH_EXTENSION, 'utf-8'); + log(`Installed /mcpctl switcher extension: ${extPath}`); + } catch (err: unknown) { + log(`Warning: failed to install /mcpctl switcher extension: ${err instanceof Error ? err.message : String(err)}`); + } + } + }); + if (hidden) { + void cmd; + } + + } + registerClaudeCommand('claude', false); registerClaudeCommand('claude-generate', true); // backward compat + // ── pi: wire the mcpctl pi extension (no MCP, no Claude) ── + config + .command('pi') + .description('Install the pi extension + sync skills (native, no MCP client, no Claude)') + .option('-p, --project ', 'Project name to make active') + .option('--extension-dir ', 'Source-tree src/pi-ext dir (dev only; default: embedded sources)') + .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)') + .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; + return; + } + + // Resolve target paths, honouring an optional --pi-dir override. + const piHome = opts.piDir ? withPiAgentDir(opts.piDir) : undefined; + const settingsPath = opts.settings ?? piHome?.settingsPath() ?? piSettingsPath(); + const extDest = piHome?.extensionDir() ?? piExtensionDir(); + const skillsInstall = piHome?.skillsDir() ?? piSkillsDir(); + // Isolate the persisted active-project state under the pi home when a + // 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 + // checkout for dev iteration. + try { + const written = opts.extensionDir + ? await installExtensionFiles(opts.extensionDir, extDest) + : await installEmbeddedExtension(extDest); + log(`Installed extension files:`); + for (const w of written) log(` ${w}`); + } catch (err: unknown) { + log(`Error: could not install extension: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; + return; + } + + // 2. Register with pi settings (extensions + skills dir). + try { + const extMain = piHome?.extensionMainPath() ?? piExtensionMainPath(); + const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extMain, skillsInstall); + log(`Updated ${settingsPath}`); + log(` extensions: ${addedExtensions.length ? addedExtensions.join(', ') : 'already registered'}`); + log(` skills: ${addedSkills.length ? addedSkills.join(', ') : 'already registered'}`); + } catch (err: unknown) { + log(`Warning: could not update pi settings: ${err instanceof Error ? err.message : String(err)}`); + } + + // 3. Persist the active project (survives across sessions/cwd). + try { + const p = await writePiProjectState(opts.project, statePath); + log(`Wrote active project to ${p}`); + } catch (err: unknown) { + log(`Warning: could not write pi state: ${err instanceof Error ? err.message : String(err)}`); + } + + // 4. Optional initial skills sync into pi's skills dir. + if (!opts.skipSkills && skillsClient) { + try { + const result = await runSkillsSync( + { project: opts.project, target: 'pi', installRoot: skillsInstall }, + { client: skillsClient, log: (...a) => log(...(a as string[])), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0) { + log(`Skills synced to ${skillsInstall} (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } else { + log('Skills: no changes (already up to date)'); + } + } catch (err: unknown) { + log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } else if (!opts.skipSkills) { + log('Warning: skipping initial skills sync (no API client provided)'); + } + + log(''); + log('Next: restart pi (or start a new session) — the mcpctl tools for the active'); + log(`project will be registered automatically. Use /mcpctl to switch projects.`); + }); + + 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 })); if (apiDeps) { diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 23417d6..65aa65e 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. * @@ -103,6 +166,53 @@ function parseServerEnv(entries: string[]): ServerEnvEntry[] { }); } +export interface ServerVolume { + name: string; + mountPath: string; + sizeGb: number; + storageClass?: string; +} + +/** + * Parse `NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`. + * + * Split on ':' rather than a regex so a Windows-style or otherwise odd mount + * path fails loudly on the leading-slash check instead of being silently + * mis-parsed into the wrong field. + */ +export function parseServerVolumes(entries: string[]): ServerVolume[] { + return entries.map((entry) => { + const parts = entry.split(':'); + if (parts.length < 2 || parts.length > 4) { + throw new Error( + `Invalid volume format '${entry}'. Expected NAME:/mount/path[:SIZE_GB[:STORAGE_CLASS]]`, + ); + } + const [name, mountPath, sizeRaw, storageClass] = parts; + if (name === undefined || name === '') { + throw new Error(`Invalid volume '${entry}': name is empty`); + } + if (mountPath === undefined || !mountPath.startsWith('/')) { + throw new Error(`Invalid volume '${entry}': mount path must be absolute (start with '/')`); + } + + let sizeGb = 10; + if (sizeRaw !== undefined && sizeRaw !== '') { + sizeGb = Number(sizeRaw); + if (!Number.isInteger(sizeGb) || sizeGb < 1) { + throw new Error(`Invalid volume '${entry}': size must be a positive whole number of GiB`); + } + } + + return { + name, + mountPath, + sizeGb, + ...(storageClass !== undefined && storageClass !== '' ? { storageClass } : {}), + }; + }); +} + function parseEnvEntries(entries: string[]): Record { const result: Record = {}; for (const entry of entries) { @@ -259,6 +369,15 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--container-port ', 'Container port number') .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('--tool-call-timeout ', 'Per-server tool-call deadline in seconds (default: mcplocal\'s global deadline)') + .option('--health-check-failure-threshold ', 'Consecutive failures before the instance is marked unhealthy (default 3)') + .option('--secret-delivery ', 'How secret env reaches the container: env (default, value written into the pod spec) or injector (pod fetches its own secrets from OpenBao under a scoped identity)') + .option('--entrypoint ', 'Comma-separated argv to exec under the injector wrapper. Required for --secret-delivery injector on a dockerImage server, whose ENTRYPOINT mcpd cannot introspect') .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') @@ -334,6 +453,11 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.description !== undefined) body.description = opts.description; if (opts.transport) body.transport = opts.transport; if (opts.replicas) body.replicas = parseInt(opts.replicas, 10); + if (opts.secretDelivery) body.secretDelivery = opts.secretDelivery; + if (opts.entrypoint) body.entrypoint = (opts.entrypoint as string).split(',').map((a) => a.trim()).filter(Boolean); + if (opts.toolCallTimeout !== undefined) { + body.toolCallTimeoutSeconds = parsePositiveInt('--tool-call-timeout', opts.toolCallTimeout as string); + } if (opts.packageName) body.packageName = opts.packageName; if (opts.runtime) body.runtime = opts.runtime; if (opts.dockerImage) body.dockerImage = opts.dockerImage; @@ -341,6 +465,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); @@ -356,6 +486,19 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } body.env = merged; } + if (opts.volume.length > 0) { + // Merge by volume name, same as --env, so a CLI volume can retarget one + // the template declared without duplicating the mount. + const cliVolumes = parseServerVolumes(opts.volume as string[]); + const existing = (body.volumes as ServerVolume[] | undefined) ?? []; + const merged = [...existing]; + for (const vol of cliVolumes) { + const idx = merged.findIndex((v) => v.name === vol.name); + if (idx >= 0) merged[idx] = vol; + else merged.push(vol); + } + body.volumes = merged; + } // Defaults when no template if (!opts.fromTemplate) { @@ -1021,7 +1164,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('\u0000')) { + 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 bf09244..56c915d 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -21,6 +21,20 @@ function formatServerDetail(server: Record): string { lines.push(`${pad('Name:')}${server.name}`); lines.push(`${pad('Transport:')}${server.transport ?? '-'}`); lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`); + { + // Surface the remaining exposure rather than leaving it silent: a server + // still on `env` with secret refs has those VALUES written into its pod + // spec, i.e. cleartext in etcd for anyone with `get pod`. + const delivery = (server as { secretDelivery?: string }).secretDelivery ?? 'env'; + const env = (server.env ?? []) as Array<{ valueFrom?: unknown }>; + const hasSecretRefs = env.some((e) => e.valueFrom !== undefined); + const note = delivery === 'injector' + ? ' (pod fetches its own secrets under a scoped OpenBao identity)' + : hasSecretRefs + ? ' ⚠ secret values are inlined into the pod spec' + : ''; + lines.push(`${pad('Secret Delivery:')}${delivery}${note}`); + } if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`); if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`); if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`); @@ -51,6 +65,22 @@ function formatServerDetail(server: Record): string { } } + const volumes = server.volumes as Array<{ name: string; mountPath: string; sizeGb?: number; storageClass?: string }> | undefined; + if (volumes && volumes.length > 0) { + lines.push(''); + lines.push('Volumes:'); + const volW = Math.max(6, ...volumes.map((v) => v.name.length)) + 2; + lines.push(` ${'NAME'.padEnd(volW)}MOUNT PATH${' '.repeat(12)}SIZE CLAIM`); + for (const v of volumes) { + const size = `${v.sizeGb ?? 10}Gi`; + const cls = v.storageClass !== undefined && v.storageClass !== '' ? ` (${v.storageClass})` : ''; + lines.push( + ` ${v.name.padEnd(volW)}${pad(v.mountPath, 22)}${pad(size, 7)}` + + `mcpctl-${server.name as string}-${v.name}${cls}`, + ); + } + } + const hc = server.healthCheck as { tool: string; arguments?: Record; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null; if (hc) { lines.push(''); @@ -90,6 +120,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 627799d..33708a9 100644 --- a/src/cli/src/commands/skills.ts +++ b/src/cli/src/commands/skills.ts @@ -10,9 +10,11 @@ import { detectModifiedFiles, type SkillState, defaultStatePath, + pathExists, } from '../utils/skills-state.js'; import { installSkillAtomic, + ensureSkillFrontmatter, removeSkillAtomic, type SkillBody, } from '../utils/skills-disk.js'; @@ -31,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 @@ -87,10 +104,24 @@ export interface SyncOpts { keepOrphans?: boolean; /** For tests: override cwd start for the marker walk-up. */ cwd?: string; - /** For tests: override skills install root (default: ~/.claude/skills). */ + /** For tests: override skills install root (default depends on target). */ installRoot?: string; - /** For tests: override state file path. */ + /** For tests: override state file path (default depends on target). */ statePath?: string; + /** Override $HOME used for default paths (tests). */ + homeDir?: string; + /** + * Which agent's skill tree to sync into: + * 'claude' (default) — ~/.claude/skills, with hooks + postInstall. + * 'prime-agent' — ~/.prime/agent/skills; no hooks/postInstall, + * shared flat tree with per-project ownership so + * configuring a second project never deletes the + * first project's skills, and pre-existing + * (untracked) skill dirs are preserved. + * 'pi' / 'opencode' — same shared-tree semantics as prime-agent, in + * ~/.pi/agent/skills and opencode's `skill` dir. + */ + target?: SyncTarget; } export interface SyncResult { @@ -120,6 +151,8 @@ export interface SyncDeps { */ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { const { client, log, warn } = deps; + const target = opts.target ?? 'claude'; + const homeDir = opts.homeDir ?? homedir(); const result: SyncResult = { installed: [], updated: [], @@ -173,10 +206,26 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise s.scope !== 'agent'); - // 3. Load state. - const statePath = opts.statePath ?? defaultStatePath(); + // 3. Load state. Defaults depend on the sync target: Claude Code gets + // ~/.claude/skills + the shared state file; prime-agent gets its own + // tree + separate state file so the two never collide. + // prime-agent, pi and opencode share the same skill semantics: their own flat + // skill tree + separate state file, no SessionStart hooks / postInstall, no + // mcpServers auto-attach. Only claude gets Claude-specific behaviour. + const isSharedTree = target !== 'claude'; + // Canonical ownership scope for a *skill*: null when the skill is global + // (globals are visible from every project, so pinning one to whichever + // project happened to sync it would lock every other project out of ever + // updating it), otherwise the project that installed it. + const ownerOf = (s: VisibleSkill): string | null => (s.scope === 'global' ? null : (projectName ?? null)); + const statePath = opts.statePath + ?? (target === 'claude' ? defaultStatePath() : join(homeDir, '.mcpctl', `skills-state-${target}.json`)); const state = await loadState(statePath); - const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills'); + // 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 ?? agentInstallRoot(target, homeDir); // 4. Diff. const visibleByName = new Map(visible.map((s) => [s.name, s])); @@ -190,7 +239,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise applyOne(v))); } - // 6. Orphan removal: skills in state but not in server's visible set. + // 6. Orphan removal: skills in state but not in the server's visible set. if (!opts.keepOrphans) { for (const name of stateNames) { if (visibleByName.has(name)) continue; const prior = state.skills[name]; if (!prior) continue; + // prime-agent shares one flat skill tree across projects while + // settings.json accumulates one MCP server per project. Never delete a + // skill that belongs to a *different* project (or the user would lose + // their first project just by configuring a second one). Only remove + // skills this project (or globals) previously installed and that have + // since left the visible set. + if (isSharedTree && !ownsOrphan(prior)) continue; try { // Preserve user-modified skills — warn + skip. const modified = await detectModifiedFiles(prior.installDir, prior.files); @@ -224,8 +284,11 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { try { // If on-disk files were locally modified, preserve unless --force. @@ -290,6 +374,36 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise(`/api/v1/skills/${encodeURIComponent(v.id)}`); const body: SkillBody = { - content: full.content, + // mcpd keeps name/description as columns, so a skill whose content is + // just the body would land on disk without the frontmatter every host + // requires and be silently rejected. + content: ensureSkillFrontmatter(full.content, full.name || v.name, full.description || v.description || ''), ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), }; const fileStates = await installSkillAtomic(targetDir, body); - // ── hooks: register metadata.hooks in ~/.claude/settings.json ── - // Tagged with _mcpctl_source: so each skill's hooks - // can be cleanly added/updated/removed without trampling other - // skills or user-added hooks. No-op when the field is absent or - // empty. const meta = (full.metadata ?? {}) as SyncedSkillMetadata; - if (meta.hooks && typeof meta.hooks === 'object') { - try { - const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); - if (hookRes.updated) result.hooksApplied.push(v.name); - } catch (err: unknown) { - warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + + // ── hooks (Claude only) ── + // prime-agent/pi have no SessionStart-hook equivalent and must never + // touch ~/.claude/settings.json. Tagged with _mcpctl_source: + // so each skill's hooks can be cleanly added/updated/ + // removed without trampling other skills or user-added hooks. No-op when + // absent. + if (!isSharedTree) { + if (meta.hooks && typeof meta.hooks === 'object') { + try { + const hookRes = await applyManagedHooks(v.name, meta.hooks as HooksByEvent); + if (hookRes.updated) result.hooksApplied.push(v.name); + } catch (err: unknown) { + warn(`mcpctl: failed to apply hooks for skill '${v.name}': ${err instanceof Error ? err.message : String(err)}`); + } + } else if (prior !== undefined) { + // Skill no longer declares hooks but used to — clean up. + try { await removeManagedHooks(v.name); } catch { /* best-effort */ } } - } else if (prior !== undefined) { - // Skill no longer declares hooks but used to — clean up. - try { await removeManagedHooks(v.name); } catch { /* best-effort */ } } // ── mcpServers: auto-attach declared deps to the active project ── @@ -327,7 +448,16 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 && projectName) { + if (isSharedTree) { + // prime-agent/pi talk to the gateway over HTTP (or directly); + // auto-attaching a skill's declared server deps would mutate the + // *shared* mcpd project attachments (and a /mcpctl switch would + // re-trigger it). Deliberately never attach for shared-tree agents, + // but say so instead of being silent. + if (mcpServerDeps.length > 0) { + warn(`mcpctl: skill '${v.name}' declares mcpServers but this agent's sync does not attach project servers; skipping attach`); + } + } else if (mcpServerDeps.length > 0 && projectName) { try { const att = await attachSkillMcpServers(client, projectName, mcpServerDeps, warn); for (const srv of att.attached) { @@ -351,7 +481,10 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise 0 @@ -418,6 +551,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise void; } +/** + * Map an agent target to its on-disk skills install root. + * "claude" → ~/.claude/skills + * "prime-agent" → ~/.prime/agent/skills + * "pi" → ~/.pi/agent/skills + * "opencode" → ~/.config/opencode/skill (XDG-aware) + * Kept here so `mcpctl skills sync --agent` and `mcpctl config ` agree. + */ +export function agentInstallRoot(agent: string | undefined, homeDir: string = homedir()): string { + switch (agent) { + case 'prime-agent': + return join(homeDir, '.prime', 'agent', 'skills'); + case 'pi': + return join(homeDir, '.pi', 'agent', 'skills'); + case 'opencode': + return opencodeSkillsDir(process.env, homeDir); + case 'claude': + default: + return join(homeDir, '.claude', 'skills'); + } +} + export function createSkillsCommand(deps: SkillsCommandDeps): Command { const { client, log } = deps; const warn = (...args: unknown[]): void => { console.error(...(args as Parameters)); }; - const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd'); + 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 under ~/.claude/skills/') + .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, 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 SessionStart hook)') + .option('--quiet', 'Suppress all output unless something changed (used by session-start hooks)') .option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)') .option('--keep-orphans', 'Do not remove skills that are no longer in the server set') .action(async (opts: { project?: string; + agent?: string; dryRun?: boolean; force?: boolean; quiet?: boolean; skipPostinstall?: boolean; keepOrphans?: boolean; }) => { + // Validate --agent so an unknown value fails loudly instead of silently + // running the default (Claude) sync. + const agent = opts.agent ?? 'claude'; + if (!isSyncTarget(agent)) { + warn(`mcpctl: unknown sync target '${agent}' (expected one of ${SYNC_TARGETS.join(', ')})`); + process.exitCode = 1; + return; + } const result = await runSkillsSync( { ...(opts.project !== undefined ? { project: opts.project } : {}), @@ -467,6 +633,8 @@ 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, + installRoot: agentInstallRoot(agent), }, { client, log, warn }, ); diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts index a523d9d..c2ce0b3 100644 --- a/src/cli/src/commands/status.ts +++ b/src/cli/src/commands/status.ts @@ -50,6 +50,7 @@ interface ServerLlm { * of the last credential-rotation failure (e.g. a dead OpenBao token). */ interface SecretBackendInfo { + id: string; name: string; type: string; isDefault?: boolean; @@ -60,6 +61,22 @@ interface SecretBackendInfo { } | null; } +/** + * Live probe result from GET /api/v1/secretbackends/:id/health. + * + * `live` and `ready` are deliberately separate: a backend that is reachable but + * whose credentials no longer grant anything is the failure mode that hid an + * OpenBao re-init for four days. `null` means the probe itself failed, which we + * report as unknown rather than pretending it means healthy. + */ +interface SecretBackendHealth { + live: boolean; + liveDetail?: string; + ready: boolean; + readyDetail?: string; + cache?: { entries: number; servingStale: number; oldestStaleSince?: number | null } | null; +} + /** * Result of a live "say hi" probe against a server LLM. `ok` says we got a * 200 + non-empty content back; `say` is the trimmed first 16 chars of the @@ -99,6 +116,7 @@ export interface StatusCommandDeps { probeServerLlm: (mcpdUrl: string, name: string, token: string | null) => Promise; /** Fetch SecretBackends from mcpd to surface backend health. Null on error. */ fetchSecretBackends: (mcpdUrl: string, token: string | null) => Promise; + probeSecretBackend: (mcpdUrl: string, id: string, token: string | null) => Promise; isTTY: boolean; } @@ -275,6 +293,38 @@ function defaultFetchSecretBackends(mcpdUrl: string, token: string | null): Prom }); } +/** + * Live-probe one SecretBackend. Resolves to null on any unhappy path — same + * never-throw discipline as the other probes here, and `null` renders as + * "unknown", never as healthy. + */ +function defaultProbeSecretBackend(mcpdUrl: string, id: string, token: string | null): Promise { + return new Promise((resolve) => { + let req: http.ClientRequest; + const headers: Record = { Accept: 'application/json' }; + if (token !== null) headers['Authorization'] = `Bearer ${token}`; + try { + req = httpDriverFor(mcpdUrl).get(`${mcpdUrl}/api/v1/secretbackends/${id}/health`, { timeout: 5000, headers }, (res) => { + if (res.statusCode !== 200) { resolve(null); res.resume(); return; } + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')) as SecretBackendHealth); + } catch { + resolve(null); + } + }); + }); + } catch { + resolve(null); + return; + } + req.on('error', () => resolve(null)); + req.on('timeout', () => { req.destroy(); resolve(null); }); + }); +} + /** * POST a tiny "say hi" prompt to /api/v1/llms//infer and decide if * the LLM actually serves inference. Returns ok=true when the response is @@ -386,6 +436,7 @@ const defaultDeps: StatusCommandDeps = { fetchProviders: defaultFetchProviders, fetchServerLlms: defaultFetchServerLlms, fetchSecretBackends: defaultFetchSecretBackends, + probeSecretBackend: defaultProbeSecretBackend, probeServerLlm: defaultProbeServerLlm, isTTY: process.stdout.isTTY ?? false, }; @@ -448,7 +499,7 @@ function formatProviderStatus(name: string, info: ProvidersInfo, ansi: boolean): } export function createStatusCommand(deps?: Partial): Command { - const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, isTTY } = { ...defaultDeps, ...deps }; + const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, fetchModels, fetchProviders, fetchServerLlms, probeServerLlm, fetchSecretBackends, probeSecretBackend, isTTY } = { ...defaultDeps, ...deps }; return new Command('status') .description('Show mcpctl status and connectivity') @@ -482,6 +533,30 @@ export function createStatusCommand(deps?: Partial): Command }))) : null; + // Same live probe the table view uses. `healthy` is derived from the + // probe, NOT from tokenMeta.lastRotationError — that field is only ever + // written for token-auth backends, so scripts consuming it were told + // every kubernetes-auth backend was healthy unconditionally. + const secretBackendsWithHealth = secretBackends !== null + ? await Promise.all(secretBackends.map(async (b) => { + const health = await probeSecretBackend(config.mcpdUrl, b.id, token); + return { + name: b.name, + type: b.type, + healthy: health !== null && health.live && health.ready, + live: health?.live ?? null, + ready: health?.ready ?? null, + servingStale: health?.cache?.servingStale ?? 0, + error: health === null + ? 'health probe failed' + : !health.live ? (health.liveDetail ?? 'unreachable') + : !health.ready ? (health.readyDetail ?? 'auth failed') + : null, + rotationError: b.tokenMeta?.lastRotationError ?? null, + }; + })) + : null; + const llm = llmLabel ? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})` : null; @@ -499,7 +574,7 @@ export function createStatusCommand(deps?: Partial): Command llmStatus, ...(providersInfo ? { providers: providersInfo } : {}), ...(serverLlmsWithHealth !== null ? { serverLlms: serverLlmsWithHealth } : {}), - ...(secretBackends !== null ? { secretBackends: secretBackends.map((b) => ({ name: b.name, type: b.type, healthy: !b.tokenMeta?.lastRotationError, error: b.tokenMeta?.lastRotationError ?? null })) } : {}), + ...(secretBackends !== null ? { secretBackends: secretBackendsWithHealth } : {}), }; log(opts.output === 'json' ? formatJson(status) : formatYaml(status)); @@ -530,7 +605,7 @@ export function createStatusCommand(deps?: Partial): Command if (!llmLabel) { log(`LLM: not configured (run 'mcpctl config setup')`); - await renderSecretBackendsSection(secretBackendsPromise, isTTY); + await renderSecretBackendsSection(secretBackendsPromise, isTTY, config.mcpdUrl, token); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); return; } @@ -595,7 +670,7 @@ export function createStatusCommand(deps?: Partial): Command } } - await renderSecretBackendsSection(secretBackendsPromise, isTTY); + await renderSecretBackendsSection(secretBackendsPromise, isTTY, config.mcpdUrl, token); await renderServerLlmsSection(serverLlmsPromise, config.mcpdUrl, token, isTTY); }); @@ -609,21 +684,50 @@ export function createStatusCommand(deps?: Partial): Command async function renderSecretBackendsSection( backendsPromise: Promise, ansi: boolean, + mcpdUrl: string, + token: string | null, ): Promise { const backends = await backendsPromise; if (backends === null || backends.length === 0) return; - const parts = backends.map((b) => { - const err = b.tokenMeta?.lastRotationError; - const tag = b.isDefault ? `${b.name}*` : b.name; - if (err) { - const short = err.split('\n')[0]?.slice(0, 80) ?? 'error'; - return ansi ? `${tag} ${RED}✗ ${short}${RESET}` : `${tag} ✗ ${short}`; - } - return ansi ? `${tag} ${GREEN}✓${RESET}` : `${tag} ✓`; - }); + const healths = await Promise.all(backends.map((b) => probeSecretBackend(mcpdUrl, b.id, token))); + const parts = backends.map((b, i) => renderOneBackend(b, healths[i] ?? null, ansi)); log(`Secrets: ${parts.join(', ')}`); } + /** + * Render one backend's status line. + * + * This used to be `tokenMeta.lastRotationError ? red : green`, which was a + * hard-coded green tick for every `auth: kubernetes` backend — the rotator + * only writes that field for token-auth backends, so it was never set and + * `mcpctl status` reported OpenBao healthy even when it was unreachable. + * Rotation state is now one clause among several, not the only signal. + */ + function renderOneBackend(b: SecretBackendInfo, health: SecretBackendHealth | null, ansi: boolean): string { + const tag = b.isDefault === true ? `${b.name}*` : b.name; + const paint = (colour: string, text: string): string => (ansi ? `${colour}${text}${RESET}` : text); + const rotationErr = b.tokenMeta?.lastRotationError ?? ''; + const rotationClause = rotationErr === '' + ? '' + : ` (rotation: ${rotationErr.split('\n')[0]?.slice(0, 60) ?? 'error'})`; + + if (health === null) { + // The probe itself failed. Unknown is not healthy — say so. + return `${tag} ${paint(YELLOW, '? unknown')}${rotationClause}`; + } + if (!health.live) { + return `${tag} ${paint(RED, `✗ unreachable: ${health.liveDetail ?? 'no detail'}`)}${rotationClause}`; + } + if (!health.ready) { + return `${tag} ${paint(RED, `✗ auth failed: ${(health.readyDetail ?? 'no detail').slice(0, 60)}`)}${rotationClause}`; + } + const stale = health.cache?.servingStale ?? 0; + if (stale > 0) { + return `${tag} ${paint(YELLOW, `⚠ degraded — serving ${String(stale)} cached secret(s)`)}${rotationClause}`; + } + return `${tag} ${paint(GREEN, '✓ reachable')}${rotationClause}`; + } + /** * Print a "Server LLMs:" section listing mcpd-managed Llm rows by tier * with a per-LLM "say hi" liveness probe. Distinct from the mcplocal-side 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/commands/trace.ts b/src/cli/src/commands/trace.ts new file mode 100644 index 0000000..a3f2a41 --- /dev/null +++ b/src/cli/src/commands/trace.ts @@ -0,0 +1,176 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +export interface TraceCommandDeps { + client: ApiClient; + log: (...args: string[]) => void; +} + +/** Mirrors mcplocal's AuditEvent rows as stored by mcpd. */ +interface AuditEvent { + timestamp: string; + sessionId: string; + projectName: string; + eventKind: string; + source: string; + serverName?: string | null; + correlationId?: string | null; + userName?: string | null; + payload: Record; +} + +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null; +} + +function str(v: unknown): string | null { + return typeof v === 'string' && v.length > 0 ? v : null; +} + +function clock(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '--:--:--'; + const p = (n: number): string => String(n).padStart(2, '0'); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +function bytes(n: number): string { + if (n < 1024) return `${String(n)}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}kB`; + return `${(n / (1024 * 1024)).toFixed(1)}MB`; +} + +/** One line of detail per event kind — what you actually want to see. */ +function detailOf(e: AuditEvent): string { + const p = e.payload; + switch (e.eventKind) { + case 'stage_execution': { + const parts = [str(p['stage']) ?? 'stage']; + const ms = num(p['durationMs']); + if (ms !== null) parts.push(`${String(ms)}ms`); + const inSize = num(p['inputSize']); + const outSize = num(p['outputSize']); + if (inSize !== null && outSize !== null) parts.push(`${bytes(inSize)} → ${bytes(outSize)}`); + const sections = num(p['sectionCount']); + if (sections !== null && sections > 0) parts.push(`${String(sections)} sections`); + return parts.join(' '); + } + case 'pipeline_execution': { + const ms = num(p['totalDurationMs']); + const stages = num(p['stageCount']); + return `${String(stages ?? 0)} stages${ms === null ? '' : `, ${String(ms)}ms total`}`; + } + case 'tool_call_trace': { + const parts = [str(p['toolName']) ?? '(tool)']; + const ms = num(p['durationMs']); + if (ms !== null) parts.push(`${String(ms)}ms`); + const size = num(p['resultSizeBytes']); + if (size !== null) parts.push(bytes(size)); + return parts.join(' '); + } + case 'gate_decision': { + const trigger = str(p['trigger']) ?? 'gate'; + const matched = Array.isArray(p['matchedPrompts']) ? (p['matchedPrompts']).length : 0; + return `${trigger} · ${String(matched)} prompts`; + } + default: + return e.eventKind; + } +} + +function degradationOf(e: AuditEvent): string | null { + if (e.payload['degraded'] !== true) return null; + return str(e.payload['degradedReason']) ?? 'degraded'; +} + +function errorOf(e: AuditEvent): string | null { + return str(e.payload['error']); +} + +export function createTraceCommand(deps?: Partial): Command { + const log = deps?.log ?? ((...args: string[]): void => { console.log(...args); }); + + return new Command('trace') + .argument('', 'trace code from an error message or degradation notice') + .description('Show the stage-by-stage timeline for one MCP request') + .option('-o, --output ', 'table (default) or json') + .option('--strict', 'exit non-zero if the trace contains an error or a degraded step') + .action(async (code: string, opts: { output?: string; strict?: boolean }) => { + const client = deps?.client; + if (!client) throw new Error('trace: no API client configured'); + + const res = await client.get<{ events?: AuditEvent[]; total?: number }>( + `/api/v1/audit/events?correlationId=${encodeURIComponent(code)}&limit=500`, + ); + const events = (res.events ?? []) + .slice() + .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + if (events.length === 0) { + log(`No trace found for '${code}'.`); + log(''); + log('Traces are written by mcplocal and stored by mcpd. If the request is very'); + log('recent the batch may not have flushed yet (up to 5s); if it is very old it'); + log('may predate trace codes. Check the code was copied exactly — they are 8'); + log('characters, no I/L/O/U.'); + if (opts.strict === true) process.exitCode = 1; + return; + } + + if (opts.output === 'json') { + log(JSON.stringify(events, null, 2)); + return; + } + + const first = events[0]!; + const start = new Date(first.timestamp).getTime(); + const call = events.find((e) => e.eventKind === 'tool_call_trace'); + const headline = call ? detailOf(call) : first.eventKind; + + log(`Trace ${code} project ${first.projectName} session ${first.sessionId.slice(0, 8)}…`); + if (first.userName) log(`user ${first.userName}`); + log(`${headline}`); + log(''); + log(' TIME Δms EVENT DETAIL'); + + let degradedCount = 0; + let errorCount = 0; + let slowest: { name: string; ms: number } | null = null; + + for (const e of events) { + const delta = new Date(e.timestamp).getTime() - start; + const degraded = degradationOf(e); + const err = errorOf(e); + if (degraded !== null) degradedCount++; + if (err !== null) errorCount++; + + // Only real steps compete for "slowest" — tool_call_trace and + // pipeline_execution are aggregates OF those steps, so including them + // would always name the total and tell you nothing. + const ms = num(e.payload['durationMs']); + const label = str(e.payload['stage']); + if (ms !== null && label !== null && (slowest === null || ms > slowest.ms)) { + slowest = { name: label, ms }; + } + + const marks = [ + degraded !== null ? `⚠ ${degraded}` : null, + err !== null ? `✗ ${err}` : null, + ].filter((x): x is string => x !== null).join(' '); + + log( + ` ${clock(e.timestamp)} ${String(delta).padStart(6)} ` + + `${e.eventKind.padEnd(19)} ${detailOf(e)}${marks ? ` ${marks}` : ''}`, + ); + } + + log(''); + const summary: string[] = [`${String(events.length)} events`]; + if (degradedCount > 0) summary.push(`⚠ ${String(degradedCount)} degraded`); + if (errorCount > 0) summary.push(`✗ ${String(errorCount)} error${errorCount === 1 ? '' : 's'}`); + if (slowest !== null) summary.push(`slowest: ${slowest.name} (${String(slowest.ms)}ms)`); + log(summary.join(' · ')); + + if (opts.strict === true && (degradedCount > 0 || errorCount > 0)) process.exitCode = 1; + }); +} 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 new file mode 100644 index 0000000..eff525c --- /dev/null +++ b/src/cli/src/config/pi-extension.ts @@ -0,0 +1,15 @@ +/** + * Embedded source of the mcpctl pi extension — DO NOT EDIT BY HAND. + * Generated by `npx tsx scripts/generate-pi-extension.ts` from + * `src/pi-ext/mcpctl-pi.ts` and `src/pi-ext/mcp-http.ts`. + * + * `mcpctl config pi` writes these verbatim into ~/.pi/agent/extensions/mcpctl/ + * (and registers them), so an installed binary with no source tree can still + * provision a working pi integration. + */ +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 *\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 new file mode 100644 index 0000000..7231a71 --- /dev/null +++ b/src/cli/src/config/prime-agent-extension.ts @@ -0,0 +1,13 @@ +/** + * The source of the `/mcpctl` project-switcher extension, exported as a string + * so `mcpctl config prime-agent` can install it into prime-agent's auto- + * discovered extensions directory (`~/.prime/agent/extensions/`). + * + * 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 !== 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/config/prime-agent.ts b/src/cli/src/config/prime-agent.ts new file mode 100644 index 0000000..2e4c60e --- /dev/null +++ b/src/cli/src/config/prime-agent.ts @@ -0,0 +1,277 @@ +/** + * Read/merge/write helpers for prime-agent's own configuration files, used + * by `mcpctl config prime-agent`. + * + * prime-agent keeps two user-editable files under `~/.prime/agent/`: + * - `settings.json` — `mcpServers` entries (`{ type: "http", url }`) plus + * model/provider preferences. `mcpctl config prime-agent` registers our + * proxy MCP gateway here, mirroring how `config claude` writes `.mcp.json`. + * - `auth.json` — per-server bearer tokens keyed as `mcp:`. + * + * Safety invariants: + * - We only ever *merge* the `mcpServers` map, preserving every other key + * and any servers the user already configured. + * - If `settings.json` exists but is corrupt, we fail loudly instead of + * swallowing the parse error and rewriting (which would destroy every + * non-mcpServers setting). Untouched corrupt files are never overwritten. + * - A project's existing `mcpServers` entry is merged (user-added fields are + * kept), never replaced wholesale. + */ +import { readFile, writeFile, mkdir, stat, chmod } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; + +/** Base URL of the deployed mcpctl HTTP MCP gateway. */ +export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu'; + +/** Every mcpctl bearer token starts with this (see `@mcpctl/shared` generateToken). */ +const MCPCTL_TOKEN_PREFIX = 'mcpctl_pat_'; + +/** Resolve the prime-agent settings.json path. */ +export function primeAgentSettingsPath(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'settings.json'); +} + +/** Resolve the prime-agent auth.json path. */ +export function primeAgentAuthPath(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'auth.json'); +} + +/** Resolve the prime-agent extensions directory (auto-discovered by the app). */ +export function primeAgentExtensionsDir(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'extensions'); +} + +/** Proxy MCP URL for a given project on the gateway. */ +export function projectMcpUrl(project: string, gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL): string { + const base = gatewayUrl.replace(/\/+$/, ''); + return `${base}/projects/${encodeURIComponent(project)}/mcp`; +} + +export interface PrimeAgentSettings { + mcpServers?: Record>; + [key: string]: unknown; +} + +/** + * Load prime-agent settings. + * - Missing file → returns `{}` (a brand-new file about to be created). + * - Unreadable/corrupt → throws, so the caller refuses to overwrite it. + */ +export async function loadPrimeAgentSettings(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 PrimeAgentSettings; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error(`setting file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`); + } +} + +/** + * Is the `mcpServers` entry named `name` one that mcpctl installed? + * + * Two shapes count: + * - `mcpctlManaged: true` — written by this CLI (current releases tag every + * entry they write). + * - *untagged*, but the URL is exactly the canonical `/projects//mcp` + * proxy URL **and** auth.json holds an `mcp:` mcpctl PAT. Older CLIs + * wrote the entry + credential pair but no tag; without adopting them a + * project switch would leave two gateways mounted at once and the `/mcpctl` + * switcher would report no active project. + * + * A hand-configured server never has an mcpctl PAT stored under `mcp:`, + * so it is never adopted — that pairing is what makes the legacy match safe. + */ +export function isMcpctlManagedEntry( + name: string, + entry: unknown, + authKeys: ReadonlySet, +): boolean { + if (entry === null || typeof entry !== 'object') return false; + const rec = entry as Record; + if (rec['mcpctlManaged'] === true) return true; + const url = rec['url']; + if (typeof url !== 'string') return false; + // Host-agnostic: adopt regardless of which gateway the old entry pointed at. + const canonical = new RegExp(`/projects/${escapeRegExp(encodeURIComponent(name))}/mcp/*$`); + return canonical.test(url) && authKeys.has(name); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Names of the projects auth.json holds an mcpctl PAT for (`mcp:`). + * Used to recognise entries an older, tag-less CLI wrote. A missing or corrupt + * auth.json yields an empty set — adoption then simply doesn't happen. + */ +export async function primeAgentAuthProjects(authPath: string): Promise> { + let parsed: Record; + try { + parsed = await loadPrimeAgentAuth(authPath); + } catch { + return new Set(); + } + const out = new Set(); + for (const [k, v] of Object.entries(parsed)) { + if (!k.startsWith('mcp:')) continue; + const key = (v as { key?: unknown } | null)?.key; + if (typeof key === 'string' && key.startsWith(MCPCTL_TOKEN_PREFIX)) out.add(k.slice(4)); + } + return out; +} + +export interface RegisterMcpResult { + settingsPath: string; + created: boolean; // true if the settings file did not previously exist + addedServer: string; + newServer: boolean; // true if the project's MCP entry was not already present + url: string; + totalServers: number; + /** Previously-managed mcpctl project entries removed so `addedServer` is the sole active one. */ + removed: string[]; +} + +/** + * Merge a proxy MCP `{ type: "http", url }` entry for `project` into the + * prime-agent settings file. Preserves all other fields and servers, and + * merges into an existing `mcpServers[project]` entry (keeping any user-added + * keys like `headers`) rather than replacing it wholesale. + */ +export async function registerPrimeAgentMcp( + project: string, + settingsPath: string, + gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL, + opts: { authPath?: string } = {}, +): Promise { + const existed = await pathExists(settingsPath); + const settings = await loadPrimeAgentSettings(settingsPath); + if (settings.mcpServers !== undefined && (typeof settings.mcpServers !== 'object' || settings.mcpServers === null)) { + throw new Error(`invalid mcpServers block in ${settingsPath} — refusing to overwrite it`); + } + + settings.mcpServers = settings.mcpServers ?? {}; + const url = projectMcpUrl(project, gatewayUrl); + const existing = settings.mcpServers[project]; + const newServer = existing === undefined; + // Merge: keep any user-added fields on the project's entry (e.g. headers), + // and tag it so the /mcpctl switcher can find the single *active* project. + settings.mcpServers[project] = { ...(existing ?? {}), type: 'http', url, mcpctlManaged: true }; + + // prime-agent loads every mcpServers entry, so only ONE mcpctl project should + // be active at a time. Remove any *other* mcpctl-managed project entries we + // previously installed — including the untagged ones older CLIs wrote (see + // isMcpctlManagedEntry) — but preserve hand-configured servers (a bespoke + // `sre`, websearch, etc) so switching never nukes unrelated integrations. + const authKeys = opts.authPath !== undefined + ? await primeAgentAuthProjects(opts.authPath) + : new Set(); + const removed: string[] = []; + for (const k of Object.keys(settings.mcpServers)) { + if (k === project) continue; + if (isMcpctlManagedEntry(k, settings.mcpServers[k], authKeys)) { + delete settings.mcpServers[k]; + removed.push(k); + } + } + const totalServers = Object.keys(settings.mcpServers).length; + + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + + return { settingsPath, created: !existed, addedServer: project, newServer, url, totalServers, removed }; +} + +/** + * Ensure `mcp:` carries `{ type: "api_key", key }` in + * `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz` + * provider credential, other `mcp:*` servers, etc). + * + * auth.json holds never-expiring bearer tokens, so it always ends up 0600 — + * never the default umask. `writeFile`'s `mode` only applies when the file is + * created, and prime-agent itself creates auth.json 0644, so we chmod after + * writing rather than trusting the open flags. + */ +export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise { + const current = await loadPrimeAgentAuth(authPath); + current[`mcp:${project}`] = { type: 'api_key', key }; + await mkdir(dirname(authPath), { recursive: true }); + await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 }); + try { + await chmod(authPath, 0o600); + } catch { /* best-effort: a credential written is better than one refused */ } +} + +/** + * Load auth.json. + * - Missing/empty → `{}` (a brand-new file about to be created). + * - Corrupt JSON → throws, so the caller refuses to overwrite it (the same + * guarantee as settings.json — one syntax error must not destroy every + * credential, including the provider API key). + */ +async function loadPrimeAgentAuth(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 Record; + return typeof parsed === 'object' && parsed !== null ? parsed : {}; + } catch (err: unknown) { + throw new Error(`auth file ${path} is not valid JSON — refusing to overwrite it. Fix it and re-run (${err instanceof Error ? err.message : String(err)})`); + } +} + +/** + * The credential currently stored for `project`, or null if there is none. + * Throws on corrupt JSON (the caller must refuse to overwrite the file). + */ +export async function readPrimeAgentAuthKey(project: string, authPath: string): Promise { + const parsed = await loadPrimeAgentAuth(authPath) as Record; + const entry = parsed[`mcp:${project}`]; + if (entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0) { + return entry.key; + } + return null; +} + +/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */ +export async function hasPrimeAgentAuth(project: string, authPath: string): Promise { + return (await readPrimeAgentAuthKey(project, authPath)) !== null; +} + +/** + * The displayable prefix mcpd records for a raw token (`tokenPrefix` on + * McpToken): the first 16 characters. Lets us match a stored credential against + * the server's token list without ever sending the secret. + */ +export function mcpTokenPrefixOf(raw: string): string { + return raw.slice(0, 16); +} + +/** Is this string shaped like an mcpctl PAT (and therefore checkable server-side)? */ +export function isMcpctlToken(raw: string): boolean { + return raw.startsWith(MCPCTL_TOKEN_PREFIX); +} + +async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch { + return false; + } +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 70ae7f1..6affe4c 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -34,8 +34,10 @@ 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 { createTraceCommand } from './commands/trace.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -52,6 +54,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()); @@ -319,6 +322,11 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createTraceCommand({ + client, + log: (...args) => console.log(...args), + })); + program.addCommand(createBackupCommand({ client, log: (...args) => console.log(...args), 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/pi-settings.ts b/src/cli/src/utils/pi-settings.ts new file mode 100644 index 0000000..dee4d41 --- /dev/null +++ b/src/cli/src/utils/pi-settings.ts @@ -0,0 +1,174 @@ +/** + * Wiring helpers for the pi extension. + * + * `mcpctl config pi --project X`: + * 1. writes the embedded extension sources (`mcpctl-pi.ts` + `mcp-http.ts`) + * into `~/.pi/agent/extensions/mcpctl/`, + * 2. adds the extension path and the pi skills dir to + * `~/.pi/agent/settings.json` (`extensions`, `skills` arrays), + * 3. writes `~/.mcpctl/pi-state.json` so the active project survives across + * sessions regardless of the shell's cwd. + * + * The extension sources are embedded in the CLI (see + * `config/pi-extension.ts`) so an installed binary with no source tree can + * still provision a working pi integration. + * + * Standalone: never touches `~/.claude/`, `~/.prime/`, or `.mcp.json`. + */ +import { readFile, writeFile, mkdir, copyFile, rename } from 'node:fs/promises'; +import { dirname, join, basename, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { PI_EXTENSION_FILES } from '../config/pi-extension.js'; + +export function piAgentDir(): string { + return join(homedir(), '.pi', 'agent'); +} + +/** Override the pi agent home (for tests / custom installs). */ +export function withPiAgentDir(base: string): { + settingsPath: () => string; + extensionDir: () => string; + skillsDir: () => string; + extensionMainPath: () => string; +} { + return { + settingsPath: () => join(base, 'settings.json'), + extensionDir: () => join(base, 'extensions', 'mcpctl'), + skillsDir: () => join(base, 'skills'), + extensionMainPath: () => join(base, 'extensions', 'mcpctl', 'mcpctl-pi.ts'), + }; +} + +export function piSettingsPath(): string { + return join(piAgentDir(), 'settings.json'); +} + +export function piExtensionDir(): string { + return join(piAgentDir(), 'extensions', 'mcpctl'); +} + +export function piSkillsDir(): string { + return join(piAgentDir(), 'skills'); +} + +export function piExtensionMainPath(): string { + return join(piExtensionDir(), 'mcpctl-pi.ts'); +} + +export function piStatePath(): string { + return join(homedir(), '.mcpctl', 'pi-state.json'); +} + +/** + * Write the embedded extension sources into the pi extensions dir. This is the + * production path used by `mcpctl config pi` — it needs no source tree, so it + * works from an installed binary. The extension imports its http client via a + * sibling `./mcp-http.js`, so both files must land together. + */ +export async function installEmbeddedExtension(dest: string = piExtensionDir()): Promise { + await mkdir(dest, { recursive: true }); + const written: string[] = []; + for (const [name, src] of Object.entries(PI_EXTENSION_FILES)) { + const dst = join(dest, name); + await writeFile(dst, src, 'utf-8'); + written.push(dst); + } + return written; +} + +/** + * Copy the bundled extension sources (relative to this source tree's + * `src/pi-ext/`) into the pi extensions dir. Kept for tests / dev; production + * uses `installEmbeddedExtension` so installed binaries are self-contained. + */ +export async function installExtensionFiles( + srcPiExtDir: string, + dest: string = piExtensionDir(), +): Promise { + await mkdir(dest, { recursive: true }); + const files = [ + ['mcpctl-pi.ts', 'mcpctl-pi.ts'], + ['mcp-http.ts', 'mcp-http.ts'], + ] as const; + const written: string[] = []; + for (const [from, to] of files) { + const src = resolve(srcPiExtDir, from); + const dst = join(dest, to); + await copyFile(src, dst); + written.push(dst); + } + return written; +} + +interface PiSettings { + extensions?: string[]; + skills?: string[]; + [k: string]: unknown; +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + return JSON.parse(raw) as PiSettings; + } catch (err: unknown) { + const e = err as { code?: string }; + if (e.code === 'ENOENT') return {}; + // settings.json may contain line comments; strip them defensively. + const stripped = (await readFile(path, 'utf-8')).replace(/^\s*\/\/.*$/gm, ''); + return JSON.parse(stripped) as PiSettings; + } +} + +async function writeSettings(path: string, settings: PiSettings): 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); +} + +/** + * Merge our extension path + skills dir into `~/.pi/agent/settings.json`. + * Idempotent — re-running doesn't duplicate entries. + * Returns the paths that were added. + */ +export async function registerWithPi( + settingsPath = piSettingsPath(), + extensionPath = piExtensionMainPath(), + skillsDir = piSkillsDir(), +): Promise<{ addedExtensions: string[]; addedSkills: string[] }> { + const settings = await readSettings(settingsPath); + if (!Array.isArray(settings.extensions)) settings.extensions = []; + if (!Array.isArray(settings.skills)) settings.skills = []; + + const addedExtensions: string[] = []; + if (!settings.extensions.includes(extensionPath)) { + settings.extensions.push(extensionPath); + addedExtensions.push(extensionPath); + } + const addedSkills: string[] = []; + if (!settings.skills.includes(skillsDir)) { + settings.skills.push(skillsDir); + addedSkills.push(skillsDir); + } + + // Only rewrite when something actually changed. Re-serialising drops any + // comments the file had (readSettings strips them to parse), so a no-op run + // must not silently reformat a hand-maintained settings.json. + if (addedExtensions.length > 0 || addedSkills.length > 0) { + await writeSettings(settingsPath, settings); + } + return { addedExtensions, addedSkills }; +} + +/** Write the persisted active-project state. */ +export async function writePiProjectState(project: string, path: string = piStatePath()): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, JSON.stringify({ project }, null, 2) + '\n', 'utf-8'); + return path; +} + +/** Debug helper: the on-disk file name of an extension path. */ +export function _base(path: string): string { + return basename(path); +} diff --git a/src/cli/src/utils/prime-agent-skills.ts b/src/cli/src/utils/prime-agent-skills.ts new file mode 100644 index 0000000..4ff7cc0 --- /dev/null +++ b/src/cli/src/utils/prime-agent-skills.ts @@ -0,0 +1,54 @@ +/** + * Prime-agent skill sync for `mcpctl config prime-agent` / `skills sync --agent prime-agent`. + * + * This is a thin convenience wrapper around the shared [`runSkillsSync`] + * implementation in `commands/skills.ts`, invoked with `target: 'prime-agent'`. + * All diffing, atomic install, preservation and orphan logic lives there; this + * module only: + * - resolves the prime-agent install root and state file paths, and + * - exposes a `runPrimeAgentSkillsSync` entry that delegates to the unified + * sync so callers and tests keep a stable, intent-revealing name. + * + * Target-specific behaviour (handled by the shared implementation): + * - installs markdown skills under `~/.prime/agent/skills//` + * - never touches `~/.claude/settings.json` (no hooks / postInstall) + * - still auto-attaches skill-declared `mcpServers` deps to the project + * - records per-project ownership and preserves untracked / cross-project + * skill dirs so a shared, hand-editable tree is never silently wiped + */ +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import { runSkillsSync, type SyncOpts, type SyncResult, type SyncDeps } from '../commands/skills.js'; + +/** Root of prime-agent's skills tree, e.g. ~/.prime/agent/skills. */ +export function primeAgentSkillsRoot(homeDir: string = homedir()): string { + return join(homeDir, '.prime', 'agent', 'skills'); +} + +/** prime-agent keeps its own state file so it never collides with Claude's. */ +export function primeAgentStatePath(homeDir: string = homedir()): string { + return join(homeDir, '.mcpctl', 'skills-state-prime-agent.json'); +} + +export type PrimeAgentSyncOpts = Pick< + SyncOpts, + 'project' | 'dryRun' | 'force' | 'quiet' | 'keepOrphans' | 'cwd' | 'installRoot' | 'statePath' | 'homeDir' +>; +export type PrimeAgentSyncResult = SyncResult; +export type PrimeAgentSyncDeps = SyncDeps; + +/** + * Sync the active project's skills into prime-agent's markdown skills tree. + * Exit-code semantics mirror `runSkillsSync`: 0 success, 1 auth error, 2 + * disk/state error. + */ +export async function runPrimeAgentSkillsSync( + opts: PrimeAgentSyncOpts, + deps: PrimeAgentSyncDeps, +): Promise { + return runSkillsSync({ ...opts, target: 'prime-agent' }, deps); +} + +// Re-export for callers that prefer to use the shared function directly. +export { runSkillsSync }; 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/src/utils/skills-disk.ts b/src/cli/src/utils/skills-disk.ts index a58c15a..23d2264 100644 --- a/src/cli/src/utils/skills-disk.ts +++ b/src/cli/src/utils/skills-disk.ts @@ -18,6 +18,40 @@ export interface SkillBody { files?: Record; } +/** + * Guarantee the YAML frontmatter every Agent Skills host requires. + * + * mcpd stores `name` and `description` as columns, and a skill's `content` is + * frequently just the body — `propose-learnings` starts straight at its `#` + * heading. Written verbatim, such a skill is rejected by the agent that loads + * it (prime-agent logs `description is required` and never loads it; Claude + * Code and pi need the same keys), so the sync has to put the frontmatter back. + * + * Content that already carries a block is left alone apart from filling in a + * missing `name`/`description` — we never rewrite a skill author's own header. + */ +export function ensureSkillFrontmatter(content: string, name: string, description: string): string { + const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(content); + if (match === null) { + return `---\nname: ${yamlScalar(name)}\ndescription: ${yamlScalar(description)}\n---\n\n${content}`; + } + const block = match[1] ?? ''; + const additions: string[] = []; + if (!/^name[ \t]*:/m.test(block)) additions.push(`name: ${yamlScalar(name)}`); + if (!/^description[ \t]*:/m.test(block)) additions.push(`description: ${yamlScalar(description)}`); + if (additions.length === 0) return content; + return `---\n${[block, ...additions].join('\n')}\n---\n${content.slice(match[0].length)}`; +} + +/** + * Emit a YAML scalar. Descriptions routinely contain `:` and `#`, which break a + * bare scalar — a JSON string is a valid YAML double-quoted scalar and escapes + * quotes and newlines correctly. + */ +function yamlScalar(value: string): string { + return JSON.stringify(value); +} + /** * Write a skill atomically into `targetDir`. If a previous install exists, * it's renamed to `.mcpctl-trash-` and rmtree'd after the diff --git a/src/cli/src/utils/skills-state.ts b/src/cli/src/utils/skills-state.ts index 8f653cd..fe64d5d 100644 --- a/src/cli/src/utils/skills-state.ts +++ b/src/cli/src/utils/skills-state.ts @@ -30,6 +30,13 @@ export interface SkillState { /** sha256 of the postInstall script if any; null if none. */ postInstallHash: string | null; lastSyncedAt: string; + /** + * Owning project name, used by the prime-agent sync to avoid cross-project + * orphan deletion in the shared ~/.prime/agent/skills tree. Globals record + * null; project-scoped skills record the project that installed them. + * Unset for the Claude Code path. + */ + project?: string | null; } export interface SkillsStateFile { 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/config-pi.test.ts b/src/cli/tests/commands/config-pi.test.ts new file mode 100644 index 0000000..fe0d673 --- /dev/null +++ b/src/cli/tests/commands/config-pi.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, mkdirSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(visibleSkills: unknown[] = []): ApiClient { + return { + get: vi.fn(async (path: string) => { + if (path.endsWith('/skills/visible')) return visibleSkills; + if (path.startsWith('/api/v1/skills/')) return { + id: 's1', name: 'demo', description: 'd', semver: '1.0.0', + content: '# demo\n\nSkill body\n', files: {}, metadata: {}, projectId: null, agentId: null, + }; + return {}; + }), + post: vi.fn(async () => ({ token: 't', user: { email: 'x' } })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config pi', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-pi-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('installs the extension + registers with a custom pi dir (skips skills)', async () => { + // Stage a src/pi-ext source tree to install from. + const srcPiExt = join(tmpDir, 'src-pi-ext'); + mkdirSync(srcPiExt, { recursive: true }); + writeFileSync(join(srcPiExt, 'mcpctl-pi.ts'), '// a\n'); + writeFileSync(join(srcPiExt, 'mcp-http.ts'), '// b\n'); + + const piDir = join(tmpDir, 'pi-agent'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + + await cmd.parseAsync( + ['pi', '--project', 'docmost', '--extension-dir', srcPiExt, '--pi-dir', piDir, '--skip-skills'], + { from: 'user' }, + ); + + // Extension files landed in the pi dir. + expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts'))).toBe(true); + expect(existsSync(join(piDir, 'extensions', 'mcpctl', 'mcp-http.ts'))).toBe(true); + + // settings.json updated. + const settings = JSON.parse(readFileSync(join(piDir, 'settings.json'), 'utf-8')); + expect(settings.extensions).toContain(join(piDir, 'extensions', 'mcpctl', 'mcpctl-pi.ts')); + expect(settings.skills).toContain(join(piDir, 'skills')); + + // Active project persisted under the pi dir (fully isolated). + const state = JSON.parse(readFileSync(join(piDir, 'pi-state.json'), 'utf-8')); + expect(state.project).toBe('docmost'); + + // Output mentions the write. + expect(output.join('\n')).toContain('Wrote active project'); + }); + + it('requires a project', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['pi', '--pi-dir', join(tmpDir, 'x'), '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + }); +}); diff --git a/src/cli/tests/commands/create.test.ts b/src/cli/tests/commands/create.test.ts index d1d2f32..2840276 100644 --- a/src/cli/tests/commands/create.test.ts +++ b/src/cli/tests/commands/create.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createCreateCommand } from '../../src/commands/create.js'; +import { createCreateCommand, parseServerVolumes } from '../../src/commands/create.js'; import { type ApiClient, ApiError } from '../../src/api-client.js'; function mockClient(): ApiClient { @@ -556,3 +556,31 @@ describe('create command', () => { }); }); }); + +describe('parseServerVolumes', () => { + it('parses name, path, size and storage class', () => { + expect(parseServerVolumes(['data:/data:20:longhorn'])).toEqual([ + { name: 'data', mountPath: '/data', sizeGb: 20, storageClass: 'longhorn' }, + ]); + }); + + it('defaults size to 10Gi and omits storageClass', () => { + expect(parseServerVolumes(['data:/data'])).toEqual([ + { name: 'data', mountPath: '/data', sizeGb: 10 }, + ]); + }); + + it('rejects a relative mount path', () => { + expect(() => parseServerVolumes(['data:data'])).toThrow(/must be absolute/); + }); + + it('rejects a non-integer or zero size', () => { + expect(() => parseServerVolumes(['data:/data:abc'])).toThrow(/positive whole number/); + expect(() => parseServerVolumes(['data:/data:0'])).toThrow(/positive whole number/); + }); + + it('rejects a malformed spec', () => { + expect(() => parseServerVolumes(['data'])).toThrow(/Invalid volume format/); + expect(() => parseServerVolumes(['a:/b:1:c:d'])).toThrow(/Invalid volume format/); + }); +}); diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts new file mode 100644 index 0000000..4507a83 --- /dev/null +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -0,0 +1,607 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { createConfigCommand } from '../../src/commands/config.js'; +import type { ApiClient } from '../../src/api-client.js'; +import { DEFAULT_MCPCTL_GATEWAY_URL } from '../../src/config/prime-agent.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({})), + post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('config prime-agent', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + let prevCwd: string; + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-prime-agent-')); + // config prime-agent writes the .mcpctl-project marker into cwd, so run + // every test from an isolated temp dir to avoid polluting the repo. + prevCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + process.exitCode = 0; + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('requires --project', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--skip-skills'], { from: 'user' }); + expect(output.join('\n')).toContain('--project is required'); + expect(process.exitCode).toBe(1); + }); + + it('writes proxy MCP entry into prime-agent settings.json', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'homeautomation', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['homeautomation']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, + mcpctlManaged: true, + }); + expect(output.join('\n')).toContain('homeautomation'); + }); + + it('merges with existing servers and preserves other settings', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + defaultProvider: 'itaz', + mcpServers: { + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, + }, + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.defaultProvider).toBe('itaz'); // untouched + expect(written.mcpServers['sre']).toBeDefined(); // preserved + expect(written.mcpServers['proj-1']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/proj-1/mcp`, + mcpctlManaged: true, + }); + }); + + it('writes a project marker for later skills sync', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const markerPath = join(tmpDir, '.mcpctl-project'); + expect(readFileSync(markerPath, 'utf-8').trim()).toBe('sre'); + }); + + it('--dry-run prints the change without writing', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-2', '-o', settingsPath, '--dry-run'], { from: 'user' }); + + expect(output.join('\n')).toContain('proj-2'); + // No file should have been created. + expect(exceptionSafeRead(settingsPath)).toBeNull(); + }); + + it('does not call the API when --skip-skills and --token are given', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'proj-3', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_test'], { from: 'user' }); + + expect(client.get).not.toHaveBeenCalled(); + expect(client.post).not.toHaveBeenCalled(); + }); + + it('backward compat: prime-agent-generate still works', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent-generate', '--project', 'proj-1', '-o', settingsPath, '--skip-skills'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['proj-1']).toBeDefined(); + }); + + it('provisions auth.json by minting a project token', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/mcptokens', expect.objectContaining({ projectName: 'labctl' })); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:labctl']).toEqual({ type: 'api_key', key: 'impersonated-tok' }); + }); + + it('uses --token without calling the API', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'docmost', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_custom'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:docmost']).toEqual({ type: 'api_key', key: 'mcpctl_pat_custom' }); + }); + + it('keeps an existing credential and does not re-mint', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ 'mcp:labctl': { type: 'api_key', key: 'existing' } })); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:labctl'].key).toBe('existing'); + }); + + it('installs the /mcpctl switcher extension by default, and skips with --skip-extension', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const extPath = join(tmpDir, 'extensions', 'mcpctl-switch.ts'); + expect(existsSync(extPath)).toBe(true); + expect(readFileSync(extPath, 'utf-8')).toContain("registerCommand('mcpctl'"); + + output.length = 0; + const cmd2 = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd2.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + expect(output.join('\n')).not.toContain('switcher extension'); + }); + + it('does not write a .mcpctl-project marker when run from $HOME', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const prevCwd = process.cwd(); + process.chdir(homedir()); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + try { + await cmd.parseAsync(['prime-agent', '--project', 'proj-x', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + } finally { + process.chdir(prevCwd); + } + expect(output.join('\n')).toContain('Skipped .mcpctl-project marker'); + expect(exceptionSafeRead(join(homedir(), '.mcpctl-project'))).toBeNull(); + }); + + it('refuses to overwrite a corrupt auth.json (and does not mint over it)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), '{ not valid json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(output.join('\n')).toContain('refusing to overwrite'); + expect(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')).toBe('{ not valid json'); + expect(process.exitCode).toBe(1); + }); + + it('exits non-zero when a credential cannot be provisioned', async () => { + // mockClient post returns { token: ... } by default; override to no token. + const badClient = { ...client, post: vi.fn(async () => ({})) } as typeof client; + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: badClient, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'x', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + expect(process.exitCode).toBe(1); + // body of provisioning error surfaced + expect(output.join('\n')).toContain('no token returned'); + }); + + it('writes auth.json with mode 0600', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); // mint path, mock post returns token + const mode = statSync(join(tmpDir, 'auth.json')).mode & 0o777; + expect(mode).toBe(0o600); + }); + + it('refuses to overwrite a corrupt settings.json', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, '{ this is not valid json !!!'); + const prevCwd = process.cwd(); + process.chdir(tmpDir); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + try { + await cmd.parseAsync(['prime-agent', '--project', 'proj-9', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + } finally { + process.chdir(prevCwd); + } + expect(output.join('\n')).toContain('refusing to overwrite'); + // The corrupt file is untouched. + expect(readFileSync(settingsPath, 'utf-8')).toBe('{ this is not valid json !!!'); + }); + + it('keeps a single active mcpctl project, preserving untagged servers (sre)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // untagged, hand-set + homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true }, + }, + })); + // Active project is homeautomation (tagged). Switch to labctl. + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); // new active + expect(written.mcpServers['homeautomation']).toBeUndefined(); // old managed removed + expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved + }); + + it('adopts an untagged entry an older CLI wrote, keeping hand-configured ones', async () => { + // Written by a CLI that predates `mcpctlManaged`: an untagged entry whose + // URL is canonical AND a matching mcp: PAT in auth.json. + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + legacy: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/legacy/mcp` }, + websearch: { type: 'http', url: 'https://search.example/mcp' }, // hand-configured + sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // canonical URL, no credential + }, + })); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + itaz: { type: 'api_key', key: 'sk-provider' }, + 'mcp:legacy': { type: 'api_key', key: 'mcpctl_pat_legacytoken1234' }, + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['legacy']).toBeUndefined(); // adopted + unmounted + expect(written.mcpServers['websearch']).toBeDefined(); // unrelated, preserved + expect(written.mcpServers['sre']).toBeDefined(); // no PAT → hand-set, preserved + expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true); + }); + + it('mints each credential under a unique name (never a fixed one)', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const body = client.post.mock.calls.find((c) => c[0] === '/api/v1/mcptokens')?.[1] as { name: string }; + // A fixed name can only ever be minted once: McpToken is unique on + // (name, projectId) and revoke is a soft delete. + expect(body.name).not.toBe('prime-agent'); + expect(body.name).toMatch(/^prime-agent-[a-z0-9-]+$/); + }); + + it('revokes the token it replaced, only after the replacement is stored', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const authPath = join(tmpDir, 'auth.json'); + writeFileSync(authPath, JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_oldtoken00000' }, + })); + const order: string[] = []; + const api = { + get: vi.fn(async (url: string) => { + order.push(`get ${url}`); + return [ + { id: 'tok-old', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_oldto' }, + { id: 'tok-other', name: 'ci-runner', status: 'active', tokenPrefix: 'mcpctl_pat_ci000' }, + ]; + }), + post: vi.fn(async (url: string) => { + order.push(`post ${url}`); + return {}; + }), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + // Explicitly replace the stored credential. + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_supplied00000'], { from: 'user' }); + + // The new credential landed on disk... + expect(JSON.parse(readFileSync(authPath, 'utf-8'))['mcp:p'].key).toBe('mcpctl_pat_supplied00000'); + // ...before the token it replaced was revoked — never the other way round. + const revokeAt = order.indexOf('post /api/v1/mcptokens/tok-old/revoke'); + expect(revokeAt).toBeGreaterThanOrEqual(0); + expect(statSync(authPath).mtimeMs).toBeGreaterThan(0); + // Tokens this auth.json never held are reported, never revoked. + expect(order).not.toContain('post /api/v1/mcptokens/tok-other/revoke'); + }); + + it('never revokes a token this auth.json did not hold', async () => { + // A run against a custom --output (or a second machine) must not touch the + // credential the real install is using. + const settingsPath = join(tmpDir, 'settings.json'); + const api = { + get: vi.fn(async () => [ + { id: 'tok-elsewhere', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_elsew' }, + ]), + post: vi.fn(async (url: string) => (url === '/api/v1/mcptokens' ? { token: 'mcpctl_pat_brandnew0000' } : {})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const revokes = api.post.mock.calls.filter((c) => String(c[0]).includes('/revoke')); + expect(revokes).toEqual([]); + // ...but the user is told about it rather than left guessing. + expect(output.join('\n')).toContain('prime-agent-abc'); + }); + + it('leaves settings.json untouched when the credential cannot be provisioned', async () => { + // The active project must keep working when a switch fails: registering the + // new project unmounts the old one, so it may not run before the mint. + const settingsPath = join(tmpDir, 'settings.json'); + const before = JSON.stringify({ + mcpServers: { + homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true }, + }, + }); + writeFileSync(settingsPath, before); + const badClient = { ...client, get: vi.fn(async () => []), post: vi.fn(async () => ({})) } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: badClient, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(process.exitCode).toBe(1); + expect(readFileSync(settingsPath, 'utf-8')).toBe(before); + }); + + it('re-mints when the stored credential is no longer active', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_revoked000000' }, + })); + const api = { + get: vi.fn(async () => [ + { id: 'tok-1', name: 'prime-agent-old', status: 'revoked', tokenPrefix: 'mcpctl_pat_revo' }, + ]), + post: vi.fn(async () => ({ token: 'mcpctl_pat_fresh0000000' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:p'].key).toBe('mcpctl_pat_fresh0000000'); + expect(process.exitCode).toBe(0); + }); + + it('keeps a stored credential that is still active', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({ + 'mcp:p': { type: 'api_key', key: 'mcpctl_pat_liveaaaaaaaa' }, + })); + const api = { + get: vi.fn(async () => [ + // mcpd records the first 16 chars of the raw token as tokenPrefix. + { id: 'tok-1', name: 'prime-agent-x', status: 'active', tokenPrefix: 'mcpctl_pat_livea' }, + ]), + post: vi.fn(async () => ({ token: 'should-not-be-minted' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client: api, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(api.post).not.toHaveBeenCalled(); + const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8')); + expect(auth['mcp:p'].key).toBe('mcpctl_pat_liveaaaaaaaa'); + }); + + it('tightens a pre-existing 0644 auth.json to 0600', async () => { + // prime-agent creates auth.json itself with the default umask; writeFile's + // `mode` is ignored for an existing file, so the write must chmod. + const settingsPath = join(tmpDir, 'settings.json'); + const authPath = join(tmpDir, 'auth.json'); + writeFileSync(authPath, JSON.stringify({ itaz: { type: 'api_key', key: 'sk-x' } }), { mode: 0o644 }); + chmodSync(authPath, 0o644); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' }); + + expect(statSync(authPath).mode & 0o777).toBe(0o600); + // The provider credential is still there. + expect(JSON.parse(readFileSync(authPath, 'utf-8')).itaz.key).toBe('sk-x'); + }); + + it('--skip-marker leaves the current directory alone', async () => { + // The /mcpctl switcher runs from whatever directory prime-agent started in. + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills', '--skip-extension', '--skip-marker', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull(); + }); + + it('the installed switcher extension publishes the active project to the footer', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // Footer status, refreshed on startup and on every reload (which is what + // the switch itself triggers) — the mcpctl equivalent of the model name. + expect(ext).toContain("pi.on('session_start'"); + expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + expect(ext).toContain('`mcpctl:${active}`'); + // notify() only accepts info|warning|error — 'success' is not a valid type. + expect(ext).not.toContain("'success'"); + }); + + it('the installed switcher shows the active project via a widget, not just setStatus', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // Both hosts render statuses next to the model name (pi in its footer, + // prime-agent in the tray — the latter via prime-agent-extension-status.patch). + expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + // The widget was a workaround for the unpatched tray; it scrolled away with + // the transcript, so it is not a status line and must not come back. + expect(ext).not.toContain('setWidget'); + // prime-agent emits session_start only from reload(), never at startup, so + // a session_start-only indicator stays blank until the first switch. + expect(ext).toContain("pi.on('turn_start'"); + }); + + it('the installed switcher filters the project list before showing it', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + // Hundreds of projects in a plain arrow-key list is unusable. + expect(ext).toContain('export function filterProjects'); + expect(ext).toContain('ctx.ui.input('); + expect(ext).toContain('FILTER_THRESHOLD'); + }); + + it('the installed switcher extension passes --skip-marker', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); + expect(ext).toContain("'--skip-extension', '--skip-marker'"); + }); + + it('merges a re-configured project entry, preserving user-added fields', async () => { + const settingsPath = join(tmpDir, 'settings.json'); + writeFileSync(settingsPath, JSON.stringify({ + mcpServers: { + ha: { type: 'http', url: 'https://old/projects/ha/mcp', headers: { Authorization: 'Bearer u' } }, + }, + })); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' }); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.mcpServers['ha']).toEqual({ + type: 'http', + url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/ha/mcp`, + headers: { Authorization: 'Bearer u' }, // user-added field preserved + mcpctlManaged: true, + }); + }); +}); + +function exceptionSafeRead(path: string): string | null { + try { + return readFileSync(path, 'utf-8'); + } catch { + return null; + } +} diff --git a/src/cli/tests/commands/skills.test.ts b/src/cli/tests/commands/skills.test.ts new file mode 100644 index 0000000..1556667 --- /dev/null +++ b/src/cli/tests/commands/skills.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { createSkillsCommand } from '../../src/commands/skills.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('skills sync --agent', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-skills-agent-')); + process.exitCode = 0; + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + process.exitCode = 0; + }); + + it('defaults to claude and runs the normal sync', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--skip-postinstall'], { from: 'user' }); + // claude path calls the project visible endpoint. + expect(String(client.get.mock.calls[0]?.[0])).toContain('/skills/visible'); + }); + + it('routes --agent prime-agent to the prime-agent target', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'prime-agent'], { from: 'user' }); + // prime-agent path also hits the project visible endpoint, and the summary + // line should mention the target. + expect(output.join('\n')).toContain('prime-agent'); + }); + + it('rejects an unknown --agent value with a non-zero exit', async () => { + const cmd = createSkillsCommand({ client, log }); + await cmd.parseAsync(['sync', '--project', 'proj', '--agent', 'bogus'], { from: 'user' }); + expect(process.exitCode).toBe(1); + expect(client.get).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts index d627b55..b6b5ee9 100644 --- a/src/cli/tests/commands/status.test.ts +++ b/src/cli/tests/commands/status.test.ts @@ -30,6 +30,7 @@ function baseDeps(overrides?: Partial): Partial null, probeServerLlm: async () => ({ ok: true, ms: 12, say: 'hi' }), fetchSecretBackends: async () => null, + probeSecretBackend: async () => ({ live: true, ready: true, cache: { entries: 0, servingStale: 0 } }), isTTY: false, ...overrides, }; @@ -46,33 +47,73 @@ afterEach(() => { }); describe('status command', () => { + const BAO = { id: 'b1', name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } }; + it('shows a healthy secret backend in the Secrets line', async () => { const cmd = createStatusCommand(baseDeps({ - fetchSecretBackends: async () => [ - { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: null } }, - { name: 'default', type: 'plaintext' }, - ], + fetchSecretBackends: async () => [BAO, { id: 'b2', name: 'default', type: 'plaintext' }], })); await cmd.parseAsync([], { from: 'user' }); const out = output.join('\n'); expect(out).toContain('Secrets:'); - expect(out).toContain('bao* ✓'); - expect(out).toContain('default ✓'); + expect(out).toContain('bao* ✓ reachable'); + expect(out).toContain('default ✓ reachable'); }); it('flags a dead secret-backend token in the Secrets line', async () => { const cmd = createStatusCommand(baseDeps({ fetchSecretBackends: async () => [ - { name: 'bao', type: 'openbao', isDefault: true, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } }, + { ...BAO, tokenMeta: { lastRotationError: 'BACKEND_TOKEN_DEAD: rejected the stored token\nmore detail' } }, ], })); await cmd.parseAsync([], { from: 'user' }); const out = output.join('\n'); - expect(out).toContain('bao* ✗'); expect(out).toContain('BACKEND_TOKEN_DEAD'); expect(out).not.toContain('more detail'); // only first line, truncated }); + it('reports an unreachable backend even when rotation never errored', async () => { + // THE bug: a kubernetes-auth backend never writes tokenMeta.lastRotationError, + // so this line used to render a green tick with OpenBao completely down. + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => ({ live: false, liveDetail: 'sealed', ready: false }), + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('bao* ✗ unreachable: sealed'); + expect(out).not.toContain('✓'); + }); + + it('distinguishes reachable-but-unusable from unreachable', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => ({ live: true, ready: false, readyDetail: 'HTTP 403 permission denied' }), + })); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).toContain('bao* ✗ auth failed: HTTP 403 permission denied'); + }); + + it('reports degraded while serving cached secrets', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => ({ live: true, ready: true, cache: { entries: 9, servingStale: 7 } }), + })); + await cmd.parseAsync([], { from: 'user' }); + expect(output.join('\n')).toContain('bao* ⚠ degraded — serving 7 cached secret(s)'); + }); + + it('reports unknown — never healthy — when the probe itself fails', async () => { + const cmd = createStatusCommand(baseDeps({ + fetchSecretBackends: async () => [BAO], + probeSecretBackend: async () => null, + })); + await cmd.parseAsync([], { from: 'user' }); + const out = output.join('\n'); + expect(out).toContain('bao* ? unknown'); + expect(out).not.toContain('✓'); + }); + it('omits the Secrets line when mcpd returns no backends', async () => { const cmd = createStatusCommand(baseDeps({ fetchSecretBackends: async () => null })); await cmd.parseAsync([], { from: 'user' }); 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/completions.test.ts b/src/cli/tests/completions.test.ts index 2e303ab..cfbfbf6 100644 --- a/src/cli/tests/completions.test.ts +++ b/src/cli/tests/completions.test.ts @@ -234,7 +234,7 @@ describe('agent + chat completions', () => { }); it('bash dispatches `create agent` with the correct flags', () => { - const createBlock = bashFile.match(/agent\)[\s\S]*?;;/)?.[0] ?? ''; + const createBlock = bashFile.match(/^\s*agent\)[\s\S]*?;;/m)?.[0] ?? ''; expect(createBlock).toContain('--llm'); expect(createBlock).toContain('--system-prompt'); expect(createBlock).toContain('--default-temperature'); 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 new file mode 100644 index 0000000..3280d0b --- /dev/null +++ b/src/cli/tests/config/pi-extension-embed.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PI_EXTENSION_FILES, PI_EXTENSION_FILENAMES } from '../../src/config/pi-extension.js'; + +/** + * `mcpctl config pi` installs the *embedded* copy of the extension, not the + * files in src/pi-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 piExtDir = join(repoRoot, 'src', 'pi-ext'); + +describe('embedded pi extension', () => { + it('matches the sources in src/pi-ext (re-run scripts/generate-pi-extension.ts)', () => { + for (const name of PI_EXTENSION_FILENAMES) { + const onDisk = readFileSync(join(piExtDir, name), 'utf-8'); + expect(PI_EXTENSION_FILES[name], `${name} is embedded`).toBeDefined(); + expect(PI_EXTENSION_FILES[name], `${name} is stale — regenerate the embed`).toBe(onDisk); + } + }); + + it('embeds every file the extension needs to load', () => { + // mcpctl-pi.ts imports ./mcp-http.js — installing one without the other + // yields an extension pi cannot load. + expect(Object.keys(PI_EXTENSION_FILES).sort()).toEqual(['mcp-http.ts', 'mcpctl-pi.ts']); + 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. + expect(main).not.toMatch(/select\([^)]*\[\s*\{\s*value:/); + // Skills must land in pi's tree, never ~/.claude: the sync passes + // --agent pi and no longer tells the user it wrote to Claude's tree. + expect(main).toContain('"--agent", "pi"'); + expect(main).not.toContain('(into ~/.claude/skills)'); + // Spawned without a shell, so the project name is never interpolated into + // a command string. Matches the call, not prose mentioning it. + expect(main).toContain('execFile("mcpctl", args'); + expect(main).not.toMatch(/execSync\s*\(/); + }); +}); diff --git a/src/cli/tests/config/pi-project-filter.test.ts b/src/cli/tests/config/pi-project-filter.test.ts new file mode 100644 index 0000000..5ffdfcf --- /dev/null +++ b/src/cli/tests/config/pi-project-filter.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { filterProjects, toolChangeAnnouncement } from '../../../pi-ext/mcpctl-pi.js'; + +/** + * pi's selector is a plain arrow-key list — no search — so the project picker + * filters before handing the list over. Real installs run to hundreds of + * projects, where scrolling is hopeless. + */ +const PROJECTS = [ + 'smoke-proj-none-mohimh46', + 'homeautomation', + 'docmost', + 'copy-homeautomation', + 'labctl', + 'mcpctl-development', + 'sre', +]; + +describe('filterProjects', () => { + it('puts the active project first, then sorts alphabetically', () => { + expect(filterProjects(PROJECTS, '', 'labctl')).toEqual([ + 'labctl', + 'copy-homeautomation', + 'docmost', + 'homeautomation', + 'mcpctl-development', + 'smoke-proj-none-mohimh46', + 'sre', + ]); + }); + + it('sorts alphabetically when nothing is active', () => { + expect(filterProjects(PROJECTS, '', null)[0]).toBe('copy-homeautomation'); + }); + + it('keeps everything for a blank or whitespace query', () => { + expect(filterProjects(PROJECTS, '', null)).toHaveLength(PROJECTS.length); + expect(filterProjects(PROJECTS, ' ', null)).toHaveLength(PROJECTS.length); + }); + + it('matches case-insensitive substrings', () => { + expect(filterProjects(PROJECTS, 'HOMEAUTO', null)).toEqual([ + 'copy-homeautomation', + 'homeautomation', + ]); + }); + + it('requires every space-separated term to match', () => { + // "home auto" finds homeautomation even though the terms aren't adjacent... + expect(filterProjects(PROJECTS, 'home auto', null)).toEqual([ + 'copy-homeautomation', + 'homeautomation', + ]); + // ...and a term that matches nothing eliminates the row. + expect(filterProjects(PROJECTS, 'home zzz', null)).toEqual([]); + }); + + it('narrows the smoke-test noise that motivated this', () => { + const many = [...PROJECTS, ...Array.from({ length: 300 }, (_, i) => `smoke-proj-none-x${String(i)}`)]; + expect(filterProjects(many, 'smoke', null)).toHaveLength(301); + expect(filterProjects(many, 'ctl', null)).toEqual(['labctl', 'mcpctl-development']); + }); + + it('leaves the caller with an empty list rather than a bad match', () => { + expect(filterProjects(PROJECTS, 'nosuchproject', null)).toEqual([]); + }); + + it('does not mutate the input', () => { + const input = [...PROJECTS]; + filterProjects(input, 'home', 'sre'); + expect(input).toEqual(PROJECTS); + }); +}); + +describe('toolChangeAnnouncement', () => { + it('names the active project and the live tools', () => { + const msg = toolChangeAnnouncement('sre', ['mc_sre_begin_session']); + expect(msg).toContain("active project is now 'sre'"); + expect(msg).toContain('mc_sre_begin_session'); + }); + + it('tells the model to ignore the previous project\'s tool names', () => { + // pi cannot unregister tools, so stale names stay in the conversation and + // the model keeps calling them until told otherwise. + expect(toolChangeAnnouncement('sre', ['mc_sre_x'])).toMatch(/no longer callable|ignore them/); + }); + + it('calls out gating so the model knows to unlock the rest', () => { + const msg = toolChangeAnnouncement('sre', ['mc_sre_begin_session']); + expect(msg).toContain('gated'); + expect(msg).toMatch(/call mc_sre_begin_session first/); + }); + + it('omits the gate hint when the project is not gated', () => { + const msg = toolChangeAnnouncement('docmost', ['mc_docmost_search', 'mc_docmost_get_page']); + expect(msg).not.toContain('gated'); + expect(msg).toContain('mc_docmost_search, mc_docmost_get_page'); + }); + + it('is explicit when a project exposes nothing', () => { + expect(toolChangeAnnouncement('empty', [])).toContain('No mcpctl tools are currently available'); + }); +}); 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/trace.test.ts b/src/cli/tests/trace.test.ts new file mode 100644 index 0000000..bc9f0df --- /dev/null +++ b/src/cli/tests/trace.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createTraceCommand } from '../src/commands/trace.js'; +import type { ApiClient } from '../src/api-client.js'; + +function clientReturning(events: unknown[]): ApiClient { + return { get: vi.fn(async () => ({ events, total: events.length })) } as unknown as ApiClient; +} + +function run(client: ApiClient, args: string[]): Promise { + const lines: string[] = []; + const cmd = createTraceCommand({ client, log: (...a: string[]) => { lines.push(a.join(' ')); } }); + return cmd.parseAsync(['node', 'trace', ...args]).then(() => lines); +} + +const base = { + sessionId: 'abcdef1234567890', projectName: 'sre', source: 'mcplocal', + correlationId: 'K3P7QW2M', userName: 'michal@itaz.eu', +}; + +describe('mcpctl trace', () => { + it('renders a waterfall ordered by time', async () => { + const lines = await run(clientReturning([ + { ...base, timestamp: '2026-08-25T22:00:02.000Z', eventKind: 'tool_call_trace', + payload: { toolName: 'docmost/search', durationMs: 461, resultSizeBytes: 14091, error: null } }, + { ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution', + payload: { stage: 'paginate', durationMs: 12, inputSize: 14091, outputSize: 5632, sectionCount: 2 } }, + ]), ['K3P7QW2M']); + + const out = lines.join('\n'); + expect(out).toContain('Trace K3P7QW2M'); + expect(out).toContain('project sre'); + expect(out).toContain('docmost/search'); + // Sorted: the stage at :00 must precede the tool_call at :02 even though + // the API returned them newest-first. + expect(out.indexOf('paginate')).toBeLessThan(out.indexOf('tool_call_trace')); + }); + + it('flags degraded steps and names the slowest', async () => { + const lines = await run(clientReturning([ + { ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution', + payload: { stage: 'summarize-tree', durationMs: 30001, inputSize: 100, outputSize: 50, + degraded: true, degradedReason: 'stage LLM budget of 30000ms exhausted' } }, + ]), ['K3P7QW2M']); + + const out = lines.join('\n'); + expect(out).toContain('⚠ stage LLM budget of 30000ms exhausted'); + expect(out).toContain('1 degraded'); + expect(out).toContain('slowest: summarize-tree (30001ms)'); + }); + + it('explains an empty result instead of printing nothing', async () => { + const lines = await run(clientReturning([]), ['ZZZZZZZZ']); + const out = lines.join('\n'); + expect(out).toContain("No trace found for 'ZZZZZZZZ'"); + expect(out).toContain('no I/L/O/U'); + }); + + it('--strict exits non-zero on a degraded trace', async () => { + process.exitCode = 0; + await run(clientReturning([ + { ...base, timestamp: '2026-08-25T22:00:00.000Z', eventKind: 'stage_execution', + payload: { stage: 'paginate', durationMs: 1, degraded: true, degradedReason: 'timed out' } }, + ]), ['K3P7QW2M', '--strict']); + expect(process.exitCode).toBe(1); + process.exitCode = 0; + }); + + it('queries mcpd by correlationId', async () => { + const client = clientReturning([]); + await run(client, ['K3P7QW2M']); + expect(client.get).toHaveBeenCalledWith( + expect.stringContaining('/api/v1/audit/events?correlationId=K3P7QW2M'), + ); + }); +}); 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/pi-settings.test.ts b/src/cli/tests/utils/pi-settings.test.ts new file mode 100644 index 0000000..721edae --- /dev/null +++ b/src/cli/tests/utils/pi-settings.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir, homedir } from 'node:os'; +import { agentInstallRoot } from '../../src/commands/skills.js'; +import { + registerWithPi, + installExtensionFiles, + installEmbeddedExtension, +} from '../../src/utils/pi-settings.js'; + +describe('skills agentInstallRoot', () => { + it('maps claude (default) to ~/.claude/skills', () => { + expect(agentInstallRoot(undefined)).toBe(join(homedir(), '.claude', 'skills')); + expect(agentInstallRoot('claude')).toBe(join(homedir(), '.claude', 'skills')); + }); + it('maps pi to ~/.pi/agent/skills', () => { + expect(agentInstallRoot('pi')).toBe(join(homedir(), '.pi', 'agent', 'skills')); + }); + it('maps prime-agent to ~/.prime/agent/skills', () => { + expect(agentInstallRoot('prime-agent')).toBe(join(homedir(), '.prime', 'agent', 'skills')); + }); +}); + +describe('pi-settings util', () => { + let tmp: string; + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'mcpctl-pi-settings-')); + }); + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + it('registers extension + skills dir into an empty settings file', async () => { + const settingsPath = join(tmp, 'settings.json'); + const extPath = join(tmp, 'ext', 'mcpctl-pi.ts'); + const skillsDir = join(tmp, 'skills'); + + const { addedExtensions, addedSkills } = await registerWithPi(settingsPath, extPath, skillsDir); + + expect(addedExtensions).toEqual([extPath]); + expect(addedSkills).toEqual([skillsDir]); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.extensions).toEqual([extPath]); + expect(written.skills).toEqual([skillsDir]); + }); + + it('is idempotent — running twice does not duplicate entries', async () => { + const settingsPath = join(tmp, 'settings.json'); + const extPath = join(tmp, 'ext', 'mcpctl-pi.ts'); + const skillsDir = join(tmp, 'skills'); + + await registerWithPi(settingsPath, extPath, skillsDir); + const second = await registerWithPi(settingsPath, extPath, skillsDir); + + expect(second.addedExtensions).toEqual([]); + expect(second.addedSkills).toEqual([]); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.extensions).toEqual([extPath]); + expect(written.skills).toEqual([skillsDir]); + }); + + it('preserves existing settings keys', async () => { + const settingsPath = join(tmp, 'settings.json'); + mkdirSync(tmp, { recursive: true }); + writeFileSync(settingsPath, JSON.stringify({ theme: 'dark', packages: ['git:foo/bar'] }, null, 2)); + + await registerWithPi(settingsPath, '/abs/ext.ts', '/abs/skills'); + + const written = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(written.theme).toBe('dark'); + expect(written.packages).toEqual(['git:foo/bar']); + expect(written.extensions).toEqual(['/abs/ext.ts']); + expect(written.skills).toEqual(['/abs/skills']); + }); + + it('copies the extension files into the pi extension dir', async () => { + const srcDir = join(tmp, 'src-pi-ext'); + const destDir = join(tmp, 'dest-ext'); + mkdirSync(srcDir, { recursive: true }); + writeFileSync(join(srcDir, 'mcpctl-pi.ts'), '// extension\n'); + writeFileSync(join(srcDir, 'mcp-http.ts'), '// client\n'); + + const written = await installExtensionFiles(srcDir, destDir); + + expect(written).toHaveLength(2); + expect(readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8')).toBe('// extension\n'); + expect(readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8')).toBe('// client\n'); + }); + + it('writes the embedded extension sources (self-contained, no source tree)', async () => { + const destDir = join(tmp, 'embedded-ext'); + + const written = await installEmbeddedExtension(destDir); + + expect(written).toHaveLength(2); + // Both filenames must be written. + expect(written.map((w) => w.replace(destDir + '/', ''))).toEqual(['mcpctl-pi.ts', 'mcp-http.ts']); + // The main file must be non-empty and import the sibling client. + const main = readFileSync(join(destDir, 'mcpctl-pi.ts'), 'utf-8'); + expect(main.length).toBeGreaterThan(1000); + expect(main).toContain('./mcp-http.js'); + const http = readFileSync(join(destDir, 'mcp-http.ts'), 'utf-8'); + expect(http).toContain('McpHttpSession'); + }); +}); diff --git a/src/cli/tests/utils/prime-agent-skills.test.ts b/src/cli/tests/utils/prime-agent-skills.test.ts new file mode 100644 index 0000000..72f14a8 --- /dev/null +++ b/src/cli/tests/utils/prime-agent-skills.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runPrimeAgentSkillsSync } from '../../src/utils/prime-agent-skills.js'; +import { loadState } from '../../src/utils/skills-state.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(overrides: Record = {}): ApiClient { + return { + get: vi.fn(async (url: string) => { + if (url.includes('/skills/visible')) { + return overrides['visible'] ?? []; + } + if (url.startsWith('/api/v1/skills/')) { + const id = url.split('/').pop() as string; + const full = (overrides['full'] as Record)?.[id]; + if (!full) throw new Error(`no full skill for ${id}`); + return full; + } + if (url.endsWith('/skills?scope=global')) { + return overrides['visible'] ?? []; + } + throw new Error(`unexpected get: ${url}`); + }), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +const SKILL_MD = `--- +name: sample-skill +description: A test skill synced into prime-agent. +--- + +# Sample Skill + +Body text. +`; + +describe('runPrimeAgentSkillsSync', () => { + let tmpDir: string; + let installRoot: string; + let statePath: string; + const log = (..._a: unknown[]) => {}; + const warn = (..._a: unknown[]) => {}; + + function deps(client: ApiClient) { + return { client, log, warn }; + } + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-pa-sync-')); + installRoot = join(tmpDir, 'skills'); + statePath = join(tmpDir, 'skills-state.json'); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('installs a new markdown skill into the prime-agent skills root', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + const result = await runPrimeAgentSkillsSync( + { project: 'proj', installRoot, statePath }, + deps(client), + ); + + expect(result.installed).toEqual(['sample-skill']); + expect(result.errors).toEqual([]); + + const skillDir = join(installRoot, 'sample-skill'); + expect(existsSync(skillDir)).toBe(true); + expect(readFileSync(join(skillDir, 'SKILL.md'), 'utf-8')).toBe(SKILL_MD); + + // State persisted so a re-sync is a no-op. + const state = await loadState(statePath); + expect(state.skills['sample-skill'].contentHash).toBe('sha256:h1'); + }); + + it('skips unchanged skills on re-sync', async () => { + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: SKILL_MD, files: {} }, + }; + const client = mockClient({ visible, full }); + + await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + + expect(result.skipped).toEqual(['sample-skill']); + expect(result.installed).toEqual([]); + }); + + it('syncs the global set when no project is provided', async () => { + const visible = [ + { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', metadata: {}, scope: 'global' }, + ]; + const full = { + 'skill-2': { id: 'skill-2', name: 'global-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g1', content: '# global\n', files: {} }, + }; + const client = mockClient({ visible, full }); + + // Isolate cwd so no stray .mcpctl-project marker is discovered. + const empty = join(tmpDir, 'empty'); + mkdirSync(empty, { recursive: true }); + + const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client)); + + expect(result.installed).toEqual(['global-skill']); + const getCalls = (client.get as ReturnType).mock.calls.map((c) => String(c[0])); + expect(getCalls.some((u) => u.includes('scope=global'))).toBe(true); + }); + + it('preserves an untracked pre-existing skill dir on first sync (no rm -rf)', async () => { + const existing = join(installRoot, 'sample-skill'); + mkdirSync(existing, { recursive: true }); + writeFileSync(join(existing, 'SKILL.md'), '# hand-authored\n', 'utf-8'); + + const visible = [ + { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', metadata: {}, scope: 'project' }, + ]; + const full = { + 'skill-1': { id: 'skill-1', name: 'sample-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:h1', content: '# server content\n', files: {} }, + }; + const client = mockClient({ visible, full }); + + const result = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client)); + + expect(result.preserved).toContain('sample-skill'); + expect(result.installed).toEqual([]); + // The hand-authored content is untouched. + expect(readFileSync(join(existing, 'SKILL.md'), 'utf-8')).toBe('# hand-authored\n'); + }); + + it('does not delete another project\'s skills when configuring a second project', async () => { + // Project A installs a skill. + const av = [ + { id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'a-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# a\n', files: {} } }; + const clientA = mockClient({ visible: av, full: af }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect(existsSync(join(installRoot, 'a-skill'))).toBe(true); + + // Project B syncs with a totally different skill set. + const bv = [ + { id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' }, + ]; + const bf = { 'b-1': { id: 'b-1', name: 'b-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# b\n', files: {} } }; + const clientB = mockClient({ visible: bv, full: bf }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + // B should neither remove A\'s skill nor claim it was removed. + expect(resultB.removed).toEqual([]); + expect(existsSync(join(installRoot, 'a-skill'))).toBe(true); + expect(existsSync(join(installRoot, 'b-skill'))).toBe(true); + }); + + it('removes an orphaned skill that belongs to the same project', async () => { + const v = [ + { id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', metadata: {}, scope: 'project' }, + ]; + const f = { 'x-1': { id: 'x-1', name: 'old-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:x', content: '# old\n', files: {} } }; + const client1 = mockClient({ visible: v, full: f }); + await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client1)); + expect(existsSync(join(installRoot, 'old-skill'))).toBe(true); + + // Next sync for the same project: the skill is gone from the visible set. + const client2 = mockClient({ visible: [], full: {} }); + const result2 = await runPrimeAgentSkillsSync({ project: 'proj', installRoot, statePath }, deps(client2)); + + expect(result2.removed).toContain('old-skill'); + expect(existsSync(join(installRoot, 'old-skill'))).toBe(false); + }); + + it('does not overwrite a same-named skill owned by a different project', async () => { + // Project A installs skill X. + const av = [ + { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } }; + const clientA = mockClient({ visible: av, full: af }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + // Frontmatter is synthesised on write, so assert the body, not exact bytes. + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a'); + + // Project B also has a skill named X with different content. + const bv = [ + { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', metadata: {}, scope: 'project' }, + ]; + const bf = { 'b-1': { id: 'b-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:b', content: '# version-b\n', files: {} } }; + const clientB = mockClient({ visible: bv, full: bf }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + expect(resultB.preserved).toContain('x-skill'); + // A's version is untouched (not clobbered by B's). + // Frontmatter is synthesised on write, so assert the body, not exact bytes. + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a'); + }); + + it('does not delete legacy, ownership-less state belonging to another project', async () => { + // State written by a CLI that predates the `project` field: the skill has + // no recorded owner and the file records projA as the last syncing project. + const legacyDir = join(installRoot, 'legacy-skill'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8'); + writeFileSync(statePath, JSON.stringify({ + schemaVersion: 1, + lastSync: '2026-01-01T00:00:00.000Z', + lastSyncProject: 'projA', + skills: { + 'legacy-skill': { + id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project', + installDir: legacyDir, files: {}, postInstallHash: null, + lastSyncedAt: '2026-01-01T00:00:00.000Z', + // note: no `project` field + }, + }, + }), 'utf-8'); + + // First sync after upgrading, for a *different* project. + const client = mockClient({ visible: [], full: {} }); + const result = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(client)); + + expect(result.removed).toEqual([]); + expect(existsSync(legacyDir)).toBe(true); + }); + + it('cleans up legacy state once the owning project syncs again', async () => { + const legacyDir = join(installRoot, 'legacy-skill'); + mkdirSync(legacyDir, { recursive: true }); + writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8'); + writeFileSync(statePath, JSON.stringify({ + schemaVersion: 1, + lastSync: '2026-01-01T00:00:00.000Z', + lastSyncProject: 'projA', + skills: { + 'legacy-skill': { + id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project', + installDir: legacyDir, files: {}, postInstallHash: null, + lastSyncedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }), 'utf-8'); + + const client = mockClient({ visible: [], full: {} }); + const result = await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(client)); + + expect(result.removed).toContain('legacy-skill'); + expect(existsSync(legacyDir)).toBe(false); + }); + + it('keeps global skills updatable after switching projects', async () => { + // A global installed while projA was active must not be pinned to projA — + // globals are visible from every project. + const gv = (hash: string) => [ + { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, metadata: {}, scope: 'global' }, + ]; + const gf = (hash: string, body: string) => ({ + 'g-1': { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, content: body, files: {} }, + }); + + const clientA = mockClient({ visible: gv('sha256:v1'), full: gf('sha256:v1', '# v1\n') }); + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA)); + expect((await loadState(statePath)).skills['shared-global']?.project).toBeNull(); + + // Switch to projB; the global has been updated server-side. + const clientB = mockClient({ visible: gv('sha256:v2'), full: gf('sha256:v2', '# v2\n') }); + const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB)); + + expect(resultB.updated).toContain('shared-global'); + expect(resultB.preserved).toEqual([]); + expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toContain('# v2'); + }); + + it('does not let a global-only sync clobber a project-owned skill', async () => { + const av = [ + { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' }, + ]; + const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } }; + await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(mockClient({ visible: av, full: af }))); + + // A global of the same name shows up on a global-only sync. + const gv = [ + { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' }, + ]; + const gf = { 'g-9': { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# global\n', files: {} } }; + const empty = join(tmpDir, 'empty3'); + mkdirSync(empty, { recursive: true }); + const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(mockClient({ visible: gv, full: gf }))); + + expect(result.preserved).toContain('x-skill'); + // Frontmatter is synthesised on write, so assert the body, not exact bytes. + expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toContain('# version-a'); + }); + + it('removes global orphans on a global-only sync', async () => { + // First sync a global skill. + const v = [ + { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' }, + ]; + const f = { 'g-1': { id: 'g-1', name: 'gone-global', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# g\n', files: {} } }; + const client1 = mockClient({ visible: v, full: f }); + const empty = join(tmpDir, 'empty2'); + mkdirSync(empty, { recursive: true }); + await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client1)); + expect(existsSync(join(installRoot, 'gone-global'))).toBe(true); + + // Next global-only sync: the global is gone from the visible set. + const client2 = mockClient({ visible: [], full: {} }); + const result2 = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(client2)); + expect(result2.removed).toContain('gone-global'); + expect(existsSync(join(installRoot, 'gone-global'))).toBe(false); + }); +}); 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/tests/utils/skill-frontmatter.test.ts b/src/cli/tests/utils/skill-frontmatter.test.ts new file mode 100644 index 0000000..dce0a11 --- /dev/null +++ b/src/cli/tests/utils/skill-frontmatter.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { ensureSkillFrontmatter } from '../../src/utils/skills-disk.js'; + +/** + * mcpd stores name/description as columns; a skill's `content` is often just + * the body. Written verbatim it lands on disk without the frontmatter every + * Agent Skills host requires — prime-agent logs "description is required" and + * refuses to load it. Observed live with the `propose-learnings` skill. + */ +describe('ensureSkillFrontmatter', () => { + it('adds frontmatter to a body that has none', () => { + const out = ensureSkillFrontmatter('# propose-learnings\n\nBody text.\n', 'propose-learnings', 'How to capture knowledge.'); + expect(out).toBe( + '---\nname: "propose-learnings"\ndescription: "How to capture knowledge."\n---\n\n# propose-learnings\n\nBody text.\n', + ); + }); + + it('leaves an existing complete header untouched', () => { + const original = '---\nname: commit\ndescription: Commit changes\n---\n\n# commit\n'; + expect(ensureSkillFrontmatter(original, 'commit', 'something else')).toBe(original); + }); + + it('fills in only the missing key, preserving the author\'s other fields', () => { + const out = ensureSkillFrontmatter('---\nname: x\nallowed-tools: Bash\n---\n\nBody\n', 'x', 'The description'); + expect(out).toContain('allowed-tools: Bash'); + expect(out).toContain('description: "The description"'); + // name was already there — not duplicated + expect(out.match(/name:/g)).toHaveLength(1); + expect(out).toContain('\n\nBody\n'); + }); + + it('quotes descriptions containing YAML metacharacters', () => { + // A bare scalar breaks on ':' and '#'; these are common in descriptions. + const out = ensureSkillFrontmatter('body', 'n', 'Use when: debugging #hard problems'); + expect(out).toContain('description: "Use when: debugging #hard problems"'); + }); + + it('escapes quotes and newlines rather than emitting invalid YAML', () => { + const out = ensureSkillFrontmatter('body', 'n', 'He said "hi"\nthen left'); + expect(out).toContain('description: "He said \\"hi\\"\\nthen left"'); + }); + + it('handles CRLF frontmatter delimiters', () => { + const out = ensureSkillFrontmatter('---\r\nname: x\r\n---\r\nBody', 'x', 'D'); + expect(out).toContain('description: "D"'); + expect(out).toContain('Body'); + }); + + it('still emits the key when the description is empty', () => { + // Can't invent one, but the key must exist for the host to parse it. + expect(ensureSkillFrontmatter('body', 'n', '')).toContain('description: ""'); + }); +}); 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/prisma/migrations/20260809120000_add_server_volumes/migration.sql b/src/db/prisma/migrations/20260809120000_add_server_volumes/migration.sql new file mode 100644 index 0000000..c87371a --- /dev/null +++ b/src/db/prisma/migrations/20260809120000_add_server_volumes/migration.sql @@ -0,0 +1,14 @@ +-- Persistent volumes for MCP servers. +-- +-- Instances are immutable and get recreated on any server edit, so anything an +-- MCP server writes to its container filesystem was previously lost at that +-- point. A volume is declared on the *server* and backed by a Docker named +-- volume or a Kubernetes PVC whose identity is derived from the server name, +-- so it outlives the instances that mount it. +-- +-- Shape: [{ "name": "data", "mountPath": "/data", "sizeGb": 10, +-- "storageClass": "longhorn" }] +-- +-- Defaults to an empty array, so existing rows keep today's behaviour. +ALTER TABLE "McpServer" ADD COLUMN "volumes" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "McpTemplate" ADD COLUMN "volumes" JSONB NOT NULL DEFAULT '[]'; diff --git a/src/db/prisma/migrations/20260820234500_add_server_secret_delivery/migration.sql b/src/db/prisma/migrations/20260820234500_add_server_secret_delivery/migration.sql new file mode 100644 index 0000000..900e905 --- /dev/null +++ b/src/db/prisma/migrations/20260820234500_add_server_secret_delivery/migration.sql @@ -0,0 +1,12 @@ +-- Per-server secret delivery mode. +-- +-- 'env' preserves today's behaviour exactly: mcpd resolves secret refs and +-- writes the VALUES into the pod spec. Defaulting to it means every existing +-- server is unchanged by this migration, and migration to 'injector' is an +-- explicit, reversible, per-server decision. +ALTER TABLE "McpServer" ADD COLUMN "secretDelivery" TEXT NOT NULL DEFAULT 'env'; + +-- argv to exec after the injector's rendered secrets are sourced. Only needed +-- for dockerImage servers, where the image ENTRYPOINT is what would otherwise +-- run and mcpd cannot introspect it. +ALTER TABLE "McpServer" ADD COLUMN "entrypoint" JSONB; diff --git a/src/db/prisma/migrations/20260826000000_add_server_toolcall_timeout/migration.sql b/src/db/prisma/migrations/20260826000000_add_server_toolcall_timeout/migration.sql new file mode 100644 index 0000000..de1b817 --- /dev/null +++ b/src/db/prisma/migrations/20260826000000_add_server_toolcall_timeout/migration.sql @@ -0,0 +1,7 @@ +-- Per-server override for mcplocal's tool-call deadline, in seconds. +-- +-- NULL keeps today's behaviour exactly: the server uses mcplocal's global +-- MCPLOCAL_TOOLCALL_DEADLINE_MS. Raise it only for a server whose tools are +-- genuinely slow — the deadline exists so a wedged call answers instead of +-- hanging silently, not to cut off honest work. +ALTER TABLE "McpServer" ADD COLUMN "toolCallTimeoutSeconds" INTEGER; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 7e4e636..4ec2538 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -77,9 +77,30 @@ model McpServer { replicas Int @default(1) env Json @default("[]") healthCheck Json? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + volumes Json @default("[]") + + /// How secret-backed env reaches the container. + /// env — mcpd resolves values and writes them into the pod spec. + /// Cleartext in etcd, readable by anyone with `get pod`. + /// injector — the pod fetches its own secrets from OpenBao via the agent + /// injector, using a ServiceAccount + role scoped to just this + /// server's secrets. The value never touches the pod spec. + /// Defaults to `env` so existing servers are bit-for-bit unchanged. + secretDelivery String @default("env") + + /// argv the injector wrapper must exec after sourcing the rendered secrets. + /// Only needed for dockerImage servers using `injector`, where the image's + /// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it. + entrypoint Json? + + /// Per-server override for mcplocal's tool-call deadline, in seconds. + /// Null = use MCPLOCAL_TOOLCALL_DEADLINE_MS. Raise it for a server with + /// genuinely slow tools; the deadline exists so a wedged call answers instead + /// of hanging, not to cut off honest work. + toolCallTimeoutSeconds Int? + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt templateName String? templateVersion String? @@ -114,8 +135,23 @@ model McpTemplate { replicas Int @default(1) env Json @default("[]") healthCheck Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + volumes Json @default("[]") + + /// How secret-backed env reaches the container. + /// env — mcpd resolves values and writes them into the pod spec. + /// Cleartext in etcd, readable by anyone with `get pod`. + /// injector — the pod fetches its own secrets from OpenBao via the agent + /// injector, using a ServiceAccount + role scoped to just this + /// server's secrets. The value never touches the pod spec. + /// Defaults to `env` so existing servers are bit-for-bit unchanged. + secretDelivery String @default("env") + + /// argv the injector wrapper must exec after sourcing the rendered secrets. + /// Only needed for dockerImage servers using `injector`, where the image's + /// own ENTRYPOINT is what would otherwise run and mcpd cannot introspect it. + entrypoint Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([name]) } diff --git a/src/db/src/seed/index.ts b/src/db/src/seed/index.ts index e8872e3..492c6af 100644 --- a/src/db/src/seed/index.ts +++ b/src/db/src/seed/index.ts @@ -16,11 +16,25 @@ export interface HealthCheckSpec { failureThreshold?: number; } +export interface SeedVolumeSpec { + name: string; + mountPath: string; + sizeGb?: number; + storageClass?: string; +} + export interface SeedTemplate { name: string; version: string; description: string; packageName?: string; + /** + * 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; transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP'; repositoryUrl?: string; @@ -30,6 +44,7 @@ export interface SeedTemplate { replicas?: number; env?: TemplateEnvEntry[]; healthCheck?: HealthCheckSpec; + volumes?: SeedVolumeSpec[]; } export async function seedTemplates( @@ -45,6 +60,7 @@ export async function seedTemplates( version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, @@ -54,12 +70,14 @@ export async function seedTemplates( replicas: tpl.replicas ?? 1, env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue, healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue, + volumes: (tpl.volumes ?? []) as unknown as Prisma.InputJsonValue, }, create: { name: tpl.name, version: tpl.version, description: tpl.description, packageName: tpl.packageName ?? null, + runtime: tpl.runtime ?? null, dockerImage: tpl.dockerImage ?? null, transport: tpl.transport, repositoryUrl: tpl.repositoryUrl ?? null, @@ -69,6 +87,7 @@ export async function seedTemplates( replicas: tpl.replicas ?? 1, env: (tpl.env ?? []) as unknown as Prisma.InputJsonValue, healthCheck: (tpl.healthCheck ?? Prisma.JsonNull) as unknown as Prisma.InputJsonValue, + volumes: (tpl.volumes ?? []) as unknown as Prisma.InputJsonValue, }, }); upserted++; diff --git a/src/mcpd/src/bootstrap/warm-secret-cache.ts b/src/mcpd/src/bootstrap/warm-secret-cache.ts new file mode 100644 index 0000000..665f984 --- /dev/null +++ b/src/mcpd/src/bootstrap/warm-secret-cache.ts @@ -0,0 +1,67 @@ +/** + * One-shot: resolve every secret that a running server depends on, so the + * value cache holds a last-known-good copy before anything needs it. + * + * The caching driver absorbs a backend outage by serving the last value it saw + * — but only for secrets it has actually seen. Without this, a cold mcpd (fresh + * deploy, pod reschedule, crash-restart) has an empty cache, and if the backend + * is unreachable at that moment every secret-bearing server fails to start. + * + * This is the honest mitigation, and it is deliberately partial: if the backend + * is ALSO down at boot, this changes nothing and instances fail loudly, which + * is correct. The alternatives — persisting last-known-good to Postgres or to + * disk — are just "plaintext secrets at rest" wearing a hat, which is the thing + * we are trying to move away from. + * + * Best-effort by construction: a failure here must never block startup, and the + * warm is per-secret so one bad reference doesn't abandon the rest. + */ +import type { PrismaClient } from '@prisma/client'; +import type { SecretService } from '../services/secret.service.js'; +import type { ServerEnvEntry } from '../validation/mcp-server.schema.js'; + +export interface WarmLog { + info: (msg: string) => void; + warn: (msg: string) => void; +} + +export async function warmSecretCache( + prisma: PrismaClient, + secrets: SecretService, + log: WarmLog, +): Promise<{ warmed: number; failed: number }> { + const servers = await prisma.mcpServer.findMany({ + where: { replicas: { gt: 0 } }, + select: { name: true, env: true }, + }); + + // Distinct (secret, key) pairs — several servers commonly share one secret, + // and there is no point paying for the same read more than once. + const refs = new Map(); + for (const server of servers) { + for (const entry of (server.env ?? []) as ServerEnvEntry[]) { + const ref = entry.valueFrom?.secretRef; + if (ref === undefined) continue; + refs.set(`${ref.name}/${ref.key}`, { name: ref.name, key: ref.key }); + } + } + if (refs.size === 0) return { warmed: 0, failed: 0 }; + + let warmed = 0; + let failed = 0; + for (const ref of refs.values()) { + try { + // Value deliberately discarded — we only want it in the cache. + await secrets.resolve(ref.name, ref.key); + warmed++; + } catch { + // Expected when the backend is down, or when a server references a + // secret that no longer exists. Neither should block startup, and both + // surface loudly at instance-start time anyway. + failed++; + } + } + + log.info(`secret cache warm: ${String(warmed)} resolved, ${String(failed)} unavailable`); + return { warmed, failed }; +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 3e7b470..a4603d4 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -26,11 +26,16 @@ import { SecretMigrateService } from './services/secret-migrate.service.js'; import { backfillInferenceTaskBindings } from './bootstrap/rbac-tasks-backfill.js'; import { bootstrapSecretBackends } from './bootstrap/secret-backends.js'; import { backfillSecretKeyNames } from './bootstrap/secret-key-names.js'; +import { ServerIdentityService } from './services/server-identity.service.js'; +import { K8sServiceAccountPort } from './services/k8s/service-account-port.js'; +import { K8sOfficialClient } from './services/k8s/k8s-client-official.js'; +import { warmSecretCache } from './bootstrap/warm-secret-cache.js'; import { registerSecretBackendRoutes } from './routes/secret-backends.js'; import { registerSecretMigrateRoutes } from './routes/secret-migrate.js'; import { SecretBackendRotator } from './services/secret-backend-rotator.service.js'; import { SecretBackendRotatorLoop } from './services/secret-backend-rotator-loop.js'; import { registerSecretBackendRotateRoutes } from './routes/secret-backend-rotate.js'; +import { registerSecretBackendHealthRoutes } from './routes/secret-backend-health.js'; import { LlmRepository } from './repositories/llm.repository.js'; import { LlmService } from './services/llm.service.js'; import { InferenceTaskRepository } from './repositories/inference-task.repository.js'; @@ -338,16 +343,32 @@ async function main(): Promise { }, }, secretRefResolver: secretResolverBridge, + }, { + // Cache-transition events go through pino so BACKEND_UNREACHABLE / + // BACKEND_RECOVERED land in ErrorLogBuffer and `mcpctl errors`. + log: { + warn: (obj: Record, msg: string): void => { app.log.warn(obj, msg); }, + info: (obj: Record, msg: string): void => { app.log.info(obj, msg); }, + }, }); const secretService = new SecretService(secretRepo, secretBackendService); const secretMigrateService = new SecretMigrateService(secretRepo, secretBackendService); const secretBackendRotator = new SecretBackendRotator({ backends: secretBackendService, secrets: secretService, + log: { + error: (obj: Record, msg: string): void => { app.log.error(obj, msg); }, + warn: (msg: string): void => { app.log.warn(msg); }, + }, }); const secretBackendRotatorLoop = new SecretBackendRotatorLoop({ backends: secretBackendService, rotator: secretBackendRotator, + log: { + info: (msg: string): void => { app.log.info(`[rotator] ${msg}`); }, + warn: (msg: string): void => { app.log.warn(`[rotator] ${msg}`); }, + error: (obj: Record, msg: string): void => { app.log.error(obj, msg); }, + }, }); const llmAdapters = new LlmAdapterRegistry(); // LlmService takes the adapter registry so create()/update() can run an @@ -365,8 +386,38 @@ async function main(): Promise { // AgentService + ChatService get fully wired below once projectService and // mcpProxyService are constructed (ChatService needs them via the // ChatToolDispatcher bridge). - const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService); + // Per-server OpenBao identities, for servers with `secretDelivery: injector`. + // Kubernetes-only: it needs a ServiceAccount per server and an orchestrator + // that can honour secret references. Left undefined elsewhere, which makes + // InstanceService fall back to inline resolution — so Docker/Podman local + // development is unaffected. + const serverIdentityService = process.env['MCPD_ORCHESTRATOR'] === 'kubernetes' + ? (() => { + const k8s = new K8sOfficialClient(); + return new ServerIdentityService( + secretBackendService, + new K8sServiceAccountPort(k8s.core, k8s.serversNamespace), + { + info: (obj: Record, msg: string): void => { app.log.info(obj, msg); }, + warn: (obj: Record, msg: string): void => { app.log.warn(obj, msg); }, + }, + { + // Must be the mount that validates THIS cluster's SA tokens; the + // chart default (`auth/kubernetes`) belongs to a different cluster + // and holds none of our roles. + authPath: process.env['MCPD_SECRET_INJECTOR_AUTH_PATH'] ?? 'auth/kubernetes', + mount: process.env['MCPD_SECRET_INJECTOR_MOUNT'] ?? 'secret', + pathPrefix: process.env['MCPD_SECRET_INJECTOR_PREFIX'] ?? 'mcpctl', + }, + ); + })() + : undefined; + + const instanceService = new InstanceService( + instanceRepo, serverRepo, orchestrator, secretService, serverIdentityService, + ); serverService.setInstanceService(instanceService); + if (serverIdentityService !== undefined) serverService.setServerIdentity(serverIdentityService); const projectService = new ProjectService(projectRepo, serverRepo); const auditLogService = new AuditLogService(auditLogRepo); const auditEventService = new AuditEventService(auditEventRepo); @@ -375,6 +426,10 @@ async function main(): Promise { const authService = new AuthService(prisma); const templateService = new TemplateService(templateRepo); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator); + // When syncStatus observes a container restart/replacement, the cached + // STDIO pipe under that containerId is dead — evict it so the next call + // redials (mcpctl#114). Setter injection, same as setInstanceService above. + instanceService.setStdioInvalidator((cid) => mcpProxyService.removeClient(cid)); const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo); const rbacService = new RbacService(rbacDefinitionRepo, prisma); const mcpTokenService = new McpTokenService(mcpTokenRepo, projectRepo, rbacDefinitionRepo, rbacService); @@ -566,6 +621,7 @@ async function main(): Promise { registerSecretRoutes(app, secretService); registerSecretBackendRoutes(app, secretBackendService); registerSecretBackendRotateRoutes(app, secretBackendRotator); + registerSecretBackendHealthRoutes(app, secretBackendService); registerSecretMigrateRoutes(app, secretMigrateService); registerLlmRoutes(app, llmService); registerAgentRoutes(app, agentService); @@ -885,6 +941,18 @@ async function main(): Promise { app.log.error({ err }, 'secret keyNames backfill failed'); }); + // One-shot: pre-populate the secret value cache so a later backend outage is + // absorbed rather than cascading into instance ERROR loops. Best-effort — if + // the backend is already down at boot this is a no-op and instances fail + // honestly. See bootstrap/warm-secret-cache.ts. + warmSecretCache( + prisma, + secretService, + { info: (m: string): void => { app.log.info(m); }, warn: (m: string): void => { app.log.warn(m); } }, + ).catch((err: unknown) => { + app.log.warn({ err }, 'secret cache warm failed (non-fatal)'); + }); + // Graceful shutdown setupGracefulShutdown(app, { disconnectDb: async () => { @@ -894,6 +962,7 @@ async function main(): Promise { healthProbeRunner.stop(); secretBackendRotatorLoop.stop(); gitBackup.stop(); + mcpProxyService.closeAll(); await prisma.$disconnect(); }, }); diff --git a/src/mcpd/src/middleware/url-permission.ts b/src/mcpd/src/middleware/url-permission.ts index 1ce5483..d9546e0 100644 --- a/src/mcpd/src/middleware/url-permission.ts +++ b/src/mcpd/src/middleware/url-permission.ts @@ -45,6 +45,9 @@ export function mapUrlToPermission(method: string, url: string): PermissionCheck if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; + // Same operation guards the trace-event purge — both are bulk deletes of + // audit history and should not be separately grantable by accident. + if (url.startsWith('/api/v1/audit/events/purge')) return { kind: 'operation', operation: 'audit-purge' }; // /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write. if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' }; // /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so diff --git a/src/mcpd/src/repositories/audit-event.repository.ts b/src/mcpd/src/repositories/audit-event.repository.ts index f5e1aa9..47dd78f 100644 --- a/src/mcpd/src/repositories/audit-event.repository.ts +++ b/src/mcpd/src/repositories/audit-event.repository.ts @@ -170,6 +170,17 @@ export class AuditEventRepository implements IAuditEventRepository { }); return groups.length; } + + /** + * Prune old events. AuditEvent had no retention at all while the table was + * write-only; now that `mcpctl trace` reads it, it is worth bounding. + */ + async deleteOlderThan(cutoff: Date): Promise { + const result = await this.prisma.auditEvent.deleteMany({ + where: { timestamp: { lt: cutoff } }, + }); + return result.count; + } } function buildWhere(filter?: AuditEventFilter): Prisma.AuditEventWhereInput { diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index 6d94852..7fb87bd 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -108,6 +108,8 @@ export interface IAuditEventRepository { countSessions(filter?: { projectName?: string; userName?: string; from?: Date; to?: Date }): Promise; /** Rank tools by invocation count for a project (from tool_call_trace events). */ toolUsage(projectName: string, from: Date, sampleLimit?: number): Promise; + /** Delete events older than `cutoff`; returns the number removed. */ + deleteOlderThan(cutoff: Date): Promise; } // ── MCP Tokens ── diff --git a/src/mcpd/src/repositories/mcp-server.repository.ts b/src/mcpd/src/repositories/mcp-server.repository.ts index 0f668c7..b5edd92 100644 --- a/src/mcpd/src/repositories/mcp-server.repository.ts +++ b/src/mcpd/src/repositories/mcp-server.repository.ts @@ -33,6 +33,10 @@ export class McpServerRepository implements IMcpServerRepository { replicas: data.replicas, env: data.env, healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue, + volumes: data.volumes, + secretDelivery: data.secretDelivery, + entrypoint: (data.entrypoint ?? Prisma.DbNull) as Prisma.InputJsonValue, + toolCallTimeoutSeconds: data.toolCallTimeoutSeconds ?? null, }, }); } @@ -51,6 +55,10 @@ export class McpServerRepository implements IMcpServerRepository { if (data.replicas !== undefined) updateData['replicas'] = data.replicas; if (data.env !== undefined) updateData['env'] = data.env; if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue; + if (data.volumes !== undefined) updateData['volumes'] = data.volumes; + if (data.secretDelivery !== undefined) updateData['secretDelivery'] = data.secretDelivery; + if (data.entrypoint !== undefined) updateData['entrypoint'] = (data.entrypoint ?? Prisma.JsonNull) as Prisma.InputJsonValue; + if (data.toolCallTimeoutSeconds !== undefined) updateData['toolCallTimeoutSeconds'] = data.toolCallTimeoutSeconds; return this.prisma.mcpServer.update({ where: { id }, data: updateData }); } diff --git a/src/mcpd/src/repositories/template.repository.ts b/src/mcpd/src/repositories/template.repository.ts index f5bdd3a..7be1b9d 100644 --- a/src/mcpd/src/repositories/template.repository.ts +++ b/src/mcpd/src/repositories/template.repository.ts @@ -52,6 +52,7 @@ export class TemplateRepository implements ITemplateRepository { replicas: data.replicas, env: (data.env ?? []) as unknown as Prisma.InputJsonValue, healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue, + volumes: (data.volumes ?? []) as unknown as Prisma.InputJsonValue, }, }); } @@ -71,6 +72,7 @@ export class TemplateRepository implements ITemplateRepository { if (data.replicas !== undefined) updateData.replicas = data.replicas; if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue; if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue; + if (data.volumes !== undefined) updateData.volumes = (data.volumes ?? []) as Prisma.InputJsonValue; return this.prisma.mcpTemplate.update({ where: { id }, diff --git a/src/mcpd/src/routes/audit-events.ts b/src/mcpd/src/routes/audit-events.ts index 1223ce1..32d56af 100644 --- a/src/mcpd/src/routes/audit-events.ts +++ b/src/mcpd/src/routes/audit-events.ts @@ -58,6 +58,15 @@ export function registerAuditEventRoutes(app: FastifyInstance, service: AuditEve return service.getById(request.params.id); }); + // POST /api/v1/audit/events/purge — drop events past the retention window. + // Mirrors /api/v1/audit-logs/purge: triggered, not scheduled, so an operator + // (or a cron) decides when a potentially large delete runs. + app.post('/api/v1/audit/events/purge', async (_request, reply) => { + const deleted = await service.purgeExpired(); + reply.code(200); + return { deleted }; + }); + // GET /api/v1/audit/tool-usage — rank tools by invocation count (for favourites) app.get<{ Querystring: { projectName?: string; window?: string; limit?: string } }>('/api/v1/audit/tool-usage', async (request, reply) => { const q = request.query; diff --git a/src/mcpd/src/routes/secret-backend-health.ts b/src/mcpd/src/routes/secret-backend-health.ts new file mode 100644 index 0000000..8a0e457 --- /dev/null +++ b/src/mcpd/src/routes/secret-backend-health.ts @@ -0,0 +1,76 @@ +/** + * GET /api/v1/secretbackends/:id/health — a live probe of a secret backend. + * + * Exists because the only health signal we had was `tokenMeta.lastRotationError`, + * and the rotator writes that field ONLY for `auth: 'token'` backends + * (`SecretBackendRotator.isRotatable()`). A `kubernetes`-auth backend therefore + * never wrote it and rendered a hard-coded green tick in `mcpctl status` — even + * with OpenBao completely unreachable. + * + * Two signals, deliberately separate, mirroring the liveness/readiness split + * that instance health probes already use: + * + * live — is the backend reachable at all? (unauthenticated) + * ready — can we actually read through it? (uses our credentials) + * + * A backend that is `live` but not `ready` is the exact shape of a re-initialised + * OpenBao that left us holding valid-looking tokens granting nothing. Collapsing + * the two into one boolean is what hid that for four days. + * + * RBAC: no special mapping needed — `mapUrlToPermission` falls through to the + * generic `secretbackends` resource, so a GET requires `view:secretbackends`. + */ +import type { FastifyInstance } from 'fastify'; +import type { SecretBackendService } from '../services/secret-backend.service.js'; +import { NotFoundError } from '../services/mcp-server.service.js'; + +interface TokenMetaShape { + lastRotationAt?: string; + lastRotationError?: string | null; + rotatable?: boolean; +} + +export function registerSecretBackendHealthRoutes( + app: FastifyInstance, + backends: SecretBackendService, +): void { + app.get<{ Params: { id: string } }>('/api/v1/secretbackends/:id/health', async (request, reply) => { + try { + const backend = await backends.getById(request.params.id); + const driver = backends.driverFor(backend); + + const live = await driver.healthCheck?.() ?? { ok: true, detail: 'no probe' }; + // Only probe readiness if the backend answered at all — otherwise the + // auth check just re-reports the same outage with a confusing message. + const ready = live.ok + ? await driver.authCheck?.() ?? { ok: true, detail: 'no probe' } + : { ok: false, detail: 'not probed (backend unreachable)' }; + + const meta = (backend.tokenMeta ?? {}) as TokenMetaShape; + const cache = backends.cacheStatsFor(backend); + + return { + backend: backend.name, + type: backend.type, + live: live.ok, + liveDetail: live.detail, + ready: ready.ok, + readyDetail: ready.detail, + // Present only for cached (remote) backends; plaintext has no cache. + cache: cache ?? null, + rotation: { + rotatable: meta.rotatable ?? false, + lastRotationAt: meta.lastRotationAt ?? null, + lastRotationError: meta.lastRotationError ?? null, + }, + }; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + reply.code(502); + return { error: err instanceof Error ? err.message : String(err) }; + } + }); +} diff --git a/src/mcpd/src/services/audit-event.service.ts b/src/mcpd/src/services/audit-event.service.ts index 488a6a1..63f6f64 100644 --- a/src/mcpd/src/services/audit-event.service.ts +++ b/src/mcpd/src/services/audit-event.service.ts @@ -17,8 +17,31 @@ export interface AuditEventQueryParams { offset?: number; } +/** + * Default retention for trace/audit events. + * + * Shorter than AuditLog's 90 days: these are high-volume per-request telemetry + * (several rows per MCP call), not a record of administrative mutations. + */ +const DEFAULT_RETENTION_DAYS = 30; + export class AuditEventService { - constructor(private readonly repo: IAuditEventRepository) {} + constructor( + private readonly repo: IAuditEventRepository, + private readonly retentionDays: number = + Number(process.env['MCPD_AUDIT_EVENT_RETENTION_DAYS']) || DEFAULT_RETENTION_DAYS, + ) {} + + /** + * Drop events past the retention window. The table previously had no prune + * at all and grew unbounded; `mcpctl trace` now reads it, so bounding it + * matters more than it did when nothing consumed it. + */ + async purgeExpired(): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - this.retentionDays); + return this.repo.deleteOlderThan(cutoff); + } async list(params?: AuditEventQueryParams): Promise<{ events: AuditEvent[]; total: number }> { const filter = this.buildFilter(params); diff --git a/src/mcpd/src/services/backup/backup-service.ts b/src/mcpd/src/services/backup/backup-service.ts index d3b1f89..5311d1f 100644 --- a/src/mcpd/src/services/backup/backup-service.ts +++ b/src/mcpd/src/services/backup/backup-service.ts @@ -39,6 +39,10 @@ export interface BackupServer { replicas: number; env: unknown; healthCheck: unknown; + volumes: unknown; + /** Optional so backups taken before the field remain restorable. */ + secretDelivery?: string; + entrypoint?: unknown; } export interface BackupSecret { @@ -140,6 +144,9 @@ export class BackupService { replicas: s.replicas, env: s.env, healthCheck: s.healthCheck, + volumes: s.volumes, + secretDelivery: s.secretDelivery, + entrypoint: s.entrypoint, })); } diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts index feed096..a710bbc 100644 --- a/src/mcpd/src/services/backup/restore-service.ts +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -172,6 +172,16 @@ export class RestoreService { transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP', replicas: server.replicas ?? 1, env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>, + // Restores the volume *declaration* only. The backing PVC / named + // volume is not part of the backup — a restored server re-attaches to + // a claim of the same name if one survived, and otherwise starts on a + // fresh empty volume. + volumes: (server.volumes ?? []) as Parameters[0]['volumes'], + // Preserve the delivery mode across a backup/restore round-trip. + // Falling back to 'env' keeps pre-field backups restorable, and 'env' + // is the safe default: it works without the per-server OpenBao + // identity, which a restore cannot assume still exists. + secretDelivery: (server.secretDelivery === 'injector' ? 'injector' : 'env'), }; if (server.packageName) createData.packageName = server.packageName; if (server.runtime) createData.runtime = server.runtime; diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts index 2afe4bb..c3f5214 100644 --- a/src/mcpd/src/services/docker/container-manager.ts +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -45,6 +45,14 @@ function mapState(state: string | undefined): ContainerInfo['state'] { } export class DockerContainerManager implements McpOrchestrator { + /** + * No injector equivalent exists for plain Docker/Podman containers, so + * `envFromSecret` cannot be honoured here — mcpd resolves those refs and + * merges them into `env` instead. Local development therefore keeps today's + * behaviour exactly. + */ + readonly capabilities = { secretRefs: false } as const; + private docker: Docker; constructor(opts?: Docker.DockerOptions) { @@ -96,7 +104,7 @@ export class DockerContainerManager implements McpOrchestrator { // Named Docker volumes stand in for k8s emptyDir: same lifetime intent // (scratch space shared across the pod), removed by removeContainer's `v`. const volumeName = (specName: string, vol: string): string => `${specName}-${vol}`; - const mounts: Docker.MountSettings[] = (spec.volumes ?? []).map((v) => ({ + const mounts: Docker.MountSettings[] = (spec.scratchVolumes ?? []).map((v) => ({ Type: 'volume' as const, Source: volumeName(spec.name, v.name), Target: v.mountPath, @@ -116,8 +124,16 @@ export class DockerContainerManager implements McpOrchestrator { Memory: memoryLimit, ...(nanoCpus ? { NanoCpus: nanoCpus } : {}), NetworkMode: spec.network ?? 'bridge', + // Scratch space shared with init containers; reaped with the container. ...(mounts.length > 0 ? { Mounts: mounts } : {}), ...(spec.restartPolicy ? { RestartPolicy: toDockerRestartPolicy(spec.restartPolicy) } : {}), + // Persistent named volumes, created on demand by the engine. Named (not + // anonymous) matters: `removeContainer` passes `v: true`, which reaps + // anonymous volumes but leaves named ones — which is what lets the + // data outlive the instance. + ...(spec.volumes && spec.volumes.length > 0 + ? { Binds: spec.volumes.map((v) => `${v.claimName}:${v.mountPath}`) } + : {}), }, }; if (spec.command) { @@ -339,6 +355,16 @@ export class DockerContainerManager implements McpOrchestrator { const stderr = new PassThrough(); this.docker.modem.demuxStream(stream, stdout, stderr); + // demuxStream never propagates end/close/error to the demuxed streams, so + // a dead container left consumers waiting on a silent pipe — same funnel + // as the one-shot execInContainer path uses (mcpctl#114). + const endStdout = () => { + if (!stdout.destroyed && !stdout.writableEnded) stdout.end(); + }; + stream.on('end', endStdout); + stream.on('close', endStdout); + stream.on('error', endStdout); + return { stdout, write(data: string) { diff --git a/src/mcpd/src/services/env-resolver.ts b/src/mcpd/src/services/env-resolver.ts index c964079..df7c89a 100644 --- a/src/mcpd/src/services/env-resolver.ts +++ b/src/mcpd/src/services/env-resolver.ts @@ -44,3 +44,63 @@ export async function resolveServerEnv( return result; } + +/** + * Split a server's env into inline values and secret references, WITHOUT + * resolving the references. + * + * Used for injector-delivered servers, where the pod fetches its own values and + * mcpd must never materialise them. `resolveServerEnv` above stays as-is: it is + * still the path for Docker, for opted-out servers, and for the generated + * client `.mcp.json`. + */ +export function partitionServerEnv(server: McpServer): { + inline: Record; + refs: Array<{ name: string; secretName: string; key: string }>; +} { + const entries = (server.env ?? []) as ServerEnvEntry[]; + const inline: Record = {}; + const refs: Array<{ name: string; secretName: string; key: string }> = []; + + for (const entry of entries) { + if (entry.value !== undefined) { + inline[entry.name] = entry.value; + } else if (entry.valueFrom?.secretRef) { + refs.push({ + name: entry.name, + secretName: entry.valueFrom.secretRef.name, + key: entry.valueFrom.secretRef.key, + }); + } + } + return { inline, refs }; +} + +/** + * Resolve every reference and DISCARD the values, purely to make a missing + * secret or key fail loudly at instance start. + * + * Once a server is injector-delivered, mcpd no longer reads its secrets, so the + * check that commit e6cd735 bought stops firing for it: a bad secretRef would + * degrade from a clear `secret resolution failed: …` into a vault-agent-init + * crashloop that mcpd reports as a generic pod failure. That is the exact class + * of bug that had gitea-mcp running for weeks with an empty token while + * reporting healthy — so we keep paying for the check. After the value cache it + * is a cache hit and costs essentially nothing. + */ +export async function validateServerEnvRefs( + server: McpServer, + resolver: SecretResolver, +): Promise { + const { refs } = partitionServerEnv(server); + for (const ref of refs) { + try { + await resolver.resolve(ref.secretName, ref.key); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Cannot resolve secret for server '${server.name}' env '${ref.name}': ${msg}`, + ); + } + } +} diff --git a/src/mcpd/src/services/health-probe.service.ts b/src/mcpd/src/services/health-probe.service.ts index 8bd4192..0079848 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,35 @@ 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'; + // Crossing the threshold means every probe rode the same cached STDIO + // pipe — evict it once so the next probe/call redials instead of failing + // forever against a connection the other side already dropped. + if (!result.healthy && state.consecutiveFailures === failureThreshold && instance.containerId) { + this.mcpProxyService?.removeClient(instance.containerId); + } + // 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/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index 39175ff..4e68821 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -2,7 +2,28 @@ import type { McpInstance, McpServer } from '@prisma/client'; import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js'; import { NotFoundError } from './mcp-server.service.js'; -import { resolveServerEnv, type SecretResolver } from './env-resolver.js'; +import { + resolveServerEnv, + partitionServerEnv, + validateServerEnvRefs, + type SecretResolver, +} from './env-resolver.js'; + +/** + * What InstanceService needs from the per-server identity layer. Kept narrow + * and local so the orchestration stays testable without an OpenBao backend. + */ +export interface ServerIdentityProvisioner { + /** Converge the server's identity; resolves to the ServiceAccount/role name. */ + ensureFor(server: McpServer): Promise; + /** Injector pod annotations for these refs under that identity. */ + annotationsFor( + identity: string, + refs: Array<{ name: string; secretName: string; key: string }>, + ): Record; + /** Rewrite argv to source the rendered secrets before exec'ing the server. */ + wrapCommand(server: McpServer, command: string[] | undefined): string[] | undefined; +} /** Runner images for package-based MCP servers, keyed by runtime name. */ const RUNNER_IMAGES: Record = { @@ -32,6 +53,9 @@ interface RetryMetadata { attemptCount?: number; lastAttemptAt?: string; nextRetryAt?: string; + /** containerStatuses[0].restartCount at last sync — a bump means every + * cached STDIO pipe to this (unchanged) containerId is dead. */ + lastRestartCount?: number; [k: string]: unknown; } @@ -52,13 +76,36 @@ export class InvalidStateError extends Error { } export class InstanceService { + private stdioInvalidator?: (containerId: string) => void; + constructor( private instanceRepo: IMcpInstanceRepository, private serverRepo: IMcpServerRepository, private orchestrator: McpOrchestrator, private secretResolver?: SecretResolver, + /** Provisions per-server OpenBao identities. Absent = injector unavailable. */ + private serverIdentity?: ServerIdentityProvisioner, ) {} + /** + * Hook for evicting cached STDIO clients (McpProxyService.removeClient). + * Setter injection, matching serverService.setInstanceService in main.ts — + * McpProxyService is constructed after this service and already imports + * from this file, so a constructor arg would be a circular import. + */ + setStdioInvalidator(fn: (containerId: string) => void): void { + this.stdioInvalidator = fn; + } + + private invalidateStdio(containerId: string | null | undefined): void { + if (!containerId) return; + try { + this.stdioInvalidator?.(containerId); + } catch { + /* best-effort */ + } + } + async list(serverId?: string): Promise { return this.instanceRepo.findAll(serverId); } @@ -71,37 +118,96 @@ export class InstanceService { /** * Sync instance statuses with actual container state. - * Detects crashed/stopped containers and marks them ERROR. + * + * Beyond marking crashed containers ERROR, this is the recovery path for + * mcpctl#114: ERROR rows whose pod came back are re-adopted (the pod has + * restartPolicy Always, so kubelet restarts the container in place and the + * row must follow it back), and an in-place restart under an unchanged + * containerId — visible only as a restartCount bump — invalidates any + * cached STDIO pipe, which is dead by definition. + * + * Every metadata write here MERGES via readRetryMeta: updateStatus replaces + * the JSON column wholesale, and clobbering nextRetryAt is what used to + * make ERROR rows instantly dueForRetry and hot-loop against a 409. */ async syncStatus(): Promise { const instances = await this.instanceRepo.findAll(); for (const inst of instances) { - if ((inst.status === 'RUNNING' || inst.status === 'STARTING') && inst.containerId) { - try { - const info = await this.orchestrator.inspectContainer(inst.containerId); + if (!inst.containerId) continue; + if (inst.status !== 'RUNNING' && inst.status !== 'STARTING' && inst.status !== 'ERROR') { + continue; + } - if (info.state === 'stopped' || info.state === 'error') { - // Container died — get last logs for error context - let errorMsg = `Container ${info.state}`; - try { - const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 }); - const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop(); - if (lastLog) errorMsg = lastLog; - } catch { /* best-effort */ } - await this.instanceRepo.updateStatus(inst.id, 'ERROR', { - metadata: { error: errorMsg }, - }); - } else if (info.state === 'starting' && inst.status === 'RUNNING') { - // Pod went back to starting (e.g. CrashLoopBackOff restart) - await this.instanceRepo.updateStatus(inst.id, 'STARTING', {}); - } else if (info.state === 'running' && inst.status === 'STARTING') { - // Pod became ready — promote to RUNNING - await this.instanceRepo.updateStatus(inst.id, 'RUNNING', {}); - } - } catch { - // Container gone entirely + let info: ContainerInfo; + try { + info = await this.orchestrator.inspectContainer(inst.containerId); + } catch { + // Container gone entirely. ERROR rows with a missing pod stay as they + // are — the retry/backoff path owns recreating them. + if (inst.status !== 'ERROR') { await this.instanceRepo.updateStatus(inst.id, 'ERROR', { - metadata: { error: 'Container not found' }, + metadata: { ...readRetryMeta(inst), error: 'Container not found' }, + }); + this.invalidateStdio(inst.containerId); + } + continue; + } + + const meta = readRetryMeta(inst); + + if (inst.status === 'ERROR') { + // The pod outlived the ERROR verdict (kubelet restarted the container + // in place). Re-adopt instead of leaving the row stuck forever. + if (info.state === 'running') { + const { error: _e, attemptCount: _a, lastAttemptAt: _l, nextRetryAt: _n, ...rest } = meta; + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...rest, lastRestartCount: info.restartCount ?? 0 }, + }); + this.invalidateStdio(inst.containerId); + } else if (info.state === 'starting') { + // Keep retry metadata until it is actually running. + await this.instanceRepo.updateStatus(inst.id, 'STARTING', { metadata: meta }); + this.invalidateStdio(inst.containerId); + } + // stopped/error: leave for the backoff/retry path. + continue; + } + + if (info.state === 'stopped' || info.state === 'error') { + // Container died — get last logs for error context + let errorMsg = `Container ${info.state}`; + try { + const logs = await this.orchestrator.getContainerLogs(inst.containerId, { tail: 5 }); + const lastLog = (logs.stdout || logs.stderr).trim().split('\n').pop(); + if (lastLog) errorMsg = lastLog; + } catch { /* best-effort */ } + await this.instanceRepo.updateStatus(inst.id, 'ERROR', { + metadata: { ...meta, error: errorMsg }, + }); + this.invalidateStdio(inst.containerId); + } else if (info.state === 'starting' && inst.status === 'RUNNING') { + // Pod went back to starting (e.g. CrashLoopBackOff restart) + await this.instanceRepo.updateStatus(inst.id, 'STARTING', { metadata: meta }); + this.invalidateStdio(inst.containerId); + } else if (info.state === 'running' && inst.status === 'STARTING') { + // Pod became ready — promote to RUNNING and clear retry state. + const { error: _e, attemptCount: _a, lastAttemptAt: _l, nextRetryAt: _n, ...rest } = meta; + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...rest, lastRestartCount: info.restartCount ?? 0 }, + }); + // A fresh/restarted pod under this name invalidates any cached pipe. + this.invalidateStdio(inst.containerId); + } else if (info.state === 'running' && info.restartCount !== undefined) { + if (typeof meta.lastRestartCount === 'number' && info.restartCount > meta.lastRestartCount) { + // In-place restart between polls: same pod name, dead pipes. + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...meta, lastRestartCount: info.restartCount }, + }); + this.invalidateStdio(inst.containerId); + } else if (meta.lastRestartCount === undefined) { + // First sighting: record the baseline without invalidating. + await this.instanceRepo.updateStatus(inst.id, 'RUNNING', { + metadata: { ...meta, lastRestartCount: info.restartCount }, }); } } @@ -232,6 +338,7 @@ export class InstanceService { } catch { // Container may already be gone } + this.invalidateStdio(instance.containerId); } await this.instanceRepo.delete(id); @@ -256,6 +363,7 @@ export class InstanceService { } catch { // best-effort } + this.invalidateStdio(inst.containerId); } } } @@ -372,6 +480,28 @@ export class InstanceService { if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { spec.containerPort = server.containerPort ?? 3000; } + + // Volumes are keyed on the server, not this instance: `spec.name` carries + // the instance id and a claim named after it would be torn down on the + // next server edit — exactly when the data has to survive. + const volumes = (server.volumes ?? []) as Array<{ + name: string; + mountPath: string; + sizeGb?: number; + storageClass?: string; + fsGroup?: number; + }>; + if (volumes.length > 0) { + spec.volumes = volumes.map((v) => ({ + claimName: `mcpctl-${server.name}-${v.name}`, + mountPath: v.mountPath, + sizeGb: v.sizeGb ?? 10, + ...(v.storageClass !== undefined && v.storageClass !== '' + ? { storageClass: v.storageClass } + : {}), + ...(v.fsGroup !== undefined ? { fsGroup: v.fsGroup } : {}), + })); + } // Package-based servers: command = [packageName, ...args] (entrypoint handles execution) // Docker-image servers: use explicit command if provided if (pkgCommand) { @@ -394,11 +524,31 @@ export class InstanceService { // will retry it in-place on the next tick whose nextRetryAt has // elapsed. Optional/missing env vars should be modeled as `value: ""` // entries on the server, not as silent secret-resolution failures. + // Injected delivery needs BOTH an orchestrator that can honour references + // (Docker cannot) and a provisioner for the per-server identity. Falling + // back to inline resolution when either is missing keeps local dev and + // plaintext-backend setups working unchanged. + const useInjector = server.secretDelivery === 'injector' + && this.orchestrator.capabilities?.secretRefs === true + && this.serverIdentity !== undefined; + if (this.secretResolver) { try { - const resolvedEnv = await resolveServerEnv(server, this.secretResolver); - if (Object.keys(resolvedEnv).length > 0) { - spec.env = resolvedEnv; + if (useInjector) { + // Resolve and DISCARD, purely so a missing secret still fails here + // with a clear message instead of becoming a vault-agent-init + // crashloop that mcpd reports as a generic pod failure. See + // validateServerEnvRefs. + await validateServerEnvRefs(server, this.secretResolver); + + const { inline, refs } = partitionServerEnv(server); + if (Object.keys(inline).length > 0) spec.env = inline; + spec.envFromSecret = refs; + } else { + const resolvedEnv = await resolveServerEnv(server, this.secretResolver); + if (Object.keys(resolvedEnv).length > 0) { + spec.env = resolvedEnv; + } } } catch (envErr) { const msg = envErr instanceof Error ? envErr.message : String(envErr); @@ -406,6 +556,27 @@ export class InstanceService { } } + if (useInjector && spec.envFromSecret !== undefined && spec.envFromSecret.length > 0) { + try { + // Converge the identity BEFORE the pod exists: a pod whose role is + // not yet written logs in, gets no capabilities, and crashloops. + const identity = await this.serverIdentity!.ensureFor(server); + spec.serviceAccountName = identity; + spec.automountServiceAccountToken = true; + spec.annotations = this.serverIdentity!.annotationsFor(identity, spec.envFromSecret); + // Replaces the image entrypoint (see wrapCommand); the original argv + // is folded in, so spec.command must not also be emitted as args. + const wrapped = this.serverIdentity!.wrapCommand(server, spec.command); + if (wrapped !== undefined) { + spec.entrypoint = wrapped; + delete spec.command; + } + } catch (idErr) { + const msg = idErr instanceof Error ? idErr.message : String(idErr); + return this.markInstanceError(instance, `secret identity provisioning failed: ${msg}`); + } + } + // Pull image if not available locally try { await this.orchestrator.pullImage(image); @@ -467,6 +638,7 @@ export class InstanceService { try { await this.orchestrator.removeContainer(instance.containerId, true); } catch { /* best-effort */ } + this.invalidateStdio(instance.containerId); } await this.instanceRepo.delete(instance.id); } diff --git a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts index 515aa46..2057052 100644 --- a/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts +++ b/src/mcpd/src/services/k8s/kubernetes-orchestrator.ts @@ -4,13 +4,40 @@ import type { ContainerSpec, ContainerInfo, ContainerLogs, + ContainerVolume, ExecResult, InteractiveExec, } from '../orchestrator.js'; import { K8sOfficialClient } from './k8s-client-official.js'; import type { K8sOfficialClientConfig } from './k8s-client-official.js'; -import { generatePodSpec } from './manifest-generator.js'; -import type { V1Pod } from '@kubernetes/client-node'; +import { generatePodSpec, generatePvcSpec, sanitizeName } from './manifest-generator.js'; +import type { V1Pod, V1PersistentVolumeClaim } from '@kubernetes/client-node'; + +/** + * HTTP status from a client-node error, across the shapes it actually throws. + * + * @kubernetes/client-node v1 raises `ApiException`, which carries the status on + * `.code` — not `.statusCode` as the pre-1.0 `HttpError` did. Reading only + * `.statusCode` yields undefined and turns every "expected" 404/409 into a + * rethrow, so a missing pod fails a delete and a missing PVC fails a create. + * The message-parsing fallback covers wrappers that preserve neither field. + */ +export function httpStatusOf(err: unknown): number | undefined { + const e = err as { + code?: unknown; + statusCode?: unknown; + response?: { statusCode?: unknown }; + message?: unknown; + }; + for (const candidate of [e.code, e.statusCode, e.response?.statusCode]) { + if (typeof candidate === 'number') return candidate; + } + if (typeof e.message === 'string') { + const match = /HTTP-Code:\s*(\d{3})/.exec(e.message); + if (match?.[1] !== undefined) return Number(match[1]); + } + return undefined; +} function mapPodState(pod: V1Pod): ContainerInfo['state'] { const cs = pod.status?.containerStatuses?.[0]; @@ -54,10 +81,25 @@ function podToContainerInfo(pod: V1Pod): ContainerInfo { info.port = ports[0].containerPort; } + // Restart visibility: a container that crashes and restarts IN PLACE keeps + // the same pod name and reports state=running again — restartCount is the + // only durable evidence, and syncStatus uses it to invalidate stale STDIO + // pipes (mcpctl#114). + const cs = pod.status?.containerStatuses?.[0]; + if (cs?.restartCount !== undefined) { + info.restartCount = cs.restartCount; + } + if (cs?.state?.running?.startedAt) { + info.startedAt = new Date(cs.state.running.startedAt as unknown as string); + } + return info; } export class KubernetesOrchestrator implements McpOrchestrator { + /** Pods can fetch their own secrets via the OpenBao agent injector. */ + readonly capabilities = { secretRefs: true } as const; + private client: K8sOfficialClient; private namespace: string; @@ -82,11 +124,46 @@ export class KubernetesOrchestrator implements McpOrchestrator { async createContainer(spec: ContainerSpec): Promise { await this.ensureNamespace(this.namespace); + // PVCs must exist before the pod references them, or the pod stays Pending + // on an unbound claim. + for (const volume of spec.volumes ?? []) { + await this.ensurePvc(volume, spec.labels); + } + const manifest = generatePodSpec(spec, this.namespace); - const pod = await this.client.core.createNamespacedPod({ - namespace: this.namespace, - body: manifest as V1Pod, - }); + let pod; + try { + pod = await this.client.core.createNamespacedPod({ + namespace: this.namespace, + body: manifest as V1Pod, + }); + } catch (err) { + if (httpStatusOf(err) !== 409) throw err; + // AlreadyExists: a retry raced a pod that is still there. If it is + // alive, ADOPT it — recreating under the same name can never succeed + // and used to loop the instance in ERROR forever (mcpctl#114). Only a + // genuinely dead pod is replaced. + const podName = (manifest as V1Pod).metadata!.name!; + const existing = await this.inspectContainer(podName); + if (existing.state === 'running' || existing.state === 'starting') { + return existing; + } + await this.removeContainer(podName, true); + // deleteNamespacedPod returns before the pod is gone (grace period); + // recreating while it is Terminating would 409 again. Bounded wait. + for (let i = 0; i < 15; i++) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + await this.inspectContainer(podName); + } catch { + break; // 404 — pod is gone + } + } + pod = await this.client.core.createNamespacedPod({ + namespace: this.namespace, + body: manifest as V1Pod, + }); + } // Wait briefly for pod to start scheduling await new Promise((resolve) => setTimeout(resolve, 500)); @@ -106,8 +183,7 @@ export class KubernetesOrchestrator implements McpOrchestrator { gracePeriodSeconds: 5, }); } catch (err: unknown) { - const status = (err as { statusCode?: number }).statusCode - ?? (err as { response?: { statusCode?: number } }).response?.statusCode; + const status = httpStatusOf(err); if (status !== 404) throw err; } } @@ -244,6 +320,17 @@ export class KubernetesOrchestrator implements McpOrchestrator { // Wait for WebSocket connection to establish const ws = await wsPromise; + // client-node's WebSocketHandler only ends stdout on an explicit + // CloseStream/Status frame from the apiserver. An abnormal socket death + // (container OOMKilled, node reboot) emits neither — without these + // handlers the PassThrough stays open and the consumer never learns the + // pipe is dead (mcpctl#114). + const endStdout = () => { + if (!stdout.destroyed && !stdout.writableEnded) stdout.end(); + }; + ws.on('close', endStdout); + ws.on('error', endStdout); + return { stdout, write(data: string) { @@ -284,6 +371,13 @@ export class KubernetesOrchestrator implements McpOrchestrator { false, // tty ); + // Same abnormal-death funnel as execInteractive — see comment there. + const endStdout = () => { + if (!stdout.destroyed && !stdout.writableEnded) stdout.end(); + }; + ws.on('close', endStdout); + ws.on('error', endStdout); + return { stdout, write(data: string) { @@ -316,13 +410,47 @@ export class KubernetesOrchestrator implements McpOrchestrator { body: { apiVersion: 'v1', kind: 'Namespace', metadata: { name } }, }); } catch (createErr: unknown) { - const status = (createErr as { statusCode?: number }).statusCode - ?? (createErr as { response?: { statusCode?: number } }).response?.statusCode; + const status = httpStatusOf(createErr); if (status !== 409) throw createErr; // Already exists is fine } } } + /** + * Create the PVC backing a server volume if it is not already there. + * + * Deliberately never updates an existing claim: most PVC spec fields are + * immutable after binding, and silently resizing someone's storage is not a + * side effect a container start should have. Growing a volume is an explicit + * `kubectl edit pvc` (the class must allow expansion). + */ + private async ensurePvc( + volume: ContainerVolume, + labels?: Record, + ): Promise { + const name = sanitizeName(volume.claimName); + try { + await this.client.core.readNamespacedPersistentVolumeClaim({ + name, + namespace: this.namespace, + }); + return; // Already there — reuse it, data and all. + } catch (err: unknown) { + const status = httpStatusOf(err); + if (status !== 404) throw err; + } + + try { + await this.client.core.createNamespacedPersistentVolumeClaim({ + namespace: this.namespace, + body: generatePvcSpec(volume, this.namespace, labels) as V1PersistentVolumeClaim, + }); + } catch (createErr: unknown) { + const status = httpStatusOf(createErr); + if (status !== 409) throw createErr; // Lost a create race — fine. + } + } + getNamespace(): string { return this.namespace; } diff --git a/src/mcpd/src/services/k8s/manifest-generator.ts b/src/mcpd/src/services/k8s/manifest-generator.ts index 4d7dce4..d89035d 100644 --- a/src/mcpd/src/services/k8s/manifest-generator.ts +++ b/src/mcpd/src/services/k8s/manifest-generator.ts @@ -1,5 +1,5 @@ -import type { ContainerSpec } from '../orchestrator.js'; -import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js'; +import type { ContainerSpec, ContainerVolume } from '../orchestrator.js'; +import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS, DEFAULT_VOLUME_FS_GROUP } from '../orchestrator.js'; const MCPCTL_LABEL = 'mcpctl.managed'; @@ -10,8 +10,10 @@ export interface K8sPodManifest { name: string; namespace: string; labels: Record; + annotations?: Record; }; spec: { + serviceAccountName?: string; containers: Array<{ name: string; image: string; @@ -41,10 +43,133 @@ export interface K8sPodManifest { env?: Array<{ name: string; value: string }>; volumeMounts?: Array<{ name: string; mountPath: string }>; }>; - volumes?: Array<{ name: string; emptyDir: Record }>; restartPolicy: 'Always' | 'Never' | 'OnFailure'; automountServiceAccountToken: boolean; nodeSelector?: Record; + // One pod-level list carrying both kinds: PVC-backed volumes that outlive + // the instance, and emptyDir scratch shared with init containers. + volumes?: Array< + | { name: string; persistentVolumeClaim: { claimName: string } } + | { name: string; emptyDir: Record } + >; + securityContext?: { fsGroup: number; fsGroupChangePolicy: string }; + }; +} + +/** Directory the OpenBao agent renders this pod's secrets into. */ +export const INJECTED_SECRET_DIR = '/vault/secrets'; + +/** + * Quote a shell value for a file that will be `.`-sourced: single quotes, with + * any embedded `'` closed-escaped-reopened. + * + * Not cosmetic. The rendered file is sourced by /bin/sh, so a value containing + * a space, `$`, a quote or a newline would otherwise truncate or mangle the + * variable — *silently*, producing an empty token and a server that starts, + * answers tools/list and reports healthy while every authenticated call fails. + * That is precisely the failure this change exists to prevent, so the escaping + * is part of the contract, not a detail. + */ +export function shellSingleQuote(inner: string): string { + return "'" + inner.split("'").join("'\\''") + "'"; +} + +export interface InjectorEnvRef { + /** Env var name inside the container. */ + name: string; + /** mcpctl Secret name. */ + secretName: string; + /** Key within that secret. */ + key: string; +} + +export interface InjectorConfig { + /** OpenBao role to log in as — one per server, scoped to its own secrets. */ + role: string; + /** Auth mount that validates THIS cluster's ServiceAccount tokens. */ + authPath: string; + /** KV v2 mount, e.g. `secret`. */ + mount: string; + /** Path prefix under the mount, e.g. `mcpctl`. */ + pathPrefix: string; +} + +/** + * Pod annotations that drive the OpenBao Agent Injector. + * + * One rendered file per referenced Secret (the agent keys files by the + * annotation suffix), each holding `export NAME='value'` lines for every env + * var drawn from that Secret. + * + * `agent-pre-populate-only` is deliberate: the agent runs as an init container + * and exits. MCP servers cannot reload env anyway, so a long-lived sidecar + * would buy nothing and cost memory in every pod — and these pods run at a flat + * 512Mi where Node-based servers have already been OOMKilled. + */ +export function buildInjectorAnnotations( + refs: InjectorEnvRef[], + cfg: InjectorConfig, +): Record { + const bySecret = new Map(); + for (const ref of refs) { + const list = bySecret.get(ref.secretName) ?? []; + list.push(ref); + bySecret.set(ref.secretName, list); + } + + const annotations: Record = { + 'vault.hashicorp.com/agent-inject': 'true', + 'vault.hashicorp.com/agent-pre-populate-only': 'true', + 'vault.hashicorp.com/role': cfg.role, + // Without this the agent logs in at the default `auth/kubernetes`, which + // validates a DIFFERENT cluster's ServiceAccounts and holds none of our + // roles — every login would fail with a confusing permission error. + 'vault.hashicorp.com/auth-path': cfg.authPath, + }; + + const prefix = cfg.pathPrefix === '' ? '' : `${cfg.pathPrefix}/`; + for (const [secretName, entries] of [...bySecret.entries()].sort((a, b) => a[0].localeCompare(b[0]))) { + const path = `${cfg.mount}/data/${prefix}${secretName}`; + annotations[`vault.hashicorp.com/agent-inject-secret-${secretName}`] = path; + const lines = entries + .slice() + .sort((a, b) => a.name.localeCompare(b.name)) + .map((e) => `export ${e.name}=${shellSingleQuote(`{{ .Data.data.${e.key} }}`)}`); + annotations[`vault.hashicorp.com/agent-inject-template-${secretName}`] = + `{{- with secret "${path}" -}}\n${lines.join('\n')}\n{{- end -}}`; + } + return annotations; +} + +/** + * Rewrite the container command to source the rendered secrets, then exec the + * real process. + * + * `sh -c