From 3e823ac398e9598e0fcb9aacbf1c911ae3cddba9 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 10:39:05 -0400 Subject: [PATCH 01/14] docs(cli): plan shell completions --- .brv | 2 +- docs/plans/2026-05-21-cli-completions.md | 748 ++++++++++++++++++ .../2026-05-21-cli-completions-design.md | 166 ++++ 3 files changed, 915 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-21-cli-completions.md create mode 100644 docs/specs/2026-05-21-cli-completions-design.md diff --git a/.brv b/.brv index c9d9c141..7f565694 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit c9d9c141e0dd7af3571bfd99465eb8231acc5768 +Subproject commit 7f565694f11c8dee6dd75437979d64bc549fc97d diff --git a/docs/plans/2026-05-21-cli-completions.md b/docs/plans/2026-05-21-cli-completions.md new file mode 100644 index 00000000..1424b79f --- /dev/null +++ b/docs/plans/2026-05-21-cli-completions.md @@ -0,0 +1,748 @@ +# 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/specs/2026-05-21-cli-completions-design.md b/docs/specs/2026-05-21-cli-completions-design.md new file mode 100644 index 00000000..2618d981 --- /dev/null +++ b/docs/specs/2026-05-21-cli-completions-design.md @@ -0,0 +1,166 @@ +# CLI Completions for the Caplets npm Package + +## Status + +Planned design for implementation on `feat/cli-completions`. + +## Goal + +Add first-class shell completion support to the `caplets` npm CLI so users can install Bash, Zsh, or Fish completions and get useful static and config-aware suggestions without shell startup side effects. + +## Non-goals + +- Do not mutate `.bashrc`, `.zshrc`, Fish config, or system completion directories during `npm install`. +- Do not add a `postinstall` script. +- Do not run downstream MCP servers, OpenAPI clients, GraphQL introspection, HTTP requests, or user CLI tools during tab completion. +- Do not complete secret values, token values, OAuth callback URLs, or raw environment variable contents. +- Do not add raw remote shell command execution for remote completions. + +## User-facing commands + +Caplets should expose a public completion script generator: + +```sh +caplets completion bash +caplets completion zsh +caplets completion fish +``` + +The command writes the requested shell script to stdout. Users install it using shell-native mechanisms: + +```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 +``` + +Caplets should also expose a hidden machine-facing command used only by generated scripts: + +```sh +caplets __complete --shell bash -- get-caplet "" +``` + +`__complete` prints newline-separated candidates to stdout and remains quiet on recoverable errors. It should be hidden from normal help output. + +## Completion scope + +### Static completions + +Complete known command names, subcommands, options, and enum values: + +- Top-level commands: `serve`, `init`, `list`, `install`, `add`, direct operation commands, `config`, `auth`, and `completion`. +- `add` subcommands: `cli`, `mcp`, `openapi`, `graphql`, `http`. +- `config` subcommands: `path`, `paths`. +- `auth` subcommands: `login`, `logout`, `list`. +- Shells for `completion`: `bash`, `zsh`, `fish`. +- Format values: `markdown`, `md`, `plain`, `json`. +- Serve transports: `stdio`, `http`. +- Remote MCP transports for `add mcp --transport`: `http`, `sse`. +- CLI generator includes for `add cli --include`: `git`, `gh`, `package`. + +### Config-aware completions + +Complete enabled configured Caplet IDs from the active Caplets config for commands whose next positional argument is a Caplet ID: + +- `get-caplet` +- `check-backend` +- `list-tools` +- `search-tools` +- `list-resources` +- `search-resources` +- `list-resource-templates` +- `read-resource` +- `list-prompts` +- `search-prompts` +- `complete` + +Complete enabled configured Caplet IDs plus a trailing dot for commands whose next positional argument is a qualified target: + +- `get-tool` +- `call-tool` +- `get-prompt` + +Example: + +```sh +caplets call-tool git +# suggests: github. +``` + +The first implementation intentionally does not complete downstream tool, prompt, or resource names because doing so would require probing downstream systems during tab completion. A future opt-in enhancement can complete cached downstream names if Caplets persists a safe metadata cache. + +Complete configured Caplet/server IDs for auth commands: + +- `auth login` +- `auth logout` + +### Remote mode completions + +When CLI remote mode is active, completions must use the command-semantic remote control API, not raw CLI string execution. Add a remote control command such as `complete_cli` that accepts the completion words and returns newline-safe suggestions from the server-owned config. + +Remote completions must not expose secrets. They should return only public command names, option names, enum values, and configured Caplet IDs. + +## Architecture + +Add a focused completion module under `packages/core/src/cli/completion.ts`. + +Responsibilities: + +- Generate shell scripts for Bash, Zsh, and Fish. +- Resolve newline-separated completion suggestions for `__complete`. +- Load local config only when a command position needs config-aware suggestions. +- Accept a remote completion callback so `__complete` can delegate to `/control` in remote mode. +- Keep all completion errors quiet by returning no suggestions, except invalid explicit `caplets completion ` invocations, which should fail with `REQUEST_INVALID`. + +`packages/core/src/cli.ts` wires public and hidden commands into the existing Commander program. The generated shell scripts call `caplets __complete`, not Commander internals. + +Remote control adds one structured command, `complete_cli`, which reuses the same resolver on the server side with server-owned config paths. + +## Output contract + +`__complete` output is newline-separated plain text: + +```text +get-caplet +list-tools +call-tool +``` + +No descriptions are required in the first implementation. Shell-specific escaping belongs in the generated shell functions; suggestion values themselves should not contain newlines. + +## Error handling + +- `caplets completion ` fails with `REQUEST_INVALID` and a clear message listing `bash`, `zsh`, and `fish`. +- `caplets __complete ...` returns no suggestions for malformed words, unreadable config, remote failures, or unsupported command contexts. +- Completion generation must not set process exit code on best-effort dynamic lookup failures. + +## Documentation + +Update the root README and CLI package README with: + +- Supported shells. +- Install snippets for Bash, Zsh, and Fish. +- Note that npm install does not modify shell startup files. +- Note that completions are static plus config-aware and do not probe downstream services. + +## Release notes + +Add a changeset for the `caplets` package because this introduces a user-facing CLI command. If implementation touches exported core types, include `@caplets/core` in the same changeset. + +## Acceptance criteria + +- `caplets completion bash`, `zsh`, and `fish` emit shell-specific scripts that invoke `caplets __complete`. +- `caplets __complete --shell bash -- ""` suggests top-level commands. +- `caplets __complete --shell bash -- add ""` suggests add subcommands. +- `caplets __complete --shell bash -- get-caplet ""` suggests enabled Caplet IDs from local config. +- `caplets __complete --shell bash -- call-tool github` suggests `github.` when `github` is configured. +- Remote mode routes `__complete` to `/control` with a structured `complete_cli` command. +- `pnpm verify` passes. From 80478cb117904cf6c5ecb4b60ac55b4ae0c5e697 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:01:02 -0400 Subject: [PATCH 02/14] feat(cli): add completion resolver --- .brv | 2 +- packages/core/src/cli/completion.ts | 179 ++++++++++++++++++++++ packages/core/test/cli-completion.test.ts | 96 ++++++++++++ 3 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/cli/completion.ts create mode 100644 packages/core/test/cli-completion.test.ts diff --git a/.brv b/.brv index 7f565694..a8eb7aac 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 7f565694f11c8dee6dd75437979d64bc549fc97d +Subproject commit a8eb7aac3d73c3422a02a21b294245ceee10d11c diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts new file mode 100644 index 00000000..5c68f65f --- /dev/null +++ b/packages/core/src/cli/completion.ts @@ -0,0 +1,179 @@ +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)' +`; +} diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts new file mode 100644 index 00000000..9edef2d4 --- /dev/null +++ b/packages/core/test/cli-completion.test.ts @@ -0,0 +1,96 @@ +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: "Use GitHub project tools.", command: "node" }, + disabled: { + name: "Disabled", + description: "Disabled test Caplet entry.", + command: "node", + disabled: true, + }, + }, + cliTools: { + repo: { + name: "Repo", + description: "Run repository maintenance commands.", + actions: { + status: { + description: "Print repository status.", + command: process.execPath, + args: ["--version"], + }, + }, + }, + }, + }), + ); + + 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( + [], + ); + }); +}); From ed67c8eb14353475715e7922d8207848f26bdc74 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:04:31 -0400 Subject: [PATCH 03/14] feat(cli): expose shell completion commands --- packages/core/src/cli.ts | 34 ++++++++++++++++++++++++++ packages/core/test/cli.test.ts | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 2404f9c9..4581ddde 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -9,6 +9,12 @@ import { } from "./cli/add"; import { loginAuth, logoutAuth, listAuth, formatAuthRows, type AuthStatusRow } from "./cli/auth"; import { initConfig } from "./cli/init"; +import { + completeCliWords, + completionScript, + completionShells, + type CompletionShell, +} from "./cli/completion"; import { formatCapletList, formatConfigPaths, @@ -96,6 +102,34 @@ export function createProgram(io: CliIO = {}): Command { outputError: (value, write) => write(value), }); + 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", { hidden: true }) + .description("Internal shell completion endpoint.") + .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" as RemoteCliCommand, { shell, words })) as string[]) + : completeCliWords(words, { configPath: currentConfigPath() }); + if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`); + }); + program .command("serve") .description("Serve configured Caplets as an MCP server.") diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index 44fd65f0..f25a1340 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -1866,6 +1866,50 @@ describe("cli init", () => { }); }); +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 }); + } + }); +}); + function writeInspectionConfig(path: string): void { writeFileSync( path, From 9d775f72e9ed8c201aceb2e0bf7cbeddcd13acde Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:08:28 -0400 Subject: [PATCH 04/14] feat(cli): route completions through remote control --- packages/core/src/remote-control/dispatch.ts | 21 ++++++++++++++ packages/core/src/remote-control/types.ts | 1 + packages/core/test/cli-remote.test.ts | 25 +++++++++++++++++ .../core/test/remote-control-dispatch.test.ts | 28 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/packages/core/src/remote-control/dispatch.ts b/packages/core/src/remote-control/dispatch.ts index 7884010c..9eba21c2 100644 --- a/packages/core/src/remote-control/dispatch.ts +++ b/packages/core/src/remote-control/dispatch.ts @@ -7,6 +7,7 @@ import { addOpenApiCaplet, } from "./../cli/add"; import { assertLoginTarget, findAuthTarget, listAuthRows, logoutAuthResult } from "./../cli/auth"; +import { completeCliWords, completionShells, type CompletionShell } from "./../cli/completion"; import { initConfig } from "./../cli/init"; import { installCaplets } from "./../cli/install"; import { listCaplets } from "./../cli/inspection"; @@ -110,6 +111,15 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc }; } + 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, + }); + } + if (request.command === "auth_list") { return listAuthRows({ ...optionalProp("configPath", context.configPath), @@ -276,6 +286,17 @@ function requiredString(args: Record, key: string): string { return value; } +function optionalString(args: Record, key: string): string | undefined { + const value = args[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new CapletsError("REQUEST_INVALID", `${key} must be a string`); + } + return value; +} + function optionalObject(args: Record, key: string): Record { const value = args[key]; if (value === undefined) { diff --git a/packages/core/src/remote-control/types.ts b/packages/core/src/remote-control/types.ts index d398d5e9..180e3951 100644 --- a/packages/core/src/remote-control/types.ts +++ b/packages/core/src/remote-control/types.ts @@ -19,6 +19,7 @@ export type RemoteCliCommand = | "init" | "add" | "install" + | "complete_cli" | "auth_login_start" | "auth_login_complete" | "auth_logout" diff --git a/packages/core/test/cli-remote.test.ts b/packages/core/test/cli-remote.test.ts index 6c0779b7..5a1000bc 100644 --- a/packages/core/test/cli-remote.test.ts +++ b/packages/core/test/cli-remote.test.ts @@ -16,6 +16,31 @@ afterEach(() => { }); describe("remote CLI routing", () => { + 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"); + }); + it("routes list --json through remote control in remote mode", async () => { const requests: unknown[] = []; const out: string[] = []; diff --git a/packages/core/test/remote-control-dispatch.test.ts b/packages/core/test/remote-control-dispatch.test.ts index 06463ff2..a2d9b2ec 100644 --- a/packages/core/test/remote-control-dispatch.test.ts +++ b/packages/core/test/remote-control-dispatch.test.ts @@ -322,6 +322,34 @@ describe("dispatchRemoteCliRequest", () => { ).resolves.toMatchObject({ ok: true, result: { remote: true } }); }); + it("dispatches complete_cli using server-owned config", async () => { + const context = testContext(); + writeFileSync( + context.configPath, + JSON.stringify({ + mcpServers: { + github: { name: "GitHub", description: "GitHub project automation.", command: "node" }, + }, + httpApis: { + users: { + name: "Users", + description: "Manage users through the API.", + baseUrl: "https://api.example.com", + auth: { type: "none" }, + actions: { list: { method: "GET", path: "/users" } }, + }, + }, + }), + ); + + const response = await dispatchRemoteCliRequest( + { command: "complete_cli", arguments: { shell: "bash", words: ["get-caplet", ""] } }, + context, + ); + + expect(response).toEqual({ ok: true, result: ["github", "users"] }); + }); + it("lists and logs out server-side auth credentials", async () => { const fixture = remoteFixtureWithOAuth(); writeTokenBundle( From 86023c5696b6be9b92faae51646fe78d6570fa19 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:10:35 -0400 Subject: [PATCH 05/14] docs(cli): document shell completions --- .changeset/cli-completions.md | 6 ++++++ README.md | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 .changeset/cli-completions.md diff --git a/.changeset/cli-completions.md b/.changeset/cli-completions.md new file mode 100644 index 00000000..81e247a6 --- /dev/null +++ b/.changeset/cli-completions.md @@ -0,0 +1,6 @@ +--- +"caplets": minor +"@caplets/core": minor +--- + +Add Bash, Zsh, and Fish shell completion generation plus config-aware completion suggestions for the Caplets CLI. diff --git a/README.md b/README.md index b283d1c2..1cf24a2d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,26 @@ caplets complete docs --resource-template 'file:///repo/{path}' --argument path 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. +### 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. + ## Agent Plugins Use Caplets as a normal MCP server everywhere, or install a native agent integration when From fa673f0c654728a7da820edaaf8e51df335282da Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:13:56 -0400 Subject: [PATCH 06/14] fix(cli): satisfy completion type checks --- packages/core/src/cli.ts | 3 ++- packages/core/src/remote-control/dispatch.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 4581ddde..7d3ab731 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -124,9 +124,10 @@ export function createProgram(io: CliIO = {}): Command { ? (options.shell as CompletionShell) : "bash"; const remote = remoteClientForCli(io); + const configPath = currentConfigPath(); const suggestions = remote ? ((await remote.request("complete_cli" as RemoteCliCommand, { shell, words })) as string[]) - : completeCliWords(words, { configPath: currentConfigPath() }); + : completeCliWords(words, configPath ? { configPath } : {}); if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`); }); diff --git a/packages/core/src/remote-control/dispatch.ts b/packages/core/src/remote-control/dispatch.ts index 9eba21c2..1a192634 100644 --- a/packages/core/src/remote-control/dispatch.ts +++ b/packages/core/src/remote-control/dispatch.ts @@ -115,8 +115,8 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc 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, + ...(context.configPath ? { configPath: context.configPath } : {}), + ...(context.projectConfigPath ? { projectConfigPath: context.projectConfigPath } : {}), }); } From b9a963e6b3b5b52a29478054e7b4ea9f3568a441 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:22:59 -0400 Subject: [PATCH 07/14] feat(cli): add Windows shell completions --- .brv | 2 +- .changeset/cli-completions.md | 2 +- README.md | 9 +++++- packages/core/src/cli.ts | 7 +++-- packages/core/src/cli/completion.ts | 37 +++++++++++++++++++++-- packages/core/test/cli-completion.test.ts | 18 +++++++++-- 6 files changed, 64 insertions(+), 11 deletions(-) diff --git a/.brv b/.brv index a8eb7aac..8c77ddda 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit a8eb7aac3d73c3422a02a21b294245ceee10d11c +Subproject commit 8c77dddad46c146c1f811a121137db8fd022668c diff --git a/.changeset/cli-completions.md b/.changeset/cli-completions.md index 81e247a6..2c01bc3d 100644 --- a/.changeset/cli-completions.md +++ b/.changeset/cli-completions.md @@ -3,4 +3,4 @@ "@caplets/core": minor --- -Add Bash, Zsh, and Fish shell completion generation plus config-aware completion suggestions for the Caplets CLI. +Add Bash, Zsh, Fish, PowerShell, and cmd shell completion generation plus config-aware completion suggestions for the Caplets CLI. diff --git a/README.md b/README.md index 1cf24a2d..f5610ea8 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Direct CLI operation commands print Markdown summaries by default. Add `--format ### 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. +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 @@ -105,6 +105,13 @@ caplets completion zsh > ~/.zsh/completions/_caplets # Fish mkdir -p ~/.config/fish/completions caplets completion fish > ~/.config/fish/completions/caplets.fish + +# PowerShell +caplets completion powershell | Out-String | Invoke-Expression + +# cmd.exe +caplets completion cmd > %USERPROFILE%\caplets-completion.cmd +%USERPROFILE%\caplets-completion.cmd ``` 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. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 7d3ab731..e96f4fee 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -105,10 +105,13 @@ export function createProgram(io: CliIO = {}): Command { program .command("completion") .description("Print a shell completion script.") - .argument("", "completion shell: bash, zsh, or fish") + .argument("", "completion shell: bash, zsh, fish, powershell, or cmd") .action((shell: string) => { if (!completionShells.includes(shell as CompletionShell)) { - throw new CapletsError("REQUEST_INVALID", "completion shell must be bash, zsh, or fish"); + throw new CapletsError( + "REQUEST_INVALID", + "completion shell must be bash, zsh, fish, powershell, or cmd", + ); } writeOut(completionScript(shell as CompletionShell)); }); diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index 5c68f65f..6d95266a 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -2,7 +2,7 @@ import { loadConfigWithSources } from "../config"; import { CapletsError } from "../errors"; import { listCaplets } from "./inspection"; -export const completionShells = ["bash", "zsh", "fish"] as const; +export const completionShells = ["bash", "zsh", "fish", "powershell", "cmd"] as const; export type CompletionShell = (typeof completionShells)[number]; export type CompletionOptions = { @@ -38,7 +38,7 @@ const topLevelCommands = [ const subcommands: Record = { add: ["cli", "mcp", "openapi", "graphql", "http"], auth: ["login", "logout", "list"], - completion: ["bash", "zsh", "fish"], + completion: [...completionShells], config: ["path", "paths"], }; @@ -81,8 +81,15 @@ export function completionScript(shell: CompletionShell): string { return zshCompletionScript(); case "fish": return fishCompletionScript(); + case "powershell": + return powershellCompletionScript(); + case "cmd": + return cmdCompletionScript(); default: - throw new CapletsError("REQUEST_INVALID", "completion shell must be bash, zsh, or fish"); + throw new CapletsError( + "REQUEST_INVALID", + "completion shell must be bash, zsh, fish, powershell, or cmd", + ); } } @@ -177,3 +184,27 @@ end complete -c caplets -f -a '(__caplets_complete)' `; } + +function powershellCompletionScript(): string { + return `# caplets PowerShell completion +Register-ArgumentCompleter -Native -CommandName caplets -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + $tokens = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) + if ($tokens.Count -eq 0 -or $commandAst.Extent.Text.EndsWith(' ')) { $tokens += '' } + caplets __complete --shell powershell -- @tokens | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } +} +`; +} + +function cmdCompletionScript(): string { + return `@echo off +REM caplets cmd completion helper +REM cmd.exe has no native programmable completion API. This doskey macro prints suggestions for the current words. +doskey caplets-complete=caplets __complete --shell cmd -- $* +REM Usage: caplets-complete get-caplet +REM The regular caplets command remains available; use caplets-complete to inspect suggestions. +doskey caplets=caplets $* +`; +} diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 9edef2d4..662b6405 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -11,7 +11,7 @@ afterEach(() => { }); describe("CLI completion scripts", () => { - it("emits Bash, Zsh, and Fish scripts that call caplets __complete", () => { + it("emits Bash, Zsh, Fish, PowerShell, and cmd scripts that call caplets __complete", () => { expect(completionScript("bash")).toContain("caplets __complete --shell bash"); expect(completionScript("bash")).toContain( "complete -o default -F _caplets_completions caplets", @@ -22,10 +22,16 @@ describe("CLI completion scripts", () => { expect(completionScript("fish")).toContain("complete -c caplets"); expect(completionScript("fish")).toContain("caplets __complete --shell fish"); + + expect(completionScript("powershell")).toContain("Register-ArgumentCompleter"); + expect(completionScript("powershell")).toContain("caplets __complete --shell powershell"); + + expect(completionScript("cmd")).toContain("doskey caplets="); + expect(completionScript("cmd")).toContain("caplets __complete --shell cmd"); }); it("rejects unknown shells for explicit script generation", () => { - expect(() => completionScript("powershell" as never)).toThrow( + expect(() => completionScript("xonsh" as never)).toThrow( expect.objectContaining({ code: "REQUEST_INVALID" }), ); }); @@ -40,7 +46,13 @@ describe("CLI completion resolver", () => { 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(["completion", ""])).toEqual([ + "bash", + "zsh", + "fish", + "powershell", + "cmd", + ]); expect(completeCliWords(["serve", "--transport", ""])).toEqual(["stdio", "http"]); expect(completeCliWords(["call-tool", "github.search", "--format", ""])).toEqual([ "markdown", From 785129a5ff4d2bc36e4b782b1067e06f1262fcab Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:36:05 -0400 Subject: [PATCH 08/14] fix(cli): address completion review feedback --- .brv | 2 +- README.md | 3 +++ docs/plans/2026-05-21-cli-completions.md | 4 +--- packages/core/src/cli.ts | 14 +++++++++++--- packages/core/src/cli/completion.ts | 2 -- packages/core/test/cli-completion.test.ts | 3 ++- packages/core/test/cli-remote.test.ts | 19 +++++++++++++++++++ 7 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.brv b/.brv index 8c77ddda..1cfe8f65 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 8c77dddad46c146c1f811a121137db8fd022668c +Subproject commit 1cfe8f658ea5f8b73a544533222142f6e9fdb324 diff --git a/README.md b/README.md index f5610ea8..9b32c490 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,9 @@ caplets completion bash > ~/.local/share/bash-completion/completions/caplets # 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 # Fish mkdir -p ~/.config/fish/completions diff --git a/docs/plans/2026-05-21-cli-completions.md b/docs/plans/2026-05-21-cli-completions.md index 1424b79f..9a4313d6 100644 --- a/docs/plans/2026-05-21-cli-completions.md +++ b/docs/plans/2026-05-21-cli-completions.md @@ -653,8 +653,6 @@ 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. @@ -670,7 +668,7 @@ Create `.changeset/cli-completions.md`: --- Add Bash, Zsh, and Fish shell completion generation plus config-aware completion suggestions for the Caplets CLI. -```` +``` - [ ] **Step 4: Run docs-sensitive checks** diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index e96f4fee..0b2ed652 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -128,9 +128,17 @@ export function createProgram(io: CliIO = {}): Command { : "bash"; const remote = remoteClientForCli(io); const configPath = currentConfigPath(); - const suggestions = remote - ? ((await remote.request("complete_cli" as RemoteCliCommand, { shell, words })) as string[]) - : completeCliWords(words, configPath ? { configPath } : {}); + let suggestions: string[] = []; + try { + suggestions = remote + ? ((await remote.request("complete_cli" as RemoteCliCommand, { + shell, + words, + })) as string[]) + : completeCliWords(words, configPath ? { configPath } : {}); + } catch { + suggestions = []; + } if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`); }); diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index 6d95266a..6d9a1404 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -204,7 +204,5 @@ REM caplets cmd completion helper REM cmd.exe has no native programmable completion API. This doskey macro prints suggestions for the current words. doskey caplets-complete=caplets __complete --shell cmd -- $* REM Usage: caplets-complete get-caplet -REM The regular caplets command remains available; use caplets-complete to inspect suggestions. -doskey caplets=caplets $* `; } diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 662b6405..132adac4 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -26,8 +26,9 @@ describe("CLI completion scripts", () => { expect(completionScript("powershell")).toContain("Register-ArgumentCompleter"); expect(completionScript("powershell")).toContain("caplets __complete --shell powershell"); - expect(completionScript("cmd")).toContain("doskey caplets="); + expect(completionScript("cmd")).toContain("doskey caplets-complete="); expect(completionScript("cmd")).toContain("caplets __complete --shell cmd"); + expect(completionScript("cmd")).not.toContain("doskey caplets=caplets"); }); it("rejects unknown shells for explicit script generation", () => { diff --git a/packages/core/test/cli-remote.test.ts b/packages/core/test/cli-remote.test.ts index 5a1000bc..6edb85cc 100644 --- a/packages/core/test/cli-remote.test.ts +++ b/packages/core/test/cli-remote.test.ts @@ -41,6 +41,25 @@ describe("remote CLI routing", () => { expect(out.join("")).toBe("github\nlinear\n"); }); + it("keeps hidden remote completion quiet when remote control fails", async () => { + const out: string[] = []; + const err: string[] = []; + const fetch = vi.fn(async () => Response.json({ ok: false, error: "server unavailable" })); + + 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), + writeErr: (value) => err.push(value), + }); + + expect(out).toEqual([]); + expect(err).toEqual([]); + }); + it("routes list --json through remote control in remote mode", async () => { const requests: unknown[] = []; const out: string[] = []; From dace45d25bae1218d9bfc370fb1d981beb5ad714 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:40:17 -0400 Subject: [PATCH 09/14] docs: brv context update --- .brv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.brv b/.brv index 1cfe8f65..b4a5f55b 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 1cfe8f658ea5f8b73a544533222142f6e9fdb324 +Subproject commit b4a5f55b100f6010dcdddc4e036a8660049347bf From 5ba168da6078732eb4cf071bd5108132ae6eca62 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 11:56:03 -0400 Subject: [PATCH 10/14] fix(cli): keep completion commands in sync --- packages/core/src/cli/completion.ts | 10 +++++----- packages/core/test/cli-completion.test.ts | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index 6d9a1404..fbd12761 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -157,7 +157,7 @@ function bashCompletionScript(): string { return `# caplets bash completion _caplets_completions() { local IFS=$'\n' - COMPREPLY=( $(caplets __complete --shell bash -- "\${COMP_WORDS[@]:1}") ) + COMPREPLY=( $(caplets __complete --shell bash -- "\${COMP_WORDS[@]:1}" 2>/dev/null) ) } complete -o default -F _caplets_completions caplets `; @@ -167,7 +167,7 @@ function zshCompletionScript(): string { return `#compdef caplets _caplets() { local -a suggestions - suggestions=("\${(@f)$(caplets __complete --shell zsh -- "\${words[@]:1}")}") + suggestions=("\${(@f)$(caplets __complete --shell zsh -- "\${words[@]:1}" 2>/dev/null)}") compadd -- $suggestions } _caplets "$@" @@ -179,7 +179,7 @@ function fishCompletionScript(): string { function __caplets_complete set -l tokens (commandline -opc) set -l current (commandline -ct) - caplets __complete --shell fish -- $tokens[2..-1] $current + caplets __complete --shell fish -- $tokens[2..-1] $current 2>/dev/null end complete -c caplets -f -a '(__caplets_complete)' `; @@ -191,7 +191,7 @@ Register-ArgumentCompleter -Native -CommandName caplets -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $tokens = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) if ($tokens.Count -eq 0 -or $commandAst.Extent.Text.EndsWith(' ')) { $tokens += '' } - caplets __complete --shell powershell -- @tokens | ForEach-Object { + caplets __complete --shell powershell -- @tokens 2>$null | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } @@ -202,7 +202,7 @@ function cmdCompletionScript(): string { return `@echo off REM caplets cmd completion helper REM cmd.exe has no native programmable completion API. This doskey macro prints suggestions for the current words. -doskey caplets-complete=caplets __complete --shell cmd -- $* +doskey caplets-complete=caplets __complete --shell cmd -- $* 2^>nul REM Usage: caplets-complete get-caplet `; } diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 132adac4..749e5717 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -2,6 +2,7 @@ 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 { createProgram } from "../src/cli"; import { completeCliWords, completionScript } from "../src/cli/completion"; const dirs: string[] = []; @@ -13,21 +14,26 @@ afterEach(() => { describe("CLI completion scripts", () => { it("emits Bash, Zsh, Fish, PowerShell, and cmd scripts that call caplets __complete", () => { expect(completionScript("bash")).toContain("caplets __complete --shell bash"); + expect(completionScript("bash")).toContain("2>/dev/null"); 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("zsh")).toContain("2>/dev/null"); expect(completionScript("fish")).toContain("complete -c caplets"); expect(completionScript("fish")).toContain("caplets __complete --shell fish"); + expect(completionScript("fish")).toContain("2>/dev/null"); expect(completionScript("powershell")).toContain("Register-ArgumentCompleter"); expect(completionScript("powershell")).toContain("caplets __complete --shell powershell"); + expect(completionScript("powershell")).toContain("2>$null"); expect(completionScript("cmd")).toContain("doskey caplets-complete="); expect(completionScript("cmd")).toContain("caplets __complete --shell cmd"); + expect(completionScript("cmd")).toContain("2^>nul"); expect(completionScript("cmd")).not.toContain("doskey caplets=caplets"); }); @@ -45,6 +51,15 @@ describe("CLI completion resolver", () => { ); }); + 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); + }); + it("suggests nested static subcommands and enum values", () => { expect(completeCliWords(["add", ""])).toEqual(["cli", "mcp", "openapi", "graphql", "http"]); expect(completeCliWords(["completion", ""])).toEqual([ From ffa6dfdbdc85fcd4c15c57239d09a9053e628383 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 12:38:56 -0400 Subject: [PATCH 11/14] docs(cli): plan completion discovery refactor --- .brv | 2 +- ...026-05-21-completion-discovery-refactor.md | 1309 +++++++++++++++++ ...21-completion-discovery-refactor-design.md | 234 +++ 3 files changed, 1544 insertions(+), 1 deletion(-) create mode 100644 docs/plans/2026-05-21-completion-discovery-refactor.md create mode 100644 docs/specs/2026-05-21-completion-discovery-refactor-design.md diff --git a/.brv b/.brv index b4a5f55b..9b4ccaf4 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit b4a5f55b100f6010dcdddc4e036a8660049347bf +Subproject commit 9b4ccaf44351e17d9ad934a549adda014f4403ee diff --git a/docs/plans/2026-05-21-completion-discovery-refactor.md b/docs/plans/2026-05-21-completion-discovery-refactor.md new file mode 100644 index 00000000..1ddec9df --- /dev/null +++ b/docs/plans/2026-05-21-completion-discovery-refactor.md @@ -0,0 +1,1309 @@ +# 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/specs/2026-05-21-completion-discovery-refactor-design.md b/docs/specs/2026-05-21-completion-discovery-refactor-design.md new file mode 100644 index 00000000..83243898 --- /dev/null +++ b/docs/specs/2026-05-21-completion-discovery-refactor-design.md @@ -0,0 +1,234 @@ +# Completion Discovery Refactor Design + +## Status + +Planned design for follow-up implementation on `feat/cli-completions`. + +## Goal + +Make Caplets CLI completions architecturally correct and more useful by replacing duplicated command lists with shared command metadata, adding persistent discovery caching, and completing qualified downstream targets such as `caplets call-tool .`. + +## User intent + +The completion feature should not be a thin static helper that drifts from the real CLI. It should feel intelligent: + +- Top-level command completions must stay in sync with the Commander CLI. +- Qualified targets should complete backend IDs and known downstream names. +- Live downstream discovery is acceptable during completion when bounded by caching, timeouts, and quiet failure. +- Remote mode must continue using structured `/control` requests and server-owned state. + +## Non-goals + +- Do not execute arbitrary raw CLI strings over remote control. +- Do not add shell profile mutation, `postinstall`, or automatic shell startup edits. +- Do not cache secrets, auth headers, token values, env values, prompt arguments, resource contents, or downstream response payloads. +- Do not start browser/device login flows during completion. +- Do not print diagnostics during normal generated shell completion scripts. Generated scripts suppress stderr; explicit/debug invocations may expose hints later. + +## Shared CLI command metadata + +Add a shared metadata module for command names and static completion behavior. This module becomes the source of truth for completion-visible commands and subcommands. + +Expected metadata shape: + +```ts +export const cliCommandNames = [ + "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", +] as const; +``` + +`completion.ts` must consume this metadata instead of owning a hand-maintained `topLevelCommands` copy. The existing test that compares `createProgram().commands` to top-level completion output remains as a regression guard. + +## Completion configuration + +Add a top-level `completion` config section: + +```json +{ + "completion": { + "discoveryTimeoutMs": 750, + "overallTimeoutMs": 1500, + "cacheTtlMs": 300000, + "negativeCacheTtlMs": 30000 + } +} +``` + +Defaults: + +- `discoveryTimeoutMs`: `750` +- `overallTimeoutMs`: `1500` +- `cacheTtlMs`: `300000` (5 minutes) +- `negativeCacheTtlMs`: `30000` (30 seconds) + +Normal config precedence applies: user config plus project config merge through the existing config loader. Invalid values fail normal config validation. Completion runtime still returns no suggestions if config loading itself fails. + +## Platform-native cache location + +Store completion discovery cache under the platform cache directory, not config or state: + +- Linux/Unix: `${XDG_CACHE_HOME}/caplets/completions` when `XDG_CACHE_HOME` is absolute, otherwise `~/.cache/caplets/completions`. +- macOS: `~/Library/Caches/caplets/completions`. +- Windows: `%LOCALAPPDATA%\caplets\cache\completions` when `LOCALAPPDATA` is absolute, otherwise `%USERPROFILE%\AppData\Local\caplets\cache\completions`. + +This should be implemented alongside existing path helpers in `packages/core/src/config/paths.ts`. + +## Cache contents and safety boundary + +Cache only public, secret-free discovery metadata needed for completions: + +- backend ID +- backend type +- kind: `tools`, `prompts`, `resources`, `resourceTemplates` +- candidate value: tool name, prompt name, resource URI, or resource template URI template +- optional public title/name/description if already returned by discovery and needed later +- `fetchedAt`, `expiresAt`, and config fingerprint +- negative-cache entries for auth-required, timeout, unavailable, or unsupported capability failures + +Never cache: + +- input arguments +- prompt argument values +- auth headers, bearer tokens, OAuth tokens, client secrets +- environment variable values +- command outputs +- resource contents +- arbitrary downstream response payloads + +## Cache invalidation + +Key cache entries by a stable secret-free completion fingerprint derived from: + +- backend ID +- backend type +- discovery kind +- discovery-relevant backend config fields +- completion settings that affect discovery/cache behavior + +Use secret-redacted fields only. Examples: + +- MCP: transport, command, args, cwd, URL, startup timeout; do not include env values or auth secrets. +- OpenAPI: spec path/spec URL/base URL and configured auth type; do not include token/header values. +- GraphQL: endpoint URL, schema source, configured operation names; do not include auth values. +- HTTP: base URL and action names/paths/methods; do not include auth/header secrets. +- CLI: action names and command/args/cwd shape; do not include env values. +- Caplet sets: source path/repo/ref and nested config fingerprint when available. + +If the fingerprint changes, old entries are misses. Prune expired and mismatched entries opportunistically. + +## Discovery behavior + +Completion uses stale-while-refresh semantics without detached background workers: + +1. Fresh cache entry: return cached suggestions immediately. +2. Stale cache entry: attempt refresh within the overall budget; return fresh suggestions if refresh succeeds, otherwise return stale suggestions. +3. Missing cache entry: attempt live discovery within the budget; return discovered suggestions if it succeeds, otherwise return static/config fallback. +4. Negative-cache entry: do not retry until `negativeCacheTtlMs` expires; return static/config fallback or stale positive data if available. + +All discovery failures are quiet for shell-facing completion. Generated shell scripts redirect stderr to null. A future explicit debug command may surface auth hints. + +Completion may start normal MCP stdio processes because MCP discovery requires it, but it must not initiate interactive login, open a browser, prompt for credentials, or write auth state. Auth-required failures should become negative-cache entries and should not clear still-valid positive cache data. + +## Qualified target completion contract + +### Tools + +For `call-tool` and `get-tool`: + +- `caplets call-tool ` suggests enabled backend IDs with a trailing dot: `github.`, `repo.`. +- `caplets call-tool repo.` suggests `repo.status`, `repo.build`, etc. +- `caplets call-tool gh.se` filters by the full typed prefix. +- CLI and HTTP backends can provide config-defined action names without live probing. +- OpenAPI, GraphQL, MCP, and Caplet-set backends may use live discovery through the cache manager. +- Failures/timeouts degrade to `server.` suggestions or no extra target names. + +### Prompts + +For `get-prompt`: + +- `caplets get-prompt ` suggests MCP backend IDs with trailing dots when prompts are possible. +- `caplets get-prompt docs.` discovers/caches MCP prompt names and suggests `docs.promptName`. +- Non-MCP backends should not suggest prompt names. + +### Resources and resource templates + +For `read-resource`: + +- The first positional argument remains the Caplet/backend ID and should complete enabled MCP backend IDs. +- The second positional argument should complete known/discovered resource URIs for the selected MCP backend. + +For `complete`: + +- The first positional argument completes enabled MCP backend IDs. +- When completing `--prompt`, suggest known/discovered prompt names for the selected backend. +- When completing `--resource-template`, suggest known/discovered resource template URI templates for the selected backend. +- Existing enum/static option completions continue to work. + +## Remote mode + +Remote mode keeps all Caplets state on the server. The local CLI must continue to call `/control` with `command: "complete_cli"`; it must not discover downstream backends locally when remote mode is active. + +The remote Caplets server owns: + +- config +- auth store +- downstream managers +- persistent completion cache +- live discovery and negative-cache decisions + +Remote control responses return completion candidates only. They must not return secrets, cache internals, auth tokens, or raw downstream errors. + +## Error handling + +- `caplets completion ` remains a normal `REQUEST_INVALID` error. +- `caplets __complete ...` remains best-effort and returns no candidates on malformed input, config load failure, cache failure, remote failure, downstream timeout, or unsupported context. +- Auth-required discovery returns stale cached results if present; otherwise static/config fallback. +- Normal generated shell completion suppresses stderr. If explicit/debug completion output is added later, auth-required diagnostics may recommend `caplets auth login `. + +## Documentation and release notes + +Update README/package README to describe: + +- completion scripts remain explicit install only +- completions may use cached live discovery +- discovery is bounded by configurable timeouts/TTLs +- cache location follows platform conventions +- auth-required backends may need `caplets auth login ` for richer completions + +Update the existing completion changeset to mention command metadata, persistent cache, and downstream qualified target completions. + +## Acceptance criteria + +- Static top-level completion suggestions come from shared command metadata, not a private duplicate list in `completion.ts`. +- A test fails if registered Commander commands drift from completion-visible command metadata. +- `completion` config defaults parse and appear in generated schema. +- Cache path helpers return XDG/macOS/Windows-appropriate cache directories. +- Completion cache stores only secret-free candidate metadata and negative-cache state. +- `call-tool` / `get-tool` complete backend IDs and tool names using config plus cache-backed live discovery. +- `get-prompt` completes backend IDs and prompt names for MCP backends. +- `read-resource` completes backend IDs and resource URIs for MCP backends. +- `complete --prompt` and `complete --resource-template` complete prompt names and resource template URI templates. +- Remote completions route through `complete_cli` and use server-owned cache/discovery. +- Completion failures remain quiet under generated shell scripts. +- `pnpm verify` passes. From fcd91e731a8d2309a9565d6173d7d63c1ebbccf8 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 13:28:01 -0400 Subject: [PATCH 12/14] feat(cli): completion discovery refactor --- .changeset/cli-completions.md | 2 +- README.md | 10 +- packages/core/src/cli.ts | 53 +-- packages/core/src/cli/commands.ts | 78 ++++ packages/core/src/cli/completion-cache.ts | 76 ++++ packages/core/src/cli/completion-discovery.ts | 288 ++++++++++++++ packages/core/src/cli/completion.ts | 198 ++++++---- packages/core/src/config.ts | 27 ++ packages/core/src/config/paths.ts | 32 ++ packages/core/src/engine.ts | 54 +++ packages/core/src/remote-control/dispatch.ts | 12 +- .../core/test/cli-completion-cache.test.ts | 77 ++++ packages/core/test/cli-completion.test.ts | 372 +++++++++++++++--- packages/core/test/config.test.ts | 41 +- packages/core/test/openapi.test.ts | 11 +- .../core/test/remote-control-dispatch.test.ts | 15 + schemas/caplets-config.schema.json | 37 ++ 17 files changed, 1221 insertions(+), 162 deletions(-) create mode 100644 packages/core/src/cli/commands.ts create mode 100644 packages/core/src/cli/completion-cache.ts create mode 100644 packages/core/src/cli/completion-discovery.ts create mode 100644 packages/core/test/cli-completion-cache.test.ts diff --git a/.changeset/cli-completions.md b/.changeset/cli-completions.md index 2c01bc3d..e3cb90a9 100644 --- a/.changeset/cli-completions.md +++ b/.changeset/cli-completions.md @@ -3,4 +3,4 @@ "@caplets/core": minor --- -Add Bash, Zsh, Fish, PowerShell, and cmd shell completion generation plus config-aware completion suggestions for the Caplets CLI. +Add Bash, Zsh, Fish, PowerShell, and cmd shell completion generation plus config-aware and cache-backed downstream completion suggestions for the Caplets CLI. diff --git a/README.md b/README.md index 9b32c490..33b076df 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,9 @@ caplets completion cmd > %USERPROFILE%\caplets-completion.cmd %USERPROFILE%\caplets-completion.cmd ``` -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. +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. + +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 Plugins @@ -327,6 +329,12 @@ you want Caplets to expose: "version": 1, "defaultSearchLimit": 20, "maxSearchLimit": 50, + "completion": { + "discoveryTimeoutMs": 750, + "overallTimeoutMs": 1500, + "cacheTtlMs": 300000, + "negativeCacheTtlMs": 30000 + }, "mcpServers": { "filesystem": { "name": "Project Files", diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 0b2ed652..2ebaef2d 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -8,6 +8,7 @@ import { addOpenApiCaplet, } from "./cli/add"; import { loginAuth, logoutAuth, listAuth, formatAuthRows, type AuthStatusRow } from "./cli/auth"; +import { cliCommands } from "./cli/commands"; import { initConfig } from "./cli/init"; import { completeCliWords, @@ -103,7 +104,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("completion") + .command(cliCommands.completion) .description("Print a shell completion script.") .argument("", "completion shell: bash, zsh, fish, powershell, or cmd") .action((shell: string) => { @@ -117,7 +118,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("__complete", { hidden: true }) + .command(cliCommands.completeHidden, { hidden: true }) .description("Internal shell completion endpoint.") .option("--shell ", "completion shell") .allowUnknownOption(true) @@ -135,7 +136,7 @@ export function createProgram(io: CliIO = {}): Command { shell, words, })) as string[]) - : completeCliWords(words, configPath ? { configPath } : {}); + : await completeCliWords(words, configPath ? { configPath } : {}); } catch { suggestions = []; } @@ -143,7 +144,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("serve") + .command(cliCommands.serve) .description("Serve configured Caplets as an MCP server.") .option("--transport ", "server transport: stdio or http") .option("--host ", "HTTP bind host") @@ -185,7 +186,7 @@ export function createProgram(io: CliIO = {}): Command { ); program - .command("init") + .command(cliCommands.init) .description("Create a starter Caplets config file.") .option("--force", "overwrite an existing config file") .action(async (options: { force?: boolean }) => { @@ -206,7 +207,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("list") + .command(cliCommands.list) .description("List configured Caplets.") .option("--all", "include disabled Caplets") .option("--json", "print JSON output") @@ -235,7 +236,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("install") + .command(cliCommands.install) .description("Install Caplets from a repo's caplets directory.") .argument("", "local repo path, Git URL, or GitHub owner/repo") .argument("[caplets...]", "optional Caplet IDs to install") @@ -273,7 +274,7 @@ export function createProgram(io: CliIO = {}): Command { }, ); - const add = program.command("add").description("Add generated Caplet files."); + const add = program.command(cliCommands.add).description("Add generated Caplet files."); add .command("cli") @@ -478,7 +479,7 @@ export function createProgram(io: CliIO = {}): Command { ); program - .command("get-caplet") + .command(cliCommands.getCaplet) .description("Print a configured Caplet card.") .argument("", "configured Caplet ID") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) @@ -499,7 +500,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("check-backend") + .command(cliCommands.checkBackend) .description("Check backend availability for a configured Caplet.") .argument("", "configured Caplet ID") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) @@ -520,7 +521,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("list-tools") + .command(cliCommands.listTools) .description("List downstream tools for a configured Caplet.") .argument("", "configured Caplet ID") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) @@ -541,7 +542,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("search-tools") + .command(cliCommands.searchTools) .description("Search downstream tools for a configured Caplet.") .argument("", "configured Caplet ID") .argument("", "search query") @@ -572,7 +573,7 @@ export function createProgram(io: CliIO = {}): Command { ); program - .command("get-tool") + .command(cliCommands.getTool) .description("Print one downstream tool schema.") .argument("", "qualified target, split on the first dot") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) @@ -594,7 +595,7 @@ export function createProgram(io: CliIO = {}): Command { }); program - .command("call-tool") + .command(cliCommands.callTool) .description("Call one downstream tool.") .argument("", "qualified target, split on the first dot") .option("--args ", "JSON object of downstream tool arguments") @@ -625,7 +626,7 @@ export function createProgram(io: CliIO = {}): Command { ); program - .command("list-resources") + .command(cliCommands.listResources) .description("List MCP resources for a configured MCP Caplet.") .argument("") .option("--limit ", "maximum number of resources to return", parsePositiveInteger) @@ -648,7 +649,7 @@ export function createProgram(io: CliIO = {}): Command { ), ); program - .command("search-resources") + .command(cliCommands.searchResources) .description("Search MCP resources and resource templates for a configured MCP Caplet.") .argument("") .argument("") @@ -677,7 +678,7 @@ export function createProgram(io: CliIO = {}): Command { ), ); program - .command("list-resource-templates") + .command(cliCommands.listResourceTemplates) .description("List MCP resource templates for a configured MCP Caplet.") .argument("") .option("--limit ", "maximum number of templates to return", parsePositiveInteger) @@ -700,7 +701,7 @@ export function createProgram(io: CliIO = {}): Command { ), ); program - .command("read-resource") + .command(cliCommands.readResource) .description("Read one MCP resource by URI.") .argument("") .argument("") @@ -721,7 +722,7 @@ export function createProgram(io: CliIO = {}): Command { ), ); program - .command("list-prompts") + .command(cliCommands.listPrompts) .description("List MCP prompts for a configured MCP Caplet.") .argument("") .option("--limit ", "maximum number of prompts to return", parsePositiveInteger) @@ -744,7 +745,7 @@ export function createProgram(io: CliIO = {}): Command { ), ); program - .command("search-prompts") + .command(cliCommands.searchPrompts) .description("Search MCP prompts for a configured MCP Caplet.") .argument("") .argument("") @@ -773,7 +774,7 @@ export function createProgram(io: CliIO = {}): Command { ), ); program - .command("get-prompt") + .command(cliCommands.getPrompt) .description("Get one MCP prompt by name.") .argument("", "qualified target, split on the first dot") .option("--args ", "JSON object of prompt arguments") @@ -799,7 +800,7 @@ export function createProgram(io: CliIO = {}): Command { ); }); program - .command("complete") + .command(cliCommands.complete) .description("Complete an MCP prompt or resource-template argument.") .argument("") .requiredOption("--argument ", "argument name") @@ -837,7 +838,9 @@ export function createProgram(io: CliIO = {}): Command { ), ); - const config = program.command("config").description("Inspect Caplets config locations."); + const config = program + .command(cliCommands.config) + .description("Inspect Caplets config locations."); config .command("path") @@ -860,7 +863,9 @@ export function createProgram(io: CliIO = {}): Command { writeOut(formatConfigPaths(paths, options.format ?? "plain")); }); - const auth = program.command("auth").description("Manage OAuth credentials for remote servers."); + const auth = program + .command(cliCommands.auth) + .description("Manage OAuth credentials for remote servers."); auth .command("login") diff --git a/packages/core/src/cli/commands.ts b/packages/core/src/cli/commands.ts new file mode 100644 index 00000000..59e6eae4 --- /dev/null +++ b/packages/core/src/cli/commands.ts @@ -0,0 +1,78 @@ +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]); diff --git a/packages/core/src/cli/completion-cache.ts b/packages/core/src/cli/completion-cache.ts new file mode 100644 index 00000000..0225d52f --- /dev/null +++ b/packages/core/src/cli/completion-cache.ts @@ -0,0 +1,76 @@ +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"; + candidates?: CompletionCandidate[]; + }; + +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`); +} diff --git a/packages/core/src/cli/completion-discovery.ts b/packages/core/src/cli/completion-discovery.ts new file mode 100644 index 00000000..69777a3e --- /dev/null +++ b/packages/core/src/cli/completion-discovery.ts @@ -0,0 +1,288 @@ +import { + DEFAULT_COMPLETION_CACHE_DIR, + type CapletConfig, + type CapletsConfig, + type CompletionConfig, +} from "../config"; +import { CapletsError } from "../errors"; +import { + completionCacheKey, + readCompletionCacheEntry, + writeCompletionCacheEntry, + type CompletionCandidate, + type CompletionDiscoveryKind, +} 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; + completion?: CompletionConfig | undefined; + cacheDir?: string | undefined; + managers?: CompletionDiscoveryManagers | undefined; + now?: number | undefined; +}; + +export async function discoverCompletionCandidates( + serverId: string, + kind: CompletionDiscoveryKind, + options: CompletionDiscoveryOptions, +): Promise { + const server = enabledServer(serverId, options.config); + if (!server) return []; + const completion = options.completion ?? options.config.options.completion; + const now = options.now ?? Date.now(); + const configCandidates = configDefinedCandidates(serverId, kind, options.config); + const cacheDir = options.cacheDir ?? DEFAULT_COMPLETION_CACHE_DIR; + const key = completionCacheKey({ + server: server.server, + backend: server.backend, + kind, + fingerprint: completionFingerprint(server, kind, completion), + }); + const cached = readCompletionCacheEntry(cacheDir, key, now); + if (cached?.status === "positive" && cached.fresh) return cached.candidates; + if (cached?.status === "negative" && cached.fresh) return cached.candidates ?? configCandidates; + + try { + const live = await withTimeout( + liveCandidates(server, kind, options.managers), + Math.min(completion.discoveryTimeoutMs, completion.overallTimeoutMs), + ); + const candidates = dedupeCandidates([...configCandidates, ...live]); + writeCompletionCacheEntry(cacheDir, key, { + status: "positive", + fetchedAt: now, + expiresAt: now + completion.cacheTtlMs, + candidates, + }); + return candidates; + } catch (error) { + writeCompletionCacheEntry(cacheDir, key, { + status: "negative", + fetchedAt: now, + expiresAt: now + completion.negativeCacheTtlMs, + reason: negativeReason(error), + ...(cached?.status === "positive" ? { candidates: cached.candidates } : {}), + }); + if (cached?.status === "positive") return cached.candidates; + return configCandidates; + } +} + +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 []; +} + +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 (kind === "tools") return []; + 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, + })); + } + throw new CapletsError( + "UNSUPPORTED_CAPABILITY", + `Completion discovery is unsupported for ${kind}`, + ); +} + +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), + }); +} + +function secretFreeServerShape(server: CapletConfig): Record { + const base = { + server: server.server, + backend: server.backend, + name: server.name, + description: server.description, + tags: server.tags, + disabled: server.disabled, + }; + switch (server.backend) { + case "mcp": + return { + ...base, + transport: server.transport, + command: server.command, + args: server.args, + cwd: server.cwd, + url: server.url, + authType: server.auth?.type, + startupTimeoutMs: server.startupTimeoutMs, + callTimeoutMs: server.callTimeoutMs, + }; + case "openapi": + return { + ...base, + specPath: server.specPath, + specUrl: server.specUrl, + baseUrl: server.baseUrl, + authType: server.auth.type, + requestTimeoutMs: server.requestTimeoutMs, + }; + case "graphql": + return { + ...base, + endpointUrl: server.endpointUrl, + schemaPath: server.schemaPath, + schemaUrl: server.schemaUrl, + authType: server.auth.type, + operationNames: server.operations ? Object.keys(server.operations) : undefined, + }; + case "http": + return { + ...base, + baseUrl: server.baseUrl, + authType: server.auth.type, + actions: Object.fromEntries( + Object.entries(server.actions).map(([name, action]) => [ + name, + { method: action.method, path: action.path }, + ]), + ), + requestTimeoutMs: server.requestTimeoutMs, + }; + case "cli": + return { + ...base, + cwd: server.cwd, + actions: Object.fromEntries( + Object.entries(server.actions).map(([name, action]) => [ + name, + { command: action.command, args: action.args, cwd: action.cwd }, + ]), + ), + timeoutMs: server.timeoutMs, + maxOutputBytes: server.maxOutputBytes, + }; + case "caplets": + return { + ...base, + configPath: server.configPath, + capletsRoot: server.capletsRoot, + defaultSearchLimit: server.defaultSearchLimit, + maxSearchLimit: server.maxSearchLimit, + }; + } +} + +function negativeReason( + error: unknown, +): "auth_required" | "timeout" | "unavailable" | "unsupported" | "error" { + if (error instanceof CapletsError) { + if ( + error.code === "AUTH_REQUIRED" || + error.code === "AUTH_FAILED" || + error.code === "AUTH_REFRESH_FAILED" + ) { + return "auth_required"; + } + if (error.code === "SERVER_UNAVAILABLE" || error.code === "SERVER_START_TIMEOUT") { + return "unavailable"; + } + if (error.code === "UNSUPPORTED_CAPABILITY" || error.code === "UNSUPPORTED_OPERATION") { + return "unsupported"; + } + if (error.code === "TOOL_CALL_TIMEOUT" || error.code === "DOWNSTREAM_COMPLETION_ERROR") { + return "timeout"; + } + } + return error instanceof Error && error.message.includes("timeout") ? "timeout" : "error"; +} + +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); + } +} + +function enabledServer(serverId: string, config: CapletsConfig): CapletConfig | undefined { + const server = + config.mcpServers[serverId] ?? + config.openapiEndpoints[serverId] ?? + config.graphqlEndpoints[serverId] ?? + config.httpApis[serverId] ?? + config.cliTools[serverId] ?? + config.capletSets[serverId]; + return server && !server.disabled ? server : undefined; +} + +function dedupeCandidates(candidates: CompletionCandidate[]): CompletionCandidate[] { + const seen = new Set(); + return candidates.filter((candidate) => { + if (seen.has(candidate.value)) return false; + seen.add(candidate.value); + return true; + }); +} diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index fbd12761..1e57ac02 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -1,78 +1,43 @@ -import { loadConfigWithSources } from "../config"; +import { + loadConfigWithSources, + type CapletConfig, + type CapletsConfig, + type CompletionConfig, +} from "../config"; import { CapletsError } from "../errors"; import { listCaplets } from "./inspection"; - -export const completionShells = ["bash", "zsh", "fish", "powershell", "cmd"] as const; -export type CompletionShell = (typeof completionShells)[number]; +import { + capletIdCommands, + cliCommands, + cliSubcommands, + qualifiedPromptCommands, + qualifiedToolCommands, + topLevelCommandNames, + type CompletionShell, +} from "./commands"; +import { + discoverCompletionCandidates, + type CompletionDiscoveryManagers, +} from "./completion-discovery"; + +export { completionShells, type CompletionShell } from "./commands"; 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: [...completionShells], - config: ["path", "paths"], + config?: CapletsConfig; + completion?: CompletionConfig; + cacheDir?: string; + managers?: CompletionDiscoveryManagers; }; const optionValueSuggestions: Record> = { - "*": { - "--format": ["markdown", "md", "plain", "json"], - }, - serve: { - "--transport": ["stdio", "http"], - }, - "add:mcp": { - "--transport": ["http", "sse"], - }, - "add:cli": { - "--include": ["git", "gh", "package"], - }, + "*": { "--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": @@ -93,7 +58,10 @@ export function completionScript(shell: CompletionShell): string { } } -export function completeCliWords(words: string[], options: CompletionOptions = {}): string[] { +export async function completeCliWords( + words: string[], + options: CompletionOptions = {}, +): Promise { try { const normalized = words.length === 0 ? [""] : words; const current = normalized.at(-1) ?? ""; @@ -101,27 +69,81 @@ export function completeCliWords(words: string[], options: CompletionOptions = { const command = normalized[0] ?? ""; const subcommand = normalized[1] ?? ""; + if (command === cliCommands.complete && previous === "--prompt" && subcommand) { + return prefixFilter( + (await discoverCompletionCandidates(subcommand, "prompts", discoveryOptions(options))).map( + (candidate) => candidate.value.replace(`${subcommand}.`, ""), + ), + current, + ); + } + + if (command === cliCommands.complete && previous === "--resource-template" && subcommand) { + return prefixFilter( + ( + await discoverCompletionCandidates( + subcommand, + "resourceTemplates", + discoveryOptions(options), + ) + ).map((candidate) => candidate.value), + current, + ); + } + const optionValues = suggestionsForOptionValue(command, subcommand, previous); if (optionValues) return prefixFilter(optionValues, current); - if (normalized.length === 1) return prefixFilter(topLevelCommands, current); + if (normalized.length === 1) return prefixFilter([...topLevelCommandNames], current); - if (normalized.length === 2 && subcommands[command]) { - return prefixFilter(subcommands[command], current); + if (normalized.length === 2 && command in cliSubcommands) { + return prefixFilter(cliSubcommands[command as keyof typeof cliSubcommands], current); } if (normalized.length === 2 && capletIdCommands.has(command)) { - return prefixFilter(configuredCapletIds(options), current); + const ids = promptResourceCommands.has(command) + ? configuredCapletIds(options, { backend: "mcp" }) + : configuredCapletIds(options); + return prefixFilter(ids, current); } - if (normalized.length === 2 && qualifiedTargetCommands.has(command)) { + if ( + normalized.length === 2 && + (qualifiedToolCommands.has(command) || qualifiedPromptCommands.has(command)) + ) { + if (current.includes(".")) { + const serverId = current.slice(0, current.indexOf(".")); + const kind = qualifiedToolCommands.has(command) ? "tools" : "prompts"; + return prefixFilter( + (await discoverCompletionCandidates(serverId, kind, discoveryOptions(options))).map( + (candidate) => candidate.value, + ), + current, + ); + } return prefixFilter( - configuredCapletIds(options).map((id) => `${id}.`), + configuredCapletIds( + options, + qualifiedPromptCommands.has(command) ? { backend: "mcp" } : undefined, + ).map((id) => `${id}.`), current, ); } - if (command === "auth" && ["login", "logout"].includes(subcommand) && normalized.length === 3) { + if (command === cliCommands.readResource && normalized.length === 3) { + return prefixFilter( + ( + await discoverCompletionCandidates(subcommand, "resources", discoveryOptions(options)) + ).map((candidate) => candidate.value), + current, + ); + } + + if ( + command === cliCommands.auth && + ["login", "logout"].includes(subcommand) && + normalized.length === 3 + ) { return prefixFilter(configuredCapletIds(options), current); } @@ -144,12 +166,36 @@ function suggestionsForOptionValue( ); } -function configuredCapletIds(options: CompletionOptions): string[] { - const loaded = loadConfigWithSources(options.configPath, options.projectConfigPath); - return listCaplets(loaded, { includeDisabled: false }).map((row) => row.server); +const promptResourceCommands = new Set([ + cliCommands.getPrompt, + cliCommands.readResource, + cliCommands.complete, +]); + +function configuredCapletIds( + options: CompletionOptions, + filter: { backend?: CapletConfig["backend"] } = {}, +): string[] { + const loaded = options.config + ? { config: options.config, sources: {}, shadows: {} } + : loadConfigWithSources(options.configPath, options.projectConfigPath); + return listCaplets(loaded, { includeDisabled: false }) + .filter((row) => !filter.backend || row.backend === filter.backend) + .map((row) => row.server); +} + +function discoveryOptions(options: CompletionOptions) { + const config = + options.config ?? loadConfigWithSources(options.configPath, options.projectConfigPath).config; + return { + config, + completion: options.completion, + cacheDir: options.cacheDir, + managers: options.managers, + }; } -function prefixFilter(values: string[], prefix: string): string[] { +function prefixFilter(values: readonly string[], prefix: string): string[] { return values.filter((value) => value.startsWith(prefix)); } diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index cd697c5c..6245f526 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -18,8 +18,11 @@ import { nestedSchema, schemaPath } from "./schema-utils"; export { DEFAULT_AUTH_DIR, + DEFAULT_COMPLETION_CACHE_DIR, DEFAULT_CONFIG_PATH, PROJECT_CONFIG_FILE, + defaultCacheBaseDir, + defaultCompletionCacheDir, resolveCapletsRoot, resolveConfigPath, resolveProjectCapletsRoot, @@ -219,6 +222,14 @@ export type CapletConfig = export type CapletsOptions = { defaultSearchLimit: number; maxSearchLimit: number; + completion: CompletionConfig; +}; + +export type CompletionConfig = { + discoveryTimeoutMs: number; + overallTimeoutMs: number; + cacheTtlMs: number; + negativeCacheTtlMs: number; }; export type CapletsConfig = { @@ -802,6 +813,21 @@ function configSchemaFor( .max(50) .default(50) .describe("Maximum accepted search_tools limit."), + completion: 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({ + discoveryTimeoutMs: 750, + overallTimeoutMs: 1500, + cacheTtlMs: 300_000, + negativeCacheTtlMs: 30_000, + }) + .describe("Shell completion discovery timeout and cache settings."), mcpServers: z .record(z.string().regex(SERVER_ID_PATTERN), serverValueSchema) .default({}) @@ -1607,6 +1633,7 @@ export function parseConfig(input: unknown): CapletsConfig { options: { defaultSearchLimit: parsed.data.defaultSearchLimit, maxSearchLimit: parsed.data.maxSearchLimit, + completion: parsed.data.completion, }, mcpServers: servers, openapiEndpoints, diff --git a/packages/core/src/config/paths.ts b/packages/core/src/config/paths.ts index 5a5689e6..3c33c49b 100644 --- a/packages/core/src/config/paths.ts +++ b/packages/core/src/config/paths.ts @@ -36,6 +36,26 @@ export function defaultStateBaseDir( : posix.join(home, ".local", "state"); } +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 defaultConfigPath( env: PathEnv = process.env, home = homedir(), @@ -54,8 +74,20 @@ export function defaultAuthDir( return pathJoin(defaultStateBaseDir(env, home, platform), "caplets", "auth"); } +export function defaultCompletionCacheDir( + env: PathEnv = process.env, + home = homedir(), + platform: Platform = process.platform, +): string { + const pathJoin = platform === "win32" ? win32.join : posix.join; + return platform === "win32" + ? pathJoin(defaultCacheBaseDir(env, home, platform), "caplets", "cache", "completions") + : pathJoin(defaultCacheBaseDir(env, home, platform), "caplets", "completions"); +} + export const DEFAULT_CONFIG_PATH = defaultConfigPath(); export const DEFAULT_AUTH_DIR = defaultAuthDir(); +export const DEFAULT_COMPLETION_CACHE_DIR = defaultCompletionCacheDir(); export const PROJECT_CONFIG_FILE = join(".caplets", "config.json"); export function resolveConfigPath(path?: string): string { diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts index 8461c6f9..cea6231f 100644 --- a/packages/core/src/engine.ts +++ b/packages/core/src/engine.ts @@ -18,6 +18,8 @@ import { OpenApiManager } from "./openapi"; import { ServerRegistry } from "./registry"; import { handleServerTool } from "./tools"; +type ToolSummary = { name: string; description?: string }; + export type CapletsEngineOptions = { configPath?: string; projectConfigPath?: string; @@ -149,6 +151,39 @@ export class CapletsEngine { } } + async completeCliWords(words: string[]): Promise { + const { completeCliWords } = await import("./cli/completion"); + return await completeCliWords(words, { + config: this.registry.config, + managers: { + listTools: async (server) => this.listCompletionTools(server), + listPrompts: async (server) => { + if (server.backend !== "mcp") return []; + return (await this.downstream.listPrompts(server)).map((prompt) => ({ + name: prompt.name, + ...(prompt.description ? { description: prompt.description } : {}), + })); + }, + listResources: async (server) => { + if (server.backend !== "mcp") return []; + return (await this.downstream.listResources(server)).map((resource) => ({ + uri: resource.uri, + ...(resource.name ? { name: resource.name } : {}), + ...(resource.description ? { description: resource.description } : {}), + })); + }, + listResourceTemplates: async (server) => { + if (server.backend !== "mcp") return []; + return (await this.downstream.listResourceTemplates(server)).map((template) => ({ + uriTemplate: template.uriTemplate, + ...(template.name ? { name: template.name } : {}), + ...(template.description ? { description: template.description } : {}), + })); + }, + }, + }); + } + async close(): Promise { this.closed = true; try { @@ -171,6 +206,25 @@ export class CapletsEngine { } } + private async listCompletionTools(server: CapletConfig): Promise { + const tools = + server.backend === "mcp" + ? await this.downstream.listTools(server) + : server.backend === "openapi" + ? await this.openapi.listTools(server) + : server.backend === "graphql" + ? await this.graphql.listTools(server) + : server.backend === "http" + ? await this.http.listTools(server) + : server.backend === "cli" + ? await this.cli.listTools(server) + : await this.capletSets.listTools(server); + return tools.map((tool) => ({ + name: tool.name, + ...(tool.description ? { description: tool.description } : {}), + })); + } + private async reloadOnce(): Promise { if (this.closed) { return false; diff --git a/packages/core/src/remote-control/dispatch.ts b/packages/core/src/remote-control/dispatch.ts index 1a192634..3539b016 100644 --- a/packages/core/src/remote-control/dispatch.ts +++ b/packages/core/src/remote-control/dispatch.ts @@ -7,7 +7,7 @@ import { addOpenApiCaplet, } from "./../cli/add"; import { assertLoginTarget, findAuthTarget, listAuthRows, logoutAuthResult } from "./../cli/auth"; -import { completeCliWords, completionShells, type CompletionShell } from "./../cli/completion"; +import { completionShells, type CompletionShell } from "./../cli/completion"; import { initConfig } from "./../cli/init"; import { installCaplets } from "./../cli/install"; import { listCaplets } from "./../cli/inspection"; @@ -114,10 +114,12 @@ async function dispatch(request: RemoteCliRequest, context: RemoteControlDispatc 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") ?? [""], { - ...(context.configPath ? { configPath: context.configPath } : {}), - ...(context.projectConfigPath ? { projectConfigPath: context.projectConfigPath } : {}), - }); + const engine = new CapletsEngine(context); + try { + return await engine.completeCliWords(optionalStringArray(request.arguments, "words") ?? [""]); + } finally { + await engine.close(); + } } if (request.command === "auth_list") { diff --git a/packages/core/test/cli-completion-cache.test.ts b/packages/core/test/cli-completion-cache.test.ts new file mode 100644 index 00000000..77094383 --- /dev/null +++ b/packages/core/test/cli-completion-cache.test.ts @@ -0,0 +1,77 @@ +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" }), + ); + }); +}); diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 749e5717..36862fb0 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -1,7 +1,7 @@ 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 { afterEach, describe, expect, it, vi } from "vitest"; import { createProgram } from "../src/cli"; import { completeCliWords, completionScript } from "../src/cli/completion"; @@ -18,19 +18,15 @@ describe("CLI completion scripts", () => { 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("zsh")).toContain("2>/dev/null"); - expect(completionScript("fish")).toContain("complete -c caplets"); expect(completionScript("fish")).toContain("caplets __complete --shell fish"); expect(completionScript("fish")).toContain("2>/dev/null"); - expect(completionScript("powershell")).toContain("Register-ArgumentCompleter"); expect(completionScript("powershell")).toContain("caplets __complete --shell powershell"); expect(completionScript("powershell")).toContain("2>$null"); - expect(completionScript("cmd")).toContain("doskey caplets-complete="); expect(completionScript("cmd")).toContain("caplets __complete --shell cmd"); expect(completionScript("cmd")).toContain("2^>nul"); @@ -45,80 +41,352 @@ describe("CLI completion scripts", () => { }); describe("CLI completion resolver", () => { - it("suggests top-level commands", () => { - expect(completeCliWords([""])).toEqual( + it("suggests top-level commands", async () => { + await expect(completeCliWords([""])).resolves.toEqual( expect.arrayContaining(["add", "auth", "call-tool", "completion", "serve"]), ); }); - it("keeps top-level command suggestions in sync with registered CLI commands", () => { + it("keeps top-level command suggestions in sync with registered CLI commands", async () => { const registeredCommands = createProgram() .commands.filter((command) => command.name() !== "__complete") .map((command) => command.name()) .sort(); - - expect(completeCliWords([""]).toSorted()).toEqual(registeredCommands); + expect((await completeCliWords([""])).toSorted()).toEqual(registeredCommands); }); - it("suggests nested static subcommands and enum values", () => { - expect(completeCliWords(["add", ""])).toEqual(["cli", "mcp", "openapi", "graphql", "http"]); - expect(completeCliWords(["completion", ""])).toEqual([ + it("suggests nested static subcommands and enum values", async () => { + await expect(completeCliWords(["add", ""])).resolves.toEqual([ + "cli", + "mcp", + "openapi", + "graphql", + "http", + ]); + await expect(completeCliWords(["completion", ""])).resolves.toEqual([ "bash", "zsh", "fish", "powershell", "cmd", ]); - expect(completeCliWords(["serve", "--transport", ""])).toEqual(["stdio", "http"]); - expect(completeCliWords(["call-tool", "github.search", "--format", ""])).toEqual([ - "markdown", - "md", - "plain", - "json", + await expect(completeCliWords(["serve", "--transport", ""])).resolves.toEqual([ + "stdio", + "http", ]); + await expect(completeCliWords(["call-tool", "github.search", "--format", ""])).resolves.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: "Use GitHub project tools.", command: "node" }, - disabled: { - name: "Disabled", - description: "Disabled test Caplet entry.", - command: "node", - disabled: true, + it("suggests enabled Caplet IDs from local config", async () => { + const { dir, configPath } = writeCompletionConfig({ + mcpServers: { + github: { name: "GitHub", description: "Use GitHub project tools.", command: "node" }, + disabled: { + name: "Disabled", + description: "Disabled test Caplet entry.", + command: "node", + disabled: true, + }, + }, + cliTools: { + repo: { + name: "Repo", + description: "Run repository maintenance commands.", + actions: { + status: { + description: "Print repository status.", + command: process.execPath, + args: ["--version"], + }, }, }, - cliTools: { - repo: { - name: "Repo", - description: "Run repository maintenance commands.", - actions: { - status: { - description: "Print repository status.", - command: process.execPath, - args: ["--version"], - }, + }, + }); + dirs.push(dir); + + await expect(completeCliWords(["get-caplet", ""], { configPath })).resolves.toEqual([ + "github", + "repo", + ]); + await expect(completeCliWords(["call-tool", "git"], { configPath })).resolves.toEqual([ + "github.", + ]); + await expect(completeCliWords(["auth", "login", ""], { configPath })).resolves.toEqual([ + "github", + "repo", + ]); + }); + + it("limits prompt and resource contexts to MCP Caplet IDs", async () => { + const { dir, configPath } = writeCompletionConfig({ + mcpServers: { + docs: { + name: "Docs", + description: "Documentation MCP completion server.", + command: "node", + }, + }, + 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" } }, + }, + }, + cliTools: { + repo: { + name: "Repo", + description: "Run repository maintenance commands.", + actions: { status: { command: process.execPath, args: ["--version"] } }, + }, + }, + }); + dirs.push(dir); + + await expect(completeCliWords(["get-prompt", ""], { configPath })).resolves.toEqual(["docs."]); + await expect(completeCliWords(["read-resource", ""], { configPath })).resolves.toEqual([ + "docs", + ]); + await expect(completeCliWords(["complete", ""], { configPath })).resolves.toEqual(["docs"]); + }); + + 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", + ]); + }); + + 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" }] }, + completion: { + discoveryTimeoutMs: 10, + overallTimeoutMs: 20, + cacheTtlMs: 0, + negativeCacheTtlMs: 30_000, + }, + }); + + 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"]); + }); + + it("does not call a failing manager again while negative cache is fresh", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-")); + dirs.push(dir); + const { configPath } = writeMcpConfig(dir, "github"); + const listTools = vi.fn(async () => { + throw new Error("unavailable"); + }); - expect(completeCliWords(["get-caplet", ""], { configPath })).toEqual(["github", "repo"]); - expect(completeCliWords(["call-tool", "git"], { configPath })).toEqual(["github."]); - expect(completeCliWords(["auth", "login", ""], { configPath })).toEqual(["github", "repo"]); + await expect( + completeCliWords(["call-tool", "github."], { + configPath, + cacheDir: dir, + managers: { listTools }, + }), + ).resolves.toEqual([]); + await expect( + completeCliWords(["call-tool", "github."], { + configPath, + cacheDir: dir, + managers: { listTools }, + }), + ).resolves.toEqual([]); + expect(listTools).toHaveBeenCalledTimes(1); }); - it("returns no suggestions instead of throwing when config loading fails", () => { - expect(completeCliWords(["get-caplet", ""], { configPath: "/missing/config.json" })).toEqual( - [], - ); + it("uses negative cache TTL for unsupported live discovery", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-")); + dirs.push(dir); + const { configPath } = writeMcpConfig(dir, "github"); + const listPrompts = vi.fn(async () => [{ name: "summarize" }]); + + await expect( + completeCliWords(["get-prompt", "github."], { + configPath, + cacheDir: dir, + completion: { + discoveryTimeoutMs: 750, + overallTimeoutMs: 1500, + cacheTtlMs: 300_000, + negativeCacheTtlMs: 30_000, + }, + }), + ).resolves.toEqual([]); + + await expect( + completeCliWords(["get-prompt", "github."], { + configPath, + cacheDir: dir, + managers: { listPrompts }, + completion: { + discoveryTimeoutMs: 750, + overallTimeoutMs: 1500, + cacheTtlMs: 300_000, + negativeCacheTtlMs: 30_000, + }, + }), + ).resolves.toEqual([]); + expect(listPrompts).not.toHaveBeenCalled(); + }); + + it("invalidates cached completions when HTTP action discovery shape changes", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-")); + dirs.push(dir); + const firstConfig = writeCompletionConfigInDir(dir, { + 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" } }, + }, + }, + }); + await expect( + completeCliWords(["call-tool", "status_api."], { configPath: firstConfig, cacheDir: dir }), + ).resolves.toEqual(["status_api.check"]); + + const secondConfig = writeCompletionConfigInDir(dir, { + httpApis: { + status_api: { + name: "Status API", + description: "Check service status through HTTP actions.", + baseUrl: "https://api.example.com", + auth: { type: "none" }, + actions: { check_status: { method: "POST", path: "/status/check" } }, + }, + }, + }); + await expect( + completeCliWords(["call-tool", "status_api."], { configPath: secondConfig, cacheDir: dir }), + ).resolves.toEqual(["status_api.check_status"]); + }); + + 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}"]); + }); + + it("returns no suggestions instead of throwing when config loading fails", async () => { + await expect( + completeCliWords(["get-caplet", ""], { configPath: "/missing/config.json" }), + ).resolves.toEqual([]); }); }); + +function writeCompletionConfig(config: Record) { + const dir = mkdtempSync(join(tmpdir(), "caplets-completion-")); + const configPath = writeCompletionConfigInDir(dir, config); + return { dir, configPath }; +} + +function writeCompletionConfigInDir(dir: string, config: Record) { + const configPath = join(dir, "config.json"); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(config)); + return configPath; +} + +function writeMcpConfig(dir: string, server: string) { + const configPath = join(dir, "config.json"); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + [server]: { + name: server, + description: `${server} MCP server for completion tests.`, + command: "node", + }, + }, + }), + ); + return { dir, configPath }; +} + +function writeMcpConfigWithDir(server: string) { + const dir = mkdtempSync(join(tmpdir(), "caplets-completion-")); + return writeMcpConfig(dir, server); +} diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index 8a40b04f..581f15a0 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -11,7 +11,13 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { capletJsonSchema, loadCapletFiles } from "../src/caplet-files"; -import { configJsonSchema, loadConfig, loadConfigWithSources, parseConfig } from "../src/config"; +import { + configJsonSchema, + defaultCompletionCacheDir, + loadConfig, + loadConfigWithSources, + parseConfig, +} from "../src/config"; import { CapletsError } from "../src/errors"; describe("config", () => { @@ -263,7 +269,16 @@ describe("config", () => { ); const config = loadConfig(userConfigPath, projectConfigPath); - expect(config.options).toEqual({ defaultSearchLimit: 7, maxSearchLimit: 40 }); + expect(config.options).toEqual({ + defaultSearchLimit: 7, + maxSearchLimit: 40, + completion: { + discoveryTimeoutMs: 750, + overallTimeoutMs: 1500, + cacheTtlMs: 300_000, + negativeCacheTtlMs: 30_000, + }, + }); expect(Object.keys(config.mcpServers).sort()).toEqual(["projectOnly", "shared", "userOnly"]); expect(config.mcpServers.shared?.command).toBe("project-shared"); rmSync(dir, { recursive: true, force: true }); @@ -1170,9 +1185,31 @@ describe("config", () => { expect(config.options).toEqual({ defaultSearchLimit: 5, maxSearchLimit: 10, + completion: { + discoveryTimeoutMs: 750, + overallTimeoutMs: 1500, + cacheTtlMs: 300_000, + negativeCacheTtlMs: 30_000, + }, }); }); + it("uses platform-native completion cache paths", () => { + expect( + defaultCompletionCacheDir({ XDG_CACHE_HOME: "/tmp/cache" }, "/home/alice", "linux"), + ).toBe("/tmp/cache/caplets/completions"); + expect(defaultCompletionCacheDir({}, "/Users/alice", "darwin")).toBe( + "/Users/alice/Library/Caches/caplets/completions", + ); + expect( + defaultCompletionCacheDir( + { LOCALAPPDATA: "C:\\Users\\Alice\\AppData\\Local" }, + "C:\\Users\\Alice", + "win32", + ), + ).toBe("C:\\Users\\Alice\\AppData\\Local\\caplets\\cache\\completions"); + }); + it("loads OpenAPI endpoints with defaults and explicit auth", () => { process.env.OPENAPI_PUBLIC_SECRET = "must-not-leak"; const config = parseConfig({ diff --git a/packages/core/test/openapi.test.ts b/packages/core/test/openapi.test.ts index 03a7a725..08677a12 100644 --- a/packages/core/test/openapi.test.ts +++ b/packages/core/test/openapi.test.ts @@ -642,7 +642,16 @@ describe("native OpenAPI Caplets", () => { }; const registry = new ServerRegistry({ version: 1, - options: { defaultSearchLimit: 20, maxSearchLimit: 50 }, + options: { + defaultSearchLimit: 20, + maxSearchLimit: 50, + completion: { + discoveryTimeoutMs: 750, + overallTimeoutMs: 1500, + cacheTtlMs: 300_000, + negativeCacheTtlMs: 30_000, + }, + }, mcpServers: {}, openapiEndpoints: { remote: endpoint }, graphqlEndpoints: {}, diff --git a/packages/core/test/remote-control-dispatch.test.ts b/packages/core/test/remote-control-dispatch.test.ts index a2d9b2ec..a8c99deb 100644 --- a/packages/core/test/remote-control-dispatch.test.ts +++ b/packages/core/test/remote-control-dispatch.test.ts @@ -350,6 +350,21 @@ describe("dispatchRemoteCliRequest", () => { expect(response).toEqual({ ok: true, result: ["github", "users"] }); }); + 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"]); + }); + it("lists and logs out server-side auth credentials", async () => { const fixture = remoteFixtureWithOAuth(); writeTokenBundle( diff --git a/schemas/caplets-config.schema.json b/schemas/caplets-config.schema.json index 650cc3cd..645b2529 100644 --- a/schemas/caplets-config.schema.json +++ b/schemas/caplets-config.schema.json @@ -30,6 +30,43 @@ "exclusiveMinimum": 0, "maximum": 50 }, + "completion": { + "default": { + "discoveryTimeoutMs": 750, + "overallTimeoutMs": 1500, + "cacheTtlMs": 300000, + "negativeCacheTtlMs": 30000 + }, + "description": "Shell completion discovery timeout and cache settings.", + "type": "object", + "properties": { + "discoveryTimeoutMs": { + "default": 750, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "overallTimeoutMs": { + "default": 1500, + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "cacheTtlMs": { + "default": 300000, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "negativeCacheTtlMs": { + "default": 30000, + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, "mcpServers": { "default": {}, "description": "Downstream MCP servers keyed by stable server ID.", From fc26c4083b3e08d2571642a5eceb4d80e88a922c Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 13:28:19 -0400 Subject: [PATCH 13/14] chore: update byterover memory pointer --- .brv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.brv b/.brv index 9b4ccaf4..b7ddf21e 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 9b4ccaf44351e17d9ad934a549adda014f4403ee +Subproject commit b7ddf21e4f387e3bcfd820fe58c2233991a82f36 From 675abed5058d68a9835ebfa9eb286c768b137692 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 21 May 2026 13:53:50 -0400 Subject: [PATCH 14/14] fix(cli): preserve PowerShell trailing completion --- .brv | 2 +- packages/core/src/cli.ts | 10 ++++++++-- packages/core/src/cli/completion.ts | 4 +++- packages/core/test/cli-completion.test.ts | 8 +++++++- packages/core/test/cli.test.ts | 24 +++++++++++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.brv b/.brv index b7ddf21e..300e602e 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit b7ddf21e4f387e3bcfd820fe58c2233991a82f36 +Subproject commit 300e602ebf649d4e4129ce8434f45bbd8746b55a diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 2ebaef2d..35c597a8 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -14,6 +14,7 @@ import { completeCliWords, completionScript, completionShells, + trailingSpaceCompletionToken, type CompletionShell, } from "./cli/completion"; import { @@ -80,6 +81,10 @@ export async function runCli(args: string[], io: CliIO = {}): Promise { } } +function normalizeCompletionWords(words: string[]): string[] { + return words.map((word) => (word === trailingSpaceCompletionToken ? "" : word)); +} + export function createProgram(io: CliIO = {}): Command { const writeOut = io.writeOut ?? ((value: string) => process.stdout.write(value)); const writeErr = io.writeErr ?? ((value: string) => process.stderr.write(value)); @@ -129,14 +134,15 @@ export function createProgram(io: CliIO = {}): Command { : "bash"; const remote = remoteClientForCli(io); const configPath = currentConfigPath(); + const completionWords = normalizeCompletionWords(words); let suggestions: string[] = []; try { suggestions = remote ? ((await remote.request("complete_cli" as RemoteCliCommand, { shell, - words, + words: completionWords, })) as string[]) - : await completeCliWords(words, configPath ? { configPath } : {}); + : await completeCliWords(completionWords, configPath ? { configPath } : {}); } catch { suggestions = []; } diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index 1e57ac02..cfa24d74 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -22,6 +22,8 @@ import { export { completionShells, type CompletionShell } from "./commands"; +export const trailingSpaceCompletionToken = "__CAPLETS_TRAILING_SPACE__"; + export type CompletionOptions = { configPath?: string; projectConfigPath?: string; @@ -236,7 +238,7 @@ function powershellCompletionScript(): string { Register-ArgumentCompleter -Native -CommandName caplets -ScriptBlock { param($wordToComplete, $commandAst, $cursorPosition) $tokens = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object { $_.ToString() }) - if ($tokens.Count -eq 0 -or $commandAst.Extent.Text.EndsWith(' ')) { $tokens += '' } + if ($tokens.Count -eq 0 -or $commandAst.Extent.Text.EndsWith(' ')) { $tokens += '${trailingSpaceCompletionToken}' } caplets __complete --shell powershell -- @tokens 2>$null | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 36862fb0..3975b1cc 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createProgram } from "../src/cli"; -import { completeCliWords, completionScript } from "../src/cli/completion"; +import { + completeCliWords, + completionScript, + trailingSpaceCompletionToken, +} from "../src/cli/completion"; const dirs: string[] = []; @@ -26,6 +30,8 @@ describe("CLI completion scripts", () => { expect(completionScript("fish")).toContain("2>/dev/null"); expect(completionScript("powershell")).toContain("Register-ArgumentCompleter"); expect(completionScript("powershell")).toContain("caplets __complete --shell powershell"); + expect(completionScript("powershell")).toContain(trailingSpaceCompletionToken); + expect(completionScript("powershell")).not.toContain("$tokens += ''"); expect(completionScript("powershell")).toContain("2>$null"); expect(completionScript("cmd")).toContain("doskey caplets-complete="); expect(completionScript("cmd")).toContain("caplets __complete --shell cmd"); diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index f25a1340..cbee7135 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -1892,6 +1892,30 @@ describe("cli completion commands", () => { ]); }); + it("maps the PowerShell trailing-space sentinel before resolving completions", 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", "powershell", "--", "call-tool", "__CAPLETS_TRAILING_SPACE__"], + { + 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 }); + } + }); + it("uses configured Caplet IDs in local completion", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-cli-completion-")); const configPath = join(dir, "config.json");