diff --git a/.changeset/bright-walls-refresh.md b/.changeset/bright-walls-refresh.md
new file mode 100644
index 00000000..03a40b0c
--- /dev/null
+++ b/.changeset/bright-walls-refresh.md
@@ -0,0 +1,8 @@
+---
+"@caplets/core": patch
+"@caplets/opencode": patch
+"@caplets/pi": patch
+"caplets": patch
+---
+
+Refresh expired downstream OAuth/OIDC tokens before calling MCP, OpenAPI, GraphQL, and HTTP backends, persisting rotated credentials when providers return them.
diff --git a/.codex/config.toml b/.codex/config.toml
new file mode 100644
index 00000000..a3721b56
--- /dev/null
+++ b/.codex/config.toml
@@ -0,0 +1,7 @@
+[mcp_servers.caplets-local]
+command = "node"
+args = ["./packages/cli/dist/index.js", "serve", "--transport", "stdio"]
+env_vars = ["GH_TOKEN"]
+
+[mcp_servers.caplets-local.env]
+CAPLETS_MODE = "local"
diff --git a/AGENTS.md b/AGENTS.md
index 4ae49cf3..ecdca696 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,13 +13,13 @@
## Package Map
- `packages/core` is the runtime/library source: config parsing, schema generation, MCP/OpenAPI/GraphQL/HTTP/CLI backends, native service exports.
-- `packages/cli` publishes the `caplets` binary and delegates almost all behavior to `@caplets/core`; `serve`/no arg starts the stdio MCP server.
+- `packages/cli` publishes the `caplets` binary and delegates almost all behavior to `@caplets/core`; `caplets serve` starts the MCP server, while no args print help.
- `packages/opencode` and `packages/pi` are native agent integrations that wrap `@caplets/core/native`; keep integration-specific schema/adapter code there.
- `packages/benchmarks` owns deterministic and opt-in live coding-agent benchmarks; deterministic benchmark docs are generated from `pnpm benchmark`.
## Generated And Checked Files
-- Put design specs in `docs/specs/`, implementation plans in `docs/plans/`, and product requirements documents in `docs/product/`; do not use `docs/superpowers/` in this repo.
+- Source code is the source of truth. Keep long-lived product docs in `docs/product/`, architecture docs in `docs/`, and ADRs in `docs/adr/`. Avoid committing short-lived implementation plans or design specs unless explicitly requested; if they are needed, put them in `docs/plans/` or `docs/specs/` and delete or repurpose them once they are superseded. Do not use `docs/superpowers/` in this repo.
- Config schema source of truth is Zod in `packages/core/src/config.ts`; update `schemas/caplets-config.schema.json` with `pnpm schema:generate` and verify with `pnpm schema:check`.
- Code Mode runtime API declaration source of truth is `packages/core/src/code-mode/runtime-api.d.ts`; update `packages/core/src/code-mode/runtime-api.generated.ts` with `pnpm code-mode:generate-api` and verify with `pnpm code-mode:check-api`.
- `pnpm benchmark` updates `docs/benchmarks/coding-agent.md`; `pnpm benchmark:check` fails if the committed report is stale.
@@ -28,7 +28,7 @@
## Config And Runtime Gotchas
- Default user config path is resolved by core; tests commonly override with `CAPLETS_CONFIG`.
-- Project config lives at `.caplets/config.json` and executable project config is intentionally restricted unless `CAPLETS_TRUST_PROJECT_CAPLETS` is enabled.
+- Project config lives at `.caplets/config.json`; project Markdown Caplet files load by default, while executable backend maps in project config are intentionally rejected.
- Runtime config reload keeps the last known-good config on parse/validation errors; do not change this behavior without updating reload tests.
- Caplet tool names come from configured server IDs and expose progressive discovery operations (`get_caplet`, `list_tools`/`search_tools`, `get_tool`, `call_tool`) rather than flattening downstream tools.
diff --git a/README.md b/README.md
index 2bc1c8a3..304eb475 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,57 @@
-

+
Caplets
- Give your agent capabilities, not tools.
- Turn MCP servers, APIs, and commands into focused agent capabilities.
+ Give your agent capabilities, not giant tool walls.
+ Caplets wraps MCP servers, APIs, and commands behind focused capability cards.
-
caplets.dev
-
-
- MCP · OpenAPI · GraphQL · HTTP · CLI
-
---
-Caplets turns MCP servers, APIs, and commands into focused agent capabilities: one card first, searchable tools next, inspectable schemas before calls, and preserved results after.
+Caplets gives coding agents a Code Mode surface for MCP servers, APIs, and commands. Instead
+of exposing every downstream operation as a giant tool list, each backend becomes a typed
+`caplets.` handle the agent can inspect, search, call, filter, join, and summarize inside
+one compact workflow.
-Stop dumping every operation into context up front. Caplets wraps each tool source as a capability an agent can discover, inspect, call, and recover from one step at a time. Instead of exposing a giant flat wall of operations, Caplets shows a compact capability card with source, status, and next actions. The agent chooses a domain first, then uses scoped operations like `search_tools`, `describe_tool`, and `call_tool` only when it needs more detail.
+Progressive discovery is still available when you want visible wrapper tools, but Code Mode is
+the default exposure for configured backends.
-For MCP-backed Caplets, the scoped operation set also includes resource discovery and reading, prompt listing and rendering, resource-template discovery, and completion for prompt or template arguments. Non-MCP backends expose focused tool and action operations.
+Caplets can wrap:
-## Try the aha moment
+- MCP servers
+- OpenAPI, GraphQL, and simple HTTP APIs
+- Curated repository CLI commands
+- Shared Caplet files from this repo's `caplets/` catalog
-Install Caplets, add Context7, and watch your agent see one capability before it searches downstream tools.
+## Quick Start
+
+Install the CLI and wire it into your agent:
```sh
npm install -g caplets
-caplets init
-caplets add mcp context7 --command npx --arg -y --arg @upstash/context7-mcp
-caplets serve
+caplets setup
```
-In the deterministic benchmark, 106 flat tools became 3 top-level capabilities with an 87.9% smaller initial payload. Your agent starts with `context7`, then drills in through `inspect`, `search_tools`, `describe_tool`, and `call_tool` only when needed.
-
-## Quick Start
-
-Caplets requires Node.js 24 or newer.
+Install a no-auth example Caplet and try it from your agent:
```sh
-npm install -g caplets
-caplets init
-caplets serve
+caplets install spiritledsoftware/caplets osv
```
-Connect Caplets to any MCP client:
+Or add Caplets manually to any MCP client:
```json
{
@@ -68,86 +64,53 @@ Connect Caplets to any MCP client:
}
```
-Ask your agent to use Caplets. It will see a compact capability list first, then inspect only the backend it needs.
+## Use Caplets
-Add capabilities from existing systems when you are ready to give agents a focused tool surface:
+Add your own capability sources:
```sh
-# Wrap an MCP server
caplets add mcp docs --command npx --arg -y --arg @upstash/context7-mcp
-
-# Convert useful repository commands into curated tools
+caplets add openapi users --spec ./openapi.json --base-url https://api.example.com
+caplets add graphql catalog --endpoint-url https://api.example.com/graphql --schema ./schema.graphql
+caplets add http status-api --base-url https://api.example.com --action get_status:GET:/status/{service}
caplets add cli repo-tools --repo . --include git,gh,package
-
-# Install ready-made Caplets from a repository
-caplets install spiritledsoftware/caplets github linear context7
```
-Configured Caplets can be invoked directly from the CLI for agent-friendly scripts and smoke tests:
+Inspect and call them from the CLI:
```sh
-caplets inspect context7
-caplets list-tools context7
-caplets get-tool context7 resolve-library-id
-caplets call-tool context7 resolve-library-id --args '{"libraryName":"react"}'
-caplets call-tool context7 resolve-library-id --args '{"libraryName":"react"}' --field result.id --format json
-caplets list-resources docs
-caplets read-resource docs file:///repo/README.md
-caplets list-prompts linear
-caplets get-prompt linear review_issue --args '{"issueId":"CAP-123"}'
-caplets complete docs --resource-template 'file:///repo/{path}' --argument path --value src/
+caplets list
+caplets inspect osv
+caplets search-tools osv vulnerability
+caplets get-tool osv query_package_version
+caplets call-tool osv query_package_version --args '{"name":"react","ecosystem":"npm","version":"18.2.0"}'
```
-The older qualified form, such as `caplets call-tool context7.resolve-library-id` or `caplets get-prompt linear.review_issue`, remains supported for scripts and existing usage.
-
-Direct CLI operation commands print Markdown summaries by default. Add `--format plain` for plain text or `--format json` for machine-readable JSON (`md` is accepted as an alias for `markdown`). If a downstream tool returns `isError: true`, Caplets still exits with status code 1.
+MCP-backed Caplets also support resources, resource templates, prompts, and argument
+completion. Direct CLI commands print Markdown by default; pass `--format json` for
+machine-readable output. In agent sessions, Code Mode keeps the same operations behind typed
+handles so discovery, execution, filtering, and synthesis can happen in one call.
-### Shell completions
+## Agent Surfaces
-The npm package ships shell completion generators for Bash, Zsh, Fish, PowerShell, and cmd. Installation is explicit: `npm install -g caplets` does not modify shell startup files or system completion directories.
-
-```sh
-# Bash
-mkdir -p ~/.local/share/bash-completion/completions
-caplets completion bash > ~/.local/share/bash-completion/completions/caplets
+Caplets works as a regular MCP server through `caplets serve`. By default, that server exposes
+Code Mode for the configured backends. Caplets also has native integrations for agents that can
+load packages directly:
-# Zsh
-mkdir -p ~/.zsh/completions
-caplets completion zsh > ~/.zsh/completions/_caplets
-# Ensure ~/.zsh/completions is on fpath before compinit, then reload your shell:
-# echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc
-# echo 'autoload -Uz compinit && compinit' >> ~/.zshrc
+| Agent | Setup |
+| ----------------------------------------- | ----------------------------------------------------------------------------------------------- |
+| Codex, Claude Code, and other MCP clients | `caplets setup` or `caplets serve` |
+| OpenCode | [`@caplets/opencode`](https://github.com/spiritledsoftware/caplets/tree/main/packages/opencode) |
+| Pi | [`@caplets/pi`](https://github.com/spiritledsoftware/caplets/tree/main/packages/pi) |
-# Fish
-mkdir -p ~/.config/fish/completions
-caplets completion fish > ~/.config/fish/completions/caplets.fish
+`caplets setup` uses each harness's MCP configuration command:
-# PowerShell
-caplets completion powershell | Out-String | Invoke-Expression
-
-# cmd.exe
-caplets completion cmd > %USERPROFILE%\caplets-completion.cmd
-%USERPROFILE%\caplets-completion.cmd
+```sh
+codex mcp add caplets -- caplets serve
+claude mcp add --transport stdio --scope user caplets -- caplets serve
```
-Completions include command names, options, common enum values, configured Caplet IDs, and cache-backed downstream names for split targets such as `caplets call-tool repo ` and qualified targets such as `caplets call-tool repo.`. Downstream discovery is bounded by the `completion` config timeouts and a platform-native cache directory. Generated shell scripts suppress completion stderr; run the underlying CLI command directly when debugging completion behavior.
-
-Backends that require OAuth or token auth may need `caplets auth login ` before live downstream completions can return richer results. Completion never starts interactive login flows.
-
-## Agent Integrations
-
-Use Caplets as a normal MCP server everywhere, or install a native agent integration when
-your coding agent supports one.
-
-| Agent | Install | What It Provides |
-| -------------- | -------------------------------------------------------------- | --------------------------------------------------------------- |
-| Any MCP client | Add `caplets serve` or `caplets attach` manually in MCP config | Universal Code Mode gateway; progressive exposure is opt-in |
-| Claude Code | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud Code Mode gateway |
-| Codex | Add `caplets serve` or `caplets attach` manually in MCP config | Local or remote/Cloud Code Mode gateway |
-| OpenCode | Install [`@caplets/opencode`](packages/opencode/README.md) | Native `caplets__` tools and prompt guidance hooks |
-| Pi | Install [`@caplets/pi`](packages/pi/README.md) | Native `caplets__` tools with Pi prompt snippets/guidelines |
-
-Codex local MCP config (`~/.codex/config.toml`):
+Equivalent local Codex config:
```toml
[mcp_servers.caplets]
@@ -155,20 +118,7 @@ command = "caplets"
args = ["serve"]
```
-Claude Code or generic JSON MCP config:
-
-```json
-{
- "mcpServers": {
- "caplets": {
- "command": "caplets",
- "args": ["serve"]
- }
- }
-}
-```
-
-Codex remote or Cloud MCP config (`~/.codex/config.toml`):
+For a remote or Cloud-backed MCP server, point the client at `caplets attach` instead:
```toml
[mcp_servers.caplets]
@@ -176,8 +126,6 @@ command = "caplets"
args = ["attach"]
```
-Claude Code or generic JSON remote or Cloud MCP config:
-
```json
{
"mcpServers": {
@@ -189,1112 +137,78 @@ Claude Code or generic JSON remote or Cloud MCP config:
}
```
-For Caplets Cloud, authenticate once and set the remote selection environment for the agent:
-
-```sh
-caplets cloud auth login
-export CAPLETS_MODE=cloud
-export CAPLETS_REMOTE_URL=https://cloud.caplets.dev
-```
+Native integrations expose `caplets__code_mode` for multi-step TypeScript workflows over
+generated `caplets.` handles. Progressive exposure adds `caplets__` tools; direct
+exposure adds operation-level tools such as `caplets____`.
-For a self-hosted remote:
+Remote mode is available with `caplets attach`, self-hosted HTTP service settings, or
+Caplets Cloud auth:
```sh
export CAPLETS_MODE=remote
export CAPLETS_REMOTE_URL=https://caplets.example.com/caplets
export CAPLETS_REMOTE_TOKEN=...
-```
-
-## Core Alchemy
-
-Core Alchemy deploys the public landing page from `apps/landing`. It does not deploy the private Cloud Worker or Cloud dashboard; those belong to the nested Cloud repository.
-
-### Remote Caplets service
-
-OpenCode and Pi can use native `caplets__` tools backed by a remote Caplets HTTP service. Codex, Claude Code, and any MCP client can connect to the same remote MCP endpoint directly.
-
-Hosted Caplets Cloud uses browser-mediated Cloud Auth:
-
-```sh
-caplets cloud auth login --workspace personal
-caplets cloud auth status
-caplets cloud auth workspaces
-caplets cloud auth switch team
-caplets cloud auth logout
-```
-
-Cloud Auth stores one Selected Workspace locally. `caplets attach --workspace ` must match that saved workspace; switch explicitly before attaching another hosted workspace. Self-hosted remotes continue to use `CAPLETS_REMOTE_URL`, `CAPLETS_REMOTE_TOKEN`, or Basic Auth credentials.
-
-Access tokens are short-lived. The CLI refreshes expired hosted credentials before attach, persists the rotated refresh token returned by Cloud, and treats revoked refresh credentials as a fresh-login requirement. `caplets cloud auth logout` clears local credentials; Cloud logout revokes the refresh credential family so rotated refresh tokens stop working together.
-Use `caplets attach --once` for a finite Project Binding smoke test, or `caplets attach` to run a remote-backed MCP server over stdio or HTTP with Project Binding and local overlay.
-
-Start a local HTTP service. `--path` is the service base path; Caplets mounts MCP,
-control, and health endpoints underneath it:
-
-```sh
-CAPLETS_SERVER_URL=http://127.0.0.1:5387/caplets \
-CAPLETS_SERVER_PASSWORD=... \
-caplets serve --transport http
-```
-
-With `CAPLETS_SERVER_URL=http://127.0.0.1:5387/caplets`, the derived endpoints are:
-
-- MCP: `http://127.0.0.1:5387/caplets/mcp`
-- Control: `http://127.0.0.1:5387/caplets/control`
-- Health: `http://127.0.0.1:5387/caplets/healthz`
-
-`caplets serve --transport http` serves plain HTTP. For non-loopback or network access, expose it only through HTTPS/TLS (for example, a reverse proxy or secure tunnel) and enable Basic Auth; Basic Auth over plain HTTP exposes credentials. Keep credentials out of plugin manifests.
-
-#### Docker Compose self-hosting
-
-This repository includes a source-build Docker image and Compose service for running the HTTP service from the checked-out source tree:
-
-```sh
-CAPLETS_SERVER_PASSWORD=change-me docker compose up --build
-```
-
-By default, Compose publishes the service on loopback only:
-
-- Base URL: `http://127.0.0.1:5387`
-- MCP endpoint: `http://127.0.0.1:5387/mcp`
-- Control endpoint: `http://127.0.0.1:5387/control`
-- Health endpoint: `http://127.0.0.1:5387/healthz`
-
-The service stores Caplets config and auth state in a Docker named volume mounted at `/data`. To use a host-visible bind mount instead, replace this Compose volume entry:
-
-```yaml
-volumes:
- - caplets-data:/data
-```
-
-with:
-
-```yaml
-volumes:
- - ./data:/data
-```
-
-To expose the service to a LAN interface or reverse proxy, set an explicit bind address and public base URL:
-
-```sh
-CAPLETS_BIND_ADDRESS=0.0.0.0 \
-CAPLETS_SERVER_URL=https://caplets.example.com \
-CAPLETS_SERVER_PASSWORD=change-me \
-docker compose up --build
-```
-
-Only expose Caplets beyond loopback through HTTPS/TLS and Basic Auth. `CAPLETS_SERVER_PASSWORD` protects both the MCP and control endpoints; downstream provider tokens and auth files remain server-owned inside the mounted `/data` location.
-
-Native integrations and remote-capable CLI commands read remote client settings from environment variables:
-
-```sh
-CAPLETS_MODE=remote \
-CAPLETS_SERVER_URL=https://caplets.example.com/caplets \
-CAPLETS_SERVER_USER=caplets \
-CAPLETS_SERVER_PASSWORD=... \
-opencode
+caplets cloud auth login
+CAPLETS_MODE=cloud CAPLETS_REMOTE_URL=https://cloud.caplets.dev opencode
```
-For MCP-backed Codex or Claude Code configs, point the agent's MCP server entry at the derived `/mcp` URL using that agent's supported HTTP MCP configuration. If Basic Auth is needed, use the agent's secure secret or environment interpolation mechanism rather than hardcoding credentials.
-
-In `CAPLETS_MODE=remote`, read and execute commands use a merged Caplets view. Remote server rows load first, user-global local Caplets overlay them, and project-local Caplets have the highest priority. A local Caplet with the same ID shadows the remote one, runs locally, and prints a warning such as `Warning: project Caplet shared shadows remote Caplet`; remote-only IDs continue through remote control.
-
-Local overlays in remote mode are best-effort. Invalid local config sources warn and are ignored while remaining valid layers still load; invalid Caplet files warn and are skipped individually. For sibling file layouts, `foo/CAPLET.md` wins over `foo.md` and warns that `foo.md` was shadowed; if the winning `CAPLET.md` is invalid, that local ID is skipped instead of falling back to `foo.md`.
-
-Mutating commands do not automatically write to the server just because remote mode is active. `caplets init`, `caplets add ...`, and `caplets install ...` write project-local by default, `--project` is the explicit project-local target, `--global` writes user-global local files, and `--remote` sends the mutation to the remote Caplets server. Target flags are mutually exclusive.
-
-## Convert Existing Tooling
-
-Caplets is designed to convert what you already use into agent-friendly capability domains.
-
-| Existing source | Command |
-| ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
-| Local MCP server | `caplets add mcp local-tools --command node --arg ./server.mjs` |
-| Remote MCP server | `caplets add mcp remote-tools --url https://mcp.example.com/mcp --transport http --token-env MCP_TOKEN` |
-| OpenAPI service | `caplets add openapi users --spec ./openapi.json --base-url https://api.example.com --token-env USERS_API_TOKEN` |
-| GraphQL endpoint | `caplets add graphql catalog --endpoint-url https://api.example.com/graphql --schema ./schema.graphql` |
-| Simple HTTP API | `caplets add http status-api --base-url https://api.example.com --action get_status:GET:/status/{service}` |
-| Repository CLI workflows | `caplets add cli repo-tools --repo . --include git,gh,package` |
-| Shared Caplet catalog | `caplets install spiritledsoftware/caplets github linear context7` |
-
-Generated Caplet files are written to `./.caplets` by default so teams can review,
-version, and customize them with the project.
-
-## Why It Matters
-
-Flat tool lists make agents guess before they understand. If every downstream server exposes every operation up front, the model starts with a noisy list, duplicate tool names, and a larger context surface before it knows which capability matters.
-
-Caplets turns that flat wall into a staged path:
-
-1. **Choose** a capability, such as `GitHub`.
-2. **Inspect** matching operations with `search_tools` or `tools`.
-3. **Resolve** the exact schema with `describe_tool`.
-4. **Invoke** with `call_tool` while preserving downstream content, structured data, and error state.
-
-A backend enters agent context as a focused card with source, status, and next actions, not a wall of operations.
-
## Benchmark
-Caplets reduces the tool surface an agent has to carry while preserving access to the
-same downstream operations.
-
-In Caplets' deterministic coding-agent benchmark, the same seven mock MCP servers are
-exposed two ways: direct flat MCP aggregation versus Caplets progressive disclosure.
-
-| Initial Agent Surface | Direct Flat MCP | Caplets | Reduction |
-| ------------------------- | ----------------: | -----------: | ------------: |
-| Visible tools | 215 | 7 | 96.7% fewer |
-| Serialized MCP payload | 63,250 bytes | 12,720 bytes | 79.9% smaller |
-| Approx. context surface | 15,813 tokens | 3,180 tokens | 12,633 fewer |
-| Top-level name collisions | 3 duplicate names | 0 | eliminated |
-
-Caplets does not remove access to downstream tools. It places them behind scoped
-discovery operations, so the agent sees less up front while retaining access to the same
-capabilities when needed.
-
-In a live Pi eval on a real-world large MCP stack, Caplets Code Mode completed the same
-10/10 tasks as direct MCP and Executor while using far fewer total tokens. The stack used
-GitHub, Context7, DeepWiki, Git, filesystem, Playwright, ast-grep, language-server, and
-web-search MCP servers. The run used `openai-codex/gpt-5.5` as both the main model and
-judge model, with 2 runs per task per mode.
-
-| Mode | Tasks Passed | Avg request + output tokens | Avg provider tokens |
-| ------------------------------- | -----------: | --------------------------: | ------------------: |
-| Caplets Code Mode | 10/10 | 236,803 | 126,877 |
-| Caplets progressive + Code Mode | 10/10 | 422,861 | 264,624 |
-| Caplets progressive | 10/10 | 461,171 | 294,217 |
-| Executor MCP | 10/10 | 675,842 | 369,992 |
-| Direct vanilla MCP | 10/10 | 846,048 | 544,121 |
-
-Against the same pass-rate baseline, Caplets Code Mode used 72.0% fewer request+output
-tokens than direct vanilla MCP and 65.0% fewer than Executor MCP. Caplets progressive
-disclosure also beat direct vanilla MCP by 45.5% and Executor MCP by 31.8% on
-request+output tokens.
-
-Live results depend on local agent CLIs, credentials, model/provider behavior, and the
-date of the run. The deterministic surface benchmark remains the reproducible,
-credential-free claim; the live eval demonstrates the same trend in a realistic large
-MCP harness.
-
-See [`docs/benchmarks/coding-agent.md`](docs/benchmarks/coding-agent.md) for methodology,
-limitations, and reproduction commands.
-
-```sh
-pnpm benchmark
-pnpm benchmark:check
-pnpm build
-CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:pi-eval -- --task-suite mcp-real-world-large --mode caplets-code-mode,caplets-progressive,vanilla-mcp,executor-mcp --model openai-codex/gpt-5.5 --runs 2
-```
-
-## Design Model
-
-Caplets combines two ideas that work well separately but leave a gap together: agent skills and MCP servers.
-
-Agent skills are great at progressive disclosure. They show an agent a compact capability card first, then let it read deeper instructions only when that skill is relevant. MCP servers are great at live tool execution, but most clients expose their tools as one flat list up front. That means a powerful MCP setup can flood the agent with every tool from every server before it knows which capability area matters.
-
-Caplets borrows the skill-shaped discovery model and applies it to MCP, OpenAPI, GraphQL, HTTP, and CLI backends. Each backend becomes a skill-like capability card first; its actual operations stay hidden until the agent chooses that capability and asks to search, list, inspect, or call them.
-
-A capability is safe for agents when it reveals itself in stages:
-
-- **Discoverable as one capability:** source, status, auth posture, and next actions are visible before any downstream tool list enters context.
-- **Inspectable before invocation:** agents search inside the selected capability, then inspect exact tool schemas before any call is made.
-- **Lossless after the call:** Caplets preserves structured content, resource links, images, and downstream error state instead of flattening results away.
-
-## Trust Before Invocation
-
-Caplets keeps trust mechanics visible before an agent calls a backend.
-
-| Mechanic | Example | Why it matters |
-| -------- | ---------------------------------- | ------------------------------------------------------------------------------- |
-| Source | `.caplets/config.json` | Users can see where the capability came from before trusting it. |
-| Auth | `GITHUB_TOKEN: redacted` | Secrets stay hidden while auth state remains inspectable. |
-| Timeout | `30s boundary` | Slow or stuck backends fail visibly instead of disappearing into agent context. |
-| Error | `safe message + raw detail scoped` | Recovery information stays useful without leaking sensitive configuration. |
-
-If a backend fails, Caplets keeps the error scoped to the capability, preserves useful recovery detail, and redacts sensitive configuration before it reaches the agent.
-
-## Capabilities
-
-- Reads downstream MCP server definitions, native OpenAPI endpoint definitions, native GraphQL endpoint definitions, explicit HTTP API action definitions, and curated CLI tool definitions from user and project config sources.
-- Registers one generated MCP tool for each enabled MCP server, OpenAPI endpoint, GraphQL endpoint, HTTP API, or CLI tools backend.
-- Uses the configured server ID as the generated tool name.
-- Uses the configured `name` and `description` as the capability card shown to agents.
-- Starts downstream MCP servers and loads OpenAPI specs lazily when an operation needs them.
-- Supports stdio, Streamable HTTP, and legacy HTTP+SSE downstream servers.
-- Lets agents `tools`, `search_tools`, `describe_tool`, and `call_tool` within one selected Caplet namespace.
-- Converts OpenAPI operations into MCP-style tool metadata and executes HTTP calls directly.
-- Converts configured GraphQL operations into MCP-style tool metadata, and can auto-generate GraphQL tools from schema root query and mutation fields.
-- Converts explicitly configured HTTP actions into MCP-style tool metadata and executes HTTP calls directly.
-- Converts explicitly configured CLI actions into MCP-style tool metadata and executes commands directly without a shell.
-- Preserves downstream tool results instead of rewriting them into a custom format.
-- Redacts secrets from structured errors.
-- Supports static remote auth and OAuth token storage for remote servers.
-
-## Configure
-
-Create a starter project config at `./.caplets/config.json`:
-
-```sh
-caplets init
-```
-
-To create a starter user config instead, pass `--global`. The user config path is
-`${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` on Unix-like platforms and
-`%APPDATA%\caplets\config.json` on Windows:
-
-```sh
-caplets init --global
-```
-
-The generated config includes a disabled example server. Replace it with the MCP servers
-you want Caplets to expose:
-
-```json
-{
- "$schema": "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplets-config.schema.json",
- "version": 1,
- "defaultSearchLimit": 20,
- "maxSearchLimit": 50,
- "completion": {
- "discoveryTimeoutMs": 750,
- "overallTimeoutMs": 1500,
- "cacheTtlMs": 300000,
- "negativeCacheTtlMs": 30000
- },
- "mcpServers": {
- "filesystem": {
- "name": "Project Files",
- "description": "Read, search, and edit local project files.",
- "command": "npx",
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/you/code"],
- "cwd": "/home/you/code",
- "startupTimeoutMs": 10000,
- "callTimeoutMs": 60000,
- "toolCacheTtlMs": 30000
- },
- "docs": {
- "name": "Hosted Docs",
- "description": "Search hosted product and API documentation.",
- "transport": "http",
- "url": "https://mcp.example.com/mcp",
- "auth": {
- "type": "bearer",
- "token": "$env:DOCS_MCP_TOKEN"
- }
- }
- },
- "openapiEndpoints": {
- "users": {
- "name": "Users API",
- "description": "Manage users through the internal HTTP API.",
- "specPath": "./openapi.json",
- "baseUrl": "https://api.example.com",
- "auth": {
- "type": "bearer",
- "token": "$env:USERS_API_TOKEN"
- }
- }
- },
- "graphqlEndpoints": {
- "catalog": {
- "name": "Catalog GraphQL",
- "description": "Query and update catalog records through GraphQL.",
- "endpointUrl": "https://api.example.com/graphql",
- "introspection": true,
- "auth": {
- "type": "oidc",
- "issuer": "https://login.example.com"
- }
- }
- },
- "httpApis": {
- "status": {
- "name": "Status API",
- "description": "Read deployment status from a simple HTTP API.",
- "baseUrl": "https://api.example.com",
- "auth": { "type": "none" },
- "actions": {
- "get_status": {
- "method": "GET",
- "path": "/status/{service}",
- "description": "Fetch status for one service.",
- "inputSchema": {
- "type": "object",
- "properties": { "service": { "type": "string" } },
- "required": ["service"]
- }
- }
- }
- }
- }
-}
-```
-
-The user config path can be overridden with `CAPLETS_CONFIG`; the project config path can be
-overridden with `CAPLETS_PROJECT_CONFIG`:
-
-```sh
-CAPLETS_PROJECT_CONFIG=/path/to/project/.caplets/config.json caplets init
-CAPLETS_CONFIG=/path/to/user/config.json caplets init --global
-CAPLETS_CONFIG=/path/to/config.json caplets serve
-```
-
-Inspect the installed CLI version and resolved config locations:
-
-```sh
-caplets --version
-caplets config path
-caplets config paths
-caplets config paths --json
-```
-
-Caplets validates config files at startup and hot reloads config changes while `caplets serve`
-is running. Invalid edits are ignored until fixed, so the MCP server keeps serving the last
-known-good config instead of dropping every tool because of a transient JSON or validation error.
-
-The optional `$schema` field points editors at the generated JSON Schema in
-[`schemas/caplets-config.schema.json`](schemas/caplets-config.schema.json). CI verifies that
-the committed schema stays in sync with the Zod config validator.
-
-### Caplet Files
-
-For richer skill-like cards, add Markdown Caplet files beside `config.json`. Every Caplet
-file must include exactly one executable backend: `mcpServer`, `openapiEndpoint`,
-`graphqlEndpoint`, `httpApi`, `cliTools`, or `capletSet`;
-serverless Caplets are intentionally out of scope.
-
-Top-level files derive the Caplet ID from the filename:
-
-```md
----
-$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json
-name: GitHub
-description: Interact with GitHub repositories, issues, and pull requests.
-tags:
- - code
- - review
-mcpServer:
- transport: http
- url: https://api.githubcopilot.com/mcp
- auth:
- type: bearer
- token: $env:GH_TOKEN
----
-
-# GitHub
-
-Use this Caplet for repository, issue, pull request, and code review workflows.
-```
-
-OpenAPI-backed Caplet files use `openapiEndpoint`:
-
-```md
----
-name: Users API
-description: Manage users through the internal HTTP API.
-openapiEndpoint:
- specPath: ./openapi.json
- baseUrl: https://api.example.com
- auth:
- type: bearer
- token: $env:USERS_API_TOKEN
----
-
-# Users API
-```
-
-GraphQL-backed Caplet files use `graphqlEndpoint`:
-
-```md
----
-name: Catalog GraphQL
-description: Query and update catalog records through GraphQL.
-graphqlEndpoint:
- endpointUrl: https://api.example.com/graphql
- schemaPath: ./schema.graphql
- auth:
- type: oidc
- issuer: https://login.example.com
----
-
-# Catalog GraphQL
-```
-
-HTTP action Caplet files use `httpApi`:
-
-```md
----
-name: Status API
-description: Read deployment status from a simple HTTP API.
-httpApi:
- baseUrl: https://api.example.com
- auth:
- type: none
- actions:
- get_status:
- method: GET
- path: /status/{service}
- description: Fetch status for one service.
- inputSchema:
- type: object
- properties:
- service:
- type: string
- required: [service]
----
-
-# Status API
-```
-
-CLI-backed Caplet files use `cliTools`:
-
-```md
----
-name: Repository CLI
-description: Run curated repository workflows through local CLI commands.
-cliTools:
- cwd: /home/you/project
- actions:
- git_status:
- description: Show concise Git working tree status.
- command: git
- args: ["status", "--short"]
- annotations:
- readOnlyHint: true
----
-
-# Repository CLI
-```
-
-Top-level files derive their Caplet ID from the filename. Directory-style Caplets use
-`linear/CAPLET.md`, which is exposed as `linear`; sibling files can be referenced with
-normal Markdown links from `CAPLET.md`.
-
-This repository includes polished working examples under [`caplets/`](caplets/):
-
-- `github`: GitHub's hosted MCP endpoint, using `GH_TOKEN`.
-- `linear`: Linear's hosted OAuth MCP endpoint.
-- `context7`: Context7 documentation lookup through `@upstash/context7-mcp`.
-- `repo-cli`: Read-oriented repository CLI workflows through `git` and package scripts.
-- `ast-grep`: Structural code search, scan, rewrite, rule testing, and scaffolding through the `ast-grep` CLI.
-- `osv`: OSV.dev vulnerability lookups through explicit read-only HTTP actions.
-- `npm`: npm registry operations through npm's published OpenAPI spec URL.
-- `pypi`: PyPI project metadata, release metadata, and Simple API JSON through a curated OpenAPI spec.
-- `deepwiki`: Repository-focused documentation and architecture context through DeepWiki MCP.
-- `sourcegraph`: Cross-repository code search and navigation through Sourcegraph MCP.
-- `playwright`: Headless browser automation for frontend inspection and testing through Playwright MCP.
-- `lsp`: Language Server Protocol-backed code intelligence through `language-server-mcp`.
-- `coding-agent-toolkit`: A CapletSet that bundles high-value coding-agent examples; source children are symlinks to canonical top-level examples and installed copies are materialized as self-contained files/directories.
-
-GraphQL is intentionally skipped in this showcase batch so the examples can focus on HTTP,
-OpenAPI, MCP, CLI, and CapletSet coverage without duplicating GitHub or GitLab surfaces.
-
-Install every example from a repo's `caplets/` directory into the current project's
-`./.caplets` directory:
-
-```sh
-caplets install spiritledsoftware/caplets
-```
-
-Install one or more individual Caplets by ID:
-
-```sh
-caplets install spiritledsoftware/caplets github
-caplets install spiritledsoftware/caplets github linear
-```
-
-`caplets install` accepts a GitHub `owner/repo` shorthand, a Git URL, or a local repository path.
-By default it writes to `./.caplets`, creating that directory when needed. Pass `-g` or
-`--global` to write to your user Caplets root instead, which is
-`${XDG_CONFIG_HOME:-~/.config}/caplets` on Unix-like platforms, `%APPDATA%\caplets` on Windows,
-or the parent directory of `CAPLETS_CONFIG` when that environment variable is set. Existing
-Caplets are not overwritten unless `--force` is passed.
-
-On Unix-like platforms, relative `XDG_CONFIG_HOME` and `XDG_STATE_HOME` values are ignored.
-
-Caplets loads user Caplet files from the user Caplets root and project Caplet files from the
-current working directory's `./.caplets` directory. Later sources override earlier ones in this
-order: user `config.json`, user Caplet files, project `config.json`, and project Caplet files.
-That means a project-local Caplet can intentionally replace a user-level Caplet with the same ID.
-Use `caplets list` to see each Caplet's winning source; when a project Caplet shadows a user-level
-Caplet, the list output includes a warning naming the shadowed path.
-
-`caplets init` refuses to overwrite an existing project config. To intentionally replace the file:
-
-```sh
-caplets init --force
-```
-
-Use `caplets init --global --force` to replace the user config instead.
-
-### Caplet IDs
-
-Each key under `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, `httpApis`, `cliTools`, or `capletSets` is the
-stable Caplet ID. It becomes the generated MCP tool name exactly, so keep it short and specific:
-
-```json
-{
- "mcpServers": {
- "linear": {
- "name": "Linear",
- "description": "Read and update Linear issues and projects.",
- "command": "npx",
- "args": ["-y", "linear-mcp-server"]
- }
- }
-}
-```
-
-Caplet IDs must match `^[a-zA-Z0-9_-]{1,64}$` and must be unique across `mcpServers`,
-`openapiEndpoints`, `graphqlEndpoints`, `httpApis`, `cliTools`, and `capletSets`. Spaces, dots, slashes, colons, and Unicode IDs are rejected.
-
-### Stdio Servers
-
-Use `command` for a local stdio MCP server. `args`, `env`, and `cwd` are optional.
-
-```json
-{
- "name": "Local Tools",
- "description": "Run local development tools through stdio.",
- "command": "node",
- "args": ["./server.mjs"],
- "env": {
- "API_TOKEN": "${API_TOKEN}"
- },
- "cwd": "/home/you/project"
-}
-```
-
-### Remote Servers
-
-Use `transport` and `url` for remote MCP servers.
-
-```json
-{
- "name": "Remote Docs",
- "description": "Search documentation from a remote MCP server.",
- "transport": "http",
- "url": "https://mcp.example.com/mcp",
- "auth": {
- "type": "headers",
- "headers": {
- "x-api-key": "$env:REMOTE_DOCS_API_KEY"
- }
- }
-}
-```
-
-`transport` can be `http` for MCP Streamable HTTP or `sse` for legacy HTTP+SSE. Remote
-URLs must use `https://`, except loopback development URLs such as `http://localhost`.
-
-### OpenAPI Endpoints
-
-Use `openapiEndpoints` for native HTTP APIs described by OpenAPI 3 specs. Each entry
-points at one spec through either `specPath` or `specUrl`, and may override the request
-base URL with `baseUrl`.
-
-```json
-{
- "name": "Users API",
- "description": "Manage users through the internal HTTP API.",
- "specPath": "./openapi.json",
- "baseUrl": "https://api.example.com",
- "auth": { "type": "none" }
-}
-```
-
-OpenAPI auth is explicit and supports:
-
-- `{"type": "none"}`
-- `{"type": "bearer", "token": "$env:TOKEN"}`
-- `{"type": "headers", "headers": {"x-api-key": "$env:API_KEY"}}`
-- `{"type": "oauth2", ...}`
-- `{"type": "oidc", ...}`
-
-OpenAPI `call_tool.args` uses grouped HTTP inputs:
-
-```json
-{
- "operation": "call_tool",
- "tool": "GET /users/{id}",
- "arguments": {
- "path": { "id": "42" },
- "query": { "active": true },
- "body": { "name": "Ada" }
- }
-}
-```
-
-Every OpenAPI endpoint can set:
-
-- `requestTimeoutMs`: timeout for HTTP calls. Defaults to `60000`.
-- `operationCacheTtlMs`: how long OpenAPI operation metadata stays fresh. Defaults to `30000`; `0` refreshes every time.
-- `disabled`: omit the endpoint from Caplets discovery. Defaults to `false`.
-
-### GraphQL Endpoints
-
-Use `graphqlEndpoints` for native GraphQL APIs. Each entry points at a GraphQL HTTP
-endpoint and exactly one schema source: `schemaPath`, `schemaUrl`, or `introspection: true`.
-
-```json
-{
- "name": "Catalog GraphQL",
- "description": "Query and update catalog records through GraphQL.",
- "endpointUrl": "https://api.example.com/graphql",
- "schemaPath": "./schema.graphql",
- "auth": { "type": "oidc", "issuer": "https://login.example.com" },
- "operations": {
- "product": {
- "document": "query Product($id: ID!) { product(id: $id) { id name } }",
- "operationName": "Product",
- "description": "Fetch a product by ID."
- }
- }
-}
-```
-
-When `operations` is omitted or empty, Caplets auto-generates tools from schema root
-fields: `query_` and `mutation_`. Generated tools use bounded scalar
-selection sets and pass `call_tool.args` directly as GraphQL variables/root-field
-arguments.
-
-Every GraphQL endpoint can set:
-
-- `requestTimeoutMs`: timeout for HTTP calls. Defaults to `60000`.
-- `operationCacheTtlMs`: how long GraphQL operation metadata stays fresh. Defaults to `30000`; `0` refreshes every time.
-- `selectionDepth`: maximum depth for generated selection sets. Defaults to `2`; maximum `5`.
-- `disabled`: omit the endpoint from Caplets discovery. Defaults to `false`.
-
-### HTTP APIs
-
-Use `httpApis` for simple HTTP APIs that do not have an OpenAPI spec. Each action is an
-explicitly configured tool; Caplets does not discover routes, import curl commands, or execute
-shell snippets.
-
-```json
-{
- "name": "Status API",
- "description": "Read and update deployment status through HTTP actions.",
- "baseUrl": "https://api.example.com",
- "auth": { "type": "bearer", "token": "$env:STATUS_API_TOKEN" },
- "maxResponseBytes": 1000000,
- "actions": {
- "get_status": {
- "method": "GET",
- "path": "/status/{service}",
- "description": "Fetch status for one service.",
- "inputSchema": {
- "type": "object",
- "properties": {
- "service": { "type": "string" },
- "verbose": { "type": "boolean" }
- },
- "required": ["service"]
- },
- "query": { "verbose": "$input.verbose" }
- },
- "set_status": {
- "method": "POST",
- "path": "/status/{service}",
- "jsonBody": { "state": "$input.state", "note": "$input.note" }
- }
- }
-}
-```
-
-HTTP API actions support `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. `baseUrl` must be HTTPS
-except loopback URLs, must not include credentials, query, or fragment, and action `path` values
-must start with `/` and be URL paths that cannot change origin or escape the base URL path.
-
-Action mappings can set `query`, `headers`, and `jsonBody`. `query` and `headers` must resolve
-to object maps whose values are strings, numbers, or booleans. `jsonBody` may use literals,
-nested arrays/objects, `$input.field` references, or `$input` for the whole argument object.
-Path placeholders such as `{service}` are read directly from `call_tool.args` and URL-encoded.
-Configured action headers cannot set managed headers such as `authorization`, `host`,
-`content-length`, `connection`, or `content-type`; JSON bodies set `content-type` automatically.
-
-HTTP API auth supports `none`, `bearer`, `headers`, `oauth2`, and `oidc`, matching OpenAPI and
-GraphQL. Responses are returned as structured content with `status`, `statusText`, safe headers,
-parsed `body` when present, and `elapsedMs`; non-2xx responses set `isError`, redirects are rejected,
-timeouts are enforced, response bodies are capped by `maxResponseBytes` (default `1000000`), and
-errors redact secrets.
-
-### CLI Tools
-
-Use `cliTools` for curated local command-line workflows. Each action is an explicitly configured
-tool; Caplets does not expose arbitrary shell access and always spawns `command` plus `args`
-without shell interpolation.
-
-```json
-{
- "name": "Repository CLI",
- "description": "Run curated repository workflows through local CLI commands.",
- "cwd": "/home/you/project",
- "timeoutMs": 60000,
- "maxOutputBytes": 1000000,
- "actions": {
- "git_status": {
- "description": "Show concise Git working tree status.",
- "command": "git",
- "args": ["status", "--short"],
- "annotations": { "readOnlyHint": true }
- },
- "run_tests": {
- "description": "Run the package test script.",
- "command": "pnpm",
- "args": ["run", "test"],
- "timeoutMs": 120000,
- "annotations": { "readOnlyHint": true }
- }
- }
-}
-```
-
-CLI actions can set `inputSchema`, `outputSchema`, `env`, action-level `cwd`, `timeoutMs`,
-`maxOutputBytes`, `output: {"type":"json"}`, and MCP annotations. `$input.field` references are
-supported inside `args`, `env`, and `cwd` strings. Caplets performs basic required-field and
-primitive-type validation before spawning. Results are returned as structured content with
-`exitCode`, `stdout`, `stderr`, and `elapsedMs`; non-zero exits set `isError`.
-
-Generate and add a CLI Caplet manifest from a repository:
-
-```sh
-caplets add cli repo-tools --repo . --include git,gh,package
-```
-
-`caplets add` writes generated Markdown Caplet files to `./.caplets/.md` by default.
-Pass `-g` or `--global` to write to the user Caplets root, `--print` to review the generated
-manifest without writing, `--output ` for an explicit destination, or `--force` to overwrite
-an existing destination file.
-
-### Caplet Sets
-
-Use `capletSets` to expose another Caplets collection as nested Caplets. Each child Caplet appears
-as one downstream tool and supports the full Caplets operation set: `inspect`, `check`,
-`tools`, `search_tools`, `describe_tool`, and `call_tool`.
-
-```json
-{
- "capletSets": {
- "team": {
- "name": "Team Caplets",
- "description": "Use the team's shared Caplets collection.",
- "configPath": "./team-caplets/config.json",
- "capletsRoot": "./team-caplets/caplets"
- }
- }
-}
-```
-
-`configPath` and `capletsRoot` are independently optional, but at least one is required. Child
-collections are isolated from the parent collection; they use their own config and Caplet files,
-with their own `defaultSearchLimit` and `maxSearchLimit` when set on the `capletSets` entry.
-Recursive nesting is supported, and repeated source paths are rejected as cycles.
-
-Markdown Caplet files use `capletSet` frontmatter:
-
-```yaml
----
-name: Team Caplets
-description: Use the team's shared Caplets collection.
-capletSet:
- configPath: ./team-caplets/config.json
- capletsRoot: ./team-caplets/caplets
----
-```
-
-Add MCP, OpenAPI, GraphQL, and HTTP API Caplets with the same destination options:
-
-```sh
-caplets add mcp local-tools --command node --arg ./server.mjs
-caplets add mcp remote-tools --url https://mcp.example.com/mcp --transport http --token-env MCP_TOKEN
-caplets add openapi users --spec ./openapi.json --base-url https://api.example.com --token-env USERS_API_TOKEN
-caplets add graphql catalog --endpoint-url https://api.example.com/graphql --schema ./schema.graphql
-caplets add graphql catalog-live --endpoint-url https://api.example.com/graphql --introspection
-caplets add http status-api --base-url https://api.example.com --action get_status:GET:/status/{service}
-```
-
-For `caplets add mcp`, use `--command` with repeated `--arg`, optional `--cwd`, and repeated
-`--env KEY=VALUE` for stdio servers, or `--url` with `--transport http|sse` for remote servers.
-For HTTP authentication, pass `--token-env ` so generated manifests reference `$env:ENV`
-instead of embedding raw bearer tokens.
-
-### Authentication
-
-Remote servers can use:
-
-- `{"type": "none"}`
-- `{"type": "bearer", "token": "$env:TOKEN"}`
-- `{"type": "headers", "headers": {"x-api-key": "$env:API_KEY"}}`
-- `{"type": "oauth2", ...}`
-- `{"type": "oidc", ...}`
+The deterministic benchmark compares flat MCP exposure with Caplets over the same mock
+servers:
-For OAuth/OIDC-backed MCP, OpenAPI, GraphQL, and HTTP API Caplets, authenticate once with:
+| Initial surface | Direct MCP | Caplets | Reduction |
+| ----------------------- | ------------: | -----------: | ------------: |
+| Visible tools | 215 | 7 | 96.7% fewer |
+| Serialized payload | 63,250 bytes | 12,720 bytes | 79.9% smaller |
+| Approx. context surface | 15,813 tokens | 3,180 tokens | 12,633 fewer |
-```sh
-caplets auth login
-```
-
-For headless terminals:
-
-```sh
-caplets auth login --no-open
-```
-
-In local mode, OAuth/OIDC tokens are stored under
-`${XDG_STATE_HOME:-~/.local/state}/caplets/auth/.json` on Unix-like platforms and
-`%LOCALAPPDATA%\caplets\auth\.json` on Windows. Token files use owner-only file
-permissions where the platform supports them. In `CAPLETS_MODE=remote`, `caplets auth list` shows
-local and remote auth targets with a `source` field. `caplets auth login ` and
-`caplets auth logout ` use the matching scope when it is unambiguous; if the same ID exists
-in multiple scopes, pass `--project`, `--global`, or `--remote`. Remote OAuth/OIDC credentials are
-stored server-side and are not returned to the local client. Caplets supports well-known OAuth/OIDC
-discovery and dynamic client registration when advertised. When a token expires, run
-`caplets auth login ` again.
+The landing-page live Pi eval reports Caplets Code Mode passing the same 10/10 real-world
+large MCP tasks as direct MCP and Executor.sh while using 72.0% fewer request + output
+tokens than direct vanilla MCP. Live runs are model- and environment-dependent; the
+deterministic benchmark is the reproducible claim.
-Auth target flags are mutually exclusive. `caplets auth list --project`,
-`caplets auth list --global`, and `caplets auth list --remote` filter the listing to one scope.
+See [docs/benchmarks/coding-agent.md](https://github.com/spiritledsoftware/caplets/blob/main/docs/benchmarks/coding-agent.md) for methodology and reproduction commands.
-To inspect or remove stored OAuth credentials:
+## Repository
-```sh
-caplets auth list
-caplets auth logout
-```
-
-To list configured Caplets without starting downstream backends:
+This monorepo uses pnpm. Published packages support Node.js `>=22`; repo verification uses
+the root toolchain, which requires Node.js `>=24`.
```sh
-caplets list
-caplets list --all
-caplets list --json
-```
-
-Human output includes a `source` column. JSON output includes each Caplet's `source`, `path`, and
-`shadows` metadata. If a project source overrides a user source, human output prints a warning such
-as `Warning: project Caplet GitHub shadows global Caplet at /path/to/github.md`.
-
-For safety, `caplets add` and `caplets install` reject symlinked output paths and symlinked parent
-directories instead of following them.
-
-### Optional Server Settings
-
-Every server can set:
-
-- `startupTimeoutMs`: timeout for starting or checking the downstream server. Defaults to `10000`.
-- `callTimeoutMs`: timeout for downstream tool calls. Defaults to `60000`.
-- `toolCacheTtlMs`: how long downstream tool metadata stays fresh. Defaults to `30000`; `0` refreshes every time.
-- `disabled`: omit the server from Caplets discovery. Defaults to `false`.
-
-## Add Caplets To An MCP Client
-
-Configure your MCP client to run Caplets as a stdio server:
-
-```json
-{
- "mcpServers": {
- "caplets": {
- "command": "caplets",
- "args": ["serve"]
- }
- }
-}
-```
-
-If your client starts the configured command directly, `caplets` without arguments also
-starts the MCP server. `serve` is explicit and recommended for clarity.
-
-`caplets serve` watches the effective user config, project config, user Caplet files, and
-project Caplet files. Adding, editing, disabling, or removing a Caplet updates the
-top-level MCP tool list without restarting Caplets. When an MCP-backed Caplet changes or is
-removed, Caplets closes only that affected downstream connection; unrelated Caplets and
-their downstream connections keep running.
-
-## Quick Integration Setup
-
-Run the interactive setup flow to choose one or more agent integrations:
-
-```bash
-caplets setup
-```
-
-For scripted setup, pass the integration explicitly:
-
-```bash
-caplets setup codex
-caplets setup claude-code
-caplets setup opencode
-caplets setup pi
-caplets setup mcp-client --output ./caplets.mcp.json
-```
-
-Preview the actions before changing anything:
-
-```bash
-caplets setup codex --dry-run
-```
-
-For native integrations that should connect to a remote Caplets HTTP service:
-
-```bash
-caplets setup codex --remote-url https://caplets.example.com/caplets
-caplets setup claude-code --remote-url https://caplets.example.com/caplets
-caplets setup opencode --remote-url https://caplets.example.com/caplets
-```
-
-For Codex and Claude Code, `caplets setup` uses each harness's MCP configuration command:
-`codex mcp add caplets -- caplets serve` and
-`claude mcp add --transport stdio --scope user caplets -- caplets serve`. Generic MCP
-clients still require an explicit `--output` path because their config locations are not
-standardized. The setup command does not store secrets or start `caplets serve`.
-
-## Additional Native Integrations
-
-OpenCode and Pi support true native tool registration. Those integrations expose one
-prefixed tool per configured Caplet, such as `caplets__github`, while reusing the same
-Caplets config and backend runtime.
-
-- [`@caplets/opencode`](packages/opencode/README.md): OpenCode plugin that injects prompt guidance through plugin hooks instead of editing `opencode.json`.
-- [`@caplets/pi`](packages/pi/README.md): Pi extension installable with `pi install npm:@caplets/pi`, with guidance provided through Pi tool prompt snippets and guidelines.
-
-Native integrations hot reload config and Caplet file edits through the same runtime used by
-`caplets serve`. Existing native tools execute against the latest valid config without host
-restart. Pi also refreshes newly added Caplet tools at runtime and deactivates removed Caplet
-tools when Pi's active-tool APIs are available. OpenCode's current plugin API snapshots the
-tool inventory at plugin load, so adding, removing, or renaming OpenCode native tools still
-requires restarting OpenCode; already-registered tools execute against live Caplets state, and
-prompt guidance is rebuilt from current Caplets state for those initially registered tools only.
-Newly added OpenCode tools are not advertised until restart.
-
-## How Agents Use It
-
-Caplets initially exposes one MCP tool per enabled Caplet. If the config has `filesystem`,
-`docs`, and `users`, the client sees three top-level tools: `filesystem`, `docs`, and
-`users`.
-
-Each generated Caplet tool accepts an `operation`:
-
-```json
-{
- "operation": "tools"
-}
-```
-
-Search within a selected server:
-
-```json
-{
- "operation": "search_tools",
- "query": "read file",
- "limit": 10
-}
-```
-
-Inspect one exact downstream tool:
-
-```json
-{
- "operation": "describe_tool",
- "tool": "read_file"
-}
-```
-
-Call one exact downstream tool:
-
-```json
-{
- "operation": "call_tool",
- "tool": "read_file",
- "arguments": {
- "path": "/home/you/code/project/README.md"
- }
-}
-```
-
-Available operations:
-
-- `inspect`: return the configured capability card without starting the downstream server.
-- `check`: verify the selected backend, whether MCP, OpenAPI, GraphQL, HTTP, CLI, or nested Caplets.
-- `tools`: return compact downstream tool metadata.
-- `search_tools`: search downstream tool names and descriptions within this Caplet.
-- `describe_tool`: return full metadata for one exact downstream tool.
-- `call_tool`: invoke one exact downstream tool with JSON object arguments.
-
-Requests are strict: operation-specific extra fields are rejected, and `call_tool` requires
-`arguments` to be a JSON object.
-
-Discovery operations (`inspect`, `check`, `tools`, `search_tools`, and
-`describe_tool`) return wrapper-generated results whose `structuredContent.caplets` field
-identifies the Caplet with `id`, plus backend, operation, status, and elapsed time when
-available. Discovery result objects and compact tool entries also use `id` for the
-configured Caplet identity. Compact `tools` and `search_tools` entries may include
-input/output schema hashes; treat those
-hashes as reuse hints for a schema you have already inspected, not as a replacement for
-`describe_tool` when arguments, output, or semantics are unclear.
-
-Direct `call_tool` preserves the downstream tool result shape instead of wrapping it in
-`structuredContent.result`. When the result can carry MCP metadata, Caplets adds
-`_meta.caplets` with the same call context and status. It may also include artifact metadata
-for paths mentioned by downstream content, such as screenshots, snapshots, logs, downloads,
-or other saved files. Artifact `displayPath` values are either absolute local paths or
-relative to the downstream MCP server process, not necessarily relative to the current
-project or Caplets process.
-
-Code Mode is the default exposure because it keeps discovery, filtering, execution, and
-summary work inside one compact tool call. To expose the older progressive wrapper tools,
-set `options.exposure` to `progressive` or `progressive_and_code_mode`.
-
-## Development
-
-```sh
-pnpm install
-pnpm dev
+pnpm install --frozen-lockfile
+pnpm verify
```
-Useful commands:
+Useful focused checks:
```sh
-pnpm build
-pnpm test
-pnpm typecheck
-pnpm lint
pnpm format:check
-pnpm schema:generate
-pnpm schema:check
-pnpm verify
+pnpm lint
+pnpm typecheck
+pnpm test
+pnpm benchmark:check
+pnpm build
```
-`pnpm dev` rebuilds Caplets source changes and restarts the local stdio MCP server from
-`dist/index.js`. Use it for local development, not as the command configured in an MCP
-client, because build logs are written to stdout. Runtime config hot reload is built into
-normal `caplets serve` and does not require `pnpm dev`.
-
-## Product Notes
+Package map:
-The product requirements document lives at
-[`docs/product/caplets-progressive-mcp-disclosure-prd.md`](docs/product/caplets-progressive-mcp-disclosure-prd.md).
-It describes the progressive MCP disclosure model, configuration rules, MVP tool surface,
-security expectations, and non-goals.
+- `packages/core` - runtime, config, Code Mode, backends, MCP server, remote attach
+- `packages/cli` - published `caplets` binary
+- `packages/opencode` - native OpenCode plugin
+- `packages/pi` - native Pi extension
+- `packages/benchmarks` - deterministic and opt-in live benchmarks
+- `apps/landing` - public site at `caplets.dev`
-Caplets intentionally does not provide a hosted service, GUI, cross-server flattened tool
-search, automatic MCP client config import, or namespaced flattened tool IDs such as
-`server.tool`.
-
-Progressive disclosure is context management, not a security boundary. Caplets reduces the
-tool surface shown to the agent up front, but downstream MCP servers remain responsible for
-their own tool behavior and any client-side confirmations.
-
-## Release Flow
-
-User-facing changes should include a changeset:
-
-```sh
-pnpm changeset
-```
+Long-lived docs:
-Merging changesets to `main` lets the release workflow open a version PR. Merging that
-version PR publishes the package to npm through trusted publishing.
+- [Code Mode PRD](https://github.com/spiritledsoftware/caplets/blob/main/docs/product/caplets-code-mode-prd.md)
+- [Architecture](https://github.com/spiritledsoftware/caplets/blob/main/docs/architecture.md)
+- [ADR 0001: Code Mode default exposure](https://github.com/spiritledsoftware/caplets/blob/main/docs/adr/0001-code-mode-default-exposure.md)
+- [Benchmark methodology](https://github.com/spiritledsoftware/caplets/blob/main/docs/benchmarks/coding-agent.md)
+- [Native integrations](https://github.com/spiritledsoftware/caplets/blob/main/docs/native-integrations.md)
+- [Project Binding](https://github.com/spiritledsoftware/caplets/blob/main/docs/project-binding.md)
## License
diff --git a/docs/adr/0001-code-mode-default-exposure.md b/docs/adr/0001-code-mode-default-exposure.md
new file mode 100644
index 00000000..04e481b9
--- /dev/null
+++ b/docs/adr/0001-code-mode-default-exposure.md
@@ -0,0 +1,37 @@
+# ADR 0001: Make Code Mode The Default Backend Exposure
+
+## Status
+
+Accepted
+
+## Context
+
+Early Caplets work centered on progressive disclosure: show one capability card first, then let the agent inspect and call downstream operations through scoped wrapper tools. That model reduced flat tool-list bloat, but larger workflows still required repeated model/tool round trips for discovery, schema inspection, execution, filtering, and synthesis.
+
+The current implementation supports multiple exposure modes and defaults global config to `code_mode`. The landing page, benchmark report, native integrations, and runtime code all treat Code Mode as the main path for agents using configured backends.
+
+Source code remains the source of truth for behavior. This ADR records the decision and rationale, not a substitute for `packages/core` or generated schemas.
+
+## Decision
+
+Code Mode is the default backend exposure for Caplets.
+
+Configured backends should enter the primary agent surface as generated `caplets.` handles inside a Code Mode TypeScript script. Agents should perform discovery, exact schema inspection, tool calls, filtering, joins, and compact synthesis inside that script.
+
+Progressive wrapper tools remain supported through the `progressive` and `progressive_and_code_mode` exposure modes. Direct MCP exposure remains supported through `direct` and `direct_and_code_mode` when the downstream surface should be registered directly.
+
+## Consequences
+
+- Product docs should describe Caplets as Code Mode first, not only as a progressive disclosure gateway.
+- Progressive disclosure remains an implementation and compatibility model, but not the primary product frame.
+- Benchmarks should report Code Mode separately from progressive modes.
+- Agent guidance should prefer compact one-pass Code Mode scripts for multi-step work.
+- Long-lived docs should preserve progressive exposure details as an alternate mode, not as the main PRD.
+
+## Evidence
+
+- `packages/core/src/config.ts` defaults `options.exposure` to `code_mode`.
+- `packages/core/src/serve/session.ts` registers a `code_mode` MCP tool when Code Mode Caplets are callable.
+- `packages/core/src/native/tools.ts` defines `caplets__code_mode` for native integrations.
+- `docs/benchmarks/coding-agent.md` records deterministic Code Mode workflow results and live benchmark guidance.
+- `apps/landing/src/pages/index.astro` presents Code Mode as the primary evaluated mode.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 00000000..e3bd3f0b
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,120 @@
+# Caplets Architecture
+
+Caplets is a Code Mode and capability gateway for coding agents. It turns configured backends into Caplet handles, optional progressive wrapper tools, and optional direct MCP surfaces.
+
+Source code is authoritative. This document summarizes the architecture, but implementation details in `packages/core`, generated schemas, tests, and package entrypoints win when docs drift.
+
+## Runtime Layers
+
+### Configuration And Caplet Sources
+
+The core config loader accepts user config, project config, and Markdown Caplet files. The schema source of truth is Zod in `packages/core/src/config.ts`, with generated JSON Schemas in `schemas/`.
+
+Supported backend families are:
+
+- `mcpServers`
+- `openapiEndpoints`
+- `graphqlEndpoints`
+- `httpApis`
+- `cliTools`
+- `capletSets`
+
+Project sources override user/global sources. Source-aware inspection reports where each Caplet came from and warns when one Caplet shadows another.
+
+### Engine
+
+`packages/core/src/engine.ts` owns the active config, backend managers, config reload behavior, and execution dispatch. Reload keeps the last known-good config if parsing or validation fails.
+
+Backend managers provide a common shape for listing tools, searching tools, describing exact tools, calling tools, and checking readiness. MCP-backed Caplets additionally support resources, resource templates, prompts, and completion.
+
+### Exposure Policy
+
+`packages/core/src/exposure/policy.ts` resolves one exposure value into three booleans:
+
+- `codeMode`
+- `progressive`
+- `direct`
+
+The global default is `code_mode`. Per-Caplet config may choose `direct`, `progressive`, `code_mode`, `direct_and_code_mode`, or `progressive_and_code_mode`.
+
+### MCP Server
+
+`packages/core/src/serve/session.ts` registers the user-facing MCP surface.
+
+- Code Mode exposure registers one `code_mode` tool that runs TypeScript against generated `caplets.` handles.
+- Progressive exposure registers one wrapper tool per Caplet.
+- Direct exposure registers discovered downstream MCP tools, resources, resource templates, and prompts.
+
+The HTTP server in `packages/core/src/serve/http.ts` exposes MCP, control, and health endpoints for self-hosting and remote clients. Stdio remains the local MCP transport for ordinary client config.
+
+### Code Mode
+
+Code Mode is implemented under `packages/core/src/code-mode/`.
+
+The runtime generates TypeScript declarations from the current callable Caplets, statically checks the submitted script, runs it in the sandbox, bridges handle methods back to the native service, stores logs when configured, and returns JSON-serializable results with diagnostics.
+
+The intended agent pattern is one compact script:
+
+1. choose handles
+2. inspect or check only when useful
+3. search for candidate operations
+4. describe exact operations when schemas are needed
+5. call tools
+6. filter, join, and summarize inside the script
+7. return compact decision-ready evidence
+
+### Native Service
+
+`packages/core/src/native/service.ts` powers OpenCode and Pi. It uses the same engine and exposure policy as MCP serving, then exposes native tools with agent-specific prompt guidance.
+
+`caplets__code_mode` is the native Code Mode entrypoint. `caplets__` tools exist for progressive exposure. Direct native exposure registers operation-level tools named `caplets____`.
+
+### Remote Control
+
+Remote control under `packages/core/src/remote-control/` lets CLI and native integrations operate against a self-hosted or Cloud Caplets service. Remote mode uses server-owned config, auth, and execution, with local/project overlays where supported.
+
+### Project Binding
+
+Project Binding under `packages/core/src/project-binding/` connects a local project root to a remote runtime. The foreground attach loop owns session state, heartbeat, reconnect behavior, sync preflight, and terminal recovery commands.
+
+`docs/project-binding.md` is the living operational contract for Project Binding.
+
+## Backend Contracts
+
+### MCP
+
+MCP-backed Caplets preserve downstream tool results and expose resources, templates, prompts, and completion when the downstream server supports them. Direct exposure can register those downstream surfaces directly.
+
+### OpenAPI, GraphQL, And HTTP
+
+OpenAPI, GraphQL, and HTTP backends expose explicit operation/action tools. They do not synthesize MCP resources or prompts. HTTP-like backends enforce safe URL handling, bounded response bodies, timeouts, and redacted errors.
+
+### CLI Tools
+
+CLI-backed Caplets expose curated actions only. Actions spawn declared commands and args without shell interpolation. Inputs are validated before spawn, and outputs are bounded.
+
+### Caplet Sets
+
+Caplet sets expose another Caplets collection as a nested backend. This lets a team or repository share a Caplet catalog without flattening every child backend into the parent config.
+
+## Auth And Secrets
+
+Auth supports none, bearer, headers, OAuth2, and OIDC where the backend family supports them. OAuth/OIDC state is stored outside config. Errors and diagnostics redact configured secrets.
+
+Cloud Auth stores hosted credentials and a selected workspace. `caplets attach` refreshes expired hosted credentials before creating Binding Sessions and fails closed when refresh credentials are revoked.
+
+## Distribution And Deployment
+
+The public CLI package is `caplets`. The native packages are `@caplets/opencode` and `@caplets/pi`.
+
+The repo includes a source-build `Dockerfile` and `docker-compose.yml` for self-hosting the HTTP service. Release workflows publish npm packages and can publish the service image.
+
+## Benchmark Architecture
+
+`packages/benchmarks` owns deterministic and opt-in live benchmarks.
+
+- Deterministic benchmarks are stable, credential-free, and committed through `docs/benchmarks/coding-agent.md`.
+- Live benchmarks require local agents, credentials, selected models, and explicit `CAPLETS_BENCH_LIVE=1`.
+- Pi eval modes include Code Mode, progressive Code Mode, direct Code Mode, vanilla MCP, and Executor MCP competitors.
+
+Benchmark output is product evidence, not runtime configuration truth. Runtime truth lives in `packages/core`.
diff --git a/docs/plans/2026-05-12-cli-inspection-polish.md b/docs/plans/2026-05-12-cli-inspection-polish.md
deleted file mode 100644
index 851ac039..00000000
--- a/docs/plans/2026-05-12-cli-inspection-polish.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# Caplets CLI Inspection Polish
-
-## Summary
-
-Add a small read-only inspection suite to the existing Commander-based CLI:
-
-- `caplets --version` prints the package version from `package.json`.
-- `caplets list` lists enabled configured Caplets by default.
-- `caplets list --all` includes disabled entries.
-- `caplets list --json` emits stable machine-readable JSON.
-- `caplets config path` prints the effective user config path, honoring `CAPLETS_CONFIG`.
-- `caplets config paths` prints user config, project config, Caplets roots, auth directory, and trust/env state.
-
-No runtime MCP server behavior changes.
-
-## Key Changes
-
-- Update `src/cli.ts` to import `package.json` version and register Commander version support.
-- Add a `list` command that loads config through existing `loadConfig(envConfigPath())`, builds a `ServerRegistry`, and prints:
- - text columns: `server`, `backend`, `status`, `name`
- - JSON array objects: `{ server, backend, name, description, disabled, status }`
-- Add `config` subcommands:
- - `config path`: prints only `resolveConfigPath(envConfigPath())`
- - `config paths`: prints resolved user config path, project config path, user root, project root, auth dir, whether `CAPLETS_CONFIG` is set, and whether `CAPLETS_TRUST_PROJECT_CAPLETS` is enabled
- - `--json` on `config paths` for scriptable output
-- Keep existing `init` and `auth` behavior unchanged.
-- Keep disabled Caplets excluded from `caplets list` unless `--all` is passed.
-
-## Interfaces
-
-Public CLI additions:
-
-```sh
-caplets --version
-caplets list
-caplets list --all
-caplets list --json
-caplets config path
-caplets config paths
-caplets config paths --json
-```
-
-Recommended text output shapes:
-
-```txt
-server backend status name
-docs mcp not_started Hosted Docs
-users openapi not_started Users API
-```
-
-```txt
-userConfig: /home/you/.caplets/config.json
-projectConfig: /repo/.caplets/config.json
-userRoot: /home/you/.caplets
-projectRoot: /repo/.caplets
-authDir: /home/you/.caplets/auth
-envConfig: unset
-projectCapletsTrusted: false
-```
-
-## Test Plan
-
-Add focused tests in `test/cli.test.ts`:
-
-- `runCli(["--version"])` prints `package.json` version and does not throw.
-- `runCli(["list"])` prints only enabled MCP/OpenAPI/GraphQL Caplets.
-- `runCli(["list", "--all"])` includes disabled Caplets with `disabled` status.
-- `runCli(["list", "--json"])` emits parseable JSON and redacts/no-ops on auth secrets.
-- `runCli(["config", "path"])` honors `CAPLETS_CONFIG`.
-- `runCli(["config", "paths", "--json"])` returns stable resolved paths and env/trust flags.
-- Unsupported options still raise `REQUEST_INVALID`.
-
-Run:
-
-```sh
-pnpm format:check
-pnpm lint
-pnpm typecheck
-pnpm test
-pnpm build
-```
-
-## Assumptions
-
-- "Servers" means all configured Caplet backends: MCP servers, OpenAPI endpoints, and GraphQL endpoints.
-- Human-readable text is the default; `--json` is the stable automation contract.
-- The inspection commands must be read-only and must not start downstream servers, load remote specs, or validate live OAuth.
-- `caplets serve` remains handled by `src/index.ts` as the MCP server entrypoint.
diff --git a/docs/plans/2026-05-12-graphql-backend.md b/docs/plans/2026-05-12-graphql-backend.md
deleted file mode 100644
index c44b44ac..00000000
--- a/docs/plans/2026-05-12-graphql-backend.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# GraphQL Backend Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add a native GraphQL backend to Caplets, with OAuth/OIDC discovery support shared by GraphQL and OpenAPI.
-
-**Architecture:** Caplets gains a third backend family, `graphql`, alongside MCP and OpenAPI. GraphQL endpoints expose the same Caplet wrapper operations and use either configured GraphQL documents or generated tools from schema root fields. Auth is generalized so `caplets auth login/list/logout` works for OAuth/OIDC MCP, OpenAPI, and GraphQL Caplets.
-
-**Tech Stack:** TypeScript ESM, Node.js 22+, `@modelcontextprotocol/sdk`, GraphQL.js, Zod, Vitest, rolldown, oxfmt, oxlint.
-
----
-
-## Task 1: Add GraphQL Config And Caplet File Support
-
-**Files:**
-
-- Modify: `package.json`
-- Modify: `src/config.ts`
-- Modify: `src/caplet-files.ts`
-- Modify: `src/registry.ts`
-- Modify: `scripts/generate-config-schema.ts`
-- Modify: `schemas/caplets-config.schema.json`
-- Modify: `schemas/caplet.schema.json`
-- Test: `test/config.test.ts`
-- Test: `test/registry.test.ts`
-
-- [x] Add `graphql` as a runtime dependency.
-- [x] Add top-level `graphqlEndpoints` config keyed by Caplet ID.
-- [x] Add Markdown frontmatter key `graphqlEndpoint`.
-- [x] Validate exactly one GraphQL schema source: `schemaPath`, `schemaUrl`, or `introspection: true`.
-- [x] Add optional `operations` map, where each operation has exactly one of `document` or `documentPath`, plus optional `operationName` and `description`.
-- [x] Support GraphQL auth types `none`, `bearer`, `headers`, `oauth2`, and `oidc`.
-- [x] Extend OpenAPI auth to support `oauth2` and `oidc`.
-- [x] Reject duplicate Caplet IDs across `mcpServers`, `openapiEndpoints`, and `graphqlEndpoints`.
-- [x] Reject untrusted project `graphqlEndpoints`, matching OpenAPI project-config safety.
-- [x] Update safe `get_caplet` detail and capability descriptions for GraphQL.
-- [x] Regenerate committed JSON schemas.
-
-## Task 2: Generalize OAuth/OIDC Auth
-
-**Files:**
-
-- Modify: `src/auth.ts`
-- Modify: `src/cli.ts`
-- Modify: `src/downstream.ts`
-- Modify: `src/openapi.ts`
-- Test: `test/auth.test.ts`
-- Test: `test/cli.test.ts`
-- Test: `test/openapi.test.ts`
-
-- [x] Add shared auth target typing for MCP remote servers, OpenAPI endpoints, and GraphQL endpoints.
-- [x] Keep existing MCP SDK OAuth flow for MCP remote servers.
-- [x] Add generic authorization-code-with-PKCE OAuth/OIDC flow for OpenAPI and GraphQL.
-- [x] Support OAuth Protected Resource Metadata discovery (`/.well-known/oauth-protected-resource`).
-- [x] Support OAuth Authorization Server Metadata discovery (`/.well-known/oauth-authorization-server`).
-- [x] Support OIDC discovery fallback (`/.well-known/openid-configuration`).
-- [x] Support dynamic client registration when `registration_endpoint` is advertised and no `clientId` is configured.
-- [x] For `auth.type: "oidc"`, include `openid` and default scopes to `openid profile email` unless scopes are configured.
-- [x] Store access tokens, refresh tokens, optional ID tokens, expiry, scope, issuer, subject, discovered metadata, and dynamic client info in token bundles.
-- [x] Ensure `caplets auth login/list/logout ` finds OAuth/OIDC MCP, OpenAPI, and GraphQL Caplets without printing secrets.
-- [x] Apply OAuth/OIDC access tokens to OpenAPI requests and remote spec loading.
-
-## Task 3: Implement GraphQL Manager
-
-**Files:**
-
-- Create: `src/graphql.ts`
-- Modify: `src/tools.ts`
-- Modify: `src/index.ts`
-- Test: `test/graphql.test.ts`
-- Test: `test/tools.test.ts`
-
-- [x] Load schemas from SDL files, introspection JSON files, remote schema files, or endpoint introspection.
-- [x] Validate configured GraphQL documents using GraphQL.js `parse` and `validate`.
-- [x] Convert configured operations into MCP-style tool metadata.
-- [x] When `operations` is omitted or empty, auto-generate tools from root `Query` and `Mutation` fields.
-- [x] Name auto-generated tools `query_` and `mutation_`.
-- [x] Generate bounded scalar-leaf selection sets with `__typename`, skipping nested fields that require arguments.
-- [x] Use default selection depth `2`; reject values above `5`.
-- [x] Convert GraphQL variables and auto-generated root field arguments into JSON Schema input schemas.
-- [x] Execute GraphQL calls with POST `{ query, variables, operationName }`.
-- [x] Return structured HTTP/GraphQL responses and mark `isError` for non-2xx HTTP responses or GraphQL `errors`.
-- [x] Add read-only annotations for query tools and destructive annotations for mutation tools.
-- [x] Enforce HTTPS except loopback, redirect rejection, request timeouts, response-size limits, and redacted auth failures.
-
-## Task 4: Documentation And Verification
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `docs/product/caplets-progressive-mcp-disclosure-prd.md`
-- Modify: `docs/plans/2026-05-12-graphql-backend.md`
-
-- [x] Document `graphqlEndpoints` JSON config and `graphqlEndpoint` Caplet files.
-- [x] Document configured-operation and auto-generated GraphQL modes.
-- [x] Document OAuth/OIDC discovery and `caplets auth` support across backends.
-- [x] Run `pnpm install`.
-- [x] Run `pnpm schema:generate`.
-- [x] Run `pnpm verify`.
-
-## Assumptions
-
-- Auto-generation includes queries and mutations by default when no operations are configured.
-- Auto-generated tools use generated selection sets only; callers do not pass custom `selectionSet` in v1.
-- Configured GraphQL `call_tool.arguments` is the variables object directly.
-- Auto-generated GraphQL `call_tool.arguments` is the root field arguments object directly.
-- GraphQL subscriptions are out of scope for v1.
-- `oidc` means browser authorization-code flow with PKCE plus OIDC discovery and ID token storage.
-- Caplets stores OIDC ID tokens for identity/status, but downstream OpenAPI/GraphQL calls use access tokens by default.
diff --git a/docs/plans/2026-05-12-hot-reload.md b/docs/plans/2026-05-12-hot-reload.md
deleted file mode 100644
index 971117b6..00000000
--- a/docs/plans/2026-05-12-hot-reload.md
+++ /dev/null
@@ -1,81 +0,0 @@
-# Hot Reload Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Make normal `caplets serve` reload config and Caplet file changes without restarting the Caplets MCP process.
-
-**Architecture:** Introduce an in-process runtime controller that owns the MCP server, registered top-level tool handles, the current registry, backend managers, and filesystem watchers. Reloads parse the effective config exactly as startup does, update registered MCP tools through `RegisteredTool` handles, invalidate only affected backend caches, and keep the last known-good config active when a reload fails.
-
-**Tech Stack:** TypeScript, Node.js `fs.watch`, `@modelcontextprotocol/sdk` `McpServer.registerTool`, Vitest.
-
----
-
-## Task 1: Runtime Controller
-
-**Files:**
-
-- Create: `src/runtime.ts`
-- Modify: `src/index.ts`
-- Test: `test/runtime.test.ts`
-
-- [ ] Add `CapletsRuntime` that creates `McpServer`, `ServerRegistry`, `DownstreamManager`, `OpenApiManager`, and `GraphQLManager`.
-- [ ] Track registered top-level Caplet tools in a `Map`.
-- [ ] Register initial enabled Caplets during startup.
-- [ ] Keep `src/index.ts` as a small CLI/server entrypoint.
-
-## Task 2: Reload Reconciliation
-
-**Files:**
-
-- Modify: `src/runtime.ts`
-- Modify: `src/downstream.ts`
-- Modify: `src/openapi.ts`
-- Modify: `src/graphql.ts`
-- Test: `test/runtime.test.ts`, `test/downstream.test.ts`, `test/openapi.test.ts`, `test/graphql.test.ts`
-
-- [ ] On reload, call existing `loadConfig()` with the same resolved paths used at startup.
-- [ ] Add new Caplets with `registerTool`.
-- [ ] Update existing Caplets with `RegisteredTool.update`.
-- [ ] Remove missing or disabled Caplets with `RegisteredTool.remove`.
-- [ ] Add `DownstreamManager.closeServer(serverId)`.
-- [ ] Add `OpenApiManager.invalidate(serverId)` and `GraphQLManager.invalidate(serverId)`.
-- [ ] Invalidate or close only backends whose normalized config changed.
-- [ ] Keep serving the previous registry and tools if reload parsing or validation fails.
-
-## Task 3: Filesystem Watching
-
-**Files:**
-
-- Modify: `src/runtime.ts`
-- Test: `test/runtime.test.ts`
-
-- [ ] Watch the effective user config file, project config file, user Caplets root, and trusted project Caplets root.
-- [ ] Use dependency-free `fs.watch` with a 250 ms debounce.
-- [ ] Watch directories so new, renamed, and deleted Markdown Caplet files are detected.
-- [ ] Recreate watchers after every successful reload because roots can change with `CAPLETS_CONFIG`.
-- [ ] Close all watchers during runtime shutdown.
-
-## Task 4: Documentation
-
-**Files:**
-
-- Modify: `README.md`
-
-- [ ] Document that `caplets serve` hot reloads config and Caplet file changes by default.
-- [ ] Document last known-good behavior for invalid edits.
-- [ ] Document that changed or removed MCP-backed Caplets close only their affected downstream connection.
-- [ ] Keep `pnpm dev` documented as source rebuild/restart tooling, separate from runtime config hot reload.
-
-## Task 5: Verification
-
-**Files:**
-
-- Modify tests as needed.
-
-- [ ] Run targeted tests for runtime reload and manager invalidation.
-- [ ] Run `pnpm format:check`.
-- [ ] Run `pnpm lint`.
-- [ ] Run `pnpm typecheck`.
-- [ ] Run `pnpm schema:check`.
-- [ ] Run `pnpm test`.
-- [ ] Run `pnpm build`.
diff --git a/docs/plans/2026-05-12-mcp-backed-caplet-files.md b/docs/plans/2026-05-12-mcp-backed-caplet-files.md
deleted file mode 100644
index be75b815..00000000
--- a/docs/plans/2026-05-12-mcp-backed-caplet-files.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# MCP-Backed Caplet Files Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
-
-**Goal:** Add skill-like Markdown Caplet files while keeping every Caplet backed by exactly one MCP server.
-
-**Architecture:** Caplets remain executable MCP capability wrappers, not standalone skills. Existing `config.json` entries are normalized into Caplets, and Markdown files add richer capability cards with required `mcpServer` frontmatter. Runtime operations become Caplet-first: `get_caplet` and `check_mcp_server` replace `get_server` and `check_server`.
-
-**Tech Stack:** TypeScript ESM, Node.js 22+, `@modelcontextprotocol/sdk`, Zod, `vfile-matter`, Vitest, rolldown, oxfmt, oxlint.
-
----
-
-## Task 1: Add Caplet File Loading
-
-**Files:**
-
-- Modify: `package.json`
-- Modify: `src/config.ts`
-- Create: `src/caplet-files.ts`
-- Test: `test/config.test.ts`
-
-- [x] Add the `vfile-matter` runtime dependency.
-- [x] Create a Caplet frontmatter schema that requires fenced YAML frontmatter with `name`, `description`, and `mcpServer`.
-- [x] Discover top-level `*.md` files and one-level `*/CAPLET.md` files under both the user Caplets directory and project `.caplets` directory.
-- [x] Derive IDs from paths: `github.md` -> `github`, `linear/CAPLET.md` -> `linear`.
-- [x] Reject duplicate Caplet IDs within the same root.
-- [x] Normalize Caplet files into the existing `mcpServers` map shape, with `name` and `description` outside `mcpServer`.
-- [x] Merge source precedence as user config, user Caplet files, project config, trusted project Caplet files.
-- [x] Preserve Markdown body and `tags` on normalized Caplet config.
-- [x] Add tests for loading top-level and directory Caplets, project override precedence, same-ID config override, missing `mcpServer`, and duplicate IDs.
-
-## Task 2: Rename Runtime Operations To Caplet-First Language
-
-**Files:**
-
-- Modify: `src/tools.ts`
-- Modify: `src/registry.ts`
-- Modify: `src/index.ts`
-- Test: `test/tools.test.ts`
-- Test: `test/registry.test.ts`
-- Test: `test/benchmark.test.ts`
-
-- [x] Rename operation `get_server` to `get_caplet`.
-- [x] Rename operation `check_server` to `check_mcp_server`.
-- [x] Do not keep compatibility aliases for old operation names.
-- [x] Update request validation, operation descriptions, examples, errors, and tests.
-- [x] Return full Caplet detail from `get_caplet`, including `caplet`, `name`, `description`, optional `tags`, optional `body`, and safe `mcpServer` metadata. Do not expose local source paths.
-- [x] Ensure `get_caplet` does not start or probe the downstream MCP server.
-- [x] Keep `list_tools`, `search_tools`, `get_tool`, and `call_tool` behavior unchanged.
-
-## Task 3: Add Caplet Schema Generation
-
-**Files:**
-
-- Modify: `src/config.ts`
-- Modify: `scripts/generate-config-schema.ts`
-- Create: `schemas/caplet.schema.json`
-- Test: `test/config.test.ts`
-
-- [x] Export a generated JSON Schema for Caplet frontmatter.
-- [x] Update schema generation/checking to maintain both `schemas/caplets-config.schema.json` and `schemas/caplet.schema.json`.
-- [x] Add a drift test for the committed Caplet schema.
-- [x] Run schema generation after implementation.
-
-## Task 4: Update CLI Starters And Documentation
-
-**Files:**
-
-- Modify: `src/cli.ts`
-- Modify: `README.md`
-- Modify: `docs/product/caplets-progressive-mcp-disclosure-prd.md`
-- Test: `test/cli.test.ts`
-
-- [x] Keep `caplets init` writing the existing plain `config.json` starter.
-- [x] Add README documentation for Markdown Caplet files and directory Caplets.
-- [x] Document that every Caplet file must include `mcpServer`; serverless Caplets are out of scope.
-- [x] Update documented operations to `get_caplet` and `check_mcp_server`.
-- [x] Ensure CLI tests still validate starter config behavior.
-
-## Task 5: Verification And Final Review
-
-**Files:**
-
-- All touched files.
-
-- [x] Run `pnpm install` if the lockfile needs the new `vfile-matter` dependency.
-- [x] Run `pnpm format:check`.
-- [x] Run `pnpm lint`.
-- [x] Run `pnpm typecheck`.
-- [x] Run `pnpm schema:check`.
-- [x] Run `pnpm test`.
-- [x] Run `pnpm build`.
-- [x] Perform a final review for secret leakage in `get_caplet` safe backend metadata.
diff --git a/docs/plans/2026-05-13-http-actions-backend.md b/docs/plans/2026-05-13-http-actions-backend.md
deleted file mode 100644
index 69bdff2c..00000000
--- a/docs/plans/2026-05-13-http-actions-backend.md
+++ /dev/null
@@ -1,93 +0,0 @@
-# HTTP Actions Backend Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add a native HTTP actions backend for non-OpenAPI APIs, where each Caplet exposes explicitly configured HTTP actions as discoverable tools.
-
-**Architecture:** Caplets gains a fourth backend family, `http`, alongside MCP, OpenAPI, and GraphQL. HTTP action Caplets define a `baseUrl`, auth, timeout/output limits, and a map of named actions. Each action becomes one MCP-style tool. Calls build an HTTP request from structured argument mappings, execute it directly, and return structured status, headers, body, and timing data.
-
-**Tech Stack:** TypeScript ESM, Node.js 22+, `@modelcontextprotocol/sdk`, Zod, Vitest, rolldown, oxfmt, oxlint.
-
----
-
-## Task 1: Add HTTP Actions Config And Caplet File Support
-
-**Files:**
-
-- Modify: `src/config.ts`
-- Modify: `src/caplet-files.ts`
-- Modify: `scripts/generate-config-schema.ts` if needed
-- Modify: `schemas/caplets-config.schema.json`
-- Modify: `schemas/caplet.schema.json`
-- Test: `test/config.test.ts`
-
-- [x] Add `HttpApiConfig` and `HttpActionConfig` types.
-- [x] Add top-level `httpApis` config keyed by Caplet ID.
-- [x] Add Markdown frontmatter key `httpApi`.
-- [x] Keep v1 explicit-config-only: no discovery, imports, or manifests.
-- [x] Require `baseUrl`, `auth`, and at least one action.
-- [x] Support auth types `none`, `bearer`, `headers`, `oauth2`, and `oidc`, matching OpenAPI/GraphQL.
-- [x] Validate HTTPS except loopback for `baseUrl`.
-- [x] Reject duplicate Caplet IDs across `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, and `httpApis`.
-- [x] Reject untrusted project `httpApis`, matching OpenAPI/GraphQL project-config safety.
-- [x] Normalize relative Caplet/config local paths only where needed; HTTP paths are URL paths, not filesystem paths.
-- [x] Regenerate committed JSON schemas.
-
-## Task 2: Implement HTTP Actions Manager
-
-**Files:**
-
-- Create: `src/http-actions.ts`
-- Modify: `src/tools.ts`
-- Modify: `src/registry.ts`
-- Modify: `src/runtime.ts`
-- Test: `test/http-actions.test.ts`
-- Test: `test/tools.test.ts`
-- Test: `test/registry.test.ts`
-- Test: `test/runtime.test.ts`
-
-- [x] Create `HttpActionManager` with `checkApi`, `listTools`, `getTool`, `callTool`, `compact`, `search`, and `invalidate` methods.
-- [x] Convert configured actions into MCP-style tools using each action's `inputSchema`.
-- [x] Support HTTP methods `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`.
-- [x] Build URLs from `baseUrl` plus action `path`, preserving origin and rejecting origin changes.
-- [x] Resolve `{field}` path placeholders from `call_tool.arguments` and URL-encode values.
-- [x] Support `query`, `headers`, and `jsonBody` mappings.
-- [x] Support mapping values as literals, nested arrays/objects, `$input.field` references, and `$input` for the full argument object.
-- [x] Reject forbidden request headers such as `authorization`, `host`, `content-length`, `connection`, and `content-type` when user-configured headers would conflict with managed headers.
-- [x] Apply static bearer/header auth and generic OAuth/OIDC headers.
-- [x] Reject redirects.
-- [x] Enforce request timeouts and maximum response byte limits.
-- [x] Return structured `{ status, statusText, headers, body, elapsedMs }` responses and mark `isError` for non-2xx status.
-- [x] Redact secrets from errors.
-- [x] Route `backend: "http"` through generated Caplet operations.
-- [x] Add safe registry detail for HTTP APIs without exposing `baseUrl`, auth, or sensitive mappings.
-- [x] Include HTTP APIs in runtime registration, reload comparison, and cache invalidation.
-
-## Task 3: Documentation And Verification
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `docs/product/caplets-progressive-mcp-disclosure-prd.md`
-- Modify: `docs/plans/2026-05-13-http-actions-backend.md`
-
-- [x] Document `httpApis` JSON config and `httpApi` Caplet files.
-- [x] Document explicit action configuration, request mappings, auth support, and safety constraints.
-- [x] Document response shape and error behavior.
-- [x] Run `pnpm schema:generate`.
-- [x] Run `pnpm format:check`.
-- [x] Run `pnpm lint`.
-- [x] Run `pnpm typecheck`.
-- [x] Run `pnpm schema:check`.
-- [x] Run `pnpm test`.
-- [x] Run `pnpm build`.
-
-## Assumptions
-
-- The top-level config key is `httpApis` and Caplet file key is `httpApi`.
-- Runtime backend discriminator is `backend: "http"`.
-- `check_backend` validates configured actions and returns a tool count without making network calls.
-- v1 does not support action discovery, imports, manifests, raw shell/curl execution, form bodies, file upload, streaming, cookies, or custom redirect following.
-- `inputSchema` defaults to `{ "type": "object", "additionalProperties": true }` when omitted.
-- JSON request bodies set `content-type: application/json` automatically when `jsonBody` is configured.
-- HTTP response parsing matches OpenAPI behavior: JSON content is parsed as JSON; other content is text.
diff --git a/docs/plans/2026-05-13-xdg-cross-platform-paths.md b/docs/plans/2026-05-13-xdg-cross-platform-paths.md
deleted file mode 100644
index 3c14c2fe..00000000
--- a/docs/plans/2026-05-13-xdg-cross-platform-paths.md
+++ /dev/null
@@ -1,202 +0,0 @@
-# XDG and Cross-Platform Paths Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Move Caplets user config to XDG/standard platform config locations and OAuth state to XDG/standard platform state locations.
-
-**Architecture:** Centralize all default user paths in `src/config/paths.ts`, then consume those shared defaults from config loading, CLI inspection, installation, runtime, and auth storage. Project-local `./.caplets` behavior stays unchanged because it is project state, not user state. `CAPLETS_CONFIG` remains the explicit config override, and internal/test `authDir` overrides remain supported.
-
-**Tech Stack:** Node.js 22, TypeScript, Vitest, Commander, Caplets existing config/auth modules.
-
----
-
-## Target Paths
-
-| Platform | Config | State/Auth |
-| ---------------- | --------------------------------------------------- | -------------------------------------------------------------- |
-| Linux/macOS/Unix | `${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` | `${XDG_STATE_HOME:-~/.local/state}/caplets/auth/.json` |
-| Windows | `%APPDATA%\\caplets\\config.json` | `%LOCALAPPDATA%\\caplets\\auth\\.json` |
-| Override | `CAPLETS_CONFIG=/custom/config.json` wins | Existing `authDir` option wins |
-
-XDG environment variables must be absolute. Relative `XDG_CONFIG_HOME` and `XDG_STATE_HOME` values are ignored.
-
-## File Structure
-
-- Modify: `src/config/paths.ts` - own all platform-specific default path resolution.
-- Modify: `src/auth/store.ts` - use the shared default auth directory instead of hard-coding `~/.caplets/auth`.
-- Modify: `src/cli/inspection.ts` - expose the split config and state roots in `caplets config paths`.
-- Modify: `test/config.test.ts` or create `test/config-paths.test.ts` - cover path helper behavior.
-- Modify: `test/cli.test.ts` - update path inspection expectations.
-- Modify: `README.md` - document XDG and Windows paths.
-- Modify: `docs/product/caplets-progressive-mcp-disclosure-prd.md` - update product/default path references.
-- Create: `.changeset/.md` - note the pre-1.0 default location change.
-
-### Task 1: Platform-Aware Path Helpers
-
-**Files:**
-
-- Modify: `src/config/paths.ts`
-- Test: `test/config-paths.test.ts`
-
-- [ ] **Step 1: Write path helper tests**
-
-Add tests that call injectable helper functions with explicit environment, home, and platform inputs. Cover Unix defaults, Unix XDG overrides, ignored relative XDG overrides, Windows environment defaults, and Windows homedir fallbacks.
-
-- [ ] **Step 2: Run path helper tests to verify they fail**
-
-Run: `pnpm vitest run test/config-paths.test.ts`
-
-Expected: FAIL because the injectable helpers do not exist yet.
-
-- [ ] **Step 3: Implement path helpers**
-
-In `src/config/paths.ts`, add exported helpers equivalent to:
-
-```ts
-type Platform = NodeJS.Platform;
-type PathEnv = NodeJS.ProcessEnv;
-
-export function defaultConfigBaseDir(
- env: PathEnv = process.env,
- home = homedir(),
- platform: Platform = process.platform,
-): string {
- if (platform === "win32") {
- return env.APPDATA && isAbsolute(env.APPDATA) ? env.APPDATA : join(home, "AppData", "Roaming");
- }
- return env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME)
- ? env.XDG_CONFIG_HOME
- : join(home, ".config");
-}
-
-export function defaultStateBaseDir(
- env: PathEnv = process.env,
- home = homedir(),
- platform: Platform = process.platform,
-): string {
- if (platform === "win32") {
- return env.LOCALAPPDATA && isAbsolute(env.LOCALAPPDATA)
- ? env.LOCALAPPDATA
- : join(home, "AppData", "Local");
- }
- return env.XDG_STATE_HOME && isAbsolute(env.XDG_STATE_HOME)
- ? env.XDG_STATE_HOME
- : join(home, ".local", "state");
-}
-
-export function defaultConfigPath(...): string {
- return join(defaultConfigBaseDir(...), "caplets", "config.json");
-}
-
-export function defaultAuthDir(...): string {
- return join(defaultStateBaseDir(...), "caplets", "auth");
-}
-```
-
-Keep exported `DEFAULT_CONFIG_PATH` and `DEFAULT_AUTH_DIR`, but define them from the new functions.
-
-- [ ] **Step 4: Run path helper tests to verify they pass**
-
-Run: `pnpm vitest run test/config-paths.test.ts`
-
-Expected: PASS.
-
-### Task 2: Auth Store Uses State Directory
-
-**Files:**
-
-- Modify: `src/auth/store.ts`
-- Test: `test/auth.test.ts`
-
-- [ ] **Step 1: Write or update auth default tests**
-
-Add coverage proving `authStorePath("remote")` resolves under the shared default auth dir. Keep traversal rejection coverage intact.
-
-- [ ] **Step 2: Run auth tests to verify they fail before implementation**
-
-Run: `pnpm vitest run test/auth.test.ts`
-
-Expected: FAIL if the test expects the new default while the auth store still hard-codes `~/.caplets/auth`.
-
-- [ ] **Step 3: Update auth store default**
-
-Import `DEFAULT_AUTH_DIR` from `../config/paths.js`, remove direct `homedir()` usage, and use `DEFAULT_AUTH_DIR` in `authStorePath` and `listTokenBundles` defaults.
-
-- [ ] **Step 4: Run auth tests to verify they pass**
-
-Run: `pnpm vitest run test/auth.test.ts`
-
-Expected: PASS.
-
-### Task 3: CLI Path Inspection
-
-**Files:**
-
-- Modify: `src/cli/inspection.ts`
-- Modify: `test/cli.test.ts`
-
-- [ ] **Step 1: Update CLI path inspection tests**
-
-Update `caplets config paths --json` expectations to include `stateRoot`, and verify `authDir` can still be overridden by the internal test `authDir` option.
-
-- [ ] **Step 2: Run CLI tests to verify they fail before implementation**
-
-Run: `pnpm vitest run test/cli.test.ts`
-
-Expected: FAIL because `stateRoot` is not present yet.
-
-- [ ] **Step 3: Update inspection output**
-
-Import `defaultStateBaseDir` if needed, add `stateRoot: dirname(authDir ?? DEFAULT_AUTH_DIR)` or equivalent root reporting, and update human `formatConfigPaths` output.
-
-- [ ] **Step 4: Run CLI tests to verify they pass**
-
-Run: `pnpm vitest run test/cli.test.ts`
-
-Expected: PASS.
-
-### Task 4: Documentation and Changeset
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `docs/product/caplets-progressive-mcp-disclosure-prd.md`
-- Create: `.changeset/.md`
-
-- [ ] **Step 1: Update README paths**
-
-Replace default user config references with `~/.config/caplets/config.json`, auth state references with `~/.local/state/caplets/auth/.json`, and include Windows `%APPDATA%\\caplets\\config.json` and `%LOCALAPPDATA%\\caplets\\auth` equivalents.
-
-- [ ] **Step 2: Update PRD path references**
-
-Update all user-level path references from `~/.caplets` to the new split config/state locations. Keep project `./.caplets` references unchanged.
-
-- [ ] **Step 3: Add changeset**
-
-Create a minor changeset explaining that default config and OAuth token state locations now follow XDG and Windows platform conventions.
-
-- [ ] **Step 4: Check docs for stale user path references**
-
-Run a content search for `~/.caplets` and `.caplets/auth` and update stale default-user references only. Existing historical plans may remain unchanged if they describe already-completed old work.
-
-### Task 5: Full Verification and Review
-
-**Files:**
-
-- Verify all modified files.
-
-- [ ] **Step 1: Run targeted tests**
-
-Run: `pnpm vitest run test/config-paths.test.ts test/config.test.ts test/cli.test.ts test/auth.test.ts`
-
-Expected: PASS.
-
-- [ ] **Step 2: Run full verification**
-
-Run: `pnpm verify`
-
-Expected: PASS.
-
-- [ ] **Step 3: Review changed diff**
-
-Inspect the final diff for accidental compatibility fallback to `~/.caplets`, stale docs, or unrelated changes.
diff --git a/docs/plans/2026-05-14-cli-tools-backend.md b/docs/plans/2026-05-14-cli-tools-backend.md
deleted file mode 100644
index 64470402..00000000
--- a/docs/plans/2026-05-14-cli-tools-backend.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# CLI Tools Backend For Caplets
-
-## Summary
-
-Add a fifth backend, `cliTools`, that exposes curated command-line workflows as typed Caplets tools. Runtime execution is deterministic and shell-free: Caplets maps declared JSON inputs into `spawn(command, args)` calls, returns bounded structured results, and never exposes arbitrary bash.
-
-Also add `caplets author cli` to generate reviewable CLI Caplet manifests from repo/package metadata and curated templates for `git`, `gh`, and the detected package manager. Generation prints to stdout by default.
-
-## Key Changes
-
-- Add `cliTools` to config and Caplet frontmatter alongside `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, and `httpApis`.
-- Use an `actions` map shape, mirroring `httpApis.actions`:
- - action key is the downstream tool name.
- - each action supports `description`, `inputSchema`, optional `outputSchema`, `command`, `args`, `env`, `cwd`, `timeoutMs`, `maxOutputBytes`, `output`, and MCP annotations.
-- Execute with `spawn(command, args)` only; no shell mode in v1.
-- Support `$input.foo` interpolation inside argv/env/cwd strings.
-- Apply basic runtime input validation for required fields and primitive JSON Schema types before spawning.
-- Default limits: `timeoutMs: 60000`, `maxOutputBytes: 1000000`, configurable at Caplet and action level.
-- Return CLI results as structured content:
- - `{ exitCode, stdout, stderr, elapsedMs }`
- - `isError: true` for non-zero exits.
- - optional stdout JSON parsing when `output.type: "json"` is declared.
-- `check_backend` validates manifest shape, cwd/env resolution, command availability, and tool count without running configured actions.
-- `list_tools`, `search_tools`, `get_tool`, `call_tool`, and field selection should work consistently with existing backends.
-- Load user-root CLI Caplets normally; load project `.caplets` CLI Caplets only under the existing `CAPLETS_TRUST_PROJECT_CAPLETS` gate.
-
-## Authoring UX
-
-- Add `caplets author cli ` with scriptable flags:
- - `--repo ` to inspect a repository.
- - `--include git,gh,package` to choose generators/templates.
- - `--command ` for single-CLI generation.
- - `--output -` by default, with explicit file output supported.
-- Heuristic generator only in v1; no OpenAI/API/agent dependency.
-- Repo workflow generation should inspect package scripts and lockfiles, then generate safe tools such as test, lint, typecheck, build, repo status, changed files, and PR status when applicable.
-- Single-CLI templates should cover `git`, `gh`, and detected package manager commands.
-- Generated manifests must be explicit Markdown Caplet files with `cliTools`, not hidden runtime state.
-
-## Docs And Examples
-
-- Update README config docs, Caplet file docs, generated schemas, and backend operation docs.
-- Add real bundled CLI examples under `caplets/`, focused on repo maintenance and GitHub workflows.
-- Examples should be safe/read-oriented by default; mutating actions must carry clear annotations such as `readOnlyHint: false` and `destructiveHint` where appropriate.
-
-## Test Plan
-
-- Config tests for `cliTools`, duplicate IDs across all five backend maps, Caplet frontmatter loading, project trust behavior, defaults, limits, and invalid command/action shapes.
-- CLI manager tests for list/search/get/call, input interpolation, basic validation, JSON output parsing, non-zero exit handling, timeout handling, output byte limits, command-not-found errors, cwd/env behavior, and secret redaction.
-- Runtime/registry tests for `cliTools` registration, `check_backend`, reload invalidation, and `caplets list`.
-- Authoring tests for stdout output, explicit output path, package-script detection, `git`/`gh` templates, package manager detection, and generated Caplet validation.
-- Schema check, typecheck, focused tests, full `pnpm verify`.
-
-## Assumptions
-
-- V1 intentionally excludes shell snippets, conditional argv construction, full JSON Schema validation, LLM-assisted generation, and automatic installation into the user Caplets root.
-- Complex workflows should be modeled as package scripts or wrapper executables, then called through typed `cliTools` actions.
-- Caplets surfaces risk annotations but does not block declared mutating tools at runtime; client approval remains outside Caplets.
diff --git a/docs/plans/2026-05-14-coding-agent-benchmarks.md b/docs/plans/2026-05-14-coding-agent-benchmarks.md
deleted file mode 100644
index fa41e689..00000000
--- a/docs/plans/2026-05-14-coding-agent-benchmarks.md
+++ /dev/null
@@ -1,366 +0,0 @@
-# Coding Agent Benchmarks Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add reproducible deterministic and opt-in live coding-agent benchmarks comparing Caplets against non-Caplets MCP exposure so README claims have measurable evidence.
-
-**Architecture:** Build a benchmark fixture suite with synthetic coding-agent tasks, mock MCP servers, deterministic context-surface analysis, and live agent runners. Pi is the primary live runner, with OpenCode supported through the same adapter interface when available.
-
-**Tech Stack:** Node 22+, Vitest, MCP SDK, existing Caplets CLI/runtime, Pi CLI, OpenCode CLI, JSON/Markdown reports.
-
----
-
-## Architectural Decisions
-
-- Benchmark both **direct/flat MCP** and **Pi proxy baseline** against **Caplets progressive disclosure**.
-- Keep deterministic benchmarks in normal CI because they are fast, stable, and credential-free.
-- Keep live LLM agent benchmarks opt-in via `pnpm benchmark:live` because they require installed agents, credentials, and model-dependent behavior.
-- Use local mock MCP servers so benchmark tasks are reproducible and do not depend on GitHub, Linear, docs sites, or network APIs.
-- Score live runs by observable task success, not subjective transcript quality.
-- Produce machine-readable JSON reports plus Markdown summaries consumed by README.
-- Design runner adapters for `pi` and `opencode`; make Pi the default live target.
-
----
-
-## Proposed Files
-
-- Create `benchmarks/fixtures/coding-agent-workspace/`: small repo fixture copied into temp dirs for each run.
-- Create `benchmarks/fixtures/mcp-server.mjs`: deterministic mock MCP server exposing many realistic coding tools.
-- Create `benchmarks/fixtures/tasks.json`: benchmark tasks, expected file changes, validation commands, and required MCP facts.
-- Create `benchmarks/lib/surface.mjs`: direct vs Caplets tool payload metrics.
-- Create `benchmarks/lib/live-agent.mjs`: shared live runner types and process helpers.
-- Create `benchmarks/lib/pi-runner.mjs`: Pi live runner.
-- Create `benchmarks/lib/opencode-runner.mjs`: OpenCode live runner.
-- Create `benchmarks/lib/scoring.mjs`: validation, transcript, and metric scoring.
-- Create `benchmarks/run-deterministic.mjs`: deterministic report generator/checker.
-- Create `benchmarks/run-live.mjs`: opt-in live benchmark matrix runner.
-- Create `docs/benchmarks/coding-agent.md`: committed benchmark report.
-- Modify `README.md`: add benchmark/value section.
-- Modify `package.json`: add benchmark scripts.
-- Modify `.gitignore`: ignore local live benchmark output.
-- Modify or replace `test/benchmark.test.ts`: assert deterministic benchmark invariants and report freshness.
-
----
-
-## Benchmark Matrix
-
-| Mode | Agent | MCP Exposure | Purpose |
-| ------------- | -------- | ---------------------- | ------------------------------ |
-| deterministic | none | direct flat vs Caplets | CI-safe context/value proof |
-| live | Pi | direct flat | Classic tool-flooding baseline |
-| live | Pi | Pi MCP adapter proxy | Harder competitor baseline |
-| live | Pi | Caplets | Primary Caplets result |
-| live | OpenCode | direct flat | Optional second agent baseline |
-| live | OpenCode | Caplets | Optional second agent result |
-
----
-
-## Metrics
-
-Deterministic metrics:
-
-- Initial tool count.
-- Initial serialized MCP `tools/list` byte size.
-- Approximate token count using `Math.ceil(bytes / 4)`.
-- Duplicate tool-name collisions in direct flat mode.
-- Candidate tools visible before task-specific discovery.
-- Expected discovery path length.
-
-Live metrics:
-
-- Task pass rate.
-- Validation command result.
-- Wall-clock duration.
-- Agent process exit code.
-- Tool-call count where available from JSON logs.
-- Transcript byte size.
-- Approximate input/output token proxy where native usage is unavailable.
-- Whether the agent selected the correct MCP capability/tool.
-- Number of failed/irrelevant MCP calls.
-
----
-
-## Task 1: Benchmark Fixture Design
-
-**Files:**
-
-- Create `benchmarks/fixtures/tasks.json`
-- Create `benchmarks/fixtures/coding-agent-workspace/package.json`
-- Create `benchmarks/fixtures/coding-agent-workspace/src/discount.js`
-- Create `benchmarks/fixtures/coding-agent-workspace/test/discount.test.js`
-- Create `benchmarks/fixtures/coding-agent-workspace/src/retry.js`
-- Create `benchmarks/fixtures/coding-agent-workspace/test/retry.test.js`
-
-- [ ] Add a small fixture repo with failing tests.
-- [ ] Add tasks that require facts available only through MCP tools.
-- [ ] Ensure each task has a deterministic validation command.
-- [ ] Keep fixture code JavaScript to avoid adding build dependencies.
-
-Example task shape:
-
-```json
-[
- {
- "id": "discount-policy",
- "prompt": "Fix the discount calculation. Use the available MCP tools to inspect the current product policy before editing files. Do not hard-code test-only behavior.",
- "validationCommand": "node --test test/discount.test.js",
- "expectedFiles": ["src/discount.js"],
- "requiredFact": "Premium customers receive 15% off only when the cart total is at least 100."
- },
- {
- "id": "retry-policy",
- "prompt": "Fix retry scheduling. Use the available MCP tools to inspect the retry policy before editing files.",
- "validationCommand": "node --test test/retry.test.js",
- "expectedFiles": ["src/retry.js"],
- "requiredFact": "Retry delays are 100ms, 250ms, and 500ms for attempts 1 through 3."
- }
-]
-```
-
----
-
-## Task 2: Mock MCP Server
-
-**Files:**
-
-- Create `benchmarks/fixtures/mcp-server.mjs`
-
-- [ ] Implement one configurable mock MCP server.
-- [ ] Expose realistic tool domains like `policy_search`, `policy_get`, `ticket_get`, `api_schema_get`, plus many irrelevant tools.
-- [ ] Support `--server policy`, `--server tickets`, and `--server api` modes.
-- [ ] Return deterministic structured content.
-- [ ] Include duplicate downstream tool names across servers to demonstrate collision avoidance.
-
-Representative tools:
-
-- `search`
-- `get`
-- `list_recent_changes`
-- `read_policy`
-- `lookup_schema`
-- `get_ticket`
-- `search_tickets`
-- 30 to 50 distractor tools across domains.
-
----
-
-## Task 3: Deterministic Surface Benchmark
-
-**Files:**
-
-- Create `benchmarks/lib/surface.mjs`
-- Create `benchmarks/run-deterministic.mjs`
-- Modify `test/benchmark.test.ts`
-
-- [ ] Build direct flat tool payload from mock server metadata.
-- [ ] Build Caplets top-level payload using existing `ServerRegistry` and `capabilityDescription`.
-- [ ] Compute byte and approximate token reductions.
-- [ ] Count duplicate direct tool names.
-- [ ] Assert Caplets reduces initial surface by at least the current PRD threshold: `>= 70%`.
-- [ ] Assert Caplets has zero flattened downstream tool-name collisions at top level.
-- [ ] Generate `docs/benchmarks/coding-agent.md`.
-
-Expected script:
-
-```json
-{
- "scripts": {
- "benchmark": "node benchmarks/run-deterministic.mjs",
- "benchmark:check": "node benchmarks/run-deterministic.mjs --check"
- }
-}
-```
-
----
-
-## Task 4: Live Runner Abstraction
-
-**Files:**
-
-- Create `benchmarks/lib/live-agent.mjs`
-- Create `benchmarks/lib/scoring.mjs`
-
-- [ ] Define a shared runner contract.
-- [ ] Add process execution helper using `child_process.spawn`.
-- [ ] Capture stdout, stderr, exit code, duration, and JSON events when available.
-- [ ] Add timeout handling that kills only the child process group.
-- [ ] Add validation scoring by running each task's validation command in the temp workspace.
-- [ ] Never include live benchmark execution in `pnpm verify`.
-
----
-
-## Task 5: Pi Runner
-
-**Files:**
-
-- Create `benchmarks/lib/pi-runner.mjs`
-- Create `benchmarks/live-config/pi/`
-
-- [ ] Detect `pi` CLI availability.
-- [ ] Run Pi in print/JSON mode.
-- [ ] Use isolated `PI_CODING_AGENT_DIR` per benchmark run.
-- [ ] Generate project-local MCP config for each mode: direct flat MCP, Pi MCP adapter proxy, and Caplets.
-- [ ] Require explicit env var for live runs, e.g. `CAPLETS_BENCH_LIVE=1`.
-- [ ] Record Pi version, model, and command line.
-
-Expected invocation pattern:
-
-```sh
-CAPLETS_BENCH_LIVE=1 pnpm benchmark:live -- --agent pi --model provider/model
-```
-
----
-
-## Task 6: OpenCode Runner
-
-**Files:**
-
-- Create `benchmarks/lib/opencode-runner.mjs`
-
-- [ ] Detect `opencode` CLI availability.
-- [ ] Run `opencode run --format json --model --dir `.
-- [ ] Use isolated config/env paths where OpenCode supports them.
-- [ ] Generate MCP config for direct and Caplets modes.
-- [ ] Record OpenCode version, model, and command line.
-- [ ] Treat OpenCode as optional in the first live report if Pi is the selected primary agent.
-
-Expected invocation pattern:
-
-```sh
-CAPLETS_BENCH_LIVE=1 pnpm benchmark:live -- --agent opencode --model openai/gpt-5.5
-```
-
----
-
-## Task 7: Caplets Benchmark Mode Config
-
-**Files:**
-
-- Create `benchmarks/lib/config.mjs`
-- Use existing built `dist/index.js` or package command after `pnpm build`
-
-- [ ] Generate a temp Caplets config pointing at benchmark MCP fixture servers.
-- [ ] Start Caplets through MCP stdio from the agent config.
-- [ ] Ensure Caplets receives mock servers as downstream `mcpServers`.
-- [ ] Keep Caplets config isolated from the user's real config via `CAPLETS_CONFIG`.
-- [ ] Add cleanup for temp dirs and spawned processes.
-
----
-
-## Task 8: Live Benchmark Orchestrator
-
-**Files:**
-
-- Create `benchmarks/run-live.mjs`
-- Modify `package.json`
-
-- [ ] Parse CLI args: `--agent`, `--mode`, `--model`, `--tasks`, `--runs`, `--timeout-ms`.
-- [ ] Refuse to run unless `CAPLETS_BENCH_LIVE=1`.
-- [ ] Copy fixture workspace to a fresh temp dir per task/run/mode.
-- [ ] Run matrix: `direct-flat`, `pi-proxy` for Pi only, and `caplets`.
-- [ ] Score each run.
-- [ ] Write JSON to `benchmark-results/live/.json`.
-- [ ] Write Markdown summary to `benchmark-results/live/.md`.
-
-Expected scripts:
-
-```json
-{
- "scripts": {
- "benchmark:live": "node benchmarks/run-live.mjs",
- "benchmark:live:pi": "node benchmarks/run-live.mjs --agent pi",
- "benchmark:live:opencode": "node benchmarks/run-live.mjs --agent opencode"
- }
-}
-```
-
----
-
-## Task 9: Reports And README
-
-**Files:**
-
-- Create `docs/benchmarks/coding-agent.md`
-- Modify `README.md`
-
-- [ ] Add a README section after the product/value description.
-- [ ] Include deterministic benchmark headline numbers.
-- [ ] Link to full benchmark report.
-- [ ] Explain live benchmarks are opt-in and model-dependent.
-- [ ] Document exact commands to reproduce.
-
-README section should be factual, not hype:
-
-```md
-## Benchmarks
-
-Caplets includes reproducible coding-agent benchmarks that compare direct MCP exposure with Caplets progressive disclosure.
-
-In the deterministic benchmark fixture, Caplets reduces the initial MCP tool surface by at least 70% versus direct flat aggregation while preserving access to every downstream tool through scoped discovery and `call_tool`.
-
-See [`docs/benchmarks/coding-agent.md`](docs/benchmarks/coding-agent.md) for methodology, fixture details, and live Pi/OpenCode benchmark instructions.
-```
-
----
-
-## Task 10: CI And Freshness Checks
-
-**Files:**
-
-- Modify `package.json`
-- Modify `.github/workflows/ci.yml` only if adding a separate benchmark check is preferable
-- Modify `test/benchmark.test.ts`
-
-- [ ] Add `pnpm benchmark:check`.
-- [ ] Include deterministic benchmark freshness either in `pnpm test` or `pnpm verify`.
-- [ ] Do not include `benchmark:live` in CI.
-- [ ] Ensure `pnpm verify` remains credential-free and deterministic.
-
-Recommended package script change:
-
-```json
-{
- "scripts": {
- "benchmark": "node benchmarks/run-deterministic.mjs",
- "benchmark:check": "node benchmarks/run-deterministic.mjs --check",
- "benchmark:live": "node benchmarks/run-live.mjs",
- "verify": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm schema:check && pnpm test && pnpm benchmark:check && pnpm build"
- }
-}
-```
-
----
-
-## Task 11: Validation
-
-- [ ] Run `pnpm format:check`.
-- [ ] Run `pnpm lint`.
-- [ ] Run `pnpm typecheck`.
-- [ ] Run `pnpm test`.
-- [ ] Run `pnpm benchmark`.
-- [ ] Run `pnpm benchmark:check`.
-- [ ] Run `pnpm verify`.
-
-Optional live validation:
-
-```sh
-CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:pi -- --model --runs 1
-CAPLETS_BENCH_LIVE=1 pnpm benchmark:live:opencode -- --model openai/gpt-5.5 --runs 1
-```
-
-Expected:
-
-- Deterministic benchmark passes in CI.
-- Live benchmark writes local reports under `benchmark-results/live/`.
-- README links to committed deterministic report.
-- Live benchmark failures are reported as benchmark results, not test-suite failures.
-
----
-
-## Risks
-
-- Pi CLI behavior may change; isolate Pi-specific behavior behind `pi-runner.mjs`.
-- OpenCode MCP config isolation may need follow-up once exact config paths are verified.
-- Live LLM results will vary by model and date; reports must include full run metadata.
-- Comparing against `pi-mcp-adapter` is useful but could blur the value claim because it already solves some context-pressure problems. Keeping direct-flat and proxy baselines separate avoids that.
-- Tool-call counts may not be available uniformly; scoring must tolerate missing telemetry and still validate task success.
diff --git a/docs/plans/2026-05-14-output-field-selection.md b/docs/plans/2026-05-14-output-field-selection.md
deleted file mode 100644
index b71b1f9a..00000000
--- a/docs/plans/2026-05-14-output-field-selection.md
+++ /dev/null
@@ -1,262 +0,0 @@
-# Output Field Selection Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add `call_tool.fields` projection for MCP, OpenAPI, and HTTP tools only when the backend tool exposes an output schema.
-
-**Architecture:** Keep field selection as a wrapper-layer post-call projection over `structuredContent`. Each backend exposes `outputSchema` metadata through normal `list_tools`/`get_tool` discovery. `call_tool.fields` is accepted by the static wrapper schema, but is only valid at runtime for non-GraphQL tools with output schemas.
-
-**Tech Stack:** TypeScript, Zod, `@modelcontextprotocol/sdk`, Vitest, JSON Schema-style tool metadata.
-
----
-
-## Decisions Locked
-
-- `fields` is a top-level `call_tool` wrapper field, not part of downstream `arguments`.
-- `fields` paths target `structuredContent`.
-- HTTP/OpenAPI payload fields require explicit `body.` paths because those backends wrap responses in `{ status, statusText, headers, body }`.
-- MCP paths target the downstream MCP `structuredContent` directly.
-- GraphQL is excluded because GraphQL already has native selection sets.
-- Missing output schema is a hard `REQUEST_INVALID` error, not a no-op.
-- `list_tools`/`search_tools` compact output should include `hasOutputSchema`.
-- `get_tool` should expose actual `outputSchema` where available.
-- Projection should update both `structuredContent` and text `content` when the original result has JSON text generated from structured content.
-
----
-
-## Files To Modify
-
-- `src/tools.ts`: parse `fields`, validate operation-specific fields, route projection after backend calls.
-- `src/downstream.ts`: expose `hasOutputSchema` in compact MCP tool metadata.
-- `src/openapi.ts`: extract response schemas, attach `outputSchema`, include `hasOutputSchema`.
-- `src/http-actions.ts`: add configured `outputSchema` support, attach it to tools, include `hasOutputSchema`.
-- `src/config.ts`: add `outputSchema` to HTTP action config validation/type.
-- `schemas/caplets-config.schema.json`: regenerate after config schema changes.
-- `src/field-selection.ts`: implement schema-aware field path validation and projection.
-- `test/field-selection.test.ts`: direct projection utility coverage.
-- `test/tools.test.ts`: wrapper validation, projection routing, GraphQL rejection.
-- `test/downstream.test.ts`: MCP fixture output schema discovery and filtering.
-- `test/openapi.test.ts`: OpenAPI response schema extraction and filtering.
-- `test/http-actions.test.ts`: HTTP configured output schema and filtering.
-- `test/config.test.ts`: HTTP `outputSchema` config validation.
-- `test/fixtures/stdio-server.mjs`: add `outputSchema` to `echo`.
-
----
-
-## Public Request Shape
-
-```json
-{
- "operation": "call_tool",
- "tool": "read_items",
- "arguments": { "limit": 10 },
- "fields": ["body.items.title", "body.items.url"]
-}
-```
-
-For MCP:
-
-```json
-{
- "operation": "call_tool",
- "tool": "search",
- "arguments": { "query": "mcp" },
- "fields": ["items.title", "items.url"]
-}
-```
-
-Invalid for GraphQL:
-
-```json
-{
- "operation": "call_tool",
- "tool": "query_user",
- "arguments": { "id": "42" },
- "fields": ["body.data.user.name"]
-}
-```
-
-Expected error:
-
-```json
-{
- "code": "REQUEST_INVALID",
- "message": "call_tool.fields is not supported for GraphQL-backed Caplets; select fields in the GraphQL operation document instead"
-}
-```
-
----
-
-## Task 1: Add Field Selection Request Validation
-
-**Files:**
-
-- Modify: `src/tools.ts`
-- Test: `test/tools.test.ts`
-
-- [x] Add `fields` to `generatedToolInputSchema` as `z.array(z.string().min(1)).min(1).optional()`.
-- [x] Update the description to say `fields` is only valid for `call_tool` after `get_tool` shows an `outputSchema`.
-- [x] Change `validateOperationRequest` for `call_tool` from `allowed(["tool", "arguments"])` to `allowed(["tool", "arguments", "fields"])`.
-- [x] Reject empty arrays and non-string path values through Zod.
-- [x] Return `fields` in the required request type only when present.
-- [x] Add tests that `call_tool` accepts `fields: ["body.name"]`, rejects `fields` for non-`call_tool` operations, rejects `fields: []`, and treats `arguments.fields` as downstream input.
-- [x] Run `pnpm test test/tools.test.ts` and confirm the new tests pass.
-
----
-
-## Task 2: Implement Schema-Aware Projection Utility
-
-**Files:**
-
-- Create: `src/field-selection.ts`
-- Test: `test/field-selection.test.ts`
-
-- [x] Write tests for object projection, array item projection, unknown schema paths, missing output schema, missing runtime values, and multiple nested selections.
-- [x] Implement `projectStructuredContent(value, outputSchema, fields)`.
-- [x] Validate paths against JSON Schema object `properties` and array `items`.
-- [x] Omit selected runtime values that are absent.
-- [x] Throw `CapletsError` with `REQUEST_INVALID` for missing schemas, non-object runtime content, or schema-disallowed fields.
-- [x] Run `pnpm test test/field-selection.test.ts` and confirm it passes.
-
----
-
-## Task 3: Expose Output Schema Metadata In Compact Tools
-
-**Files:**
-
-- Modify: `src/downstream.ts`
-- Modify: `src/openapi.ts`
-- Modify: `src/http-actions.ts`
-- Test: `test/tools.test.ts`
-
-- [x] Extend `CompactTool` with `hasOutputSchema: boolean`.
-- [x] Update MCP, OpenAPI, GraphQL, and HTTP `compact()` implementations to return `hasOutputSchema: Boolean(tool.outputSchema)`.
-- [x] Update compact tool test expectations.
-- [x] Run `pnpm test test/tools.test.ts`.
-
----
-
-## Task 4: Add MCP Projection
-
-**Files:**
-
-- Modify: `src/tools.ts`
-- Modify: `test/fixtures/stdio-server.mjs`
-- Test: `test/downstream.test.ts`
-- Test: `test/tools.test.ts`
-
-- [x] Add `outputSchema: z.object({ message: z.string() }).strict()` to the `echo` fixture tool.
-- [x] In `handleServerTool`, when `parsed.fields` exists, reject GraphQL, fetch the selected tool metadata, require `tool.outputSchema`, call the backend, and project the result.
-- [x] Add `projectCallToolResult()` in `src/tools.ts` or use a helper from `src/field-selection.ts`.
-- [x] Preserve `isError` and other result properties.
-- [x] Replace returned `content` with projected pretty JSON text.
-- [x] Test MCP `echo` with `fields: ["message"]`.
-- [x] Test missing output schema plus `fields` fails with `REQUEST_INVALID` before full output is returned.
-- [x] Run `pnpm test test/downstream.test.ts test/tools.test.ts`.
-
----
-
-## Task 5: Add OpenAPI Output Schema Extraction
-
-**Files:**
-
-- Modify: `src/openapi.ts`
-- Test: `test/openapi.test.ts`
-
-- [x] Extend `OpenApiOperation` with `outputSchema?: Record`.
-- [x] Extract the first `2xx` JSON response schema from OpenAPI operations.
-- [x] Wrap the response body schema in the structured envelope schema for `{ status, statusText, headers, body }`.
-- [x] Attach `outputSchema` in `toTool()` when available.
-- [x] Test `get_tool` includes `outputSchema` for JSON response schema operations.
-- [x] Test compact output says `hasOutputSchema: true`.
-- [x] Test `call_tool.fields: ["body.name"]` returns only `{ body: { name: "Ada" } }`.
-- [x] Test fields on an operation without response schema throws `REQUEST_INVALID`.
-- [x] Run `pnpm test test/openapi.test.ts test/tools.test.ts`.
-
----
-
-## Task 6: Add HTTP Action Output Schema Config
-
-**Files:**
-
-- Modify: `src/config.ts`
-- Modify: `src/http-actions.ts`
-- Test: `test/config.test.ts`
-- Test: `test/http-actions.test.ts`
-- Regenerate: `schemas/caplets-config.schema.json`
-
-- [x] Add `outputSchema?: Record` to `HttpActionConfig`.
-- [x] Add `outputSchema` to `httpActionSchema` with description `JSON Schema for structuredContent returned by this action.`
-- [x] Attach configured `outputSchema` in HTTP `toTool()` when available.
-- [x] Add config parsing tests for HTTP action output schemas.
-- [x] Add HTTP action tests for `get_tool`, compact metadata, and `call_tool.fields`.
-- [x] Run `pnpm schema:generate`.
-- [x] Run `pnpm test test/config.test.ts test/http-actions.test.ts`.
-
----
-
-## Task 7: Add Shared Result Projection In Wrapper
-
-**Files:**
-
-- Modify: `src/tools.ts`
-- Modify: `src/field-selection.ts`
-- Test: `test/tools.test.ts`
-
-- [x] Ensure result projection is centralized and shared by MCP, OpenAPI, and HTTP.
-- [x] Ensure `fields` omitted keeps existing pass-through behavior unchanged.
-- [x] Ensure GraphQL with `fields` fails with the planned error.
-- [x] Ensure projection never mutates the original result.
-- [x] Run `pnpm test test/tools.test.ts`.
-
----
-
-## Task 8: Update Agent-Facing Descriptions
-
-**Files:**
-
-- Modify: `src/tools.ts`
-- Modify: `src/registry.ts`
-- Test: `test/tools.test.ts`
-- Test: `test/registry.test.ts`
-
-- [x] Update `fields` schema description.
-- [x] Keep `arguments` warning about downstream inputs only inside `arguments`.
-- [x] Update `capabilityDescription` to mention optional `fields` only after `get_tool` confirms `outputSchema` for a non-GraphQL tool.
-- [x] Run `pnpm test test/tools.test.ts test/registry.test.ts`.
-
----
-
-## Task 9: Verification
-
-**Files:**
-
-- All modified files
-
-- [x] Run `pnpm test test/field-selection.test.ts test/tools.test.ts test/downstream.test.ts test/openapi.test.ts test/http-actions.test.ts test/config.test.ts`.
-- [x] Run `pnpm verify`.
-- [x] Fix any failures without weakening the feature contract.
-- [x] Confirm `git status --short` only contains intended files.
-
----
-
-## Risks And Guardrails
-
-- Static wrapper input schema cannot truly hide `fields` per downstream tool. Runtime validation and discovery metadata are the correct enforcement points.
-- OpenAPI response schema extraction should stay minimal: JSON `2xx` only, no content negotiation beyond `application/json`.
-- GraphQL should remain rejected for `fields` to avoid competing with GraphQL selection sets.
-- Do not support JSONPath, wildcards, computed selectors, array indexes, or aliases in the first implementation.
-- Do not silently no-op when schema is absent because that can leak full responses.
-
----
-
-## Acceptance Criteria
-
-- `list_tools` and `search_tools` show `hasOutputSchema`.
-- `get_tool` shows `outputSchema` for MCP tools that expose it.
-- `get_tool` shows synthesized `outputSchema` for OpenAPI operations with JSON response schemas.
-- `get_tool` shows configured `outputSchema` for HTTP actions.
-- `call_tool.fields` filters `structuredContent` and text content for MCP, OpenAPI, and HTTP.
-- `call_tool.fields` fails with `REQUEST_INVALID` for GraphQL.
-- `call_tool.fields` fails with `REQUEST_INVALID` when no output schema exists.
-- Existing `call_tool` behavior is unchanged when `fields` is omitted.
diff --git a/docs/plans/2026-05-14-project-first-caplets-add.md b/docs/plans/2026-05-14-project-first-caplets-add.md
deleted file mode 100644
index 1e3871ca..00000000
--- a/docs/plans/2026-05-14-project-first-caplets-add.md
+++ /dev/null
@@ -1,194 +0,0 @@
-# Project-First Caplets Add Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Make project-local Caplets the default workflow by removing the project trust gate, making `add` and `install` write to `./.caplets` by default, and exposing source/shadowing information clearly.
-
-**Architecture:** Treat project `.caplets` as first-class project configuration loaded from the current working directory only. Project sources override global/user sources, but `caplets list` exposes source and shadowing information so users can see when project-local capabilities replace global ones. Keep generated Caplets as Markdown files and route all new scaffolding through `caplets add`.
-
-**Tech Stack:** TypeScript, Commander, Zod, Markdown Caplet files, Vitest, Node.js fs/path APIs.
-
----
-
-## Decisions Locked
-
-- Project Caplets load by default; remove `CAPLETS_TRUST_PROJECT_CAPLETS`.
-- Project scope is current working directory only; do not walk parents or Git roots in v1.
-- Project sources override global/user sources when IDs collide.
-- `caplets list` shows source by default and warns when a Caplet shadows another source.
-- `caplets add` and `caplets install` are project-first by default.
-- `-g, --global` writes to the user Caplets root.
-- `caplets add` replaces the public `caplets author cli` flow; keep `author cli` only as a temporary deprecated alias if compatibility is low-cost.
-- Generated Caplets are Markdown files, not direct edits to `config.json`.
-
----
-
-## Files To Modify
-
-- `src/config/paths.ts`: remove project trust env constant and make project path helpers unconditional.
-- `src/config.ts`: always load project Caplet files; add source/shadow metadata loader while keeping `loadConfig()` compatibility.
-- `src/caplet-files.ts`: expose text validation for generated Caplet manifests if needed.
-- `src/runtime.ts`: always watch project `.caplets`; remove trust checks from watched paths.
-- `src/cli.ts`: add `add` commands, update `install` with `-g/--global`, keep/deprecate `author cli` if practical.
-- `src/cli/install.ts`: support project-first destination selection.
-- `src/cli/inspection.ts`: include source metadata and shadow warnings.
-- `src/cli/author.ts` and/or new `src/cli/add.ts`: generate Caplet Markdown for CLI, MCP, OpenAPI, GraphQL, and HTTP.
-- `test/config-paths.test.ts`: update removed trust behavior.
-- `test/config.test.ts`: project loading, project override, and source/shadow metadata tests.
-- `test/runtime.test.ts`: watcher behavior without trust gate.
-- `test/cli.test.ts`: list source/warnings, install destination, add command wiring.
-- `test/author-cli.test.ts` or new `test/add.test.ts`: add command generation and validation tests.
-- `README.md`: document project-first add/install, removed trust gate, and source/shadowing behavior.
-
----
-
-## Task 1: Remove Project Trust Gate
-
-**Files:**
-
-- Modify: `src/config/paths.ts`
-- Modify: `src/config.ts`
-- Modify: `src/runtime.ts`
-- Test: `test/config-paths.test.ts`
-- Test: `test/config.test.ts`
-- Test: `test/runtime.test.ts`
-
-- [ ] Remove `TRUST_PROJECT_CAPLETS_ENV` exports and all `isTrustedEnvEnabled()` usage related to project Caplet loading.
-- [ ] Make `loadConfig(configPath, projectConfigPath)` always load project Markdown Caplet files from `resolveProjectCapletsRoot()`.
-- [ ] Keep project config loading from `./.caplets/config.json` unchanged.
-- [ ] Update runtime watcher setup so project `.caplets` is always included in watched paths.
-- [ ] Remove tests that assert project Markdown files are ignored without env trust.
-- [ ] Add tests that project Markdown files load without setting any environment variable.
-- [ ] Run `pnpm test test/config-paths.test.ts test/config.test.ts test/runtime.test.ts`.
-
----
-
-## Task 2: Add Source And Shadow Metadata
-
-**Files:**
-
-- Modify: `src/config.ts`
-- Modify: `src/caplet-files.ts` if source paths need to be returned from file discovery.
-- Test: `test/config.test.ts`
-
-- [ ] Add source metadata types: `global-config`, `global-file`, `project-config`, and `project-file`.
-- [ ] Add a loader such as `loadConfigWithSources()` that returns `{ config, sources, shadows }`.
-- [ ] Keep `loadConfig()` as a wrapper returning only `config` so runtime behavior remains compatible.
-- [ ] Track the winning source kind and path for each final Caplet ID.
-- [ ] Track shadowed entries when a later source overrides an earlier source.
-- [ ] Preserve the existing source precedence order: global config, global files, project config, project files.
-- [ ] Add tests proving project files override global files.
-- [ ] Add tests proving `sources` points at the winning project path and `shadows` includes the global path.
-- [ ] Run `pnpm test test/config.test.ts`.
-
----
-
-## Task 3: Update `caplets list` Source UX
-
-**Files:**
-
-- Modify: `src/cli/inspection.ts`
-- Modify: `src/cli.ts`
-- Test: `test/cli.test.ts`
-
-- [ ] Change `caplets list` to use `loadConfigWithSources()`.
-- [ ] Add a `source` column to human table output.
-- [ ] Append short warning lines for shadowed Caplets, for example: `Warning: project Caplet GitHub shadows global Caplet at /path/to/github.md`.
-- [ ] Add `source`, `path`, and `shadows` fields to `caplets list --json` output.
-- [ ] Keep disabled filtering behavior unchanged.
-- [ ] Add tests for human source output.
-- [ ] Add tests for JSON source/shadow output.
-- [ ] Run `pnpm test test/cli.test.ts`.
-
----
-
-## Task 4: Make `caplets install` Project-First
-
-**Files:**
-
-- Modify: `src/cli.ts`
-- Modify: `src/cli/install.ts`
-- Test: `test/cli.test.ts`
-
-- [ ] Add `-g, --global` to `caplets install`.
-- [ ] Make default destination `resolveProjectCapletsRoot()`.
-- [ ] Use `resolveCapletsRoot(resolveConfigPath(envConfigPath()))` only when `--global` is passed.
-- [ ] Preserve `--force` behavior.
-- [ ] Ensure project `.caplets` is created when missing.
-- [ ] Update install output text only if needed to make project/global destination clear.
-- [ ] Add tests that default install writes under `./.caplets`.
-- [ ] Add tests that `--global` writes under the user Caplets root.
-- [ ] Run `pnpm test test/cli.test.ts`.
-
----
-
-## Task 5: Add `caplets add cli`
-
-**Files:**
-
-- Modify: `src/cli.ts`
-- Modify: `src/cli/author.ts` or create `src/cli/add.ts`
-- Test: `test/author-cli.test.ts`
-- Test: `test/cli.test.ts`
-
-- [ ] Add public command `caplets add cli ` using the existing CLI Caplet generator.
-- [ ] Make default output project `.caplets/.md`.
-- [ ] Add `-g, --global` for user root output.
-- [ ] Add `--print` for stdout-only review.
-- [ ] Add `--output ` for explicit file output.
-- [ ] Add `--force` to overwrite existing destination files.
-- [ ] Validate generated Caplet text before writing.
-- [ ] Keep `caplets author cli` as a deprecated alias that prints a warning to stderr, unless keeping it creates excessive complexity.
-- [ ] Add tests for default project write, global write, print mode, output mode, and overwrite protection.
-- [ ] Run `pnpm test test/author-cli.test.ts test/cli.test.ts`.
-
----
-
-## Task 6: Extend `caplets add` For MCP, OpenAPI, GraphQL, And HTTP
-
-**Files:**
-
-- Modify: `src/cli.ts`
-- Modify: `src/cli/author.ts` or create focused modules under `src/cli/add/`
-- Test: `test/author-cli.test.ts` or `test/add.test.ts`
-- Test: `test/cli.test.ts`
-
-- [ ] Add `caplets add mcp ` supporting stdio via `--command`, repeated `--arg`, optional `--cwd`, optional `--env KEY=VALUE`, and remote via `--url --transport http|sse`.
-- [ ] Add `caplets add openapi ` supporting `--spec `, optional `--base-url`, and auth flags.
-- [ ] Add `caplets add graphql ` supporting `--endpoint-url` and exactly one of `--schema ` or `--introspection`.
-- [ ] Add `caplets add http ` supporting `--base-url` and repeated `--action `.
-- [ ] Share destination behavior with `add cli`: project default, `--global`, `--print`, `--output`, `--force`.
-- [ ] Never embed raw bearer tokens; render `$env:ENV_NAME` from a `--token-env ` option.
-- [ ] Validate generated Caplet text before printing or writing.
-- [ ] Add tests for valid generation for each backend.
-- [ ] Add tests for invalid connection shape, invalid action syntax, invalid schema/introspection combination, and overwrite protection.
-- [ ] Run `pnpm test test/author-cli.test.ts test/cli.test.ts` or the new focused test file.
-
----
-
-## Task 7: Documentation And Verification
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: tests as needed.
-
-- [ ] Replace `caplets author cli` documentation with `caplets add cli`.
-- [ ] Document `caplets add mcp`, `add openapi`, `add graphql`, and `add http`.
-- [ ] Document that `caplets add` and `caplets install` write to `./.caplets` by default.
-- [ ] Document `-g, --global` for user-level writes.
-- [ ] Remove `CAPLETS_TRUST_PROJECT_CAPLETS` documentation.
-- [ ] Document project override precedence and `caplets list` shadow warnings.
-- [ ] Run targeted tests for changed areas.
-- [ ] Run full verification: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm schema:check && pnpm test && pnpm build`.
-
----
-
-## Out Of Scope
-
-- Parent-directory or Git-root `.caplets` discovery.
-- Remote marketplace search.
-- AI-assisted generation.
-- Live backend checks during `caplets add`.
-- Direct edits to `config.json` from `caplets add`.
-- Removing `caplets install` repo support.
diff --git a/docs/plans/2026-05-15-native-agent-caplet-extensions.md b/docs/plans/2026-05-15-native-agent-caplet-extensions.md
deleted file mode 100644
index 7e8dee44..00000000
--- a/docs/plans/2026-05-15-native-agent-caplet-extensions.md
+++ /dev/null
@@ -1,807 +0,0 @@
-# Native Agent Caplet Extensions Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add native OpenCode and Pi Caplets extensions that expose Caplets as native agent tools with prompt guidance, without requiring agents to access Caplets through MCP.
-
-**Architecture:** Factor the existing Caplets operation runtime into a reusable in-process service, then publish two separate workspace packages: `@caplets/opencode` and `@caplets/pi`. Each adapter snapshots configured Caplets at extension/plugin load, registers one prefixed native tool per Caplet (`caplets_`), executes through the shared service, and injects system/prompt guidance through native prompt hooks rather than editing user config.
-
-**Tech Stack:** TypeScript ESM, Node.js 22+, pnpm workspace, rolldown, Vitest, Zod, TypeBox for Pi schemas, `@opencode-ai/plugin`, `@earendil-works/pi-coding-agent`, existing Caplets backend managers.
-
----
-
-## Decisions From Architecture Grill
-
-- Native adapters register **one native tool per Caplet**, not one generic dispatch tool.
-- Native adapters use a **shared in-process runtime library**, not a child `caplets` CLI process.
-- OpenCode and Pi adapters are **separate npm packages** in the same repo workspace.
-- Package names use the `@caplets/*` scope for non-CLI packages: `@caplets/opencode` and `@caplets/pi`.
-- Native tool names are **prefixed** as `caplets_` to avoid overriding built-in agent tools.
-- Tool registration is **snapshot-at-load** for v1. New/removed Caplets require reloading the agent/plugin.
-- Prompt guidance uses **existing Caplet metadata plus shared system guidance**. Do not add new Caplet frontmatter fields in v1.
-- OpenCode prompt guidance must use plugin hooks. Do not edit user `opencode.json`.
-- Pi prompt guidance should use `promptSnippet` and `promptGuidelines` on registered tools.
-
----
-
-## Current Code Context
-
-- `packages/core/src/tools.ts` already owns the Caplets operation contract: `get_caplet`, `check_backend`, `list_tools`, `search_tools`, `get_tool`, and `call_tool`.
-- `packages/core/src/tools.ts` already delegates execution to `DownstreamManager`, `OpenApiManager`, `GraphQLManager`, `HttpActionManager`, and `CliToolsManager`.
-- `packages/core/src/runtime.ts` couples that operation handling to MCP registration through `McpServer.registerTool`.
-- `packages/core/src/registry.ts` already exposes safe Caplet summaries/details and `capabilityDescription`.
-- `packages/core/src/generated-tool-input-schema.mjs` already defines the operation schema and user-facing field descriptions.
-- `packages/benchmarks/lib/opencode-runner.mjs` and `packages/benchmarks/lib/pi-runner.mjs` only exercise MCP modes today.
-
-The smallest correct change is to extract the non-MCP execution path into a native service and make MCP runtime plus native adapters consume the same operation handler.
-
----
-
-## Target File Structure
-
-### Core Package
-
-- Modify: `packages/core/package.json`
- - Add workspace-aware scripts.
- - Add package exports for `caplets/native`.
- - Keep the existing `caplets` CLI bin unchanged.
-- Create: `pnpm-workspace.yaml`
- - Include `.` and `packages/*`.
-- Modify: `packages/core/rolldown.config.ts`
- - Build both `packages/core/src/index.ts` and `packages/core/src/native.ts` as ESM outputs.
-- Create: `packages/core/src/native.ts`
- - Public native service export surface for adapter packages.
-- Create: `packages/core/src/native/service.ts`
- - Shared in-process Caplets service that owns registry/managers and calls `handleServerTool`.
-- Create: `packages/core/src/native/tools.ts`
- - Native tool naming, prompt text, schema shape, and result formatting helpers.
-- Test: `packages/core/test/native.test.ts`
- - Core native service behavior independent from OpenCode/Pi.
-
-### OpenCode Adapter Package
-
-- Create: `packages/opencode/package.json`
-- Create: `packages/opencode/rolldown.config.ts`
-- Create: `packages/opencode/tsconfig.json`
-- Create: `packages/opencode/src/index.ts`
- - OpenCode plugin entrypoint.
-- Create: `packages/opencode/src/schema.ts`
- - Zod/OpenCode tool schema adapter.
-- Create: `packages/opencode/README.md`
- - Installation and config-free plugin usage.
-- Test: `packages/opencode/test/opencode.test.ts`
-
-### Pi Adapter Package
-
-- Create: `packages/pi/package.json`
-- Create: `packages/pi/rolldown.config.ts`
-- Create: `packages/pi/tsconfig.json`
-- Create: `packages/pi/src/index.ts`
- - Pi extension entrypoint.
-- Create: `packages/pi/src/schema.ts`
- - TypeBox schema adapter.
-- Create: `packages/pi/README.md`
- - Installation and extension usage.
-- Test: `packages/pi/test/pi.test.ts`
-
-### Documentation And Benchmarks
-
-- Modify: `README.md`
- - Add native adapter section after MCP install/config docs.
-- Modify: `docs/benchmarks/coding-agent.md`
- - Document that existing benchmark is MCP-mode only until native benchmark modes are added.
-- Modify: `packages/benchmarks/lib/opencode-runner.mjs`
- - Add `native-caplets` mode in a later task after adapter package exists.
-- Modify: `packages/benchmarks/lib/pi-runner.mjs`
- - Add `native-caplets` mode in a later task after adapter package exists.
-- Modify: `packages/benchmarks/test/benchmark.test.ts`
- - Assert native benchmark config generation shape.
-
----
-
-## Public Native API
-
-Add this API as `caplets/native` from the root `caplets` package.
-
-```ts
-export type NativeCapletsServiceOptions = {
- configPath?: string;
- projectConfigPath?: string;
- authDir?: string;
-};
-
-export type NativeCapletTool = {
- caplet: string;
- toolName: string;
- title: string;
- description: string;
- promptGuidance: string[];
-};
-
-export type NativeCapletsService = {
- listTools(): NativeCapletTool[];
- execute(capletId: string, request: unknown): Promise;
- close(): Promise;
-};
-
-export function createNativeCapletsService(
- options?: NativeCapletsServiceOptions,
-): NativeCapletsService;
-
-export function nativeCapletToolName(capletId: string): string;
-
-export function nativeCapletsSystemGuidance(toolNames: string[]): string;
-```
-
-Service behavior:
-
-- Load Caplets config once at construction using existing `loadConfig` path rules.
-- Snapshot enabled Caplets for `listTools()`.
-- Use `ServerRegistry` plus existing backend managers exactly like MCP runtime.
-- Reuse `handleServerTool()` for operation validation/execution.
-- Return the same structured values and errors the MCP runtime returns, but adapters can format for their host tool APIs.
-- Close downstream MCP processes via `DownstreamManager.close()` when the adapter process/session shuts down.
-- Do not start or probe backends during `listTools()`.
-- Do not expose local source paths, env values, tokens, headers, raw command args beyond existing safe `get_caplet` metadata.
-
-Native tool naming:
-
-```ts
-export function nativeCapletToolName(capletId: string): string {
- return `caplets_${capletId.replace(/-/g, "_")}`;
-}
-```
-
-This keeps current Caplet IDs unchanged internally while avoiding OpenCode/Pi tool-name collisions. Because existing `SERVER_ID_PATTERN` allows hyphens, the native name normalizes `-` to `_` for broader agent tool compatibility.
-
----
-
-## Prompt Guidance Contract
-
-The adapters should inject one shared system guidance block and per-tool descriptions.
-
-Shared guidance:
-
-```md
-## Caplets Native Tools
-
-Caplets tools are native wrappers around configured Caplet backends. Each tool is named `caplets_` and represents one capability domain.
-
-Recommended flow:
-
-1. Call the relevant `caplets_` tool with `operation: "get_caplet"` to read the full Caplet card.
-2. Call `check_backend` only when availability is uncertain.
-3. Use `search_tools` or `list_tools` to discover the selected Caplet's downstream operations.
-4. Use `get_tool` before `call_tool` when argument or output schema is unclear.
-5. For `call_tool`, put downstream inputs only inside the top-level `arguments` object.
-6. Do not invent downstream tool names; execute only exact names returned by `list_tools`, `search_tools`, or `get_tool`.
-```
-
-Per-tool prompt guidance:
-
-- Start from existing `capabilityDescription(caplet)`.
-- Replace examples of direct tool names with the native prefixed name where needed.
-- Preserve Caplet `name`, `description`, `tags`, and Markdown body behavior.
-- Do not add new Caplet schema fields for prompt guidance in v1.
-
-OpenCode guidance:
-
-- Register native tools through the plugin `tool` map.
-- Inject shared system guidance through OpenCode's `experimental.chat.system.transform` hook.
-- Do not edit `opencode.json`, agent prompts, or user config files.
-
-Pi guidance:
-
-- Register tools with `promptSnippet` and `promptGuidelines`.
-- Every `promptGuidelines` bullet must explicitly name the tool, e.g. `Use caplets_github ...`, because Pi appends bullets flat to its Guidelines section.
-
----
-
-## Task 1: Workspace And Build Layout
-
-**Files:**
-
-- Create: `pnpm-workspace.yaml`
-- Modify: `package.json`
-- Modify: `rolldown.config.ts`
-
-- [ ] Create `pnpm-workspace.yaml`.
-
-```yaml
-packages:
- - "."
- - "packages/*"
-```
-
-- [ ] Update root `package.json` scripts.
-
-Expected script shape:
-
-```json
-{
- "scripts": {
- "build": "pnpm build:core && pnpm --filter @caplets/opencode build && pnpm --filter @caplets/pi build",
- "build:core": "rolldown -c",
- "build:watch": "rolldown -c --watch",
- "prepack": "pnpm build:core",
- "verify": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm schema:check && pnpm test && pnpm benchmark:check && pnpm build"
- }
-}
-```
-
-Keep existing scripts not shown here unchanged.
-
-- [ ] Add root package export for native API.
-
-Expected export shape:
-
-```json
-{
- "exports": {
- ".": "./dist/index.js",
- "./native": "./dist/native.js"
- }
-}
-```
-
-- [ ] Update `packages/core/rolldown.config.ts` to emit both CLI and native entrypoints.
-
-Expected config shape:
-
-```ts
-import { defineConfig } from "rolldown";
-
-export default defineConfig({
- input: {
- index: "src/index.ts",
- native: "src/native.ts",
- },
- output: {
- dir: "./dist",
- format: "esm",
- banner: (chunk) => (chunk.name === "index" ? "#!/usr/bin/env node" : ""),
- },
- platform: "node",
-});
-```
-
-- [ ] Run `pnpm build:core`.
-
-Expected: build succeeds and emits `dist/index.js` plus `dist/native.js`.
-
----
-
-## Task 2: Native Service Core
-
-**Files:**
-
-- Create: `packages/core/src/native.ts`
-- Create: `packages/core/src/native/service.ts`
-- Create: `packages/core/src/native/tools.ts`
-- Test: `packages/core/test/native.test.ts`
-
-- [ ] Write failing tests for native tool listing and prefixed naming in `packages/core/test/native.test.ts`.
-
-Test coverage:
-
-- `createNativeCapletsService({ configPath })` reads an existing Caplets config.
-- `listTools()` returns enabled Caplets only.
-- Native tool names are `caplets_` and convert hyphens to underscores.
-- Tool descriptions include the existing Caplet capability card.
-- `listTools()` does not start downstream MCP servers.
-
-- [ ] Write failing tests for native execution.
-
-Test coverage:
-
-- `execute("alpha", { operation: "get_caplet" })` returns the same safe detail shape as `ServerRegistry.detail()`.
-- `execute("alpha", { operation: "search_tools", query: "x" })` delegates to the same backend manager path used by MCP runtime.
-- Invalid operation returns the same structured error result shape as MCP runtime.
-- `close()` closes downstream processes without throwing when nothing has started.
-
-- [ ] Implement `packages/core/src/native/tools.ts`.
-
-Implementation responsibilities:
-
-- `nativeCapletToolName(capletId)`.
-- `nativeCapletsSystemGuidance(toolNames)`.
-- `nativeCapletPromptGuidance(toolName, caplet)`.
-- `nativeCapletToolDescription(caplet)` using existing `capabilityDescription(caplet)`.
-
-- [ ] Implement `packages/core/src/native/service.ts`.
-
-Implementation responsibilities:
-
-- Instantiate `ServerRegistry`, `DownstreamManager`, `OpenApiManager`, `GraphQLManager`, `HttpActionManager`, and `CliToolsManager` exactly once.
-- Expose `listTools()`, `execute(capletId, request)`, and `close()`.
-- Call `handleServerTool()` for execution.
-- Catch errors and return `errorResult(error)` for adapter consistency.
-- Keep service construction free of MCP SDK server registration.
-
-- [ ] Implement `packages/core/src/native.ts` as the public export barrel.
-
-Expected exports:
-
-```ts
-export {
- createNativeCapletsService,
- type NativeCapletsService,
- type NativeCapletsServiceOptions,
- type NativeCapletTool,
-} from "./native/service";
-export { nativeCapletToolName, nativeCapletsSystemGuidance } from "./native/tools";
-export { generatedToolInputSchema } from "./tools";
-export { generatedToolInputJsonSchema } from "./generated-tool-input-schema.mjs";
-```
-
-- [ ] Run `pnpm --filter @caplets/core test -- test/native.test.ts`.
-
-Expected: native service tests pass.
-
-- [ ] Run `pnpm typecheck`.
-
-Expected: no TypeScript errors.
-
----
-
-## Task 3: OpenCode Adapter Package
-
-**Files:**
-
-- Create: `packages/opencode/package.json`
-- Create: `packages/opencode/rolldown.config.ts`
-- Create: `packages/opencode/tsconfig.json`
-- Create: `packages/opencode/src/schema.ts`
-- Create: `packages/opencode/src/index.ts`
-- Create: `packages/opencode/README.md`
-- Test: `packages/opencode/test/opencode.test.ts`
-
-- [ ] Create `packages/opencode/package.json`.
-
-Expected shape:
-
-```json
-{
- "name": "@caplets/opencode",
- "version": "0.0.0",
- "description": "Native OpenCode plugin for Caplets.",
- "type": "module",
- "main": "dist/index.js",
- "exports": {
- ".": "./dist/index.js"
- },
- "files": ["dist", "README.md"],
- "scripts": {
- "build": "rolldown -c"
- },
- "dependencies": {
- "caplets": "workspace:*",
- "@opencode-ai/plugin": "^0.0.0"
- },
- "peerDependencies": {
- "@opencode-ai/plugin": ">=0"
- }
-}
-```
-
-During implementation, replace `^0.0.0` with the current compatible `@opencode-ai/plugin` version resolved by `pnpm add -F @caplets/opencode @opencode-ai/plugin`.
-
-- [ ] Create package build config mirroring the root ESM Node build without a shebang.
-
-- [ ] Implement `packages/opencode/src/schema.ts`.
-
-Implementation responsibilities:
-
-- Convert the existing generated operation schema into OpenCode `tool.schema` fields.
-- Keep `operation` enum values identical to `operations` in `packages/core/src/generated-tool-input-schema.mjs`.
-- Keep `arguments` as a JSON object for `call_tool` only.
-- Keep optional `fields` as string array.
-
-- [ ] Implement `packages/opencode/src/index.ts` OpenCode plugin.
-
-Behavior:
-
-- Export a default OpenCode plugin function.
-- Create one native service at plugin load.
-- Build a tool record from `service.listTools()`.
-- Each key is the native prefixed `toolName`.
-- Each tool executes `service.execute(capletId, args)` and returns text suitable for OpenCode.
-- Add an `experimental.chat.system.transform` hook that pushes `nativeCapletsSystemGuidance(toolNames)` into `output.system`.
-- Do not write files or mutate OpenCode config.
-
-- [ ] Write `packages/opencode/test/opencode.test.ts`.
-
-Test coverage:
-
-- Plugin returns one OpenCode tool per enabled Caplet.
-- Tool keys are prefixed and hyphen-normalized.
-- Tool descriptions include Caplet name and operation guidance.
-- Tool execution delegates to `service.execute()` with the original Caplet ID.
-- The system transform hook appends Caplets guidance and names registered native tools.
-
-- [ ] Add README usage docs.
-
-Required documentation:
-
-- Install package.
-- Register plugin using OpenCode's plugin mechanism.
-- State that no MCP server is required for this mode.
-- State that the plugin does not edit `opencode.json`.
-- Show the `caplets_` naming convention.
-
-- [ ] Run `pnpm --filter @caplets/opencode build`.
-
-Expected: adapter package builds successfully.
-
-- [ ] Run `pnpm test -- packages/opencode/test/opencode.test.ts`.
-
-Expected: OpenCode adapter tests pass.
-
----
-
-## Task 4: Pi Adapter Package
-
-**Files:**
-
-- Create: `packages/pi/package.json`
-- Create: `packages/pi/rolldown.config.ts`
-- Create: `packages/pi/tsconfig.json`
-- Create: `packages/pi/src/schema.ts`
-- Create: `packages/pi/src/index.ts`
-- Create: `packages/pi/README.md`
-- Test: `packages/pi/test/pi.test.ts`
-
-- [ ] Create `packages/pi/package.json`.
-
-Expected shape:
-
-```json
-{
- "name": "@caplets/pi",
- "version": "0.0.0",
- "description": "Native Pi extension for Caplets.",
- "type": "module",
- "main": "dist/index.js",
- "exports": {
- ".": "./dist/index.js"
- },
- "files": ["dist", "README.md"],
- "scripts": {
- "build": "rolldown -c"
- },
- "dependencies": {
- "caplets": "workspace:*",
- "typebox": "^1.0.0",
- "@earendil-works/pi-coding-agent": "^0.0.0"
- },
- "peerDependencies": {
- "@earendil-works/pi-coding-agent": ">=0"
- }
-}
-```
-
-During implementation, replace placeholder versions with the current compatible versions resolved through package installation.
-
-- [ ] Implement `packages/pi/src/schema.ts`.
-
-Implementation responsibilities:
-
-- Build a TypeBox schema matching the Caplets operation request shape.
-- Use string literal union for `operation`.
-- Keep optional fields aligned with `generatedToolInputDescriptions`.
-- Preserve strict object behavior where Pi supports it.
-
-- [ ] Implement `packages/pi/src/index.ts` Pi extension.
-
-Behavior:
-
-- Export Pi's default extension function.
-- Create one native service at extension load.
-- Register one Pi tool per `service.listTools()` entry.
-- Use `name: tool.toolName`, `label: tool.title`, `description: tool.description`.
-- Set `promptSnippet` to a one-line Caplets capability summary.
-- Set `promptGuidelines` to bullets that explicitly name the tool.
-- `execute()` calls `service.execute(capletId, params)`.
-- Return Pi-compatible `{ content, details }`, with `details.result` carrying structured result data when available.
-- Attach a best-effort process shutdown handler that calls `service.close()` once.
-
-- [ ] Write `packages/pi/test/pi.test.ts`.
-
-Test coverage:
-
-- Extension registers one tool per enabled Caplet.
-- Tool names are prefixed and hyphen-normalized.
-- `promptGuidelines` explicitly include each native tool name.
-- Execution delegates to `service.execute()` with original Caplet ID.
-- Returned result includes LLM-readable text and details.
-
-- [ ] Add README usage docs.
-
-Required documentation:
-
-- Install package.
-- Register or load extension using Pi's extension mechanism.
-- State that no MCP server is required for this mode.
-- Show `caplets_` naming.
-- Explain that new Caplets require `/reload` or restarting Pi for v1.
-
-- [ ] Run `pnpm --filter @caplets/pi build`.
-
-Expected: adapter package builds successfully.
-
-- [ ] Run `pnpm test -- packages/pi/test/pi.test.ts`.
-
-Expected: Pi adapter tests pass.
-
----
-
-## Task 5: Root Tests And Type Safety
-
-**Files:**
-
-- Modify: `packages/core/test/tools.test.ts` if schema expectations need native export coverage.
-- Modify: `packages/core/test/registry.test.ts` only if prompt helper behavior moves.
-- Modify: `packages/core/test/runtime.test.ts` only if refactoring changes MCP runtime construction.
-
-- [ ] Add tests proving existing MCP runtime behavior is unchanged.
-
-Coverage:
-
-- `CapletsRuntime.registeredToolIds()` still returns raw Caplet IDs, not prefixed native names.
-- MCP generated tool descriptions still use existing `capabilityDescription()` output.
-- MCP operation validation remains strict.
-
-- [ ] Add tests proving native naming does not leak into MCP mode.
-
-Expected:
-
-- MCP tool name remains `github`.
-- Native tool name is `caplets_github`.
-
-- [ ] Run focused tests.
-
-Commands:
-
-```sh
-pnpm --filter @caplets/core test -- test/native.test.ts test/runtime.test.ts test/tools.test.ts
-pnpm test -- packages/opencode/test/opencode.test.ts packages/pi/test/pi.test.ts
-```
-
-Expected: all focused tests pass.
-
----
-
-## Task 6: Native Benchmark Modes
-
-**Files:**
-
-- Modify: `packages/benchmarks/lib/opencode-runner.mjs`
-- Modify: `packages/benchmarks/lib/pi-runner.mjs`
-- Modify: `packages/benchmarks/live-config/opencode/README.md`
-- Modify: `packages/benchmarks/live-config/pi/README.md`
-- Modify: `packages/benchmarks/test/benchmark.test.ts`
-
-- [ ] Add OpenCode `native-caplets` mode.
-
-Expected behavior:
-
-- Existing `direct-flat` and `caplets` modes remain unchanged.
-- `native-caplets` config loads `@caplets/opencode` as a plugin instead of registering the Caplets MCP server.
-- The generated Caplets config file is still written for the native service to read.
-- The runner sets `CAPLETS_CONFIG` to the generated config path.
-- The runner does not expose mock servers directly through MCP in native mode.
-
-- [ ] Add Pi `native-caplets` mode.
-
-Expected behavior:
-
-- Existing `direct-flat`, `pi-proxy`, and `caplets` modes remain unchanged.
-- `native-caplets` config loads `@caplets/pi` as an extension instead of registering the Caplets MCP server.
-- The generated Caplets config file is still written for the native service to read.
-- The runner sets `CAPLETS_CONFIG` to the generated config path.
-
-- [ ] Update benchmark tests.
-
-Coverage:
-
-- `OPENCODE_CONFIG_MODES` includes `native-caplets`.
-- `PI_CONFIG_MODES` includes `native-caplets`.
-- Native configs do not include a Caplets MCP server entry.
-- Native configs preserve generated Caplets config support files.
-
-- [ ] Update live benchmark docs.
-
-Docs must state:
-
-- `caplets` mode means Caplets over MCP.
-- `native-caplets` mode means native adapter package.
-- Live native modes require the adapter package to be built.
-
-- [ ] Run benchmark tests.
-
-Command:
-
-```sh
-pnpm --filter @caplets/benchmarks test -- test/benchmark.test.ts
-```
-
-Expected: benchmark config tests pass.
-
----
-
-## Task 7: Documentation And Install UX
-
-**Files:**
-
-- Modify: `README.md`
-- Create or modify: `docs/native-adapters.md`
-- Modify: `package.json`
-
-- [ ] Add README native adapter section.
-
-Required content:
-
-- Explain the three exposure modes: direct downstream MCP, Caplets MCP gateway, native Caplets adapter.
-- Explain why native adapters exist: lower adapter overhead, native prompt hooks, no MCP server process for Caplets itself.
-- State that downstream MCP backends may still be used by Caplets internally; “native” means the agent-to-Caplets boundary is native.
-- Show native tool naming: `caplets_`.
-- State that v1 snapshots Caplets at plugin/extension load.
-
-- [ ] Add `docs/native-adapters.md`.
-
-Required sections:
-
-- Architecture diagram in text form.
-- OpenCode install/use.
-- Pi install/use.
-- Security model.
-- Prompt guidance behavior.
-- Troubleshooting.
-
-- [ ] Add package release notes.
-
-If this repo uses Changesets at implementation time, add a changeset that describes:
-
-- `caplets`: adds native runtime export.
-- `@caplets/opencode`: initial native OpenCode adapter.
-- `@caplets/pi`: initial native Pi adapter.
-
-- [ ] Run docs-related checks.
-
-Command:
-
-```sh
-pnpm format:check
-```
-
-Expected: markdown formatting passes.
-
----
-
-## Task 8: Full Verification
-
-**Files:**
-
-- All touched files.
-
-- [ ] Install dependencies after adding workspace packages.
-
-Command:
-
-```sh
-pnpm install
-```
-
-Expected: lockfile updates only for intentional workspace/package dependencies.
-
-- [ ] Run formatting.
-
-Command:
-
-```sh
-pnpm format:check
-```
-
-Expected: pass.
-
-- [ ] Run lint.
-
-Command:
-
-```sh
-pnpm lint
-```
-
-Expected: pass.
-
-- [ ] Run typecheck.
-
-Command:
-
-```sh
-pnpm typecheck
-```
-
-Expected: pass across root and packages.
-
-- [ ] Run schema drift check.
-
-Command:
-
-```sh
-pnpm schema:check
-```
-
-Expected: pass. No Caplet schema changes should be needed for this feature.
-
-- [ ] Run tests.
-
-Command:
-
-```sh
-pnpm test
-```
-
-Expected: pass.
-
-- [ ] Run deterministic benchmark check.
-
-Command:
-
-```sh
-pnpm benchmark:check
-```
-
-Expected: pass.
-
-- [ ] Run full build.
-
-Command:
-
-```sh
-pnpm build
-```
-
-Expected: root package plus both native adapter packages build successfully.
-
-- [ ] Run full verification.
-
-Command:
-
-```sh
-pnpm verify
-```
-
-Expected: pass.
-
----
-
-## Security And Safety Requirements
-
-- Native adapters must not expose secrets in tool descriptions, system guidance, errors, or details.
-- Native adapters must not write or modify user OpenCode/Pi config.
-- Native adapters must preserve existing Caplets trust rules for project `.caplets` files.
-- Native adapters must preserve existing OAuth/auth store behavior.
-- OpenCode native tools must not override built-in tools because names are prefixed.
-- Pi guidelines must explicitly name each tool to avoid ambiguous prompt bullets.
-- Adapter package tests must include a secret-looking config value and assert it is absent from tool descriptions and `get_caplet` detail.
-
----
-
-## Residual Risks
-
-- OpenCode prompt hook APIs are experimental. The implementation must pin tests to the actual installed `@opencode-ai/plugin` types and fail fast if `experimental.chat.system.transform` changes.
-- Pi extension APIs may differ by installed version. Keep the Pi adapter package peer range explicit once the implementation resolves the current package version.
-- Snapshot-at-load means users must reload to see newly added Caplets. This is deliberate for v1 and must be documented clearly.
-- Native adapters reduce the agent-to-Caplets MCP boundary, but MCP-backed Caplets still start downstream MCP servers internally when selected.
-
----
-
-## Completion Criteria
-
-- `caplets/native` exports a stable in-process service used by both adapters.
-- `@caplets/opencode` builds and registers prefixed native Caplet tools plus system prompt guidance through hooks.
-- `@caplets/pi` builds and registers prefixed native Caplet tools plus Pi prompt snippets/guidelines.
-- Existing MCP behavior remains unchanged.
-- Docs clearly distinguish MCP gateway mode from native adapter mode.
-- `pnpm verify` passes.
diff --git a/docs/plans/2026-05-15-native-hot-reload.md b/docs/plans/2026-05-15-native-hot-reload.md
deleted file mode 100644
index cdd9501f..00000000
--- a/docs/plans/2026-05-15-native-hot-reload.md
+++ /dev/null
@@ -1,1569 +0,0 @@
-# Native Hot Reload Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add hot-reload support to native Caplets integrations so native tool execution uses the latest config, Pi can refresh its per-tool surface at runtime, and OpenCode gets the strongest behavior its current plugin API supports.
-
-**Architecture:** Extract the existing MCP runtime reload/watch/backend-invalidation machinery into a shared core engine that owns config state, filesystem watchers, backend managers, reload serialization, and last-known-good behavior. Rebuild the MCP `CapletsRuntime` and native `DefaultNativeCapletsService` on top of that engine; Pi subscribes to native tool-change events and syncs registered/active tools, while OpenCode keeps static tool inventory but reads live service state for execution and system guidance.
-
-**Tech Stack:** TypeScript, Node.js `fs.watch`, Vitest, `@modelcontextprotocol/sdk` `RegisteredTool`, OpenCode `@opencode-ai/plugin`, Pi extension APIs `registerTool`, `getActiveTools`, and `setActiveTools`.
-
----
-
-## Architecture Decisions From Grill Session
-
-- OpenCode strategy: support hot-reload for existing registered tools only, with room to expand if OpenCode adds a runtime plugin-tool registry later.
-- Pi removal strategy: deactivate stale Caplets with `setActiveTools()` when hard `unregisterTool()` is unavailable.
-- Core strategy: do not duplicate `CapletsRuntime` reload logic in native service; extract a shared engine and reuse it.
-- Compatibility strategy: keep the current `NativeCapletsService` methods and add new methods, rather than changing adapter call sites to a completely different object.
-
-## Current State
-
-- `packages/core/src/runtime.ts` already supports config and Caplet file watching, debounced reloads, pending reload coalescing, last-known-good config on validation failure, selective backend invalidation, MCP tool add/update/remove, and watcher refresh.
-- `packages/core/src/native/service.ts` loads config once in the constructor and never reloads.
-- `packages/opencode/src/index.ts` calls `service.listTools()` once while constructing the plugin `Hooks.tool` map.
-- `packages/pi/src/index.ts` calls `service.listTools()` once and calls `pi.registerTool()` once per Caplet.
-- Pi docs say `pi.registerTool()` works after startup and new tools refresh immediately in-session. Pi docs also expose `getActiveTools()` and `setActiveTools(names)` for dynamic activation.
-- OpenCode plugin types expose `Hooks.tool` as a static object and do not expose `registerTool`, `unregisterTool`, `updateToolDefinition`, or `refreshTools`. Upstream OpenCode issue `anomalyco/opencode#25531` requests this capability.
-
-## File Structure
-
-- Create `packages/core/src/engine.ts`: shared reloadable Caplets engine; owns config paths, registry, managers, watchers, reload lifecycle, listeners, and execution.
-- Modify `packages/core/src/runtime.ts`: remove duplicated reload/watch/backend-manager ownership and delegate to `CapletsEngine`; keep MCP-specific tool registration/reconciliation here.
-- Modify `packages/core/src/native/service.ts`: wrap `CapletsEngine`, expose `reload()`, `onToolsChanged()`, and live `listTools()`.
-- Modify `packages/core/src/native.ts`: export new native listener/event types if they are public.
-- Create `packages/core/test/engine.test.ts`: shared engine reload behavior tests moved from runtime-native overlap.
-- Modify `packages/core/test/runtime.test.ts`: keep MCP tool reconciliation tests; remove direct private `reloadOnce` spy and rely on engine tests for reload coalescing.
-- Modify `packages/core/test/native.test.ts`: add native service reload, watcher, invalid config, and tool-change listener tests.
-- Modify `packages/pi/src/index.ts`: register initial tools, subscribe to service tool changes, register new/updated tools, and preserve non-Caplets active tools while deactivating stale Caplet tools.
-- Modify `packages/pi/test/pi.test.ts`: add dynamic registration and active-tool preservation tests.
-- Modify `packages/opencode/src/index.ts`: keep static `Hooks.tool` inventory, but compute system guidance from `service.listTools()` each transform call so existing-tool metadata stays fresh.
-- Modify `packages/opencode/test/opencode.test.ts`: assert system guidance reflects current service state after list changes.
-- Modify `README.md`, `packages/pi/README.md`, and `packages/opencode/README.md`: document host-specific hot-reload behavior.
-- Modify `.changeset/native-agent-integrations.md`: extend the existing unreleased native integrations changeset to mention native hot-reload.
-
----
-
-### Task 1: Extract Shared Reloadable Engine
-
-**Files:**
-
-- Create: `packages/core/src/engine.ts`
-- Test: `packages/core/test/engine.test.ts`
-- Modify later: `packages/core/src/runtime.ts`
-
-- [ ] **Step 1: Write failing shared engine tests**
-
-Create `packages/core/test/engine.test.ts` with these tests. This intentionally mirrors the proven behavior currently covered indirectly through `CapletsRuntime`, but targets the shared engine directly.
-
-```ts
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { CapletsEngine } from "../src/engine";
-
-describe("CapletsEngine", () => {
- const dirs: string[] = [];
-
- afterEach(() => {
- for (const dir of dirs.splice(0)) {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-
- it("adds, updates, and removes enabled Caplets across successful reloads", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: {
- name: "Alpha",
- description: "Search alpha project documents.",
- command: process.execPath,
- },
- beta: {
- name: "Beta",
- description: "Search beta project documents.",
- command: process.execPath,
- disabled: true,
- },
- },
- });
- dirs.push(dir);
- const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false });
- const events: Array<{ previous: string[]; next: string[]; invalidated: boolean }> = [];
- engine.onReload(({ previous, next, invalidated }) => {
- events.push({
- previous: Object.keys(previous.mcpServers).sort(),
- next: Object.keys(next.mcpServers).sort(),
- invalidated,
- });
- });
-
- expect(engine.enabledServers().map((caplet) => caplet.server)).toEqual(["alpha"]);
-
- writeConfig(configPath, {
- mcpServers: {
- alpha: {
- name: "Alpha Reloaded",
- description: "Search alpha project documents after reload.",
- command: process.execPath,
- },
- gamma: {
- name: "Gamma",
- description: "Search gamma project documents.",
- command: process.execPath,
- },
- },
- });
-
- await expect(engine.reload()).resolves.toBe(true);
- expect(
- engine
- .enabledServers()
- .map((caplet) => caplet.server)
- .sort(),
- ).toEqual(["alpha", "gamma"]);
- expect(engine.enabledServers().find((caplet) => caplet.server === "alpha")?.name).toBe(
- "Alpha Reloaded",
- );
- expect(events).toHaveLength(1);
- expect(events[0]).toEqual({
- previous: ["alpha", "beta"],
- next: ["alpha", "gamma"],
- invalidated: true,
- });
-
- writeConfig(configPath, {
- mcpServers: {
- gamma: {
- name: "Gamma",
- description: "Search gamma project documents.",
- command: process.execPath,
- },
- },
- });
-
- await expect(engine.reload()).resolves.toBe(true);
- expect(engine.enabledServers().map((caplet) => caplet.server)).toEqual(["gamma"]);
-
- await engine.close();
- });
-
- it("keeps last known-good config when reload validation fails", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: {
- name: "Alpha",
- description: "Search alpha project documents.",
- command: process.execPath,
- },
- },
- });
- dirs.push(dir);
- const errors: string[] = [];
- const engine = new CapletsEngine({
- configPath,
- projectConfigPath,
- watch: false,
- writeErr: (value) => errors.push(value),
- });
- const listener = vi.fn();
- engine.onReload(listener);
-
- writeFileSync(configPath, "{ invalid json");
-
- await expect(engine.reload()).resolves.toBe(false);
- expect(engine.enabledServers().map((caplet) => caplet.server)).toEqual(["alpha"]);
- expect(listener).not.toHaveBeenCalled();
- expect(errors.join("")).toContain("Caplets config reload failed");
-
- await engine.close();
- });
-
- it("runs a follow-up reload when another reload is requested mid-flight", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: {
- name: "Alpha",
- description: "Search alpha project documents.",
- command: process.execPath,
- },
- },
- });
- dirs.push(dir);
- const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false });
- let calls = 0;
-
- (engine as unknown as { reloadOnce: () => Promise }).reloadOnce = vi.fn(async () => {
- calls += 1;
- if (calls === 1) {
- void engine.reload();
- }
- return true;
- });
-
- await engine.reload();
- expect(calls).toBe(2);
-
- await engine.close();
- });
-
- it("watches config and Caplet paths when watch is enabled", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: {
- name: "Alpha",
- description: "Search alpha project documents.",
- command: process.execPath,
- },
- },
- });
- dirs.push(dir);
- const engine = new CapletsEngine({ configPath, projectConfigPath, watchDebounceMs: 10 });
- let reloads = 0;
- (engine as unknown as { reload: () => Promise }).reload = vi.fn(async () => {
- reloads += 1;
- return true;
- });
-
- writeConfig(configPath, {
- mcpServers: {
- beta: {
- name: "Beta",
- description: "Search beta project documents.",
- command: process.execPath,
- },
- },
- });
-
- await eventually(() => expect(reloads).toBeGreaterThan(0));
- await engine.close();
- });
-
- function tempConfig(config: unknown): {
- dir: string;
- configPath: string;
- projectConfigPath: string;
- } {
- const dir = mkdtempSync(join(tmpdir(), "caplets-engine-"));
- const userRoot = join(dir, "user");
- const projectRoot = join(dir, "project", ".caplets");
- mkdirSync(userRoot, { recursive: true });
- mkdirSync(projectRoot, { recursive: true });
- const configPath = join(userRoot, "config.json");
- const projectConfigPath = join(projectRoot, "config.json");
- writeConfig(configPath, config);
- return { dir, configPath, projectConfigPath };
- }
-});
-
-function writeConfig(path: string, config: unknown): void {
- writeFileSync(path, JSON.stringify(config));
-}
-
-async function eventually(assertion: () => void): Promise {
- const deadline = Date.now() + 1_000;
- let lastError: unknown;
- while (Date.now() < deadline) {
- try {
- assertion();
- return;
- } catch (error) {
- lastError = error;
- await new Promise((resolve) => setTimeout(resolve, 20));
- }
- }
- try {
- assertion();
- } catch {
- throw lastError;
- }
-}
-```
-
-- [ ] **Step 2: Run engine test to verify it fails**
-
-Run: `pnpm --filter @caplets/core test -- test/engine.test.ts`
-
-Expected: FAIL because `../src/engine.js` does not exist.
-
-- [ ] **Step 3: Create the shared engine**
-
-Create `packages/core/src/engine.ts`. Move the reload, watcher, config path, backend manager, and helper logic from `packages/core/src/runtime.ts` into this file. The public surface must match this shape:
-
-```ts
-import { existsSync, readdirSync, statSync, watch, type FSWatcher } from "node:fs";
-import { dirname, parse } from "node:path";
-import { CliToolsManager } from "./cli-tools";
-import {
- type CapletConfig,
- type CapletsConfig,
- loadConfig,
- resolveCapletsRoot,
- resolveConfigPath,
- resolveProjectConfigPath,
-} from "./config";
-import { DownstreamManager } from "./downstream";
-import { errorResult, toSafeError } from "./errors";
-import { GraphQLManager } from "./graphql";
-import { HttpActionManager } from "./http-actions";
-import { OpenApiManager } from "./openapi";
-import { ServerRegistry } from "./registry";
-import { handleServerTool } from "./tools";
-
-export type CapletsEngineOptions = {
- configPath?: string;
- projectConfigPath?: string;
- authDir?: string;
- watchDebounceMs?: number;
- watch?: boolean;
- writeErr?: (value: string) => void;
-};
-
-export type CapletsEngineReloadEvent = {
- previous: CapletsConfig;
- next: CapletsConfig;
- invalidated: boolean;
-};
-
-type RuntimePaths = {
- configPath: string;
- projectConfigPath: string;
-};
-
-type WatchedPath = {
- path: string;
- reason: "config" | "caplets";
-};
-
-export class CapletsEngine {
- private registry: ServerRegistry;
- private readonly downstream: DownstreamManager;
- private readonly openapi: OpenApiManager;
- private readonly graphql: GraphQLManager;
- private readonly http: HttpActionManager;
- private readonly cli: CliToolsManager;
- private readonly paths: RuntimePaths;
- private readonly watchDebounceMs: number;
- private readonly watchEnabled: boolean;
- private readonly writeErr: (value: string) => void;
- private readonly reloadListeners = new Set<(event: CapletsEngineReloadEvent) => void>();
- private watchers: FSWatcher[] = [];
- private reloadTimer: NodeJS.Timeout | undefined;
- private watcherRefreshTimer: NodeJS.Timeout | undefined;
- private reloading: Promise | undefined;
- private pendingReload = false;
- private closed = false;
-
- constructor(options: CapletsEngineOptions = {}) {
- this.paths = {
- configPath: resolveConfigPath(options.configPath),
- projectConfigPath: options.projectConfigPath ?? resolveProjectConfigPath(),
- };
- const config = loadConfig(this.paths.configPath, this.paths.projectConfigPath);
- this.registry = new ServerRegistry(config);
- this.downstream = new DownstreamManager(this.registry, selectAuthOptions(options.authDir));
- this.openapi = new OpenApiManager(this.registry, selectAuthOptions(options.authDir));
- this.graphql = new GraphQLManager(this.registry, selectAuthOptions(options.authDir));
- this.http = new HttpActionManager(this.registry, selectAuthOptions(options.authDir));
- this.cli = new CliToolsManager(this.registry);
- this.watchDebounceMs = options.watchDebounceMs ?? 250;
- this.watchEnabled = options.watch ?? true;
- this.writeErr = options.writeErr ?? ((value: string) => process.stderr.write(value));
- if (this.watchEnabled) {
- this.resetWatchers();
- }
- }
-
- currentConfig(): CapletsConfig {
- return this.registry.config;
- }
-
- enabledServers(): CapletConfig[] {
- return nextEnabledServers(this.registry.config);
- }
-
- watchedPaths(): string[] {
- return [...new Set(watchedPaths(this.paths).map((entry) => entry.path))].sort();
- }
-
- onReload(listener: (event: CapletsEngineReloadEvent) => void): () => void {
- this.reloadListeners.add(listener);
- return () => {
- this.reloadListeners.delete(listener);
- };
- }
-
- scheduleReload(): void {
- if (this.closed) return;
- if (this.reloadTimer) clearTimeout(this.reloadTimer);
- this.reloadTimer = setTimeout(() => {
- this.reloadTimer = undefined;
- void this.reload();
- }, this.watchDebounceMs);
- }
-
- async reload(): Promise {
- if (this.closed) return false;
- if (this.reloading) {
- this.pendingReload = true;
- return await this.reloading;
- }
- this.reloading = this.reloadUntilSettled().finally(() => {
- this.reloading = undefined;
- });
- return await this.reloading;
- }
-
- async execute(serverId: string, request: unknown): Promise {
- try {
- const caplet = this.registry.require(serverId);
- return await handleServerTool(
- caplet,
- request,
- this.registry,
- this.downstream,
- this.openapi,
- this.graphql,
- this.http,
- this.cli,
- );
- } catch (error) {
- return errorResult(error);
- }
- }
-
- async close(): Promise {
- this.closed = true;
- try {
- if (this.reloadTimer) {
- clearTimeout(this.reloadTimer);
- this.reloadTimer = undefined;
- }
- if (this.watcherRefreshTimer) {
- clearTimeout(this.watcherRefreshTimer);
- this.watcherRefreshTimer = undefined;
- }
- if (this.reloading) {
- await this.reloading;
- }
- } finally {
- this.closeWatchers();
- await this.downstream.close();
- this.reloadListeners.clear();
- }
- }
-
- private async reloadOnce(): Promise {
- if (this.closed) return false;
- let nextConfig: CapletsConfig;
- try {
- nextConfig = loadConfig(this.paths.configPath, this.paths.projectConfigPath);
- } catch (error) {
- this.writeErr(`Caplets config reload failed; keeping last known-good config.\n`);
- this.writeErr(`${JSON.stringify(toSafeError(error, "CONFIG_INVALID"), null, 2)}\n`);
- return false;
- }
-
- if (this.closed) return false;
- const previousConfig = this.registry.config;
- const nextRegistry = new ServerRegistry(nextConfig);
- this.registry = nextRegistry;
- this.downstream.updateRegistry(nextRegistry);
- this.openapi.updateRegistry(nextRegistry);
- this.graphql.updateRegistry(nextRegistry);
- this.http.updateRegistry(nextRegistry);
- this.cli.updateRegistry(nextRegistry);
-
- let invalidated = true;
- try {
- await this.invalidateChangedBackends(previousConfig, nextConfig);
- } catch (error) {
- invalidated = false;
- this.writeErr(`Caplets backend invalidation failed; continuing reload.\n`);
- this.writeErr(`${JSON.stringify(toSafeError(error, "INTERNAL_ERROR"), null, 2)}\n`);
- }
- if (this.closed) return false;
- if (this.watchEnabled) {
- this.resetWatchers();
- }
- this.emitReload({ previous: previousConfig, next: nextConfig, invalidated });
- return invalidated;
- }
-
- private async reloadUntilSettled(): Promise {
- let succeeded = true;
- do {
- this.pendingReload = false;
- try {
- succeeded = (await this.reloadOnce()) && succeeded;
- } catch (err) {
- this.writeErr(`Caplets reload failed.\n`);
- this.writeErr(`${JSON.stringify(toSafeError(err, "INTERNAL_ERROR"), null, 2)}\n`);
- succeeded = false;
- }
- } while (this.pendingReload && !this.closed);
- return succeeded && !this.closed;
- }
-
- private emitReload(event: CapletsEngineReloadEvent): void {
- for (const listener of this.reloadListeners) {
- listener(event);
- }
- }
-
- private async invalidateChangedBackends(
- previous: CapletsConfig,
- next: CapletsConfig,
- ): Promise {
- const previousCaplets = new Map(allCaplets(previous).map((server) => [server.server, server]));
- const nextCaplets = new Map(allCaplets(next).map((server) => [server.server, server]));
- const changedIds = new Set([...previousCaplets.keys(), ...nextCaplets.keys()]);
-
- for (const serverId of changedIds) {
- const before = previousCaplets.get(serverId);
- const after = nextCaplets.get(serverId);
- const changed = serializeCaplet(before) !== serializeCaplet(after);
- if (!changed) continue;
- if (before?.backend === "mcp") await this.downstream.closeServer(serverId);
- if (before?.backend === "openapi" || after?.backend === "openapi" || !after)
- this.openapi.invalidate(serverId);
- if (before?.backend === "graphql" || after?.backend === "graphql" || !after)
- this.graphql.invalidate(serverId);
- if (before?.backend === "http" || after?.backend === "http" || !after)
- this.http.invalidate(serverId);
- if (before?.backend === "cli" || after?.backend === "cli" || !after)
- this.cli.invalidate(serverId);
- }
- }
-
- private resetWatchers(): void {
- this.closeWatchers();
- const watched = new Set();
- for (const entry of watchedPaths(this.paths)) {
- const watchPath = existsSync(entry.path) ? entry.path : nearestExistingParent(entry.path);
- const watchKey = `${entry.reason}:${watchPath}`;
- if (!watchPath || watched.has(watchKey)) continue;
- watched.add(watchKey);
- try {
- this.watchers.push(...this.watchEntry(entry, watchPath));
- } catch (error) {
- this.writeErr(`Caplets could not watch ${entry.reason} path ${entry.path}.\n`);
- this.writeErr(`${JSON.stringify(toSafeError(error, "SERVER_UNAVAILABLE"), null, 2)}\n`);
- }
- }
- }
-
- private closeWatchers(): void {
- for (const watcher of this.watchers) watcher.close();
- this.watchers = [];
- }
-
- private watchEntry(entry: WatchedPath, watchPath: string): FSWatcher[] {
- if (entry.reason === "caplets" && existsSync(entry.path) && isDirectory(watchPath)) {
- return this.watchDirectoryTree(watchPath);
- }
- return [
- watch(watchPath, { persistent: true }, (eventType) => {
- this.scheduleReload();
- if (eventType === "rename" && entry.reason === "caplets" && existsSync(entry.path)) {
- this.scheduleWatcherRefresh();
- }
- }),
- ];
- }
-
- private watchDirectoryTree(root: string): FSWatcher[] {
- const watchers: FSWatcher[] = [];
- const directories = discoverDirectories(root);
- for (const directory of directories) {
- try {
- watchers.push(
- watch(directory, { persistent: true }, (eventType) => {
- this.scheduleReload();
- if (eventType === "rename") this.scheduleWatcherRefresh();
- }),
- );
- } catch (error) {
- for (const watcher of watchers) watcher.close();
- throw error;
- }
- }
- return watchers;
- }
-
- private scheduleWatcherRefresh(): void {
- if (this.closed) return;
- if (this.watcherRefreshTimer) clearTimeout(this.watcherRefreshTimer);
- this.watcherRefreshTimer = setTimeout(() => {
- this.watcherRefreshTimer = undefined;
- if (!this.closed) this.resetWatchers();
- }, this.watchDebounceMs);
- }
-}
-
-function selectAuthOptions(authDir: string | undefined): { authDir?: string } {
- return authDir ? { authDir } : {};
-}
-
-function watchedPaths(paths: RuntimePaths): WatchedPath[] {
- return uniqueWatchedPaths([
- { path: dirname(paths.configPath), reason: "config" },
- { path: dirname(paths.projectConfigPath), reason: "config" },
- { path: resolveCapletsRoot(paths.configPath), reason: "caplets" },
- { path: dirname(paths.projectConfigPath), reason: "caplets" },
- ]);
-}
-
-function uniqueWatchedPaths(entries: WatchedPath[]): WatchedPath[] {
- const seen = new Set();
- const unique: WatchedPath[] = [];
- for (const entry of entries) {
- const key = `${entry.reason}:${entry.path}`;
- if (seen.has(key)) continue;
- seen.add(key);
- unique.push(entry);
- }
- return unique;
-}
-
-function allCaplets(config: CapletsConfig): CapletConfig[] {
- return [
- ...Object.values(config.mcpServers),
- ...Object.values(config.openapiEndpoints),
- ...Object.values(config.graphqlEndpoints),
- ...Object.values(config.httpApis),
- ...Object.values(config.cliTools),
- ];
-}
-
-function nextEnabledServers(config: CapletsConfig): CapletConfig[] {
- return allCaplets(config).filter((server) => !server.disabled);
-}
-
-function serializeCaplet(caplet: CapletConfig | undefined): string {
- return JSON.stringify(caplet ?? null);
-}
-
-function nearestExistingParent(path: string): string | undefined {
- let candidate = dirname(path);
- const root = parse(candidate).root;
- while (candidate && candidate !== root) {
- if (existsSync(candidate)) return candidate;
- candidate = dirname(candidate);
- }
- return existsSync(root) ? root : undefined;
-}
-
-function discoverDirectories(root: string): string[] {
- if (!isDirectory(root)) return [];
- const directories = [root];
- for (const entry of readdirSync(root, { withFileTypes: true })) {
- if (entry.isDirectory()) directories.push(...discoverDirectories(`${root}/${entry.name}`));
- }
- return directories;
-}
-
-function isDirectory(path: string): boolean {
- try {
- return statSync(path).isDirectory();
- } catch {
- return false;
- }
-}
-```
-
-- [ ] **Step 4: Run engine tests**
-
-Run: `pnpm --filter @caplets/core test -- test/engine.test.ts`
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit**
-
-```sh
-git add packages/core/src/engine.ts packages/core/test/engine.test.ts
-git commit -m "refactor(core): extract reloadable Caplets engine"
-```
-
----
-
-### Task 2: Rebuild MCP Runtime On The Shared Engine
-
-**Files:**
-
-- Modify: `packages/core/src/runtime.ts`
-- Test: `packages/core/test/runtime.test.ts`
-
-- [ ] **Step 1: Update runtime tests for the engine split**
-
-Keep the MCP-specific tests in `packages/core/test/runtime.test.ts`: initial tool registration, raw MCP names with prefixed native names, add/update/remove reconciliation, invalid config retaining old MCP tools, backend invalidation failure behavior, watched path exposure, and watcher scheduling.
-
-Delete or move the test named `runs a follow-up reload when another reload is requested mid-flight`; that behavior is now covered in `packages/core/test/engine.test.ts`.
-
-Run: `pnpm --filter @caplets/core test -- test/runtime.test.ts`
-
-Expected: FAIL while `CapletsRuntime` still owns duplicated logic and/or the private `reloadOnce` test no longer applies.
-
-- [ ] **Step 2: Replace runtime state ownership with `CapletsEngine`**
-
-In `packages/core/src/runtime.ts`, remove imports that are now engine-owned: `existsSync`, `readdirSync`, `statSync`, `watch`, `dirname`, `parse`, `loadConfig`, `resolveCapletsRoot`, `resolveConfigPath`, `resolveProjectConfigPath`, `CliToolsManager`, `DownstreamManager`, `errorResult`, `toSafeError`, `GraphQLManager`, `HttpActionManager`, `OpenApiManager`, `ServerRegistry`, and `handleServerTool`.
-
-Add these imports:
-
-```ts
-import type { CapletConfig, CapletsConfig } from "./config";
-import { CapletsEngine } from "./engine";
-```
-
-Change the class fields to this shape:
-
-```ts
-export class CapletsRuntime {
- readonly server: ToolServer;
- private readonly engine: CapletsEngine;
- private readonly tools = new Map();
- private readonly unsubscribeReload: () => void;
- private closed = false;
-```
-
-Change the constructor to instantiate the engine and reconcile initial tools:
-
-```ts
- constructor(options: CapletsRuntimeOptions = {}) {
- this.engine = new CapletsEngine(options);
- this.server =
- options.server ??
- new McpServer({
- name: "caplets",
- version: packageJsonVersion,
- });
- this.reconcileTools(undefined, this.engine.currentConfig());
- this.unsubscribeReload = this.engine.onReload(({ previous, next }) => {
- this.reconcileTools(previous, next);
- });
- }
-```
-
-Replace reload/watch/current-config methods with delegates:
-
-```ts
- scheduleReload(): void {
- this.engine.scheduleReload();
- }
-
- async reload(): Promise {
- return await this.engine.reload();
- }
-
- async close(): Promise {
- if (this.closed) return;
- this.closed = true;
- this.unsubscribeReload();
- try {
- await this.engine.close();
- } finally {
- await this.server.close();
- }
- }
-
- currentConfig(): CapletsConfig {
- return this.engine.currentConfig();
- }
-
- watchedPaths(): string[] {
- return this.engine.watchedPaths();
- }
-```
-
-Replace `handleTool` with a delegate:
-
-```ts
- private async handleTool(serverId: string, request: unknown): Promise {
- return await this.engine.execute(serverId, request);
- }
-```
-
-Keep `reconcileTools`, `registerCapletTool`, `nextEnabledServers`, `capletById`, and `serializeCaplet` in `runtime.ts` because they are MCP tool-registration concerns.
-
-- [ ] **Step 3: Remove duplicated runtime helper code**
-
-Delete these from `packages/core/src/runtime.ts` after the engine delegate is in place:
-
-```ts
-type RuntimePaths = { configPath: string; projectConfigPath: string };
-type WatchedPath = { path: string; reason: "config" | "caplets" };
-selectAuthOptions;
-watchedPaths;
-uniqueWatchedPaths;
-allCaplets;
-nearestExistingParent;
-discoverDirectories;
-isDirectory;
-```
-
-Keep `nextEnabledServers`, `capletById`, and `serializeCaplet` if they are still used by MCP reconciliation.
-
-- [ ] **Step 4: Run runtime tests**
-
-Run: `pnpm --filter @caplets/core test -- test/runtime.test.ts`
-
-Expected: PASS.
-
-- [ ] **Step 5: Run focused core tests**
-
-Run: `pnpm --filter @caplets/core test -- test/engine.test.ts test/runtime.test.ts`
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit**
-
-```sh
-git add packages/core/src/runtime.ts packages/core/test/runtime.test.ts
-git commit -m "refactor(core): reuse engine in MCP runtime"
-```
-
----
-
-### Task 3: Make Native Service Reloadable
-
-**Files:**
-
-- Modify: `packages/core/src/native/service.ts`
-- Modify: `packages/core/src/native.ts`
-- Test: `packages/core/test/native.test.ts`
-
-- [ ] **Step 1: Write failing native reload tests**
-
-Append these tests to `packages/core/test/native.test.ts` before the `tempConfig` helper:
-
-```ts
-it("reloads native tool metadata after config changes", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: {
- name: "Alpha",
- description: "Search alpha project documents.",
- command: process.execPath,
- },
- },
- });
- dirs.push(dir);
- const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false });
-
- try {
- expect(service.listTools().map((tool) => tool.caplet)).toEqual(["alpha"]);
- writeFileSync(
- configPath,
- JSON.stringify({
- mcpServers: {
- beta: {
- name: "Beta",
- description: "Search beta project documents.",
- command: process.execPath,
- },
- },
- }),
- );
-
- await expect(service.reload()).resolves.toBe(true);
- expect(service.listTools()).toEqual([
- expect.objectContaining({ caplet: "beta", toolName: "caplets_beta", title: "Beta" }),
- ]);
- } finally {
- await service.close();
- }
-});
-
-it("notifies native tool listeners on successful reload only", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: {
- name: "Alpha",
- description: "Search alpha project documents.",
- command: process.execPath,
- },
- },
- });
- dirs.push(dir);
- const service = createNativeCapletsService({ configPath, projectConfigPath, watch: false });
- const events: string[][] = [];
- const unsubscribe = service.onToolsChanged((tools) => {
- events.push(tools.map((tool) => tool.caplet));
- });
-
- try {
- writeFileSync(configPath, "{ invalid json");
- await expect(service.reload()).resolves.toBe(false);
- expect(events).toEqual([]);
-
- writeFileSync(
- configPath,
- JSON.stringify({
- mcpServers: {
- gamma: {
- name: "Gamma",
- description: "Search gamma project documents.",
- command: process.execPath,
- },
- },
- }),
- );
- await expect(service.reload()).resolves.toBe(true);
- expect(events).toEqual([["gamma"]]);
-
- unsubscribe();
- writeFileSync(
- configPath,
- JSON.stringify({
- mcpServers: {
- delta: {
- name: "Delta",
- description: "Search delta project documents.",
- command: process.execPath,
- },
- },
- }),
- );
- await expect(service.reload()).resolves.toBe(true);
- expect(events).toEqual([["gamma"]]);
- } finally {
- await service.close();
- }
-});
-```
-
-Update the import from `node:fs` at the top of `native.test.ts` to keep `writeFileSync` available; it already is available today.
-
-- [ ] **Step 2: Run native tests to verify they fail**
-
-Run: `pnpm --filter @caplets/core test -- test/native.test.ts`
-
-Expected: FAIL because `watch`, `reload`, and `onToolsChanged` are not in `NativeCapletsServiceOptions` / `NativeCapletsService` yet.
-
-- [ ] **Step 3: Extend native service types**
-
-In `packages/core/src/native/service.ts`, replace the type block with:
-
-```ts
-export type NativeCapletsServiceOptions = {
- configPath?: string;
- projectConfigPath?: string;
- authDir?: string;
- watchDebounceMs?: number;
- watch?: boolean;
- writeErr?: (value: string) => void;
-};
-
-export type NativeCapletTool = {
- caplet: string;
- toolName: string;
- title: string;
- description: string;
- promptGuidance: string[];
-};
-
-export type NativeCapletsToolsChangedListener = (tools: NativeCapletTool[]) => void;
-
-export type NativeCapletsService = {
- listTools(): NativeCapletTool[];
- execute(capletId: string, request: unknown): Promise;
- reload(): Promise;
- onToolsChanged(listener: NativeCapletsToolsChangedListener): () => void;
- close(): Promise;
-};
-```
-
-- [ ] **Step 4: Replace native service internals with `CapletsEngine`**
-
-In `packages/core/src/native/service.ts`, remove direct manager/config imports and add:
-
-```ts
-import { CapletsEngine } from "../engine";
-```
-
-Replace `DefaultNativeCapletsService` with:
-
-```ts
-class DefaultNativeCapletsService implements NativeCapletsService {
- private readonly engine: CapletsEngine;
-
- constructor(options: NativeCapletsServiceOptions) {
- this.engine = new CapletsEngine(options);
- }
-
- listTools(): NativeCapletTool[] {
- return this.engine.enabledServers().map((caplet) => {
- const toolName = nativeCapletToolName(caplet.server);
- return {
- caplet: caplet.server,
- toolName,
- title: caplet.name,
- description: nativeCapletToolDescription(toolName, caplet),
- promptGuidance: nativeCapletPromptGuidance(toolName, caplet),
- };
- });
- }
-
- async execute(capletId: string, request: unknown): Promise {
- return await this.engine.execute(capletId, request);
- }
-
- async reload(): Promise {
- return await this.engine.reload();
- }
-
- onToolsChanged(listener: NativeCapletsToolsChangedListener): () => void {
- return this.engine.onReload(() => listener(this.listTools()));
- }
-
- async close(): Promise {
- await this.engine.close();
- }
-}
-```
-
-- [ ] **Step 5: Export listener type**
-
-In `packages/core/src/native.ts`, update the export block:
-
-```ts
-export {
- createNativeCapletsService,
- type NativeCapletTool,
- type NativeCapletsService,
- type NativeCapletsServiceOptions,
- type NativeCapletsToolsChangedListener,
-} from "./native/service";
-```
-
-- [ ] **Step 6: Run native tests**
-
-Run: `pnpm --filter @caplets/core test -- test/native.test.ts`
-
-Expected: PASS.
-
-- [ ] **Step 7: Run core focused tests**
-
-Run: `pnpm --filter @caplets/core test -- test/engine.test.ts test/runtime.test.ts test/native.test.ts`
-
-Expected: PASS.
-
-- [ ] **Step 8: Commit**
-
-```sh
-git add packages/core/src/native.ts packages/core/src/native/service.ts packages/core/test/native.test.ts
-git commit -m "feat(core): hot reload native Caplets service"
-```
-
----
-
-### Task 4: Sync Pi Native Tools At Runtime
-
-**Files:**
-
-- Modify: `packages/pi/src/index.ts`
-- Test: `packages/pi/test/pi.test.ts`
-
-- [ ] **Step 1: Extend Pi test API types**
-
-In `packages/pi/test/pi.test.ts`, add this helper type near `RegisteredTool`:
-
-```ts
-type MockPiApi = {
- registerTool: Mock<(definition: unknown) => void>;
- getActiveTools: Mock<() => Array<{ name: string }>>;
- setActiveTools: Mock<(names: string[]) => void>;
-};
-```
-
-Add this helper near `mockService`:
-
-```ts
-function mockPiApi(activeTools: string[] = []): { api: MockPiApi; registered: RegisteredTool[] } {
- const registered: RegisteredTool[] = [];
- const api: MockPiApi = {
- registerTool: vi.fn((definition) => registered.push(definition as RegisteredTool)),
- getActiveTools: vi.fn(() => activeTools.map((name) => ({ name }))),
- setActiveTools: vi.fn(),
- };
- return { api, registered };
-}
-```
-
-Update `mockService` so tests can trigger listeners:
-
-```ts
-type MockService = NativeCapletsService & {
- listTools: Mock<() => NativeCapletTool[]>;
- execute: Mock;
- reload: Mock;
- onToolsChanged: Mock;
- close: Mock;
- setTools(tools: NativeCapletTool[]): void;
- emitToolsChanged(): void;
-};
-
-function mockService(tools: NativeCapletTool[]): MockService {
- let currentTools = tools;
- const listeners = new Set<(tools: NativeCapletTool[]) => void>();
- return {
- listTools: vi.fn<() => NativeCapletTool[]>(() => currentTools),
- execute: vi.fn(async () => ({ ok: true })),
- reload: vi.fn(async () => true),
- onToolsChanged: vi.fn((listener) => {
- listeners.add(listener);
- return () => listeners.delete(listener);
- }),
- close: vi.fn(async () => {}),
- setTools(nextTools) {
- currentTools = nextTools;
- },
- emitToolsChanged() {
- for (const listener of listeners) listener(currentTools);
- },
- };
-}
-```
-
-- [ ] **Step 2: Add failing Pi dynamic sync tests**
-
-Append these tests to `packages/pi/test/pi.test.ts`:
-
-```ts
-it("registers newly added tools when the native service changes", () => {
- const service = mockService([
- {
- caplet: "git-hub",
- toolName: "caplets_git_hub",
- title: "GitHub",
- description: "GitHub Caplet",
- promptGuidance: ["Use caplets_git_hub for GitHub."],
- },
- ]);
- const { api, registered } = mockPiApi(["read", "caplets_git_hub"]);
-
- capletsPiExtension(api, { service });
- service.setTools([
- {
- caplet: "git-hub",
- toolName: "caplets_git_hub",
- title: "GitHub",
- description: "GitHub Caplet",
- promptGuidance: ["Use caplets_git_hub for GitHub."],
- },
- {
- caplet: "linear",
- toolName: "caplets_linear",
- title: "Linear",
- description: "Linear Caplet",
- promptGuidance: ["Use caplets_linear for Linear."],
- },
- ]);
- service.emitToolsChanged();
-
- expect(registered.map((tool) => tool.name)).toEqual(["caplets_git_hub", "caplets_linear"]);
- expect(api.setActiveTools).toHaveBeenLastCalledWith([
- "read",
- "caplets_git_hub",
- "caplets_linear",
- ]);
-});
-
-it("deactivates stale Caplets while preserving non-Caplets active tools", () => {
- const service = mockService([
- {
- caplet: "git-hub",
- toolName: "caplets_git_hub",
- title: "GitHub",
- description: "GitHub Caplet",
- promptGuidance: ["Use caplets_git_hub for GitHub."],
- },
- {
- caplet: "linear",
- toolName: "caplets_linear",
- title: "Linear",
- description: "Linear Caplet",
- promptGuidance: ["Use caplets_linear for Linear."],
- },
- ]);
- const { api } = mockPiApi(["read", "bash", "caplets_git_hub", "caplets_linear"]);
-
- capletsPiExtension(api, { service });
- service.setTools([
- {
- caplet: "linear",
- toolName: "caplets_linear",
- title: "Linear",
- description: "Linear Caplet",
- promptGuidance: ["Use caplets_linear for Linear."],
- },
- ]);
- service.emitToolsChanged();
-
- expect(api.setActiveTools).toHaveBeenLastCalledWith(["read", "bash", "caplets_linear"]);
-});
-
-it("works when Pi active-tool APIs are unavailable", () => {
- const service = mockService([]);
- const registered: RegisteredTool[] = [];
-
- capletsPiExtension(
- { registerTool: (definition) => registered.push(definition as RegisteredTool) },
- { service },
- );
-
- service.setTools([
- {
- caplet: "linear",
- toolName: "caplets_linear",
- title: "Linear",
- description: "Linear Caplet",
- promptGuidance: ["Use caplets_linear for Linear."],
- },
- ]);
- service.emitToolsChanged();
-
- expect(registered.map((tool) => tool.name)).toEqual(["caplets_linear"]);
-});
-```
-
-- [ ] **Step 3: Run Pi tests to verify they fail**
-
-Run: `pnpm --filter @caplets/pi test`
-
-Expected: FAIL because the adapter does not subscribe to `onToolsChanged` and does not call active-tool APIs.
-
-- [ ] **Step 4: Extend Pi adapter API type**
-
-In `packages/pi/src/index.ts`, change `PiExtensionApi` to:
-
-```ts
-export type PiExtensionApi = {
- registerTool(definition: unknown): void;
- getActiveTools?(): Array<{ name: string }>;
- setActiveTools?(names: string[]): void;
-};
-```
-
-- [ ] **Step 5: Add Pi sync helpers**
-
-In `packages/pi/src/index.ts`, replace the loop in `capletsPiExtension` with a `syncTools` helper. Use this exact structure so active non-Caplets tools are preserved:
-
-```ts
-export default function capletsPiExtension(pi: PiExtensionApi, options: CapletsPiOptions = {}) {
- const service = options.service ?? createNativeCapletsService();
- if (!options.service) {
- registerNativeCapletsProcessCleanup(service);
- }
-
- const registeredCapletTools = new Set();
- let knownCapletTools = new Set();
-
- const syncTools = (caplets = service.listTools()) => {
- const nextCapletTools = new Set(caplets.map((caplet) => caplet.toolName));
- for (const caplet of caplets) {
- if (registeredCapletTools.has(caplet.toolName)) {
- continue;
- }
- registeredCapletTools.add(caplet.toolName);
- pi.registerTool(createPiTool(service, caplet));
- }
-
- if (pi.getActiveTools && pi.setActiveTools) {
- const activeNonCaplets = pi
- .getActiveTools()
- .map((tool) => tool.name)
- .filter((name) => !knownCapletTools.has(name));
- pi.setActiveTools([...activeNonCaplets, ...nextCapletTools]);
- }
-
- knownCapletTools = nextCapletTools;
- };
-
- syncTools();
- service.onToolsChanged(syncTools);
-}
-```
-
-Add this helper below `capletsPiExtension`:
-
-```ts
-function createPiTool(service: NativeCapletsService, caplet: NativeCapletTool): unknown {
- return {
- name: caplet.toolName,
- label: caplet.title,
- description: caplet.description,
- promptSnippet: `Use ${caplet.toolName} for the ${caplet.title} Caplet capability domain.`,
- promptGuidelines: caplet.promptGuidance,
- parameters: capletsPiParameters(),
- async execute(_toolCallId: string, params: unknown) {
- const result = await service.execute(caplet.caplet, params);
- const serialized = serializeResult(result);
- return {
- content: [{ type: "text", text: serialized.text }],
- details: serialized.serializationError
- ? { result, serializationError: serialized.serializationError }
- : { result },
- };
- },
- };
-}
-```
-
-Update the import from `@caplets/core/native` to include `type NativeCapletTool`.
-
-- [ ] **Step 6: Run Pi tests**
-
-Run: `pnpm --filter @caplets/pi test`
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit**
-
-```sh
-git add packages/pi/src/index.ts packages/pi/test/pi.test.ts
-git commit -m "feat(pi): sync native Caplet tools on reload"
-```
-
----
-
-### Task 5: Refresh OpenCode Guidance For Existing Tools
-
-**Files:**
-
-- Modify: `packages/opencode/src/index.ts`
-- Test: `packages/opencode/test/opencode.test.ts`
-
-- [ ] **Step 1: Add failing OpenCode guidance test**
-
-Append this test to `packages/opencode/test/opencode.test.ts`:
-
-```ts
-it("refreshes system guidance from the current native tool list", async () => {
- const { createCapletsOpenCodeHooks } = await import("../src/index");
- let tools = [
- {
- caplet: "git-hub",
- toolName: "caplets_git_hub",
- title: "GitHub",
- description: "GitHub\n\nUse this Caplet.",
- promptGuidance: ["Use caplets_git_hub for GitHub."],
- },
- ];
- const service = {
- listTools: () => tools,
- execute: vi.fn(async () => ({ ok: true })),
- reload: vi.fn(async () => true),
- onToolsChanged: vi.fn(() => () => {}),
- close: vi.fn(async () => {}),
- };
-
- const hooks = await createCapletsOpenCodeHooks(service);
- tools = [
- {
- caplet: "linear",
- toolName: "caplets_linear",
- title: "Linear",
- description: "Linear\n\nUse this Caplet.",
- promptGuidance: ["Use caplets_linear for Linear."],
- },
- ];
-
- const output = { system: [] as string[] };
- await hooks["experimental.chat.system.transform"]?.({} as never, output);
-
- expect(output.system.join("\n")).toContain("caplets_linear");
- expect(output.system.join("\n")).not.toContain("caplets_git_hub");
-});
-```
-
-- [ ] **Step 2: Run OpenCode tests to verify failure**
-
-Run: `pnpm --filter @caplets/opencode test`
-
-Expected: FAIL because `toolNames` are snapshotted before the hook is returned.
-
-- [ ] **Step 3: Compute guidance at transform time**
-
-In `packages/opencode/src/index.ts`, keep `capletTools` for static `Hooks.tool`, but remove the top-level `toolNames` constant. Change the transform hook to:
-
-```ts
- "experimental.chat.system.transform": async (_input, output) => {
- output.system.push(
- nativeCapletsSystemGuidance(service.listTools().map((caplet) => caplet.toolName)),
- );
- },
-```
-
-Do not dynamically add/remove OpenCode plugin tools in this task; the current OpenCode API does not support runtime plugin-tool inventory mutation.
-
-- [ ] **Step 4: Run OpenCode tests**
-
-Run: `pnpm --filter @caplets/opencode test`
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit**
-
-```sh
-git add packages/opencode/src/index.ts packages/opencode/test/opencode.test.ts
-git commit -m "feat(opencode): refresh native guidance from live service"
-```
-
----
-
-### Task 6: Document Host-Specific Hot Reload Behavior
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `packages/pi/README.md`
-- Modify: `packages/opencode/README.md`
-- Modify: `.changeset/native-agent-integrations.md`
-
-- [ ] **Step 1: Update root README native section**
-
-In `README.md`, replace lines 718-719 text with:
-
-```md
-Native integrations hot reload config and Caplet file edits through the same runtime used by
-`caplets serve`. Existing native tools execute against the latest valid config without host
-restart. Pi also refreshes newly added Caplet tools at runtime and deactivates removed Caplet
-tools when Pi's active-tool APIs are available. OpenCode's current plugin API snapshots the
-tool inventory at plugin load, so adding, removing, or renaming OpenCode native tools still
-requires restarting OpenCode; already-registered tools and injected guidance use live Caplets
-state.
-```
-
-- [ ] **Step 2: Update Pi README**
-
-In `packages/pi/README.md`, replace the final paragraph with:
-
-```md
-The extension hot reloads Caplets config and Caplet file edits. Existing tools execute against
-the latest valid backend config. Newly added Caplets are registered in the current Pi session;
-removed or disabled Caplets are deactivated with Pi's active-tool APIs when available. If Pi is
-running without `getActiveTools()` / `setActiveTools()`, stale tools may remain registered until
-Pi reloads extensions or restarts, but calls to removed Caplets return Caplets' normal structured
-"server not found" error.
-```
-
-- [ ] **Step 3: Update OpenCode README**
-
-In `packages/opencode/README.md`, replace the final paragraph with:
-
-```md
-The plugin hot reloads Caplets config and Caplet file edits for already-registered tools, so
-existing native tools execute against the latest valid backend config and prompt guidance is
-rebuilt from current Caplets state. OpenCode's current plugin API snapshots `Hooks.tool` at
-plugin load, so adding, removing, or renaming native tools still requires restarting OpenCode.
-```
-
-- [ ] **Step 4: Update existing changeset**
-
-Append this sentence to `.changeset/native-agent-integrations.md`:
-
-```md
-Native integrations now share the hot-reload runtime so existing native tools execute against
-the latest valid Caplets config; Pi can register newly added Caplet tools and deactivate stale
-ones at runtime when its active-tool APIs are available.
-```
-
-- [ ] **Step 5: Run docs formatting check**
-
-Run: `pnpm format:check`
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit**
-
-```sh
-git add README.md packages/pi/README.md packages/opencode/README.md .changeset/native-agent-integrations.md
-git commit -m "docs: describe native hot reload behavior"
-```
-
----
-
-### Task 7: Full Verification And Cleanup
-
-**Files:**
-
-- Verify all changed files.
-
-- [ ] **Step 1: Run package-focused tests**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/engine.test.ts test/runtime.test.ts test/native.test.ts
-pnpm --filter @caplets/pi test
-pnpm --filter @caplets/opencode test
-```
-
-Expected: all commands PASS.
-
-- [ ] **Step 2: Run typecheck**
-
-Run: `pnpm typecheck`
-
-Expected: PASS.
-
-- [ ] **Step 3: Run lint and format checks**
-
-Run:
-
-```sh
-pnpm format:check
-pnpm lint
-```
-
-Expected: both PASS.
-
-- [ ] **Step 4: Run full verification gate**
-
-Run: `pnpm verify`
-
-Expected: PASS, including `format:check`, `lint`, `typecheck`, `schema:check`, `test`, `benchmark:check`, and `build`.
-
-- [ ] **Step 5: Inspect final diff**
-
-Run:
-
-```sh
-git status --short
-git diff --stat HEAD
-git diff HEAD -- packages/core/src/engine.ts packages/core/src/runtime.ts packages/core/src/native/service.ts packages/pi/src/index.ts packages/opencode/src/index.ts
-```
-
-Expected: only planned files changed; no generated schema or benchmark docs changed unless `pnpm verify` explicitly updated them.
-
-- [ ] **Step 6: Final commit if previous task commits were skipped**
-
-If implementing as one commit instead of per-task commits, run:
-
-```sh
-git add packages/core/src/engine.ts packages/core/src/runtime.ts packages/core/src/native.ts packages/core/src/native/service.ts packages/core/test/engine.test.ts packages/core/test/runtime.test.ts packages/core/test/native.test.ts packages/pi/src/index.ts packages/pi/test/pi.test.ts packages/opencode/src/index.ts packages/opencode/test/opencode.test.ts README.md packages/pi/README.md packages/opencode/README.md .changeset/native-agent-integrations.md
-git commit -m "feat: hot reload native Caplets integrations"
-```
-
-Expected: commit succeeds without pre-commit failures.
-
----
-
-## Residual Risks
-
-- Pi `setActiveTools()` replaces the entire active tool list. The plan preserves all currently active non-Caplets tools and swaps only the Caplets-owned subset, but it cannot reactivate a non-Caplets tool that another extension intentionally deactivated before Caplets observes the active set.
-- Pi may treat duplicate `registerTool()` calls as collisions rather than updates. The plan avoids re-registering known tool names. If metadata changes for an existing Pi tool and Pi has no update API, the execution backend still updates, but the displayed label/description may remain as originally registered until Pi reloads extensions.
-- OpenCode cannot add/remove plugin tools at runtime with the current API. This plan documents that limitation and keeps the implementation ready to expand if OpenCode adds a runtime plugin-tool registry.
-- `fs.watch` behavior varies by platform. This plan preserves the existing `caplets serve` watcher strategy rather than changing watcher primitives.
-
-## Completion Criteria
-
-- `CapletsEngine` owns one copy of reload/watch/backend lifecycle behavior.
-- `CapletsRuntime` still passes all existing MCP hot-reload tests through the shared engine.
-- `createNativeCapletsService()` supports `reload()` and `onToolsChanged()` and hot reloads config state.
-- Pi registers newly added Caplets after native reload and deactivates stale Caplet tools without disabling active non-Caplets tools.
-- OpenCode existing native tools execute against live service state and system guidance is rebuilt from current service tools.
-- README/package docs explain exact host-specific behavior.
-- `pnpm verify` passes.
diff --git a/docs/plans/2026-05-19-caplets-interface-ux.md b/docs/plans/2026-05-19-caplets-interface-ux.md
deleted file mode 100644
index 1a8924e1..00000000
--- a/docs/plans/2026-05-19-caplets-interface-ux.md
+++ /dev/null
@@ -1,297 +0,0 @@
-# Caplets Interface UX Improvements Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Improve the Caplets agent interface with only low-risk, feasible changes that preserve the current wrapper protocol.
-
-**Architecture:** Keep `get_caplet`/`list_tools`/`get_tool`/`call_tool` unchanged. Add clearer Caplet identity in wrapper text and metadata, extract local artifact hints from downstream browser output, add schema fingerprints to compact tool metadata, and polish Pi rendering so large Caplet results stay readable.
-
-**Tech Stack:** TypeScript, Zod, MCP `CallToolResult`, Vitest, native Caplets service, Pi native-tool integration.
-
----
-
-## Why This Plan Exists
-
-After using both the Browser and Stealth Browser Caplets against `http://localhost:4199`, the interface worked, but these practical pain points stood out:
-
-- Browser and Stealth Browser expose identical downstream tool names, so results should identify the active Caplet more clearly.
-- Screenshot responses report relative paths such as `./browser-caplet-localhost-4199.png` without enough artifact-context metadata.
-- Two Caplets can expose identical downstream schemas, but agents have no lightweight way to recognize that known schemas match.
-- Current discovery results use generic text like `Result available in structuredContent.result.`.
-- The Pi wrapper serializes the whole result JSON in expanded mode, which is noisy for large snapshots and screenshots.
-- The generated prompt guidance is correct but over-cautious; it nudges agents to rediscover familiar tools every time.
-
----
-
-## Explicitly Out Of Scope
-
-These ideas are feasible in theory but are not included because they add protocol complexity or compatibility risk:
-
-- Fast-path `call_tool` requests without `operation`.
-- A batched browser `run_flow` operation.
-- Wrapping direct `call_tool` results in a new `structuredContent.result` envelope.
-- Guaranteed absolute screenshot paths when the downstream MCP browser server only reports relative paths.
-- Flattening downstream browser tools into separate native tools.
-
----
-
-## Decisions Locked
-
-- Keep existing operation names, request shapes, and behavior backward compatible.
-- Do not require agents to change existing Caplets calls.
-- Preserve direct `call_tool` pass-through shape: keep downstream `content`, `structuredContent`, `isError`, and `_meta` intact.
-- Add Caplets metadata to direct `call_tool` results only under `_meta.caplets`.
-- Add richer metadata to wrapper-generated discovery results under `structuredContent.caplets` while keeping `structuredContent.result` unchanged.
-- Expose artifact paths as hints with explicit path-resolution status when absolute paths cannot be guaranteed.
-- Treat schema fingerprints as hints for reuse, not as a replacement for `get_tool` when semantics are unclear.
-
----
-
-## Files To Modify
-
-- `packages/core/src/tools.ts`: annotate wrapper results, add artifact extraction, and improve wrapper text.
-- `packages/core/src/downstream.ts`: add schema fingerprints to MCP compact metadata.
-- `packages/core/src/openapi.ts`: add schema fingerprints to OpenAPI compact metadata.
-- `packages/core/src/http-actions.ts`: add schema fingerprints to HTTP compact metadata.
-- `packages/core/src/cli-tools.ts`: add schema fingerprints to CLI compact metadata.
-- `packages/core/src/caplet-sets.ts`: add schema fingerprints to Caplet-set compact metadata.
-- `packages/core/src/graphql.ts`: add schema fingerprints to GraphQL compact metadata.
-- `packages/core/src/native/tools.ts`: update native system guidance and prompt guidance.
-- `packages/pi/src/index.ts`: render concise Caplet summaries and artifact hints.
-- `packages/core/test/tools.test.ts`: wrapper text, metadata, and artifact tests.
-- Backend tests under `packages/core/test/`: compact metadata fingerprint tests.
-- `packages/core/test/native.test.ts`: guidance tests.
-- `packages/pi/test/pi.test.ts`: renderer tests.
-- `README.md`: document the metadata, artifact, and schema-fingerprint improvements.
-
----
-
-## Public Result Shape Additions
-
-### Discovery operation metadata
-
-Discovery operations keep the existing `structuredContent.result` shape and add Caplets metadata beside it:
-
-```json
-{
- "content": [
- {
- "type": "text",
- "text": "Browser list_tools result available in structuredContent.result."
- }
- ],
- "structuredContent": {
- "caplets": {
- "id": "browser",
- "name": "Browser",
- "backend": "mcp",
- "operation": "list_tools",
- "status": "ok"
- },
- "result": {
- "id": "browser",
- "tools": []
- }
- }
-}
-```
-
-### Direct `call_tool` metadata
-
-Direct downstream results are not enveloped. Caplets metadata is attached under `_meta.caplets`:
-
-```json
-{
- "content": [{ "type": "text", "text": "...downstream output..." }],
- "structuredContent": { "ok": true },
- "_meta": {
- "caplets": {
- "id": "browser",
- "name": "Browser",
- "backend": "mcp",
- "operation": "call_tool",
- "tool": "browser_take_screenshot",
- "status": "ok",
- "artifacts": [
- {
- "kind": "screenshot",
- "displayPath": "./browser-caplet-localhost-4199.png",
- "pathResolution": "relative-to-mcp-server"
- }
- ]
- }
- }
-}
-```
-
-### Compact schema fingerprints
-
-Compact tool metadata gains stable schema hashes:
-
-```json
-{
- "id": "browser",
- "tool": "browser_navigate",
- "description": "Navigate to a URL",
- "hasInputSchema": true,
- "hasOutputSchema": false,
- "inputSchemaHash": "sha256:...",
- "outputSchemaHash": null
-}
-```
-
----
-
-## Task 1: Add Shared Caplet Metadata Helpers
-
-**Files:**
-
-- Modify: `packages/core/src/tools.ts`
-- Test: `packages/core/test/tools.test.ts`
-
-- [ ] Add a `CapletResultMetadata` type with `caplet`, `name`, `backend`, `operation`, optional `tool`, `status`, optional `elapsedMs`, and optional `artifacts`.
-- [ ] Add `metadataFor(server, operation, tool, startedAt)` to build metadata consistently.
-- [ ] Add `jsonResult(value, metadata)` support while preserving the existing `structuredContent.result` field.
-- [ ] Add `annotateCallToolResult(result, metadata)` that preserves downstream `content`, `structuredContent`, `isError`, and existing `_meta` fields.
-- [ ] Ensure `annotateCallToolResult()` merges with an existing `_meta` object instead of replacing it.
-- [ ] Add tests proving `get_caplet`, `list_tools`, and `get_tool` include `structuredContent.caplets` and keep `structuredContent.result` unchanged.
-- [ ] Add tests proving direct `call_tool` results keep their original shape and receive `_meta.caplets`.
-- [ ] Add tests proving downstream `_meta` values are preserved.
-- [ ] Add tests proving `isError: true` downstream results are annotated but otherwise unchanged.
-- [ ] Run `pnpm --filter @caplets/core test -- test/tools.test.ts`.
-
----
-
-## Task 2: Improve Human-Readable Result Headers
-
-**Files:**
-
-- Modify: `packages/core/src/tools.ts`
-- Modify: `packages/pi/src/index.ts`
-- Test: `packages/core/test/tools.test.ts`
-- Test: `packages/pi/test/pi.test.ts`
-
-- [ ] Change discovery-operation text from `Result available in structuredContent.result.` to a Caplet-specific sentence such as `Browser list_tools result available in structuredContent.result.`.
-- [ ] Keep discovery text short and do not inline large structured payloads.
-- [ ] Update Pi `renderCall()` if needed so operation and downstream tool names remain visible for identical tool names across Caplets.
-- [ ] Update Pi collapsed `renderResult()` to prefer `_meta.caplets` or `structuredContent.caplets` and render `✓ Browser call_tool browser_click complete` when metadata exists.
-- [ ] Update Pi expanded `renderResult()` to show a concise metadata header before output.
-- [ ] Add tests for Browser and Stealth Browser style names proving identical downstream tool names are disambiguated by Caplet title.
-- [ ] Run `pnpm --filter @caplets/core test -- test/tools.test.ts` and `pnpm --filter @caplets/pi test`.
-
----
-
-## Task 3: Add Artifact Metadata Extraction
-
-**Files:**
-
-- Modify: `packages/core/src/tools.ts`
-- Test: `packages/core/test/tools.test.ts`
-
-- [ ] Add an artifact extractor that scans downstream text content for local Markdown links emitted by MCP browser tools, for example `[Screenshot of viewport](./file.png)`.
-- [ ] Ignore `http:`, `https:`, `mailto:`, and fragment-only links.
-- [ ] Classify common artifacts by filename and surrounding text: `screenshot`, `snapshot`, `console-log`, `network-log`, and fallback `file`.
-- [ ] Return artifact objects with `kind`, `displayPath`, and `pathResolution`.
-- [ ] Use `pathResolution: "relative-to-mcp-server"` for relative paths unless core can reliably resolve the downstream output directory.
-- [ ] Use `pathResolution: "absolute"` only for absolute local paths that already appear in downstream output.
-- [ ] Attach extracted artifacts to `_meta.caplets.artifacts` on direct `call_tool` results.
-- [ ] Add tests for screenshot links, snapshot links, console-log links, multiple artifacts, no artifacts, and external links.
-- [ ] Run `pnpm --filter @caplets/core test -- test/tools.test.ts`.
-
----
-
-## Task 4: Add Schema Fingerprints To Compact Tool Metadata
-
-**Files:**
-
-- Modify: `packages/core/src/downstream.ts`
-- Modify: `packages/core/src/openapi.ts`
-- Modify: `packages/core/src/http-actions.ts`
-- Modify: `packages/core/src/cli-tools.ts`
-- Modify: `packages/core/src/caplet-sets.ts`
-- Modify: `packages/core/src/graphql.ts`
-- Test: backend-specific test files under `packages/core/test/`
-
-- [ ] Add a small stable JSON hashing utility for schema-like values using deterministic key ordering and SHA-256.
-- [ ] Extend every backend compact tool shape with `inputSchemaHash` and `outputSchemaHash`.
-- [ ] Return `null` for a missing schema so compact output has a stable shape.
-- [ ] Keep full schemas available only through `get_tool`.
-- [ ] Add tests proving two identical schemas produce identical hashes.
-- [ ] Add tests proving object key order differences do not change hashes.
-- [ ] Update existing compact metadata test expectations for all backend managers touched.
-- [ ] Run focused backend tests, then `pnpm typecheck`.
-
----
-
-## Task 5: Update Native Agent Guidance
-
-**Files:**
-
-- Modify: `packages/core/src/native/tools.ts`
-- Test: `packages/core/test/native.test.ts`
-
-- [ ] Update `nativeCapletsSystemGuidance()` so the recommended flow says `get_caplet` and `get_tool` are for unfamiliar or schema-unclear tools, not mandatory for every repeat use.
-- [ ] Add guidance that schema hashes from `list_tools` can identify matching schemas across Caplets when the exact hash is already understood.
-- [ ] Keep the warning that downstream inputs must stay inside `arguments`.
-- [ ] Keep the warning that agents must not invent downstream tool names.
-- [ ] Update `nativeCapletPromptGuidance()` to be less repetitive while preserving the safe discovery path.
-- [ ] Add tests for revised guidance text.
-- [ ] Run `pnpm --filter @caplets/core test -- test/native.test.ts`.
-
----
-
-## Task 6: Polish Pi Rendering For Large Caplet Results
-
-**Files:**
-
-- Modify: `packages/pi/src/index.ts`
-- Test: `packages/pi/test/pi.test.ts`
-
-- [ ] Add a helper that extracts Caplets metadata from either `details.result._meta.caplets` or `details.result.structuredContent.caplets`.
-- [ ] Update collapsed rendering to use metadata when present and keep the output one line.
-- [ ] Update expanded rendering to show metadata, artifact lines, and then a concise output preview.
-- [ ] Keep the full raw result available in `details.result` for inspection.
-- [ ] Avoid dumping huge snapshot text before the useful summary.
-- [ ] Add tests where result content contains a large snapshot to ensure collapsed output remains concise.
-- [ ] Add tests where screenshot metadata renders a clear artifact line.
-- [ ] Run `pnpm --filter @caplets/pi test`.
-
----
-
-## Task 7: Documentation
-
-**Files:**
-
-- Modify: `README.md`
-
-- [ ] Document that discovery results now include `structuredContent.caplets` metadata.
-- [ ] Document that direct `call_tool` results keep their downstream shape and include `_meta.caplets` when possible.
-- [ ] Document artifact metadata and path-resolution caveats.
-- [ ] Document schema hashes as reuse hints, not as a replacement for `get_tool` when semantics are unclear.
-- [ ] Document that the explicit progressive-discovery flow remains the safest first-use path.
-- [ ] Run `pnpm format:check`.
-
----
-
-## Task 8: Full Verification
-
-**Files:**
-
-- No direct source changes unless failures reveal missing updates.
-
-- [ ] Run `pnpm format:check`.
-- [ ] Run `pnpm lint`.
-- [ ] Run `pnpm typecheck`.
-- [ ] Run `pnpm test`.
-- [ ] Run `pnpm build`.
-- [ ] If schema generation changed checked files, run `pnpm schema:generate` and then `pnpm schema:check`.
-- [ ] If benchmark docs are stale, run `pnpm benchmark` and then `pnpm benchmark:check`.
-- [ ] Run `pnpm verify` before merging.
-
----
-
-## Rollout Notes
-
-- If adding `_meta.caplets` to direct `call_tool` results causes compatibility issues, keep metadata only for wrapper-generated operations and Pi rendering can fall back to call arguments.
-- If artifact extraction produces false positives, restrict it to MCP Caplets whose downstream tool names start with `browser_`.
-- If schema hashing changes compact output too much for snapshots/tests, ship hashes behind one shared compact helper and update all backend expectations together.
diff --git a/docs/plans/2026-05-19-http-mcp-serving.md b/docs/plans/2026-05-19-http-mcp-serving.md
deleted file mode 100644
index aec6c292..00000000
--- a/docs/plans/2026-05-19-http-mcp-serving.md
+++ /dev/null
@@ -1,1648 +0,0 @@
-# HTTP MCP Serving Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add opt-in Hono-based Streamable HTTP MCP serving to `caplets serve` with optional Basic Auth, while making no-arg `caplets` show help.
-
-**Architecture:** Refactor MCP tool registration/reload reconciliation into a reusable `CapletsMcpSession` that shares one `CapletsEngine` across stdio and HTTP sessions. Stdio creates one session and connects it to `StdioServerTransport`; HTTP creates a Hono app, routes each `Mcp-Session-Id` to a per-session `@hono/mcp` `StreamableHTTPTransport`, and keeps all sessions backed by the same engine.
-
-**Tech Stack:** TypeScript, Commander, Vitest, MCP SDK, Hono, `@hono/node-server`, `@hono/mcp`, pnpm.
-
----
-
-## File structure
-
-- Modify `packages/core/package.json` to add direct Hono dependencies.
-- Create `packages/core/src/serve/session.ts` for reusable MCP server/session registration and reload reconciliation.
-- Modify `packages/core/src/runtime.ts` so `CapletsRuntime` delegates to `CapletsMcpSession` and preserves its public API.
-- Create `packages/core/src/serve/options.ts` for serve option normalization, validation, auth resolution, path normalization, and loopback detection.
-- Create `packages/core/src/serve/stdio.ts` for stdio serve orchestration.
-- Create `packages/core/src/serve/http.ts` for Hono routes, Basic Auth middleware, MCP session map, DNS rebinding transport options, startup logs, and shutdown cleanup.
-- Create `packages/core/src/serve/index.ts` to expose `serveCaplets`, `serveStdio`, `serveHttp`, and option types.
-- Modify `packages/core/src/cli.ts` to add `serve`, no-arg help, and a test-injectable serve runner.
-- Modify `packages/cli/src/index.ts` to always delegate to `runCli`.
-- Add `packages/core/test/serve-options.test.ts` for option validation and auth resolution.
-- Add `packages/core/test/serve-session.test.ts` for shared session tool reconciliation.
-- Add `packages/core/test/serve-http.test.ts` for Hono route/auth/session behavior.
-- Modify `packages/core/test/cli.test.ts` for no-arg help and `serve` command parsing through injected runner.
-- Keep `packages/core/test/runtime.test.ts` passing as compatibility coverage.
-
----
-
-### Task 1: Add Hono dependencies
-
-**Files:**
-
-- Modify: `packages/core/package.json`
-- Modify: `pnpm-lock.yaml`
-
-- [ ] **Step 1: Add dependencies with pnpm**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core add @hono/mcp@^0.3.0 @hono/node-server@^1.19.9 hono@^4.11.5
-```
-
-Expected: `packages/core/package.json` contains the three new dependencies and `pnpm-lock.yaml` is updated.
-
-- [ ] **Step 2: Verify package manifest**
-
-Check `packages/core/package.json` contains this dependency block shape:
-
-```json
-{
- "dependencies": {
- "@apidevtools/swagger-parser": "^12.1.0",
- "@hono/mcp": "^0.3.0",
- "@hono/node-server": "^1.19.9",
- "@modelcontextprotocol/sdk": "^1.29.0",
- "commander": "^14.0.3",
- "graphql": "^16.14.0",
- "hono": "^4.11.5",
- "vfile": "^6.0.3",
- "vfile-matter": "^5.0.1",
- "zod": "^4.4.3"
- }
-}
-```
-
-The exact order may be adjusted by the package manager, but all three Hono dependencies must be direct dependencies.
-
-- [ ] **Step 3: Commit dependency change**
-
-Run:
-
-```bash
-git add packages/core/package.json pnpm-lock.yaml
-git commit -m "chore(core): add Hono MCP dependencies"
-```
-
----
-
-### Task 2: Extract reusable MCP session wrapper
-
-**Files:**
-
-- Create: `packages/core/src/serve/session.ts`
-- Modify: `packages/core/src/runtime.ts`
-- Test: `packages/core/test/serve-session.test.ts`
-- Existing test: `packages/core/test/runtime.test.ts`
-
-- [ ] **Step 1: Write failing session tests**
-
-Create `packages/core/test/serve-session.test.ts`:
-
-```ts
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import type { RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { CapletsEngine } from "../src/engine";
-import { CapletsMcpSession } from "../src/serve/session";
-
-const dirs: string[] = [];
-
-afterEach(() => {
- for (const dir of dirs.splice(0)) {
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-describe("CapletsMcpSession", () => {
- it("registers enabled Caplets from a shared engine", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: { name: "Alpha", description: "Search alpha.", command: "node" },
- beta: { name: "Beta", description: "Search beta.", command: "node", disabled: true },
- },
- });
- dirs.push(dir);
- const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false });
- const server = mockServer();
- const session = new CapletsMcpSession(engine, { server });
-
- expect(session.registeredToolIds()).toEqual(["alpha"]);
- expect(server.registerTool).toHaveBeenCalledTimes(1);
-
- await session.close();
- await engine.close();
- });
-
- it("reconciles tools when the shared engine reloads", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- alpha: { name: "Alpha", description: "Search alpha.", command: "node" },
- },
- });
- dirs.push(dir);
- const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false });
- const server = mockServer();
- const session = new CapletsMcpSession(engine, { server });
- const alpha = server.registered.get("alpha")!;
-
- writeConfig(configPath, {
- httpApis: {
- gamma: {
- name: "Gamma HTTP",
- description: "Call gamma over HTTP.",
- baseUrl: "http://127.0.0.1:1",
- auth: { type: "none" },
- actions: { search: { method: "GET", path: "/search" } },
- },
- },
- });
- await engine.reload();
-
- expect(alpha.remove).toHaveBeenCalledTimes(1);
- expect(session.registeredToolIds()).toEqual(["gamma"]);
- expect(server.registered.get("gamma")).toBeDefined();
-
- await session.close();
- await engine.close();
- });
-});
-
-function tempConfig(config: unknown): {
- dir: string;
- configPath: string;
- projectConfigPath: string;
-} {
- const dir = mkdtempSync(join(tmpdir(), "caplets-session-"));
- const userRoot = join(dir, "user");
- const projectRoot = join(dir, "project", ".caplets");
- mkdirSync(userRoot, { recursive: true });
- mkdirSync(projectRoot, { recursive: true });
- const configPath = join(userRoot, "config.json");
- const projectConfigPath = join(projectRoot, "config.json");
- writeConfig(configPath, config);
- return { dir, configPath, projectConfigPath };
-}
-
-function writeConfig(path: string, config: unknown): void {
- writeFileSync(path, JSON.stringify(config));
-}
-
-function mockServer() {
- const registered = new Map();
- return {
- registered,
- registerTool: vi.fn((name: string) => {
- const tool = {
- update: vi.fn(),
- remove: vi.fn(() => registered.delete(name)),
- enable: vi.fn(),
- disable: vi.fn(),
- enabled: true,
- handler: vi.fn(),
- } as unknown as RegisteredTool;
- registered.set(name, tool);
- return tool;
- }),
- connect: vi.fn(async () => {}),
- close: vi.fn(async () => {}),
- };
-}
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-session.test.ts
-```
-
-Expected: FAIL because `../src/serve/session` does not exist.
-
-- [ ] **Step 3: Implement `CapletsMcpSession`**
-
-Create `packages/core/src/serve/session.ts`:
-
-```ts
-import { McpServer, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp";
-import type { Transport } from "@modelcontextprotocol/sdk/shared/transport";
-import { version as packageJsonVersion } from "../../../package.json";
-import type { CapletConfig, CapletsConfig } from "../config";
-import { CapletsEngine } from "../engine";
-import { capabilityDescription } from "../registry";
-import { generatedToolInputSchema } from "../tools";
-
-export type ToolServer = Pick;
-
-export type CapletsMcpSessionOptions = {
- server?: ToolServer;
-};
-
-export class CapletsMcpSession {
- readonly server: ToolServer;
- private readonly tools = new Map();
- private readonly unsubscribeReload: () => void;
- private closed = false;
-
- constructor(
- private readonly engine: CapletsEngine,
- options: CapletsMcpSessionOptions = {},
- ) {
- this.server =
- options.server ??
- new McpServer({
- name: "caplets",
- version: packageJsonVersion,
- });
- this.unsubscribeReload = this.engine.onReload(({ previous, next }) =>
- this.reconcileTools(previous, next),
- );
- this.reconcileTools(undefined, this.engine.currentConfig());
- }
-
- async connect(transport: Transport): Promise {
- await this.server.connect(transport);
- }
-
- registeredToolIds(): string[] {
- return [...this.tools.keys()].sort();
- }
-
- async close(): Promise {
- if (this.closed) {
- return;
- }
- this.closed = true;
- this.unsubscribeReload();
- this.tools.clear();
- await this.server.close();
- }
-
- private reconcileTools(previous: CapletsConfig | undefined, next: CapletsConfig): void {
- const enabled = new Map(nextEnabledServers(next).map((server) => [server.server, server]));
-
- for (const [serverId, tool] of this.tools) {
- const caplet = enabled.get(serverId);
- if (!caplet) {
- tool.remove();
- this.tools.delete(serverId);
- continue;
- }
-
- const previousCaplet = previous ? capletById(previous, serverId) : undefined;
- if (!previousCaplet || serializeCaplet(previousCaplet) !== serializeCaplet(caplet)) {
- tool.update({
- title: caplet.name,
- description: capabilityDescription(caplet),
- callback: async (request) => this.handleTool(serverId, request),
- enabled: true,
- });
- }
- }
-
- for (const caplet of enabled.values()) {
- if (this.tools.has(caplet.server)) {
- continue;
- }
- this.tools.set(caplet.server, this.registerCapletTool(caplet));
- }
- }
-
- private registerCapletTool(caplet: CapletConfig): RegisteredTool {
- return this.server.registerTool(
- caplet.server,
- {
- title: caplet.name,
- description: capabilityDescription(caplet),
- inputSchema: generatedToolInputSchema,
- },
- async (request) => this.handleTool(caplet.server, request),
- );
- }
-
- private async handleTool(serverId: string, request: unknown): Promise {
- return await this.engine.execute(serverId, request);
- }
-}
-
-function nextEnabledServers(config: CapletsConfig): CapletConfig[] {
- return [
- ...Object.values(config.mcpServers),
- ...Object.values(config.openapiEndpoints),
- ...Object.values(config.graphqlEndpoints),
- ...Object.values(config.httpApis),
- ...Object.values(config.cliTools),
- ...Object.values(config.capletSets),
- ].filter((server) => !server.disabled);
-}
-
-function capletById(config: CapletsConfig, serverId: string): CapletConfig | undefined {
- return (
- config.mcpServers[serverId] ??
- config.openapiEndpoints[serverId] ??
- config.graphqlEndpoints[serverId] ??
- config.httpApis[serverId] ??
- config.cliTools[serverId] ??
- config.capletSets[serverId]
- );
-}
-
-function serializeCaplet(caplet: CapletConfig | undefined): string {
- return JSON.stringify(caplet ?? null);
-}
-```
-
-- [ ] **Step 4: Refactor `CapletsRuntime` to delegate to the session wrapper**
-
-Replace `packages/core/src/runtime.ts` with:
-
-```ts
-import type { Transport } from "@modelcontextprotocol/sdk/shared/transport";
-import { type CapletsConfig } from "./config";
-import { CapletsEngine, type CapletsEngineOptions } from "./engine";
-import { CapletsMcpSession, type ToolServer } from "./serve/session";
-
-type CapletsRuntimeOptions = {
- configPath?: string;
- projectConfigPath?: string;
- authDir?: string;
- watchDebounceMs?: number;
- server?: ToolServer;
- writeErr?: (value: string) => void;
-};
-
-export class CapletsRuntime {
- readonly server: ToolServer;
- private readonly engine: CapletsEngine;
- private readonly session: CapletsMcpSession;
-
- constructor(options: CapletsRuntimeOptions = {}) {
- this.engine = new CapletsEngine(engineOptions(options));
- this.session = new CapletsMcpSession(this.engine, selectSessionOptions(options));
- this.server = this.session.server;
- }
-
- async connect(transport: Transport): Promise {
- await this.session.connect(transport);
- }
-
- scheduleReload(): void {
- this.engine.scheduleReload();
- }
-
- async reload(): Promise {
- return await this.engine.reload();
- }
-
- async close(): Promise {
- try {
- await this.session.close();
- } finally {
- await this.engine.close();
- }
- }
-
- currentConfig(): CapletsConfig {
- return this.engine.currentConfig();
- }
-
- registeredToolIds(): string[] {
- return this.session.registeredToolIds();
- }
-
- watchedPaths(): string[] {
- return this.engine.watchedPaths();
- }
-}
-
-function selectSessionOptions(options: CapletsRuntimeOptions): { server?: ToolServer } {
- return options.server === undefined ? {} : { server: options.server };
-}
-
-function engineOptions(options: CapletsRuntimeOptions): CapletsEngineOptions {
- const engineOptions: CapletsEngineOptions = {};
- if (options.configPath !== undefined) {
- engineOptions.configPath = options.configPath;
- }
- if (options.projectConfigPath !== undefined) {
- engineOptions.projectConfigPath = options.projectConfigPath;
- }
- if (options.authDir !== undefined) {
- engineOptions.authDir = options.authDir;
- }
- if (options.watchDebounceMs !== undefined) {
- engineOptions.watchDebounceMs = options.watchDebounceMs;
- }
- if (options.writeErr !== undefined) {
- engineOptions.writeErr = options.writeErr;
- }
- return engineOptions;
-}
-```
-
-- [ ] **Step 5: Run session and runtime tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-session.test.ts test/runtime.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit session refactor**
-
-Run:
-
-```bash
-git add packages/core/src/serve/session.ts packages/core/src/runtime.ts packages/core/test/serve-session.test.ts
-git commit -m "refactor(core): share MCP session registration"
-```
-
----
-
-### Task 3: Add serve option normalization
-
-**Files:**
-
-- Create: `packages/core/src/serve/options.ts`
-- Test: `packages/core/test/serve-options.test.ts`
-
-- [ ] **Step 1: Write failing option tests**
-
-Create `packages/core/test/serve-options.test.ts`:
-
-```ts
-import { describe, expect, it } from "vitest";
-import { resolveServeOptions } from "../src/serve/options";
-
-describe("resolveServeOptions", () => {
- it("defaults serve to stdio", () => {
- expect(resolveServeOptions({}, {})).toEqual({ transport: "stdio" });
- });
-
- it("defaults HTTP serving to localhost port 5387 and /mcp", () => {
- expect(resolveServeOptions({ transport: "http" }, {})).toMatchObject({
- transport: "http",
- host: "127.0.0.1",
- port: 5387,
- path: "/mcp",
- auth: { enabled: false, user: "caplets" },
- });
- });
-
- it("normalizes trailing slashes in HTTP path", () => {
- expect(resolveServeOptions({ transport: "http", path: "/custom/" }, {})).toMatchObject({
- transport: "http",
- path: "/custom",
- });
- });
-
- it("resolves Basic Auth from password with default user", () => {
- const testPassword = ["test", "password"].join("-");
-
- expect(resolveServeOptions({ transport: "http", password: testPassword }, {})).toMatchObject({
- transport: "http",
- auth: { enabled: true, user: "caplets", password: testPassword },
- });
- });
-
- it("resolves Basic Auth from env and lets flags win", () => {
- const envPassword = ["test", "env", "password"].join("-");
-
- expect(
- resolveServeOptions(
- { transport: "http", user: "cli-user" },
- { CAPLETS_SERVER_USER: "env-user", CAPLETS_SERVER_PASSWORD: envPassword },
- ),
- ).toMatchObject({
- transport: "http",
- auth: { enabled: true, user: "cli-user", password: envPassword },
- });
- });
-
- it("rejects explicit user without password", () => {
- expect(() => resolveServeOptions({ transport: "http", user: "alice" }, {})).toThrow(
- /requires a password/u,
- );
- });
-
- it("rejects HTTP-only options for stdio", () => {
- expect(() => resolveServeOptions({ transport: "stdio", host: "127.0.0.1" }, {})).toThrow(
- /only valid with --transport http/u,
- );
- });
-
- it("rejects invalid port and path", () => {
- expect(() => resolveServeOptions({ transport: "http", port: "0" }, {})).toThrow(
- /valid TCP port/u,
- );
- expect(() => resolveServeOptions({ transport: "http", path: "mcp" }, {})).toThrow(
- /must start with/u,
- );
- expect(() => resolveServeOptions({ transport: "http", path: "/mcp?x=1" }, {})).toThrow(
- /query string/u,
- );
- });
-});
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-options.test.ts
-```
-
-Expected: FAIL because `../src/serve/options` does not exist.
-
-- [ ] **Step 3: Implement `serve/options.ts`**
-
-Create `packages/core/src/serve/options.ts`:
-
-```ts
-import { CapletsError } from "../errors";
-
-export type ServeTransport = "stdio" | "http";
-
-export type RawServeOptions = {
- transport?: string;
- host?: string;
- port?: string | number;
- path?: string;
- user?: string;
- password?: string;
-};
-
-export type StdioServeOptions = {
- transport: "stdio";
-};
-
-export type HttpServeOptions = {
- transport: "http";
- host: string;
- port: number;
- path: string;
- auth: HttpBasicAuthOptions;
- warnUnauthenticatedNetwork: boolean;
- loopback: boolean;
-};
-
-export type HttpBasicAuthOptions =
- | { enabled: false; user: string }
- | { enabled: true; user: string; password: string };
-
-export type ServeOptions = StdioServeOptions | HttpServeOptions;
-
-export type ServeEnv = Partial>;
-
-const HTTP_ONLY_OPTIONS = ["host", "port", "path", "user", "password"] as const;
-
-export function resolveServeOptions(
- raw: RawServeOptions,
- env: ServeEnv = process.env,
-): ServeOptions {
- const transport = parseTransport(raw.transport ?? "stdio");
- if (transport === "stdio") {
- const invalid = HTTP_ONLY_OPTIONS.filter((key) => raw[key] !== undefined);
- if (invalid.length > 0) {
- throw new CapletsError(
- "REQUEST_INVALID",
- `${invalid.map((key) => `--${key}`).join(", ")} ${invalid.length === 1 ? "is" : "are"} only valid with --transport http`,
- );
- }
- return { transport };
- }
-
- const host = nonEmpty(raw.host, "--host") ?? "127.0.0.1";
- const port = parsePort(raw.port ?? 5387);
- const path = normalizeHttpPath(raw.path ?? "/mcp");
- const userWasExplicit = raw.user !== undefined || hasEnv(env.CAPLETS_SERVER_USER);
- const user =
- nonEmpty(raw.user, "--user") ??
- nonEmpty(env.CAPLETS_SERVER_USER, "CAPLETS_SERVER_USER") ??
- "caplets";
- const password =
- nonEmpty(raw.password, "--password") ??
- nonEmpty(env.CAPLETS_SERVER_PASSWORD, "CAPLETS_SERVER_PASSWORD");
-
- if (userWasExplicit && password === undefined) {
- throw new CapletsError(
- "REQUEST_INVALID",
- "HTTP Basic Auth requires a password; pass --password or set CAPLETS_SERVER_PASSWORD.",
- );
- }
-
- const loopback = isLoopbackHost(host);
- return {
- transport,
- host,
- port,
- path,
- auth: password === undefined ? { enabled: false, user } : { enabled: true, user, password },
- warnUnauthenticatedNetwork: !loopback && password === undefined,
- loopback,
- };
-}
-
-export function isLoopbackHost(host: string): boolean {
- const normalized = host.toLocaleLowerCase();
- return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
-}
-
-function parseTransport(value: string): ServeTransport {
- if (value === "stdio" || value === "http") {
- return value;
- }
- throw new CapletsError(
- "REQUEST_INVALID",
- `Expected --transport to be stdio or http, got ${value}`,
- );
-}
-
-function parsePort(value: string | number): number {
- const parsed = typeof value === "number" ? value : Number(value);
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65_535) {
- throw new CapletsError(
- "REQUEST_INVALID",
- `Expected --port to be a valid TCP port, got ${value}`,
- );
- }
- return parsed;
-}
-
-function normalizeHttpPath(value: string): string {
- if (!value.startsWith("/")) {
- throw new CapletsError("REQUEST_INVALID", "HTTP --path must start with /");
- }
- if (value.includes("?") || value.includes("#")) {
- throw new CapletsError(
- "REQUEST_INVALID",
- "HTTP --path must not include a query string or fragment",
- );
- }
- return value === "/" ? value : value.replace(/\/+$/u, "");
-}
-
-function nonEmpty(value: string | undefined, label: string): string | undefined {
- if (value === undefined) {
- return undefined;
- }
- const trimmed = value.trim();
- if (!trimmed) {
- throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`);
- }
- return trimmed;
-}
-
-function hasEnv(value: string | undefined): boolean {
- return value !== undefined && value.trim() !== "";
-}
-```
-
-- [ ] **Step 4: Run option tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-options.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit option normalization**
-
-Run:
-
-```bash
-git add packages/core/src/serve/options.ts packages/core/test/serve-options.test.ts
-git commit -m "feat(core): validate serve transport options"
-```
-
----
-
-### Task 4: Add stdio serve helper
-
-**Files:**
-
-- Create: `packages/core/src/serve/stdio.ts`
-- Create: `packages/core/src/serve/index.ts`
-- Test indirectly through later CLI tests
-
-- [ ] **Step 1: Implement stdio helper**
-
-Create `packages/core/src/serve/stdio.ts`:
-
-```ts
-import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio";
-import { CapletsEngine, type CapletsEngineOptions } from "../engine";
-import { CapletsMcpSession } from "./session";
-
-export type ServeStdioOptions = CapletsEngineOptions & {
- signalHandling?: boolean;
-};
-
-export async function serveStdio(options: ServeStdioOptions = {}): Promise {
- const engine = new CapletsEngine(options);
- const session = new CapletsMcpSession(engine);
- let closing = false;
-
- const close = async () => {
- if (closing) {
- return;
- }
- closing = true;
- try {
- await session.close();
- } finally {
- await engine.close();
- }
- };
-
- if (options.signalHandling !== false) {
- process.once("SIGINT", () => void close().finally(() => process.exit(130)));
- process.once("SIGTERM", () => void close().finally(() => process.exit(143)));
- }
-
- await session.connect(new StdioServerTransport());
-}
-```
-
-Create `packages/core/src/serve/index.ts`:
-
-```ts
-import type { CapletsEngineOptions } from "../engine";
-import { resolveServeOptions, type RawServeOptions, type ServeOptions } from "./options";
-import { serveHttp } from "./http";
-import { serveStdio } from "./stdio";
-
-export { resolveServeOptions } from "./options";
-export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./options";
-export { serveHttp } from "./http";
-export { serveStdio } from "./stdio";
-
-export type ServeCapletsOptions = {
- raw: RawServeOptions;
- engine?: CapletsEngineOptions;
- env?: NodeJS.ProcessEnv;
- writeErr?: (value: string) => void;
-};
-
-export async function serveCaplets(options: ServeCapletsOptions): Promise {
- const resolved = resolveServeOptions(options.raw, options.env ?? process.env);
- await serveResolvedCaplets(resolved, options.engine, options.writeErr);
-}
-
-export async function serveResolvedCaplets(
- resolved: ServeOptions,
- engineOptions: CapletsEngineOptions = {},
- writeErr?: (value: string) => void,
-): Promise {
- if (resolved.transport === "stdio") {
- await serveStdio({ ...engineOptions, ...(writeErr ? { writeErr } : {}) });
- return;
- }
- await serveHttp(resolved, { ...engineOptions, ...(writeErr ? { writeErr } : {}) }, writeErr);
-}
-```
-
-This references `serveHttp` before it exists. The repository will not compile until Task 5 creates `packages/core/src/serve/http.ts`.
-
-- [ ] **Step 2: Do not commit yet**
-
-Do not commit this task independently because `serve/index.ts` imports `serveHttp`, which is created in the next task. Commit Task 4 and Task 5 together after tests pass.
-
----
-
-### Task 5: Add Hono HTTP serving
-
-**Files:**
-
-- Create: `packages/core/src/serve/http.ts`
-- Complete: `packages/core/src/serve/index.ts`
-- Test: `packages/core/test/serve-http.test.ts`
-
-- [ ] **Step 1: Write failing HTTP route/auth tests**
-
-Create `packages/core/test/serve-http.test.ts`:
-
-```ts
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import { CapletsEngine } from "../src/engine";
-import { createHttpServeApp } from "../src/serve/http";
-import type { HttpServeOptions } from "../src/serve/options";
-
-const dirs: string[] = [];
-
-afterEach(() => {
- for (const dir of dirs.splice(0)) {
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-describe("createHttpServeApp", () => {
- it("serves root info and health without auth", async () => {
- const { engine } = testEngine();
- const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} });
-
- const root = await app.request("http://127.0.0.1:5387/");
- expect(root.status).toBe(200);
- await expect(root.json()).resolves.toMatchObject({
- name: "caplets",
- transport: "http",
- mcp: "/mcp",
- health: "/healthz",
- auth: { type: "basic", enabled: false },
- });
-
- const health = await app.request("http://127.0.0.1:5387/healthz");
- expect(health.status).toBe(200);
- await expect(health.json()).resolves.toEqual({
- status: "ok",
- transport: "http",
- mcpPath: "/mcp",
- });
-
- await engine.close();
- });
-
- it("requires Basic Auth on MCP path when password is configured", async () => {
- const { engine } = testEngine();
- const testPassword = ["test", "password"].join("-");
- const app = createHttpServeApp(
- httpOptions({ auth: { enabled: true, user: "caplets", password: testPassword } }),
- engine,
- { writeErr: () => {} },
- );
-
- const missing = await app.request("http://127.0.0.1:5387/mcp", { method: "POST" });
- expect(missing.status).toBe(401);
- expect(missing.headers.get("www-authenticate")).toContain("Basic");
-
- const wrong = await app.request("http://127.0.0.1:5387/mcp", {
- method: "POST",
- headers: {
- authorization: `Basic ${Buffer.from(`caplets:not-the-${testPassword}`).toString("base64")}`,
- },
- });
- expect(wrong.status).toBe(401);
-
- await engine.close();
- });
-
- it("returns 404 for nested MCP paths", async () => {
- const { engine } = testEngine();
- const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} });
-
- const response = await app.request("http://127.0.0.1:5387/mcp/extra");
- expect(response.status).toBe(404);
-
- await engine.close();
- });
-});
-
-function httpOptions(overrides: Partial = {}): HttpServeOptions {
- return {
- transport: "http",
- host: "127.0.0.1",
- port: 5387,
- path: "/mcp",
- auth: { enabled: false, user: "caplets" },
- warnUnauthenticatedNetwork: false,
- loopback: true,
- ...overrides,
- };
-}
-
-function testEngine(): { engine: CapletsEngine } {
- const dir = mkdtempSync(join(tmpdir(), "caplets-http-"));
- dirs.push(dir);
- const userRoot = join(dir, "user");
- const projectRoot = join(dir, "project", ".caplets");
- mkdirSync(userRoot, { recursive: true });
- mkdirSync(projectRoot, { recursive: true });
- const configPath = join(userRoot, "config.json");
- const projectConfigPath = join(projectRoot, "config.json");
- writeFileSync(
- configPath,
- JSON.stringify({
- httpApis: {
- status: {
- name: "Status",
- description: "Status API.",
- baseUrl: "http://127.0.0.1:1",
- auth: { type: "none" },
- actions: { check: { method: "GET", path: "/check" } },
- },
- },
- }),
- );
- return { engine: new CapletsEngine({ configPath, projectConfigPath, watch: false }) };
-}
-```
-
-- [ ] **Step 2: Run test to verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-http.test.ts
-```
-
-Expected: FAIL because `../src/serve/http` does not exist.
-
-- [ ] **Step 3: Implement Hono HTTP serving**
-
-Create `packages/core/src/serve/http.ts`:
-
-```ts
-import { randomUUID, timingSafeEqual } from "node:crypto";
-import type { ServerType } from "@hono/node-server";
-import { serve } from "@hono/node-server";
-import { StreamableHTTPTransport } from "@hono/mcp";
-import { Hono } from "hono";
-import type { Context, MiddlewareHandler } from "hono";
-import type { CapletsEngineOptions } from "../engine";
-import { CapletsEngine } from "../engine";
-import type { HttpBasicAuthOptions, HttpServeOptions } from "./options";
-import { CapletsMcpSession } from "./session";
-
-type HttpServeIo = {
- writeErr?: (value: string) => void;
-};
-
-type HttpSession = {
- server: CapletsMcpSession;
- transport: StreamableHTTPTransport;
-};
-
-export function createHttpServeApp(
- options: HttpServeOptions,
- engine: CapletsEngine,
- io: HttpServeIo = {},
-): Hono {
- const app = new Hono();
- const sessions = new Map();
-
- app.get("/", (c) =>
- c.json({
- name: "caplets",
- transport: "http",
- mcp: options.path,
- health: "/healthz",
- auth: { type: "basic", enabled: options.auth.enabled },
- }),
- );
-
- app.get("/healthz", (c) =>
- c.json({
- status: "ok",
- transport: "http",
- mcpPath: options.path,
- }),
- );
-
- app.all(options.path, basicAuth(options.auth), async (c) => {
- const sessionId = c.req.header("mcp-session-id");
- if (sessionId) {
- const existing = sessions.get(sessionId);
- if (!existing) {
- return c.json(
- {
- jsonrpc: "2.0",
- error: { code: -32001, message: "Session not found" },
- id: null,
- },
- 404,
- );
- }
- return existing.transport.handleRequest(c);
- }
-
- if (c.req.method !== "POST") {
- return c.json(
- {
- jsonrpc: "2.0",
- error: { code: -32000, message: "Bad Request: Mcp-Session-Id header is required" },
- id: null,
- },
- 400,
- );
- }
-
- const nextSessionId = randomUUID();
- const session = await createHttpSession(
- engine,
- nextSessionId,
- options,
- async (closedSessionId) => {
- const closed = sessions.get(closedSessionId);
- sessions.delete(closedSessionId);
- if (closed) {
- await closed.server.close();
- }
- },
- );
- sessions.set(nextSessionId, session);
- return session.transport.handleRequest(c);
- });
-
- app.notFound((c) => c.json({ error: "not_found" }, 404));
-
- Object.defineProperty(app, "__capletsClose", {
- value: async () => {
- await Promise.allSettled(
- [...sessions.values()].map(async (session) => {
- await session.server.close();
- }),
- );
- sessions.clear();
- },
- });
-
- if (options.warnUnauthenticatedNetwork) {
- (io.writeErr ?? process.stderr.write.bind(process.stderr))(
- `Warning: Caplets MCP HTTP server is listening on ${options.host} without authentication.\n`,
- );
- }
-
- return app;
-}
-
-export async function serveHttp(
- options: HttpServeOptions,
- engineOptions: CapletsEngineOptions = {},
- writeErr: (value: string) => void = (value) => process.stderr.write(value),
-): Promise {
- const engine = new CapletsEngine(engineOptions);
- const app = createHttpServeApp(options, engine, { writeErr });
- const server = serve({ fetch: app.fetch, hostname: options.host, port: options.port }, () => {
- writeErr(
- `Caplets MCP HTTP server listening on http://${formatHost(options.host)}:${options.port}${options.path}\n`,
- );
- writeErr(`Health check: http://${formatHost(options.host)}:${options.port}/healthz\n`);
- writeErr(
- `Basic Auth: ${options.auth.enabled ? `enabled (user: ${options.auth.user})` : "disabled"}\n`,
- );
- });
-
- installHttpSignalHandlers(server, app, engine, writeErr);
-}
-
-async function createHttpSession(
- engine: CapletsEngine,
- sessionId: string,
- options: HttpServeOptions,
- onClose: (sessionId: string) => Promise,
-): Promise {
- const transport = new StreamableHTTPTransport({
- sessionIdGenerator: () => sessionId,
- onsessionclosed: onClose,
- ...(options.loopback ? dnsRebindingOptions(options) : {}),
- });
- const server = new CapletsMcpSession(engine);
- await server.connect(transport);
- return { server, transport };
-}
-
-function basicAuth(auth: HttpBasicAuthOptions): MiddlewareHandler {
- return async (c, next) => {
- if (!auth.enabled) {
- await next();
- return;
- }
- const header = c.req.header("authorization") ?? "";
- const credentials = parseBasicAuth(header);
- if (
- !credentials ||
- !safeEqual(credentials.user, auth.user) ||
- !safeEqual(credentials.password, auth.password)
- ) {
- c.header("www-authenticate", 'Basic realm="caplets"');
- return c.text("Unauthorized", 401);
- }
- await next();
- };
-}
-
-function parseBasicAuth(header: string): { user: string; password: string } | undefined {
- const [scheme, encoded] = header.split(" ");
- if (scheme?.toLocaleLowerCase() !== "basic" || !encoded) {
- return undefined;
- }
- const decoded = Buffer.from(encoded, "base64").toString("utf8");
- const separator = decoded.indexOf(":");
- if (separator < 0) {
- return undefined;
- }
- return { user: decoded.slice(0, separator), password: decoded.slice(separator + 1) };
-}
-
-function safeEqual(left: string, right: string): boolean {
- const leftBuffer = Buffer.from(left);
- const rightBuffer = Buffer.from(right);
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
-}
-
-function dnsRebindingOptions(options: HttpServeOptions): {
- enableDnsRebindingProtection: true;
- allowedHosts: string[];
- allowedOrigins: string[];
-} {
- const hostForHeader = options.host === "::1" ? "[::1]" : options.host;
- return {
- enableDnsRebindingProtection: true,
- allowedHosts: [
- options.host,
- hostForHeader,
- `${hostForHeader}:${options.port}`,
- `localhost:${options.port}`,
- ],
- allowedOrigins: [`http://${hostForHeader}:${options.port}`, `http://localhost:${options.port}`],
- };
-}
-
-function installHttpSignalHandlers(
- server: ServerType,
- app: Hono,
- engine: CapletsEngine,
- writeErr: (value: string) => void,
-): void {
- const close = async () => {
- await new Promise((resolve) => server.close(() => resolve()));
- await (app as unknown as { __capletsClose?: () => Promise }).__capletsClose?.();
- await engine.close();
- };
- process.once(
- "SIGINT",
- () =>
- void close()
- .catch((error) => writeErr(`${String(error)}\n`))
- .finally(() => process.exit(130)),
- );
- process.once(
- "SIGTERM",
- () =>
- void close()
- .catch((error) => writeErr(`${String(error)}\n`))
- .finally(() => process.exit(143)),
- );
-}
-
-function formatHost(host: string): string {
- return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
-}
-```
-
-- [ ] **Step 4: Fix import/type issues from actual Hono versions**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core typecheck
-```
-
-Expected first run may FAIL on exact exported type names from `@hono/node-server` or `@hono/mcp`. If it fails:
-
-- If `ServerType` is type-only-exported from `@hono/node-server/dist/types`, replace `import type { ServerType } from "@hono/node-server";` with `import type { ServerType } from "@hono/node-server/dist/types";` only if the package export permits it.
-- If `StreamableHTTPTransport` option type rejects `allowedOrigins`, keep only `enableDnsRebindingProtection` and `allowedHosts`.
-- If `Hono` generic typing rejects the synthetic `__capletsClose` property, replace the property attachment with a helper return type in this file:
-
-```ts
-type CapletsHttpApp = Hono & { closeCapletsSessions: () => Promise };
-```
-
-Then return that from `createHttpServeApp` and call `app.closeCapletsSessions()` in `serveHttp`.
-
-- [ ] **Step 5: Run HTTP tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-http.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit stdio/http serve helpers**
-
-Run:
-
-```bash
-git add packages/core/src/serve/index.ts packages/core/src/serve/stdio.ts packages/core/src/serve/http.ts packages/core/test/serve-http.test.ts
-git commit -m "feat(core): serve MCP over Hono HTTP"
-```
-
----
-
-### Task 6: Wire `serve` into the core CLI
-
-**Files:**
-
-- Modify: `packages/core/src/cli.ts`
-- Modify: `packages/core/src/index.ts`
-- Test: `packages/core/test/cli.test.ts`
-
-- [ ] **Step 1: Add failing CLI tests**
-
-Append tests near the existing CLI help/version tests in `packages/core/test/cli.test.ts`:
-
-```ts
-it("prints top-level help for no arguments", async () => {
- const out: string[] = [];
-
- await runCli([], {
- writeOut: (value) => out.push(value),
- writeErr: (value) => out.push(value),
- });
-
- expect(out.join("")).toContain("Usage: caplets");
- expect(out.join("")).toContain("Commands:");
- expect(out.join("")).toContain("serve");
-});
-
-it("resolves serve defaults to stdio", async () => {
- const served: unknown[] = [];
-
- await runCli(["serve"], {
- writeOut: () => {},
- serve: async (options) => {
- served.push(options);
- },
- });
-
- expect(served).toEqual([{ transport: "stdio" }]);
-});
-
-it("resolves HTTP serve defaults", async () => {
- const served: unknown[] = [];
-
- await runCli(["serve", "--transport", "http"], {
- writeOut: () => {},
- serve: async (options) => {
- served.push(options);
- },
- });
-
- expect(served).toEqual([
- expect.objectContaining({
- transport: "http",
- host: "127.0.0.1",
- port: 5387,
- path: "/mcp",
- auth: { enabled: false, user: "caplets" },
- }),
- ]);
-});
-
-it("rejects HTTP-only serve options with stdio", async () => {
- await expect(
- runCli(["serve", "--transport", "stdio", "--port", "5387"], { writeErr: () => {} }),
- ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError);
-});
-```
-
-Also update the local `CliIO` TypeScript expectations by importing the real type only if needed. The test uses `serve` before the type exists, so it should fail initially.
-
-- [ ] **Step 2: Run CLI tests to verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/cli.test.ts --runInBand
-```
-
-Expected: FAIL because `CliIO` does not accept `serve`, no-arg behavior is not implemented, and the `serve` command does not exist in Commander.
-
-- [ ] **Step 3: Modify CLI imports and `CliIO`**
-
-In `packages/core/src/cli.ts`, add imports:
-
-```ts
-import { resolveServeOptions, type ServeOptions } from "./serve";
-import { serveResolvedCaplets } from "./serve";
-```
-
-Update `CliIO`:
-
-```ts
-type CliIO = {
- writeOut?: (value: string) => void;
- writeErr?: (value: string) => void;
- authDir?: string;
- version?: string;
- setExitCode?: (code: number) => void;
- serve?: (options: ServeOptions) => Promise;
-};
-```
-
-- [ ] **Step 4: Add no-arg help behavior**
-
-In `runCli`, before `program.parseAsync`, add:
-
-```ts
-if (args.length === 0) {
- program.outputHelp();
- return;
-}
-```
-
-The resulting function body should start like:
-
-```ts
-export async function runCli(args: string[], io: CliIO = {}): Promise {
- const program = createProgram(io);
- try {
- if (args.length === 0) {
- program.outputHelp();
- return;
- }
- await program.parseAsync(["node", "caplets", ...args]);
- } catch (error) {
- // existing error handling stays unchanged
- }
-}
-```
-
-- [ ] **Step 5: Add `serve` command to `createProgram`**
-
-After the main `program` setup and before `init`, add:
-
-```ts
-program
- .command("serve")
- .description("Serve configured Caplets as an MCP server.")
- .option("--transport ", "server transport: stdio or http")
- .option("--host ", "HTTP bind host")
- .option("--port ", "HTTP bind port")
- .option("--path ", "HTTP MCP endpoint path")
- .option("--user ", "HTTP Basic Auth username")
- .option("--password ", "HTTP Basic Auth password")
- .action(
- async (options: {
- transport?: string;
- host?: string;
- port?: string;
- path?: string;
- user?: string;
- password?: string;
- }) => {
- const resolved = resolveServeOptions(options);
- const runner =
- io.serve ??
- ((serveOptions: ServeOptions) =>
- serveResolvedCaplets(
- serveOptions,
- {
- ...(envConfigPath() ? { configPath: envConfigPath() } : {}),
- ...(io.authDir ? { authDir: io.authDir } : {}),
- },
- writeErr,
- ));
- await runner(resolved);
- },
- );
-```
-
-- [ ] **Step 6: Export serve helpers from core index**
-
-In `packages/core/src/index.ts`, add:
-
-```ts
-export { serveCaplets, serveHttp, serveResolvedCaplets, serveStdio } from "./serve";
-export type { HttpServeOptions, RawServeOptions, ServeOptions, StdioServeOptions } from "./serve";
-```
-
-- [ ] **Step 7: Run focused CLI tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/cli.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 8: Commit CLI wiring**
-
-Run:
-
-```bash
-git add packages/core/src/cli.ts packages/core/src/index.ts packages/core/test/cli.test.ts
-git commit -m "feat(core): add serve command options"
-```
-
----
-
-### Task 7: Simplify CLI binary entrypoint
-
-**Files:**
-
-- Modify: `packages/cli/src/index.ts`
-- Test: covered by package build/typecheck
-
-- [ ] **Step 1: Replace special-case entrypoint**
-
-Replace `packages/cli/src/index.ts` with:
-
-```ts
-import { runCli } from "@caplets/core";
-import { version as packageVersion } from "../package.json";
-
-async function main() {
- await runCli(process.argv.slice(2), { version: packageVersion });
-}
-
-main().catch((error) => {
- console.error(error);
- process.exit(1);
-});
-```
-
-- [ ] **Step 2: Run CLI package typecheck/build**
-
-Run:
-
-```bash
-pnpm --filter caplets typecheck
-pnpm --filter caplets build
-```
-
-Expected: PASS.
-
-- [ ] **Step 3: Commit entrypoint simplification**
-
-Run:
-
-```bash
-git add packages/cli/src/index.ts
-git commit -m "refactor(cli): delegate serve command to core"
-```
-
----
-
-### Task 8: Add MCP HTTP integration coverage
-
-**Files:**
-
-- Modify: `packages/core/test/serve-http.test.ts`
-
-- [ ] **Step 1: Add initialize/list-tools integration test**
-
-Append this test to `packages/core/test/serve-http.test.ts`:
-
-```ts
-it("initializes an MCP HTTP session and lists Caplet tools", async () => {
- const { engine } = testEngine();
- const app = createHttpServeApp(httpOptions(), engine, { writeErr: () => {} });
-
- const init = await app.request("http://127.0.0.1:5387/mcp", {
- method: "POST",
- headers: {
- accept: "application/json, text/event-stream",
- "content-type": "application/json",
- },
- body: JSON.stringify({
- jsonrpc: "2.0",
- id: 1,
- method: "initialize",
- params: {
- protocolVersion: "2025-03-26",
- capabilities: {},
- clientInfo: { name: "test", version: "1.0.0" },
- },
- }),
- });
-
- expect(init.status).toBe(200);
- const sessionId = init.headers.get("mcp-session-id");
- expect(sessionId).toBeTruthy();
-
- await app.request("http://127.0.0.1:5387/mcp", {
- method: "POST",
- headers: {
- accept: "application/json, text/event-stream",
- "content-type": "application/json",
- "mcp-session-id": sessionId!,
- },
- body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
- });
-
- const tools = await app.request("http://127.0.0.1:5387/mcp", {
- method: "POST",
- headers: {
- accept: "application/json, text/event-stream",
- "content-type": "application/json",
- "mcp-session-id": sessionId!,
- },
- body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
- });
-
- expect(tools.status).toBe(200);
- const body = await tools.text();
- expect(body).toContain("status");
-
- const deleted = await app.request("http://127.0.0.1:5387/mcp", {
- method: "DELETE",
- headers: { "mcp-session-id": sessionId! },
- });
- expect(deleted.status).toBe(200);
-
- await engine.close();
-});
-```
-
-If `@hono/mcp` returns SSE frames for `tools/list`, asserting `body` contains `status` is sufficient. If it returns pure JSON, the same assertion still passes.
-
-- [ ] **Step 2: Run HTTP tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-http.test.ts
-```
-
-Expected: PASS. If the DELETE request requires `mcp-protocol-version`, add header:
-
-```ts
-"mcp-protocol-version": "2025-03-26"
-```
-
-and rerun.
-
-- [ ] **Step 3: Commit HTTP integration test**
-
-Run:
-
-```bash
-git add packages/core/test/serve-http.test.ts
-git commit -m "test(core): cover HTTP MCP sessions"
-```
-
----
-
-### Task 9: Run focused and full verification
-
-**Files:**
-
-- No source files unless verification reveals issues.
-
-- [ ] **Step 1: Run focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/serve-options.test.ts test/serve-session.test.ts test/serve-http.test.ts test/runtime.test.ts test/cli.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 2: Run typecheck**
-
-Run:
-
-```bash
-pnpm typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 3: Run all tests**
-
-Run:
-
-```bash
-pnpm test
-```
-
-Expected: PASS.
-
-- [ ] **Step 4: Run full gate**
-
-Run:
-
-```bash
-pnpm verify
-```
-
-Expected: PASS through format, lint, typecheck, schema check, tests, benchmark check, and build.
-
-- [ ] **Step 5: Commit any verification fixes**
-
-If verification required fixes, commit them:
-
-```bash
-git add
-git commit -m "fix(core): polish HTTP serve implementation"
-```
-
-If no fixes were needed, do not create an empty commit.
-
----
-
-## Self-review notes
-
-- Spec coverage: CLI behavior is covered by Tasks 3, 6, and 7. Hono HTTP behavior, Basic Auth, root/health endpoints, DNS rebinding options, session routing, startup logs, and shutdown are covered by Tasks 5 and 8. Runtime architecture is covered by Task 2. Verification is covered by Task 9.
-- Red-flag scan: no incomplete sections are intentionally left. The only conditional instructions are concrete version/type compatibility branches for real package typings.
-- Type consistency: `ServeOptions`, `HttpServeOptions`, `CapletsMcpSession`, and `ToolServer` are introduced before later tasks consume them.
diff --git a/docs/plans/2026-05-19-remote-native-integrations.md b/docs/plans/2026-05-19-remote-native-integrations.md
deleted file mode 100644
index 9804e9ed..00000000
--- a/docs/plans/2026-05-19-remote-native-integrations.md
+++ /dev/null
@@ -1,1438 +0,0 @@
-# Remote Native Integrations Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Let OpenCode, Pi, Codex, and Claude Code connect to a remote `caplets serve --transport http` service while preserving existing local defaults.
-
-**Architecture:** Add remote-aware native service option resolution in `@caplets/core/native`, then implement a `RemoteNativeCapletsService` backed by MCP SDK `Client` + `StreamableHTTPClientTransport`. OpenCode and Pi continue using `createNativeCapletsService()` but pass host-specific config into it; Codex/Claude remain MCP-backed and get documented remote HTTP config examples.
-
-**Tech Stack:** TypeScript, Vitest, MCP SDK Streamable HTTP client, existing Caplets native service interfaces, OpenCode plugin API, Pi extension API, pnpm.
-
----
-
-## File structure
-
-- Create `packages/core/src/native/options.ts` for env/config resolution, mode selection, remote URL validation, and Basic Auth header creation.
-- Create `packages/core/src/native/remote.ts` for remote MCP client wrapper and `RemoteNativeCapletsService`.
-- Modify `packages/core/src/native/service.ts` to delegate to local or remote implementation.
-- Modify `packages/core/src/native/tools.ts` only if remote tool guidance needs a helper; otherwise keep existing local helpers unchanged.
-- Modify `packages/core/src/native.ts` to export new option types needed by integrations.
-- Add `packages/core/test/native-options.test.ts`.
-- Add `packages/core/test/native-remote.test.ts`.
-- Modify `packages/core/test/process-cleanup.test.ts` only if `NativeCapletsService` type changes.
-- Modify `packages/opencode/src/index.ts` to accept second-argument config and pass it into `createNativeCapletsService()`.
-- Add `packages/opencode/src/config.ts` for OpenCode config normalization if the logic is more than one small helper.
-- Modify `packages/opencode/test/opencode.test.ts` to verify second-argument config propagation.
-- Modify `packages/opencode/README.md` with env and plugin-config remote examples.
-- Modify `packages/pi/src/index.ts` to accept args/native options while preserving the `{ service }` test seam.
-- Modify `packages/pi/test/pi.test.ts` to verify Pi args propagation.
-- Modify `packages/pi/README.md` with `~/.pi/agent/settings.json` package args examples and note to use the active Pi settings path if different.
-- Modify `README.md` and `plugins/caplets/skills/caplets/SKILL.md` with remote service guidance for Codex/Claude.
-- Modify `packages/core/test/agent-plugins.test.ts` if docs/plugin artifact assertions need remote examples.
-- Add a changeset for `@caplets/core`, `@caplets/opencode`, `@caplets/pi`, and `caplets`.
-
----
-
-### Task 1: Add native service option resolution
-
-**Files:**
-
-- Create: `packages/core/src/native/options.ts`
-- Modify: `packages/core/src/native.ts`
-- Test: `packages/core/test/native-options.test.ts`
-
-- [ ] **Step 1: Write failing option-resolution tests**
-
-Create `packages/core/test/native-options.test.ts`:
-
-```ts
-import { describe, expect, it } from "vitest";
-import { CapletsError } from "../src/errors";
-import { resolveNativeCapletsServiceOptions } from "../src/native/options";
-
-describe("resolveNativeCapletsServiceOptions", () => {
- it("defaults to local mode without remote configuration", () => {
- expect(resolveNativeCapletsServiceOptions({}, {})).toEqual({
- mode: "local",
- });
- });
-
- it("uses remote mode in auto when a remote URL is configured", () => {
- expect(
- resolveNativeCapletsServiceOptions({}, { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/mcp" }),
- ).toMatchObject({
- mode: "remote",
- remote: {
- url: new URL("http://127.0.0.1:5387/mcp"),
- auth: { enabled: false, user: "caplets" },
- pollIntervalMs: 30_000,
- },
- });
- });
-
- it("lets explicit local mode ignore remote env vars", () => {
- expect(
- resolveNativeCapletsServiceOptions(
- { mode: "local" },
- { CAPLETS_REMOTE_URL: "http://127.0.0.1:5387/mcp" },
- ),
- ).toEqual({ mode: "local" });
- });
-
- it("requires a URL in explicit remote mode", () => {
- expect(() => resolveNativeCapletsServiceOptions({ mode: "remote" }, {})).toThrow(
- expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError,
- );
- });
-
- it("rejects non-loopback http URLs", () => {
- expect(() =>
- resolveNativeCapletsServiceOptions({ remote: { url: "http://caplets.example.com/mcp" } }, {}),
- ).toThrow(/https/u);
- });
-
- it("lets config override env vars", () => {
- const configPassword = ["config", "password"].join("-");
- expect(
- resolveNativeCapletsServiceOptions(
- {
- remote: {
- url: "https://configured.example.com/mcp",
- user: "configured",
- password: configPassword,
- },
- },
- {
- CAPLETS_REMOTE_URL: "https://env.example.com/mcp",
- CAPLETS_REMOTE_USER: "env-user",
- CAPLETS_REMOTE_PASSWORD: ["env", "password"].join("-"),
- },
- ),
- ).toMatchObject({
- mode: "remote",
- remote: {
- url: new URL("https://configured.example.com/mcp"),
- auth: { enabled: true, user: "configured", password: configPassword },
- },
- });
- });
-
- it("defaults Basic Auth user when password exists", () => {
- const password = ["remote", "password"].join("-");
- expect(
- resolveNativeCapletsServiceOptions(
- { remote: { url: "https://caplets.example.com/mcp", password } },
- {},
- ),
- ).toMatchObject({
- remote: { auth: { enabled: true, user: "caplets", password } },
- });
- });
-
- it("rejects user without password", () => {
- expect(() =>
- resolveNativeCapletsServiceOptions(
- { remote: { url: "https://caplets.example.com/mcp", user: "caplets" } },
- {},
- ),
- ).toThrow(/requires a password/u);
- });
-
- it("builds request headers without logging credentials", () => {
- const password = ["remote", "password"].join("-");
- const resolved = resolveNativeCapletsServiceOptions(
- {
- remote: {
- url: "https://caplets.example.com/mcp",
- user: "caplets",
- password,
- },
- },
- {},
- );
- expect(resolved.mode).toBe("remote");
- expect(resolved.mode === "remote" ? resolved.remote.requestInit.headers : undefined).toEqual({
- Authorization: `Basic ${Buffer.from(`caplets:${password}`).toString("base64")}`,
- });
- });
-});
-```
-
-- [ ] **Step 2: Run option tests to verify red**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/native-options.test.ts
-```
-
-Expected: FAIL with module-not-found for `../src/native/options`.
-
-- [ ] **Step 3: Implement native option resolution**
-
-Create `packages/core/src/native/options.ts`:
-
-```ts
-import { CapletsError } from "../errors";
-
-export type NativeCapletsMode = "auto" | "local" | "remote";
-
-export type NativeRemoteCapletsOptions = {
- url?: string;
- user?: string;
- password?: string;
- pollIntervalMs?: number;
- fetch?: typeof fetch;
-};
-
-export type NativeCapletsServiceResolutionInput = {
- mode?: NativeCapletsMode;
- remote?: NativeRemoteCapletsOptions;
-};
-
-export type NativeCapletsEnv = Partial<
- Record<
- | "CAPLETS_NATIVE_MODE"
- | "CAPLETS_REMOTE_URL"
- | "CAPLETS_REMOTE_USER"
- | "CAPLETS_REMOTE_PASSWORD",
- string
- >
->;
-
-export type NativeRemoteAuthOptions =
- | { enabled: false; user: string }
- | { enabled: true; user: string; password: string };
-
-export type ResolvedNativeCapletsServiceOptions =
- | { mode: "local" }
- | {
- mode: "remote";
- remote: {
- url: URL;
- auth: NativeRemoteAuthOptions;
- pollIntervalMs: number;
- requestInit: RequestInit;
- fetch?: typeof fetch;
- };
- };
-
-const DEFAULT_REMOTE_USER = "caplets";
-const DEFAULT_POLL_INTERVAL_MS = 30_000;
-
-export function resolveNativeCapletsServiceOptions(
- input: NativeCapletsServiceResolutionInput = {},
- env: NativeCapletsEnv = process.env,
-): ResolvedNativeCapletsServiceOptions {
- const mode = parseMode(input.mode ?? env.CAPLETS_NATIVE_MODE ?? "auto");
- if (mode === "local") {
- return { mode: "local" };
- }
-
- const rawUrl =
- nonEmpty(input.remote?.url, "remote.url") ??
- nonEmpty(env.CAPLETS_REMOTE_URL, "CAPLETS_REMOTE_URL");
- if (mode === "remote" && rawUrl === undefined) {
- throw new CapletsError(
- "REQUEST_INVALID",
- "CAPLETS_NATIVE_MODE=remote requires CAPLETS_REMOTE_URL or remote.url.",
- );
- }
- if (rawUrl === undefined) {
- return { mode: "local" };
- }
-
- const url = parseRemoteUrl(rawUrl);
- const userWasExplicit = input.remote?.user !== undefined || hasEnv(env.CAPLETS_REMOTE_USER);
- const user =
- nonEmpty(input.remote?.user, "remote.user") ??
- nonEmpty(env.CAPLETS_REMOTE_USER, "CAPLETS_REMOTE_USER") ??
- DEFAULT_REMOTE_USER;
- const password =
- nonEmpty(input.remote?.password, "remote.password") ??
- nonEmpty(env.CAPLETS_REMOTE_PASSWORD, "CAPLETS_REMOTE_PASSWORD");
-
- if (userWasExplicit && password === undefined) {
- throw new CapletsError(
- "REQUEST_INVALID",
- "Remote Caplets Basic Auth requires a password; set CAPLETS_REMOTE_PASSWORD or remote.password.",
- );
- }
-
- const auth: NativeRemoteAuthOptions =
- password === undefined ? { enabled: false, user } : { enabled: true, user, password };
- const requestInit: RequestInit = auth.enabled
- ? { headers: { Authorization: basicAuthHeader(auth.user, auth.password) } }
- : {};
-
- return {
- mode: "remote",
- remote: {
- url,
- auth,
- pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs),
- requestInit,
- ...(input.remote?.fetch ? { fetch: input.remote.fetch } : {}),
- },
- };
-}
-
-function parseMode(value: string): NativeCapletsMode {
- if (value === "auto" || value === "local" || value === "remote") {
- return value;
- }
- throw new CapletsError(
- "REQUEST_INVALID",
- `Expected CAPLETS_NATIVE_MODE to be auto, local, or remote, got ${value}`,
- );
-}
-
-function parseRemoteUrl(value: string): URL {
- let url: URL;
- try {
- url = new URL(value);
- } catch {
- throw new CapletsError("REQUEST_INVALID", `Invalid remote Caplets URL: ${value}`);
- }
- if (url.protocol === "https:") {
- return url;
- }
- if (url.protocol === "http:" && isLoopbackHost(url.hostname)) {
- return url;
- }
- throw new CapletsError(
- "REQUEST_INVALID",
- "Remote Caplets URL must use https except loopback development URLs.",
- );
-}
-
-function isLoopbackHost(host: string): boolean {
- return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1";
-}
-
-function parsePollInterval(value: number | undefined): number {
- if (value === undefined) {
- return DEFAULT_POLL_INTERVAL_MS;
- }
- if (!Number.isInteger(value) || value < 1_000) {
- throw new CapletsError("REQUEST_INVALID", "remote.pollIntervalMs must be an integer >= 1000.");
- }
- return value;
-}
-
-function basicAuthHeader(user: string, password: string): string {
- return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
-}
-
-function nonEmpty(value: string | undefined, label: string): string | undefined {
- if (value === undefined) {
- return undefined;
- }
- const trimmed = value.trim();
- if (!trimmed) {
- throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`);
- }
- return trimmed;
-}
-
-function hasEnv(value: string | undefined): boolean {
- return value !== undefined && value.trim() !== "";
-}
-```
-
-- [ ] **Step 4: Export option types from native entrypoint**
-
-Modify `packages/core/src/native.ts` to add:
-
-```ts
-export {
- resolveNativeCapletsServiceOptions,
- type NativeCapletsMode,
- type NativeRemoteCapletsOptions,
- type ResolvedNativeCapletsServiceOptions,
-} from "./native/options";
-```
-
-- [ ] **Step 5: Run option tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/native-options.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit native option resolution**
-
-Run:
-
-```bash
-git add packages/core/src/native/options.ts packages/core/src/native.ts packages/core/test/native-options.test.ts
-git commit -m "feat(core): resolve remote native service options"
-```
-
----
-
-### Task 2: Add remote native service implementation
-
-**Files:**
-
-- Create: `packages/core/src/native/remote.ts`
-- Modify: `packages/core/src/native/service.ts`
-- Test: `packages/core/test/native-remote.test.ts`
-
-- [ ] **Step 1: Write failing remote-service tests**
-
-Create `packages/core/test/native-remote.test.ts`:
-
-```ts
-import { EventEmitter } from "node:events";
-import { describe, expect, it, vi } from "vitest";
-import { CapletsError } from "../src/errors";
-import { RemoteNativeCapletsService, type RemoteCapletsClient } from "../src/native/remote";
-import { createNativeCapletsService } from "../src/native/service";
-
-describe("RemoteNativeCapletsService", () => {
- it("maps remote MCP tools to native Caplets tools", async () => {
- const client = fakeRemoteClient({
- tools: [
- {
- name: "git-hub",
- title: "GitHub",
- description: "GitHub progressive tools.",
- inputSchema: { type: "object" },
- },
- ],
- });
- const service = new RemoteNativeCapletsService({
- clientFactory: async () => client,
- pollIntervalMs: 10_000,
- });
-
- await service.reload();
-
- expect(service.listTools()).toEqual([
- expect.objectContaining({
- caplet: "git-hub",
- toolName: "caplets_git_hub",
- title: "GitHub",
- }),
- ]);
- expect(service.listTools()[0]?.description).toContain("GitHub progressive tools.");
- expect(service.listTools()[0]?.promptGuidance[0]).toContain("remote Caplets service");
-
- await service.close();
- });
-
- it("calls remote tools by Caplet ID", async () => {
- const client = fakeRemoteClient({
- tools: [{ name: "linear", inputSchema: { type: "object" } }],
- });
- const service = new RemoteNativeCapletsService({
- clientFactory: async () => client,
- pollIntervalMs: 10_000,
- });
-
- const result = await service.execute("linear", { operation: "get_caplet" });
-
- expect(client.callTool).toHaveBeenCalledWith("linear", {
- operation: "get_caplet",
- });
- expect(result).toEqual({ content: [{ type: "text", text: "ok" }] });
-
- await service.close();
- });
-
- it("notifies listeners on tool-list-change notifications", async () => {
- const client = fakeRemoteClient({
- tools: [{ name: "alpha", inputSchema: { type: "object" } }],
- });
- const service = new RemoteNativeCapletsService({
- clientFactory: async () => client,
- pollIntervalMs: 10_000,
- });
- const changes: string[][] = [];
- service.onToolsChanged((tools) => changes.push(tools.map((tool) => tool.caplet)));
-
- await service.reload();
- client.setTools([{ name: "beta", title: "Beta", inputSchema: { type: "object" } }]);
- await client.emitToolsChanged();
-
- expect(changes).toEqual([["alpha"], ["beta"]]);
- await service.close();
- });
-
- it("keeps last known-good tools when refresh fails", async () => {
- const errors: string[] = [];
- const client = fakeRemoteClient({
- tools: [{ name: "alpha", inputSchema: { type: "object" } }],
- });
- const service = new RemoteNativeCapletsService({
- clientFactory: async () => client,
- pollIntervalMs: 10_000,
- writeErr: (value) => errors.push(value),
- });
-
- await service.reload();
- client.failListTools = true;
- const reloaded = await service.reload();
-
- expect(reloaded).toBe(false);
- expect(service.listTools().map((tool) => tool.caplet)).toEqual(["alpha"]);
- expect(errors.join("")).toContain("keeping last known-good remote tools");
- await service.close();
- });
-
- it("closes the client and stops future notifications", async () => {
- const client = fakeRemoteClient({
- tools: [{ name: "alpha", inputSchema: { type: "object" } }],
- });
- const service = new RemoteNativeCapletsService({
- clientFactory: async () => client,
- pollIntervalMs: 10_000,
- });
- const listener = vi.fn();
- service.onToolsChanged(listener);
-
- await service.reload();
- await service.close();
- client.setTools([{ name: "beta", inputSchema: { type: "object" } }]);
- await client.emitToolsChanged();
-
- expect(client.close).toHaveBeenCalled();
- expect(listener).toHaveBeenCalledTimes(1);
- });
-});
-
-describe("createNativeCapletsService remote mode", () => {
- it("returns a remote service when remote mode resolves", async () => {
- const service = createNativeCapletsService({
- mode: "remote",
- remote: { url: "http://127.0.0.1:5387/mcp" },
- remoteClientFactory: async () => fakeRemoteClient({ tools: [] }),
- });
-
- await expect(service.reload()).resolves.toBe(true);
- await service.close();
- });
-
- it("fails fast for invalid remote configuration", () => {
- expect(() => createNativeCapletsService({ mode: "remote" })).toThrow(
- expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError,
- );
- });
-});
-
-type FakeRemoteTool = {
- name: string;
- title?: string;
- description?: string;
- inputSchema: { type: "object" };
-};
-
-function fakeRemoteClient(initial: { tools: FakeRemoteTool[] }): RemoteCapletsClient & {
- failListTools: boolean;
- setTools(tools: FakeRemoteTool[]): void;
- emitToolsChanged(): Promise;
- callTool: ReturnType;
- close: ReturnType;
-} {
- const events = new EventEmitter();
- let tools = initial.tools;
- return {
- failListTools: false,
- setTools(next) {
- tools = next;
- },
- async emitToolsChanged() {
- events.emit("toolsChanged");
- await Promise.resolve();
- },
- async listTools() {
- if (this.failListTools) {
- throw new Error("list failed");
- }
- return tools;
- },
- callTool: vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] })),
- onToolsChanged(listener) {
- events.on("toolsChanged", listener);
- return () => events.off("toolsChanged", listener);
- },
- close: vi.fn(async () => {}),
- };
-}
-```
-
-- [ ] **Step 2: Run remote tests to verify red**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/native-remote.test.ts
-```
-
-Expected: FAIL because `../src/native/remote` does not exist and `remoteClientFactory` is not accepted.
-
-- [ ] **Step 3: Implement remote service**
-
-Create `packages/core/src/native/remote.ts`:
-
-```ts
-import { Client } from "@modelcontextprotocol/sdk/client/index";
-import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp";
-import { version as packageJsonVersion } from "../../package.json";
-import { CapletsError } from "../errors";
-import type {
- NativeCapletTool,
- NativeCapletsService,
- NativeCapletsToolsChangedListener,
-} from "./service";
-import { nativeCapletToolName } from "./tools";
-
-type RemoteTool = {
- name: string;
- title?: string;
- description?: string;
- inputSchema: { type: "object" };
-};
-
-export type RemoteCapletsClient = {
- listTools(): Promise;
- callTool(name: string, arguments_: unknown): Promise;
- onToolsChanged(listener: () => void | Promise): () => void;
- close(): Promise;
-};
-
-export type RemoteCapletsClientFactory = () => Promise;
-
-export type RemoteNativeCapletsServiceOptions = {
- clientFactory: RemoteCapletsClientFactory;
- pollIntervalMs: number;
- writeErr?: (value: string) => void;
-};
-
-export class RemoteNativeCapletsService implements NativeCapletsService {
- private client: RemoteCapletsClient | undefined;
- private tools: NativeCapletTool[] = [];
- private readonly listeners = new Set();
- private unsubscribeToolsChanged: (() => void) | undefined;
- private poll: NodeJS.Timeout | undefined;
- private closed = false;
-
- constructor(private readonly options: RemoteNativeCapletsServiceOptions) {}
-
- listTools(): NativeCapletTool[] {
- return this.tools;
- }
-
- async execute(capletId: string, request: unknown): Promise {
- const client = await this.ensureClient();
- try {
- return await client.callTool(capletId, request);
- } catch (error) {
- throw classifyRemoteError(error, `Remote Caplets tool ${capletId} failed`);
- }
- }
-
- async reload(): Promise {
- try {
- const client = await this.ensureClient();
- const next = mapRemoteTools(await client.listTools());
- const changed = toolSignature(next) !== toolSignature(this.tools);
- this.tools = next;
- if (changed) {
- this.emitToolsChanged();
- }
- return true;
- } catch (error) {
- this.writeErr(
- `Remote Caplets refresh failed; keeping last known-good remote tools: ${safeMessage(error)}\n`,
- );
- return false;
- }
- }
-
- onToolsChanged(listener: NativeCapletsToolsChangedListener): () => void {
- this.listeners.add(listener);
- return () => this.listeners.delete(listener);
- }
-
- async close(): Promise {
- if (this.closed) {
- return;
- }
- this.closed = true;
- if (this.poll) {
- clearInterval(this.poll);
- this.poll = undefined;
- }
- this.unsubscribeToolsChanged?.();
- this.unsubscribeToolsChanged = undefined;
- await this.client?.close();
- this.client = undefined;
- this.listeners.clear();
- }
-
- private async ensureClient(): Promise {
- if (this.closed) {
- throw new CapletsError("SERVER_UNAVAILABLE", "Remote Caplets service is closed.");
- }
- if (this.client) {
- return this.client;
- }
- const client = await this.options.clientFactory();
- this.client = client;
- this.unsubscribeToolsChanged = client.onToolsChanged(() => void this.reload());
- this.startPolling();
- return client;
- }
-
- private startPolling(): void {
- if (this.poll) {
- return;
- }
- this.poll = setInterval(() => void this.reload(), this.options.pollIntervalMs);
- this.poll.unref?.();
- }
-
- private emitToolsChanged(): void {
- const snapshot = this.listTools();
- for (const listener of this.listeners) {
- listener(snapshot);
- }
- }
-
- private writeErr(value: string): void {
- (this.options.writeErr ?? process.stderr.write.bind(process.stderr))(value);
- }
-}
-
-export function createSdkRemoteCapletsClient(options: {
- url: URL;
- requestInit: RequestInit;
- fetch?: typeof fetch;
-}): RemoteCapletsClientFactory {
- return async () => {
- const transport = new StreamableHTTPClientTransport(options.url, {
- requestInit: options.requestInit,
- ...(options.fetch ? { fetch: options.fetch } : {}),
- });
- const client = new Client(
- { name: "caplets-native", version: packageJsonVersion },
- {
- capabilities: {},
- },
- );
- await client.connect(transport);
- return {
- async listTools() {
- const result = await client.listTools();
- return result.tools;
- },
- async callTool(name, arguments_) {
- return await client.callTool({
- name,
- arguments: arguments_ as Record,
- });
- },
- onToolsChanged(listener) {
- client.setNotificationHandler(
- { method: "notifications/tools/list_changed" } as never,
- () => {
- void listener();
- },
- );
- return () =>
- client.removeNotificationHandler({
- method: "notifications/tools/list_changed",
- } as never);
- },
- async close() {
- await transport.terminateSession().catch(() => undefined);
- await client.close();
- },
- };
- };
-}
-
-function mapRemoteTools(tools: RemoteTool[]): NativeCapletTool[] {
- return tools
- .map((tool) => {
- const toolName = nativeCapletToolName(tool.name);
- const title = tool.title ?? tool.name;
- return {
- caplet: tool.name,
- toolName,
- title,
- description: [
- tool.description ?? `Remote Caplets capability domain ${title}.`,
- "",
- `Native tool name: ${toolName}`,
- `Remote Caplet ID: ${tool.name}`,
- ].join("\n"),
- promptGuidance: [
- `Use ${toolName} for the ${title} remote Caplets service capability domain.`,
- ],
- };
- })
- .sort((left, right) => left.caplet.localeCompare(right.caplet));
-}
-
-function toolSignature(tools: NativeCapletTool[]): string {
- return JSON.stringify(tools);
-}
-
-function safeMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
-}
-
-function classifyRemoteError(error: unknown, fallbackMessage: string): Error {
- const message = safeMessage(error);
- if (/401|403|unauthorized|forbidden/i.test(message)) {
- return new CapletsError(
- "AUTH_FAILED",
- "Remote Caplets authentication failed. Check CAPLETS_REMOTE_USER and CAPLETS_REMOTE_PASSWORD.",
- );
- }
- return error instanceof Error ? error : new CapletsError("SERVER_UNAVAILABLE", fallbackMessage);
-}
-```
-
-If TypeScript rejects `setNotificationHandler` object literals, use the SDK's exported notification schema type for tool-list changes or fall back to client options `listChanged.tools.onChanged` during `new Client(...)`. Keep the public `RemoteCapletsClient` interface unchanged so tests stay stable.
-
-- [ ] **Step 4: Wire service factory**
-
-Modify `packages/core/src/native/service.ts`:
-
-- Add imports:
-
-```ts
-import {
- resolveNativeCapletsServiceOptions,
- type NativeCapletsServiceResolutionInput,
-} from "./options";
-import {
- createSdkRemoteCapletsClient,
- RemoteNativeCapletsService,
- type RemoteCapletsClientFactory,
-} from "./remote";
-```
-
-- Extend `NativeCapletsServiceOptions`:
-
-```ts
-export type NativeCapletsServiceOptions = NativeCapletsServiceResolutionInput & {
- configPath?: string;
- projectConfigPath?: string;
- authDir?: string;
- watchDebounceMs?: number;
- watch?: boolean;
- writeErr?: (value: string) => void;
- remoteClientFactory?: RemoteCapletsClientFactory;
-};
-```
-
-- Replace `createNativeCapletsService` with:
-
-```ts
-export function createNativeCapletsService(
- options: NativeCapletsServiceOptions = {},
-): NativeCapletsService {
- const resolved = resolveNativeCapletsServiceOptions(options);
- if (resolved.mode === "remote") {
- return new RemoteNativeCapletsService({
- clientFactory:
- options.remoteClientFactory ??
- createSdkRemoteCapletsClient({
- url: resolved.remote.url,
- requestInit: resolved.remote.requestInit,
- ...(resolved.remote.fetch ? { fetch: resolved.remote.fetch } : {}),
- }),
- pollIntervalMs: resolved.remote.pollIntervalMs,
- ...(options.writeErr ? { writeErr: options.writeErr } : {}),
- });
- }
- return new DefaultNativeCapletsService(localEngineOptions(options));
-}
-```
-
-- Add helper:
-
-```ts
-function localEngineOptions(
- options: NativeCapletsServiceOptions,
-): ConstructorParameters[0] {
- const engineOptions: ConstructorParameters[0] = {};
- if (options.configPath !== undefined) engineOptions.configPath = options.configPath;
- if (options.projectConfigPath !== undefined)
- engineOptions.projectConfigPath = options.projectConfigPath;
- if (options.authDir !== undefined) engineOptions.authDir = options.authDir;
- if (options.watchDebounceMs !== undefined)
- engineOptions.watchDebounceMs = options.watchDebounceMs;
- if (options.watch !== undefined) engineOptions.watch = options.watch;
- if (options.writeErr !== undefined) engineOptions.writeErr = options.writeErr;
- return engineOptions;
-}
-```
-
-- [ ] **Step 5: Export remote types from native entrypoint**
-
-Modify `packages/core/src/native.ts` to add:
-
-```ts
-export {
- RemoteNativeCapletsService,
- createSdkRemoteCapletsClient,
- type RemoteCapletsClient,
- type RemoteCapletsClientFactory,
-} from "./native/remote";
-```
-
-- [ ] **Step 6: Run remote tests and typecheck**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/native-remote.test.ts test/native-options.test.ts test/process-cleanup.test.ts
-pnpm --filter @caplets/core typecheck
-```
-
-Expected: PASS. If SDK notification typing requires a different handler shape, fix `createSdkRemoteCapletsClient` without changing tests.
-
-- [ ] **Step 7: Commit remote native service**
-
-Run:
-
-```bash
-git add packages/core/src/native/remote.ts packages/core/src/native/service.ts packages/core/src/native.ts packages/core/test/native-remote.test.ts
-git commit -m "feat(core): add remote native Caplets service"
-```
-
----
-
-### Task 3: Add OpenCode second-argument config
-
-**Files:**
-
-- Modify: `packages/opencode/src/index.ts`
-- Test: `packages/opencode/test/opencode.test.ts`
-- Docs: `packages/opencode/README.md`
-
-- [ ] **Step 1: Write failing OpenCode config propagation test**
-
-Append this test to `packages/opencode/test/opencode.test.ts`:
-
-```ts
-it("passes second-argument config into the native service", async () => {
- vi.resetModules();
- const nativeMocks = vi.hoisted(() => ({
- createNativeCapletsService: vi.fn(() => ({
- listTools: () => [],
- execute: vi.fn(async () => ({})),
- reload: vi.fn(async () => true),
- onToolsChanged: vi.fn(() => () => {}),
- close: vi.fn(async () => {}),
- })),
- registerNativeCapletsProcessCleanup: vi.fn(),
- }));
- vi.doMock("@caplets/core/native", () => nativeMocks);
- const plugin = (await import("../src/index")).default;
-
- await plugin(
- {} as never,
- {
- mode: "remote",
- remote: {
- url: "https://caplets.example.com/mcp",
- user: "caplets",
- pollIntervalMs: 5_000,
- },
- } as never,
- );
-
- expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({
- mode: "remote",
- remote: {
- url: "https://caplets.example.com/mcp",
- user: "caplets",
- pollIntervalMs: 5_000,
- },
- });
-});
-```
-
-If this conflicts with the existing top-level `vi.mock("@caplets/core/native")`, instead add a new isolated test file `packages/opencode/test/opencode-config.test.ts` that mocks before importing `../src/index.js`.
-
-- [ ] **Step 2: Run OpenCode test to verify red**
-
-Run:
-
-```bash
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: FAIL because plugin ignores the second argument.
-
-- [ ] **Step 3: Update OpenCode plugin signature**
-
-Modify `packages/opencode/src/index.ts`:
-
-```ts
-import { type Plugin, type PluginInput } from "@opencode-ai/plugin";
-import {
- createNativeCapletsService,
- registerNativeCapletsProcessCleanup,
- type NativeCapletsServiceOptions,
-} from "@caplets/core/native";
-import { createCapletsOpenCodeHooks } from "./hooks";
-
-export type CapletsOpenCodeConfig = Pick;
-
-const plugin: Plugin = async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => {
- const service = createNativeCapletsService(normalizeOpenCodeConfig(config));
- registerNativeCapletsProcessCleanup(service);
- return createCapletsOpenCodeHooks(service);
-};
-
-function normalizeOpenCodeConfig(config: CapletsOpenCodeConfig | undefined): CapletsOpenCodeConfig {
- if (!config) {
- return {};
- }
- return {
- ...(config.mode ? { mode: config.mode } : {}),
- ...(config.remote ? { remote: config.remote } : {}),
- };
-}
-
-export default plugin;
-```
-
-If `Plugin` type does not allow the second argument, define the implementation separately and cast at export:
-
-```ts
-const plugin = (async (_ctx: PluginInput, config?: CapletsOpenCodeConfig) => { ... }) as Plugin;
-```
-
-- [ ] **Step 4: Document OpenCode remote config**
-
-Append to `packages/opencode/README.md`:
-
-````md
-## Remote Caplets service
-
-By default the plugin reads local Caplets config. To use a remote `caplets serve --transport http` service, set environment variables:
-
-```sh
-CAPLETS_REMOTE_URL=http://127.0.0.1:5387/mcp opencode
-```
-````
-
-For authenticated remote services, keep the password in the environment:
-
-```sh
-CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \
-CAPLETS_REMOTE_USER=caplets \
-CAPLETS_REMOTE_PASSWORD=... \
-opencode
-```
-
-OpenCode plugin config can also pass non-secret settings as the plugin factory's second argument:
-
-```ts
-export default {
- plugin: [
- [
- "@caplets/opencode",
- {
- mode: "remote",
- remote: {
- url: "https://caplets.example.com/mcp",
- user: "caplets",
- },
- },
- ],
- ],
-};
-```
-
-Plugin config overrides environment variables. Prefer `CAPLETS_REMOTE_PASSWORD` for the Basic Auth password unless your OpenCode setup provides secure secret storage.
-
-````md
-- [ ] **Step 5: Run OpenCode tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-pnpm --filter @caplets/opencode typecheck
-```
-````
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit OpenCode config support**
-
-Run:
-
-```bash
-git add packages/opencode/src/index.ts packages/opencode/test/opencode.test.ts packages/opencode/README.md
-git commit -m "feat(opencode): accept remote Caplets config"
-```
-
----
-
-### Task 4: Add Pi args/native option support
-
-**Files:**
-
-- Modify: `packages/pi/src/index.ts`
-- Test: `packages/pi/test/pi.test.ts`
-- Docs: `packages/pi/README.md`
-
-- [ ] **Step 1: Write failing Pi args propagation test**
-
-Add this test near the existing service-creation tests in `packages/pi/test/pi.test.ts`:
-
-```ts
-it("passes Pi args into the native service", () => {
- const service = mockService([]);
- nativeMocks.createNativeCapletsService.mockReturnValueOnce(service);
- const pi = mockPiApi();
-
- capletsPiExtension(pi, {
- args: {
- mode: "remote",
- remote: {
- url: "https://caplets.example.com/mcp",
- user: "caplets",
- pollIntervalMs: 5_000,
- },
- },
- });
-
- expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({
- mode: "remote",
- remote: {
- url: "https://caplets.example.com/mcp",
- user: "caplets",
- pollIntervalMs: 5_000,
- },
- });
-});
-```
-
-If the existing `CapletsPiOptions` type rejects `args`, the test should fail at typecheck until implementation.
-
-- [ ] **Step 2: Run Pi test to verify red**
-
-Run:
-
-```bash
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-```
-
-Expected: FAIL because `args` is not part of `CapletsPiOptions` and service creation ignores it.
-
-- [ ] **Step 3: Update Pi option types and service creation**
-
-Modify `packages/pi/src/index.ts` imports:
-
-```ts
-import {
- createNativeCapletsService,
- registerNativeCapletsProcessCleanup,
- type NativeCapletTool,
- type NativeCapletsService,
- type NativeCapletsServiceOptions,
-} from "@caplets/core/native";
-```
-
-Replace `CapletsPiOptions` with:
-
-```ts
-export type CapletsPiOptions = {
- service?: NativeCapletsService;
- native?: Pick;
- args?: Pick;
-};
-```
-
-Replace service creation with:
-
-```ts
-const ownsService = !options.service;
-const serviceOptions = options.native ?? options.args ?? {};
-const service = options.service ?? createNativeCapletsService(serviceOptions);
-```
-
-Do not change behavior when `service` is injected; tests and advanced users rely on that seam.
-
-- [ ] **Step 4: Document Pi settings args**
-
-Append to `packages/pi/README.md`:
-
-````md
-## Remote Caplets service
-
-By default the extension reads local Caplets config. To use a remote `caplets serve --transport http` service, set environment variables:
-
-```sh
-CAPLETS_REMOTE_URL=http://127.0.0.1:5387/mcp pi
-```
-````
-
-For authenticated remote services, keep the password in the environment:
-
-```sh
-CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \
-CAPLETS_REMOTE_USER=caplets \
-CAPLETS_REMOTE_PASSWORD=... \
-pi
-```
-
-You can also pass non-secret remote settings through Pi package args in your Pi user settings file. Current Pi docs use `~/.pi/agent/settings.json`; use your runtime's active settings path if it differs:
-
-```json
-{
- "packages": [
- {
- "source": "npm:@caplets/pi",
- "args": {
- "mode": "remote",
- "remote": {
- "url": "https://caplets.example.com/mcp",
- "user": "caplets"
- }
- }
- }
- ]
-}
-```
-
-Package args override environment variables. Prefer `CAPLETS_REMOTE_PASSWORD` for the Basic Auth password unless your Pi setup provides secure secret storage.
-
-````md
-- [ ] **Step 5: Run Pi tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-pnpm --filter @caplets/pi typecheck
-```
-````
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit Pi args support**
-
-Run:
-
-```bash
-git add packages/pi/src/index.ts packages/pi/test/pi.test.ts packages/pi/README.md
-git commit -m "feat(pi): accept remote Caplets args"
-```
-
----
-
-### Task 5: Document Codex and Claude remote MCP configuration
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `plugins/caplets/skills/caplets/SKILL.md`
-- Modify: `packages/core/test/agent-plugins.test.ts` if needed
-
-- [ ] **Step 1: Add failing docs assertion if appropriate**
-
-If `packages/core/test/agent-plugins.test.ts` already checks README/plugin guidance, add a test asserting the root README includes `CAPLETS_REMOTE_URL` and `caplets serve --transport http`. Use this pattern:
-
-```ts
-it("documents remote Caplets service configuration for MCP-backed plugins", () => {
- const readme = readFileSync(path.join(repoRoot, "README.md"), "utf8");
- expect(readme).toContain("CAPLETS_REMOTE_URL");
- expect(readme).toContain("caplets serve --transport http");
- expect(readme).toContain("https://caplets.example.com/mcp");
-});
-```
-
-If that test file is not a good fit, skip this test and rely on docs review plus `pnpm test`.
-
-- [ ] **Step 2: Update root README Agent Plugins section**
-
-In `README.md`, after the existing paragraph ending with “install the Caplets CLI globally first,” add:
-
-````md
-### Remote Caplets service
-
-OpenCode and Pi can use native `caplets_` tools backed by a remote Caplets HTTP service. Codex, Claude Code, and any MCP client can connect to the same remote MCP endpoint directly.
-
-Start the remote service:
-
-```sh
-caplets serve --transport http --host 127.0.0.1 --port 5387 --path /mcp
-```
-````
-
-For authenticated network use, configure Basic Auth on the server and keep credentials out of plugin manifests:
-
-```sh
-CAPLETS_SERVER_PASSWORD=... caplets serve --transport http --host 0.0.0.0
-```
-
-Native integrations read remote client settings from environment variables:
-
-```sh
-CAPLETS_REMOTE_URL=https://caplets.example.com/mcp \
-CAPLETS_REMOTE_USER=caplets \
-CAPLETS_REMOTE_PASSWORD=... \
-opencode
-```
-
-For MCP-backed Codex or Claude Code configs, point the agent's MCP server entry at the remote URL using that agent's supported HTTP MCP configuration. If Basic Auth is needed, use the agent's secure secret or environment interpolation mechanism rather than hardcoding credentials.
-
-````md
-- [ ] **Step 3: Update Caplets skill guidance**
-
-In `plugins/caplets/skills/caplets/SKILL.md`, add one bullet under “Guidance”:
-
-```md
-- When Caplets is configured as a remote MCP HTTP service, treat connection/auth failures as remote-service issues and ask the user to verify `CAPLETS_REMOTE_URL`, Basic Auth credentials, and that `caplets serve --transport http` is running.
-```
-````
-
-- [ ] **Step 4: Run docs/plugin tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/agent-plugins.test.ts
-pnpm format:check
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit remote MCP documentation**
-
-Run:
-
-```bash
-git add README.md plugins/caplets/skills/caplets/SKILL.md packages/core/test/agent-plugins.test.ts
-git commit -m "docs: document remote Caplets service connections"
-```
-
-If `agent-plugins.test.ts` was not modified, omit it from `git add`.
-
----
-
-### Task 6: Add changeset
-
-**Files:**
-
-- Create: `.changeset/.md`
-
-- [ ] **Step 1: Create changeset file**
-
-Create a changeset such as `.changeset/remote-native-caplets.md`:
-
-```md
----
-"@caplets/core": minor
-"@caplets/opencode": minor
-"@caplets/pi": minor
-"caplets": patch
----
-
-Add remote Caplets service support for native integrations, including remote-backed OpenCode and Pi native tools plus documentation for MCP-backed Codex and Claude Code remote connections.
-```
-
-- [ ] **Step 2: Check changeset status**
-
-Run:
-
-```bash
-pnpm changeset status --since=origin/main
-```
-
-Expected: output includes minor bumps for `@caplets/core`, `@caplets/opencode`, and `@caplets/pi`. `caplets` may appear as patch or as an internal dependent bump depending on Changesets dependency analysis.
-
-- [ ] **Step 3: Commit changeset**
-
-Run:
-
-```bash
-git add .changeset/remote-native-caplets.md
-git commit -m "chore: add changeset for remote native integrations"
-```
-
----
-
-### Task 7: Full verification
-
-**Files:**
-
-- No source files unless verification reveals issues.
-
-- [ ] **Step 1: Run focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts test/process-cleanup.test.ts test/agent-plugins.test.ts
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 2: Run typecheck**
-
-Run:
-
-```bash
-pnpm typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 3: Run all tests**
-
-Run:
-
-```bash
-pnpm test
-```
-
-Expected: PASS.
-
-- [ ] **Step 4: Run full gate**
-
-Run:
-
-```bash
-pnpm verify
-```
-
-Expected: PASS through format, lint, typecheck, schema check, tests, benchmark check, and build.
-
-- [ ] **Step 5: Commit verification fixes if any**
-
-If verification required source changes, review the working tree and stage only the files changed by those fixes:
-
-```bash
-git status --short
-git add packages/core/src/native/options.ts packages/core/src/native/remote.ts packages/core/src/native/service.ts packages/core/src/native.ts packages/opencode/src/index.ts packages/pi/src/index.ts README.md packages/opencode/README.md packages/pi/README.md plugins/caplets/skills/caplets/SKILL.md
-git commit -m "fix: polish remote native integration support"
-```
-
-If some listed files were not changed, `git add` will still succeed for tracked files. If verification fixes touched tests or a changeset, add those concrete changed paths too after checking `git status --short`. If no fixes were needed, do not create an empty commit.
-
----
-
-## Self-review notes
-
-- Spec coverage: Tasks 1 and 2 cover core remote option resolution, Basic Auth, URL validation, remote MCP client use, refresh notifications, polling fallback, last known-good behavior, and cleanup. Tasks 3 and 4 cover OpenCode second-argument config and Pi settings/package args. Task 5 covers Codex/Claude MCP-backed remote guidance. Task 6 covers release metadata. Task 7 covers verification.
-- Red-flag scan: no incomplete implementation steps are intentionally left. Conditional notes are limited to concrete SDK/host typing compatibility paths with stable public interfaces preserved.
-- Type consistency: `NativeCapletsServiceOptions`, `NativeRemoteCapletsOptions`, `RemoteCapletsClient`, `RemoteCapletsClientFactory`, and `RemoteNativeCapletsService` names are introduced before later tasks consume them.
diff --git a/docs/plans/2026-05-19-token-efficiency.md b/docs/plans/2026-05-19-token-efficiency.md
deleted file mode 100644
index e912bd33..00000000
--- a/docs/plans/2026-05-19-token-efficiency.md
+++ /dev/null
@@ -1,1541 +0,0 @@
-# Token-Efficient Caplets Interface Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use subagent-driven-development (recommended) or executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Reduce redundant token output across Caplets discovery, invocation, native Pi/OpenCode adapters, and benchmarks while preserving machine-readable results and compatibility with MCP-shaped tool responses.
-
-**Architecture:** Centralize result serialization decisions in small core helpers, then update each backend and adapter to avoid echoing the same payload in both human text and structured data. Keep detailed structured objects available in `structuredContent`/adapter `details`, but make visible `content` concise, purposeful, and bounded by default. Add tests that assert redundant JSON payloads are not emitted and benchmarks that measure runtime/discovery result surfaces, not only initial tool metadata.
-
-**Tech Stack:** TypeScript, MCP SDK `CallToolResult`, Zod, Vitest, pnpm, existing Caplets core/pi/opencode packages.
-
----
-
-## Scope And Constraints
-
-- Do not remove `structuredContent`; agents and adapters rely on it for reliable machine-readable data.
-- Keep MCP result shape valid by continuing to return a `content` array, but make it empty or minimal for structured-only operations where tests confirm the SDK accepts it.
-- Preserve full downstream text content for MCP-backed `call_tool` unless a new explicit compacting layer applies at the Pi/OpenCode adapter boundary.
-- Avoid changing external downstream tool behavior. Caplets should not silently mutate downstream MCP `content`, except where Caplets itself generated redundant JSON text for HTTP/OpenAPI/GraphQL/CLI/field-selection/error results.
-- Keep CLI default human summaries useful; JSON mode can remain complete because users explicitly request machine output from the CLI.
-- Use `pnpm` only.
-
-## File Structure
-
-- Modify `packages/core/src/tools.ts`
- - Replace `jsonResult()` placeholder text with compact structured-only content.
- - Replace field-selection text duplication with a compact summary.
- - Keep metadata extraction and annotation behavior intact.
-- Create `packages/core/src/result-content.ts`
- - Own shared helpers for compact MCP content blocks: structured-only notices, compact JSON summaries, and bounded text previews.
-- Modify `packages/core/src/errors.ts`
- - Use compact error text while keeping `structuredContent.error` complete.
-- Modify `packages/core/src/http-actions.ts`
- - Return compact generated text for HTTP action results instead of pretty-printing the full structured object.
-- Modify `packages/core/src/openapi.ts`
- - Return compact generated text for OpenAPI action results instead of pretty-printing the full structured object.
-- Modify `packages/core/src/graphql.ts`
- - Return compact generated text for GraphQL action results instead of pretty-printing the full structured object.
-- Modify `packages/core/src/cli-tools.ts`
- - Return compact generated text for CLI action results instead of pretty-printing stdout/stderr structured object.
-- Modify `packages/core/src/capability-description.ts`
- - Split long repeated workflow guidance from per-tool capability description.
-- Modify `packages/core/src/native/tools.ts`
- - Move repeated workflow guidance into one global system guidance block and make per-tool prompt guidance one line.
-- Modify `packages/core/src/runtime.ts`
- - Use concise per-tool MCP descriptions and rely on generated input schema for operation details.
-- Modify `packages/core/src/generated-tool-input-schema.ts`
- - Shorten schema field descriptions while retaining critical constraints.
-- Modify `packages/core/src/downstream.ts`, `packages/core/src/http-actions.ts`, `packages/core/src/openapi.ts`, `packages/core/src/graphql.ts`, `packages/core/src/cli-tools.ts`, `packages/core/src/caplet-sets.ts`
- - Make `compact()` truly compact by default and reserve verbose annotations/schema hashes for `get_tool` or a new optional discovery verbosity flag.
-- Modify `packages/core/src/registry.ts`
- - Reduce `get_caplet` detail duplication and do not include full Markdown body by default.
-- Modify `packages/pi/src/index.ts`
- - Stop serializing the full result into visible tool `content`; store it in `details.result` and produce a short preview string.
-- Modify `packages/opencode/src/hooks.ts`
- - Return short human text for structured Caplets results; stringify full result only when no structured summary is available.
-- Modify tests:
- - `packages/core/test/tools.test.ts`
- - `packages/core/test/http-actions.test.ts`
- - `packages/core/test/openapi.test.ts`
- - `packages/core/test/graphql.test.ts`
- - `packages/core/test/cli-tools.test.ts`
- - `packages/core/test/registry.test.ts`
- - `packages/core/test/downstream.test.ts`
- - `packages/pi/test/pi.test.ts`
- - `packages/opencode/test/opencode.test.ts`
-- Modify benchmarks:
- - `packages/benchmarks/lib/surface.ts`
- - `packages/benchmarks/test/benchmark.test.ts`
- - `docs/benchmarks/coding-agent.md` via `pnpm benchmark`
-
----
-
-### Task 1: Add Shared Compact Result Content Helpers
-
-**Files:**
-
-- Create: `packages/core/src/result-content.ts`
-- Test: `packages/core/test/tools.test.ts`
-
-- [ ] **Step 1: Create failing tests for compact structured-only results**
-
-Add these test cases to `packages/core/test/tools.test.ts` near the existing `jsonResult` tests:
-
-```ts
-it("returns structured-only discovery results without duplicating the payload as text", () => {
- const result = jsonResult({
- server: "browser",
- tools: [{ tool: "browser_click" }],
- });
-
- expect(result.content).toEqual([]);
- expect(result.structuredContent).toEqual({
- result: { server: "browser", tools: [{ tool: "browser_click" }] },
- });
- expect(JSON.stringify(result.content)).not.toContain("browser_click");
-});
-
-it("returns metadata in structured discovery results without human placeholder text", async () => {
- const registry = registryForBrowserAndStealth();
- const result = await handleServerTool(
- registry.require("browser"),
- { operation: "list_tools" },
- registry,
- mockDownstream([{ name: "browser_click", inputSchema: { type: "object" } }]),
- );
-
- expect(result.content).toEqual([]);
- expect(result.structuredContent?.caplets).toMatchObject({
- caplet: "browser",
- name: "Browser",
- operation: "list_tools",
- });
- expect(result.structuredContent?.result).toMatchObject({
- server: "browser",
- tools: [{ tool: "browser_click" }],
- });
-});
-```
-
-If helper names in the current test file differ, reuse the existing local registry/downstream setup already used by nearby tests instead of creating new fixtures.
-
-- [ ] **Step 2: Run the focused test and verify it fails**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: FAIL because `jsonResult()` currently returns a text content block containing `result available in structuredContent.result`.
-
-- [ ] **Step 3: Create `packages/core/src/result-content.ts`**
-
-Write this file:
-
-```ts
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types";
-
-export type TextContentBlock = { type: "text"; text: string };
-
-export function structuredOnlyContent(): [] {
- return [];
-}
-
-export function textContent(text: string): TextContentBlock[] {
- return text ? [{ type: "text", text }] : [];
-}
-
-export function compactJsonText(value: unknown, maxLength = 600): string {
- return compactText(JSON.stringify(value), maxLength);
-}
-
-export function compactText(value: string, maxLength = 600): string {
- const collapsed = value.replace(/\s+/gu, " ").trim();
- return collapsed.length > maxLength
- ? `${collapsed.slice(0, maxLength - 1).trimEnd()}…`
- : collapsed;
-}
-
-export function resultKeys(value: unknown): string {
- if (!value || typeof value !== "object" || Array.isArray(value)) {
- return "scalar result";
- }
- const keys = Object.keys(value).filter((key) => key !== "elapsedMs");
- return keys.length > 0 ? `structured keys: ${keys.join(", ")}` : "empty structured result";
-}
-
-export function statusSummary(value: unknown): string {
- if (!value || typeof value !== "object" || Array.isArray(value)) {
- return compactJsonText(value);
- }
- const record = value as Record;
- const status = typeof record.status === "number" ? `status ${record.status}` : undefined;
- const statusText =
- typeof record.statusText === "string" && record.statusText ? record.statusText : undefined;
- const exitCode = typeof record.exitCode === "number" ? `exit ${record.exitCode}` : undefined;
- const body = "body" in record ? "body" : undefined;
- const json = "json" in record ? "json" : undefined;
- const stdout = typeof record.stdout === "string" && record.stdout ? "stdout" : undefined;
- const stderr = typeof record.stderr === "string" && record.stderr ? "stderr" : undefined;
- return (
- [status, statusText, exitCode, body, json, stdout, stderr]
- .filter((part): part is string => Boolean(part))
- .join("; ") || resultKeys(record)
- );
-}
-
-export function compactStructuredContent(value: unknown): TextContentBlock[] {
- return textContent(statusSummary(value));
-}
-
-export function compactCallToolResultContent(result: CallToolResult): TextContentBlock[] {
- if (result.isError === true) {
- return textContent("downstream tool returned an error");
- }
- return compactStructuredContent(result.structuredContent);
-}
-```
-
-- [ ] **Step 4: Update `jsonResult()` to use structured-only content**
-
-In `packages/core/src/tools.ts`, import the helper:
-
-```ts
-import { structuredOnlyContent } from "./result-content";
-```
-
-Replace `jsonResult()` with:
-
-```ts
-export function jsonResult(value: unknown, metadata?: CapletResultMetadata): CallToolResult {
- return {
- content: structuredOnlyContent(),
- structuredContent: {
- ...(metadata === undefined ? {} : { caplets: metadata }),
- result: value as Record,
- },
- };
-}
-```
-
-- [ ] **Step 5: Run focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: PASS for the new tests, with failures only where old tests still expect placeholder text.
-
-- [ ] **Step 6: Update old placeholder expectations**
-
-In `packages/core/test/tools.test.ts`, replace expectations like:
-
-```ts
-expect(result.content).toEqual([
- { type: "text", text: "Result available in structuredContent.result." },
-]);
-```
-
-with:
-
-```ts
-expect(result.content).toEqual([]);
-```
-
-For Caplet-specific list tests, replace expectations like:
-
-```ts
-expect(browser.content).toEqual([
- {
- type: "text",
- text: "Browser list_tools result available in structuredContent.result.",
- },
-]);
-```
-
-with:
-
-```ts
-expect(browser.content).toEqual([]);
-```
-
-- [ ] **Step 7: Run focused tests again**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: PASS.
-
----
-
-### Task 2: Remove Generated JSON Text Duplication From Non-MCP Backends
-
-**Files:**
-
-- Modify: `packages/core/src/http-actions.ts`
-- Modify: `packages/core/src/openapi.ts`
-- Modify: `packages/core/src/graphql.ts`
-- Modify: `packages/core/src/cli-tools.ts`
-- Test: `packages/core/test/http-actions.test.ts`
-- Test: `packages/core/test/openapi.test.ts`
-- Test: `packages/core/test/graphql.test.ts`
-- Test: `packages/core/test/cli-tools.test.ts`
-
-- [ ] **Step 1: Add focused assertions that generated text is compact**
-
-In each backend test file, update one successful call assertion to check that `content[0].text` is not the full pretty JSON.
-
-For HTTP/OpenAPI/GraphQL tests, use this pattern:
-
-```ts
-expect(result.structuredContent).toMatchObject({
- status: 200,
- body: { ok: true },
-});
-expect(result.content).toEqual([{ type: "text", text: "status 200; OK; body" }]);
-expect(result.content[0]?.text).not.toContain('"body"');
-```
-
-For CLI tests, use this pattern:
-
-```ts
-expect(result.structuredContent).toMatchObject({
- exitCode: 0,
- stdout: "hello\n",
-});
-expect(result.content).toEqual([{ type: "text", text: "exit 0; stdout" }]);
-expect(result.content[0]?.text).not.toContain('"stdout"');
-```
-
-Use the actual status text in existing test responses. If the test server returns an empty status text, expect `status 200; body` instead.
-
-- [ ] **Step 2: Run backend focused tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/http-actions.test.ts test/openapi.test.ts test/graphql.test.ts test/cli-tools.test.ts
-```
-
-Expected: FAIL because these backends currently set `content[0].text` to `JSON.stringify(structured, null, 2)`.
-
-- [ ] **Step 3: Update backend imports**
-
-Add this import to each modified backend file:
-
-```ts
-import { compactStructuredContent } from "./result-content";
-```
-
-- [ ] **Step 4: Replace duplicated JSON content in HTTP actions**
-
-In `packages/core/src/http-actions.ts`, replace:
-
-```ts
-return {
- content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }],
- structuredContent: parsed,
- isError: !response.ok,
-};
-```
-
-with:
-
-```ts
-return {
- content: compactStructuredContent(parsed),
- structuredContent: parsed,
- isError: !response.ok,
-};
-```
-
-- [ ] **Step 5: Replace duplicated JSON content in OpenAPI**
-
-In `packages/core/src/openapi.ts`, replace:
-
-```ts
-return {
- content: [
- {
- type: "text",
- text: JSON.stringify(parsed, null, 2),
- },
- ],
- structuredContent: parsed as Record,
- isError: response.ok ? false : true,
-};
-```
-
-with:
-
-```ts
-return {
- content: compactStructuredContent(parsed),
- structuredContent: parsed as Record,
- isError: response.ok ? false : true,
-};
-```
-
-- [ ] **Step 6: Replace duplicated JSON content in GraphQL**
-
-In `packages/core/src/graphql.ts`, replace:
-
-```ts
-return {
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
- structuredContent: result,
- isError:
- !response.ok ||
- Boolean(body && typeof body === "object" && "errors" in body && (body as any).errors),
-};
-```
-
-with:
-
-```ts
-return {
- content: compactStructuredContent(result),
- structuredContent: result,
- isError:
- !response.ok ||
- Boolean(body && typeof body === "object" && "errors" in body && (body as any).errors),
-};
-```
-
-- [ ] **Step 7: Replace duplicated JSON content in CLI tools**
-
-In `packages/core/src/cli-tools.ts`, replace:
-
-```ts
-return {
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
- structuredContent: structured,
- isError: result.exitCode !== 0,
-};
-```
-
-with:
-
-```ts
-return {
- content: compactStructuredContent(structured),
- structuredContent: structured,
- isError: result.exitCode !== 0,
-};
-```
-
-- [ ] **Step 8: Run backend focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/http-actions.test.ts test/openapi.test.ts test/graphql.test.ts test/cli-tools.test.ts
-```
-
-Expected: PASS after updating exact expected compact strings.
-
----
-
-### Task 3: Make Field Selection And Error Results Compact
-
-**Files:**
-
-- Modify: `packages/core/src/tools.ts`
-- Modify: `packages/core/src/errors.ts`
-- Test: `packages/core/test/tools.test.ts`
-- Test: `packages/core/test/redaction.test.ts`
-
-- [ ] **Step 1: Add failing tests for projected content**
-
-In `packages/core/test/tools.test.ts`, update field-selection tests that currently expect pretty JSON text. Replace assertions like:
-
-```ts
-expect(result.content).toEqual([{ type: "text", text: '{\n "message": "ok"\n}' }]);
-```
-
-with:
-
-```ts
-expect(result.content).toEqual([{ type: "text", text: "structured keys: message" }]);
-expect(result.structuredContent).toEqual({ message: "ok" });
-```
-
-For nested body projection, use:
-
-```ts
-expect(result.content).toEqual([{ type: "text", text: "structured keys: body" }]);
-expect(result.structuredContent).toEqual({ body: { name: "Ada" } });
-```
-
-- [ ] **Step 2: Add failing test for compact error content**
-
-In `packages/core/test/tools.test.ts`, add:
-
-```ts
-it("returns compact error text while preserving structured error details", () => {
- const result = errorResult(new CapletsError("REQUEST_INVALID", "Bad input", { field: "query" }));
-
- expect(result.content).toEqual([{ type: "text", text: "REQUEST_INVALID: Bad input" }]);
- expect(result.structuredContent).toEqual({
- error: {
- code: "REQUEST_INVALID",
- message: "Bad input",
- details: { field: "query" },
- },
- });
-});
-```
-
-Import `CapletsError` and `errorResult` if the file does not already import them.
-
-- [ ] **Step 3: Run focused tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/tools.test.ts test/redaction.test.ts
-```
-
-Expected: FAIL because projected and error content currently contains full JSON text.
-
-- [ ] **Step 4: Update `projectCallToolResult()`**
-
-In `packages/core/src/tools.ts`, import:
-
-```ts
-import { compactStructuredContent, structuredOnlyContent } from "./result-content";
-```
-
-If `structuredOnlyContent` is already imported from Task 1, add only `compactStructuredContent`.
-
-Replace this block in `projectCallToolResult()`:
-
-```ts
-return {
- ...result,
- content: [
- {
- type: "text",
- text: JSON.stringify(projected, null, 2),
- },
- ],
- structuredContent: projected,
-} as T & CallToolResult;
-```
-
-with:
-
-```ts
-return {
- ...result,
- content: compactStructuredContent(projected),
- structuredContent: projected,
-} as T & CallToolResult;
-```
-
-- [ ] **Step 5: Update `errorResult()`**
-
-In `packages/core/src/errors.ts`, replace:
-
-```ts
-export function errorResult(error: unknown, fallback?: CapletsErrorCode) {
- return {
- isError: true,
- content: [
- {
- type: "text" as const,
- text: JSON.stringify(toSafeError(error, fallback), null, 2),
- },
- ],
- structuredContent: {
- error: toSafeError(error, fallback),
- },
- };
-}
-```
-
-with:
-
-```ts
-export function errorResult(error: unknown, fallback?: CapletsErrorCode) {
- const safe = toSafeError(error, fallback);
- return {
- isError: true,
- content: [
- {
- type: "text" as const,
- text: `${safe.code}: ${safe.message}`,
- },
- ],
- structuredContent: {
- error: safe,
- },
- };
-}
-```
-
-- [ ] **Step 6: Run focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/tools.test.ts test/redaction.test.ts
-```
-
-Expected: PASS after updating old JSON-text expectations.
-
----
-
-### Task 4: Reduce Repeated Tool Description And Prompt Guidance
-
-**Files:**
-
-- Modify: `packages/core/src/capability-description.ts`
-- Modify: `packages/core/src/native/tools.ts`
-- Modify: `packages/core/src/runtime.ts`
-- Test: `packages/core/test/registry.test.ts`
-- Test: `packages/core/test/runtime.test.ts`
-- Test: `packages/pi/test/pi.test.ts`
-- Test: `packages/opencode/test/opencode.test.ts`
-
-- [ ] **Step 1: Add target tests for shorter descriptions**
-
-In `packages/core/test/registry.test.ts`, replace assertions that require full repeated workflow text with:
-
-```ts
-expect(description).toContain("Enabled Caplet");
-expect(description).toContain("Use get_caplet for details when needed.");
-expect(description).not.toContain("Recommended flow:");
-expect(description).not.toContain("After get_tool shows outputSchema");
-```
-
-In Pi/OpenCode tests that inspect descriptions, add:
-
-```ts
-expect(registered[0]?.description.length).toBeLessThan(350);
-expect(registered[0]?.description).not.toContain("Recommended flow:");
-```
-
-- [ ] **Step 2: Run focused tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/registry.test.ts test/runtime.test.ts
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: FAIL because descriptions currently include the repeated progressive-discovery workflow.
-
-- [ ] **Step 3: Replace `capabilityDescription()` with a concise per-tool description**
-
-In `packages/core/src/capability-description.ts`, replace the current function with:
-
-```ts
-import type { CapletConfig } from "./config";
-
-export function capabilityDescription(server: CapletConfig): string {
- return [
- `${server.name} Caplet.`,
- server.description,
- "Use get_caplet for details when needed; use search_tools or list_tools to discover downstream operations.",
- ]
- .filter(Boolean)
- .join(" ");
-}
-```
-
-- [ ] **Step 4: Centralize the long workflow in global native guidance only**
-
-In `packages/core/src/native/tools.ts`, replace `nativeCapletPromptGuidance()` with:
-
-```ts
-export function nativeCapletPromptGuidance(toolName: string, caplet: CapletConfig): string[] {
- return [`Use ${toolName} for the ${caplet.name} Caplet capability domain.`];
-}
-```
-
-Keep `nativeCapletsSystemGuidance()` as the single location for the detailed workflow, but shorten it by replacing the current numbered list with:
-
-```ts
-return [
- "## Caplets Native Tools",
- "",
- "Caplets tools expose configured capability domains through progressive discovery.",
- "",
- "Available Caplets native tools:",
- tools,
- "",
- "Flow: get_caplet when the domain is unfamiliar; search_tools or list_tools to find exact downstream names; get_tool only when schemas are unclear; call_tool with downstream inputs inside arguments.",
- "Use fields on call_tool when a non-GraphQL downstream outputSchema allows selecting only needed structured paths.",
-].join("\n");
-```
-
-- [ ] **Step 5: Keep runtime descriptions concise**
-
-No separate implementation is needed in `packages/core/src/runtime.ts` if it imports `capabilityDescription()`. Verify registered tool descriptions become concise through existing calls at `registerCapletTool()` and `tool.update()`.
-
-- [ ] **Step 6: Update tests that expected old prose**
-
-Update any failing assertions that contain exact old workflow strings. Use concise expectations:
-
-```ts
-expect(description).toContain("Use get_caplet for details when needed");
-expect(description).not.toContain("Recommended flow:");
-```
-
-- [ ] **Step 7: Run focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/registry.test.ts test/runtime.test.ts
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: PASS.
-
----
-
-### Task 5: Shorten Generated Input Schema Descriptions
-
-**Files:**
-
-- Modify: `packages/core/src/generated-tool-input-schema.ts`
-- Test: `packages/core/test/tools.test.ts`
-- Test: `packages/pi/test/pi.test.ts`
-- Test: `packages/opencode/test/opencode.test.ts`
-
-- [ ] **Step 1: Add schema-size regression test**
-
-In `packages/core/test/tools.test.ts`, add:
-
-```ts
-it("keeps generated Caplets wrapper input schema descriptions compact", () => {
- const schema = generatedToolInputJsonSchema();
- const serialized = JSON.stringify(schema);
- const descriptionBytes = Object.values(schema.properties).reduce(
- (total, property) =>
- total +
- Buffer.byteLength(typeof property.description === "string" ? property.description : ""),
- 0,
- );
-
- expect(Buffer.byteLength(serialized)).toBeLessThan(1200);
- expect(descriptionBytes).toBeLessThan(700);
-});
-```
-
-Import `generatedToolInputJsonSchema` if needed.
-
-- [ ] **Step 2: Run focused tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: FAIL because current schema descriptions are about 1.3 KB by themselves.
-
-- [ ] **Step 3: Replace verbose generated descriptions**
-
-In `packages/core/src/generated-tool-input-schema.ts`, replace `generatedToolInputDescriptions` with:
-
-```ts
-export const generatedToolInputDescriptions = {
- operation:
- "Wrapper operation: get_caplet, check_backend, list_tools, search_tools, get_tool, or call_tool.",
- query: "Required for search_tools only.",
- limit: "Optional search_tools result limit.",
- tool: "Exact downstream tool name for get_tool or call_tool.",
- arguments: "Required JSON object for call_tool downstream inputs.",
- fields: "Optional call_tool structured output paths when outputSchema allows it.",
-} as const;
-```
-
-- [ ] **Step 4: Update tests with old exact description expectations**
-
-Replace expectations containing the old examples with compact expectations:
-
-```ts
-expect(schema.properties.operation.description).toContain("Wrapper operation");
-expect(schema.properties.arguments.description).toContain("downstream inputs");
-```
-
-- [ ] **Step 5: Run adapter tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/tools.test.ts
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: PASS.
-
----
-
-### Task 6: Make `list_tools` Compact By Default
-
-**Files:**
-
-- Modify: `packages/core/src/downstream.ts`
-- Modify: `packages/core/src/http-actions.ts`
-- Modify: `packages/core/src/openapi.ts`
-- Modify: `packages/core/src/graphql.ts`
-- Modify: `packages/core/src/cli-tools.ts`
-- Modify: `packages/core/src/caplet-sets.ts`
-- Test: `packages/core/test/downstream.test.ts`
-- Test: `packages/core/test/tools.test.ts`
-- Test: backend tests listed in Task 2
-
-- [ ] **Step 1: Add compact-list assertions**
-
-In `packages/core/test/downstream.test.ts`, update compact tool expectations to:
-
-```ts
-expect(first).toEqual({
- server: "alpha",
- tool: "example_tool",
- description: "Example tool description.",
- hasInputSchema: true,
- hasOutputSchema: false,
-});
-expect(first).not.toHaveProperty("annotations");
-expect(first).not.toHaveProperty("inputSchemaHash");
-expect(first).not.toHaveProperty("outputSchemaHash");
-```
-
-In `packages/core/test/tools.test.ts`, update list-tools expected output similarly.
-
-- [ ] **Step 2: Run focused tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/downstream.test.ts test/tools.test.ts
-```
-
-Expected: FAIL because compact tools currently include annotations and schema hashes.
-
-- [ ] **Step 3: Update `CompactTool` type**
-
-In `packages/core/src/downstream.ts`, replace:
-
-```ts
-export type CompactTool = {
- server: string;
- tool: string;
- description?: string;
- annotations?: unknown;
- hasInputSchema: boolean;
- hasOutputSchema: boolean;
- inputSchemaHash: string | null;
- outputSchemaHash: string | null;
-};
-```
-
-with:
-
-```ts
-export type CompactTool = {
- server: string;
- tool: string;
- description?: string;
- hasInputSchema: boolean;
- hasOutputSchema: boolean;
-};
-```
-
-- [ ] **Step 4: Replace all compact implementations**
-
-In each manager `compact()` method, replace the return object with this shape:
-
-```ts
-return {
- server: server.server,
- tool: tool.name,
- ...(tool.description ? { description: tool.description } : {}),
- hasInputSchema: Boolean(tool.inputSchema),
- hasOutputSchema: Boolean(tool.outputSchema),
-};
-```
-
-Use the local config variable name in each file:
-
-- `server.server` in `DownstreamManager`
-- `endpoint.server` in `OpenApiManager` and `GraphQLManager`
-- `api.server` in `HttpActionManager`
-- `config.server` in `CliToolsManager` and `CapletSetManager`
-
-- [ ] **Step 5: Remove now-unused `schemaHash` imports**
-
-Remove `import { schemaHash } from "./schema-hash";` from files where it only supported compact list hashes.
-
-- [ ] **Step 6: Run focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/downstream.test.ts test/tools.test.ts test/http-actions.test.ts test/openapi.test.ts test/graphql.test.ts test/cli-tools.test.ts test/caplet-sets.test.ts
-```
-
-Expected: PASS after updating expected compact tool objects.
-
----
-
-### Task 7: Reduce `get_caplet` Detail Duplication
-
-**Files:**
-
-- Modify: `packages/core/src/registry.ts`
-- Test: `packages/core/test/registry.test.ts`
-- Test: `packages/core/test/tools.test.ts`
-
-- [ ] **Step 1: Add concise `get_caplet` tests**
-
-In `packages/core/test/registry.test.ts`, add or update assertions:
-
-```ts
-const detail = registry.detail(config.mcpServers.enabled!);
-expect(detail).toMatchObject({
- caplet: "enabled",
- name: "Enabled",
- description: "Enabled Caplet description.",
- backend: {
- type: "mcp",
- transport: "stdio",
- disabled: false,
- },
-});
-expect(detail).not.toHaveProperty("mcpServer");
-expect(detail).not.toHaveProperty("body");
-```
-
-If an existing fixture expects a body, assert that `body` is not included by default.
-
-- [ ] **Step 2: Run focused tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/registry.test.ts test/tools.test.ts
-```
-
-Expected: FAIL because MCP detail currently duplicates backend data in `mcpServer`, and Markdown body may be included.
-
-- [ ] **Step 3: Remove `mcpServer` from the public detail type**
-
-In `packages/core/src/registry.ts`, remove the `mcpServer?: { ... }` field from `CapletServerDetail`.
-
-- [ ] **Step 4: Remove default body and MCP duplication from `detail()`**
-
-Replace the `detail()` return object with:
-
-```ts
-return {
- caplet: server.server,
- name: server.name,
- description: server.description,
- ...(server.tags ? { tags: server.tags } : {}),
- backend,
-};
-```
-
-- [ ] **Step 5: Run focused tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/registry.test.ts test/tools.test.ts
-```
-
-Expected: PASS after updating any expected snapshots or objects.
-
----
-
-### Task 8: Make Pi Adapter Visible Content Concise
-
-**Files:**
-
-- Modify: `packages/pi/src/index.ts`
-- Test: `packages/pi/test/pi.test.ts`
-
-- [ ] **Step 1: Add Pi adapter tests for concise visible content**
-
-In `packages/pi/test/pi.test.ts`, add:
-
-```ts
-it("stores full Caplets result in details while returning compact visible content", async () => {
- const service = mockService([
- {
- caplet: "context7",
- toolName: "caplets_context7",
- title: "Context7",
- description: "Context7 Caplet",
- promptGuidance: ["Use caplets_context7 for Context7."],
- },
- ]);
- const fullResult = {
- content: [{ type: "text", text: "very long docs" }],
- structuredContent: { result: { tools: [{ tool: "resolve-library-id" }] } },
- };
- service.execute.mockResolvedValueOnce(fullResult);
- const registered: RegisteredTool[] = [];
-
- capletsPiExtension(
- {
- registerTool: (definition) => registered.push(definition as unknown as RegisteredTool),
- },
- { service },
- );
-
- const result = await registered[0]?.execute("call-1", {
- operation: "list_tools",
- });
-
- expect(result?.content[0]?.text).toBe("structured keys: result");
- expect(result?.content[0]?.text).not.toContain("resolve-library-id");
- expect(result?.details).toEqual({ result: fullResult });
-});
-```
-
-- [ ] **Step 2: Run Pi tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-```
-
-Expected: FAIL because `execute()` currently serializes the entire result into visible content.
-
-- [ ] **Step 3: Add local compact serializer in Pi adapter**
-
-In `packages/pi/src/index.ts`, replace `serializeResult()` with:
-
-```ts
-function serializeResult(result: unknown): {
- text: string;
- serializationError?: string;
-} {
- try {
- return { text: compactResultText(result) };
- } catch (error) {
- const serializationError = error instanceof Error ? error.message : String(error);
- return {
- text: `[Serialization error: ${serializationError}]`,
- serializationError,
- };
- }
-}
-
-function compactResultText(result: unknown): string {
- if (!result || typeof result !== "object" || Array.isArray(result)) {
- return String(result ?? "null");
- }
- const structured = objectProperty(result, "structuredContent");
- if (structured) {
- const keys = Object.keys(structured).filter((key) => key !== "caplets");
- return keys.length ? `structured keys: ${keys.join(", ")}` : "structured result";
- }
- const content = arrayProperty(result, "content")
- .filter((item) => stringProperty(item, "type") === "text")
- .map((item) => stringProperty(item, "text"))
- .filter((text): text is string => Boolean(text));
- if (content.length > 0) {
- return content.join("\n").replace(/\s+/gu, " ").trim().slice(0, 600);
- }
- return "Caplets result";
-}
-```
-
-Keep the existing `details: { result }` behavior unchanged.
-
-- [ ] **Step 4: Update old Pi tests that expected full JSON content**
-
-Replace assertions that expect serialized JSON in `result.content[0].text` with concise text expectations. For `undefined`, keep:
-
-```ts
-expect(result?.content[0]?.text).toBe("null");
-```
-
-For structured results, use:
-
-```ts
-expect(result?.content[0]?.text).toContain("structured keys:");
-expect(result?.details.result).toEqual(fullResult);
-```
-
-- [ ] **Step 5: Run Pi tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-```
-
-Expected: PASS.
-
----
-
-### Task 9: Make OpenCode Adapter Visible Content Concise
-
-**Files:**
-
-- Modify: `packages/opencode/src/hooks.ts`
-- Test: `packages/opencode/test/opencode.test.ts`
-
-- [ ] **Step 1: Add OpenCode concise output test**
-
-In `packages/opencode/test/opencode.test.ts`, add:
-
-```ts
-it("returns compact text for structured Caplets results", async () => {
- const service = mockService([
- {
- caplet: "linear",
- toolName: "caplets_linear",
- title: "Linear",
- description: "Linear Caplet",
- promptGuidance: ["Use caplets_linear for Linear."],
- },
- ]);
- service.execute.mockResolvedValueOnce({
- content: [{ type: "text", text: "large downstream result" }],
- structuredContent: { result: { issues: [{ id: "LIN-1" }] } },
- });
-
- const hooks = await createCapletsOpenCodeHooks(service);
- const output = await hooks.tool.caplets_linear.execute({
- operation: "list_tools",
- });
-
- expect(output).toBe("structured keys: result");
- expect(output).not.toContain("LIN-1");
-});
-```
-
-Use the exact mock helper names already present in the file.
-
-- [ ] **Step 2: Run OpenCode tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: FAIL because OpenCode currently returns full pretty JSON for non-string results.
-
-- [ ] **Step 3: Add compact result helper in OpenCode hooks**
-
-In `packages/opencode/src/hooks.ts`, replace the `execute` body with:
-
-```ts
-async execute(args) {
- const result = await service.execute(caplet.caplet, args);
- return compactOpenCodeResult(result);
-},
-```
-
-Add this helper below `createCapletsOpenCodeHooks()`:
-
-```ts
-function compactOpenCodeResult(result: unknown): string {
- if (typeof result === "string") return result;
- if (!result || typeof result !== "object" || Array.isArray(result)) {
- return String(result ?? "null");
- }
- const structured = (result as Record).structuredContent;
- if (structured && typeof structured === "object" && !Array.isArray(structured)) {
- const keys = Object.keys(structured).filter((key) => key !== "caplets");
- return keys.length ? `structured keys: ${keys.join(", ")}` : "structured result";
- }
- const content = (result as Record).content;
- if (Array.isArray(content)) {
- const text = content
- .filter((item): item is { type: string; text: string } =>
- Boolean(
- item &&
- typeof item === "object" &&
- !Array.isArray(item) &&
- (item as any).type === "text" &&
- typeof (item as any).text === "string",
- ),
- )
- .map((item) => item.text)
- .join("\n")
- .replace(/\s+/gu, " ")
- .trim();
- if (text) return text.length > 600 ? `${text.slice(0, 599).trimEnd()}…` : text;
- }
- return "Caplets result";
-}
-```
-
-- [ ] **Step 4: Run OpenCode tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: PASS.
-
----
-
-### Task 10: Add Bounded Output Options For Generated HTTP And CLI Backends
-
-**Files:**
-
-- Modify: `packages/core/src/config.ts`
-- Modify: `packages/core/src/http-actions.ts`
-- Modify: `packages/core/src/openapi.ts`
-- Modify: `packages/core/src/graphql.ts`
-- Modify: `packages/core/src/cli-tools.ts`
-- Test: `packages/core/test/config.test.ts`
-- Test: `packages/core/test/http-actions.test.ts`
-- Test: `packages/core/test/openapi.test.ts`
-- Test: `packages/core/test/graphql.test.ts`
-- Test: `packages/core/test/cli-tools.test.ts`
-- Generated: `schemas/caplets-config.schema.json`
-
-- [ ] **Step 1: Add config tests for smaller defaults**
-
-In `packages/core/test/config.test.ts`, update default expectations:
-
-```ts
-expect(config.httpApis.api?.maxResponseBytes).toBe(200_000);
-expect(config.cliTools.repo?.maxOutputBytes).toBe(200_000);
-```
-
-Add a test that explicit large values still work:
-
-```ts
-it("allows explicit larger response and CLI output byte limits", () => {
- const config = loadConfigFromObject({
- version: 1,
- httpApis: {
- api: {
- name: "API",
- description: "HTTP API description.",
- baseUrl: "https://example.com",
- auth: { type: "none" },
- maxResponseBytes: 1_000_000,
- actions: {
- ping: { method: "GET", path: "/ping" },
- },
- },
- },
- cliTools: {
- repo: {
- name: "Repo",
- description: "Repository CLI description.",
- maxOutputBytes: 1_000_000,
- actions: {
- status: { command: "git", args: ["status"] },
- },
- },
- },
- });
-
- expect(config.httpApis.api?.maxResponseBytes).toBe(1_000_000);
- expect(config.cliTools.repo?.maxOutputBytes).toBe(1_000_000);
-});
-```
-
-Use the existing config test helper names in the file.
-
-- [ ] **Step 2: Run config tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/config.test.ts
-```
-
-Expected: FAIL because defaults are currently 1 MB.
-
-- [ ] **Step 3: Lower default byte limits in config**
-
-In `packages/core/src/config.ts`, replace both `.default(1_000_000)` for HTTP `maxResponseBytes` and CLI `maxOutputBytes` with:
-
-```ts
-.default(200_000)
-```
-
-- [ ] **Step 4: Add truncation marker to generated compact content**
-
-In `packages/core/src/result-content.ts`, add:
-
-```ts
-export function byteLimitHint(maxBytes: number): string {
- return `response body limit ${maxBytes} bytes`;
-}
-```
-
-Do not add this hint to every successful response. Use it only in errors or summaries when a limit was hit. Existing `readLimitedText()` already throws when the limit is exceeded, so no backend code change is needed beyond defaults unless tests require exact error messages.
-
-- [ ] **Step 5: Regenerate schema**
-
-Run:
-
-```bash
-pnpm schema:generate
-```
-
-Expected: `schemas/caplets-config.schema.json` updates default values for HTTP and CLI byte limits.
-
-- [ ] **Step 6: Run focused backend/config tests**
-
-Run:
-
-```bash
-pnpm --filter @caplets/core test -- test/config.test.ts test/http-actions.test.ts test/openapi.test.ts test/graphql.test.ts test/cli-tools.test.ts
-pnpm schema:check
-```
-
-Expected: PASS.
-
----
-
-### Task 11: Expand Benchmarks To Cover Runtime Result Surfaces
-
-**Files:**
-
-- Modify: `packages/benchmarks/lib/surface.ts`
-- Modify: `packages/benchmarks/test/benchmark.test.ts`
-- Generated: `docs/benchmarks/coding-agent.md`
-
-- [ ] **Step 1: Add benchmark expectations for runtime duplication**
-
-In `packages/benchmarks/test/benchmark.test.ts`, add assertions after the existing surface benchmark checks:
-
-```ts
-expect(result.runtime).toMatchObject({
- duplicatedStructuredContentBytes: expect.any(Number),
- compactStructuredContentBytes: expect.any(Number),
-});
-expect(result.runtime.compactStructuredContentBytes).toBeLessThan(
- result.runtime.duplicatedStructuredContentBytes,
-);
-expect(result.runtime.compactReduction).toBeGreaterThan(0.5);
-```
-
-- [ ] **Step 2: Run benchmark tests and verify failure**
-
-Run:
-
-```bash
-pnpm --filter @caplets/benchmarks test -- test/benchmark.test.ts
-```
-
-Expected: FAIL because `runtime` benchmark data does not exist.
-
-- [ ] **Step 3: Add runtime surface stats**
-
-In `packages/benchmarks/lib/surface.ts`, add this function near `surfaceStats()`:
-
-```ts
-function runtimeResultStats() {
- const structured = {
- status: 200,
- statusText: "OK",
- headers: { "content-type": "application/json" },
- body: {
- items: Array.from({ length: 20 }, (_, index) => ({
- id: `item-${index}`,
- title: `Example item ${index}`,
- description:
- "Representative downstream payload content used for token surface measurement.",
- })),
- },
- elapsedMs: 42,
- };
- const duplicated = {
- content: [{ type: "text", text: JSON.stringify(structured, null, 2) }],
- structuredContent: structured,
- };
- const compact = {
- content: [{ type: "text", text: "status 200; OK; body" }],
- structuredContent: structured,
- };
- const duplicatedBytes = Buffer.byteLength(JSON.stringify(duplicated), "utf8");
- const compactBytes = Buffer.byteLength(JSON.stringify(compact), "utf8");
- return {
- duplicatedStructuredContentBytes: duplicatedBytes,
- compactStructuredContentBytes: compactBytes,
- compactReduction: 1 - compactBytes / duplicatedBytes,
- compactReductionPercent: percent(1 - compactBytes / duplicatedBytes),
- };
-}
-```
-
-In `computeSurfaceBenchmark()`, add:
-
-```ts
-const runtime = runtimeResultStats();
-```
-
-and include it in the returned object:
-
-```ts
-runtime,
-```
-
-- [ ] **Step 4: Update markdown report**
-
-In `renderMarkdownReport()`, add this section after the deterministic initial payload results:
-
-```md
-## Runtime Result Duplication Check
-
-Generated structured backend results previously duplicated the same payload as both text content and structured content. The compact representation reduces the representative runtime result from ${result.runtime.duplicatedStructuredContentBytes} bytes to ${result.runtime.compactStructuredContentBytes} bytes, ${result.runtime.compactReductionPercent} fewer.
-```
-
-- [ ] **Step 5: Run benchmark generation**
-
-Run:
-
-```bash
-pnpm benchmark
-pnpm benchmark:check
-pnpm --filter @caplets/benchmarks test -- test/benchmark.test.ts
-```
-
-Expected: PASS and `docs/benchmarks/coding-agent.md` updated.
-
----
-
-### Task 12: Full Verification And Cleanup
-
-**Files:**
-
-- All modified files from Tasks 1-11
-
-- [ ] **Step 1: Run LSP diagnostics before the full build**
-
-Run via the harness diagnostic tool on:
-
-```text
-packages/core/src
-packages/pi/src
-packages/opencode/src
-packages/benchmarks/lib
-```
-
-Expected: no TypeScript errors.
-
-- [ ] **Step 2: Run format check**
-
-Run:
-
-```bash
-pnpm format:check
-```
-
-Expected: PASS. If it fails, run `pnpm format`, inspect changes, then re-run `pnpm format:check`.
-
-- [ ] **Step 3: Run lint**
-
-Run:
-
-```bash
-pnpm lint
-```
-
-Expected: PASS.
-
-- [ ] **Step 4: Run typecheck**
-
-Run:
-
-```bash
-pnpm typecheck
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Run schema check**
-
-Run:
-
-```bash
-pnpm schema:check
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Run all tests**
-
-Run:
-
-```bash
-pnpm test
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Run benchmark check**
-
-Run:
-
-```bash
-pnpm benchmark:check
-```
-
-Expected: PASS.
-
-- [ ] **Step 8: Run build**
-
-Run:
-
-```bash
-pnpm build
-```
-
-Expected: PASS.
-
-- [ ] **Step 9: Run full verify gate**
-
-Run:
-
-```bash
-pnpm verify
-```
-
-Expected: PASS.
-
----
-
-## Self-Review
-
-**Spec coverage:**
-
-- Discovery `content` plus `structuredContent` waste is covered by Tasks 1 and 3.
-- HTTP/OpenAPI/GraphQL/CLI generated result duplication is covered by Task 2.
-- Pi adapter full-result serialization is covered by Task 8.
-- OpenCode adapter full-result serialization is covered by Task 9.
-- Repeated caplet workflow instructions and prompt guidance are covered by Task 4.
-- Verbose generated input schema descriptions are covered by Task 5.
-- Large `list_tools` compact payloads are covered by Task 6.
-- `get_caplet` body and MCP backend duplication are covered by Task 7.
-- Large generated output defaults are covered by Task 10.
-- Benchmark blind spot is covered by Task 11.
-- Full verification is covered by Task 12.
-
-**Placeholder scan:** This plan contains no `TBD`, no `TODO`, and no steps that defer implementation decisions without concrete commands or code.
-
-**Type consistency:** New helper names are consistent across tasks: `structuredOnlyContent`, `compactStructuredContent`, `compactText`, `compactJsonText`, `statusSummary`, and `resultKeys`. The plan uses existing result fields `content`, `structuredContent`, `isError`, and `_meta.caplets` without renaming them.
diff --git a/docs/plans/2026-05-20-remote-cli-control.md b/docs/plans/2026-05-20-remote-cli-control.md
deleted file mode 100644
index d3184491..00000000
--- a/docs/plans/2026-05-20-remote-cli-control.md
+++ /dev/null
@@ -1,2582 +0,0 @@
-# Remote CLI Control Service Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Make the `caplets` CLI, OpenCode integration, and Pi integration use a unified `CAPLETS_MODE` / `CAPLETS_SERVER_*` interface so CLI commands can operate against a remote `caplets serve --transport http` service through an authenticated structured control endpoint.
-
-**Architecture:** Introduce shared server-mode and base-URL resolution, change HTTP serving so `--path` is the service base path, add a `/control` API beside `/mcp`, then route remote-capable CLI commands through a typed remote control client. The server owns config, Caplet files, installed Caplets, and downstream auth; local clients format output and never receive secrets.
-
-**Tech Stack:** TypeScript, Vitest, Hono, @hono/mcp Streamable HTTP transport, MCP SDK auth helpers, existing CapletsEngine, pnpm.
-
----
-
-## File structure
-
-- Create `packages/core/src/server/options.ts` for unified `CAPLETS_MODE`, `CAPLETS_SERVER_URL`, `CAPLETS_SERVER_USER`, and `CAPLETS_SERVER_PASSWORD` parsing.
-- Create `packages/core/src/remote-control/types.ts` for request/response envelopes, command names, and result payload types.
-- Create `packages/core/src/remote-control/client.ts` for the local CLI remote control client.
-- Create `packages/core/src/remote-control/dispatch.ts` for server-side command dispatch that reuses existing internal functions.
-- Create `packages/core/src/remote-control/auth-flow.ts` for remote auth login flow storage and callback completion.
-- Modify `packages/core/src/serve/options.ts` so HTTP `--path` is a service base path and `CAPLETS_SERVER_URL` supplies HTTP serve defaults.
-- Modify `packages/core/src/serve/http.ts` to mount `{base}/healthz`, `{base}/mcp`, `{base}/control`, and `{base}/control/auth/callback/:flowId`.
-- Modify `packages/core/src/serve/index.ts` only if new HTTP app dependencies need wiring.
-- Modify `packages/core/src/native/options.ts` so native service resolution uses `CAPLETS_MODE` and `CAPLETS_SERVER_URL` as the target model.
-- Modify `packages/opencode/src/index.ts` and `packages/pi/src/index.ts` to expose the unified option naming while preserving explicit host config seams.
-- Modify `packages/core/src/cli.ts` to route remote-capable commands through `RemoteControlClient` when mode resolution selects remote.
-- Modify `packages/core/src/cli/auth.ts` to expose structured auth helpers used by both local CLI and remote-control dispatch.
-- Modify `packages/core/src/cli/inspection.ts` only if exported result types are needed by remote-control response typing.
-- Modify `README.md`, `packages/cli/README.md`, `packages/opencode/README.md`, and `packages/pi/README.md` for the unified environment and base-path model.
-- Add `packages/core/test/server-options.test.ts`.
-- Add `packages/core/test/remote-control-client.test.ts`.
-- Add `packages/core/test/remote-control-dispatch.test.ts`.
-- Add `packages/core/test/cli-remote.test.ts`.
-- Modify `packages/core/test/serve-options.test.ts`.
-- Modify `packages/core/test/serve-http.test.ts`.
-- Modify `packages/core/test/native-options.test.ts`.
-- Modify `packages/opencode/test/opencode.test.ts`.
-- Modify `packages/pi/test/pi.test.ts`.
-- Add a changeset under `.changeset/` for `@caplets/core`, `caplets`, `@caplets/opencode`, and `@caplets/pi`.
-
----
-
-## Task 1: Add unified server option resolution
-
-**Files:**
-
-- Create: `packages/core/src/server/options.ts`
-- Test: `packages/core/test/server-options.test.ts`
-
-- [ ] **Step 1: Write failing server option tests**
-
-Create `packages/core/test/server-options.test.ts`:
-
-```ts
-import { Buffer } from "node:buffer";
-import { describe, expect, it } from "vitest";
-import type { CapletsError } from "../src/errors";
-import {
- controlUrlForBase,
- healthUrlForBase,
- mcpUrlForBase,
- resolveCapletsMode,
- resolveCapletsServer,
-} from "../src/server/options";
-
-describe("server option resolution", () => {
- it("defaults to local mode without a server URL", () => {
- expect(resolveCapletsMode({}, {})).toEqual({ mode: "local" });
- });
-
- it("uses remote mode in auto when CAPLETS_SERVER_URL is set", () => {
- expect(resolveCapletsMode({}, { CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets" })).toEqual(
- { mode: "remote" },
- );
- });
-
- it("lets explicit local mode ignore server settings", () => {
- expect(
- resolveCapletsMode({}, { CAPLETS_MODE: "local", CAPLETS_SERVER_URL: "https://example.com" }),
- ).toEqual({ mode: "local" });
- });
-
- it("requires CAPLETS_SERVER_URL in forced remote mode", () => {
- expect(() => resolveCapletsMode({}, { CAPLETS_MODE: "remote" })).toThrow(
- expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError,
- );
- });
-
- it("rejects invalid CAPLETS_MODE values", () => {
- expect(() => resolveCapletsMode({}, { CAPLETS_MODE: "cloud" })).toThrow(
- /auto, local, or remote/u,
- );
- });
-
- it("normalizes a base URL and derives service endpoints", () => {
- const resolved = resolveCapletsServer(
- {},
- {
- CAPLETS_SERVER_URL: "https://example.com/caplets/",
- CAPLETS_SERVER_USER: "admin",
- CAPLETS_SERVER_PASSWORD: ["fixture", "password"].join("-"),
- },
- );
-
- expect(resolved).toMatchObject({
- baseUrl: new URL("https://example.com/caplets"),
- auth: {
- enabled: true,
- user: "admin",
- password: ["fixture", "password"].join("-"),
- },
- });
- expect(mcpUrlForBase(resolved.baseUrl).toString()).toBe("https://example.com/caplets/mcp");
- expect(controlUrlForBase(resolved.baseUrl).toString()).toBe(
- "https://example.com/caplets/control",
- );
- expect(healthUrlForBase(resolved.baseUrl).toString()).toBe(
- "https://example.com/caplets/healthz",
- );
- expect(resolved.requestInit.headers).toEqual({
- Authorization: `Basic ${Buffer.from(`admin:${["fixture", "password"].join("-")}`).toString("base64")}`,
- });
- });
-
- it("derives endpoints from a root service base URL", () => {
- const base = new URL("http://127.0.0.1:5387");
-
- expect(mcpUrlForBase(base).toString()).toBe("http://127.0.0.1:5387/mcp");
- expect(controlUrlForBase(base).toString()).toBe("http://127.0.0.1:5387/control");
- expect(healthUrlForBase(base).toString()).toBe("http://127.0.0.1:5387/healthz");
- });
-
- it("rejects non-loopback http server URLs", () => {
- expect(() =>
- resolveCapletsServer({}, { CAPLETS_SERVER_URL: "http://caplets.example.com" }),
- ).toThrow(/https/u);
- });
-
- it("rejects username, password, query, or fragment in server URLs", () => {
- for (const url of [
- "https://user:pass@example.com/caplets",
- "https://example.com/caplets?token=secret",
- "https://example.com/caplets#fragment",
- ]) {
- expect(() => resolveCapletsServer({}, { CAPLETS_SERVER_URL: url })).toThrow(
- expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError,
- );
- }
- });
-
- it("requires a password when user is explicit", () => {
- expect(() =>
- resolveCapletsServer(
- {},
- {
- CAPLETS_SERVER_URL: "https://example.com",
- CAPLETS_SERVER_USER: "caplets",
- },
- ),
- ).toThrow(/requires a password/u);
- });
-});
-```
-
-- [ ] **Step 2: Run server option tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/server-options.test.ts
-```
-
-Expected: FAIL with module-not-found for `../src/server/options`.
-
-- [ ] **Step 3: Implement shared server option resolution**
-
-Create `packages/core/src/server/options.ts`:
-
-```ts
-import { Buffer } from "node:buffer";
-import { CapletsError } from "../errors";
-
-export type CapletsMode = "auto" | "local" | "remote";
-
-export type CapletsServerEnv = Partial<
- Record<
- "CAPLETS_MODE" | "CAPLETS_SERVER_URL" | "CAPLETS_SERVER_USER" | "CAPLETS_SERVER_PASSWORD",
- string
- >
->;
-
-export type CapletsModeInput = {
- mode?: CapletsMode | undefined;
- serverUrl?: string | undefined;
-};
-
-export type CapletsServerInput = {
- url?: string | undefined;
- user?: string | undefined;
- password?: string | undefined;
- fetch?: typeof fetch | undefined;
-};
-
-export type CapletsServerAuth =
- | { enabled: false; user: string }
- | { enabled: true; user: string; password: string };
-
-export type ResolvedCapletsServer = {
- baseUrl: URL;
- auth: CapletsServerAuth;
- requestInit: RequestInit;
- fetch?: typeof fetch;
-};
-
-const DEFAULT_SERVER_USER = "caplets";
-
-export function resolveCapletsMode(
- input: CapletsModeInput = {},
- env: CapletsServerEnv = process.env,
-): { mode: "local" } | { mode: "remote" } {
- const mode = parseMode(input.mode ?? env.CAPLETS_MODE ?? "auto");
- if (mode === "local") return { mode: "local" };
-
- const rawUrl =
- nonEmpty(input.serverUrl, "serverUrl") ??
- nonEmpty(env.CAPLETS_SERVER_URL, "CAPLETS_SERVER_URL");
- if (mode === "remote" && rawUrl === undefined) {
- throw new CapletsError("REQUEST_INVALID", "CAPLETS_MODE=remote requires CAPLETS_SERVER_URL.");
- }
- return rawUrl === undefined ? { mode: "local" } : { mode: "remote" };
-}
-
-export function resolveCapletsServer(
- input: CapletsServerInput = {},
- env: CapletsServerEnv = process.env,
-): ResolvedCapletsServer {
- const rawUrl =
- nonEmpty(input.url, "server.url") ?? nonEmpty(env.CAPLETS_SERVER_URL, "CAPLETS_SERVER_URL");
- if (rawUrl === undefined) {
- throw new CapletsError(
- "REQUEST_INVALID",
- "CAPLETS_SERVER_URL is required for remote Caplets mode.",
- );
- }
-
- const baseUrl = parseServerBaseUrl(rawUrl);
- const userWasExplicit = input.user !== undefined || hasEnv(env.CAPLETS_SERVER_USER);
- const user =
- nonEmpty(input.user, "server.user") ??
- nonEmpty(env.CAPLETS_SERVER_USER, "CAPLETS_SERVER_USER") ??
- DEFAULT_SERVER_USER;
- const password =
- nonEmpty(input.password, "server.password") ??
- nonEmpty(env.CAPLETS_SERVER_PASSWORD, "CAPLETS_SERVER_PASSWORD");
-
- if (userWasExplicit && password === undefined) {
- throw new CapletsError(
- "REQUEST_INVALID",
- "Remote Caplets Basic Auth requires a password; set CAPLETS_SERVER_PASSWORD or server.password.",
- );
- }
-
- const auth: CapletsServerAuth =
- password === undefined ? { enabled: false, user } : { enabled: true, user, password };
- const requestInit: RequestInit = auth.enabled
- ? { headers: { Authorization: basicAuthHeader(auth.user, auth.password) } }
- : {};
-
- return {
- baseUrl,
- auth,
- requestInit,
- ...(input.fetch ? { fetch: input.fetch } : {}),
- };
-}
-
-export function mcpUrlForBase(baseUrl: URL): URL {
- return appendBasePath(baseUrl, "mcp");
-}
-
-export function controlUrlForBase(baseUrl: URL): URL {
- return appendBasePath(baseUrl, "control");
-}
-
-export function healthUrlForBase(baseUrl: URL): URL {
- return appendBasePath(baseUrl, "healthz");
-}
-
-export function appendBasePath(baseUrl: URL, child: string): URL {
- const url = new URL(baseUrl.toString());
- const basePath = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/u, "");
- url.pathname = `${basePath}/${child}`;
- return url;
-}
-
-export function parseServerBaseUrl(value: string): URL {
- let url: URL;
- try {
- url = new URL(value);
- } catch {
- throw new CapletsError("REQUEST_INVALID", "Invalid CAPLETS_SERVER_URL.");
- }
- if (url.username !== "" || url.password !== "") {
- throw new CapletsError(
- "REQUEST_INVALID",
- "CAPLETS_SERVER_URL must not include username or password; use CAPLETS_SERVER_USER/CAPLETS_SERVER_PASSWORD instead.",
- );
- }
- if (url.search !== "" || url.hash !== "") {
- throw new CapletsError(
- "REQUEST_INVALID",
- "CAPLETS_SERVER_URL must not include query or fragment.",
- );
- }
- if (url.protocol === "https:") {
- return normalizeBaseUrlPath(url);
- }
- if (url.protocol === "http:" && isLoopbackHost(url.hostname)) {
- return normalizeBaseUrlPath(url);
- }
- throw new CapletsError(
- "REQUEST_INVALID",
- "CAPLETS_SERVER_URL must use https except loopback development URLs.",
- );
-}
-
-export function isLoopbackHost(host: string): boolean {
- const normalized = host.toLocaleLowerCase();
- return (
- normalized === "localhost" ||
- normalized === "127.0.0.1" ||
- normalized === "::1" ||
- normalized === "[::1]"
- );
-}
-
-function normalizeBaseUrlPath(url: URL): URL {
- const normalized = new URL(url.toString());
- normalized.pathname =
- normalized.pathname === "/" ? "/" : normalized.pathname.replace(/\/+$/u, "");
- return normalized;
-}
-
-function parseMode(value: string): CapletsMode {
- if (value === "auto" || value === "local" || value === "remote") return value;
- throw new CapletsError(
- "REQUEST_INVALID",
- `Expected CAPLETS_MODE to be auto, local, or remote, got ${value}`,
- );
-}
-
-function basicAuthHeader(user: string, password: string): string {
- return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
-}
-
-function nonEmpty(value: string | undefined, label: string): string | undefined {
- if (value === undefined) return undefined;
- const trimmed = value.trim();
- if (!trimmed) throw new CapletsError("REQUEST_INVALID", `${label} must not be empty`);
- return trimmed;
-}
-
-function hasEnv(value: string | undefined): boolean {
- return value !== undefined && value.trim() !== "";
-}
-```
-
-- [ ] **Step 4: Run server option tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/server-options.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit server option resolution**
-
-Run:
-
-```sh
-git add packages/core/src/server/options.ts packages/core/test/server-options.test.ts
-git commit -m "feat(core): add unified Caplets server options"
-```
-
----
-
-## Task 2: Update native integrations to use unified env vars
-
-**Files:**
-
-- Modify: `packages/core/src/native/options.ts`
-- Modify: `packages/core/src/native/remote.ts`
-- Modify: `packages/opencode/src/index.ts`
-- Modify: `packages/pi/src/index.ts`
-- Test: `packages/core/test/native-options.test.ts`
-- Test: `packages/core/test/native-remote.test.ts`
-- Test: `packages/opencode/test/opencode.test.ts`
-- Test: `packages/pi/test/pi.test.ts`
-
-- [ ] **Step 1: Update native option tests for unified vars**
-
-Modify `packages/core/test/native-options.test.ts` so env-driven tests use base URLs and new env names:
-
-```ts
-expect(
- resolveNativeCapletsServiceOptions({}, { CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets" }),
-).toMatchObject({
- mode: "remote",
- remote: {
- url: new URL("http://127.0.0.1:5387/caplets/mcp"),
- auth: { enabled: false, user: "caplets" },
- pollIntervalMs: 30_000,
- },
-});
-```
-
-Replace the explicit local-mode test with:
-
-```ts
-expect(
- resolveNativeCapletsServiceOptions(
- {},
- {
- CAPLETS_MODE: "local",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets",
- },
- ),
-).toEqual({ mode: "local" });
-```
-
-Replace the missing remote URL assertion with:
-
-```ts
-expect(() => resolveNativeCapletsServiceOptions({}, { CAPLETS_MODE: "remote" })).toThrow(
- expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError,
-);
-```
-
-Replace env override assertions with `CAPLETS_SERVER_URL`, `CAPLETS_SERVER_USER`, and `CAPLETS_SERVER_PASSWORD`. Replace embedded credential error text to mention `CAPLETS_SERVER_USER/CAPLETS_SERVER_PASSWORD`.
-
-- [ ] **Step 2: Update remote auth error guidance test**
-
-Modify `packages/core/test/native-remote.test.ts` auth failure expectation:
-
-```ts
-await expect(service.execute("alpha", {})).rejects.toMatchObject({
- code: "AUTH_FAILED",
- message: expect.stringContaining("CAPLETS_SERVER_USER"),
-} satisfies Partial);
-```
-
-- [ ] **Step 3: Update OpenCode config test to pass base server config**
-
-Modify `packages/opencode/test/opencode.test.ts` config propagation test to pass:
-
-```ts
-await plugin(
- {} as never,
- {
- mode: "remote",
- server: {
- url: "https://caplets.example.com/caplets",
- user: "caplets",
- },
- remote: { pollIntervalMs: 5_000 },
- } as never,
-);
-
-expect(nativeMocks.createNativeCapletsService).toHaveBeenCalledWith({
- mode: "remote",
- server: {
- url: "https://caplets.example.com/caplets",
- user: "caplets",
- },
- remote: { pollIntervalMs: 5_000 },
-});
-```
-
-- [ ] **Step 4: Update Pi settings tests to parse unified server shape**
-
-In `packages/pi/test/pi.test.ts`, update the settings/args propagation tests so accepted settings include:
-
-```json
-{
- "caplets": {
- "mode": "remote",
- "server": {
- "url": "https://caplets.example.com/caplets",
- "user": "caplets"
- },
- "remote": {
- "pollIntervalMs": 5000
- }
- }
-}
-```
-
-Expected options passed to `createNativeCapletsService`:
-
-```ts
-{
- mode: "remote",
- server: { url: "https://caplets.example.com/caplets", user: "caplets" },
- remote: { pollIntervalMs: 5_000 },
-}
-```
-
-- [ ] **Step 5: Run native and integration tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts
-pnpm --filter @caplets/opencode test -- opencode.test.ts
-pnpm --filter @caplets/pi test -- pi.test.ts
-```
-
-Expected: FAIL because implementation still reads the legacy remote env names and OpenCode/Pi config types do not expose `server`.
-
-- [ ] **Step 6: Implement native option resolution with shared server options**
-
-Modify `packages/core/src/native/options.ts`:
-
-```ts
-import {
- mcpUrlForBase,
- resolveCapletsMode,
- resolveCapletsServer,
- type CapletsMode,
- type CapletsServerEnv,
- type CapletsServerInput,
-} from "../server/options";
-
-export type NativeCapletsMode = CapletsMode;
-
-export type NativeRemoteCapletsOptions = {
- pollIntervalMs?: number;
- fetch?: typeof fetch;
-};
-
-export type NativeCapletsServiceResolutionInput = {
- mode?: NativeCapletsMode;
- server?: CapletsServerInput;
- remote?: NativeRemoteCapletsOptions;
-};
-
-export type NativeCapletsEnv = CapletsServerEnv;
-
-export function resolveNativeCapletsServiceOptions(
- input: NativeCapletsServiceResolutionInput = {},
- env: NativeCapletsEnv = process.env,
-): ResolvedNativeCapletsServiceOptions {
- const mode = resolveCapletsMode({ mode: input.mode, serverUrl: input.server?.url }, env);
- if (mode.mode === "local") return { mode: "local" };
-
- const server = resolveCapletsServer(
- {
- ...input.server,
- fetch: input.remote?.fetch ?? input.server?.fetch,
- },
- env,
- );
-
- return {
- mode: "remote",
- remote: {
- url: mcpUrlForBase(server.baseUrl),
- auth: server.auth,
- pollIntervalMs: parsePollInterval(input.remote?.pollIntervalMs),
- requestInit: server.requestInit,
- ...(server.fetch ? { fetch: server.fetch } : {}),
- },
- };
-}
-```
-
-Keep the existing `ResolvedNativeCapletsServiceOptions` type and `parsePollInterval()` helper. Remove URL parsing and Basic Auth helper duplication from this file.
-
-- [ ] **Step 7: Update native remote auth guidance**
-
-Modify `remoteAuthError()` in `packages/core/src/native/remote.ts`:
-
-```ts
-return new CapletsError(
- "AUTH_FAILED",
- "Remote Caplets authentication failed; check CAPLETS_SERVER_USER and CAPLETS_SERVER_PASSWORD.",
-);
-```
-
-- [ ] **Step 8: Update OpenCode option type**
-
-Modify `packages/opencode/src/index.ts`:
-
-```ts
-export type CapletsOpenCodeConfig = Pick;
-
-function normalizeOpenCodeConfig(config: CapletsOpenCodeConfig | undefined): CapletsOpenCodeConfig {
- if (!config) return {};
- return {
- ...(config.mode ? { mode: config.mode } : {}),
- ...(config.server ? { server: config.server } : {}),
- ...(config.remote ? { remote: config.remote } : {}),
- };
-}
-```
-
-- [ ] **Step 9: Update Pi option parsing**
-
-Modify `packages/pi/src/index.ts`:
-
-```ts
-type PiNativeCapletsOptions = Pick;
-```
-
-In `parsePiNativeOptions()`, add parsing for `server`:
-
-```ts
-const server = objectProperty(value, "server");
-if (server) {
- const parsedServer: NonNullable = {};
- for (const key of ["url", "user", "password"] as const) {
- const field = server[key];
- if (field !== undefined) {
- if (typeof field !== "string") return undefined;
- parsedServer[key] = field;
- }
- }
- result.server = parsedServer;
-}
-```
-
-Keep `remote.pollIntervalMs` parsing and remove `remote.url`, `remote.user`, and `remote.password` parsing from the target shape.
-
-- [ ] **Step 10: Run native and integration tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/native-options.test.ts test/native-remote.test.ts
-pnpm --filter @caplets/opencode test -- opencode.test.ts
-pnpm --filter @caplets/pi test -- pi.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 11: Commit unified native config**
-
-Run:
-
-```sh
-git add packages/core/src/native/options.ts packages/core/src/native/remote.ts packages/opencode/src/index.ts packages/pi/src/index.ts packages/core/test/native-options.test.ts packages/core/test/native-remote.test.ts packages/opencode/test/opencode.test.ts packages/pi/test/pi.test.ts
-git commit -m "feat(native): use unified Caplets server config"
-```
-
----
-
-## Task 3: Change HTTP serve path to a service base path
-
-**Files:**
-
-- Modify: `packages/core/src/serve/options.ts`
-- Modify: `packages/core/src/serve/http.ts`
-- Test: `packages/core/test/serve-options.test.ts`
-- Test: `packages/core/test/serve-http.test.ts`
-
-- [ ] **Step 1: Update serve option tests for base path semantics**
-
-Modify `packages/core/test/serve-options.test.ts`:
-
-```ts
-it("defaults HTTP serving to localhost port 5387 and root base path", () => {
- expect(resolveServeOptions({ transport: "http" }, {})).toMatchObject({
- transport: "http",
- host: "127.0.0.1",
- port: 5387,
- path: "/",
- auth: { enabled: false, user: "caplets" },
- });
-});
-
-it("uses CAPLETS_SERVER_URL as HTTP serve defaults", () => {
- expect(
- resolveServeOptions(
- { transport: "http" },
- {
- CAPLETS_SERVER_URL: "http://127.0.0.1:7777/caplets",
- CAPLETS_SERVER_PASSWORD: ["server", "password"].join("-"),
- },
- ),
- ).toMatchObject({
- transport: "http",
- host: "127.0.0.1",
- port: 7777,
- path: "/caplets",
- auth: {
- enabled: true,
- user: "caplets",
- password: ["server", "password"].join("-"),
- },
- });
-});
-
-it("lets explicit HTTP flags override CAPLETS_SERVER_URL defaults", () => {
- expect(
- resolveServeOptions(
- { transport: "http", host: "127.0.0.1", port: "9999", path: "/local" },
- { CAPLETS_SERVER_URL: "http://127.0.0.1:7777/caplets" },
- ),
- ).toMatchObject({ host: "127.0.0.1", port: 9999, path: "/local" });
-});
-```
-
-- [ ] **Step 2: Update HTTP app tests for mounted subroutes**
-
-Modify `packages/core/test/serve-http.test.ts` helper default path to `/`:
-
-```ts
-function httpOptions(overrides: Partial = {}): HttpServeOptions {
- return {
- transport: "http",
- host: "127.0.0.1",
- port: 5387,
- path: "/",
- auth: { enabled: false, user: "caplets" },
- warnUnauthenticatedNetwork: false,
- loopback: true,
- ...overrides,
- };
-}
-```
-
-Add a base-path route test:
-
-```ts
-it("mounts health, mcp, and control under a service base path", async () => {
- const { engine } = testEngine();
- const app = createHttpServeApp(httpOptions({ path: "/caplets" }), engine, {
- writeErr: () => {},
- });
-
- expect((await app.request("http://127.0.0.1:5387/healthz")).status).toBe(404);
- expect((await app.request("http://127.0.0.1:5387/caplets/healthz")).status).toBe(200);
- expect((await app.request("http://127.0.0.1:5387/caplets/mcp/extra")).status).toBe(404);
-
- await engine.close();
-});
-```
-
-Update root info assertions to expect `base`, `mcp`, `control`, and `health`:
-
-```ts
-await expect(root.json()).resolves.toMatchObject({
- name: "caplets",
- transport: "http",
- base: "/",
- mcp: "/mcp",
- control: "/control",
- health: "/healthz",
- auth: { type: "basic", enabled: false },
-});
-```
-
-- [ ] **Step 3: Run serve tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/serve-options.test.ts test/serve-http.test.ts
-```
-
-Expected: FAIL because current HTTP default path is `/mcp` and route mounting treats `path` as the MCP endpoint.
-
-- [ ] **Step 4: Implement base-path serve option resolution**
-
-Modify `packages/core/src/serve/options.ts`:
-
-```ts
-import { parseServerBaseUrl, isLoopbackHost as isServerLoopbackHost } from "../server/options";
-
-export type ServeEnv = Partial<
- Record<"CAPLETS_SERVER_URL" | "CAPLETS_SERVER_USER" | "CAPLETS_SERVER_PASSWORD", string>
->;
-```
-
-Inside HTTP resolution:
-
-```ts
-const serverUrl = env.CAPLETS_SERVER_URL ? parseServerBaseUrl(env.CAPLETS_SERVER_URL) : undefined;
-const host = nonEmpty(raw.host, "--host") ?? serverUrl?.hostname ?? "127.0.0.1";
-const port = parsePort(raw.port ?? (serverUrl?.port ? Number(serverUrl.port) : 5387));
-const path = normalizeHttpPath(raw.path ?? serverUrl?.pathname ?? "/");
-```
-
-Change the previous default from `"/mcp"` to `"/"`. Keep explicit flag validation and auth handling.
-
-- [ ] **Step 5: Implement service-base route helpers**
-
-Modify `packages/core/src/serve/http.ts` with helpers:
-
-```ts
-function routePath(basePath: string, child: string): string {
- const base = basePath === "/" ? "" : basePath.replace(/\/+$/u, "");
- return `${base}/${child}`;
-}
-
-function servicePaths(basePath: string): {
- base: string;
- health: string;
- mcp: string;
- control: string;
-} {
- return {
- base: basePath,
- health: routePath(basePath, "healthz"),
- mcp: routePath(basePath, "mcp"),
- control: routePath(basePath, "control"),
- };
-}
-```
-
-Use `const paths = servicePaths(options.path);` in `createHttpServeApp()`. Change routes:
-
-```ts
-app.get(paths.base, (c) => c.json({ name: "caplets", transport: "http", base: paths.base, mcp: paths.mcp, control: paths.control, health: paths.health, auth: { type: "basic", enabled: options.auth.enabled } }));
-app.get(paths.health, (c) => c.json({ status: "ok", transport: "http", basePath: paths.base, mcpPath: paths.mcp, controlPath: paths.control }));
-app.all(paths.mcp, basicAuth(options.auth), async (c) => { ...existing MCP session handling... });
-```
-
-Update `serveHttp()` logs:
-
-```ts
-const baseUrl = `http://${formatHost(options.host)}:${options.port}${options.path === "/" ? "" : options.path}`;
-writeErr(`Caplets HTTP service listening on ${baseUrl}\n`);
-writeErr(`MCP endpoint: ${baseUrl}/mcp\n`);
-writeErr(`Control endpoint: ${baseUrl}/control\n`);
-writeErr(`Health check: ${baseUrl}/healthz\n`);
-```
-
-- [ ] **Step 6: Run serve tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/serve-options.test.ts test/serve-http.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit service base-path serving**
-
-Run:
-
-```sh
-git add packages/core/src/serve/options.ts packages/core/src/serve/http.ts packages/core/test/serve-options.test.ts packages/core/test/serve-http.test.ts
-git commit -m "feat(serve): mount HTTP service under a base path"
-```
-
----
-
-## Task 4: Add remote control client and response types
-
-**Files:**
-
-- Create: `packages/core/src/remote-control/types.ts`
-- Create: `packages/core/src/remote-control/client.ts`
-- Test: `packages/core/test/remote-control-client.test.ts`
-
-- [ ] **Step 1: Write failing remote control client tests**
-
-Create `packages/core/test/remote-control-client.test.ts`:
-
-```ts
-import { describe, expect, it, vi } from "vitest";
-import { CapletsError } from "../src/errors";
-import { RemoteControlClient } from "../src/remote-control/client";
-
-describe("RemoteControlClient", () => {
- it("posts structured requests to the derived control endpoint", async () => {
- const fetchMock = vi.fn(
- async () =>
- new Response(JSON.stringify({ ok: true, result: { rows: [] } }), {
- status: 200,
- headers: { "content-type": "application/json" },
- }),
- );
- const client = new RemoteControlClient({
- baseUrl: new URL("http://127.0.0.1:5387/caplets"),
- requestInit: { headers: { Authorization: "Basic test" } },
- fetch: fetchMock as typeof fetch,
- });
-
- await expect(client.request("list", { includeDisabled: true })).resolves.toEqual({ rows: [] });
-
- expect(fetchMock).toHaveBeenCalledWith(
- new URL("http://127.0.0.1:5387/caplets/control"),
- expect.objectContaining({
- method: "POST",
- headers: expect.objectContaining({
- Authorization: "Basic test",
- "content-type": "application/json",
- }),
- body: JSON.stringify({
- command: "list",
- arguments: { includeDisabled: true },
- }),
- }),
- );
- });
-
- it("maps control errors into CapletsError", async () => {
- const fetchMock = vi.fn(
- async () =>
- new Response(
- JSON.stringify({
- ok: false,
- error: {
- code: "CONFIG_NOT_FOUND",
- message: "Remote config missing",
- },
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- ),
- );
- const client = new RemoteControlClient({
- baseUrl: new URL("https://caplets.example.com"),
- requestInit: {},
- fetch: fetchMock as typeof fetch,
- });
-
- await expect(client.request("list", {})).rejects.toMatchObject({
- code: "CONFIG_NOT_FOUND",
- message: "Remote config missing",
- } satisfies Partial);
- });
-
- it("uses safe auth and availability errors without leaking headers", async () => {
- const fetchMock = vi.fn(async () => new Response("Unauthorized", { status: 401 }));
- const client = new RemoteControlClient({
- baseUrl: new URL("https://caplets.example.com/caplets"),
- requestInit: { headers: { Authorization: "Basic secret" } },
- fetch: fetchMock as typeof fetch,
- });
-
- await expect(client.request("list", {})).rejects.toMatchObject({
- code: "AUTH_FAILED",
- message: expect.stringContaining("Remote Caplets control authentication failed"),
- } satisfies Partial);
- await expect(client.request("list", {})).rejects.not.toThrow(/secret/u);
- });
-});
-```
-
-- [ ] **Step 2: Run remote control client tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-client.test.ts
-```
-
-Expected: FAIL with module-not-found for `../src/remote-control/client`.
-
-- [ ] **Step 3: Add remote control types**
-
-Create `packages/core/src/remote-control/types.ts`:
-
-```ts
-export type RemoteCliCommand =
- | "list"
- | "get_caplet"
- | "check_backend"
- | "list_tools"
- | "search_tools"
- | "get_tool"
- | "call_tool"
- | "init"
- | "add"
- | "install"
- | "auth_login_start"
- | "auth_login_complete"
- | "auth_logout"
- | "auth_list";
-
-export type RemoteCliRequest = {
- command: RemoteCliCommand;
- arguments: Record;
-};
-
-export type RemoteCliResponse =
- | { ok: true; result: unknown; warnings?: string[] }
- | {
- ok: false;
- error: { code: string; message: string; nextAction?: string };
- warnings?: string[];
- };
-```
-
-- [ ] **Step 4: Add remote control client**
-
-Create `packages/core/src/remote-control/client.ts`:
-
-```ts
-import { CapletsError, toSafeError } from "../errors";
-import { controlUrlForBase } from "../server/options";
-import type { RemoteCliCommand, RemoteCliRequest, RemoteCliResponse } from "./types";
-
-export type RemoteControlClientOptions = {
- baseUrl: URL;
- requestInit: RequestInit;
- fetch?: typeof fetch;
-};
-
-export class RemoteControlClient {
- private readonly fetchImpl: typeof fetch;
-
- constructor(private readonly options: RemoteControlClientOptions) {
- this.fetchImpl = options.fetch ?? fetch;
- }
-
- async request(command: RemoteCliCommand, args: Record): Promise {
- const body: RemoteCliRequest = { command, arguments: args };
- let response: Response;
- try {
- response = await this.fetchImpl(controlUrlForBase(this.options.baseUrl), {
- ...this.options.requestInit,
- method: "POST",
- headers: {
- ...headersObject(this.options.requestInit.headers),
- "content-type": "application/json",
- },
- body: JSON.stringify(body),
- });
- } catch (error) {
- throw new CapletsError(
- "SERVER_UNAVAILABLE",
- `Remote Caplets server unavailable at ${safeBaseUrl(this.options.baseUrl)}.`,
- toSafeError(error),
- );
- }
-
- if (response.status === 401 || response.status === 403) {
- throw new CapletsError(
- "AUTH_FAILED",
- "Remote Caplets control authentication failed; check CAPLETS_SERVER_USER and CAPLETS_SERVER_PASSWORD.",
- );
- }
- if (!response.ok) {
- throw new CapletsError(
- "SERVER_UNAVAILABLE",
- `Remote Caplets control request failed with HTTP ${response.status} at ${safeBaseUrl(this.options.baseUrl)}.`,
- );
- }
-
- const payload = (await response.json()) as RemoteCliResponse;
- if (!payload.ok) {
- throw new CapletsError(payload.error.code, payload.error.message, {
- ...(payload.error.nextAction ? { nextAction: payload.error.nextAction } : {}),
- });
- }
- return payload.result;
- }
-}
-
-function headersObject(headers: RequestInit["headers"]): Record {
- if (!headers) return {};
- if (headers instanceof Headers) return Object.fromEntries(headers.entries());
- if (Array.isArray(headers)) return Object.fromEntries(headers);
- return headers as Record;
-}
-
-function safeBaseUrl(baseUrl: URL): string {
- const safe = new URL(baseUrl.toString());
- safe.username = "";
- safe.password = "";
- safe.search = "";
- safe.hash = "";
- return safe.toString();
-}
-```
-
-- [ ] **Step 5: Run remote control client tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-client.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit remote control client**
-
-Run:
-
-```sh
-git add packages/core/src/remote-control/types.ts packages/core/src/remote-control/client.ts packages/core/test/remote-control-client.test.ts
-git commit -m "feat(cli): add remote control client"
-```
-
----
-
-## Task 5: Add control endpoint dispatch for read, execute, and mutation commands
-
-**Files:**
-
-- Create: `packages/core/src/remote-control/dispatch.ts`
-- Modify: `packages/core/src/serve/http.ts`
-- Test: `packages/core/test/remote-control-dispatch.test.ts`
-- Test: `packages/core/test/serve-http.test.ts`
-
-- [ ] **Step 1: Write failing dispatch tests**
-
-Create `packages/core/test/remote-control-dispatch.test.ts` with read and mutation coverage:
-
-```ts
-import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import { dispatchRemoteCliRequest } from "../src/remote-control/dispatch";
-
-const dirs: string[] = [];
-
-afterEach(() => {
- for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
-});
-
-describe("dispatchRemoteCliRequest", () => {
- it("lists remote server Caplets from server-side config", async () => {
- const fixture = remoteFixture();
-
- const result = await dispatchRemoteCliRequest(
- { command: "list", arguments: { includeDisabled: false } },
- fixture.context,
- );
-
- expect(result).toEqual({
- ok: true,
- result: [
- expect.objectContaining({
- server: "status",
- backend: "http",
- source: "global-config",
- }),
- ],
- });
- });
-
- it("executes direct Caplet operations through server-side engine", async () => {
- const fixture = remoteFixture();
-
- const result = await dispatchRemoteCliRequest(
- {
- command: "get_caplet",
- arguments: { caplet: "status", request: { operation: "get_caplet" } },
- },
- fixture.context,
- );
-
- expect(result.ok).toBe(true);
- expect(JSON.stringify(result)).toContain("Status API");
- });
-
- it("writes added Caplets to the server-side destination root", async () => {
- const fixture = remoteFixture();
-
- const result = await dispatchRemoteCliRequest(
- {
- command: "add",
- arguments: {
- kind: "mcp",
- id: "remote-tools",
- options: { url: "https://mcp.example.com/mcp", transport: "http" },
- },
- },
- fixture.context,
- );
-
- expect(result).toEqual({
- ok: true,
- result: expect.objectContaining({
- remote: true,
- label: "MCP",
- path: expect.any(String),
- }),
- });
- expect(existsSync(join(fixture.projectRoot, ".caplets", "remote-tools.md"))).toBe(true);
- expect(
- readFileSync(join(fixture.projectRoot, ".caplets", "remote-tools.md"), "utf8"),
- ).toContain("mcpServer:");
- });
-});
-
-function remoteFixture() {
- const dir = mkdtempSync(join(tmpdir(), "caplets-remote-control-"));
- dirs.push(dir);
- const userRoot = join(dir, "user");
- const projectRoot = join(dir, "project");
- mkdirSync(userRoot, { recursive: true });
- mkdirSync(join(projectRoot, ".caplets"), { recursive: true });
- const configPath = join(userRoot, "config.json");
- const projectConfigPath = join(projectRoot, ".caplets", "config.json");
- writeFileSync(
- configPath,
- JSON.stringify({
- httpApis: {
- status: {
- name: "Status API",
- description: "Check status.",
- baseUrl: "http://127.0.0.1:1",
- auth: { type: "none" },
- actions: { check: { method: "GET", path: "/check" } },
- },
- },
- }),
- );
- writeFileSync(projectConfigPath, JSON.stringify({ mcpServers: {} }));
- return {
- projectRoot,
- context: {
- configPath,
- projectConfigPath,
- projectCapletsRoot: join(projectRoot, ".caplets"),
- authDir: join(dir, "state", "auth"),
- writeErr: () => {},
- },
- };
-}
-```
-
-- [ ] **Step 2: Add HTTP control endpoint test**
-
-Add to `packages/core/test/serve-http.test.ts`:
-
-```ts
-it("requires Basic Auth on the control endpoint and dispatches requests", async () => {
- const { engine } = testEngine();
- const password = ["control", "password"].join("-");
- const app = createHttpServeApp(
- httpOptions({ auth: { enabled: true, user: "caplets", password } }),
- engine,
- { writeErr: () => {} },
- );
-
- expect((await app.request("http://127.0.0.1:5387/control", { method: "POST" })).status).toBe(401);
-
- const response = await app.request("http://127.0.0.1:5387/control", {
- method: "POST",
- headers: {
- authorization: `Basic ${Buffer.from(`caplets:${password}`).toString("base64")}`,
- "content-type": "application/json",
- },
- body: JSON.stringify({
- command: "list",
- arguments: { includeDisabled: false },
- }),
- });
-
- expect(response.status).toBe(200);
- await expect(response.json()).resolves.toMatchObject({ ok: true });
-
- await engine.close();
-});
-```
-
-- [ ] **Step 3: Run dispatch and HTTP tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-dispatch.test.ts test/serve-http.test.ts
-```
-
-Expected: FAIL because `dispatchRemoteCliRequest` and `/control` do not exist.
-
-- [ ] **Step 4: Implement remote control dispatch**
-
-Create `packages/core/src/remote-control/dispatch.ts`:
-
-```ts
-import {
- addCliCaplet,
- addGraphqlCaplet,
- addHttpCaplet,
- addMcpCaplet,
- addOpenApiCaplet,
-} from "../cli/add";
-import { initConfig } from "../cli/init";
-import { installCaplets } from "../cli/install";
-import { listCaplets } from "../cli/inspection";
-import { loadConfigWithSources } from "../config";
-import { CapletsEngine, type CapletsEngineOptions } from "../engine";
-import { toSafeError } from "../errors";
-import type { RemoteCliRequest, RemoteCliResponse } from "./types";
-
-export type RemoteControlDispatchContext = CapletsEngineOptions & {
- projectCapletsRoot: string;
-};
-
-export async function dispatchRemoteCliRequest(
- request: RemoteCliRequest,
- context: RemoteControlDispatchContext,
-): Promise {
- try {
- const result = await dispatchRemoteCliRequestUnsafe(request, context);
- return { ok: true, result };
- } catch (error) {
- const safe = toSafeError(error, "INTERNAL_ERROR") as {
- code?: string;
- message?: string;
- nextAction?: string;
- };
- return {
- ok: false,
- error: {
- code: safe.code ?? "INTERNAL_ERROR",
- message: safe.message ?? "Remote control command failed.",
- ...(safe.nextAction ? { nextAction: safe.nextAction } : {}),
- },
- };
- }
-}
-
-async function dispatchRemoteCliRequestUnsafe(
- request: RemoteCliRequest,
- context: RemoteControlDispatchContext,
-): Promise {
- switch (request.command) {
- case "list":
- return listCaplets(loadConfigWithSources(context.configPath, context.projectConfigPath), {
- includeDisabled: Boolean(request.arguments.includeDisabled),
- });
- case "get_caplet":
- case "check_backend":
- case "list_tools":
- case "search_tools":
- case "get_tool":
- case "call_tool":
- return await executeRemoteCapletOperation(request, context);
- case "init":
- return {
- remote: true,
- path: initConfig({
- path: context.configPath,
- force: Boolean(request.arguments.force),
- }),
- };
- case "add":
- return dispatchAdd(request.arguments, context.projectCapletsRoot);
- case "install":
- return {
- remote: true,
- ...installCaplets(stringArg(request.arguments.repo, "repo"), {
- capletIds: stringArrayArg(request.arguments.capletIds),
- force: Boolean(request.arguments.force),
- destinationRoot: context.projectCapletsRoot,
- }),
- };
- default:
- throw new Error(`Unsupported remote control command ${request.command}`);
- }
-}
-
-async function executeRemoteCapletOperation(
- request: RemoteCliRequest,
- context: RemoteControlDispatchContext,
-): Promise {
- const engine = new CapletsEngine({ ...context, watch: false });
- try {
- return await engine.execute(
- stringArg(request.arguments.caplet, "caplet"),
- objectArg(request.arguments.request, "request"),
- );
- } finally {
- await engine.close();
- }
-}
-
-function dispatchAdd(args: Record, destinationRoot: string): unknown {
- const kind = stringArg(args.kind, "kind");
- const id = stringArg(args.id, "id");
- const options = objectArg(args.options, "options");
- const common = { ...options, destinationRoot, print: false };
- switch (kind) {
- case "cli":
- return { remote: true, label: "CLI", ...addCliCaplet(id, common) };
- case "mcp":
- return { remote: true, label: "MCP", ...addMcpCaplet(id, common) };
- case "openapi":
- return {
- remote: true,
- label: "OpenAPI",
- ...addOpenApiCaplet(id, common),
- };
- case "graphql":
- return {
- remote: true,
- label: "GraphQL",
- ...addGraphqlCaplet(id, common),
- };
- case "http":
- return { remote: true, label: "HTTP", ...addHttpCaplet(id, common) };
- default:
- throw new Error(`Unsupported add kind ${kind}`);
- }
-}
-
-function stringArg(value: unknown, key: string): string {
- if (typeof value !== "string" || value.length === 0)
- throw new Error(`Expected string argument ${key}`);
- return value;
-}
-
-function stringArrayArg(value: unknown): string[] | undefined {
- if (value === undefined) return undefined;
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
- throw new Error("Expected string array argument capletIds");
- }
- return value;
-}
-
-function objectArg(value: unknown, key: string): Record {
- if (!value || typeof value !== "object" || Array.isArray(value))
- throw new Error(`Expected object argument ${key}`);
- return value as Record;
-}
-```
-
-- [ ] **Step 5: Wire `/control` into HTTP app**
-
-Modify `packages/core/src/serve/http.ts`:
-
-```ts
-import { resolveProjectCapletsRoot } from "../config";
-import { dispatchRemoteCliRequest } from "../remote-control/dispatch";
-import type { RemoteCliRequest } from "../remote-control/types";
-```
-
-Add route after MCP route:
-
-```ts
-app.post(paths.control, basicAuth(options.auth), async (c) => {
- const request = (await c.req.json()) as RemoteCliRequest;
- const result = await dispatchRemoteCliRequest(request, {
- ...engineOptionsFromEngine(engine),
- projectCapletsRoot: resolveProjectCapletsRoot(),
- writeErr,
- });
- return c.json(result, result.ok ? 200 : 200);
-});
-```
-
-To avoid reverse-engineering private `CapletsEngine` options, change `createHttpServeApp()` signature to accept an optional dispatch context:
-
-```ts
-type HttpServeIo = {
- writeErr?: (value: string) => void;
- control?: Omit;
-};
-```
-
-Then pass `io.control ?? { projectCapletsRoot: resolveProjectCapletsRoot() }` to dispatch with `writeErr` merged. In `serveHttp()`, pass the same `engineOptions` values into `control`:
-
-```ts
-const app = createHttpServeApp(options, engine, {
- writeErr,
- control: {
- ...engineOptions,
- projectCapletsRoot: resolveProjectCapletsRoot(),
- },
-});
-```
-
-Update tests that need temp config to pass `control` options when creating the app.
-
-- [ ] **Step 6: Run dispatch and HTTP tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-dispatch.test.ts test/serve-http.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit control endpoint dispatch**
-
-Run:
-
-```sh
-git add packages/core/src/remote-control/dispatch.ts packages/core/src/serve/http.ts packages/core/test/remote-control-dispatch.test.ts packages/core/test/serve-http.test.ts
-git commit -m "feat(serve): add remote CLI control endpoint"
-```
-
----
-
-## Task 6: Route read/execute CLI commands through remote control
-
-**Files:**
-
-- Modify: `packages/core/src/cli.ts`
-- Test: `packages/core/test/cli-remote.test.ts`
-
-- [ ] **Step 1: Write failing CLI remote tests for list and direct operations**
-
-Create `packages/core/test/cli-remote.test.ts`:
-
-```ts
-import { describe, expect, it, vi } from "vitest";
-import { runCli } from "../src/cli";
-
-describe("remote CLI routing", () => {
- it("routes list through remote control when CAPLETS_MODE selects remote", async () => {
- const out: string[] = [];
- const fetchMock = vi.fn(
- async () =>
- new Response(
- JSON.stringify({
- ok: true,
- result: [
- {
- server: "github",
- backend: "mcp",
- name: "GitHub",
- description: "GitHub tools",
- disabled: false,
- status: "not_started",
- source: "global-config",
- path: "/srv/caplets/github.md",
- shadows: [],
- },
- ],
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- ),
- );
-
- await runCli(["list", "--json"], {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets",
- },
- fetch: fetchMock as typeof fetch,
- writeOut: (value) => out.push(value),
- });
-
- expect(JSON.parse(out.join(""))[0]).toMatchObject({ server: "github" });
- expect(fetchMock).toHaveBeenCalledWith(
- new URL("http://127.0.0.1:5387/caplets/control"),
- expect.objectContaining({
- body: JSON.stringify({
- command: "list",
- arguments: { includeDisabled: false },
- }),
- }),
- );
- });
-
- it("routes call-tool through remote control and preserves JSON formatting", async () => {
- const out: string[] = [];
- const fetchMock = vi.fn(
- async () =>
- new Response(
- JSON.stringify({
- ok: true,
- result: { structuredContent: { json: { ok: true } } },
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- ),
- );
-
- await runCli(["call-tool", "github.search", "--args", '{"q":"caplets"}', "--format", "json"], {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387",
- },
- fetch: fetchMock as typeof fetch,
- writeOut: (value) => out.push(value),
- });
-
- expect(JSON.parse(out.join(""))).toEqual({
- structuredContent: { json: { ok: true } },
- });
- expect(fetchMock).toHaveBeenCalledWith(
- new URL("http://127.0.0.1:5387/control"),
- expect.objectContaining({
- body: JSON.stringify({
- command: "call_tool",
- arguments: {
- caplet: "github",
- request: {
- operation: "call_tool",
- tool: "search",
- arguments: { q: "caplets" },
- },
- },
- }),
- }),
- );
- });
-
- it("keeps local-only config paths local even when remote is configured", async () => {
- const out: string[] = [];
- const fetchMock = vi.fn();
-
- await runCli(["config", "path"], {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387",
- },
- fetch: fetchMock as unknown as typeof fetch,
- writeOut: (value) => out.push(value),
- });
-
- expect(fetchMock).not.toHaveBeenCalled();
- expect(out.join("")).toContain("config.json");
- });
-});
-```
-
-- [ ] **Step 2: Run CLI remote tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-remote.test.ts
-```
-
-Expected: FAIL because `CliIO` has no `env` or `fetch`, and commands do not route remotely.
-
-- [ ] **Step 3: Add CLI IO seams and remote helpers**
-
-Modify `CliIO` in `packages/core/src/cli.ts`:
-
-```ts
-type CliIO = {
- writeOut?: (value: string) => void;
- writeErr?: (value: string) => void;
- authDir?: string;
- version?: string;
- setExitCode?: (code: number) => void;
- serve?: (options: ServeOptions) => Promise;
- env?: NodeJS.ProcessEnv | Record;
- fetch?: typeof fetch;
-};
-```
-
-Add imports:
-
-```ts
-import { RemoteControlClient } from "./remote-control/client";
-import type { RemoteCliCommand } from "./remote-control/types";
-import { resolveCapletsMode, resolveCapletsServer } from "./server/options";
-```
-
-Add helper:
-
-```ts
-function remoteClientForCli(io: CliIO): RemoteControlClient | undefined {
- const env = io.env ?? process.env;
- if (resolveCapletsMode({}, env).mode !== "remote") return undefined;
- const server = resolveCapletsServer({ fetch: io.fetch }, env);
- return new RemoteControlClient({
- baseUrl: server.baseUrl,
- requestInit: server.requestInit,
- ...(server.fetch ? { fetch: server.fetch } : {}),
- });
-}
-```
-
-Use `io.env` in `envConfigPath()` by changing it to accept env:
-
-```ts
-function envConfigPath(env: Record = process.env): string | undefined {
- return env.CAPLETS_CONFIG?.trim() || undefined;
-}
-```
-
-Replace calls to `envConfigPath()` inside `createProgram(io)` with `envConfigPath(io.env ?? process.env)`.
-
-- [ ] **Step 4: Route `list` remotely**
-
-In the `list` action, add:
-
-```ts
-const remote = remoteClientForCli(io);
-if (remote) {
- const rows = await remote.request("list", {
- includeDisabled: Boolean(options.all),
- });
- if (options.json || options.format === "json") {
- writeOut(`${JSON.stringify(rows, null, 2)}\n`);
- return;
- }
- writeOut(
- formatCapletList(rows as Parameters[0], options.format ?? "plain"),
- );
- return;
-}
-```
-
-Make the action async.
-
-- [ ] **Step 5: Route direct operation commands remotely**
-
-Modify `executeOperation()` to accept `remote?: RemoteControlClient`:
-
-```ts
-type ExecuteOperationIO = Required> & {
- authDir?: string | undefined;
- format?: CliOutputFormat | undefined;
- remote?: RemoteControlClient | undefined;
-};
-```
-
-At the start of `executeOperation()`:
-
-```ts
-const command = remoteCommandForOperation(request);
-if (io.remote && command) {
- const result = await io.remote.request(command, { caplet, request });
- const output = cliOutputForOperation(result, { ...request, caplet }, io.format ?? "markdown");
- io.writeOut(typeof output === "string" ? `${output}\n` : `${JSON.stringify(output, null, 2)}\n`);
- if (isPlainObject(result) && result.isError === true) io.setExitCode(1);
- return;
-}
-```
-
-Add helper:
-
-```ts
-function remoteCommandForOperation(request: Record): RemoteCliCommand | undefined {
- switch (request.operation) {
- case "get_caplet":
- case "check_backend":
- case "list_tools":
- case "search_tools":
- case "get_tool":
- case "call_tool":
- return request.operation;
- default:
- return undefined;
- }
-}
-```
-
-When each command calls `executeOperation`, pass `remote: remoteClientForCli(io)`.
-
-- [ ] **Step 6: Run CLI remote tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-remote.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit remote read/execute CLI routing**
-
-Run:
-
-```sh
-git add packages/core/src/cli.ts packages/core/test/cli-remote.test.ts
-git commit -m "feat(cli): route read commands through remote control"
-```
-
----
-
-## Task 7: Route mutating CLI commands through remote control
-
-**Files:**
-
-- Modify: `packages/core/src/cli.ts`
-- Test: `packages/core/test/cli-remote.test.ts`
-
-- [ ] **Step 1: Add failing mutation routing tests**
-
-Append to `packages/core/test/cli-remote.test.ts`:
-
-```ts
-it("routes add mcp through remote control and labels the remote path", async () => {
- const out: string[] = [];
- const fetchMock = vi.fn(
- async () =>
- new Response(
- JSON.stringify({
- ok: true,
- result: {
- remote: true,
- label: "MCP",
- path: "/srv/caplets/.caplets/github.md",
- text: "mcpServer:\n",
- },
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- ),
- );
-
- await runCli(
- ["add", "mcp", "github", "--url", "https://mcp.example.com/mcp", "--transport", "http"],
- {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387",
- },
- fetch: fetchMock as typeof fetch,
- writeOut: (value) => out.push(value),
- },
- );
-
- expect(out.join("")).toBe("Wrote remote MCP Caplet to /srv/caplets/.caplets/github.md\n");
- expect(fetchMock).toHaveBeenCalledWith(
- new URL("http://127.0.0.1:5387/control"),
- expect.objectContaining({
- body: JSON.stringify({
- command: "add",
- arguments: {
- kind: "mcp",
- id: "github",
- options: {
- url: "https://mcp.example.com/mcp",
- transport: "http",
- global: undefined,
- print: undefined,
- output: undefined,
- force: undefined,
- },
- },
- }),
- }),
- );
-});
-
-it("routes install through remote control", async () => {
- const out: string[] = [];
- const fetchMock = vi.fn(
- async () =>
- new Response(
- JSON.stringify({
- ok: true,
- result: {
- remote: true,
- installed: [
- {
- id: "github",
- destination: "/srv/caplets/.caplets/github",
- source: "repo#caplets/github",
- kind: "directory",
- },
- ],
- },
- }),
- { status: 200, headers: { "content-type": "application/json" } },
- ),
- );
-
- await runCli(["install", "spiritledsoftware/caplets", "github"], {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387",
- },
- fetch: fetchMock as typeof fetch,
- writeOut: (value) => out.push(value),
- });
-
- expect(out.join("")).toBe("Installed github to remote /srv/caplets/.caplets/github\n");
-});
-```
-
-- [ ] **Step 2: Run mutation routing tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-remote.test.ts
-```
-
-Expected: FAIL because mutating commands still call local helpers.
-
-- [ ] **Step 3: Add remote add result formatter**
-
-Modify `writeAddResult()` in `packages/core/src/cli.ts`:
-
-```ts
-function writeAddResult(
- writeOut: (value: string) => void,
- label: string,
- result: { path?: string; text: string; remote?: boolean },
-): void {
- if (result.path) {
- writeOut(`Wrote ${result.remote ? "remote " : ""}${label} Caplet to ${result.path}\n`);
- return;
- }
- writeOut(result.text);
-}
-```
-
-- [ ] **Step 4: Route add subcommands remotely**
-
-For each `add` subcommand action, compute `const remote = remoteClientForCli(io);` and, when present, call:
-
-```ts
-const result = (await remote.request("add", {
- kind: "mcp",
- id,
- options,
-})) as { path?: string; text: string; remote?: boolean };
-writeAddResult(writeOut, "MCP", result);
-return;
-```
-
-Use `kind: "cli"`, `"openapi"`, `"graphql"`, and `"http"` for the other add subcommands. Do not pass `destinationRoot`; the server dispatch owns server-side destinations.
-
-- [ ] **Step 5: Route install remotely**
-
-In the `install` action, add remote branch before local `installCaplets()`:
-
-```ts
-const remote = remoteClientForCli(io);
-if (remote) {
- const result = (await remote.request("install", {
- repo,
- capletIds,
- force: Boolean(options.force),
- })) as { installed: Array<{ id: string; destination: string }> };
- for (const caplet of result.installed) {
- writeOut(`Installed ${caplet.id} to remote ${caplet.destination}\n`);
- }
- return;
-}
-```
-
-- [ ] **Step 6: Route init remotely when remote mode is active**
-
-In the `init` action, add remote branch:
-
-```ts
-const remote = remoteClientForCli(io);
-if (remote) {
- const result = (await remote.request("init", {
- force: Boolean(options.force),
- })) as {
- path: string;
- remote: true;
- };
- writeOut(`Created remote Caplets config at ${result.path}\n`);
- return;
-}
-```
-
-- [ ] **Step 7: Run mutation routing tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-remote.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 8: Commit remote mutation routing**
-
-Run:
-
-```sh
-git add packages/core/src/cli.ts packages/core/test/cli-remote.test.ts
-git commit -m "feat(cli): route mutations through remote control"
-```
-
----
-
-## Task 8: Add remote auth list/logout and remote auth login flow
-
-**Files:**
-
-- Create: `packages/core/src/remote-control/auth-flow.ts`
-- Modify: `packages/core/src/remote-control/dispatch.ts`
-- Modify: `packages/core/src/serve/http.ts`
-- Modify: `packages/core/src/cli/auth.ts`
-- Modify: `packages/core/src/cli.ts`
-- Test: `packages/core/test/remote-control-dispatch.test.ts`
-- Test: `packages/core/test/cli-remote.test.ts`
-
-- [ ] **Step 1: Expose structured local auth helpers**
-
-Modify `packages/core/src/cli/auth.ts` to export result-producing helpers in addition to current printing wrappers:
-
-```ts
-export type AuthStatusRow = {
- server: string;
- status: "missing" | "expired" | "authenticated";
- expiresAt?: string;
- scope?: string;
-};
-
-export function listAuthRows(options: { authDir?: string; configPath?: string }): AuthStatusRow[] {
- const config = loadConfig(options.configPath);
- return authTargets(config)
- .sort((left, right) => left.server.localeCompare(right.server))
- .map((server) => {
- const bundle = readTokenBundle(server.server, options.authDir);
- const status = !bundle
- ? "missing"
- : isTokenBundleExpired(bundle)
- ? "expired"
- : "authenticated";
- return {
- server: server.server,
- status,
- ...(bundle?.expiresAt ? { expiresAt: bundle.expiresAt } : {}),
- ...(bundle?.scope ? { scope: bundle.scope } : {}),
- };
- });
-}
-
-export function logoutAuthResult(
- serverId: string,
- options: { authDir?: string; configPath?: string },
-): { server: string; deleted: boolean } {
- const target = findAuthTarget(serverId, loadConfig(options.configPath));
- assertLoginTarget(target, serverId);
- return {
- server: serverId,
- deleted: deleteTokenBundle(serverId, options.authDir),
- };
-}
-```
-
-Change existing `listAuth()` and `logoutAuth()` to use these helpers and preserve exact current output.
-
-- [ ] **Step 2: Add failing remote auth tests**
-
-Append to `packages/core/test/remote-control-dispatch.test.ts`:
-
-```ts
-it("lists and logs out server-side auth credentials", async () => {
- const fixture = remoteFixtureWithOAuth();
- writeTokenBundle(
- {
- server: "remote",
- accessToken: "secret-access-token",
- expiresAt: "2999-01-01T00:00:00.000Z",
- },
- fixture.context.authDir,
- );
-
- const listed = await dispatchRemoteCliRequest(
- { command: "auth_list", arguments: {} },
- fixture.context,
- );
- expect(listed).toEqual({
- ok: true,
- result: [expect.objectContaining({ server: "remote", status: "authenticated" })],
- });
-
- const loggedOut = await dispatchRemoteCliRequest(
- { command: "auth_logout", arguments: { server: "remote" } },
- fixture.context,
- );
- expect(loggedOut).toEqual({
- ok: true,
- result: { server: "remote", deleted: true },
- });
-});
-```
-
-Add helper config with an OAuth remote MCP server and import `writeTokenBundle`.
-
-Append to `packages/core/test/cli-remote.test.ts`:
-
-```ts
-it("routes auth list and logout through remote control", async () => {
- const out: string[] = [];
- const fetchMock = vi
- .fn()
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- ok: true,
- result: [{ server: "remote", status: "authenticated" }],
- }),
- {
- status: 200,
- headers: { "content-type": "application/json" },
- },
- ),
- )
- .mockResolvedValueOnce(
- new Response(
- JSON.stringify({
- ok: true,
- result: { server: "remote", deleted: true },
- }),
- {
- status: 200,
- headers: { "content-type": "application/json" },
- },
- ),
- );
-
- const io = {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387",
- },
- fetch: fetchMock as typeof fetch,
- writeOut: (value: string) => out.push(value),
- };
-
- await runCli(["auth", "list", "--json"], io);
- await runCli(["auth", "logout", "remote"], io);
-
- expect(JSON.parse(out[0]!)).toEqual([{ server: "remote", status: "authenticated" }]);
- expect(out[1]).toBe("Deleted remote OAuth credentials for `remote`.\n");
-});
-```
-
-- [ ] **Step 3: Run remote auth tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-dispatch.test.ts test/cli-remote.test.ts
-```
-
-Expected: FAIL because auth dispatch and CLI auth routing do not exist.
-
-- [ ] **Step 4: Implement auth list/logout dispatch**
-
-Modify `packages/core/src/remote-control/dispatch.ts` imports:
-
-```ts
-import { listAuthRows, logoutAuthResult } from "../cli/auth";
-```
-
-Add cases:
-
-```ts
-case "auth_list":
- return listAuthRows({ configPath: context.configPath, authDir: context.authDir });
-case "auth_logout":
- return logoutAuthResult(stringArg(request.arguments.server, "server"), {
- configPath: context.configPath,
- authDir: context.authDir,
- });
-```
-
-- [ ] **Step 5: Implement CLI auth list/logout routing**
-
-In `packages/core/src/cli.ts`, route `auth list` remotely:
-
-```ts
-const remote = remoteClientForCli(io);
-if (remote) {
- const rows = await remote.request("auth_list", {});
- if (format === "json") {
- writeOut(`${JSON.stringify(rows, null, 2)}\n`);
- return;
- }
- writeOut(formatAuthRows(rows as AuthStatusRow[], format));
- return;
-}
-```
-
-Extract current `listAuth` formatting into an exported helper or keep a small CLI-local formatter matching existing text.
-
-Route `auth logout` remotely:
-
-```ts
-const remote = remoteClientForCli(io);
-if (remote) {
- const result = (await remote.request("auth_logout", {
- server: serverId,
- })) as {
- deleted: boolean;
- };
- writeOut(
- result.deleted
- ? `Deleted remote OAuth credentials for \`${serverId}\`.\n`
- : `No remote OAuth credentials found for \`${serverId}\`.\n`,
- );
- return;
-}
-```
-
-- [ ] **Step 6: Add remote auth login flow store**
-
-Create `packages/core/src/remote-control/auth-flow.ts`:
-
-```ts
-import { randomUUID } from "node:crypto";
-
-export type RemoteAuthFlow = {
- id: string;
- server: string;
- authorizationUrl: string;
- createdAt: number;
- complete(callbackUrl: string): Promise;
-};
-
-export class RemoteAuthFlowStore {
- private readonly flows = new Map();
-
- create(flow: Omit): RemoteAuthFlow {
- const created: RemoteAuthFlow = {
- id: randomUUID(),
- createdAt: Date.now(),
- ...flow,
- };
- this.flows.set(created.id, created);
- return created;
- }
-
- get(id: string): RemoteAuthFlow | undefined {
- return this.flows.get(id);
- }
-
- delete(id: string): void {
- this.flows.delete(id);
- }
-}
-```
-
-- [ ] **Step 7: Refactor OAuth start/complete for remote callbacks**
-
-Modify `packages/core/src/auth.ts` to add start/complete helpers that use an externally supplied redirect URI. Add these exports:
-
-```ts
-export type StartedOAuthFlow = {
- authorizationUrl: string;
- complete(callbackUrl: string): Promise;
-};
-
-export async function startOAuthFlow(
- server: CapletServerConfig,
- options: {
- redirectUri: string;
- authDir?: string;
- print?: (line: string) => void;
- },
-): Promise;
-
-export async function startGenericOAuthFlow(
- target: GenericAuthTarget,
- options: {
- redirectUri: string;
- authDir?: string;
- print?: (line: string) => void;
- },
-): Promise;
-```
-
-For MCP OAuth, reuse `FileOAuthProvider` with `redirectUri`. Capture `redirectUrl` from `redirectToAuthorization`. Return `complete(callbackUrl)` that extracts `code` and `state`, validates state, calls `auth(provider, { serverUrl: server.url, authorizationCode: code, scope })`, and stores tokens through the existing provider.
-
-For generic OAuth, move the URL construction and token exchange code from `runGenericOAuthFlow()` into the new start helper. Return `complete(callbackUrl)` that extracts `code` and `state`, validates state, exchanges the authorization code against the token endpoint, and writes the token bundle through existing `writeTokenBundle()` logic.
-
-Then rewrite existing `runOAuthFlow()` and `runGenericOAuthFlow()` as wrappers that create the loopback callback, call the new start helper, open/print the returned authorization URL, wait for callback/manual input, call `complete()`, and preserve existing output behavior.
-
-- [ ] **Step 8: Implement auth login dispatch and HTTP callback**
-
-Add `authFlowStore?: RemoteAuthFlowStore` to `HttpServeIo` and create a default store inside `createHttpServeApp()`.
-
-In `dispatchRemoteCliRequestUnsafe()` add:
-
-```ts
-case "auth_login_start":
- return await startRemoteAuthLogin(stringArg(request.arguments.server, "server"), context);
-case "auth_login_complete":
- return await completeRemoteAuthLogin(
- stringArg(request.arguments.flowId, "flowId"),
- stringArg(request.arguments.callbackUrl, "callbackUrl"),
- context,
- );
-```
-
-The start result shape:
-
-```ts
-{
- server: string;
- flowId: string;
- authorizationUrl: string;
-}
-```
-
-Add route in `packages/core/src/serve/http.ts`:
-
-```ts
-app.get(routePath(paths.control, "auth/callback/:flowId"), async (c) => {
- const flowId = c.req.param("flowId");
- const callbackUrl = c.req.url;
- const result = await dispatchRemoteCliRequest(
- { command: "auth_login_complete", arguments: { flowId, callbackUrl } },
- controlContext,
- );
- return result.ok
- ? c.text("Caplets authentication complete. You can return to your terminal.")
- : c.text(result.error.message, 400);
-});
-```
-
-- [ ] **Step 9: Implement CLI remote auth login**
-
-In `auth login` action, remote branch:
-
-```ts
-const remote = remoteClientForCli(io);
-if (remote) {
- const started = (await remote.request("auth_login_start", {
- server: serverId,
- })) as {
- server: string;
- flowId: string;
- authorizationUrl: string;
- };
- writeOut(`Open this URL to authorize ${serverId}:\n${started.authorizationUrl}\n`);
- if (options.open !== false) {
- await openBrowser(started.authorizationUrl);
- }
- writeOut(`Authenticated \`${serverId}\`.\n`);
- return;
-}
-```
-
-Reuse the existing browser-opening helper or extract one from `auth.ts`. For v1, the browser callback completes against the server route, so the CLI does not receive tokens.
-
-- [ ] **Step 10: Run auth-focused tests**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/auth.test.ts test/remote-control-dispatch.test.ts test/cli-remote.test.ts test/serve-http.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 11: Commit remote auth support**
-
-Run:
-
-```sh
-git add packages/core/src/auth.ts packages/core/src/cli/auth.ts packages/core/src/cli.ts packages/core/src/remote-control/auth-flow.ts packages/core/src/remote-control/dispatch.ts packages/core/src/serve/http.ts packages/core/test/remote-control-dispatch.test.ts packages/core/test/cli-remote.test.ts packages/core/test/serve-http.test.ts
-git commit -m "feat(cli): store remote auth credentials on server"
-```
-
----
-
-## Task 9: Add end-to-end remote CLI coverage
-
-**Files:**
-
-- Modify: `packages/core/test/cli-remote.test.ts`
-
-- [ ] **Step 1: Add in-process remote server CLI test**
-
-Append an integration-style test to `packages/core/test/cli-remote.test.ts`:
-
-```ts
-it("uses a remote in-process control app without mutating local config", async () => {
- const remote = makeRemoteServerFixture();
- const local = makeLocalClientFixture();
- const out: string[] = [];
-
- await runCli(["add", "mcp", "remote-tools", "--url", "https://mcp.example.com/mcp"], {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets",
- CAPLETS_CONFIG: local.configPath,
- },
- fetch: remote.app.fetch.bind(remote.app) as typeof fetch,
- writeOut: (value) => out.push(value),
- });
-
- expect(existsSync(join(remote.projectRoot, ".caplets", "remote-tools.md"))).toBe(true);
- expect(existsSync(join(local.projectRoot, ".caplets", "remote-tools.md"))).toBe(false);
- expect(out.join("")).toContain("remote");
-
- await remote.engine.close();
-});
-```
-
-Implement `makeRemoteServerFixture()` and `makeLocalClientFixture()` in the test file using the patterns from `serve-http.test.ts` and `cli.test.ts`.
-
-- [ ] **Step 2: Run end-to-end remote CLI test**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-remote.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 3: Commit end-to-end coverage**
-
-Run:
-
-```sh
-git add packages/core/test/cli-remote.test.ts
-git commit -m "test(cli): cover remote control server routing"
-```
-
----
-
-## Task 10: Update documentation and release metadata
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `packages/cli/README.md`
-- Modify: `packages/opencode/README.md`
-- Modify: `packages/pi/README.md`
-- Create: `.changeset/remote-cli-control.md`
-
-- [ ] **Step 1: Update root and CLI README remote service docs**
-
-In `README.md` and `packages/cli/README.md`, replace old remote env examples with:
-
-```sh
-CAPLETS_MODE=remote \
-CAPLETS_SERVER_URL=https://caplets.example.com/caplets \
-CAPLETS_SERVER_USER=caplets \
-CAPLETS_SERVER_PASSWORD=... \
-caplets list
-```
-
-Document endpoints:
-
-```text
-CAPLETS_SERVER_URL=https://caplets.example.com/caplets
-MCP endpoint: https://caplets.example.com/caplets/mcp
-Control endpoint: https://caplets.example.com/caplets/control
-Health endpoint: https://caplets.example.com/caplets/healthz
-```
-
-Document serving:
-
-```sh
-CAPLETS_SERVER_URL=http://127.0.0.1:5387/caplets \
-CAPLETS_SERVER_PASSWORD=... \
-caplets serve --transport http
-```
-
-State that `--path` is the service base path.
-
-- [ ] **Step 2: Update OpenCode and Pi READMEs**
-
-In `packages/opencode/README.md` and `packages/pi/README.md`, document:
-
-```sh
-CAPLETS_MODE=remote
-CAPLETS_SERVER_URL=https://caplets.example.com/caplets
-CAPLETS_SERVER_USER=caplets
-CAPLETS_SERVER_PASSWORD=...
-```
-
-Show explicit config shape:
-
-```json
-{
- "mode": "remote",
- "server": {
- "url": "https://caplets.example.com/caplets",
- "user": "caplets"
- },
- "remote": {
- "pollIntervalMs": 30000
- }
-}
-```
-
-- [ ] **Step 3: Add changeset**
-
-Create `.changeset/remote-cli-control.md`:
-
-```md
----
-"@caplets/core": minor
-"caplets": minor
-"@caplets/opencode": minor
-"@caplets/pi": minor
----
-
-Add unified Caplets server configuration and remote CLI control support. `caplets serve --transport http` now mounts a service base path with `/mcp`, `/control`, and `/healthz` subroutes, while CLI and native integrations can use `CAPLETS_MODE` plus `CAPLETS_SERVER_*` settings to operate against a remote Caplets service.
-```
-
-- [ ] **Step 4: Run docs format check**
-
-Run:
-
-```sh
-pnpm exec oxfmt --check README.md packages/cli/README.md packages/opencode/README.md packages/pi/README.md .changeset/remote-cli-control.md
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit docs and changeset**
-
-Run:
-
-```sh
-git add README.md packages/cli/README.md packages/opencode/README.md packages/pi/README.md .changeset/remote-cli-control.md
-git commit -m "docs: document remote CLI control mode"
-```
-
----
-
-## Task 11: Final verification
-
-**Files:**
-
-- No source files changed in this task unless verification finds defects.
-
-- [ ] **Step 1: Run LSP diagnostics**
-
-Run diagnostics before package commands:
-
-```text
-lsp_diagnostics packages/core/src
-lsp_diagnostics packages/opencode/src
-lsp_diagnostics packages/pi/src
-```
-
-Expected: no TypeScript errors.
-
-- [ ] **Step 2: Run focused test suites**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/server-options.test.ts test/serve-options.test.ts test/serve-http.test.ts test/remote-control-client.test.ts test/remote-control-dispatch.test.ts test/cli-remote.test.ts test/native-options.test.ts test/native-remote.test.ts test/auth.test.ts
-pnpm --filter @caplets/opencode test -- opencode.test.ts
-pnpm --filter @caplets/pi test -- pi.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 3: Run package checks**
-
-Run:
-
-```sh
-pnpm format:check
-pnpm lint
-pnpm typecheck
-pnpm test
-pnpm schema:check
-pnpm build
-```
-
-Expected: PASS.
-
-- [ ] **Step 4: Run full verification gate**
-
-Run:
-
-```sh
-pnpm verify
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Inspect final git state**
-
-Run:
-
-```sh
-git status --short
-git log --oneline -12
-```
-
-Expected: only intentional tracked changes are present, and commits from this plan appear in history.
-
----
-
-## Self-review
-
-- Spec coverage: Tasks 1 and 2 cover unified environment and native/CLI mode selection. Task 3 covers base-path HTTP serving. Tasks 4 and 5 cover the structured `/control` API and command-semantic server dispatch. Tasks 6 and 7 cover remote CLI read, execute, and mutation routing. Task 8 covers server-owned downstream auth credentials and remote auth login/list/logout. Task 9 verifies remote server state changes without local mutation. Task 10 covers docs and release metadata. Task 11 covers verification.
-- Placeholder scan: The plan contains no placeholders, deferred requirements, or unspecified test commands.
-- Type consistency: `CapletsMode`, `CapletsServerInput`, `ResolvedCapletsServer`, `RemoteCliRequest`, `RemoteCliResponse`, `RemoteControlClient`, `RemoteControlDispatchContext`, and `RemoteAuthFlowStore` are introduced before later tasks consume them.
diff --git a/docs/plans/2026-05-21-cli-completions.md b/docs/plans/2026-05-21-cli-completions.md
deleted file mode 100644
index 9a4313d6..00000000
--- a/docs/plans/2026-05-21-cli-completions.md
+++ /dev/null
@@ -1,746 +0,0 @@
-# CLI Completions Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add Bash, Zsh, and Fish completion support to the `caplets` npm CLI with static command/option suggestions and safe config-aware Caplet ID suggestions.
-
-**Architecture:** Put completion script generation and completion resolution in a focused core module, then wire it into the existing Commander CLI through a public `completion` command and hidden `__complete` command. Remote mode uses the structured `/control` API with a `complete_cli` command so completion remains server-owned and never executes raw CLI strings remotely.
-
-**Tech Stack:** TypeScript, Commander, Vitest, Caplets remote control, pnpm 11.0.9.
-
----
-
-## Scope and constraints
-
-- Use `pnpm` only.
-- Implement completion support in `@caplets/core` because `packages/cli` delegates CLI behavior to core.
-- Support `bash`, `zsh`, and `fish`.
-- Do not add npm `postinstall` behavior.
-- Do not edit user shell startup files automatically.
-- Do not start downstream MCP servers, run OpenAPI/GraphQL/HTTP calls, or execute configured CLI tools while completing.
-- Keep completion failures quiet for `__complete`; explicit `completion ` errors may fail clearly.
-- Remote mode must use command-semantic `/control`, not raw command strings.
-- Add a changeset because this is a user-facing CLI feature.
-
-## File structure
-
-- Create `packages/core/src/cli/completion.ts`
- - Own supported shell names, static completion tables, shell script generation, and completion resolution.
-- Modify `packages/core/src/cli.ts`
- - Add `completion ` and hidden `__complete` commands.
- - Route hidden completion to remote mode when applicable.
-- Modify `packages/core/src/remote-control/types.ts`
- - Add `complete_cli` to `RemoteCliCommand`.
-- Modify `packages/core/src/remote-control/dispatch.ts`
- - Dispatch `complete_cli` using server-owned config and the shared completion resolver.
-- Add `packages/core/test/cli-completion.test.ts`
- - Test shell script emission and local resolver behavior.
-- Modify `packages/core/test/cli-remote.test.ts`
- - Test remote-mode hidden completion routing.
-- Modify `packages/core/test/remote-control-dispatch.test.ts`
- - Test server-side `complete_cli` dispatch.
-- Modify `README.md` and `packages/cli/README.md`
- - Document completion installation snippets.
-- Add `.changeset/cli-completions.md`
- - Release note for `caplets`; include `@caplets/core` if exported APIs are changed.
-
----
-
-### Task 1: Add completion resolver and script generator
-
-**Files:**
-
-- Create: `packages/core/src/cli/completion.ts`
-- Test: `packages/core/test/cli-completion.test.ts`
-
-- [ ] **Step 1: Write failing resolver and script tests**
-
-Create `packages/core/test/cli-completion.test.ts`:
-
-```ts
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { dirname, join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import { completeCliWords, completionScript } from "../src/cli/completion";
-
-const dirs: string[] = [];
-
-afterEach(() => {
- for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
-});
-
-describe("CLI completion scripts", () => {
- it("emits Bash, Zsh, and Fish scripts that call caplets __complete", () => {
- expect(completionScript("bash")).toContain("caplets __complete --shell bash");
- expect(completionScript("bash")).toContain(
- "complete -o default -F _caplets_completions caplets",
- );
-
- expect(completionScript("zsh")).toContain("#compdef caplets");
- expect(completionScript("zsh")).toContain("caplets __complete --shell zsh");
-
- expect(completionScript("fish")).toContain("complete -c caplets");
- expect(completionScript("fish")).toContain("caplets __complete --shell fish");
- });
-
- it("rejects unknown shells for explicit script generation", () => {
- expect(() => completionScript("powershell" as never)).toThrow(
- expect.objectContaining({ code: "REQUEST_INVALID" }),
- );
- });
-});
-
-describe("CLI completion resolver", () => {
- it("suggests top-level commands", () => {
- expect(completeCliWords([""])).toEqual(
- expect.arrayContaining(["add", "auth", "call-tool", "completion", "serve"]),
- );
- });
-
- it("suggests nested static subcommands and enum values", () => {
- expect(completeCliWords(["add", ""])).toEqual(["cli", "mcp", "openapi", "graphql", "http"]);
- expect(completeCliWords(["completion", ""])).toEqual(["bash", "zsh", "fish"]);
- expect(completeCliWords(["serve", "--transport", ""])).toEqual(["stdio", "http"]);
- expect(completeCliWords(["call-tool", "github.search", "--format", ""])).toEqual([
- "markdown",
- "md",
- "plain",
- "json",
- ]);
- });
-
- it("suggests enabled Caplet IDs from local config", () => {
- const dir = mkdtempSync(join(tmpdir(), "caplets-completion-"));
- dirs.push(dir);
- const configPath = join(dir, "config.json");
- mkdirSync(dirname(configPath), { recursive: true });
- writeFileSync(
- configPath,
- JSON.stringify({
- mcpServers: {
- github: { name: "GitHub", description: "GitHub", command: "node" },
- disabled: { name: "Disabled", description: "Disabled", command: "node", disabled: true },
- },
- cliTools: {
- repo: { name: "Repo", description: "Repo", actions: {} },
- },
- }),
- );
-
- expect(completeCliWords(["get-caplet", ""], { configPath })).toEqual(["github", "repo"]);
- expect(completeCliWords(["call-tool", "git"], { configPath })).toEqual(["github."]);
- expect(completeCliWords(["auth", "login", ""], { configPath })).toEqual(["github", "repo"]);
- });
-
- it("returns no suggestions instead of throwing when config loading fails", () => {
- expect(completeCliWords(["get-caplet", ""], { configPath: "/missing/config.json" })).toEqual(
- [],
- );
- });
-});
-```
-
-- [ ] **Step 2: Run focused tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts
-```
-
-Expected: FAIL because `packages/core/src/cli/completion.ts` does not exist.
-
-- [ ] **Step 3: Implement completion module**
-
-Create `packages/core/src/cli/completion.ts`:
-
-```ts
-import { loadConfigWithSources } from "../config";
-import { CapletsError } from "../errors";
-import { listCaplets } from "./inspection";
-
-export const completionShells = ["bash", "zsh", "fish"] as const;
-export type CompletionShell = (typeof completionShells)[number];
-
-export type CompletionOptions = {
- configPath?: string;
- projectConfigPath?: string;
-};
-
-const topLevelCommands = [
- "serve",
- "init",
- "list",
- "install",
- "add",
- "get-caplet",
- "check-backend",
- "list-tools",
- "search-tools",
- "get-tool",
- "call-tool",
- "list-resources",
- "search-resources",
- "list-resource-templates",
- "read-resource",
- "list-prompts",
- "search-prompts",
- "get-prompt",
- "complete",
- "config",
- "auth",
- "completion",
-];
-
-const subcommands: Record = {
- add: ["cli", "mcp", "openapi", "graphql", "http"],
- auth: ["login", "logout", "list"],
- completion: ["bash", "zsh", "fish"],
- config: ["path", "paths"],
-};
-
-const optionValueSuggestions: Record> = {
- "*": {
- "--format": ["markdown", "md", "plain", "json"],
- },
- serve: {
- "--transport": ["stdio", "http"],
- },
- "add:mcp": {
- "--transport": ["http", "sse"],
- },
- "add:cli": {
- "--include": ["git", "gh", "package"],
- },
-};
-
-const capletIdCommands = new Set([
- "get-caplet",
- "check-backend",
- "list-tools",
- "search-tools",
- "list-resources",
- "search-resources",
- "list-resource-templates",
- "read-resource",
- "list-prompts",
- "search-prompts",
- "complete",
-]);
-
-const qualifiedTargetCommands = new Set(["get-tool", "call-tool", "get-prompt"]);
-
-export function completionScript(shell: CompletionShell): string {
- switch (shell) {
- case "bash":
- return bashCompletionScript();
- case "zsh":
- return zshCompletionScript();
- case "fish":
- return fishCompletionScript();
- default:
- throw new CapletsError("REQUEST_INVALID", "completion shell must be bash, zsh, or fish");
- }
-}
-
-export function completeCliWords(words: string[], options: CompletionOptions = {}): string[] {
- try {
- const normalized = words.length === 0 ? [""] : words;
- const current = normalized.at(-1) ?? "";
- const previous = normalized.at(-2);
- const command = normalized[0] ?? "";
- const subcommand = normalized[1] ?? "";
-
- const optionValues = suggestionsForOptionValue(command, subcommand, previous);
- if (optionValues) return prefixFilter(optionValues, current);
-
- if (normalized.length === 1) return prefixFilter(topLevelCommands, current);
-
- if (normalized.length === 2 && subcommands[command]) {
- return prefixFilter(subcommands[command], current);
- }
-
- if (normalized.length === 2 && capletIdCommands.has(command)) {
- return prefixFilter(configuredCapletIds(options), current);
- }
-
- if (normalized.length === 2 && qualifiedTargetCommands.has(command)) {
- return prefixFilter(
- configuredCapletIds(options).map((id) => `${id}.`),
- current,
- );
- }
-
- if (command === "auth" && ["login", "logout"].includes(subcommand) && normalized.length === 3) {
- return prefixFilter(configuredCapletIds(options), current);
- }
-
- return [];
- } catch {
- return [];
- }
-}
-
-function suggestionsForOptionValue(
- command: string,
- subcommand: string,
- previous: string | undefined,
-): string[] | undefined {
- if (!previous) return undefined;
- return (
- optionValueSuggestions[`${command}:${subcommand}`]?.[previous] ??
- optionValueSuggestions[command]?.[previous] ??
- optionValueSuggestions["*"]?.[previous]
- );
-}
-
-function configuredCapletIds(options: CompletionOptions): string[] {
- const loaded = loadConfigWithSources(options.configPath, options.projectConfigPath);
- return listCaplets(loaded, { includeDisabled: false }).map((row) => row.server);
-}
-
-function prefixFilter(values: string[], prefix: string): string[] {
- return values.filter((value) => value.startsWith(prefix));
-}
-
-function bashCompletionScript(): string {
- return `# caplets bash completion
-_caplets_completions() {
- local IFS=$'\\n'
- COMPREPLY=( $(caplets __complete --shell bash -- "${COMP_WORDS[@]:1}") )
-}
-complete -o default -F _caplets_completions caplets
-`;
-}
-
-function zshCompletionScript(): string {
- return `#compdef caplets
-_caplets() {
- local -a suggestions
- suggestions=("${(@f)$(caplets __complete --shell zsh -- "${words[@]:1}")}")
- compadd -- $suggestions
-}
-_caplets "$@"
-`;
-}
-
-function fishCompletionScript(): string {
- return `# caplets fish completion
-function __caplets_complete
- set -l tokens (commandline -opc)
- set -l current (commandline -ct)
- caplets __complete --shell fish -- $tokens[2..-1] $current
-end
-complete -c caplets -f -a '(__caplets_complete)'
-`;
-}
-```
-
-- [ ] **Step 4: Run focused tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit resolver module**
-
-```sh
-git add packages/core/src/cli/completion.ts packages/core/test/cli-completion.test.ts
-git commit -m "feat(cli): add completion resolver"
-```
-
----
-
-### Task 2: Wire public and hidden CLI commands
-
-**Files:**
-
-- Modify: `packages/core/src/cli.ts`
-- Test: `packages/core/test/cli.test.ts`
-
-- [ ] **Step 1: Write failing CLI command tests**
-
-Add this `describe` block to `packages/core/test/cli.test.ts` before helper functions:
-
-```ts
-describe("cli completion commands", () => {
- it("prints completion scripts", async () => {
- const out: string[] = [];
-
- await runCli(["completion", "bash"], { writeOut: (value) => out.push(value) });
-
- expect(out.join("")).toContain("caplets __complete --shell bash");
- expect(out.join("")).toContain("complete -o default -F _caplets_completions caplets");
- });
-
- it("runs the hidden completion endpoint", async () => {
- const out: string[] = [];
-
- await runCli(["__complete", "--shell", "bash", "--", "add", ""], {
- writeOut: (value) => out.push(value),
- });
-
- expect(out.join("").split("\n").filter(Boolean)).toEqual([
- "cli",
- "mcp",
- "openapi",
- "graphql",
- "http",
- ]);
- });
-
- it("uses configured Caplet IDs in local completion", async () => {
- const dir = mkdtempSync(join(tmpdir(), "caplets-cli-completion-"));
- const configPath = join(dir, "config.json");
- const out: string[] = [];
- try {
- writeInspectionConfig(configPath);
- await runCli(["__complete", "--shell", "bash", "--", "get-caplet", ""], {
- env: { CAPLETS_CONFIG: configPath },
- writeOut: (value) => out.push(value),
- });
-
- expect(out.join("").split("\n").filter(Boolean)).toEqual(["catalog", "filesystem", "users"]);
- } finally {
- rmSync(dir, { recursive: true, force: true });
- }
- });
-});
-```
-
-- [ ] **Step 2: Run focused tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli.test.ts
-```
-
-Expected: FAIL because `completion` and `__complete` commands are not registered.
-
-- [ ] **Step 3: Import completion helpers**
-
-Modify the imports near the top of `packages/core/src/cli.ts`:
-
-```ts
-import {
- completeCliWords,
- completionScript,
- completionShells,
- type CompletionShell,
-} from "./cli/completion";
-```
-
-- [ ] **Step 4: Register `completion` and `__complete` commands**
-
-Add these commands in `createProgram()` after the base `program` configuration and before `serve`:
-
-```ts
-program
- .command("completion")
- .description("Print a shell completion script.")
- .argument("", "completion shell: bash, zsh, or fish")
- .action((shell: string) => {
- if (!completionShells.includes(shell as CompletionShell)) {
- throw new CapletsError("REQUEST_INVALID", "completion shell must be bash, zsh, or fish");
- }
- writeOut(completionScript(shell as CompletionShell));
- });
-
-program
- .command("__complete")
- .description("Internal shell completion endpoint.")
- .hideHelp()
- .option("--shell ", "completion shell")
- .allowUnknownOption(true)
- .argument("[words...]", "words to complete")
- .action(async (words: string[], options: { shell?: string }) => {
- const shell = completionShells.includes(options.shell as CompletionShell)
- ? (options.shell as CompletionShell)
- : "bash";
- const remote = remoteClientForCli(io);
- const suggestions = remote
- ? ((await remote.request("complete_cli", { shell, words })) as string[])
- : completeCliWords(words, { configPath: currentConfigPath() });
- if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`);
- });
-```
-
-- [ ] **Step 5: Run focused tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit CLI wiring**
-
-```sh
-git add packages/core/src/cli.ts packages/core/test/cli.test.ts
-git commit -m "feat(cli): expose shell completion commands"
-```
-
----
-
-### Task 3: Add structured remote completion support
-
-**Files:**
-
-- Modify: `packages/core/src/remote-control/types.ts`
-- Modify: `packages/core/src/remote-control/dispatch.ts`
-- Test: `packages/core/test/remote-control-dispatch.test.ts`
-- Test: `packages/core/test/cli-remote.test.ts`
-
-- [ ] **Step 1: Write failing remote dispatch test**
-
-Add to `packages/core/test/remote-control-dispatch.test.ts`:
-
-```ts
-it("dispatches complete_cli using server-owned config", async () => {
- const context = testContext();
- writeFileSync(
- context.configPath,
- JSON.stringify({
- mcpServers: {
- github: { name: "GitHub", description: "GitHub", command: "node" },
- },
- httpApis: {
- users: {
- name: "Users",
- description: "Users",
- baseUrl: "https://api.example.com",
- actions: {},
- },
- },
- }),
- );
-
- const response = await dispatchRemoteCliRequest(
- { command: "complete_cli", arguments: { shell: "bash", words: ["get-caplet", ""] } },
- context,
- );
-
- expect(response).toEqual({ ok: true, result: ["github", "users"] });
-});
-```
-
-- [ ] **Step 2: Write failing remote CLI routing test**
-
-Add to `packages/core/test/cli-remote.test.ts` inside `describe("remote CLI routing", ...)`:
-
-```ts
-it("routes hidden completion through remote control in remote mode", async () => {
- const requests: unknown[] = [];
- const out: string[] = [];
- const fetch = vi.fn(async (_url: Parameters[0], init?: RequestInit) => {
- requests.push(JSON.parse(String(init?.body ?? "{}")));
- return Response.json({ ok: true, result: ["github", "linear"] });
- });
-
- await runCli(["__complete", "--shell", "bash", "--", "get-caplet", ""], {
- env: {
- CAPLETS_MODE: "remote",
- CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets",
- },
- fetch,
- writeOut: (value) => out.push(value),
- });
-
- expect(requests).toEqual([
- { command: "complete_cli", arguments: { shell: "bash", words: ["get-caplet", ""] } },
- ]);
- expect(out.join("")).toBe("github\nlinear\n");
-});
-```
-
-- [ ] **Step 3: Run focused tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-dispatch.test.ts test/cli-remote.test.ts
-```
-
-Expected: FAIL because `complete_cli` is not a valid remote command.
-
-- [ ] **Step 4: Add remote command type**
-
-Modify `packages/core/src/remote-control/types.ts` by adding `"complete_cli"` to `RemoteCliCommand` after `"install"`:
-
-```ts
- | "complete_cli"
-```
-
-- [ ] **Step 5: Dispatch remote completion**
-
-Modify `packages/core/src/remote-control/dispatch.ts` imports:
-
-```ts
-import { completeCliWords, completionShells, type CompletionShell } from "./../cli/completion";
-```
-
-Add this branch after the `install` branch and before auth branches:
-
-```ts
-if (request.command === "complete_cli") {
- const shell = optionalString(request.arguments, "shell") ?? "bash";
- if (!completionShells.includes(shell as CompletionShell)) return [];
- return completeCliWords(optionalStringArray(request.arguments, "words") ?? [""], {
- configPath: context.configPath,
- projectConfigPath: context.projectConfigPath,
- });
-}
-```
-
-- [ ] **Step 6: Run focused tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-dispatch.test.ts test/cli-remote.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit remote support**
-
-```sh
-git add packages/core/src/remote-control/types.ts packages/core/src/remote-control/dispatch.ts packages/core/test/remote-control-dispatch.test.ts packages/core/test/cli-remote.test.ts
-git commit -m "feat(cli): route completions through remote control"
-```
-
----
-
-### Task 4: Document npm package installation flow
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `packages/cli/README.md`
-- Create: `.changeset/cli-completions.md`
-
-- [ ] **Step 1: Update README completion docs**
-
-Add this section after the direct CLI operation examples in `README.md`:
-
-````md
-### Shell completions
-
-The npm package ships shell completion generators for Bash, Zsh, and Fish. Installation is explicit: `npm install -g caplets` does not modify shell startup files or system completion directories.
-
-```sh
-# Bash
-mkdir -p ~/.local/share/bash-completion/completions
-caplets completion bash > ~/.local/share/bash-completion/completions/caplets
-
-# Zsh
-mkdir -p ~/.zsh/completions
-caplets completion zsh > ~/.zsh/completions/_caplets
-
-# Fish
-mkdir -p ~/.config/fish/completions
-caplets completion fish > ~/.config/fish/completions/caplets.fish
-```
-````
-
-Completions include command names, options, common enum values, and configured Caplet IDs. They do not probe downstream MCP servers, HTTP APIs, GraphQL endpoints, OpenAPI specs, or configured CLI tools during tab completion.
-
-- [ ] **Step 2: Ensure package README mirrors root README**
-
-Because `packages/cli` copies the root README in `prepack`, either let the root README flow through unchanged or add the same section to `packages/cli/README.md` if the file is currently committed separately.
-
-- [ ] **Step 3: Add changeset**
-
-Create `.changeset/cli-completions.md`:
-
-```md
----
-"caplets": minor
-"@caplets/core": minor
----
-
-Add Bash, Zsh, and Fish shell completion generation plus config-aware completion suggestions for the Caplets CLI.
-```
-
-- [ ] **Step 4: Run docs-sensitive checks**
-
-Run:
-
-```sh
-pnpm format:check
-pnpm --filter caplets test
-```
-
-Expected: both commands pass.
-
-- [ ] **Step 5: Commit docs and changeset**
-
-```sh
-git add README.md packages/cli/README.md .changeset/cli-completions.md
-git commit -m "docs(cli): document shell completions"
-```
-
----
-
-### Task 5: Full verification and final cleanup
-
-**Files:**
-
-- Verify all changed files.
-
-- [ ] **Step 1: Run full repository verification**
-
-Run:
-
-```sh
-pnpm verify
-```
-
-Expected: format, lint, typecheck, schema check, tests, benchmark check, and build all pass.
-
-- [ ] **Step 2: Inspect final diff**
-
-Run:
-
-```sh
-git status --short
-git diff --stat
-```
-
-Expected: only intended completion, docs, tests, and changeset files are modified.
-
-- [ ] **Step 3: Commit any verification fixes**
-
-If verification required fixes, commit them:
-
-```sh
-git add
-git commit -m "fix(cli): polish shell completions"
-```
-
-- [ ] **Step 4: Push branch**
-
-Run:
-
-```sh
-git push -u origin feat/cli-completions
-```
-
-Expected: branch pushes successfully.
-
----
-
-## Self-review checklist
-
-- Spec coverage: covers public completion generation, hidden resolver, static suggestions, config-aware suggestions, remote mode, docs, and changeset.
-- Placeholder scan: no `TBD` or open implementation decisions remain.
-- Type consistency: plan uses `CompletionShell`, `completeCliWords`, `completionScript`, and `complete_cli` consistently across core CLI and remote control.
-- Risk check: plan explicitly avoids npm postinstall side effects and live downstream probing during completion.
diff --git a/docs/plans/2026-05-21-completion-discovery-refactor.md b/docs/plans/2026-05-21-completion-discovery-refactor.md
deleted file mode 100644
index 1ddec9df..00000000
--- a/docs/plans/2026-05-21-completion-discovery-refactor.md
+++ /dev/null
@@ -1,1309 +0,0 @@
-# Completion Discovery Refactor Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Replace duplicated CLI completion command lists with shared metadata and add cache-backed live downstream completions for qualified tools, prompts, resources, and resource templates.
-
-**Architecture:** A shared command metadata module feeds both Commander registration and completion resolution. A focused completion discovery layer loads config, consults a platform-native persistent cache, performs bounded live discovery through existing managers, and returns best-effort candidates while preserving remote/server state ownership. Completion cache entries store only secret-free candidate metadata keyed by backend/config fingerprints.
-
-**Tech Stack:** TypeScript, Commander, Zod config schema, Vitest, Node filesystem/path APIs, existing Caplets managers (`DownstreamManager`, `OpenApiManager`, `GraphQLManager`, `HttpActionManager`, `CliToolsManager`, `CapletSetManager`).
-
----
-
-## File structure
-
-- Create `packages/core/src/cli/commands.ts`
- - Shared source of truth for top-level command names, hidden command names, subcommands, completion shells, option enum values, and command categories (`capletIdCommands`, `qualifiedToolCommands`, etc.).
-- Modify `packages/core/src/cli.ts`
- - Use command metadata constants when registering Commander commands.
- - Pass async completion options to `completeCliWords`.
-- Modify `packages/core/src/cli/completion.ts`
- - Consume shared command metadata.
- - Support async cache-backed discovery for qualified targets and option-context completions.
-- Create `packages/core/src/cli/completion-cache.ts`
- - Platform-cache-backed JSON cache helpers, cache keying, TTL checks, negative-cache entries, pruning, and atomic writes.
-- Create `packages/core/src/cli/completion-discovery.ts`
- - Discovery orchestration, config-defined candidates, live manager-backed discovery, timeout/budget handling, fallback behavior, and candidate formatting.
-- Modify `packages/core/src/config/paths.ts`
- - Add platform-native cache base and completion cache directory helpers.
-- Modify `packages/core/src/config.ts`
- - Add `CompletionConfig`, defaults, parsing, merge support, and schema descriptions.
-- Modify `packages/core/src/engine.ts`
- - Add an engine method for server-owned completion discovery used by remote control.
-- Modify `packages/core/src/remote-control/dispatch.ts`
- - Route `complete_cli` through the engine discovery path instead of the purely static resolver.
-- Modify `packages/core/test/cli-completion.test.ts`
- - Expand static metadata sync, qualified target, cache, timeout, and config-defined completion coverage.
-- Modify `packages/core/test/config.test.ts` and/or `packages/core/test/config-paths.test.ts`
- - Cover completion config defaults and platform cache paths.
-- Modify `packages/core/test/remote-control-dispatch.test.ts`
- - Cover server-owned remote completion discovery and secret-free responses.
-- Modify `README.md`, `packages/cli/README.md`, `.changeset/cli-completions.md`, and `schemas/caplets-config.schema.json`.
-
----
-
-## Task 1: Move CLI command completion metadata into a shared module
-
-**Files:**
-
-- Create: `packages/core/src/cli/commands.ts`
-- Modify: `packages/core/src/cli/completion.ts`
-- Modify: `packages/core/src/cli.ts`
-- Test: `packages/core/test/cli-completion.test.ts`
-
-- [ ] **Step 1: Write the failing metadata sync test**
-
-Add this test to `packages/core/test/cli-completion.test.ts` if it is not already present:
-
-```ts
-it("keeps top-level command suggestions in sync with registered CLI commands", () => {
- const registeredCommands = createProgram()
- .commands.filter((command) => command.name() !== "__complete")
- .map((command) => command.name())
- .sort();
-
- expect(completeCliWords([""]).toSorted()).toEqual(registeredCommands);
-});
-```
-
-- [ ] **Step 2: Run the test before refactor**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts
-```
-
-Expected: PASS today, but still backed by a duplicated list. The refactor must keep this green while removing the duplicate source.
-
-- [ ] **Step 3: Add shared command metadata**
-
-Create `packages/core/src/cli/commands.ts`:
-
-```ts
-export const completionShells = ["bash", "zsh", "fish", "powershell", "cmd"] as const;
-export type CompletionShell = (typeof completionShells)[number];
-
-export const cliCommands = {
- completion: "completion",
- completeHidden: "__complete",
- serve: "serve",
- init: "init",
- list: "list",
- install: "install",
- add: "add",
- getCaplet: "get-caplet",
- checkBackend: "check-backend",
- listTools: "list-tools",
- searchTools: "search-tools",
- getTool: "get-tool",
- callTool: "call-tool",
- listResources: "list-resources",
- searchResources: "search-resources",
- listResourceTemplates: "list-resource-templates",
- readResource: "read-resource",
- listPrompts: "list-prompts",
- searchPrompts: "search-prompts",
- getPrompt: "get-prompt",
- complete: "complete",
- config: "config",
- auth: "auth",
-} as const;
-
-export const topLevelCommandNames = [
- cliCommands.serve,
- cliCommands.init,
- cliCommands.list,
- cliCommands.install,
- cliCommands.add,
- cliCommands.getCaplet,
- cliCommands.checkBackend,
- cliCommands.listTools,
- cliCommands.searchTools,
- cliCommands.getTool,
- cliCommands.callTool,
- cliCommands.listResources,
- cliCommands.searchResources,
- cliCommands.listResourceTemplates,
- cliCommands.readResource,
- cliCommands.listPrompts,
- cliCommands.searchPrompts,
- cliCommands.getPrompt,
- cliCommands.complete,
- cliCommands.config,
- cliCommands.auth,
- cliCommands.completion,
-] as const;
-
-export const cliSubcommands = {
- [cliCommands.add]: ["cli", "mcp", "openapi", "graphql", "http"],
- [cliCommands.auth]: ["login", "logout", "list"],
- [cliCommands.completion]: [...completionShells],
- [cliCommands.config]: ["path", "paths"],
-} as const satisfies Record;
-
-export const capletIdCommands = new Set([
- cliCommands.getCaplet,
- cliCommands.checkBackend,
- cliCommands.listTools,
- cliCommands.searchTools,
- cliCommands.listResources,
- cliCommands.searchResources,
- cliCommands.listResourceTemplates,
- cliCommands.readResource,
- cliCommands.listPrompts,
- cliCommands.searchPrompts,
- cliCommands.complete,
-]);
-
-export const qualifiedToolCommands = new Set([cliCommands.getTool, cliCommands.callTool]);
-
-export const qualifiedPromptCommands = new Set([cliCommands.getPrompt]);
-```
-
-- [ ] **Step 4: Update completion resolver imports**
-
-In `packages/core/src/cli/completion.ts`, remove the local `completionShells`, `CompletionShell`, `topLevelCommands`, `subcommands`, `capletIdCommands`, and `qualifiedTargetCommands` definitions. Import the shared metadata:
-
-```ts
-import {
- capletIdCommands,
- cliCommands,
- cliSubcommands,
- completionShells,
- qualifiedPromptCommands,
- qualifiedToolCommands,
- topLevelCommandNames,
- type CompletionShell,
-} from "./commands";
-
-export { completionShells, type CompletionShell } from "./commands";
-```
-
-Update resolver references:
-
-```ts
-if (normalized.length === 1) return prefixFilter(topLevelCommandNames, current);
-
-if (normalized.length === 2 && cliSubcommands[command]) {
- return prefixFilter(cliSubcommands[command], current);
-}
-
-if (
- normalized.length === 2 &&
- (qualifiedToolCommands.has(command) || qualifiedPromptCommands.has(command))
-) {
- return prefixFilter(
- configuredCapletIds(options).map((id) => `${id}.`),
- current,
- );
-}
-
-if (
- command === cliCommands.auth &&
- ["login", "logout"].includes(subcommand) &&
- normalized.length === 3
-) {
- return prefixFilter(configuredCapletIds(options), current);
-}
-```
-
-- [ ] **Step 5: Update Commander registration to use metadata constants**
-
-In `packages/core/src/cli.ts`, import:
-
-```ts
-import { cliCommands } from "./cli/commands";
-```
-
-Replace each top-level `.command("...")` string with its metadata constant, for example:
-
-```ts
-program.command(cliCommands.completion);
-program.command(cliCommands.completeHidden, { hidden: true });
-program.command(cliCommands.serve);
-program.command(cliCommands.callTool);
-```
-
-For grouped commands:
-
-```ts
-const add = program.command(cliCommands.add).description("Add generated Caplet files.");
-const config = program.command(cliCommands.config).description("Inspect Caplets config locations.");
-const auth = program
- .command(cliCommands.auth)
- .description("Manage OAuth credentials for remote servers.");
-```
-
-- [ ] **Step 6: Verify metadata refactor**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts test/cli.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit metadata refactor**
-
-```sh
-git add packages/core/src/cli/commands.ts packages/core/src/cli/completion.ts packages/core/src/cli.ts packages/core/test/cli-completion.test.ts
-git commit -m "refactor(cli): share completion command metadata"
-```
-
----
-
-## Task 2: Add completion config defaults and platform cache paths
-
-**Files:**
-
-- Modify: `packages/core/src/config/paths.ts`
-- Modify: `packages/core/src/config.ts`
-- Test: `packages/core/test/config.test.ts`
-- Test: `packages/core/test/config-paths.test.ts` if present, otherwise add path tests to `packages/core/test/config.test.ts`
-- Generated: `schemas/caplets-config.schema.json`
-
-- [ ] **Step 1: Write failing config tests**
-
-Add assertions that parsing an otherwise minimal config includes completion defaults:
-
-```ts
-expect(config.options.completion).toEqual({
- discoveryTimeoutMs: 750,
- overallTimeoutMs: 1500,
- cacheTtlMs: 300_000,
- negativeCacheTtlMs: 30_000,
-});
-```
-
-Add an override test:
-
-```ts
-const config = parseConfig({
- version: 1,
- completion: {
- discoveryTimeoutMs: 250,
- overallTimeoutMs: 1000,
- cacheTtlMs: 60_000,
- negativeCacheTtlMs: 10_000,
- },
- mcpServers: {},
-});
-expect(config.options.completion.discoveryTimeoutMs).toBe(250);
-```
-
-Add path tests:
-
-```ts
-expect(defaultCacheBaseDir({ XDG_CACHE_HOME: "/tmp/cache" }, "/home/alice", "linux")).toBe(
- "/tmp/cache",
-);
-expect(defaultCacheBaseDir({}, "/Users/alice", "darwin")).toBe("/Users/alice/Library/Caches");
-expect(
- defaultCacheBaseDir(
- { LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local" },
- "C:\\Users\\Alice",
- "win32",
- ),
-).toBe("C:\\Users\\Alice\\AppData\\Local");
-```
-
-- [ ] **Step 2: Run tests and verify failure**
-
-```sh
-pnpm --filter @caplets/core test -- test/config.test.ts
-```
-
-Expected: FAIL because `completion` config and cache path helpers do not exist yet.
-
-- [ ] **Step 3: Add path helpers**
-
-In `packages/core/src/config/paths.ts`, add:
-
-```ts
-export function defaultCacheBaseDir(
- env: PathEnv = process.env,
- home = homedir(),
- platform: Platform = process.platform,
-): string {
- if (platform === "win32") {
- return env.LOCALAPPDATA && win32.isAbsolute(env.LOCALAPPDATA)
- ? env.LOCALAPPDATA
- : win32.join(home, "AppData", "Local");
- }
-
- if (platform === "darwin") {
- return posix.join(home, "Library", "Caches");
- }
-
- return env.XDG_CACHE_HOME && posix.isAbsolute(env.XDG_CACHE_HOME)
- ? env.XDG_CACHE_HOME
- : posix.join(home, ".cache");
-}
-
-export function defaultCompletionCacheDir(
- env: PathEnv = process.env,
- home = homedir(),
- platform: Platform = process.platform,
-): string {
- const pathJoin = platform === "win32" ? win32.join : posix.join;
- return pathJoin(defaultCacheBaseDir(env, home, platform), "caplets", "completions");
-}
-
-export const DEFAULT_COMPLETION_CACHE_DIR = defaultCompletionCacheDir();
-```
-
-Export `DEFAULT_COMPLETION_CACHE_DIR`, `defaultCacheBaseDir`, and `defaultCompletionCacheDir` from `packages/core/src/config.ts`.
-
-- [ ] **Step 4: Add completion config schema**
-
-In `packages/core/src/config.ts`, add:
-
-```ts
-export type CompletionConfig = {
- discoveryTimeoutMs: number;
- overallTimeoutMs: number;
- cacheTtlMs: number;
- negativeCacheTtlMs: number;
-};
-```
-
-Change `CapletsOptions`:
-
-```ts
-export type CapletsOptions = {
- defaultSearchLimit: number;
- maxSearchLimit: number;
- completion: CompletionConfig;
-};
-```
-
-Add schema:
-
-```ts
-const completionConfigSchema = z
- .object({
- discoveryTimeoutMs: z.number().int().positive().default(750),
- overallTimeoutMs: z.number().int().positive().default(1500),
- cacheTtlMs: z.number().int().nonnegative().default(300_000),
- negativeCacheTtlMs: z.number().int().nonnegative().default(30_000),
- })
- .strict()
- .default({});
-```
-
-Add `completion: completionConfigSchema` to the top-level config object and return it in `parseConfig`:
-
-```ts
-options: {
- defaultSearchLimit: parsed.data.defaultSearchLimit,
- maxSearchLimit: parsed.data.maxSearchLimit,
- completion: parsed.data.completion,
-},
-```
-
-- [ ] **Step 5: Generate config schema**
-
-Run:
-
-```sh
-pnpm schema:generate
-```
-
-Expected: `schemas/caplets-config.schema.json` changes to include `completion`.
-
-- [ ] **Step 6: Verify config changes**
-
-```sh
-pnpm --filter @caplets/core test -- test/config.test.ts
-pnpm schema:check
-```
-
-Expected: PASS.
-
-- [ ] **Step 7: Commit config changes**
-
-```sh
-git add packages/core/src/config.ts packages/core/src/config/paths.ts packages/core/test/config.test.ts schemas/caplets-config.schema.json
-git commit -m "feat(cli): configure completion discovery cache"
-```
-
----
-
-## Task 3: Implement secret-free persistent completion cache
-
-**Files:**
-
-- Create: `packages/core/src/cli/completion-cache.ts`
-- Test: `packages/core/test/cli-completion-cache.test.ts`
-
-- [ ] **Step 1: Write cache tests**
-
-Create `packages/core/test/cli-completion-cache.test.ts` with tests for fresh, stale, negative, and secret-free behavior:
-
-```ts
-import { mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
-import {
- completionCacheKey,
- readCompletionCacheEntry,
- writeCompletionCacheEntry,
-} from "../src/cli/completion-cache";
-
-const dirs: string[] = [];
-
-afterEach(() => {
- for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
-});
-
-describe("completion cache", () => {
- it("round-trips fresh positive entries", () => {
- const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-"));
- dirs.push(dir);
- const key = completionCacheKey({
- server: "repo",
- backend: "cli",
- kind: "tools",
- fingerprint: "abc",
- });
- writeCompletionCacheEntry(dir, key, {
- status: "positive",
- fetchedAt: 1000,
- expiresAt: 2000,
- candidates: [{ value: "repo.status" }],
- });
- expect(readCompletionCacheEntry(dir, key, 1500)).toEqual(
- expect.objectContaining({ status: "positive", fresh: true }),
- );
- });
-
- it("marks expired entries as stale instead of deleting usable candidates", () => {
- const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-"));
- dirs.push(dir);
- const key = completionCacheKey({
- server: "repo",
- backend: "cli",
- kind: "tools",
- fingerprint: "abc",
- });
- writeCompletionCacheEntry(dir, key, {
- status: "positive",
- fetchedAt: 1000,
- expiresAt: 2000,
- candidates: [{ value: "repo.status" }],
- });
- expect(readCompletionCacheEntry(dir, key, 2500)).toEqual(
- expect.objectContaining({ status: "positive", fresh: false }),
- );
- });
-
- it("stores negative entries without candidates", () => {
- const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-"));
- dirs.push(dir);
- const key = completionCacheKey({
- server: "github",
- backend: "mcp",
- kind: "tools",
- fingerprint: "abc",
- });
- writeCompletionCacheEntry(dir, key, {
- status: "negative",
- fetchedAt: 1000,
- expiresAt: 2000,
- reason: "auth_required",
- });
- expect(readCompletionCacheEntry(dir, key, 1500)).toEqual(
- expect.objectContaining({ status: "negative", fresh: true, reason: "auth_required" }),
- );
- });
-});
-```
-
-- [ ] **Step 2: Run tests and verify failure**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion-cache.test.ts
-```
-
-Expected: FAIL because cache module is missing.
-
-- [ ] **Step 3: Implement cache module**
-
-Create `packages/core/src/cli/completion-cache.ts`:
-
-```ts
-import { createHash } from "node:crypto";
-import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
-import { join } from "node:path";
-
-export type CompletionDiscoveryKind = "tools" | "prompts" | "resources" | "resourceTemplates";
-
-export type CompletionCandidate = {
- value: string;
- label?: string | undefined;
- description?: string | undefined;
-};
-
-export type CompletionCacheKeyInput = {
- server: string;
- backend: string;
- kind: CompletionDiscoveryKind;
- fingerprint: string;
-};
-
-export type CompletionCacheEntry =
- | {
- status: "positive";
- fetchedAt: number;
- expiresAt: number;
- candidates: CompletionCandidate[];
- }
- | {
- status: "negative";
- fetchedAt: number;
- expiresAt: number;
- reason: "auth_required" | "timeout" | "unavailable" | "unsupported" | "error";
- };
-
-export type ReadCompletionCacheEntry = CompletionCacheEntry & { fresh: boolean };
-
-export function completionCacheKey(input: CompletionCacheKeyInput): string {
- return createHash("sha256").update(JSON.stringify(input)).digest("hex");
-}
-
-export function readCompletionCacheEntry(
- cacheDir: string,
- key: string,
- now = Date.now(),
-): ReadCompletionCacheEntry | undefined {
- try {
- const parsed = JSON.parse(
- readFileSync(cachePath(cacheDir, key), "utf8"),
- ) as CompletionCacheEntry;
- if (parsed.status === "positive" && Array.isArray(parsed.candidates)) {
- return { ...parsed, fresh: now <= parsed.expiresAt };
- }
- if (parsed.status === "negative" && typeof parsed.reason === "string") {
- return { ...parsed, fresh: now <= parsed.expiresAt };
- }
- } catch {
- return undefined;
- }
- return undefined;
-}
-
-export function writeCompletionCacheEntry(
- cacheDir: string,
- key: string,
- entry: CompletionCacheEntry,
-): void {
- mkdirSync(cacheDir, { recursive: true });
- const path = cachePath(cacheDir, key);
- const tempPath = `${path}.${process.pid}.tmp`;
- writeFileSync(tempPath, JSON.stringify(entry), { mode: 0o600 });
- renameSync(tempPath, path);
-}
-
-function cachePath(cacheDir: string, key: string): string {
- return join(cacheDir, `${key}.json`);
-}
-```
-
-- [ ] **Step 4: Verify cache tests**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion-cache.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit cache module**
-
-```sh
-git add packages/core/src/cli/completion-cache.ts packages/core/test/cli-completion-cache.test.ts
-git commit -m "feat(cli): add persistent completion cache"
-```
-
----
-
-## Task 4: Add cache-backed completion discovery orchestration
-
-**Files:**
-
-- Create: `packages/core/src/cli/completion-discovery.ts`
-- Modify: `packages/core/src/cli/completion.ts`
-- Modify: `packages/core/src/engine.ts`
-- Test: `packages/core/test/cli-completion.test.ts`
-
-- [ ] **Step 1: Write failing qualified target tests**
-
-In `packages/core/test/cli-completion.test.ts`, add tests for config-defined qualified targets:
-
-```ts
-it("suggests config-defined tool names for qualified CLI and HTTP targets", async () => {
- const { dir, configPath } = writeCompletionConfig({
- cliTools: {
- repo: {
- name: "Repo",
- description: "Run repository maintenance commands.",
- actions: {
- status: {
- description: "Print repository status.",
- command: process.execPath,
- args: ["--version"],
- },
- build: {
- description: "Build the repository.",
- command: process.execPath,
- args: ["--version"],
- },
- },
- },
- },
- httpApis: {
- status_api: {
- name: "Status API",
- description: "Check service status through HTTP actions.",
- baseUrl: "https://api.example.com",
- auth: { type: "none" },
- actions: { check: { method: "GET", path: "/status" } },
- },
- },
- });
- dirs.push(dir);
-
- await expect(completeCliWords(["call-tool", "repo."], { configPath })).resolves.toEqual([
- "repo.status",
- "repo.build",
- ]);
- await expect(completeCliWords(["get-tool", "status_api."], { configPath })).resolves.toEqual([
- "status_api.check",
- ]);
-});
-```
-
-Update existing synchronous `completeCliWords(...)` tests to `await completeCliWords(...)` if the resolver becomes async.
-
-- [ ] **Step 2: Run tests and verify failure**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts
-```
-
-Expected: FAIL because `completeCliWords` does not yet return qualified tool names.
-
-- [ ] **Step 3: Implement discovery function signatures**
-
-Create `packages/core/src/cli/completion-discovery.ts` with public entry points:
-
-```ts
-import type { CapletConfig, CapletsConfig, CompletionConfig } from "../config";
-import type { CompletionDiscoveryKind, CompletionCandidate } from "./completion-cache";
-
-export type CompletionDiscoveryManagers = {
- listTools?: (server: CapletConfig) => Promise>;
- listPrompts?: (server: CapletConfig) => Promise>;
- listResources?: (
- server: CapletConfig,
- ) => Promise>;
- listResourceTemplates?: (
- server: CapletConfig,
- ) => Promise>;
-};
-
-export type CompletionDiscoveryOptions = {
- config: CapletsConfig;
- cacheDir?: string | undefined;
- managers?: CompletionDiscoveryManagers | undefined;
- now?: number | undefined;
-};
-
-export async function discoverCompletionCandidates(
- serverId: string,
- kind: CompletionDiscoveryKind,
- options: CompletionDiscoveryOptions,
-): Promise {
- // Implement in later steps.
- return configDefinedCandidates(serverId, kind, options.config);
-}
-```
-
-- [ ] **Step 4: Add config-defined candidates**
-
-Implement `configDefinedCandidates`:
-
-```ts
-function configDefinedCandidates(
- serverId: string,
- kind: CompletionDiscoveryKind,
- config: CapletsConfig,
-): CompletionCandidate[] {
- if (kind !== "tools") return [];
- const cli = config.cliTools[serverId];
- if (cli && !cli.disabled) {
- return Object.keys(cli.actions).map((name) => ({ value: `${serverId}.${name}` }));
- }
- const http = config.httpApis[serverId];
- if (http && !http.disabled) {
- return Object.keys(http.actions).map((name) => ({ value: `${serverId}.${name}` }));
- }
- const graphql = config.graphqlEndpoints[serverId];
- if (graphql && !graphql.disabled && graphql.operations) {
- return Object.keys(graphql.operations).map((name) => ({ value: `${serverId}.${name}` }));
- }
- return [];
-}
-```
-
-- [ ] **Step 5: Wire async resolver for qualified tool/prompt contexts**
-
-In `packages/core/src/cli/completion.ts`, change:
-
-```ts
-export async function completeCliWords(words: string[], options: CompletionOptions = {}): Promise {
-```
-
-When context is `call-tool` or `get-tool` and the current token contains a dot, split the server prefix and call `discoverCompletionCandidates(serverId, "tools", ...)`. Filter returned values by full prefix.
-
-When context is `get-prompt` and the current token contains a dot, call `discoverCompletionCandidates(serverId, "prompts", ...)`.
-
-For existing top-level/static cases, return immediately as before. Update CLI call sites to `await completeCliWords(...)`.
-
-- [ ] **Step 6: Add engine-owned discovery manager wiring**
-
-In `packages/core/src/engine.ts`, add:
-
-```ts
-async completeCliWords(words: string[]): Promise {
- const { completeCliWords } = await import("./cli/completion");
- return await completeCliWords(words, {
- config: this.registry.config,
- managers: {
- listTools: async (server) => {
- const result = await handleServerTool(
- server,
- { operation: "list_tools" },
- this.registry,
- this.downstream,
- this.openapi,
- this.graphql,
- this.http,
- this.cli,
- this.capletSets,
- );
- return result?.structuredContent?.result?.tools?.map((tool: { tool: string; description?: string }) => ({
- name: tool.tool,
- description: tool.description,
- })) ?? [];
- },
- listPrompts: async (server) => {
- if (server.backend !== "mcp") return [];
- return (await this.downstream.listPrompts(server)).map((prompt) => ({
- name: prompt.name,
- description: prompt.description,
- }));
- },
- listResources: async (server) => {
- if (server.backend !== "mcp") return [];
- return (await this.downstream.listResources(server)).map((resource) => ({
- uri: resource.uri,
- name: resource.name,
- description: resource.description,
- }));
- },
- listResourceTemplates: async (server) => {
- if (server.backend !== "mcp") return [];
- return (await this.downstream.listResourceTemplates(server)).map((template) => ({
- uriTemplate: template.uriTemplate,
- name: template.name,
- description: template.description,
- }));
- },
- },
- });
-}
-```
-
-During implementation, prefer direct manager calls over parsing `handleServerTool` results where practical; the snippet above documents the shape and fallback behavior.
-
-- [ ] **Step 7: Verify qualified config-defined completion**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts test/cli.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 8: Commit discovery orchestration base**
-
-```sh
-git add packages/core/src/cli/completion.ts packages/core/src/cli/completion-discovery.ts packages/core/src/engine.ts packages/core/test/cli-completion.test.ts packages/core/test/cli.test.ts
-git commit -m "feat(cli): complete qualified configured targets"
-```
-
----
-
-## Task 5: Add live discovery, persistent cache, and timeout behavior
-
-**Files:**
-
-- Modify: `packages/core/src/cli/completion-discovery.ts`
-- Modify: `packages/core/src/cli/completion.ts`
-- Test: `packages/core/test/cli-completion.test.ts`
-- Test: `packages/core/test/cli-completion-cache.test.ts`
-
-- [ ] **Step 1: Write tests for cache-first and timeout fallback**
-
-Add tests that pass fake managers:
-
-```ts
-it("uses cached discovered tool names when live discovery times out", async () => {
- const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-"));
- dirs.push(dir);
- const { configPath } = writeMcpConfig(dir, "github");
-
- await completeCliWords(["call-tool", "github."], {
- configPath,
- cacheDir: dir,
- managers: {
- listTools: async () => [{ name: "search" }],
- },
- });
-
- await expect(
- completeCliWords(["call-tool", "github."], {
- configPath,
- cacheDir: dir,
- managers: {
- listTools: async () => await new Promise(() => {}),
- },
- completion: {
- discoveryTimeoutMs: 10,
- overallTimeoutMs: 20,
- cacheTtlMs: 0,
- negativeCacheTtlMs: 30_000,
- },
- }),
- ).resolves.toEqual(["github.search"]);
-});
-```
-
-Add a negative-cache test that verifies a failing manager is not called again until TTL expiry.
-
-- [ ] **Step 2: Run tests and verify failure**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts test/cli-completion-cache.test.ts
-```
-
-Expected: FAIL until discovery cache is wired.
-
-- [ ] **Step 3: Implement fingerprints**
-
-In `completion-discovery.ts`, implement a secret-free fingerprint function:
-
-```ts
-function completionFingerprint(
- server: CapletConfig,
- kind: CompletionDiscoveryKind,
- completion: CompletionConfig,
-): string {
- return JSON.stringify({
- kind,
- completion: {
- discoveryTimeoutMs: completion.discoveryTimeoutMs,
- cacheTtlMs: completion.cacheTtlMs,
- negativeCacheTtlMs: completion.negativeCacheTtlMs,
- },
- server: secretFreeServerShape(server),
- });
-}
-```
-
-`secretFreeServerShape` must include only the fields listed in the spec and must not include env values, auth token/header values, or response data.
-
-- [ ] **Step 4: Implement timeout helper**
-
-Add:
-
-```ts
-async function withTimeout(promise: Promise, timeoutMs: number): Promise {
- let timeout: NodeJS.Timeout | undefined;
- try {
- return await Promise.race([
- promise,
- new Promise((_, reject) => {
- timeout = setTimeout(() => reject(new Error("completion discovery timeout")), timeoutMs);
- }),
- ]);
- } finally {
- if (timeout) clearTimeout(timeout);
- }
-}
-```
-
-- [ ] **Step 5: Implement cache-backed discovery flow**
-
-In `discoverCompletionCandidates`:
-
-1. Find enabled server by ID.
-2. Build cache key from server/backend/kind/fingerprint.
-3. Read cache entry.
-4. Return fresh positive cache immediately.
-5. Return static/config candidates immediately if fresh negative cache exists.
-6. Attempt live discovery with `Math.min(discoveryTimeoutMs, remainingOverallBudget)`.
-7. On success, write positive cache and return discovered candidates plus config-defined candidates, deduped.
-8. On failure/timeout, write negative cache and return stale positive candidates if available, otherwise config-defined candidates.
-
-- [ ] **Step 6: Implement live manager selection**
-
-Add:
-
-```ts
-async function liveCandidates(
- server: CapletConfig,
- kind: CompletionDiscoveryKind,
- managers: CompletionDiscoveryManagers | undefined,
-): Promise {
- if (kind === "tools" && managers?.listTools) {
- return (await managers.listTools(server)).map((tool) => ({
- value: `${server.server}.${tool.name}`,
- description: tool.description,
- }));
- }
- if (server.backend !== "mcp") return [];
- if (kind === "prompts" && managers?.listPrompts) {
- return (await managers.listPrompts(server)).map((prompt) => ({
- value: `${server.server}.${prompt.name}`,
- description: prompt.description,
- }));
- }
- if (kind === "resources" && managers?.listResources) {
- return (await managers.listResources(server)).map((resource) => ({
- value: resource.uri,
- label: resource.name,
- description: resource.description,
- }));
- }
- if (kind === "resourceTemplates" && managers?.listResourceTemplates) {
- return (await managers.listResourceTemplates(server)).map((template) => ({
- value: template.uriTemplate,
- label: template.name,
- description: template.description,
- }));
- }
- return [];
-}
-```
-
-- [ ] **Step 7: Verify cache/live behavior**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts test/cli-completion-cache.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 8: Commit cache-backed live discovery**
-
-```sh
-git add packages/core/src/cli/completion-discovery.ts packages/core/src/cli/completion.ts packages/core/test/cli-completion.test.ts packages/core/test/cli-completion-cache.test.ts
-git commit -m "feat(cli): cache live completion discovery"
-```
-
----
-
-## Task 6: Complete resources, templates, and `complete` option contexts
-
-**Files:**
-
-- Modify: `packages/core/src/cli/completion.ts`
-- Modify: `packages/core/src/cli/completion-discovery.ts`
-- Test: `packages/core/test/cli-completion.test.ts`
-
-- [ ] **Step 1: Write tests for MCP resource/prompt contexts**
-
-Add tests:
-
-```ts
-it("suggests resource URIs for read-resource after a selected backend", async () => {
- const { dir, configPath } = writeMcpConfigWithDir("docs");
- dirs.push(dir);
- await expect(
- completeCliWords(["read-resource", "docs", "file://"], {
- configPath,
- managers: { listResources: async () => [{ uri: "file:///repo/README.md" }] },
- }),
- ).resolves.toEqual(["file:///repo/README.md"]);
-});
-
-it("suggests prompt and resource-template option values for complete", async () => {
- const { dir, configPath } = writeMcpConfigWithDir("docs");
- dirs.push(dir);
- await expect(
- completeCliWords(["complete", "docs", "--prompt", ""], {
- configPath,
- managers: { listPrompts: async () => [{ name: "summarize" }] },
- }),
- ).resolves.toEqual(["summarize"]);
- await expect(
- completeCliWords(["complete", "docs", "--resource-template", "file://"], {
- configPath,
- managers: { listResourceTemplates: async () => [{ uriTemplate: "file:///repo/{path}" }] },
- }),
- ).resolves.toEqual(["file:///repo/{path}"]);
-});
-```
-
-- [ ] **Step 2: Run tests and verify failure**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts
-```
-
-Expected: FAIL until option context support is implemented.
-
-- [ ] **Step 3: Implement read-resource positional completion**
-
-In `completeCliWords`:
-
-```ts
-if (command === cliCommands.readResource && normalized.length === 3) {
- return prefixFilter(
- (await discoverCompletionCandidates(subcommand, "resources", discoveryOptions(options))).map(
- (candidate) => candidate.value,
- ),
- current,
- );
-}
-```
-
-- [ ] **Step 4: Implement complete option context discovery**
-
-When `previous === "--prompt"` and `command === "complete"`, discover `prompts` for `normalized[1]`. When `previous === "--resource-template"`, discover `resourceTemplates` for `normalized[1]`.
-
-```ts
-if (command === cliCommands.complete && previous === "--prompt" && subcommand) {
- return prefixFilter(
- (await discoverCompletionCandidates(subcommand, "prompts", discoveryOptions(options))).map(
- (candidate) => candidate.value.replace(`${subcommand}.`, ""),
- ),
- current,
- );
-}
-```
-
-Resource-template values should be returned as raw URI templates, not `server.template` qualified names, because the CLI option accepts the URI template only.
-
-- [ ] **Step 5: Verify broader contexts**
-
-```sh
-pnpm --filter @caplets/core test -- test/cli-completion.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit resource/template completion**
-
-```sh
-git add packages/core/src/cli/completion.ts packages/core/src/cli/completion-discovery.ts packages/core/test/cli-completion.test.ts
-git commit -m "feat(cli): complete MCP resources and prompts"
-```
-
----
-
-## Task 7: Route remote completions through server-owned discovery
-
-**Files:**
-
-- Modify: `packages/core/src/engine.ts`
-- Modify: `packages/core/src/remote-control/dispatch.ts`
-- Test: `packages/core/test/remote-control-dispatch.test.ts`
-- Test: `packages/core/test/cli-remote.test.ts`
-
-- [ ] **Step 1: Write failing remote discovery test**
-
-In `packages/core/test/remote-control-dispatch.test.ts`, add a context with CLI/HTTP config actions and assert `complete_cli` returns qualified tool names:
-
-```ts
-it("routes complete_cli through server-owned discovery", async () => {
- const context = testContext();
- const response = await dispatchRemoteCliRequest(
- {
- command: "complete_cli",
- arguments: { shell: "bash", words: ["call-tool", "server_status."] },
- },
- context,
- );
- expect(response).toMatchObject({ ok: true });
- expect(response.ok && response.result).toEqual(["server_status.check"]);
-});
-```
-
-- [ ] **Step 2: Run tests and verify failure**
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-dispatch.test.ts test/cli-remote.test.ts
-```
-
-Expected: FAIL until `complete_cli` uses `CapletsEngine.completeCliWords`.
-
-- [ ] **Step 3: Update remote dispatch**
-
-In `packages/core/src/remote-control/dispatch.ts`, change the `complete_cli` branch:
-
-```ts
-if (request.command === "complete_cli") {
- const shell = optionalString(request.arguments, "shell") ?? "bash";
- if (!completionShells.includes(shell as CompletionShell)) return [];
- const engine = new CapletsEngine(context);
- try {
- return await engine.completeCliWords(optionalStringArray(request.arguments, "words") ?? [""]);
- } finally {
- await engine.close();
- }
-}
-```
-
-- [ ] **Step 4: Ensure remote client failures remain quiet**
-
-Keep the `try/catch` around `remote.request("complete_cli", ...)` in `cli.ts`. Confirm `packages/core/test/cli-remote.test.ts` still has the quiet-failure test.
-
-- [ ] **Step 5: Verify remote completion**
-
-```sh
-pnpm --filter @caplets/core test -- test/remote-control-dispatch.test.ts test/cli-remote.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit remote discovery routing**
-
-```sh
-git add packages/core/src/engine.ts packages/core/src/remote-control/dispatch.ts packages/core/test/remote-control-dispatch.test.ts packages/core/test/cli-remote.test.ts
-git commit -m "feat(cli): discover completions on remote server"
-```
-
----
-
-## Task 8: Update docs and changeset
-
-**Files:**
-
-- Modify: `README.md`
-- Modify: `packages/cli/README.md`
-- Modify: `.changeset/cli-completions.md`
-
-- [ ] **Step 1: Update README completion behavior notes**
-
-Add a paragraph after the existing shell completion install snippets:
-
-```md
-Completions include command names, options, common enum values, configured Caplet IDs, and cache-backed downstream names for qualified targets such as `caplets call-tool repo.`. Downstream discovery is bounded by the `completion` config timeouts and a platform-native cache directory. Generated shell scripts suppress completion stderr; run the underlying CLI command directly when debugging completion behavior.
-```
-
-Add config example:
-
-```json
-{
- "completion": {
- "discoveryTimeoutMs": 750,
- "overallTimeoutMs": 1500,
- "cacheTtlMs": 300000,
- "negativeCacheTtlMs": 30000
- }
-}
-```
-
-Mention auth:
-
-```md
-Backends that require OAuth or token auth may need `caplets auth login ` before live downstream completions can return richer results. Completion never starts interactive login flows.
-```
-
-- [ ] **Step 2: Mirror package README**
-
-Apply the same README changes to `packages/cli/README.md` if it is committed independently.
-
-- [ ] **Step 3: Update changeset**
-
-Change `.changeset/cli-completions.md` body to:
-
-```md
-Add Bash, Zsh, Fish, PowerShell, and cmd shell completion generation plus config-aware and cache-backed downstream completion suggestions for the Caplets CLI.
-```
-
-- [ ] **Step 4: Verify docs formatting**
-
-```sh
-pnpm format:check
-```
-
-Expected: PASS.
-
-- [ ] **Step 5: Commit docs**
-
-```sh
-git add README.md packages/cli/README.md .changeset/cli-completions.md
-git commit -m "docs(cli): describe cache-backed completions"
-```
-
----
-
-## Task 9: Final verification and push
-
-**Files:**
-
-- All changed implementation, tests, docs, schema, and changeset files.
-
-- [ ] **Step 1: Run full verification**
-
-```sh
-pnpm verify
-```
-
-Expected:
-
-- format check passes
-- lint passes
-- typecheck passes
-- schema check passes
-- all Vitest tests pass
-- benchmark check passes
-- build passes
-
-- [ ] **Step 2: Inspect working tree**
-
-```sh
-git status --short
-git diff --stat
-```
-
-Expected: no unstaged implementation/doc changes. `.brv` may be modified by ByteRover; stage only if the existing hook/workflow requires it.
-
-- [ ] **Step 3: Push branch**
-
-```sh
-git push
-```
-
-Expected: `feat/cli-completions` is updated on PR #71.
-
-- [ ] **Step 4: Check PR status**
-
-```sh
-gh pr view 71 --json url,headRefOid,statusCheckRollup
-```
-
-Expected: PR points at the final pushed commit and CI has started or passed.
-
----
-
-## Self-review checklist
-
-- Spec coverage: shared metadata, platform cache path, config defaults, persistent cache, stale/negative cache behavior, qualified tool/prompt/resource/template completions, remote server ownership, docs, schema, and verification are all mapped to tasks.
-- Placeholder scan: no implementation step depends on `TODO` or unspecified behavior; every task has concrete files, commands, and expected results.
-- Type consistency: `CompletionShell`, `CompletionDiscoveryKind`, `CompletionCandidate`, `CompletionConfig`, `completeCliWords`, and `discoverCompletionCandidates` names are consistent across tasks.
diff --git a/docs/plans/2026-05-21-docker-self-hosting.md b/docs/plans/2026-05-21-docker-self-hosting.md
deleted file mode 100644
index cf6348d2..00000000
--- a/docs/plans/2026-05-21-docker-self-hosting.md
+++ /dev/null
@@ -1,474 +0,0 @@
-# Docker Self-Hosting Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add source-build Docker and Docker Compose support so Caplets can be self-hosted as an HTTP service with persistent server-owned state.
-
-**Architecture:** The implementation adds root container artifacts only: a multi-stage `Dockerfile`, a focused `.dockerignore`, and a `docker-compose.yml` service that runs `caplets serve --transport http`. Runtime state is persisted under `/data` via XDG environment variables, while Compose owns external bind address/port configuration and passes through unified `CAPLETS_SERVER_*` settings.
-
-**Tech Stack:** Docker, Docker Compose, Node.js 24 slim images, Corepack, `pnpm@11.0.9`, Caplets CLI HTTP serve mode.
-
----
-
-## File Structure
-
-- Create: `.dockerignore`
- - Keeps Docker build contexts small and prevents local caches, VCS metadata, generated output, and secret-bearing local state from entering the image build context.
-- Create: `Dockerfile`
- - Multi-stage source-build image for the monorepo.
- - Build stage installs workspace dependencies and runs `pnpm build`.
- - Runtime stage copies the built workspace and starts the HTTP Caplets server.
-- Create: `docker-compose.yml`
- - Local/self-hosted Compose service with configurable host binding, port, credentials, health check, and durable named volume.
-- Modify: `README.md`
- - Adds a Docker Compose self-hosting section near the existing “Remote Caplets service” docs.
-- Existing reference only: `docs/specs/2026-05-21-docker-self-hosting-design.md`
- - Source of truth for the approved design.
-
----
-
-### Task 1: Add Docker build context rules
-
-**Files:**
-
-- Create: `.dockerignore`
-
-- [ ] **Step 1: Create `.dockerignore`**
-
-Create `.dockerignore` with exactly this content:
-
-```dockerignore
-.git
-.github
-.husky
-.brv
-.pi
-.pi-lens
-.opencode
-.claude-plugin
-node_modules
-**/node_modules
-dist
-**/dist
-.turbo
-coverage
-*.log
-.env
-.env.*
-!.env.example
-.caplets
-**/.caplets
-.DS_Store
-```
-
-Rationale:
-
-- `node_modules`, `dist`, `.turbo`, and `coverage` are regenerated inside Docker.
-- `.git`, agent state directories, and local Caplets config/state should not enter the image context.
-- `.env` files are excluded to avoid accidental secret inclusion.
-
-- [ ] **Step 2: Verify `.dockerignore` exists and is formatted as plain text**
-
-Run:
-
-```bash
-test -f .dockerignore && sed -n '1,120p' .dockerignore
-```
-
-Expected output contains the entries from Step 1 and exits with status `0`.
-
-- [ ] **Step 3: Commit Docker ignore rules**
-
-Run:
-
-```bash
-git add .dockerignore
-git commit -m "build: add docker ignore rules"
-```
-
-Expected: commit succeeds and includes only `.dockerignore`.
-
----
-
-### Task 2: Add the source-build Dockerfile
-
-**Files:**
-
-- Create: `Dockerfile`
-
-- [ ] **Step 1: Create `Dockerfile`**
-
-Create `Dockerfile` with exactly this content:
-
-```dockerfile
-# syntax=docker/dockerfile:1.7
-
-ARG NODE_VERSION=24-bookworm-slim
-ARG PNPM_VERSION=11.0.9
-
-FROM node:${NODE_VERSION} AS build
-ARG PNPM_VERSION
-WORKDIR /app
-
-RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
-
-COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.json vitest.config.ts ./
-COPY packages/core/package.json packages/core/package.json
-COPY packages/cli/package.json packages/cli/package.json
-COPY packages/opencode/package.json packages/opencode/package.json
-COPY packages/pi/package.json packages/pi/package.json
-COPY packages/benchmarks/package.json packages/benchmarks/package.json
-
-RUN pnpm install --frozen-lockfile
-
-COPY . .
-
-RUN pnpm build
-
-FROM node:${NODE_VERSION} AS runtime
-ARG PNPM_VERSION
-ENV NODE_ENV=production \
- XDG_CONFIG_HOME=/data/config \
- XDG_STATE_HOME=/data/state \
- CAPLETS_SERVER_URL=http://127.0.0.1:5387
-WORKDIR /app
-
-RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
-
-COPY --from=build /app /app
-
-VOLUME ["/data"]
-EXPOSE 5387
-
-CMD ["pnpm", "--filter", "caplets", "exec", "caplets", "serve", "--transport", "http", "--host", "0.0.0.0"]
-```
-
-Notes for implementer:
-
-- Keep `CAPLETS_SERVER_URL` as a default only. Compose will override it for user-facing deployments.
-- `XDG_CONFIG_HOME=/data/config` makes the default config path `/data/config/caplets/config.json`.
-- `XDG_STATE_HOME=/data/state` makes auth state persist under `/data/state/caplets/auth`.
-- The runtime image intentionally copies the built workspace and `node_modules` from the build stage to preserve workspace links for local-source execution.
-
-- [ ] **Step 2: Validate Dockerfile parses with Docker**
-
-Run:
-
-```bash
-docker build --target runtime -t caplets:self-host-test .
-```
-
-Expected: build succeeds. The final lines should include an image tagged `caplets:self-host-test`.
-
-If Docker is unavailable in the execution environment, record the exact Docker error in the task notes and continue to Step 3; do not claim the Docker build was verified.
-
-- [ ] **Step 3: Commit the Dockerfile**
-
-Run:
-
-```bash
-git add Dockerfile
-git commit -m "build: add caplets docker image"
-```
-
-Expected: commit succeeds and includes only `Dockerfile`.
-
----
-
-### Task 3: Add Docker Compose service
-
-**Files:**
-
-- Create: `docker-compose.yml`
-
-- [ ] **Step 1: Create `docker-compose.yml`**
-
-Create `docker-compose.yml` with exactly this content:
-
-```yaml
-services:
- caplets:
- build:
- context: .
- dockerfile: Dockerfile
- image: caplets:local
- restart: unless-stopped
- environment:
- CAPLETS_SERVER_URL: ${CAPLETS_SERVER_URL:-http://127.0.0.1:5387}
- CAPLETS_SERVER_USER: ${CAPLETS_SERVER_USER:-caplets}
- CAPLETS_SERVER_PASSWORD: ${CAPLETS_SERVER_PASSWORD:-}
- XDG_CONFIG_HOME: /data/config
- XDG_STATE_HOME: /data/state
- ports:
- - "${CAPLETS_BIND_ADDRESS:-127.0.0.1}:${CAPLETS_PORT:-5387}:5387"
- volumes:
- - caplets-data:/data
- healthcheck:
- test:
- - CMD-SHELL
- - >-
- node -e "fetch('http://127.0.0.1:5387/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
- interval: 30s
- timeout: 5s
- retries: 5
- start_period: 10s
-
-volumes:
- caplets-data:
-```
-
-Rationale:
-
-- The container listens internally on `0.0.0.0:5387` from the Dockerfile command.
-- Compose defaults external binding to `127.0.0.1` for safety.
-- `CAPLETS_SERVER_PASSWORD` is intentionally empty by default so local loopback startup works without hard-coded secrets.
-- Self-hosters exposing beyond loopback must set `CAPLETS_SERVER_PASSWORD` and should place the service behind HTTPS/TLS.
-
-- [ ] **Step 2: Validate Compose renders correctly**
-
-Run:
-
-```bash
-docker compose config
-```
-
-Expected: command exits `0` and rendered output includes:
-
-```yaml
-ports:
- - mode: ingress
- host_ip: 127.0.0.1
- target: 5387
- published: "5387"
-```
-
-Compose output formatting can vary by Docker Compose version. If the exact YAML differs, verify these equivalent facts in the rendered output:
-
-- service name is `caplets`
-- published port is `5387`
-- host IP is `127.0.0.1`
-- named volume `caplets-data` is declared
-
-- [ ] **Step 3: Validate LAN binding override renders correctly**
-
-Run:
-
-```bash
-CAPLETS_BIND_ADDRESS=0.0.0.0 CAPLETS_PORT=15387 docker compose config
-```
-
-Expected: command exits `0` and rendered output shows host IP `0.0.0.0` and published port `15387` for target port `5387`.
-
-- [ ] **Step 4: Commit Compose service**
-
-Run:
-
-```bash
-git add docker-compose.yml
-git commit -m "build: add docker compose service"
-```
-
-Expected: commit succeeds and includes only `docker-compose.yml`.
-
----
-
-### Task 4: Document Docker self-hosting
-
-**Files:**
-
-- Modify: `README.md` after the remote HTTP service paragraph and before “Native integrations and remote-capable CLI commands read remote client settings from environment variables:”
-
-- [ ] **Step 1: Insert Docker Compose documentation**
-
-In `README.md`, find this paragraph under `### Remote Caplets service`:
-
-```markdown
-`caplets serve --transport http` serves plain HTTP. For non-loopback or network access, expose it only through HTTPS/TLS (for example, a reverse proxy or secure tunnel) and enable Basic Auth; Basic Auth over plain HTTP exposes credentials. Keep credentials out of plugin manifests.
-```
-
-Immediately after it, insert this section:
-
-````markdown
-#### Docker Compose self-hosting
-
-This repository includes a source-build Docker image and Compose service for running the HTTP service from the checked-out source tree:
-
-```sh
-CAPLETS_SERVER_PASSWORD=change-me docker compose up --build
-```
-
-By default, Compose publishes the service on loopback only:
-
-- Base URL: `http://127.0.0.1:5387`
-- MCP endpoint: `http://127.0.0.1:5387/mcp`
-- Control endpoint: `http://127.0.0.1:5387/control`
-- Health endpoint: `http://127.0.0.1:5387/healthz`
-
-The service stores Caplets config and auth state in a Docker named volume mounted at `/data`. To use a host-visible bind mount instead, replace this Compose volume entry:
-
-```yaml
-volumes:
- - caplets-data:/data
-```
-
-with:
-
-```yaml
-volumes:
- - ./data:/data
-```
-
-To expose the service to a LAN interface or reverse proxy, set an explicit bind address and public base URL:
-
-```sh
-CAPLETS_BIND_ADDRESS=0.0.0.0 \
-CAPLETS_SERVER_URL=https://caplets.example.com \
-CAPLETS_SERVER_PASSWORD=change-me \
-docker compose up --build
-```
-
-Only expose Caplets beyond loopback through HTTPS/TLS and Basic Auth. `CAPLETS_SERVER_PASSWORD` protects both the MCP and control endpoints; downstream provider tokens and auth files remain server-owned inside the mounted `/data` location.
-````
-
-Important markdown escaping note: the inserted section contains fenced code blocks inside markdown. Keep each fence exactly as shown.
-
-- [ ] **Step 2: Verify README formatting**
-
-Run:
-
-```bash
-pnpm format:check README.md
-```
-
-Expected: `All matched files use the correct format.`
-
-- [ ] **Step 3: Commit README documentation**
-
-Run:
-
-```bash
-git add README.md
-git commit -m "docs: document docker self-hosting"
-```
-
-Expected: commit succeeds and includes only `README.md`.
-
----
-
-### Task 5: Run final verification
-
-**Files:**
-
-- Verify: `.dockerignore`
-- Verify: `Dockerfile`
-- Verify: `docker-compose.yml`
-- Verify: `README.md`
-
-- [ ] **Step 1: Run formatting check for changed text files**
-
-Run:
-
-```bash
-pnpm format:check .dockerignore Dockerfile docker-compose.yml README.md docs/specs/2026-05-21-docker-self-hosting-design.md
-```
-
-Expected: command exits `0` and reports all matched files use correct format.
-
-- [ ] **Step 2: Run Compose config validation**
-
-Run:
-
-```bash
-docker compose config
-```
-
-Expected: command exits `0` and renders the `caplets` service and `caplets-data` volume.
-
-If Docker Compose is unavailable, record the exact command failure in the task notes and continue; do not claim Compose was verified.
-
-- [ ] **Step 3: Run Docker image build verification**
-
-Run:
-
-```bash
-docker build -t caplets:self-host-test .
-```
-
-Expected: command exits `0` and creates image `caplets:self-host-test`.
-
-If Docker is unavailable, record the exact command failure in the task notes and continue; do not claim the image build was verified.
-
-- [ ] **Step 4: Optionally run service smoke test when Docker daemon is available**
-
-Run:
-
-```bash
-CAPLETS_SERVER_PASSWORD=change-me docker compose up --build -d
-sleep 5
-curl --fail http://127.0.0.1:5387/healthz
-docker compose down
-```
-
-Expected:
-
-- `curl` exits `0`.
-- Response body is JSON with a healthy Caplets HTTP service, including `transport` set to `http`.
-- `docker compose down` exits `0` and stops the service.
-
-If the service fails to become healthy, inspect logs with:
-
-```bash
-docker compose logs caplets
-```
-
-Fix the issue before completing the task.
-
-- [ ] **Step 5: Run repo-level non-Docker checks**
-
-Run:
-
-```bash
-pnpm verify
-```
-
-Expected: command exits `0`.
-
-- [ ] **Step 6: Commit any verification fixes**
-
-If Steps 1-5 required fixes, commit those fixes:
-
-```bash
-git add .dockerignore Dockerfile docker-compose.yml README.md
-git commit -m "fix: polish docker self-hosting setup"
-```
-
-If no fixes were required, do not create an empty commit.
-
----
-
-## Self-Review
-
-### Spec coverage
-
-- Root `Dockerfile`: Task 2.
-- Root `docker-compose.yml`: Task 3.
-- README Docker usage docs: Task 4.
-- Unified env alignment: Task 2 and Task 3 use `CAPLETS_SERVER_URL`, `CAPLETS_SERVER_USER`, `CAPLETS_SERVER_PASSWORD`, and XDG state/config paths; Task 4 documents remote clients with the same server URL model.
-- Source-build image rather than npm-install-only image: Task 2.
-- Configurable external binding: Task 3 and Task 4.
-- Named volume default and bind-mount alternative: Task 3 and Task 4.
-- Health check: Task 3 and Task 5.
-- Security guidance for loopback defaults, HTTPS/TLS, Basic Auth, and server-owned state: Task 3 and Task 4.
-- Verification commands: Task 2, Task 3, and Task 5.
-
-### Placeholder scan
-
-No placeholder markers are present. Every file creation step includes exact content. Every verification step includes exact commands and expected outcomes.
-
-### Type and name consistency
-
-- Compose service name is consistently `caplets`.
-- Named volume is consistently `caplets-data`.
-- Internal port is consistently `5387`.
-- External bind variables are consistently `CAPLETS_BIND_ADDRESS` and `CAPLETS_PORT`.
-- Unified server variables are consistently `CAPLETS_SERVER_URL`, `CAPLETS_SERVER_USER`, and `CAPLETS_SERVER_PASSWORD`.
diff --git a/docs/plans/2026-05-21-ghcr-release-publishing.md b/docs/plans/2026-05-21-ghcr-release-publishing.md
deleted file mode 100644
index 01214df9..00000000
--- a/docs/plans/2026-05-21-ghcr-release-publishing.md
+++ /dev/null
@@ -1,296 +0,0 @@
-# GHCR Release Publishing Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Publish the Caplets Docker image to GitHub Container Registry when the existing Changesets release workflow publishes npm packages.
-
-**Architecture:** Extend `.github/workflows/release.yml` in-place. The release job keeps the current install, verify, and Changesets publish flow, then runs Docker metadata/login/build-push steps only when `changesets/action` reports `published == 'true'` and the published package list includes the `caplets` CLI package. The image is pushed to `ghcr.io/spiritledsoftware/caplets` with `latest`, semantic version, `v`-prefixed semantic version, and short SHA tags.
-
-**Tech Stack:** GitHub Actions, Changesets, Docker Buildx, GHCR, `docker/login-action`, `docker/metadata-action`, `docker/build-push-action`, Node.js 24, pnpm 11.0.9.
-
----
-
-## File Structure
-
-- Modify: `.github/workflows/release.yml`
- - Add job-scoped `packages: write` permission for GHCR publishing.
- - Add an `id: changesets` to the existing Changesets action step.
- - Add a step to check whether the `caplets` CLI package was published, then read `packages/cli/package.json` version for Docker metadata.
- - Add QEMU, Docker Buildx, GHCR login, metadata, and build-push steps gated on published CLI releases.
-- Verify only: `Dockerfile`
- - Existing source-build image is the image pushed by the workflow.
-
----
-
-### Task 1: Add GHCR permissions and Changesets output ID
-
-**Files:**
-
-- Modify: `.github/workflows/release.yml`
-
-- [ ] **Step 1: Update workflow permissions and Changesets step ID**
-
-In `.github/workflows/release.yml`, change the permissions block from:
-
-```yaml
-permissions:
- contents: write
- pull-requests: write
- id-token: write
-```
-
-to keep workflow-level permissions unchanged and add job-scoped package publishing permission:
-
-```yaml
-permissions:
- contents: write
- pull-requests: write
- id-token: write
-
-jobs:
- release:
- permissions:
- contents: write
- pull-requests: write
- id-token: write
- packages: write
-```
-
-Then change the Changesets step from:
-
-```yaml
-- name: Create release PR or publish
- uses: changesets/action@v1
-```
-
-to:
-
-```yaml
-- name: Create release PR or publish
- id: changesets
- uses: changesets/action@v1
-```
-
-- [ ] **Step 2: Verify workflow formatting**
-
-Run:
-
-```bash
-pnpm format:check .github/workflows/release.yml
-```
-
-Expected: command exits `0` and reports all matched files use the correct format.
-
-- [ ] **Step 3: Commit workflow release output plumbing**
-
-Run:
-
-```bash
-git add .github/workflows/release.yml
-git commit -m "ci: expose release publish result"
-```
-
-Expected: commit succeeds and includes only `.github/workflows/release.yml`.
-
----
-
-### Task 2: Add Docker image metadata and publishing steps
-
-**Files:**
-
-- Modify: `.github/workflows/release.yml`
-
-- [ ] **Step 1: Insert Docker publish steps after Changesets**
-
-Immediately after the existing `Create release PR or publish` step, insert:
-
-```yaml
-- name: Check whether CLI package was published
- if: steps.changesets.outputs.published == 'true'
- id: cli-package
- env:
- PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
- run: |
- cli_published=$(node <<'NODE'
- const publishedPackages = JSON.parse(process.env.PUBLISHED_PACKAGES || '[]');
- const cliPublished = publishedPackages.some((pkg) => pkg && pkg.name === 'caplets');
- process.stdout.write(cliPublished ? 'true' : 'false');
- NODE
- )
- echo "published=${cli_published}" >> "$GITHUB_OUTPUT"
-
-- name: Read Docker image version
- if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true'
- id: image-version
- run: echo "version=$(node -p \"require('./packages/cli/package.json').version\")" >> "$GITHUB_OUTPUT"
-
-- name: Setup QEMU
- if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true'
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130
-
-- name: Setup Docker Buildx
- if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true'
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
-
-- name: Log in to GitHub Container Registry
- if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true'
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
-- name: Generate Docker metadata
- if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true'
- id: docker-meta
- uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051
- with:
- images: ghcr.io/spiritledsoftware/caplets
- tags: |
- type=raw,value=latest
- type=raw,value=${{ steps.image-version.outputs.version }}
- type=raw,value=v${{ steps.image-version.outputs.version }}
- type=sha,format=short,prefix=sha-
-
-- name: Publish Docker image
- if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true'
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
- with:
- context: .
- file: ./Dockerfile
- platforms: linux/amd64,linux/arm64
- push: true
- tags: ${{ steps.docker-meta.outputs.tags }}
- labels: ${{ steps.docker-meta.outputs.labels }}
-```
-
-- [ ] **Step 2: Verify workflow formatting**
-
-Run:
-
-```bash
-pnpm format:check .github/workflows/release.yml
-```
-
-Expected: command exits `0` and reports all matched files use the correct format.
-
-- [ ] **Step 3: Commit Docker publishing steps**
-
-Run:
-
-```bash
-git add .github/workflows/release.yml
-git commit -m "ci: publish docker image on release"
-```
-
-Expected: commit succeeds and includes only `.github/workflows/release.yml`.
-
----
-
-### Task 3: Verify release workflow and Docker image build
-
-**Files:**
-
-- Verify: `.github/workflows/release.yml`
-- Verify: `Dockerfile`
-
-- [ ] **Step 1: Run workflow formatting check**
-
-Run:
-
-```bash
-pnpm format:check .github/workflows/release.yml
-```
-
-Expected: command exits `0` and reports all matched files use the correct format.
-
-- [ ] **Step 2: Check workflow contains required release gates and tags**
-
-Run:
-
-```bash
-node <<'NODE'
-const fs = require('node:fs');
-const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8');
-const required = [
- 'packages: write',
- 'id: changesets',
- "if: steps.changesets.outputs.published == 'true' && steps.cli-package.outputs.published == 'true'",
- 'id: cli-package',
- 'uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130',
- 'uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9',
- 'uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051',
- 'uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8',
- 'images: ghcr.io/spiritledsoftware/caplets',
- 'type=raw,value=latest',
- 'type=raw,value=${{ steps.image-version.outputs.version }}',
- 'type=raw,value=v${{ steps.image-version.outputs.version }}',
- 'type=sha,format=short,prefix=sha-',
-];
-const missing = required.filter((entry) => !workflow.includes(entry));
-if (missing.length > 0) {
- console.error(`Missing workflow entries:\n${missing.join('\n')}`);
- process.exit(1);
-}
-NODE
-```
-
-Expected: command exits `0` with no missing workflow entries.
-
-- [ ] **Step 3: Verify the Docker image still builds**
-
-Run:
-
-```bash
-docker build -t caplets:self-host-test .
-```
-
-Expected: command exits `0` and creates image `caplets:self-host-test`.
-
-If Docker is unavailable, record the exact Docker error in task notes and continue; do not claim Docker build verification passed.
-
-- [ ] **Step 4: Run focused repo checks**
-
-Run:
-
-```bash
-pnpm verify
-```
-
-Expected: command exits `0`.
-
-- [ ] **Step 5: Commit verification fixes if needed**
-
-If verification required fixes, run:
-
-```bash
-git add .github/workflows/release.yml
-git commit -m "fix: polish docker release publishing"
-```
-
-Expected: commit succeeds only if fixes were made. If no fixes were required, do not create an empty commit.
-
----
-
-## Self-Review
-
-### Spec coverage
-
-- GHCR target image `ghcr.io/spiritledsoftware/caplets`: Task 2.
-- Publish only after a real CLI package release: Tasks 1 and 2 gate on both `steps.changesets.outputs.published == 'true'` and `steps.cli-package.outputs.published == 'true'`.
-- Use repository-scoped token and no extra long-lived registry secret: Task 2 uses `docker/login-action` pinned to a full commit SHA with `secrets.GITHUB_TOKEN`.
-- Add required GitHub Packages permission: Task 1.
-- Tags for `latest`, semantic version, `v` semantic version, and short SHA: Task 2 and Task 3.
-- Multi-platform Docker publishing includes QEMU registration before Buildx: Task 2.
-- Local verification of workflow and Dockerfile: Task 3.
-
-### Placeholder scan
-
-No placeholder markers are present. Every edit step includes exact YAML or exact verification commands.
-
-### Type and name consistency
-
-- Changesets step ID is consistently `changesets`.
-- Version step ID is consistently `image-version`.
-- Docker metadata step ID is consistently `docker-meta`.
-- Image name is consistently `ghcr.io/spiritledsoftware/caplets`.
diff --git a/docs/plans/2026-05-21-mcp-resources-prompts.md b/docs/plans/2026-05-21-mcp-resources-prompts.md
deleted file mode 100644
index 397e1aa3..00000000
--- a/docs/plans/2026-05-21-mcp-resources-prompts.md
+++ /dev/null
@@ -1,2182 +0,0 @@
-# MCP Resources and Prompts Implementation Plan
-
-> Superseded for remote/server environment naming: current client integrations use `CAPLETS_REMOTE_*`, while the process hosting `caplets serve --transport http` uses `CAPLETS_SERVER_*`. This plan remains historical implementation context.
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Add MCP resource, resource-template, prompt, and completion support to MCP-backed Caplets while keeping non-MCP backends tool-only.
-
-**Architecture:** Generate backend-aware Caplet operation schemas, route MCP-only operations only for `backend: "mcp"`, and extend `DownstreamManager` to cache and forward MCP resources/prompts/completions through the existing managed MCP connection. CLI, remote control, Pi, and OpenCode consume the same backend-aware operation model so agents are not invited to call MCP-specific operations on OpenAPI, GraphQL, HTTP, CLI, or Caplet-set backends.
-
-**Tech Stack:** TypeScript, Zod 4, MCP SDK 1.29, Commander, Vitest, Pi/OpenCode native adapters, pnpm 11.0.9.
-
----
-
-## Scope and constraints
-
-- Use `pnpm` only.
-- Preserve one top-level Caplets tool per enabled Caplet.
-- Do not flatten downstream resources/prompts into Caplets `tools/list`.
-- Do not expose MCP-only operation names in generated schemas for non-MCP backends.
-- Keep `get_caplet` non-probing; live MCP capability observation belongs in `check_backend` and operation results.
-- Use command-semantic remote control via `/control`; do not add raw CLI-string execution.
-- Keep remote mode server-owned for config, Caplet files, and downstream auth secrets.
-- Add a changeset because the generated tool schema, CLI surface, and package docs are user-facing.
-
-## File structure
-
-- Modify `packages/core/src/generated-tool-input-schema.ts`
- - Own backend-aware operation arrays, field descriptions, JSON Schema generation, and Zod schema generation.
-- Modify `packages/core/src/tools.ts`
- - Validate requests with the selected Caplet backend, dispatch MCP-only operations, and annotate resource/prompt/completion results.
-- Modify `packages/core/src/downstream.ts`
- - Add MCP resource, resource-template, prompt, completion, capability, cache, compact, and search methods.
-- Modify `packages/core/src/errors.ts`
- - Add `UNSUPPORTED_OPERATION`, `UNSUPPORTED_CAPABILITY`, `RESOURCE_NOT_FOUND`, `PROMPT_NOT_FOUND`, `DOWNSTREAM_RESOURCE_ERROR`, `DOWNSTREAM_PROMPT_ERROR`, and `DOWNSTREAM_COMPLETION_ERROR`.
-- Modify `packages/core/src/capability-description.ts`
- - Describe MCP-backed Caplets as tools/resources/prompts/completions; keep non-MCP descriptions tool-oriented.
-- Modify `packages/core/src/serve/session.ts`
- - Register and update each Caplet tool with its backend-specific input schema.
-- Modify `packages/core/src/native/service.ts`
- - Include backend-aware schema metadata in `NativeCapletTool`.
-- Modify `packages/core/src/native/tools.ts`
- - Add MCP-specific guidance only for MCP-backed Caplets.
-- Modify `packages/core/src/native/remote.ts`
- - Preserve remote tool input schema metadata when available from remote MCP `tools/list`.
-- Modify `packages/pi/src/index.ts`
- - Use each native Caplet tool's JSON Schema instead of the global schema.
-- Modify `packages/opencode/src/schema.ts` and `packages/opencode/src/hooks.ts`
- - Generate OpenCode argument schemas from each native Caplet tool's operation set.
-- Modify `packages/core/src/cli.ts`
- - Add resource/prompt/completion commands, remote routing, and human summaries.
-- Modify `packages/core/src/remote-control/types.ts` and `packages/core/src/remote-control/dispatch.ts`
- - Add command-semantic remote operation names and request validation.
-- Modify `README.md`, `packages/cli/README.md`, `packages/pi/README.md`, and `packages/opencode/README.md`.
-- Add `.changeset/mcp-resources-prompts.md` for `@caplets/core`, `caplets`, `@caplets/pi`, and `@caplets/opencode`.
-- Modify tests:
- - `packages/core/test/tools.test.ts`
- - `packages/core/test/downstream.test.ts`
- - `packages/core/test/serve-session.test.ts`
- - `packages/core/test/cli.test.ts`
- - `packages/core/test/cli-remote.test.ts`
- - `packages/core/test/remote-control-dispatch.test.ts`
- - `packages/core/test/native-remote.test.ts`
- - `packages/pi/test/pi.test.ts`
- - `packages/opencode/test/opencode.test.ts`
-
----
-
-### Task 1: Add backend-aware generated schemas and validation
-
-**Files:**
-
-- Modify: `packages/core/src/generated-tool-input-schema.ts`
-- Modify: `packages/core/src/tools.ts`
-- Modify: `packages/core/src/errors.ts`
-- Test: `packages/core/test/tools.test.ts`
-
-- [ ] **Step 1: Write failing schema and validation tests**
-
-Add these tests to `packages/core/test/tools.test.ts` inside `describe("generated tool request validation", ...)`:
-
-```ts
-import {
- generatedToolInputJsonSchemaForCaplet,
- generatedToolInputSchemaForCaplet,
- mcpOperations,
- operations,
-} from "../src/generated-tool-input-schema";
-
-it("generates tool-only schemas for non-MCP Caplets", () => {
- const nonMcp = generatedToolInputJsonSchemaForCaplet({ backend: "http" });
- expect(nonMcp.properties.operation.enum).toEqual(operations);
- expect(nonMcp.properties.operation.enum).not.toContain("list_resources");
- expect(nonMcp.properties).not.toHaveProperty("uri");
- expect(nonMcp.properties).not.toHaveProperty("prompt");
- expect(nonMcp.properties).not.toHaveProperty("ref");
- expect(nonMcp.properties).not.toHaveProperty("argument");
-});
-
-it("generates MCP capability schemas only for MCP Caplets", () => {
- const mcp = generatedToolInputJsonSchemaForCaplet({ backend: "mcp" });
- expect(mcp.properties.operation.enum).toEqual(mcpOperations);
- expect(mcp.properties.operation.enum).toContain("list_resources");
- expect(mcp.properties.operation.enum).toContain("get_prompt");
- expect(mcp.properties.operation.enum).toContain("complete");
- expect(mcp.properties).toHaveProperty("uri");
- expect(mcp.properties).toHaveProperty("prompt");
- expect(mcp.properties).toHaveProperty("ref");
- expect(mcp.properties).toHaveProperty("argument");
-});
-
-it("validates MCP-only operation request shapes for MCP backends", () => {
- expect(
- validateOperationRequest({ operation: "read_resource", uri: "file:///x" }, 50, "mcp"),
- ).toEqual({ operation: "read_resource", uri: "file:///x" });
- expect(
- validateOperationRequest({ operation: "get_prompt", prompt: "review" }, 50, "mcp"),
- ).toEqual({ operation: "get_prompt", prompt: "review", arguments: {} });
- expect(
- validateOperationRequest(
- {
- operation: "complete",
- ref: { type: "prompt", name: "review" },
- argument: { name: "issueId", value: "CAP-" },
- },
- 50,
- "mcp",
- ),
- ).toEqual({
- operation: "complete",
- ref: { type: "prompt", name: "review" },
- argument: { name: "issueId", value: "CAP-" },
- });
-});
-
-it("rejects MCP-only operations for non-MCP backends", () => {
- expect(() => validateOperationRequest({ operation: "list_resources" }, 50, "http")).toThrow(
- expect.objectContaining({ code: "UNSUPPORTED_OPERATION" }),
- );
-});
-
-it("rejects operation-specific extra fields for MCP-only operations", () => {
- expect(() =>
- validateOperationRequest({ operation: "list_resources", tool: "x" }, 50, "mcp"),
- ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }));
- expect(() =>
- validateOperationRequest(
- { operation: "get_prompt", prompt: "x", fields: ["message"] },
- 50,
- "mcp",
- ),
- ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }));
- expect(() =>
- validateOperationRequest(
- { operation: "complete", ref: { type: "prompt", name: "x" } },
- 50,
- "mcp",
- ),
- ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }));
-});
-```
-
-- [ ] **Step 2: Run focused tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: FAIL because `generatedToolInputJsonSchemaForCaplet`, `generatedToolInputSchemaForCaplet`, `mcpOperations`, new errors, and the third `validateOperationRequest` parameter do not exist.
-
-- [ ] **Step 3: Add error codes**
-
-Modify `packages/core/src/errors.ts` by inserting the new codes before `UNSUPPORTED_TRANSPORT`:
-
-```ts
- "UNSUPPORTED_OPERATION",
- "UNSUPPORTED_CAPABILITY",
- "RESOURCE_NOT_FOUND",
- "PROMPT_NOT_FOUND",
- "DOWNSTREAM_RESOURCE_ERROR",
- "DOWNSTREAM_PROMPT_ERROR",
- "DOWNSTREAM_COMPLETION_ERROR",
-```
-
-- [ ] **Step 4: Implement backend-aware schema exports**
-
-Replace `packages/core/src/generated-tool-input-schema.ts` with this shape, preserving the exported `operations`, `generatedToolInputDescriptions`, `generatedToolInputJsonSchema()`, and adding MCP-aware exports:
-
-```ts
-import { z } from "zod";
-
-export const operations = [
- "get_caplet",
- "check_backend",
- "list_tools",
- "search_tools",
- "get_tool",
- "call_tool",
-] as const;
-
-export const mcpOperations = [
- ...operations,
- "list_resources",
- "search_resources",
- "list_resource_templates",
- "read_resource",
- "list_prompts",
- "search_prompts",
- "get_prompt",
- "complete",
-] as const;
-
-export type GeneratedOperation = (typeof operations)[number];
-export type GeneratedMcpOperation = (typeof mcpOperations)[number];
-export type CapletSchemaBackend = { backend: string };
-
-export const generatedToolInputDescriptions = {
- operation:
- "Wrapper operation. Non-MCP Caplets expose tool operations only; MCP Caplets also expose resources, prompts, and completions.",
- query: "Required for search operations only.",
- limit: "Optional list/search result limit.",
- tool: "Exact downstream tool name for get_tool or call_tool.",
- arguments: "JSON object for call_tool inputs or get_prompt arguments.",
- fields: "Optional call_tool structured output paths when outputSchema allows it.",
- uri: "Exact downstream resource URI for read_resource.",
- prompt: "Exact downstream prompt name for get_prompt.",
- ref: "Completion target reference for complete.",
- argument: "Completion argument object for complete.",
-} as const;
-
-export const completionRefSchema = z.union([
- z.object({ type: z.literal("prompt"), name: z.string().min(1) }).strict(),
- z.object({ type: z.literal("resourceTemplate"), uri: z.string().min(1) }).strict(),
-]);
-
-export const completionArgumentSchema = z
- .object({ name: z.string().min(1), value: z.string() })
- .strict();
-
-const baseShape = {
- query: z.string().optional().describe(generatedToolInputDescriptions.query),
- limit: z.number().int().positive().optional().describe(generatedToolInputDescriptions.limit),
- tool: z.string().optional().describe(generatedToolInputDescriptions.tool),
- arguments: z
- .record(z.string(), z.unknown())
- .optional()
- .describe(generatedToolInputDescriptions.arguments),
- fields: z
- .array(z.string().min(1))
- .min(1)
- .optional()
- .describe(generatedToolInputDescriptions.fields),
-};
-
-export function generatedToolInputSchemaForCaplet(caplet: CapletSchemaBackend) {
- const operationValues = caplet.backend === "mcp" ? mcpOperations : operations;
- return z
- .object({
- operation: z.enum(operationValues).describe(generatedToolInputDescriptions.operation),
- ...baseShape,
- ...(caplet.backend === "mcp"
- ? {
- uri: z.string().optional().describe(generatedToolInputDescriptions.uri),
- prompt: z.string().optional().describe(generatedToolInputDescriptions.prompt),
- ref: completionRefSchema.optional().describe(generatedToolInputDescriptions.ref),
- argument: completionArgumentSchema
- .optional()
- .describe(generatedToolInputDescriptions.argument),
- }
- : {}),
- })
- .strict();
-}
-
-export const generatedToolInputSchema = generatedToolInputSchemaForCaplet({ backend: "tool" });
-
-export function generatedToolInputJsonSchemaForCaplet(caplet: CapletSchemaBackend) {
- const mcp = caplet.backend === "mcp";
- return {
- type: "object",
- properties: {
- operation: {
- type: "string",
- enum: mcp ? mcpOperations : operations,
- description: generatedToolInputDescriptions.operation,
- },
- query: { type: "string", description: generatedToolInputDescriptions.query },
- limit: { type: "integer", minimum: 1, description: generatedToolInputDescriptions.limit },
- tool: { type: "string", description: generatedToolInputDescriptions.tool },
- arguments: { type: "object", description: generatedToolInputDescriptions.arguments },
- fields: {
- type: "array",
- items: { type: "string", minLength: 1 },
- minItems: 1,
- description: generatedToolInputDescriptions.fields,
- },
- ...(mcp
- ? {
- uri: { type: "string", description: generatedToolInputDescriptions.uri },
- prompt: { type: "string", description: generatedToolInputDescriptions.prompt },
- ref: {
- oneOf: [
- {
- type: "object",
- properties: { type: { const: "prompt" }, name: { type: "string", minLength: 1 } },
- required: ["type", "name"],
- additionalProperties: false,
- },
- {
- type: "object",
- properties: {
- type: { const: "resourceTemplate" },
- uri: { type: "string", minLength: 1 },
- },
- required: ["type", "uri"],
- additionalProperties: false,
- },
- ],
- description: generatedToolInputDescriptions.ref,
- },
- argument: {
- type: "object",
- properties: { name: { type: "string", minLength: 1 }, value: { type: "string" } },
- required: ["name", "value"],
- additionalProperties: false,
- description: generatedToolInputDescriptions.argument,
- },
- }
- : {}),
- },
- required: ["operation"],
- additionalProperties: false,
- } as const;
-}
-
-export function generatedToolInputJsonSchema() {
- return generatedToolInputJsonSchemaForCaplet({ backend: "tool" });
-}
-```
-
-- [ ] **Step 5: Extend `validateOperationRequest`**
-
-Modify `packages/core/src/tools.ts` so `handleServerTool` passes `server.backend` and `validateOperationRequest` accepts backend:
-
-```ts
-const parsed = validateOperationRequest(
- request,
- registry.config.options.maxSearchLimit,
- server.backend,
-);
-```
-
-Update the function signature and parsing start:
-
-```ts
-export function validateOperationRequest(
- request: unknown,
- maxSearchLimit: number,
- backend: string = "tool",
-): RequiredOperationRequest {
- const result = generatedToolInputSchemaForCaplet({ backend }).safeParse(request);
- if (
- request &&
- typeof request === "object" &&
- "operation" in request &&
- typeof (request as { operation?: unknown }).operation === "string" &&
- !mcpOperations.includes((request as { operation: string }).operation as never)
- ) {
- throw new CapletsError(
- "UNKNOWN_OPERATION",
- `Unknown operation: ${(request as { operation: string }).operation}`,
- );
- }
- if (
- request &&
- typeof request === "object" &&
- "operation" in request &&
- typeof (request as { operation?: unknown }).operation === "string" &&
- backend !== "mcp" &&
- mcpOperations.includes((request as { operation: string }).operation as never) &&
- !operations.includes((request as { operation: string }).operation as never)
- ) {
- throw new CapletsError(
- "UNSUPPORTED_OPERATION",
- `${(request as { operation: string }).operation} is only available for MCP-backed Caplets`,
- );
- }
- if (!result.success) {
- throw new CapletsError(
- "REQUEST_INVALID",
- "Generated server tool request is invalid",
- result.error.issues,
- );
- }
- const value = result.data;
- // keep existing allowed() helper and existing operation cases, then add the MCP-only cases below
-}
-```
-
-Add operation cases after `call_tool`:
-
-```ts
-case "list_resources":
-case "list_resource_templates":
-case "list_prompts":
- allowed(["limit"]);
- if (value.limit !== undefined && value.limit > maxSearchLimit) {
- throw new CapletsError("REQUEST_INVALID", `${value.operation} limit must be <= ${maxSearchLimit}`);
- }
- return value.limit === undefined ? { operation: value.operation } : { operation: value.operation, limit: value.limit };
-case "search_resources":
-case "search_prompts":
- allowed(["query", "limit"]);
- if (!value.query) throw new CapletsError("REQUEST_INVALID", `${value.operation} requires query`);
- if (value.limit !== undefined && value.limit > maxSearchLimit) {
- throw new CapletsError("REQUEST_INVALID", `${value.operation} limit must be <= ${maxSearchLimit}`);
- }
- return value.limit === undefined
- ? { operation: value.operation, query: value.query }
- : { operation: value.operation, query: value.query, limit: value.limit };
-case "read_resource":
- allowed(["uri"]);
- if (!value.uri) throw new CapletsError("REQUEST_INVALID", "read_resource requires uri");
- return { operation: "read_resource", uri: value.uri };
-case "get_prompt":
- allowed(["prompt", "arguments"]);
- if (!value.prompt) throw new CapletsError("REQUEST_INVALID", "get_prompt requires prompt");
- if (value.arguments !== undefined && !isPlainObject(value.arguments)) {
- throw new CapletsError("REQUEST_INVALID", "get_prompt.arguments must be a JSON object");
- }
- return { operation: "get_prompt", prompt: value.prompt, arguments: value.arguments ?? {} };
-case "complete":
- allowed(["ref", "argument"]);
- if (!value.ref) throw new CapletsError("REQUEST_INVALID", "complete requires ref");
- if (!value.argument) throw new CapletsError("REQUEST_INVALID", "complete requires argument");
- return { operation: "complete", ref: value.ref, argument: value.argument };
-```
-
-Extend `RequiredOperationRequest` with exact union members:
-
-```ts
- | { operation: "list_resources" | "list_resource_templates" | "list_prompts"; limit?: number }
- | { operation: "search_resources" | "search_prompts"; query: string; limit?: number }
- | { operation: "read_resource"; uri: string }
- | { operation: "get_prompt"; prompt: string; arguments: Record }
- | {
- operation: "complete";
- ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string };
- argument: { name: string; value: string };
- };
-```
-
-- [ ] **Step 6: Run schema tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: PASS for validation tests; handler tests may still fail for unimplemented MCP operation dispatch if tests were added beyond this task.
-
-- [ ] **Step 7: Commit Task 1**
-
-```sh
-git add packages/core/src/generated-tool-input-schema.ts packages/core/src/tools.ts packages/core/src/errors.ts packages/core/test/tools.test.ts
-git commit -m "feat(core): add backend-aware Caplet operation schemas"
-```
-
----
-
-### Task 2: Wire backend-specific schemas into MCP serving and native integrations
-
-**Files:**
-
-- Modify: `packages/core/src/capability-description.ts`
-- Modify: `packages/core/src/serve/session.ts`
-- Modify: `packages/core/src/native/service.ts`
-- Modify: `packages/core/src/native/tools.ts`
-- Modify: `packages/core/src/native/remote.ts`
-- Modify: `packages/pi/src/index.ts`
-- Modify: `packages/opencode/src/schema.ts`
-- Modify: `packages/opencode/src/hooks.ts`
-- Test: `packages/core/test/serve-session.test.ts`
-- Test: `packages/core/test/native-remote.test.ts`
-- Test: `packages/pi/test/pi.test.ts`
-- Test: `packages/opencode/test/opencode.test.ts`
-
-- [ ] **Step 1: Write failing MCP session schema tests**
-
-Add to `packages/core/test/serve-session.test.ts`:
-
-```ts
-it("registers MCP Caplets with MCP-only operations and non-MCP Caplets without them", async () => {
- const { dir, configPath, projectConfigPath } = tempConfig({
- mcpServers: {
- docs: { name: "Docs", description: "Read docs.", command: "node" },
- },
- httpApis: {
- status: {
- name: "Status",
- description: "Check status.",
- baseUrl: "http://127.0.0.1:1",
- auth: { type: "none" },
- actions: { check: { method: "GET", path: "/status" } },
- },
- },
- });
- dirs.push(dir);
- const engine = new CapletsEngine({ configPath, projectConfigPath, watch: false });
- const server = mockServer();
- const session = new CapletsMcpSession(engine, { server });
-
- const docsOptions = server.registerTool.mock.calls.find(([name]) => name === "docs")?.[1];
- const statusOptions = server.registerTool.mock.calls.find(([name]) => name === "status")?.[1];
- expect(docsOptions.inputSchema.shape.operation.options).toContain("list_resources");
- expect(docsOptions.description).toContain("resources");
- expect(statusOptions.inputSchema.shape.operation.options).not.toContain("list_resources");
- expect(statusOptions.description).not.toContain("read_resource");
-
- await session.close();
- await engine.close();
-});
-```
-
-Update `mockServer()` in the same file so `registerTool` records the options argument:
-
-```ts
-registerTool: vi.fn((name: string, _options: unknown) => {
-```
-
-- [ ] **Step 2: Write failing native adapter tests**
-
-Add to `packages/core/test/native-remote.test.ts`:
-
-```ts
-it("preserves remote input schema metadata on native tools", async () => {
- const schema = {
- type: "object",
- properties: {
- operation: { type: "string", enum: ["get_caplet", "list_resources"] },
- },
- required: ["operation"],
- };
- const fixture = client([
- { name: "docs", title: "Docs", description: "Docs", inputSchema: schema } as never,
- ]);
- const service = new RemoteNativeCapletsService({ client: fixture.api, pollIntervalMs: 60_000 });
- await service.reload();
- expect(service.listTools()[0]?.inputSchema).toEqual(schema);
- await service.close();
-});
-```
-
-Add a Pi test near existing tool registration tests in `packages/pi/test/pi.test.ts`:
-
-```ts
-it("registers each Caplet with its native input schema", async () => {
- const schema = {
- type: "object",
- properties: { operation: { type: "string", enum: ["get_caplet", "list_resources"] } },
- required: ["operation"],
- };
- const service = mockService([
- {
- caplet: "docs",
- toolName: "caplets_docs",
- title: "Docs",
- description: "Docs",
- promptGuidance: [],
- inputSchema: schema,
- },
- ]);
- const pi = mockPi();
- await createCapletsPiExtension({ service })(pi);
- expect(pi.registerTool).toHaveBeenCalledWith(expect.objectContaining({ parameters: schema }));
-});
-```
-
-Add an OpenCode test near schema/tool tests in `packages/opencode/test/opencode.test.ts`:
-
-```ts
-it("builds OpenCode args from the Caplet operation set", async () => {
- const service = mockService([
- {
- caplet: "docs",
- toolName: "caplets_docs",
- title: "Docs",
- description: "Docs",
- promptGuidance: [],
- operationNames: ["get_caplet", "list_resources"],
- },
- ]);
- const hooks = await createCapletsOpenCodeHooks(service);
- expect(Object.keys(hooks.tool ?? {})).toEqual(["caplets_docs"]);
- expect(service.listTools()[0]?.operationNames).toEqual(["get_caplet", "list_resources"]);
-});
-```
-
-- [ ] **Step 3: Run focused tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/serve-session.test.ts test/native-remote.test.ts
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: FAIL because registered tools still use the global schema and native tools do not carry schema metadata.
-
-- [ ] **Step 4: Update capability descriptions**
-
-Change `packages/core/src/capability-description.ts`:
-
-```ts
-import type { CapletConfig } from "./config";
-
-export function capabilityDescription(server: CapletConfig): string {
- return [
- `${server.name} Caplet.`,
- server.description,
- server.backend === "mcp"
- ? "Use get_caplet for details; use tools for actions, resources for readable context, prompts for reusable workflows, and complete for prompt/resource-template arguments."
- : "Use get_caplet for details when needed; use search_tools or list_tools to discover downstream operations.",
- ]
- .filter(Boolean)
- .join(" ");
-}
-```
-
-- [ ] **Step 5: Register backend-specific MCP tool schemas**
-
-In `packages/core/src/serve/session.ts`, import `generatedToolInputSchemaForCaplet` instead of `generatedToolInputSchema`. In both `tool.update()` and `registerTool()`, pass the backend-aware input schema:
-
-```ts
-inputSchema: generatedToolInputSchemaForCaplet(caplet),
-```
-
-For `tool.update`, include the field with title, description, callback, and enabled:
-
-```ts
-tool.update({
- title: caplet.name,
- description: capabilityDescription(caplet),
- inputSchema: generatedToolInputSchemaForCaplet(caplet),
- callback: async (request) => this.handleTool(serverId, request),
- enabled: true,
-});
-```
-
-- [ ] **Step 6: Add native schema fields**
-
-Modify `NativeCapletTool` in `packages/core/src/native/service.ts`:
-
-```ts
-export type NativeCapletTool = {
- caplet: string;
- toolName: string;
- title: string;
- description: string;
- promptGuidance: string[];
- inputSchema: ReturnType;
- operationNames: string[];
-};
-```
-
-Import schema helpers and populate local native tools:
-
-```ts
-import { generatedToolInputJsonSchemaForCaplet } from "../generated-tool-input-schema";
-
-const inputSchema = generatedToolInputJsonSchemaForCaplet(caplet);
-return {
- caplet: caplet.server,
- toolName,
- title: caplet.name,
- description: nativeCapletToolDescription(toolName, caplet),
- promptGuidance: nativeCapletPromptGuidance(toolName, caplet),
- inputSchema,
- operationNames: [...inputSchema.properties.operation.enum],
-};
-```
-
-- [ ] **Step 7: Update native prompt guidance**
-
-Modify `packages/core/src/native/tools.ts`:
-
-```ts
-export function nativeCapletsSystemGuidance(toolNames: string[]): string {
- const tools = toolNames.length > 0 ? toolNames.map((tool) => `- ${tool}`).join("\n") : "- none";
- return [
- "## Caplets Native Tools",
- "",
- "Caplets tools expose configured capability domains through progressive discovery.",
- "",
- "Available Caplets native tools:",
- tools,
- "",
- "Flow: get_caplet when the domain is unfamiliar; use search_tools/list_tools for actions; MCP-backed Caplets may also expose resources, prompts, and completions in their tool schema.",
- "Use fields on call_tool when a non-GraphQL downstream outputSchema allows selecting only needed structured paths.",
- ].join("\n");
-}
-
-export function nativeCapletPromptGuidance(toolName: string, caplet: CapletConfig): string[] {
- return caplet.backend === "mcp"
- ? [
- `Use ${toolName} for the ${caplet.name} Caplet capability domain.`,
- "Prefer resources for readable context, prompts for reusable workflows, and tools for actions.",
- ]
- : [`Use ${toolName} for the ${caplet.name} Caplet capability domain.`];
-}
-```
-
-- [ ] **Step 8: Preserve schema metadata for remote native tools**
-
-Modify `packages/core/src/native/remote.ts`:
-
-```ts
-export type RemoteCapletsTool = {
- name: string;
- title?: string | undefined;
- description?: string | undefined;
- inputSchema?: unknown;
-};
-```
-
-In `createSdkRemoteCapletsClient().listTools()` include `inputSchema`:
-
-```ts
-...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
-```
-
-In `remoteToolToNativeTool`, derive operation names:
-
-```ts
-const inputSchema = isPlainObject(tool.inputSchema)
- ? tool.inputSchema
- : generatedToolInputJsonSchemaForCaplet({ backend: "tool" });
-const operationNames = operationNamesFromSchema(inputSchema);
-```
-
-Add helpers:
-
-```ts
-function operationNamesFromSchema(schema: Record): string[] {
- const properties = schema.properties;
- if (!isPlainObject(properties)) return [...operations];
- const operation = properties.operation;
- if (!isPlainObject(operation) || !Array.isArray(operation.enum)) return [...operations];
- return operation.enum.filter((value): value is string => typeof value === "string");
-}
-
-function isPlainObject(value: unknown): value is Record {
- return value !== null && typeof value === "object" && !Array.isArray(value);
-}
-```
-
-Return `inputSchema` and `operationNames` from `remoteToolToNativeTool`.
-
-- [ ] **Step 9: Use native schemas in Pi and OpenCode**
-
-In `packages/pi/src/index.ts`, remove the import of `generatedToolInputJsonSchema` and change `createPiTool`:
-
-```ts
-parameters: caplet.inputSchema as ToolDefinition["parameters"],
-```
-
-In `packages/opencode/src/schema.ts`, replace the fixed schema helper with:
-
-```ts
-import { tool } from "@opencode-ai/plugin";
-import { operations } from "@caplets/core/generated-tool-input-schema";
-
-export function capletsOpenCodeArgs(operationNames: string[] = [...operations]) {
- const enumValues = operationNames.length > 0 ? operationNames : [...operations];
- return {
- operation: tool.schema.enum(enumValues as [string, ...string[]]),
- query: tool.schema.string().optional(),
- limit: tool.schema.number().int().positive().optional(),
- tool: tool.schema.string().optional(),
- arguments: tool.schema.record(tool.schema.string(), tool.schema.unknown()).optional(),
- fields: tool.schema.array(tool.schema.string().min(1)).min(1).optional(),
- uri: tool.schema.string().optional(),
- prompt: tool.schema.string().optional(),
- ref: tool.schema.unknown().optional(),
- argument: tool.schema.unknown().optional(),
- };
-}
-```
-
-In `packages/opencode/src/hooks.ts`, call:
-
-```ts
-args: capletsOpenCodeArgs(caplet.operationNames),
-```
-
-- [ ] **Step 10: Run native and session tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/serve-session.test.ts test/native-remote.test.ts
-pnpm --filter @caplets/pi test -- test/pi.test.ts
-pnpm --filter @caplets/opencode test -- test/opencode.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 11: Commit Task 2**
-
-```sh
-git add packages/core/src/capability-description.ts packages/core/src/serve/session.ts packages/core/src/native/service.ts packages/core/src/native/tools.ts packages/core/src/native/remote.ts packages/pi/src/index.ts packages/opencode/src/schema.ts packages/opencode/src/hooks.ts packages/core/test/serve-session.test.ts packages/core/test/native-remote.test.ts packages/pi/test/pi.test.ts packages/opencode/test/opencode.test.ts
-git commit -m "feat(core): expose backend-specific Caplet schemas"
-```
-
----
-
-### Task 3: Add MCP resources, prompts, completions, and cache handling to DownstreamManager
-
-**Files:**
-
-- Modify: `packages/core/src/downstream.ts`
-- Modify: `packages/core/test/fixtures/stdio-server.ts`
-- Test: `packages/core/test/downstream.test.ts`
-
-- [ ] **Step 1: Extend the stdio MCP fixture**
-
-Modify `packages/core/test/fixtures/stdio-server.ts` to import `ResourceTemplate` and register resource/prompt capabilities:
-
-```ts
-import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp";
-```
-
-Append before `await server.connect(...)`:
-
-```ts
-server.registerResource(
- "README",
- "file:///repo/README.md",
- { description: "Project README", mimeType: "text/markdown" },
- async (uri) => ({
- contents: [{ uri: uri.href, text: "# Fixture README", mimeType: "text/markdown" }],
- }),
-);
-
-server.registerResource(
- "Repository file",
- new ResourceTemplate("file:///repo/{path}", {
- list: async () => ({
- resources: [
- { uri: "file:///repo/src/index.ts", name: "src/index.ts", mimeType: "text/typescript" },
- ],
- }),
- complete: {
- path: (value) => ["README.md", "src/index.ts"].filter((path) => path.startsWith(value)),
- },
- }),
- { description: "Read a repository file", mimeType: "text/plain" },
- async (uri) => ({
- contents: [{ uri: uri.href, text: `content:${uri.href}`, mimeType: "text/plain" }],
- }),
-);
-
-server.registerPrompt(
- "review_issue",
- {
- description: "Review an issue before implementation.",
- argsSchema: { issueId: z.string().describe("Issue ID") },
- },
- ({ issueId }) => ({
- messages: [{ role: "user", content: { type: "text", text: `Review ${issueId}` } }],
- }),
-);
-```
-
-- [ ] **Step 2: Write failing downstream tests**
-
-Add to `packages/core/test/downstream.test.ts`:
-
-```ts
-it("lists, searches, and reads MCP resources and templates", async () => {
- const fixture = join(fixturesDir, "stdio-server.ts");
- const config = parseConfig({
- mcpServers: {
- fixture: {
- name: "Fixture",
- description: "Fixture server.",
- command: "pnpm",
- args: ["exec", "tsx", fixture],
- },
- },
- });
- const manager = new DownstreamManager(new ServerRegistry(config));
- const server = config.mcpServers.fixture!;
- try {
- const resources = await manager.listResources(server);
- expect(resources.map((resource) => resource.uri)).toContain("file:///repo/README.md");
- expect(manager.searchResources(server, resources, "readme", 10)).toEqual([
- expect.objectContaining({ kind: "resource", uri: "file:///repo/README.md" }),
- ]);
-
- const templates = await manager.listResourceTemplates(server);
- expect(templates).toEqual([expect.objectContaining({ uriTemplate: "file:///repo/{path}" })]);
-
- const read = await manager.readResource(server, "file:///repo/README.md");
- expect(read).toMatchObject({
- contents: [{ uri: "file:///repo/README.md", text: "# Fixture README" }],
- });
- } finally {
- await manager.close();
- }
-});
-
-it("lists, searches, gets prompts, and forwards completions", async () => {
- const fixture = join(fixturesDir, "stdio-server.ts");
- const config = parseConfig({
- mcpServers: {
- fixture: {
- name: "Fixture",
- description: "Fixture server.",
- command: "pnpm",
- args: ["exec", "tsx", fixture],
- },
- },
- });
- const manager = new DownstreamManager(new ServerRegistry(config));
- const server = config.mcpServers.fixture!;
- try {
- const prompts = await manager.listPrompts(server);
- expect(prompts).toEqual([expect.objectContaining({ name: "review_issue" })]);
- expect(manager.searchPrompts(server, prompts, "issue", 10)).toEqual([
- expect.objectContaining({ prompt: "review_issue" }),
- ]);
-
- const prompt = await manager.getPrompt(server, "review_issue", { issueId: "CAP-123" });
- expect(prompt).toMatchObject({
- messages: [{ role: "user", content: { text: "Review CAP-123" } }],
- });
-
- const completion = await manager.complete(server, {
- ref: { type: "resourceTemplate", uri: "file:///repo/{path}" },
- argument: { name: "path", value: "src/" },
- });
- expect(completion).toMatchObject({ completion: { values: ["src/index.ts"] } });
- } finally {
- await manager.close();
- }
-});
-
-it("returns UNSUPPORTED_CAPABILITY when the server does not advertise resources", async () => {
- const config = parseConfig({
- mcpServers: { empty: { name: "Empty", description: "No resources.", command: "node" } },
- });
- const server = config.mcpServers.empty!;
- const manager = new DownstreamManager(new ServerRegistry(config));
- vi.spyOn(manager as never, "connect").mockResolvedValue({
- client: { getServerCapabilities: () => ({ tools: {} }) },
- } as never);
- await expect(manager.listResources(server)).rejects.toMatchObject({
- code: "UNSUPPORTED_CAPABILITY",
- });
-});
-```
-
-- [ ] **Step 3: Run downstream tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/downstream.test.ts
-```
-
-Expected: FAIL because `DownstreamManager` has no resource/prompt/completion methods.
-
-- [ ] **Step 4: Add MCP imports, cache fields, and compact types**
-
-Modify `packages/core/src/downstream.ts` imports:
-
-```ts
-import {
- CompatibilityCallToolResultSchema,
- CompleteResultSchema,
- type CompleteRequestParams,
- ListPromptsResultSchema,
- ListResourceTemplatesResultSchema,
- ListResourcesResultSchema,
- PromptListChangedNotificationSchema,
- type Prompt,
- ReadResourceResultSchema,
- ResourceListChangedNotificationSchema,
- type Resource,
- type ResourceTemplate as McpResourceTemplate,
- ToolListChangedNotificationSchema,
- type Tool,
-} from "@modelcontextprotocol/sdk/types";
-```
-
-Extend `ManagedConnection`:
-
-```ts
- resources?: Resource[];
- resourcesFetchedAt?: number;
- resourceTemplates?: McpResourceTemplate[];
- resourceTemplatesFetchedAt?: number;
- prompts?: Prompt[];
- promptsFetchedAt?: number;
-```
-
-Add compact types near `CompactTool`:
-
-```ts
-export type CompactResource = {
- id: string;
- kind: "resource";
- uri: string;
- name?: string;
- description?: string;
- mimeType?: string;
- size?: number;
-};
-export type CompactResourceTemplate = {
- id: string;
- kind: "resourceTemplate";
- uriTemplate: string;
- name?: string;
- description?: string;
- mimeType?: string;
-};
-export type CompactPrompt = {
- id: string;
- prompt: string;
- description?: string;
- arguments?: Prompt["arguments"];
-};
-```
-
-- [ ] **Step 5: Add notification-based invalidation**
-
-Inside `connect()` before `await client.connect(...)`, register handlers:
-
-```ts
-client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
- connection.tools = undefined;
- connection.toolsFetchedAt = undefined;
-});
-client.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
- connection.resources = undefined;
- connection.resourcesFetchedAt = undefined;
- connection.resourceTemplates = undefined;
- connection.resourceTemplatesFetchedAt = undefined;
-});
-client.setNotificationHandler(PromptListChangedNotificationSchema, () => {
- connection.prompts = undefined;
- connection.promptsFetchedAt = undefined;
-});
-```
-
-- [ ] **Step 6: Add capability assertion helpers**
-
-Add helpers in `DownstreamManager`:
-
-```ts
-private async assertCapability(server: CapletServerConfig, capability: "resources" | "prompts" | "completions"): Promise {
- const connection = await this.connect(server);
- const capabilities = connection.client.getServerCapabilities();
- if (!capabilities?.[capability]) {
- throw new CapletsError(
- "UNSUPPORTED_CAPABILITY",
- `${server.server} does not advertise MCP ${capability}`,
- { server: server.server, capability },
- );
- }
- return connection;
-}
-
-private isCacheFresh(fetchedAt: number | undefined, ttlMs: number): boolean {
- return fetchedAt !== undefined && ttlMs > 0 && Date.now() - fetchedAt <= ttlMs;
-}
-```
-
-- [ ] **Step 7: Implement list/read/get/complete methods**
-
-Add public methods to `DownstreamManager`:
-
-```ts
-async listResources(server: CapletServerConfig, force = false): Promise {
- const connection = await this.assertCapability(server, "resources");
- if (!force && connection.resources && this.isCacheFresh(connection.resourcesFetchedAt, server.toolCacheTtlMs)) {
- return connection.resources;
- }
- const resources: Resource[] = [];
- let cursor: string | undefined;
- do {
- const result = await connection.client.listResources(cursor ? { cursor } : undefined, { timeout: server.startupTimeoutMs });
- resources.push(...(result.resources ?? []));
- cursor = result.nextCursor;
- } while (cursor);
- connection.resources = resources;
- connection.resourcesFetchedAt = Date.now();
- return resources;
-}
-
-async listResourceTemplates(server: CapletServerConfig, force = false): Promise {
- const connection = await this.assertCapability(server, "resources");
- if (!force && connection.resourceTemplates && this.isCacheFresh(connection.resourceTemplatesFetchedAt, server.toolCacheTtlMs)) {
- return connection.resourceTemplates;
- }
- const templates: McpResourceTemplate[] = [];
- let cursor: string | undefined;
- do {
- const result = await connection.client.listResourceTemplates(cursor ? { cursor } : undefined, { timeout: server.startupTimeoutMs });
- templates.push(...(result.resourceTemplates ?? []));
- cursor = result.nextCursor;
- } while (cursor);
- connection.resourceTemplates = templates;
- connection.resourceTemplatesFetchedAt = Date.now();
- return templates;
-}
-
-async readResource(server: CapletServerConfig, uri: string) {
- const connection = await this.assertCapability(server, "resources");
- try {
- return await connection.client.readResource({ uri }, { timeout: server.callTimeoutMs });
- } catch (error) {
- throw new CapletsError("DOWNSTREAM_RESOURCE_ERROR", `Downstream resource read failed for ${server.server}/${uri}`, toSafeError(error));
- }
-}
-
-async listPrompts(server: CapletServerConfig, force = false): Promise {
- const connection = await this.assertCapability(server, "prompts");
- if (!force && connection.prompts && this.isCacheFresh(connection.promptsFetchedAt, server.toolCacheTtlMs)) {
- return connection.prompts;
- }
- const prompts: Prompt[] = [];
- let cursor: string | undefined;
- do {
- const result = await connection.client.listPrompts(cursor ? { cursor } : undefined, { timeout: server.startupTimeoutMs });
- prompts.push(...(result.prompts ?? []));
- cursor = result.nextCursor;
- } while (cursor);
- connection.prompts = prompts;
- connection.promptsFetchedAt = Date.now();
- return prompts;
-}
-
-async getPrompt(server: CapletServerConfig, promptName: string, args: Record) {
- const prompts = await this.listPrompts(server);
- if (!prompts.some((prompt) => prompt.name === promptName)) {
- throw new CapletsError("PROMPT_NOT_FOUND", `Prompt ${promptName} was not found on ${server.server}`);
- }
- const connection = await this.assertCapability(server, "prompts");
- try {
- return await connection.client.getPrompt({ name: promptName, arguments: stringifyPromptArgs(args) }, { timeout: server.callTimeoutMs });
- } catch (error) {
- throw new CapletsError("DOWNSTREAM_PROMPT_ERROR", `Downstream prompt failed for ${server.server}/${promptName}`, toSafeError(error));
- }
-}
-
-async complete(server: CapletServerConfig, request: { ref: { type: "prompt"; name: string } | { type: "resourceTemplate"; uri: string }; argument: { name: string; value: string } }) {
- const connection = await this.assertCapability(server, "completions");
- const params: CompleteRequestParams = {
- ref: request.ref.type === "prompt" ? { type: "ref/prompt", name: request.ref.name } : { type: "ref/resource", uri: request.ref.uri },
- argument: request.argument,
- };
- try {
- return await connection.client.complete(params, { timeout: server.callTimeoutMs });
- } catch (error) {
- throw new CapletsError("DOWNSTREAM_COMPLETION_ERROR", `Downstream completion failed for ${server.server}`, toSafeError(error));
- }
-}
-```
-
-Add helper:
-
-```ts
-function stringifyPromptArgs(args: Record): Record {
- return Object.fromEntries(
- Object.entries(args).map(([key, value]) => [
- key,
- typeof value === "string" ? value : JSON.stringify(value),
- ]),
- );
-}
-```
-
-- [ ] **Step 8: Add compact and search helpers**
-
-Add methods:
-
-```ts
-compactResource(server: CapletServerConfig, resource: Resource): CompactResource {
- return {
- id: server.server,
- kind: "resource",
- uri: resource.uri,
- ...(resource.name ? { name: resource.name } : {}),
- ...(resource.description ? { description: resource.description } : {}),
- ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
- ...(typeof resource.size === "number" ? { size: resource.size } : {}),
- };
-}
-
-compactResourceTemplate(server: CapletServerConfig, template: McpResourceTemplate): CompactResourceTemplate {
- return {
- id: server.server,
- kind: "resourceTemplate",
- uriTemplate: template.uriTemplate,
- ...(template.name ? { name: template.name } : {}),
- ...(template.description ? { description: template.description } : {}),
- ...(template.mimeType ? { mimeType: template.mimeType } : {}),
- };
-}
-
-compactPrompt(server: CapletServerConfig, prompt: Prompt): CompactPrompt {
- return {
- id: server.server,
- prompt: prompt.name,
- ...(prompt.description ? { description: prompt.description } : {}),
- ...(prompt.arguments ? { arguments: prompt.arguments } : {}),
- };
-}
-
-searchResources(server: CapletServerConfig, resources: Resource[], query: string, limit: number): Array {
- const lower = query.toLocaleLowerCase();
- return resources
- .map((resource) => this.compactResource(server, resource))
- .filter((resource) => [resource.uri, resource.name, resource.description, resource.mimeType].some((value) => value?.toLocaleLowerCase().includes(lower)))
- .slice(0, limit);
-}
-
-searchResourceTemplates(server: CapletServerConfig, templates: McpResourceTemplate[], query: string, limit: number): CompactResourceTemplate[] {
- const lower = query.toLocaleLowerCase();
- return templates
- .map((template) => this.compactResourceTemplate(server, template))
- .filter((template) => [template.uriTemplate, template.name, template.description, template.mimeType].some((value) => value?.toLocaleLowerCase().includes(lower)))
- .slice(0, limit);
-}
-
-searchPrompts(server: CapletServerConfig, prompts: Prompt[], query: string, limit: number): CompactPrompt[] {
- const lower = query.toLocaleLowerCase();
- return prompts
- .map((prompt) => this.compactPrompt(server, prompt))
- .filter((prompt) => [prompt.prompt, prompt.description, ...(prompt.arguments ?? []).flatMap((arg) => [arg.name, arg.description])].some((value) => value?.toLocaleLowerCase().includes(lower)))
- .slice(0, limit);
-}
-```
-
-- [ ] **Step 9: Enrich `checkServer` with MCP capabilities and counts**
-
-Change `checkServer` so it still refreshes tools and additionally reports best-effort counts:
-
-```ts
-const connection = await this.connect(server);
-const capabilities = connection.client.getServerCapabilities() ?? {};
-const tools = await this.refreshTools(server, true);
-const result = {
- id: server.server,
- status: "available",
- capabilities: {
- tools: Boolean(capabilities.tools),
- resources: Boolean(capabilities.resources),
- resourceTemplates: Boolean(capabilities.resources),
- prompts: Boolean(capabilities.prompts),
- completions: Boolean(capabilities.completions),
- },
- toolCount: tools.length,
- elapsedMs: Date.now() - startedAt,
-};
-if (capabilities.resources) {
- Object.assign(result, {
- resourceCount: (await this.listResources(server, true)).length,
- resourceTemplateCount: (await this.listResourceTemplates(server, true)).length,
- });
-}
-if (capabilities.prompts) {
- Object.assign(result, { promptCount: (await this.listPrompts(server, true)).length });
-}
-return result;
-```
-
-- [ ] **Step 10: Run downstream tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/downstream.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 11: Commit Task 3**
-
-```sh
-git add packages/core/src/downstream.ts packages/core/test/fixtures/stdio-server.ts packages/core/test/downstream.test.ts
-git commit -m "feat(core): add MCP resource and prompt forwarding"
-```
-
----
-
-### Task 4: Dispatch MCP operations through generated Caplet tools
-
-**Files:**
-
-- Modify: `packages/core/src/tools.ts`
-- Test: `packages/core/test/tools.test.ts`
-
-- [ ] **Step 1: Write failing handler tests**
-
-Add to `packages/core/test/tools.test.ts` inside `describe("generated tool handlers", ...)`:
-
-```ts
-it("lists and searches MCP resources through handleServerTool", async () => {
- const downstream = {
- listResources: vi.fn().mockResolvedValue([
- {
- uri: "file:///repo/README.md",
- name: "README",
- description: "Docs",
- mimeType: "text/markdown",
- },
- ]),
- listResourceTemplates: vi
- .fn()
- .mockResolvedValue([
- { uriTemplate: "file:///repo/{path}", name: "File", description: "Repo file" },
- ]),
- compactResource: (_server: unknown, resource: unknown) => ({
- id: "alpha",
- kind: "resource",
- ...(resource as object),
- }),
- compactResourceTemplate: (_server: unknown, template: unknown) => ({
- id: "alpha",
- kind: "resourceTemplate",
- ...(template as object),
- }),
- searchResources: vi.fn((_server, resources) =>
- resources.map((resource: unknown) => ({
- id: "alpha",
- kind: "resource",
- ...(resource as object),
- })),
- ),
- searchResourceTemplates: vi.fn((_server, templates) =>
- templates.map((template: unknown) => ({
- id: "alpha",
- kind: "resourceTemplate",
- ...(template as object),
- })),
- ),
- } as unknown as DownstreamManager;
-
- const listed = (await handleServerTool(
- server,
- { operation: "list_resources" },
- registry,
- downstream,
- )) as any;
- expect(listed.structuredContent.result.resources).toEqual([
- expect.objectContaining({ uri: "file:///repo/README.md" }),
- ]);
- expect(listed.structuredContent.result.resourceTemplates).toEqual([
- expect.objectContaining({ uriTemplate: "file:///repo/{path}" }),
- ]);
-
- const searched = (await handleServerTool(
- server,
- { operation: "search_resources", query: "readme" },
- registry,
- downstream,
- )) as any;
- expect(searched.structuredContent.result.matches).toEqual([
- expect.objectContaining({ kind: "resource" }),
- expect.objectContaining({ kind: "resourceTemplate" }),
- ]);
-});
-
-it("reads resources, gets prompts, and completes with Caplets metadata", async () => {
- const downstream = {
- readResource: vi
- .fn()
- .mockResolvedValue({ contents: [{ uri: "file:///repo/README.md", text: "hello" }] }),
- getPrompt: vi.fn().mockResolvedValue({
- messages: [{ role: "user", content: { type: "text", text: "Review CAP-1" } }],
- }),
- complete: vi.fn().mockResolvedValue({ completion: { values: ["CAP-1"] } }),
- } as unknown as DownstreamManager;
-
- const read = (await handleServerTool(
- server,
- { operation: "read_resource", uri: "file:///repo/README.md" },
- registry,
- downstream,
- )) as any;
- expect(read.contents[0].text).toBe("hello");
- expect(read._meta.caplets).toMatchObject({
- operation: "read_resource",
- uri: "file:///repo/README.md",
- });
-
- const prompt = (await handleServerTool(
- server,
- { operation: "get_prompt", prompt: "review_issue", arguments: { issueId: "CAP-1" } },
- registry,
- downstream,
- )) as any;
- expect(prompt.messages[0].content.text).toBe("Review CAP-1");
- expect(prompt._meta.caplets).toMatchObject({ operation: "get_prompt", prompt: "review_issue" });
-
- const completion = (await handleServerTool(
- server,
- {
- operation: "complete",
- ref: { type: "prompt", name: "review_issue" },
- argument: { name: "issueId", value: "CAP-" },
- },
- registry,
- downstream,
- )) as any;
- expect(completion.completion.values).toEqual(["CAP-1"]);
- expect(completion._meta.caplets).toMatchObject({ operation: "complete" });
-});
-```
-
-- [ ] **Step 2: Run handler tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: FAIL because `handleServerTool` does not dispatch MCP-only operations and metadata type lacks `uri`/`prompt`.
-
-- [ ] **Step 3: Extend metadata**
-
-In `packages/core/src/tools.ts`, update `CapletResultMetadata`:
-
-```ts
- uri?: string;
- prompt?: string;
-```
-
-Update `metadataFor` signature:
-
-```ts
-export function metadataFor(
- server: CapletConfig,
- operation: RequiredOperationRequest["operation"],
- target?: string | { tool?: string; uri?: string; prompt?: string },
- startedAt?: number,
-): CapletResultMetadata {
- const targetFields = typeof target === "string" ? { tool: target } : (target ?? {});
- return {
- id: server.server,
- name: server.name,
- backend: server.backend,
- operation,
- ...targetFields,
- status: "ok",
- ...(startedAt === undefined ? {} : { elapsedMs: Date.now() - startedAt }),
- };
-}
-```
-
-Add a generic annotation helper:
-
-```ts
-export function annotateMcpResult(result: T, metadata: CapletResultMetadata): T {
- const existingMeta = (result as { _meta?: unknown })._meta;
- return {
- ...result,
- _meta: {
- ...(isPlainObject(existingMeta) ? existingMeta : {}),
- caplets: metadata,
- },
- };
-}
-```
-
-- [ ] **Step 4: Dispatch MCP-only operations**
-
-Add switch cases in `handleServerTool` after `call_tool`:
-
-```ts
-case "list_resources": {
- const backend = mcpBackendFor(server, downstream);
- const resources = await backend.listResources(server as never);
- const templates = await backend.listResourceTemplates(server as never);
- const limit = parsed.limit ?? resources.length + templates.length;
- return jsonResult(
- {
- id: server.server,
- name: server.name,
- resources: resources.slice(0, limit).map((resource) => backend.compactResource(server as never, resource)),
- resourceTemplates: templates.slice(0, Math.max(0, limit - resources.length)).map((template) => backend.compactResourceTemplate(server as never, template)),
- },
- metadataFor(server, "list_resources", undefined, startedAt),
- );
-}
-case "search_resources": {
- const backend = mcpBackendFor(server, downstream);
- const resources = await backend.listResources(server as never);
- const templates = await backend.listResourceTemplates(server as never);
- const limit = parsed.limit ?? registry.config.options.defaultSearchLimit;
- const resourceMatches = backend.searchResources(server as never, resources, parsed.query, limit);
- const templateMatches = backend.searchResourceTemplates(server as never, templates, parsed.query, Math.max(0, limit - resourceMatches.length));
- return jsonResult({ id: server.server, name: server.name, query: parsed.query, matches: [...resourceMatches, ...templateMatches] }, metadataFor(server, "search_resources", undefined, startedAt));
-}
-case "list_resource_templates": {
- const backend = mcpBackendFor(server, downstream);
- const templates = await backend.listResourceTemplates(server as never);
- const limit = parsed.limit ?? templates.length;
- return jsonResult({ id: server.server, name: server.name, resourceTemplates: templates.slice(0, limit).map((template) => backend.compactResourceTemplate(server as never, template)) }, metadataFor(server, "list_resource_templates", undefined, startedAt));
-}
-case "read_resource": {
- const result = await mcpBackendFor(server, downstream).readResource(server as never, parsed.uri);
- return annotateMcpResult(result, metadataFor(server, "read_resource", { uri: parsed.uri }, startedAt));
-}
-case "list_prompts": {
- const backend = mcpBackendFor(server, downstream);
- const prompts = await backend.listPrompts(server as never);
- const limit = parsed.limit ?? prompts.length;
- return jsonResult({ id: server.server, name: server.name, prompts: prompts.slice(0, limit).map((prompt) => backend.compactPrompt(server as never, prompt)) }, metadataFor(server, "list_prompts", undefined, startedAt));
-}
-case "search_prompts": {
- const backend = mcpBackendFor(server, downstream);
- const prompts = await backend.listPrompts(server as never);
- const limit = parsed.limit ?? registry.config.options.defaultSearchLimit;
- return jsonResult({ id: server.server, name: server.name, query: parsed.query, prompts: backend.searchPrompts(server as never, prompts, parsed.query, limit) }, metadataFor(server, "search_prompts", undefined, startedAt));
-}
-case "get_prompt": {
- const result = await mcpBackendFor(server, downstream).getPrompt(server as never, parsed.prompt, parsed.arguments);
- return annotateMcpResult(result, metadataFor(server, "get_prompt", { prompt: parsed.prompt }, startedAt));
-}
-case "complete": {
- const result = await mcpBackendFor(server, downstream).complete(server as never, { ref: parsed.ref, argument: parsed.argument });
- return annotateMcpResult(result, metadataFor(server, "complete", undefined, startedAt));
-}
-```
-
-Add helper near `backendFor`:
-
-```ts
-function mcpBackendFor(server: CapletConfig, downstream: DownstreamManager) {
- if (server.backend !== "mcp") {
- throw new CapletsError(
- "UNSUPPORTED_OPERATION",
- "MCP resource, prompt, and completion operations require an MCP-backed Caplet",
- );
- }
- return downstream;
-}
-```
-
-- [ ] **Step 5: Run tools tests to verify green**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/tools.test.ts
-```
-
-Expected: PASS.
-
-- [ ] **Step 6: Commit Task 4**
-
-```sh
-git add packages/core/src/tools.ts packages/core/test/tools.test.ts
-git commit -m "feat(core): dispatch MCP resources and prompts"
-```
-
----
-
-### Task 5: Add CLI and remote-control operation support
-
-**Files:**
-
-- Modify: `packages/core/src/cli.ts`
-- Modify: `packages/core/src/remote-control/types.ts`
-- Modify: `packages/core/src/remote-control/dispatch.ts`
-- Test: `packages/core/test/cli.test.ts`
-- Test: `packages/core/test/cli-remote.test.ts`
-- Test: `packages/core/test/remote-control-dispatch.test.ts`
-
-- [ ] **Step 1: Write failing CLI routing tests**
-
-Add to `packages/core/test/cli-remote.test.ts`:
-
-```ts
-it("routes read-resource through remote control", async () => {
- const requests: unknown[] = [];
- const out: string[] = [];
- const fetch = vi.fn(async (url: Parameters[0], init?: RequestInit) => {
- requests.push({ url: String(url), body: init?.body });
- return Response.json({
- ok: true,
- result: { contents: [{ uri: "file:///repo/README.md", text: "hello" }] },
- });
- });
-
- await runCli(["read-resource", "docs", "file:///repo/README.md", "--format", "json"], {
- env: { CAPLETS_MODE: "remote", CAPLETS_SERVER_URL: "http://127.0.0.1:5387/caplets" },
- fetch,
- writeOut: (value) => out.push(value),
- });
-
- expect(requests).toEqual([
- {
- url: "http://127.0.0.1:5387/caplets/control",
- body: JSON.stringify({
- command: "read_resource",
- arguments: {
- caplet: "docs",
- request: { operation: "read_resource", uri: "file:///repo/README.md" },
- },
- }),
- },
- ]);
- expect(JSON.parse(out.join(""))).toEqual({
- contents: [{ uri: "file:///repo/README.md", text: "hello" }],
- });
-});
-```
-
-Add to `packages/core/test/remote-control-dispatch.test.ts`:
-
-```ts
-it("accepts new MCP engine commands through remote dispatch", async () => {
- const context = testContext({
- mcpServers: { docs: { name: "Docs", description: "Docs.", command: "node" } },
- });
- const response = await dispatchRemoteCliRequest(
- {
- command: "list_resources",
- arguments: { caplet: "docs", request: { operation: "list_resources" } },
- },
- context,
- );
- expect(response).toMatchObject({
- ok: false,
- error: { code: expect.stringMatching(/SERVER_|UNSUPPORTED_|DOWNSTREAM_/u) },
- });
-});
-```
-
-- [ ] **Step 2: Write failing local CLI command tests**
-
-Add to `packages/core/test/cli.test.ts`:
-
-```ts
-it("prints help with MCP resource and prompt commands", async () => {
- const out: string[] = [];
- await runCli(["--help"], {
- writeOut: (value) => out.push(value),
- writeErr: (value) => out.push(value),
- });
- const text = out.join("");
- expect(text).toContain("list-resources");
- expect(text).toContain("read-resource");
- expect(text).toContain("list-prompts");
- expect(text).toContain("get-prompt");
- expect(text).toContain("complete");
-});
-```
-
-- [ ] **Step 3: Run CLI tests to verify red**
-
-Run:
-
-```sh
-pnpm --filter @caplets/core test -- test/cli.test.ts test/cli-remote.test.ts test/remote-control-dispatch.test.ts
-```
-
-Expected: FAIL because commands and remote command names do not exist.
-
-- [ ] **Step 4: Extend remote-control command types and dispatch set**
-
-In `packages/core/src/remote-control/types.ts`, add these to `RemoteCliCommand` after `call_tool`:
-
-```ts
- | "list_resources"
- | "search_resources"
- | "list_resource_templates"
- | "read_resource"
- | "list_prompts"
- | "search_prompts"
- | "get_prompt"
- | "complete"
-```
-
-In `packages/core/src/remote-control/dispatch.ts`, add the same strings to `ENGINE_COMMANDS`.
-
-- [ ] **Step 5: Route new operations from CLI remote mode**
-
-In `packages/core/src/cli.ts`, extend `remoteCommandForOperation` switch:
-
-```ts
- case "list_resources":
- case "search_resources":
- case "list_resource_templates":
- case "read_resource":
- case "list_prompts":
- case "search_prompts":
- case "get_prompt":
- case "complete":
- return operation;
-```
-
-- [ ] **Step 6: Add CLI commands**
-
-In `packages/core/src/cli.ts`, add commands after `call-tool`:
-
-```ts
-program
- .command("list-resources")
- .description("List MCP resources for a configured MCP Caplet.")
- .argument("")
- .option("--limit ", "maximum number of resources to return", parsePositiveInteger)
- .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat)
- .action(async (caplet: string, options: { limit?: number; format?: CliOutputFormat }) => {
- await executeOperation(
- caplet,
- options.limit === undefined
- ? { operation: "list_resources" }
- : { operation: "list_resources", limit: options.limit },
- {
- writeOut,
- writeErr,
- setExitCode,
- authDir: io.authDir,
- env,
- remote: remoteClientForCli(io),
- format: options.format,
- },
- );
- });
-
-program
- .command("search-resources")
- .description("Search MCP resources and resource templates for a configured MCP Caplet.")
- .argument("")
- .argument("")
- .option("--limit ", "maximum number of matches to return", parsePositiveInteger)
- .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat)
- .action(
- async (
- caplet: string,
- query: string,
- options: { limit?: number; format?: CliOutputFormat },
- ) => {
- await executeOperation(
- caplet,
- options.limit === undefined
- ? { operation: "search_resources", query }
- : { operation: "search_resources", query, limit: options.limit },
- {
- writeOut,
- writeErr,
- setExitCode,
- authDir: io.authDir,
- env,
- remote: remoteClientForCli(io),
- format: options.format,
- },
- );
- },
- );
-
-program
- .command("list-resource-templates")
- .description("List MCP resource templates for a configured MCP Caplet.")
- .argument("")
- .option("--limit ", "maximum number of templates to return", parsePositiveInteger)
- .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat)
- .action(async (caplet: string, options: { limit?: number; format?: CliOutputFormat }) => {
- await executeOperation(
- caplet,
- options.limit === undefined
- ? { operation: "list_resource_templates" }
- : { operation: "list_resource_templates", limit: options.limit },
- {
- writeOut,
- writeErr,
- setExitCode,
- authDir: io.authDir,
- env,
- remote: remoteClientForCli(io),
- format: options.format,
- },
- );
- });
-
-program
- .command("read-resource")
- .description("Read one MCP resource by URI.")
- .argument("")
- .argument("")
- .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat)
- .action(async (caplet: string, uri: string, options: { format?: CliOutputFormat }) => {
- await executeOperation(
- caplet,
- { operation: "read_resource", uri },
- {
- writeOut,
- writeErr,
- setExitCode,
- authDir: io.authDir,
- env,
- remote: remoteClientForCli(io),
- format: options.format,
- },
- );
- });
-
-program
- .command("list-prompts")
- .description("List MCP prompts for a configured MCP Caplet.")
- .argument("")
- .option("--limit ", "maximum number of prompts to return", parsePositiveInteger)
- .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat)
- .action(async (caplet: string, options: { limit?: number; format?: CliOutputFormat }) => {
- await executeOperation(
- caplet,
- options.limit === undefined
- ? { operation: "list_prompts" }
- : { operation: "list_prompts", limit: options.limit },
- {
- writeOut,
- writeErr,
- setExitCode,
- authDir: io.authDir,
- env,
- remote: remoteClientForCli(io),
- format: options.format,
- },
- );
- });
-
-program
- .command("search-prompts")
- .description("Search MCP prompts for a configured MCP Caplet.")
- .argument("")
- .argument("")
- .option("--limit ", "maximum number of prompts to return", parsePositiveInteger)
- .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat)
- .action(
- async (
- caplet: string,
- query: string,
- options: { limit?: number; format?: CliOutputFormat },
- ) => {
- await executeOperation(
- caplet,
- options.limit === undefined
- ? { operation: "search_prompts", query }
- : { operation: "search_prompts", query, limit: options.limit },
- {
- writeOut,
- writeErr,
- setExitCode,
- authDir: io.authDir,
- env,
- remote: remoteClientForCli(io),
- format: options.format,
- },
- );
- },
- );
-
-program
- .command("get-prompt")
- .description("Get one MCP prompt by name.")
- .argument("", "qualified target, split on the first dot")
- .option("--args ", "JSON object of prompt arguments")
- .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat)
- .action(async (target: string, options: { args?: string; format?: CliOutputFormat }) => {
- const { caplet, tool: prompt } = parseQualifiedTarget(target);
- await executeOperation(
- caplet,
- {
- operation: "get_prompt",
- prompt,
- arguments: parseJsonObjectOption(options.args, "get-prompt --args"),
- },
- {
- writeOut,
- writeErr,
- setExitCode,
- authDir: io.authDir,
- env,
- remote: remoteClientForCli(io),
- format: options.format,
- },
- );
- });
-
-program
- .command("complete")
- .description("Complete an MCP prompt or resource-template argument.")
- .argument("")
- .requiredOption("--argument ", "argument name")
- .option("--value ", "argument prefix", "")
- .option("--prompt