From 2f49b9e494188554309db3b674e5c7a5a99e460b Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 14 May 2026 19:48:38 -0400 Subject: [PATCH 1/2] feat: add cli tools backend --- .changeset/curated-cli-tools.md | 5 + README.md | 76 +++- caplets/github-cli/CAPLET.md | 41 ++ caplets/repo-cli/CAPLET.md | 39 ++ docs/benchmarks/coding-agent.md | 8 +- docs/plans/2026-05-14-cli-tools-backend.md | 57 +++ schemas/caplet.schema.json | 139 +++++++ schemas/caplets-config.schema.json | 174 +++++++++ src/capability-description.mjs | 4 +- src/caplet-files.ts | 115 +++++- src/cli-tools.ts | 427 +++++++++++++++++++++ src/cli.ts | 30 ++ src/cli/author.ts | 267 +++++++++++++ src/cli/inspection.ts | 2 + src/config.ts | 249 +++++++++++- src/generated-tool-input-schema.mjs | 2 +- src/registry.ts | 21 +- src/runtime.ts | 12 +- src/tools.ts | 26 +- test/author-cli.test.ts | 88 +++++ test/cli-tools.test.ts | 224 +++++++++++ test/config.test.ts | 14 + test/openapi.test.ts | 1 + test/registry.test.ts | 27 ++ test/runtime.test.ts | 29 ++ 25 files changed, 2047 insertions(+), 30 deletions(-) create mode 100644 .changeset/curated-cli-tools.md create mode 100644 caplets/github-cli/CAPLET.md create mode 100644 caplets/repo-cli/CAPLET.md create mode 100644 docs/plans/2026-05-14-cli-tools-backend.md create mode 100644 src/cli-tools.ts create mode 100644 src/cli/author.ts create mode 100644 test/author-cli.test.ts create mode 100644 test/cli-tools.test.ts diff --git a/.changeset/curated-cli-tools.md b/.changeset/curated-cli-tools.md new file mode 100644 index 00000000..ea036389 --- /dev/null +++ b/.changeset/curated-cli-tools.md @@ -0,0 +1,5 @@ +--- +"caplets": minor +--- + +Add `cliTools` Caplet backends for typed, shell-free CLI actions plus `caplets author cli` for generating reviewable CLI Caplet manifests. diff --git a/README.md b/README.md index a88190f2..a6ab928a 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ the agent chooses that server and asks to search, list, inspect, or call them. ## What It Does -- Reads downstream MCP server definitions, native OpenAPI endpoint definitions, native GraphQL endpoint definitions, and explicit HTTP API action definitions from the user config file. -- Registers one generated MCP tool for each enabled MCP server, OpenAPI endpoint, GraphQL endpoint, or HTTP API. +- Reads downstream MCP server definitions, native OpenAPI endpoint definitions, native GraphQL endpoint definitions, explicit HTTP API action definitions, and curated CLI tool definitions from the user config file. +- Registers one generated MCP tool for each enabled MCP server, OpenAPI endpoint, GraphQL endpoint, HTTP API, or CLI tools backend. - Uses the configured server ID as the generated tool name. - Uses the configured `name` and `description` as the capability card shown to agents. - Starts downstream MCP servers and loads OpenAPI specs lazily when an operation needs them. @@ -84,6 +84,7 @@ the agent chooses that server and asks to search, list, inspect, or call them. - Converts OpenAPI operations into MCP-style tool metadata and executes HTTP calls directly. - Converts configured GraphQL operations into MCP-style tool metadata, and can auto-generate GraphQL tools from schema root query and mutation fields. - Converts explicitly configured HTTP actions into MCP-style tool metadata and executes HTTP calls directly. +- Converts explicitly configured CLI actions into MCP-style tool metadata and executes commands directly without a shell. - Preserves downstream tool results instead of rewriting them into a custom format. - Redacts secrets from structured errors. - Supports static remote auth and OAuth token storage for remote servers. @@ -218,7 +219,7 @@ the committed schema stays in sync with the Zod config validator. For richer skill-like cards, add Markdown Caplet files beside `config.json`. Every Caplet file must include exactly one executable backend: `mcpServer`, `openapiEndpoint`, -`graphqlEndpoint`, or `httpApi`; +`graphqlEndpoint`, `httpApi`, or `cliTools`; serverless Caplets are intentionally out of scope. Top-level files derive the Caplet ID from the filename: @@ -301,6 +302,26 @@ httpApi: # Status API ``` +CLI-backed Caplet files use `cliTools`: + +```md +--- +name: Repository CLI +description: Run curated repository workflows through local CLI commands. +cliTools: + cwd: /home/you/project + actions: + git_status: + description: Show concise Git working tree status. + command: git + args: ["status", "--short"] + annotations: + readOnlyHint: true +--- + +# Repository CLI +``` + Top-level files derive their Caplet ID from the filename. Directory-style Caplets use `linear/CAPLET.md`, which is exposed as `linear`; sibling files can be referenced with normal Markdown links from `CAPLET.md`. @@ -310,6 +331,8 @@ This repository includes polished working examples under [`caplets/`](caplets/): - `github`: GitHub's official MCP server container, using `GITHUB_PERSONAL_ACCESS_TOKEN`. - `linear`: Linear's hosted OAuth MCP endpoint. - `context7`: Context7 documentation lookup through `@upstash/context7-mcp`. +- `repo-cli`: Read-oriented repository CLI workflows through `git` and package scripts. +- `github-cli`: Read-oriented GitHub workflows through the `gh` CLI. Install every example from a repo's `caplets/` directory: @@ -350,7 +373,7 @@ caplets init --force ### Caplet IDs -Each key under `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, or `httpApis` is the +Each key under `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, `httpApis`, or `cliTools` is the stable Caplet ID. It becomes the generated MCP tool name exactly, so keep it short and specific: ```json @@ -367,7 +390,7 @@ stable Caplet ID. It becomes the generated MCP tool name exactly, so keep it sho ``` Caplet IDs must match `^[a-zA-Z0-9_-]{1,64}$` and must be unique across `mcpServers`, -`openapiEndpoints`, `graphqlEndpoints`, and `httpApis`. Spaces, dots, slashes, colons, and Unicode IDs are rejected. +`openapiEndpoints`, `graphqlEndpoints`, `httpApis`, and `cliTools`. Spaces, dots, slashes, colons, and Unicode IDs are rejected. ### Stdio Servers @@ -537,6 +560,49 @@ parsed `body` when present, and `elapsedMs`; non-2xx responses set `isError`, re timeouts are enforced, response bodies are capped by `maxResponseBytes` (default `1000000`), and errors redact secrets. +### CLI Tools + +Use `cliTools` for curated local command-line workflows. Each action is an explicitly configured +tool; Caplets does not expose arbitrary shell access and always spawns `command` plus `args` +without shell interpolation. + +```json +{ + "name": "Repository CLI", + "description": "Run curated repository workflows through local CLI commands.", + "cwd": "/home/you/project", + "timeoutMs": 60000, + "maxOutputBytes": 1000000, + "actions": { + "git_status": { + "description": "Show concise Git working tree status.", + "command": "git", + "args": ["status", "--short"], + "annotations": { "readOnlyHint": true } + }, + "run_tests": { + "description": "Run the package test script.", + "command": "pnpm", + "args": ["run", "test"], + "timeoutMs": 120000, + "annotations": { "readOnlyHint": true } + } + } +} +``` + +CLI actions can set `inputSchema`, `outputSchema`, `env`, action-level `cwd`, `timeoutMs`, +`maxOutputBytes`, `output: {"type":"json"}`, and MCP annotations. `$input.field` references are +supported inside `args`, `env`, and `cwd` strings. Caplets performs basic required-field and +primitive-type validation before spawning. Results are returned as structured content with +`exitCode`, `stdout`, `stderr`, and `elapsedMs`; non-zero exits set `isError`. + +Generate a reviewable CLI Caplet manifest from a repository: + +```sh +caplets author cli repo-tools --repo . --include git,gh,package --output - +``` + ### Authentication Remote servers can use: diff --git a/caplets/github-cli/CAPLET.md b/caplets/github-cli/CAPLET.md new file mode 100644 index 00000000..d789f26e --- /dev/null +++ b/caplets/github-cli/CAPLET.md @@ -0,0 +1,41 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: GitHub CLI +description: Inspect GitHub pull requests and issues through curated gh CLI commands. +tags: + - cli + - github + - code +cliTools: + actions: + gh_pr_status: + description: Show pull request status for the current branch as JSON. + command: gh + args: + - pr + - status + - --json + - currentBranch + output: + type: json + annotations: + readOnlyHint: true + openWorldHint: true + gh_issue_list: + description: List open GitHub issues as JSON. + command: gh + args: + - issue + - list + - --json + - number,title,state,url + output: + type: json + annotations: + readOnlyHint: true + openWorldHint: true +--- + +# GitHub CLI + +Use this Caplet to expose read-oriented GitHub workflows through `gh` without giving the agent an unrestricted shell. diff --git a/caplets/repo-cli/CAPLET.md b/caplets/repo-cli/CAPLET.md new file mode 100644 index 00000000..676ed551 --- /dev/null +++ b/caplets/repo-cli/CAPLET.md @@ -0,0 +1,39 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: Repository CLI +description: Inspect and run common local repository workflows through curated CLI tools. +tags: + - cli + - code +cliTools: + actions: + git_status: + description: Show concise Git working tree status. + command: git + args: + - status + - --short + annotations: + readOnlyHint: true + git_current_branch: + description: Print the current Git branch name. + command: git + args: + - branch + - --show-current + annotations: + readOnlyHint: true + package_test: + description: Run the repository test script with pnpm. + command: pnpm + args: + - run + - test + timeoutMs: 120000 + annotations: + readOnlyHint: true +--- + +# Repository CLI + +Use this Caplet to expose a small, typed set of local repository commands without giving an agent arbitrary shell access. diff --git a/docs/benchmarks/coding-agent.md b/docs/benchmarks/coding-agent.md index 266682f6..796ce708 100644 --- a/docs/benchmarks/coding-agent.md +++ b/docs/benchmarks/coding-agent.md @@ -14,13 +14,13 @@ The fixture uses local mock MCP metadata only. It does not call external APIs, d ## Summary - Initial tools visible: direct flat MCP 106, Caplets top-level 3, 97.2% fewer. -- Serialized payload bytes: direct flat MCP 32090, Caplets top-level 8358, 74.0% fewer. -- Approx. tokens: direct flat MCP 8023, Caplets top-level 2090, 5933 fewer. +- Serialized payload bytes: direct flat MCP 32090, Caplets top-level 8400, 73.8% fewer. +- Approx. tokens: direct flat MCP 8023, Caplets top-level 2100, 5923 fewer. - Candidate set before discovery: direct flat MCP 106, Caplets top-level 3, 103 fewer. ## Deterministic Results -Caplets reduces the initial serialized MCP tool payload by 74.0%, from 32090 bytes to 8358 bytes. It reduces initially visible tools by 97.2%, from 106 direct flat tools to 3 Caplets capability tools, while preserving access to downstream tools through scoped discovery and `call_tool`. +Caplets reduces the initial serialized MCP tool payload by 73.8%, from 32090 bytes to 8400 bytes. It reduces initially visible tools by 97.2%, from 106 direct flat tools to 3 Caplets capability tools, while preserving access to downstream tools through scoped discovery and `call_tool`. ## Collision Check @@ -38,7 +38,7 @@ Caplets starts from 3 capability tools. Expected task-specific discovery is 4 ca ## Validation -- Initial payload reduction threshold: 74.0% >= 70.0% +- Initial payload reduction threshold: 73.8% >= 70.0% - Top-level Caplets collisions: 0 Payload implementation: `source` diff --git a/docs/plans/2026-05-14-cli-tools-backend.md b/docs/plans/2026-05-14-cli-tools-backend.md new file mode 100644 index 00000000..64470402 --- /dev/null +++ b/docs/plans/2026-05-14-cli-tools-backend.md @@ -0,0 +1,57 @@ +# CLI Tools Backend For Caplets + +## Summary + +Add a fifth backend, `cliTools`, that exposes curated command-line workflows as typed Caplets tools. Runtime execution is deterministic and shell-free: Caplets maps declared JSON inputs into `spawn(command, args)` calls, returns bounded structured results, and never exposes arbitrary bash. + +Also add `caplets author cli` to generate reviewable CLI Caplet manifests from repo/package metadata and curated templates for `git`, `gh`, and the detected package manager. Generation prints to stdout by default. + +## Key Changes + +- Add `cliTools` to config and Caplet frontmatter alongside `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, and `httpApis`. +- Use an `actions` map shape, mirroring `httpApis.actions`: + - action key is the downstream tool name. + - each action supports `description`, `inputSchema`, optional `outputSchema`, `command`, `args`, `env`, `cwd`, `timeoutMs`, `maxOutputBytes`, `output`, and MCP annotations. +- Execute with `spawn(command, args)` only; no shell mode in v1. +- Support `$input.foo` interpolation inside argv/env/cwd strings. +- Apply basic runtime input validation for required fields and primitive JSON Schema types before spawning. +- Default limits: `timeoutMs: 60000`, `maxOutputBytes: 1000000`, configurable at Caplet and action level. +- Return CLI results as structured content: + - `{ exitCode, stdout, stderr, elapsedMs }` + - `isError: true` for non-zero exits. + - optional stdout JSON parsing when `output.type: "json"` is declared. +- `check_backend` validates manifest shape, cwd/env resolution, command availability, and tool count without running configured actions. +- `list_tools`, `search_tools`, `get_tool`, `call_tool`, and field selection should work consistently with existing backends. +- Load user-root CLI Caplets normally; load project `.caplets` CLI Caplets only under the existing `CAPLETS_TRUST_PROJECT_CAPLETS` gate. + +## Authoring UX + +- Add `caplets author cli ` with scriptable flags: + - `--repo ` to inspect a repository. + - `--include git,gh,package` to choose generators/templates. + - `--command ` for single-CLI generation. + - `--output -` by default, with explicit file output supported. +- Heuristic generator only in v1; no OpenAI/API/agent dependency. +- Repo workflow generation should inspect package scripts and lockfiles, then generate safe tools such as test, lint, typecheck, build, repo status, changed files, and PR status when applicable. +- Single-CLI templates should cover `git`, `gh`, and detected package manager commands. +- Generated manifests must be explicit Markdown Caplet files with `cliTools`, not hidden runtime state. + +## Docs And Examples + +- Update README config docs, Caplet file docs, generated schemas, and backend operation docs. +- Add real bundled CLI examples under `caplets/`, focused on repo maintenance and GitHub workflows. +- Examples should be safe/read-oriented by default; mutating actions must carry clear annotations such as `readOnlyHint: false` and `destructiveHint` where appropriate. + +## Test Plan + +- Config tests for `cliTools`, duplicate IDs across all five backend maps, Caplet frontmatter loading, project trust behavior, defaults, limits, and invalid command/action shapes. +- CLI manager tests for list/search/get/call, input interpolation, basic validation, JSON output parsing, non-zero exit handling, timeout handling, output byte limits, command-not-found errors, cwd/env behavior, and secret redaction. +- Runtime/registry tests for `cliTools` registration, `check_backend`, reload invalidation, and `caplets list`. +- Authoring tests for stdout output, explicit output path, package-script detection, `git`/`gh` templates, package manager detection, and generated Caplet validation. +- Schema check, typecheck, focused tests, full `pnpm verify`. + +## Assumptions + +- V1 intentionally excludes shell snippets, conditional argv construction, full JSON Schema validation, LLM-assisted generation, and automatic installation into the user Caplets root. +- Complex workflows should be modeled as package scripts or wrapper executables, then called through typed `cliTools` actions. +- Caplets surfaces risk annotations but does not block declared mutating tools at runtime; client approval remains outside Caplets. diff --git a/schemas/caplet.schema.json b/schemas/caplet.schema.json index cc9adc45..7f03de45 100644 --- a/schemas/caplet.schema.json +++ b/schemas/caplet.schema.json @@ -1003,6 +1003,145 @@ "required": ["baseUrl", "auth", "actions"], "additionalProperties": false, "description": "HTTP API backend configuration for this Caplet." + }, + "cliTools": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,64}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "description": "Action capability description.", + "type": "string", + "minLength": 1 + }, + "inputSchema": { + "description": "JSON Schema for call_tool arguments.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "outputSchema": { + "description": "JSON Schema for structuredContent returned by this action.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this action.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "output": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text", "json"] + } + }, + "additionalProperties": false + }, + "annotations": { + "type": "object", + "properties": { + "readOnlyHint": { + "type": "boolean" + }, + "destructiveHint": { + "type": "boolean" + }, + "idempotentHint": { + "type": "boolean" + }, + "openWorldHint": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "required": ["command"], + "additionalProperties": false + }, + "description": "Configured CLI actions keyed by stable tool name.", + "minProperties": 1 + }, + "cwd": { + "description": "Default working directory for CLI actions.", + "type": "string", + "minLength": 1 + }, + "env": { + "description": "Default environment variables.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "timeoutMs": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "disabled": { + "description": "When true, omit this Caplet from discovery.", + "type": "boolean" + } + }, + "required": ["actions"], + "additionalProperties": false, + "description": "CLI tools backend configuration for this Caplet." } }, "required": ["name", "description"], diff --git a/schemas/caplets-config.schema.json b/schemas/caplets-config.schema.json index ea6c0121..0d25433c 100644 --- a/schemas/caplets-config.schema.json +++ b/schemas/caplets-config.schema.json @@ -1130,6 +1130,180 @@ "required": ["name", "description", "baseUrl", "auth", "actions"], "additionalProperties": false } + }, + "cliTools": { + "default": {}, + "description": "CLI tools keyed by stable Caplet ID.", + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,64}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80, + "description": "Human-readable CLI tools display name." + }, + "description": { + "type": "string", + "description": "Capability description shown to agents before CLI actions are disclosed." + }, + "actions": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]{1,64}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "description": { + "description": "Action capability description.", + "type": "string", + "minLength": 1 + }, + "inputSchema": { + "description": "JSON Schema for call_tool arguments.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "outputSchema": { + "description": "JSON Schema for structuredContent returned by this action.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "command": { + "type": "string", + "minLength": 1, + "description": "Executable command to spawn without a shell." + }, + "args": { + "description": "Arguments passed to the command.", + "type": "array", + "items": { + "type": "string" + } + }, + "env": { + "description": "Additional environment variables for the command.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "cwd": { + "description": "Working directory for this action.", + "type": "string", + "minLength": 1 + }, + "timeoutMs": { + "description": "Command timeout in milliseconds.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "description": "Maximum combined stdout and stderr bytes to keep.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "output": { + "type": "object", + "properties": { + "type": { + "default": "text", + "description": "How stdout should be represented in structuredContent.", + "type": "string", + "enum": ["text", "json"] + } + }, + "additionalProperties": false + }, + "annotations": { + "type": "object", + "properties": { + "readOnlyHint": { + "type": "boolean" + }, + "destructiveHint": { + "type": "boolean" + }, + "idempotentHint": { + "type": "boolean" + }, + "openWorldHint": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "required": ["command"], + "additionalProperties": false + }, + "description": "Configured CLI actions keyed by stable tool name.", + "minProperties": 1 + }, + "cwd": { + "description": "Default working directory for CLI actions.", + "type": "string", + "minLength": 1 + }, + "env": { + "description": "Default environment variables for CLI actions.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "tags": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "timeoutMs": { + "default": 60000, + "description": "Default timeout in milliseconds for CLI actions.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "maxOutputBytes": { + "default": 1000000, + "description": "Default maximum combined stdout and stderr bytes to keep.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "disabled": { + "default": false, + "description": "When true, omit this CLI tools Caplet.", + "type": "boolean" + } + }, + "required": ["name", "description", "actions"], + "additionalProperties": false + } } }, "additionalProperties": false diff --git a/src/capability-description.mjs b/src/capability-description.mjs index 4b3a3d2b..b23b9635 100644 --- a/src/capability-description.mjs +++ b/src/capability-description.mjs @@ -6,7 +6,9 @@ export function capabilityDescription(server) { ? "OpenAPI endpoint" : server.backend === "graphql" ? "GraphQL endpoint" - : "HTTP API"; + : server.backend === "http" + ? "HTTP API" + : "CLI tools"; const checkOperation = server.backend === "mcp" ? "check_mcp_server" : "check_backend"; const hint = [ `Use this Caplet to inspect and call tools from its ${backendName} backend.`, diff --git a/src/caplet-files.ts b/src/caplet-files.ts index e458a43e..24d69ee0 100644 --- a/src/caplet-files.ts +++ b/src/caplet-files.ts @@ -451,6 +451,60 @@ const capletHttpApiSchema = z } }); +const capletCliToolOutputSchema = z + .object({ + type: z.enum(["text", "json"]).optional(), + }) + .strict(); + +const capletCliToolAnnotationsSchema = z + .object({ + readOnlyHint: z.boolean().optional(), + destructiveHint: z.boolean().optional(), + idempotentHint: z.boolean().optional(), + openWorldHint: z.boolean().optional(), + }) + .strict(); + +const capletCliToolActionSchema = z + .object({ + description: z.string().min(1).optional().describe("Action capability description."), + inputSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON Schema for call_tool arguments."), + outputSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON Schema for structuredContent returned by this action."), + command: z.string().min(1).describe("Executable command to spawn without a shell."), + args: z.array(z.string()).optional().describe("Arguments passed to the command."), + env: z.record(z.string(), z.string()).optional().describe("Additional environment variables."), + cwd: z.string().min(1).optional().describe("Working directory for this action."), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + output: capletCliToolOutputSchema.optional(), + annotations: capletCliToolAnnotationsSchema.optional(), + }) + .strict(); + +const capletCliToolsSchema = z + .object({ + actions: z + .record(z.string().regex(SERVER_ID_PATTERN), capletCliToolActionSchema) + .refine( + (actions) => Object.keys(actions).length > 0, + "CLI tools backend must define at least one action", + ) + .describe("Configured CLI actions keyed by stable tool name."), + cwd: z.string().min(1).optional().describe("Default working directory for CLI actions."), + env: z.record(z.string(), z.string()).optional().describe("Default environment variables."), + timeoutMs: z.number().int().positive().optional(), + maxOutputBytes: z.number().int().positive().optional(), + disabled: z.boolean().optional().describe("When true, omit this Caplet from discovery."), + }) + .strict(); + export const capletFileSchema = z .object({ $schema: z @@ -483,6 +537,9 @@ export const capletFileSchema = z httpApi: capletHttpApiSchema .describe("HTTP API backend configuration for this Caplet.") .optional(), + cliTools: capletCliToolsSchema + .describe("CLI tools backend configuration for this Caplet.") + .optional(), }) .strict() .superRefine((frontmatter, ctx) => { @@ -490,12 +547,13 @@ export const capletFileSchema = z Number(Boolean(frontmatter.mcpServer)) + Number(Boolean(frontmatter.openapiEndpoint)) + Number(Boolean(frontmatter.graphqlEndpoint)) + - Number(Boolean(frontmatter.httpApi)); + Number(Boolean(frontmatter.httpApi)) + + Number(Boolean(frontmatter.cliTools)); if (backendCount !== 1) { ctx.addIssue({ code: "custom", message: - "Caplet file must define exactly one backend: mcpServer, openapiEndpoint, graphqlEndpoint, or httpApi", + "Caplet file must define exactly one backend: mcpServer, openapiEndpoint, graphqlEndpoint, httpApi, or cliTools", }); } }); @@ -503,7 +561,7 @@ export const capletFileSchema = z type CapletFileFrontmatter = z.infer; export function capletJsonSchema(): unknown { - return patchHttpApiJsonSchema({ + return patchCapletJsonSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json", title: "Caplet file frontmatter", @@ -517,6 +575,7 @@ export type CapletFileConfig = { openapiEndpoints?: Record; graphqlEndpoints?: Record; httpApis?: Record; + cliTools?: Record; }; export function loadCapletFiles(root: string): CapletFileConfig | undefined { @@ -528,12 +587,14 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { const openapiEndpoints: Record = {}; const graphqlEndpoints: Record = {}; const httpApis: Record = {}; + const cliTools: Record = {}; for (const candidate of discoverCapletFiles(root)) { if ( servers[candidate.id] || openapiEndpoints[candidate.id] || graphqlEndpoints[candidate.id] || - httpApis[candidate.id] + httpApis[candidate.id] || + cliTools[candidate.id] ) { throw new CapletsError("CONFIG_INVALID", `Duplicate Caplet ID ${candidate.id} under ${root}`); } @@ -547,6 +608,9 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { } else if (isPlainObject(config) && config.backend === "http") { const { backend: _backend, ...endpoint } = config; httpApis[candidate.id] = endpoint; + } else if (isPlainObject(config) && config.backend === "cli") { + const { backend: _backend, ...endpoint } = config; + cliTools[candidate.id] = endpoint; } else { servers[candidate.id] = config; } @@ -556,12 +620,14 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { const hasOpenApi = Object.keys(openapiEndpoints).length > 0; const hasGraphQl = Object.keys(graphqlEndpoints).length > 0; const hasHttpApis = Object.keys(httpApis).length > 0; - return hasServers || hasOpenApi || hasGraphQl || hasHttpApis + const hasCliTools = Object.keys(cliTools).length > 0; + return hasServers || hasOpenApi || hasGraphQl || hasHttpApis || hasCliTools ? { ...(hasServers ? { mcpServers: servers } : {}), ...(hasOpenApi ? { openapiEndpoints } : {}), ...(hasGraphQl ? { graphqlEndpoints } : {}), ...(hasHttpApis ? { httpApis } : {}), + ...(hasCliTools ? { cliTools } : {}), } : undefined; } @@ -671,6 +737,19 @@ function capletToServerConfig( }; } + if (frontmatter.cliTools) { + return { + ...frontmatter.cliTools, + cwd: normalizeLocalPath(frontmatter.cliTools.cwd, baseDir), + actions: normalizeCliToolActions(frontmatter.cliTools.actions, baseDir), + backend: "cli", + name: frontmatter.name, + description: frontmatter.description, + ...(frontmatter.tags ? { tags: frontmatter.tags } : {}), + body, + }; + } + return { ...frontmatter.mcpServer!, name: frontmatter.name, @@ -680,6 +759,21 @@ function capletToServerConfig( }; } +function normalizeCliToolActions( + actions: z.infer["actions"], + baseDir: string, +): z.infer["actions"] { + return Object.fromEntries( + Object.entries(actions).map(([name, action]) => [ + name, + { + ...action, + cwd: normalizeLocalPath(action.cwd, baseDir) as string | undefined, + }, + ]), + ); +} + function normalizeGraphQlOperations( operations: z.infer["operations"], baseDir: string, @@ -768,7 +862,7 @@ function hasEnvReference(value: string): boolean { return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value); } -function patchHttpApiJsonSchema(schema: T): T { +function patchCapletJsonSchema(schema: T): T { const httpApiProperties = schemaPath>(schema, [ "properties", "httpApi", @@ -782,5 +876,14 @@ function patchHttpApiJsonSchema(schema: T): T { if (baseUrl) { baseUrl.format = "uri"; } + const cliToolsProperties = schemaPath>(schema, [ + "properties", + "cliTools", + "properties", + ]); + const cliActions = nestedSchema>(cliToolsProperties, "actions"); + if (cliActions) { + cliActions.minProperties = 1; + } return schema; } diff --git a/src/cli-tools.ts b/src/cli-tools.ts new file mode 100644 index 00000000..94748ac7 --- /dev/null +++ b/src/cli-tools.ts @@ -0,0 +1,427 @@ +import { constants, existsSync, accessSync } from "node:fs"; +import { delimiter, isAbsolute, join } from "node:path"; +import { spawn } from "node:child_process"; +import type { CompatibilityCallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { CliToolActionConfig, CliToolsConfig } from "./config.js"; +import type { CompactTool } from "./downstream.js"; +import { CapletsError, toSafeError } from "./errors.js"; +import type { ServerRegistry } from "./registry.js"; + +const DEFAULT_INPUT_SCHEMA = { type: "object", additionalProperties: true } as const; +type CliToolAction = CliToolActionConfig & { name: string }; + +export class CliToolsManager { + constructor(private registry: ServerRegistry) {} + + updateRegistry(registry: ServerRegistry): void { + this.registry = registry; + } + + invalidate(_serverId: string): void {} + + async checkTools(config: CliToolsConfig): Promise<{ + server: string; + status: string; + toolCount?: number; + elapsedMs: number; + error?: unknown; + }> { + const startedAt = Date.now(); + try { + for (const action of actionsFor(config)) { + const cwd = interpolateString(action.cwd ?? config.cwd, {}, "cwd"); + if (cwd && !existsSync(cwd)) { + throw new CapletsError( + "CONFIG_INVALID", + `CLI cwd does not exist for ${config.server}/${action.name}`, + ); + } + resolveCommandPath(action.command); + } + this.registry.setStatus(config.server, "available"); + return { + server: config.server, + status: "available", + toolCount: Object.keys(config.actions).length, + elapsedMs: Date.now() - startedAt, + }; + } catch (error) { + const safe = toSafeError(error, "SERVER_UNAVAILABLE"); + this.registry.setStatus(config.server, "unavailable", safe); + return { + server: config.server, + status: "unavailable", + elapsedMs: Date.now() - startedAt, + error: safe, + }; + } + } + + async listTools(config: CliToolsConfig): Promise { + return actionsFor(config).map((action) => this.toTool(action)); + } + + async getTool(config: CliToolsConfig, toolName: string): Promise { + return this.toTool(getAction(config, toolName)); + } + + async callTool( + config: CliToolsConfig, + toolName: string, + args: Record, + ): Promise { + const action = getAction(config, toolName); + validateInput(action, args); + const execution = resolveExecution(config, action, args); + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), execution.timeoutMs); + + try { + const result = await spawnCommand(execution, controller.signal, () => Date.now() - startedAt); + const structured = parseStructuredResult(action, result); + return { + content: [{ type: "text", text: JSON.stringify(structured, null, 2) }], + structuredContent: structured, + isError: result.exitCode !== 0, + }; + } catch (error) { + if (isAbortError(error)) { + throw new CapletsError( + "TOOL_CALL_TIMEOUT", + `CLI tool timed out for ${config.server}/${toolName}`, + ); + } + if (error instanceof CapletsError) { + throw error; + } + throw new CapletsError( + "DOWNSTREAM_TOOL_ERROR", + `CLI tool failed for ${config.server}/${toolName}`, + toSafeError(error), + ); + } finally { + clearTimeout(timeout); + } + } + + compact(config: CliToolsConfig, tool: Tool): CompactTool { + return { + server: config.server, + tool: tool.name, + ...(tool.description ? { description: tool.description } : {}), + ...(tool.annotations ? { annotations: tool.annotations } : {}), + hasInputSchema: Boolean(tool.inputSchema), + hasOutputSchema: Boolean(tool.outputSchema), + }; + } + + search(config: CliToolsConfig, tools: Tool[], query: string, limit: number): CompactTool[] { + const needle = query.toLocaleLowerCase(); + return tools + .filter((tool) => + `${tool.name}\n${tool.description ?? ""}`.toLocaleLowerCase().includes(needle), + ) + .sort((left, right) => left.name.localeCompare(right.name)) + .slice(0, limit) + .map((tool) => this.compact(config, tool)); + } + + private toTool(action: CliToolAction): Tool { + return { + name: action.name, + ...(action.description ? { description: action.description } : {}), + inputSchema: (action.inputSchema ?? DEFAULT_INPUT_SCHEMA) as Tool["inputSchema"], + ...(action.outputSchema ? { outputSchema: action.outputSchema as Tool["outputSchema"] } : {}), + ...(action.annotations ? { annotations: action.annotations as Tool["annotations"] } : {}), + }; + } +} + +type Execution = { + command: string; + args: string[]; + cwd?: string; + env: NodeJS.ProcessEnv; + timeoutMs: number; + maxOutputBytes: number; +}; + +type SpawnResult = { + exitCode: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + elapsedMs: number; +}; + +function actionsFor(config: CliToolsConfig): CliToolAction[] { + return Object.entries(config.actions) + .map(([name, action]) => ({ name, ...action })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function getAction(config: CliToolsConfig, toolName: string): CliToolAction { + const actions = actionsFor(config); + const action = actions.find((candidate) => candidate.name === toolName); + if (!action) { + throw new CapletsError("TOOL_NOT_FOUND", `Tool ${toolName} was not found on ${config.server}`, { + server: config.server, + tool: toolName, + suggestions: actions + .map((candidate) => candidate.name) + .filter((name) => name.toLocaleLowerCase().includes(toolName.toLocaleLowerCase()[0] ?? "")) + .slice(0, 5), + }); + } + return action; +} + +function resolveExecution( + config: CliToolsConfig, + action: CliToolAction, + input: Record, +): Execution { + const cwd = interpolateString(action.cwd ?? config.cwd, input, "cwd"); + if (cwd && !existsSync(cwd)) { + throw new CapletsError( + "CONFIG_INVALID", + `CLI cwd does not exist for ${config.server}/${action.name}`, + ); + } + const env = { + ...process.env, + ...resolveEnv(config.env, input), + ...resolveEnv(action.env, input), + }; + return { + command: interpolateString(action.command, input, "command") ?? action.command, + args: (action.args ?? []).map((arg, index) => + interpolateRequiredString(arg, input, `args.${index}`), + ), + ...(cwd ? { cwd } : {}), + env, + timeoutMs: action.timeoutMs ?? config.timeoutMs, + maxOutputBytes: action.maxOutputBytes ?? config.maxOutputBytes, + }; +} + +function resolveEnv( + env: Record | undefined, + input: Record, +): Record { + if (!env) { + return {}; + } + return Object.fromEntries( + Object.entries(env).map(([key, value]) => [ + key, + interpolateRequiredString(value, input, `env.${key}`), + ]), + ); +} + +function interpolateString( + value: string | undefined, + input: Record, + field: string, +): string | undefined { + return value === undefined ? undefined : interpolateRequiredString(value, input, field); +} + +function interpolateRequiredString( + value: string, + input: Record, + field: string, +): string { + return value.replace(/\$input(?:\.([A-Za-z0-9_.-]+))?/g, (_match, path: string | undefined) => { + if (!path) { + throw new CapletsError("REQUEST_INVALID", `CLI ${field} cannot interpolate $input directly`); + } + const selected = valueAtPath(input, path); + if (selected === undefined || selected === null) { + throw new CapletsError("REQUEST_INVALID", `CLI ${field} references missing input ${path}`); + } + if ( + typeof selected !== "string" && + typeof selected !== "number" && + typeof selected !== "boolean" + ) { + throw new CapletsError( + "REQUEST_INVALID", + `CLI ${field} input ${path} must be a string, number, or boolean`, + ); + } + return String(selected); + }); +} + +function valueAtPath(input: Record, path: string): unknown { + let current: unknown = input; + for (const segment of path.split(".")) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return undefined; + } + current = (current as Record)[segment]; + } + return current; +} + +function validateInput(action: CliToolAction, input: Record): void { + const schema = action.inputSchema; + if (!schema) { + return; + } + const required = Array.isArray(schema.required) ? schema.required : []; + for (const key of required) { + if (typeof key === "string" && input[key] === undefined) { + throw new CapletsError("REQUEST_INVALID", `CLI tool ${action.name} requires input ${key}`); + } + } + const properties = isPlainObject(schema.properties) ? schema.properties : {}; + for (const [key, property] of Object.entries(properties)) { + if (input[key] === undefined || !isPlainObject(property) || typeof property.type !== "string") { + continue; + } + if (!matchesJsonType(input[key], property.type)) { + throw new CapletsError( + "REQUEST_INVALID", + `CLI tool ${action.name} input ${key} must be ${property.type}`, + ); + } + } +} + +function matchesJsonType(value: unknown, type: string): boolean { + switch (type) { + case "string": + return typeof value === "string"; + case "number": + case "integer": + return typeof value === "number" && (type === "number" || Number.isInteger(value)); + case "boolean": + return typeof value === "boolean"; + case "object": + return isPlainObject(value); + case "array": + return Array.isArray(value); + case "null": + return value === null; + default: + return true; + } +} + +function spawnCommand( + execution: Execution, + signal: AbortSignal, + elapsedMs: () => number, +): Promise { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + let outputBytes = 0; + const child = spawn(execution.command, execution.args, { + cwd: execution.cwd, + env: execution.env, + shell: false, + signal, + windowsHide: true, + }); + child.on("error", reject); + const append = (stream: "stdout" | "stderr", chunk: Buffer) => { + outputBytes += chunk.byteLength; + if (outputBytes > execution.maxOutputBytes) { + child.kill(); + reject(new CapletsError("DOWNSTREAM_TOOL_ERROR", "CLI tool output exceeded byte limit")); + return; + } + if (stream === "stdout") { + stdout += chunk.toString("utf8"); + } else { + stderr += chunk.toString("utf8"); + } + }; + child.stdout?.on("data", (chunk: Buffer) => append("stdout", chunk)); + child.stderr?.on("data", (chunk: Buffer) => append("stderr", chunk)); + child.on("close", (exitCode, childSignal) => { + resolve({ exitCode, signal: childSignal, stdout, stderr, elapsedMs: elapsedMs() }); + }); + }); +} + +function parseStructuredResult( + action: CliToolAction, + result: SpawnResult, +): Record { + const structured: Record = { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + elapsedMs: result.elapsedMs, + ...(result.signal ? { signal: result.signal } : {}), + }; + if (action.output?.type === "json" && result.stdout.trim()) { + try { + structured.json = JSON.parse(result.stdout); + } catch (error) { + throw new CapletsError( + "DOWNSTREAM_PROTOCOL_ERROR", + `CLI tool ${action.name} stdout was not valid JSON`, + toSafeError(error), + ); + } + } + return structured; +} + +function resolveCommandPath(command: string): string { + if (isAbsolute(command) || command.includes("/")) { + assertExecutable(command); + return command; + } + for (const directory of (process.env.PATH ?? "").split(delimiter)) { + if (!directory) { + continue; + } + const candidate = join(directory, command); + if (isExecutable(candidate)) { + return candidate; + } + if (process.platform === "win32") { + for (const ext of (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")) { + const windowsCandidate = join(directory, `${command}${ext.toLowerCase()}`); + if (isExecutable(windowsCandidate)) { + return windowsCandidate; + } + } + } + } + throw new CapletsError("SERVER_UNAVAILABLE", `CLI command ${command} was not found on PATH`); +} + +function assertExecutable(path: string): void { + if (!isExecutable(path)) { + throw new CapletsError("SERVER_UNAVAILABLE", `CLI command ${path} is not executable`); + } +} + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function isAbortError(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "AbortError" || error.message.toLowerCase().includes("abort")) + ); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/cli.ts b/src/cli.ts index e1465ce4..770d5049 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,6 +1,7 @@ import { Command, CommanderError } from "commander"; import { version as packageJsonVersion } from "../package.json"; import { loginAuth, logoutAuth, listAuth } from "./cli/auth.js"; +import { authorCliCaplet } from "./cli/author.js"; import { initConfig } from "./cli/init.js"; import { formatCapletList, @@ -97,6 +98,35 @@ export function createProgram(io: CliIO = {}): Command { } }); + const author = program.command("author").description("Generate reviewable Caplet files."); + + author + .command("cli") + .description("Generate a CLI tools Caplet.") + .argument("", "Caplet ID/display seed") + .option("--repo ", "repository path to inspect") + .option("--include ", "comma-separated generators to include: git,gh,package") + .option("--command ", "single CLI command template to generate") + .option("--output ", "output path, or - for stdout", "-") + .action( + ( + id: string, + options: { + repo?: string; + include?: string; + command?: string; + output?: string; + }, + ) => { + const result = authorCliCaplet(id, options); + if (result.path) { + writeOut(`Wrote CLI Caplet to ${result.path}\n`); + return; + } + writeOut(result.text); + }, + ); + const config = program.command("config").description("Inspect Caplets config locations."); config diff --git a/src/cli/author.ts b/src/cli/author.ts new file mode 100644 index 00000000..6c4e19f5 --- /dev/null +++ b/src/cli/author.ts @@ -0,0 +1,267 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +type AuthorCliOptions = { + repo?: string; + include?: string; + command?: string; + output?: string; +}; + +type CliAction = { + description?: string; + command: string; + args?: string[]; + cwd?: string; + timeoutMs?: number; + maxOutputBytes?: number; + output?: { type: "text" | "json" }; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; +}; + +export function authorCliCaplet( + id: string, + options: AuthorCliOptions = {}, +): { + path?: string; + text: string; +} { + const repo = resolve(options.repo ?? process.cwd()); + const include = parseInclude(options.include); + const actions: Record = {}; + + if (include.has("git") || options.command === "git") { + Object.assign(actions, gitActions(repo)); + } + if (include.has("gh") || options.command === "gh") { + Object.assign(actions, ghActions(repo)); + } + if (include.has("package") || isPackageCommand(options.command)) { + Object.assign(actions, packageActions(repo, options.command)); + } + + if (options.command && Object.keys(actions).length === 0) { + actions[`${sanitizeId(options.command)}_version`] = { + description: `Print ${options.command} version information.`, + command: options.command, + args: ["--version"], + annotations: { readOnlyHint: true }, + }; + } + + if (Object.keys(actions).length === 0) { + throw new Error("No CLI actions could be generated for the requested options"); + } + + const text = renderCaplet({ + name: titleize(id), + description: `Curated CLI tools for ${basename(repo)} workflows.`, + cwd: repo, + actions, + }); + const output = options.output ?? "-"; + if (output !== "-") { + writeFileSync(output, text); + return { path: output, text }; + } + return { text }; +} + +function parseInclude(value: string | undefined): Set { + if (!value) { + return new Set(["git", "gh", "package"]); + } + return new Set( + value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + ); +} + +function gitActions(repo: string): Record { + return { + git_status: { + description: "Show concise Git working tree status.", + command: "git", + args: ["status", "--short"], + cwd: repo, + annotations: { readOnlyHint: true }, + }, + git_current_branch: { + description: "Print the current Git branch name.", + command: "git", + args: ["branch", "--show-current"], + cwd: repo, + annotations: { readOnlyHint: true }, + }, + git_changed_files: { + description: "List changed tracked and untracked files.", + command: "git", + args: ["status", "--porcelain=v1", "--untracked-files=all"], + cwd: repo, + annotations: { readOnlyHint: true }, + }, + }; +} + +function ghActions(repo: string): Record { + return { + gh_pr_status: { + description: "Show pull request status for the current branch as JSON.", + command: "gh", + args: ["pr", "status", "--json", "currentBranch"], + cwd: repo, + output: { type: "json" }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + gh_issue_list: { + description: "List open GitHub issues as JSON.", + command: "gh", + args: ["issue", "list", "--json", "number,title,state,url"], + cwd: repo, + output: { type: "json" }, + annotations: { readOnlyHint: true, openWorldHint: true }, + }, + }; +} + +function packageActions( + repo: string, + explicitCommand: string | undefined, +): Record { + const packageJsonPath = join(repo, "package.json"); + if (!existsSync(packageJsonPath)) { + return {}; + } + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + scripts?: Record; + packageManager?: string; + }; + const manager = + explicitCommand && isPackageCommand(explicitCommand) + ? explicitCommand + : detectPackageManager(repo, packageJson); + const scripts = packageJson.scripts ?? {}; + const actions: Record = {}; + for (const script of ["test", "lint", "typecheck", "build", "verify"]) { + if (!scripts[script]) { + continue; + } + actions[`package_${script}`] = { + description: `Run the package ${script} script.`, + command: manager, + args: ["run", script], + cwd: repo, + annotations: { readOnlyHint: script !== "build" }, + ...(script === "test" || script === "verify" ? { timeoutMs: 120_000 } : {}), + } as CliAction; + } + return actions; +} + +function detectPackageManager( + repo: string, + packageJson: { packageManager?: string }, +): "pnpm" | "npm" | "bun" | "yarn" { + if (packageJson.packageManager?.startsWith("pnpm@")) { + return "pnpm"; + } + if (packageJson.packageManager?.startsWith("bun@")) { + return "bun"; + } + if (packageJson.packageManager?.startsWith("yarn@")) { + return "yarn"; + } + if (existsSync(join(repo, "pnpm-lock.yaml"))) { + return "pnpm"; + } + if (existsSync(join(repo, "bun.lockb")) || existsSync(join(repo, "bun.lock"))) { + return "bun"; + } + if (existsSync(join(repo, "yarn.lock"))) { + return "yarn"; + } + return "npm"; +} + +function isPackageCommand(command: string | undefined): command is "pnpm" | "npm" | "bun" | "yarn" { + return command === "pnpm" || command === "npm" || command === "bun" || command === "yarn"; +} + +function renderCaplet(input: { + name: string; + description: string; + cwd: string; + actions: Record; +}): string { + const lines = [ + "---", + "$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json", + `name: ${yamlString(input.name)}`, + `description: ${yamlString(input.description)}`, + "tags:", + " - cli", + " - code", + "cliTools:", + ` cwd: ${yamlString(input.cwd)}`, + " actions:", + ]; + for (const [name, action] of Object.entries(input.actions).sort(([left], [right]) => + left.localeCompare(right), + )) { + lines.push(` ${name}:`); + if (action.description) { + lines.push(` description: ${yamlString(action.description)}`); + } + lines.push(` command: ${yamlString(action.command)}`); + if (action.args?.length) { + lines.push(" args:"); + for (const arg of action.args) { + lines.push(` - ${yamlString(arg)}`); + } + } + if (action.cwd && action.cwd !== input.cwd) { + lines.push(` cwd: ${yamlString(action.cwd)}`); + } + if (action.timeoutMs) { + lines.push(` timeoutMs: ${action.timeoutMs}`); + } + if (action.maxOutputBytes) { + lines.push(` maxOutputBytes: ${action.maxOutputBytes}`); + } + if (action.output) { + lines.push(" output:"); + lines.push(` type: ${action.output.type}`); + } + if (action.annotations) { + lines.push(" annotations:"); + for (const [key, value] of Object.entries(action.annotations)) { + lines.push(` ${key}: ${value ? "true" : "false"}`); + } + } + } + lines.push("---", "", `# ${input.name}`, "", input.description, ""); + return `${lines.join("\n")}`; +} + +function yamlString(value: string): string { + return JSON.stringify(value); +} + +function titleize(id: string): string { + return id + .split(/[-_]/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function sanitizeId(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 48) || "cli"; +} diff --git a/src/cli/inspection.ts b/src/cli/inspection.ts index afa6de13..b1c9ffe4 100644 --- a/src/cli/inspection.ts +++ b/src/cli/inspection.ts @@ -58,6 +58,8 @@ function allCaplets(config: CapletsConfig): CapletConfig[] { ...Object.values(config.mcpServers), ...Object.values(config.openapiEndpoints), ...Object.values(config.graphqlEndpoints), + ...Object.values(config.httpApis), + ...Object.values(config.cliTools), ]; } diff --git a/src/config.ts b/src/config.ts index cee97992..c80061b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -161,11 +161,52 @@ export type HttpApiConfig = { disabled: boolean; }; +export type CliToolOutputConfig = { + type: "text" | "json"; +}; + +export type CliToolActionConfig = { + description?: string | undefined; + inputSchema?: Record | undefined; + outputSchema?: Record | undefined; + command: string; + args?: string[] | undefined; + env?: Record | undefined; + cwd?: string | undefined; + timeoutMs?: number | undefined; + maxOutputBytes?: number | undefined; + output?: CliToolOutputConfig | undefined; + annotations?: + | { + readOnlyHint?: boolean | undefined; + destructiveHint?: boolean | undefined; + idempotentHint?: boolean | undefined; + openWorldHint?: boolean | undefined; + } + | undefined; +}; + +export type CliToolsConfig = { + server: string; + backend: "cli"; + name: string; + description: string; + tags?: string[] | undefined; + body?: string | undefined; + actions: Record; + cwd?: string | undefined; + env?: Record | undefined; + timeoutMs: number; + maxOutputBytes: number; + disabled: boolean; +}; + export type CapletConfig = | CapletServerConfig | OpenApiEndpointConfig | GraphQlEndpointConfig - | HttpApiConfig; + | HttpApiConfig + | CliToolsConfig; export type CapletsOptions = { defaultSearchLimit: number; @@ -179,6 +220,7 @@ export type CapletsConfig = { openapiEndpoints: Record; graphqlEndpoints: Record; httpApis: Record; + cliTools: Record; }; const NON_INTERPOLATED_SERVER_FIELDS = new Set(["name", "description", "tags", "body"]); @@ -545,15 +587,109 @@ const normalizedHttpApiSchema = publicHttpApiSchema.extend({ body: z.string().optional(), }); +const cliToolOutputSchema = z + .object({ + type: z + .enum(["text", "json"]) + .default("text") + .describe("How stdout should be represented in structuredContent."), + }) + .strict(); + +const cliToolAnnotationsSchema = z + .object({ + readOnlyHint: z.boolean().optional(), + destructiveHint: z.boolean().optional(), + idempotentHint: z.boolean().optional(), + openWorldHint: z.boolean().optional(), + }) + .strict(); + +const cliToolActionSchema = z + .object({ + description: z.string().min(1).optional().describe("Action capability description."), + inputSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON Schema for call_tool arguments."), + outputSchema: z + .record(z.string(), z.unknown()) + .optional() + .describe("JSON Schema for structuredContent returned by this action."), + command: z.string().min(1).describe("Executable command to spawn without a shell."), + args: z.array(z.string()).optional().describe("Arguments passed to the command."), + env: z + .record(z.string(), z.string()) + .optional() + .describe("Additional environment variables for the command."), + cwd: z.string().min(1).optional().describe("Working directory for this action."), + timeoutMs: z.number().int().positive().optional().describe("Command timeout in milliseconds."), + maxOutputBytes: z + .number() + .int() + .positive() + .optional() + .describe("Maximum combined stdout and stderr bytes to keep."), + output: cliToolOutputSchema.optional(), + annotations: cliToolAnnotationsSchema.optional(), + }) + .strict(); + +const publicCliToolsSchema = z + .object({ + name: z.string().trim().min(1).max(80).describe("Human-readable CLI tools display name."), + description: z + .string() + .describe("Capability description shown to agents before CLI actions are disclosed.") + .refine( + (value) => value.trim().length >= 10, + "description must contain at least 10 non-whitespace characters", + ) + .refine((value) => value.length <= 1500, "description must be at most 1500 characters"), + actions: z + .record(z.string().regex(SERVER_ID_PATTERN), cliToolActionSchema) + .refine( + (actions) => Object.keys(actions).length > 0, + "CLI tools backend must define at least one action", + ) + .describe("Configured CLI actions keyed by stable tool name."), + cwd: z.string().min(1).optional().describe("Default working directory for CLI actions."), + env: z + .record(z.string(), z.string()) + .optional() + .describe("Default environment variables for CLI actions."), + tags: z.array(z.string().trim().min(1).max(80)).optional(), + timeoutMs: z + .number() + .int() + .positive() + .default(60_000) + .describe("Default timeout in milliseconds for CLI actions."), + maxOutputBytes: z + .number() + .int() + .positive() + .default(1_000_000) + .describe("Default maximum combined stdout and stderr bytes to keep."), + disabled: z.boolean().default(false).describe("When true, omit this CLI tools Caplet."), + }) + .strict(); + +const normalizedCliToolsSchema = publicCliToolsSchema.extend({ + body: z.string().optional(), +}); + type ConfigSchemaServerValue = z.infer; type ConfigSchemaOpenApiEndpointValue = z.infer; type ConfigSchemaGraphQlEndpointValue = z.infer; type ConfigSchemaHttpApiValue = z.infer; +type ConfigSchemaCliToolsValue = z.infer; type ConfigInput = { mcpServers?: Record; openapiEndpoints?: Record; graphqlEndpoints?: Record; httpApis?: Record; + cliTools?: Record; [key: string]: unknown; }; @@ -562,6 +698,7 @@ function configSchemaFor( openApiEndpointValueSchema: z.ZodTypeAny, graphQlEndpointValueSchema: z.ZodTypeAny, httpApiValueSchema: z.ZodTypeAny, + cliToolsValueSchema: z.ZodTypeAny, ) { return z .object({ @@ -600,6 +737,10 @@ function configSchemaFor( .record(z.string().regex(SERVER_ID_PATTERN), httpApiValueSchema) .default({}) .describe("HTTP APIs keyed by stable Caplet ID."), + cliTools: z + .record(z.string().regex(SERVER_ID_PATTERN), cliToolsValueSchema) + .default({}) + .describe("CLI tools keyed by stable Caplet ID."), }) .strict() .superRefine((config, ctx) => { @@ -815,6 +956,42 @@ function configSchemaFor( } } } + + for (const [server, rawValue] of Object.entries(config.cliTools)) { + const raw = rawValue as ConfigSchemaCliToolsValue; + const duplicateBackend = config.mcpServers[server] + ? "mcpServers" + : config.openapiEndpoints[server] + ? "openapiEndpoints" + : config.graphqlEndpoints[server] + ? "graphqlEndpoints" + : config.httpApis[server] + ? "httpApis" + : undefined; + if (duplicateBackend) { + ctx.addIssue({ + code: "custom", + path: ["cliTools", server], + message: `Caplet ID ${server} is already used by ${duplicateBackend}`, + }); + } + if (!SERVER_ID_PATTERN.test(server)) { + ctx.addIssue({ + code: "custom", + path: ["cliTools", server], + message: "CLI tools ID must match ^[a-zA-Z0-9_-]{1,64}$", + }); + } + for (const actionName of Object.keys(raw.actions)) { + if (!SERVER_ID_PATTERN.test(actionName)) { + ctx.addIssue({ + code: "custom", + path: ["cliTools", server, "actions", actionName], + message: "CLI action ID must match ^[a-zA-Z0-9_-]{1,64}$", + }); + } + } + } }); } @@ -823,16 +1000,18 @@ export const configFileSchema = configSchemaFor( publicOpenApiEndpointSchema, publicGraphQlEndpointSchema, publicHttpApiSchema, + publicCliToolsSchema, ); const normalizedConfigFileSchema = configSchemaFor( normalizedServerSchema, normalizedOpenApiEndpointSchema, normalizedGraphQlEndpointSchema, normalizedHttpApiSchema, + normalizedCliToolsSchema, ); export function configJsonSchema(): unknown { - return patchHttpApiJsonSchema({ + return patchConfigJsonSchema({ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplets-config.schema.json", title: "Caplets config", @@ -871,11 +1050,12 @@ export function loadConfig( Object.keys(config.mcpServers).length === 0 && Object.keys(config.openapiEndpoints).length === 0 && Object.keys(config.graphqlEndpoints).length === 0 && - Object.keys(config.httpApis).length === 0 + Object.keys(config.httpApis).length === 0 && + Object.keys(config.cliTools).length === 0 ) { throw new CapletsError( "CONFIG_INVALID", - "Caplets config must define at least one MCP server, OpenAPI endpoint, GraphQL endpoint, or HTTP API", + "Caplets config must define at least one MCP server, OpenAPI endpoint, GraphQL endpoint, HTTP API, or CLI tools backend", ); } return config; @@ -925,6 +1105,7 @@ function normalizeLocalPaths(input: ConfigInput, baseDir: string): ConfigInput { ...input, openapiEndpoints: normalizeEndpointPaths(input.openapiEndpoints, baseDir, normalizeOpenApiPath), graphqlEndpoints: normalizeEndpointPaths(input.graphqlEndpoints, baseDir, normalizeGraphQlPath), + cliTools: normalizeEndpointPaths(input.cliTools, baseDir, normalizeCliToolsPaths), }) as ConfigInput; } @@ -978,6 +1159,30 @@ function normalizeGraphQlPath( }; } +function normalizeCliToolsPaths( + endpoint: Record, + baseDir: string, +): Record { + const actions = isPlainObject(endpoint.actions) + ? Object.fromEntries( + Object.entries(endpoint.actions).map(([name, action]) => [ + name, + isPlainObject(action) + ? { + ...action, + cwd: normalizeLocalPath(action.cwd, baseDir), + } + : action, + ]), + ) + : endpoint.actions; + return { + ...endpoint, + cwd: normalizeLocalPath(endpoint.cwd, baseDir), + actions, + }; +} + function normalizeLocalPath(value: unknown, baseDir: string): unknown { if (typeof value !== "string" || !value || isAbsolute(value) || hasEnvReference(value)) { return value; @@ -1004,6 +1209,12 @@ function rejectUntrustedProjectExecutableBackends(input: ConfigInput, path: stri `Project config at ${path} cannot define httpApis; use trusted project Caplet files or user config`, ); } + if (input.cliTools && Object.keys(input.cliTools).length > 0) { + throw new CapletsError( + "CONFIG_INVALID", + `Project config at ${path} cannot define cliTools; use trusted project Caplet files or user config`, + ); + } return input; } @@ -1032,6 +1243,10 @@ function mergeConfigInputs(...inputs: Array): ConfigInp ...merged?.httpApis, ...input.httpApis, }, + cliTools: { + ...merged?.cliTools, + ...input.cliTools, + }, }; } return merged; @@ -1084,6 +1299,16 @@ export function parseConfig(input: unknown): CapletsConfig { }) as HttpApiConfig; } + const cliTools: Record = {}; + for (const [server, raw] of Object.entries(parsed.data.cliTools)) { + const interpolated = raw as ConfigSchemaCliToolsValue; + cliTools[server] = stripUndefined({ + ...interpolated, + server, + backend: "cli", + }) as CliToolsConfig; + } + return { version: parsed.data.version, options: { @@ -1094,6 +1319,7 @@ export function parseConfig(input: unknown): CapletsConfig { openapiEndpoints, graphqlEndpoints, httpApis, + cliTools, }; } @@ -1150,7 +1376,8 @@ function isPublicMetadataPath(path: string[]): boolean { (path[0] !== "mcpServers" && path[0] !== "openapiEndpoints" && path[0] !== "graphqlEndpoints" && - path[0] !== "httpApis") + path[0] !== "httpApis" && + path[0] !== "cliTools") ) { return false; } @@ -1165,7 +1392,7 @@ function hasEnvReference(value: string): boolean { return /\$\{[A-Za-z_][A-Za-z0-9_]*\}|\$env:[A-Za-z_][A-Za-z0-9_]*/.test(value); } -function patchHttpApiJsonSchema(schema: T): T { +function patchConfigJsonSchema(schema: T): T { const httpApiProperties = schemaPath>(schema, [ "properties", "httpApis", @@ -1180,6 +1407,16 @@ function patchHttpApiJsonSchema(schema: T): T { if (baseUrl) { baseUrl.format = "uri"; } + const cliToolsProperties = schemaPath>(schema, [ + "properties", + "cliTools", + "additionalProperties", + "properties", + ]); + const cliActions = nestedSchema>(cliToolsProperties, "actions"); + if (cliActions) { + cliActions.minProperties = 1; + } return schema; } diff --git a/src/generated-tool-input-schema.mjs b/src/generated-tool-input-schema.mjs index c44099eb..6835e27b 100644 --- a/src/generated-tool-input-schema.mjs +++ b/src/generated-tool-input-schema.mjs @@ -11,7 +11,7 @@ export const operations = [ export const generatedToolInputDescriptions = { operation: [ "Caplets wrapper operation to perform for this configured Caplet backend.", - "Use get_caplet to read the full Caplet card, check_backend to check any backend, check_mcp_server to check an MCP backend, list_tools or search_tools to discover downstream tools, get_tool to read a downstream input schema, and call_tool to run one downstream tool or OpenAPI operation.", + "Use get_caplet to read the full Caplet card, check_backend to check any backend, check_mcp_server to check an MCP backend, list_tools or search_tools to discover downstream tools, get_tool to read a downstream input schema, and call_tool to run one downstream tool, operation, action, or CLI command.", 'For call_tool, pass downstream inputs only inside the top-level "arguments" object.', ].join(" "), query: diff --git a/src/registry.ts b/src/registry.ts index 9d626f3d..8b805309 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -53,6 +53,13 @@ export type CapletServerDetail = { disabled: boolean; requestTimeoutMs: number; configuredActions: number; + } + | { + type: "cli"; + disabled: boolean; + timeoutMs: number; + maxOutputBytes: number; + configuredActions: number; }; mcpServer?: { transport: CapletServerConfig["transport"]; @@ -86,7 +93,8 @@ export class ServerRegistry { this.config.mcpServers[serverId] ?? this.config.openapiEndpoints[serverId] ?? this.config.graphqlEndpoints[serverId] ?? - this.config.httpApis[serverId]; + this.config.httpApis[serverId] ?? + this.config.cliTools[serverId]; return server?.disabled ? undefined : server; } @@ -147,6 +155,7 @@ export class ServerRegistry { ...Object.values(this.config.openapiEndpoints), ...Object.values(this.config.graphqlEndpoints), ...Object.values(this.config.httpApis), + ...Object.values(this.config.cliTools), ]; } } @@ -182,6 +191,16 @@ function backendDetail(server: CapletConfig): CapletServerDetail["backend"] { }; } + if (server.backend === "cli") { + return { + type: "cli", + disabled: server.disabled, + timeoutMs: server.timeoutMs, + maxOutputBytes: server.maxOutputBytes, + configuredActions: Object.keys(server.actions).length, + }; + } + return { type: "mcp", transport: server.transport, diff --git a/src/runtime.ts b/src/runtime.ts index d87262bc..d00eeb9d 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -13,6 +13,7 @@ import { TRUST_PROJECT_CAPLETS_ENV, isTrustedEnvEnabled, } from "./config.js"; +import { CliToolsManager } from "./cli-tools.js"; import { DownstreamManager } from "./downstream.js"; import { errorResult, toSafeError } from "./errors.js"; import { GraphQLManager } from "./graphql.js"; @@ -49,6 +50,7 @@ export class CapletsRuntime { private readonly openapi: OpenApiManager; private readonly graphql: GraphQLManager; private readonly http: HttpActionManager; + private readonly cli: CliToolsManager; private readonly tools = new Map(); private readonly paths: RuntimePaths; private readonly watchDebounceMs: number; @@ -71,6 +73,7 @@ export class CapletsRuntime { this.openapi = new OpenApiManager(this.registry, selectAuthOptions(options.authDir)); this.graphql = new GraphQLManager(this.registry, selectAuthOptions(options.authDir)); this.http = new HttpActionManager(this.registry, selectAuthOptions(options.authDir)); + this.cli = new CliToolsManager(this.registry); this.server = options.server ?? new McpServer({ @@ -170,6 +173,7 @@ export class CapletsRuntime { this.openapi.updateRegistry(nextRegistry); this.graphql.updateRegistry(nextRegistry); this.http.updateRegistry(nextRegistry); + this.cli.updateRegistry(nextRegistry); let invalidated = true; try { await this.invalidateChangedBackends(previousConfig, nextConfig); @@ -254,6 +258,7 @@ export class CapletsRuntime { this.openapi, this.graphql, this.http, + this.cli, ); } catch (error) { return errorResult(error); @@ -287,6 +292,9 @@ export class CapletsRuntime { if (before?.backend === "http" || after?.backend === "http" || !after) { this.http.invalidate(serverId); } + if (before?.backend === "cli" || after?.backend === "cli" || !after) { + this.cli.invalidate(serverId); + } } } @@ -408,6 +416,7 @@ function allCaplets(config: CapletsConfig): CapletConfig[] { ...Object.values(config.openapiEndpoints), ...Object.values(config.graphqlEndpoints), ...Object.values(config.httpApis), + ...Object.values(config.cliTools), ]; } @@ -420,7 +429,8 @@ function capletById(config: CapletsConfig, serverId: string): CapletConfig | und config.mcpServers[serverId] ?? config.openapiEndpoints[serverId] ?? config.graphqlEndpoints[serverId] ?? - config.httpApis[serverId] + config.httpApis[serverId] ?? + config.cliTools[serverId] ); } diff --git a/src/tools.ts b/src/tools.ts index 6c75d4ef..d47e9deb 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { CapletConfig } from "./config.js"; +import { CliToolsManager } from "./cli-tools.js"; import type { DownstreamManager } from "./downstream.js"; import { CapletsError } from "./errors.js"; import type { GraphQLManager } from "./graphql.js"; @@ -40,6 +41,7 @@ export async function handleServerTool( openapi?: OpenApiManager, graphql?: GraphQLManager, http?: HttpActionManager, + cli?: CliToolsManager, ): Promise { const parsed = validateOperationRequest(request, registry.config.options.maxSearchLimit); @@ -48,7 +50,7 @@ export async function handleServerTool( return jsonResult(registry.detail(server)); case "check_backend": return jsonResult( - await backendFor(server, downstream, openapi, graphql, http).check(server as never), + await backendFor(server, downstream, openapi, graphql, http, cli).check(server as never), ); case "check_mcp_server": if (server.backend !== "mcp") { @@ -59,7 +61,7 @@ export async function handleServerTool( } return jsonResult(await downstream.checkServer(server)); case "list_tools": { - const backend = backendFor(server, downstream, openapi, graphql, http); + const backend = backendFor(server, downstream, openapi, graphql, http, cli); const tools = await backend.listTools(server as never); return jsonResult({ server: server.server, @@ -67,7 +69,7 @@ export async function handleServerTool( }); } case "search_tools": { - const backend = backendFor(server, downstream, openapi, graphql, http); + const backend = backendFor(server, downstream, openapi, graphql, http, cli); const tools = await backend.listTools(server as never); const limit = parsed.limit ?? registry.config.options.defaultSearchLimit; return jsonResult({ @@ -77,12 +79,12 @@ export async function handleServerTool( }); } case "get_tool": { - const backend = backendFor(server, downstream, openapi, graphql, http); + const backend = backendFor(server, downstream, openapi, graphql, http, cli); const tool = await backend.getTool(server as never, parsed.tool); return jsonResult({ server: server.server, tool }); } case "call_tool": { - const backend = backendFor(server, downstream, openapi, graphql, http); + const backend = backendFor(server, downstream, openapi, graphql, http, cli); if (parsed.fields === undefined) { return backend.callTool(server as never, parsed.tool, parsed.arguments); } @@ -253,6 +255,7 @@ function backendFor( openapi?: OpenApiManager, graphql?: GraphQLManager, http?: HttpActionManager, + cli?: CliToolsManager, ) { if (server.backend === "mcp") { return { @@ -294,6 +297,19 @@ function backendFor( search: (...args: Parameters) => http.search(...args), }; } + if (server.backend === "cli") { + if (!cli) { + throw new CapletsError("INTERNAL_ERROR", "CLI tools manager is not configured"); + } + return { + check: (...args: Parameters) => cli.checkTools(...args), + listTools: (...args: Parameters) => cli.listTools(...args), + getTool: (...args: Parameters) => cli.getTool(...args), + callTool: (...args: Parameters) => cli.callTool(...args), + compact: (...args: Parameters) => cli.compact(...args), + search: (...args: Parameters) => cli.search(...args), + }; + } if (!openapi) { throw new CapletsError("INTERNAL_ERROR", "OpenAPI manager is not configured"); } diff --git a/test/author-cli.test.ts b/test/author-cli.test.ts new file mode 100644 index 00000000..29dad45a --- /dev/null +++ b/test/author-cli.test.ts @@ -0,0 +1,88 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { authorCliCaplet } from "../src/cli/author.js"; +import { validateCapletFile } from "../src/caplet-files.js"; +import { runCli } from "../src/cli.js"; + +describe("CLI Caplet authoring", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("generates a valid repo workflow Caplet to stdout text", () => { + const repo = tempRepo({ + packageManager: "pnpm@11.0.9", + scripts: { test: "vitest run", lint: "oxlint ." }, + }); + + const result = authorCliCaplet("repo-tools", { repo, include: "git,package" }); + + expect(result.path).toBeUndefined(); + expect(result.text).toContain("cliTools:"); + expect(result.text).toContain("git_status:"); + expect(result.text).toContain("package_test:"); + const path = join(repo, "CAPLET.md"); + writeFileSync(path, result.text); + expect(() => validateCapletFile(path)).not.toThrow(); + }); + + it("writes generated Caplets to an explicit output path", () => { + const repo = tempRepo({ scripts: { build: "tsc" } }); + const output = join(repo, "repo-tools.md"); + + const result = authorCliCaplet("repo-tools", { repo, include: "package", output }); + + expect(result.path).toBe(output); + expect(existsSync(output)).toBe(true); + expect(readFileSync(output, "utf8")).toContain("package_build:"); + }); + + it("supports git and gh single command templates", () => { + const repo = tempRepo({}); + + expect(authorCliCaplet("git-tools", { repo, command: "git", include: "" }).text).toContain( + "git_current_branch:", + ); + expect(authorCliCaplet("gh-tools", { repo, command: "gh", include: "" }).text).toContain( + "gh_pr_status:", + ); + }); + + it("is exposed through caplets author cli", async () => { + const repo = tempRepo({ scripts: { test: "vitest run" } }); + let output = ""; + + await runCli(["author", "cli", "repo-tools", "--repo", repo, "--include", "package"], { + writeOut: (value) => { + output += value; + }, + }); + + expect(output).toContain("cliTools:"); + expect(output).toContain("package_test:"); + }); + + function tempRepo(packageJson: { packageManager?: string; scripts?: Record }) { + const dir = mkdtempSync(join(tmpdir(), "caplets-author-")); + dirs.push(dir); + writeFileSync( + join(dir, "package.json"), + JSON.stringify( + { + name: "fixture", + ...packageJson, + }, + null, + 2, + ), + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), ""); + return dir; + } +}); diff --git a/test/cli-tools.test.ts b/test/cli-tools.test.ts new file mode 100644 index 00000000..bf8703d2 --- /dev/null +++ b/test/cli-tools.test.ts @@ -0,0 +1,224 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { CliToolsManager } from "../src/cli-tools.js"; +import { parseConfig } from "../src/config.js"; +import { CapletsError } from "../src/errors.js"; +import { ServerRegistry } from "../src/registry.js"; +import { handleServerTool } from "../src/tools.js"; +import { DownstreamManager } from "../src/downstream.js"; + +describe("CliToolsManager", () => { + const dirs: string[] = []; + + afterEach(() => { + for (const dir of dirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("lists, searches, and reads CLI action tools", async () => { + const { config, caplet } = cliConfig(); + const registry = new ServerRegistry(config); + const manager = new CliToolsManager(registry); + + expect(await manager.checkTools(caplet)).toMatchObject({ + server: "local", + status: "available", + toolCount: 2, + }); + const tools = await manager.listTools(caplet); + expect(tools.map((tool) => tool.name)).toEqual(["echo_json", "fail"]); + expect(manager.search(caplet, tools, "echo", 5)).toMatchObject([{ tool: "echo_json" }]); + expect(await manager.getTool(caplet, "echo_json")).toMatchObject({ + name: "echo_json", + inputSchema: expect.objectContaining({ type: "object" }), + annotations: { readOnlyHint: true }, + }); + }); + + it("spawns commands without a shell and returns parsed JSON output", async () => { + const { config, caplet } = cliConfig(); + const manager = new CliToolsManager(new ServerRegistry(config)); + + const result = await manager.callTool(caplet, "echo_json", { message: "hello" }); + + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + exitCode: 0, + json: { message: "hello" }, + }); + }); + + it("returns non-zero exits as tool errors with stdout and stderr", async () => { + const { config, caplet } = cliConfig(); + const manager = new CliToolsManager(new ServerRegistry(config)); + + const result = await manager.callTool(caplet, "fail", {}); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + exitCode: 7, + stdout: "out\n", + stderr: "err\n", + }); + }); + + it("validates basic input schemas before spawning", async () => { + const { config, caplet } = cliConfig(); + const manager = new CliToolsManager(new ServerRegistry(config)); + + await expect(manager.callTool(caplet, "echo_json", {})).rejects.toMatchObject({ + code: "REQUEST_INVALID", + }); + await expect(manager.callTool(caplet, "echo_json", { message: 42 })).rejects.toMatchObject({ + code: "REQUEST_INVALID", + }); + }); + + it("enforces output byte limits and timeouts", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-")); + dirs.push(dir); + const config = parseConfig({ + cliTools: { + local: { + name: "Local CLI", + description: "Run local CLI fixtures.", + maxOutputBytes: 8, + actions: { + large: { + command: process.execPath, + args: ["-e", "process.stdout.write('x'.repeat(32))"], + }, + slow: { + command: process.execPath, + args: ["-e", "setTimeout(() => {}, 1000)"], + timeoutMs: 10, + }, + }, + }, + }, + }); + const caplet = config.cliTools.local!; + const manager = new CliToolsManager(new ServerRegistry(config)); + + await expect(manager.callTool(caplet, "large", {})).rejects.toMatchObject({ + code: "DOWNSTREAM_TOOL_ERROR", + }); + await expect(manager.callTool(caplet, "slow", {})).rejects.toMatchObject({ + code: "TOOL_CALL_TIMEOUT", + }); + }); + + it("routes through handleServerTool with field selection", async () => { + const { config, caplet } = cliConfig(); + const registry = new ServerRegistry(config); + const manager = new CliToolsManager(registry); + + const result = await handleServerTool( + caplet, + { + operation: "call_tool", + tool: "echo_json", + arguments: { message: "hello" }, + fields: ["json.message"], + }, + registry, + new DownstreamManager(registry), + undefined, + undefined, + undefined, + manager, + ); + + expect(result.structuredContent).toEqual({ json: { message: "hello" } }); + }); + + it("parses CLI tools config", () => { + const { config } = cliConfig(); + expect(config.cliTools.local).toMatchObject({ + backend: "cli", + timeoutMs: 60000, + maxOutputBytes: 1000000, + actions: { + echo_json: { + command: process.execPath, + }, + }, + }); + expect(() => + parseConfig({ + mcpServers: { + local: { + name: "MCP", + description: "Local MCP server fixture.", + command: "node", + }, + }, + cliTools: { + local: { + name: "Duplicate", + description: "Duplicate CLI tools fixture.", + actions: { echo: { command: "node" } }, + }, + }, + }), + ).toThrow(CapletsError); + }); + + function cliConfig() { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-")); + dirs.push(dir); + const script = join(dir, "echo.mjs"); + writeFileSync( + script, + [ + "const message = process.argv[2];", + "if (message === 'fail') { console.log('out'); console.error('err'); process.exit(7); }", + "console.log(JSON.stringify({ message }));", + ].join("\n"), + ); + const config = parseConfig({ + cliTools: { + local: { + name: "Local CLI", + description: "Run local CLI fixtures.", + actions: { + echo_json: { + description: "Echo one JSON message.", + command: process.execPath, + args: [script, "$input.message"], + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + outputSchema: { + type: "object", + properties: { + exitCode: { type: "integer" }, + stdout: { type: "string" }, + stderr: { type: "string" }, + elapsedMs: { type: "number" }, + json: { + type: "object", + properties: { message: { type: "string" } }, + }, + }, + }, + output: { type: "json" }, + annotations: { readOnlyHint: true }, + }, + fail: { + description: "Return a non-zero exit.", + command: process.execPath, + args: [script, "fail"], + }, + }, + }, + }, + }); + return { config, caplet: config.cliTools.local! }; + } +}); diff --git a/test/config.test.ts b/test/config.test.ts index f2d26ff0..dc91dcee 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1060,6 +1060,8 @@ describe("config", () => { expect(JSON.parse(readFileSync("schemas/caplet.schema.json", "utf8"))).toEqual(capletSchema); expect(findHttpActionsSchema(configSchema)?.minProperties).toBe(1); expect(findHttpActionsSchema(capletSchema)?.minProperties).toBe(1); + expect(findCliActionsSchema(configSchema)?.minProperties).toBe(1); + expect(findCliActionsSchema(capletSchema)?.minProperties).toBe(1); }); it("rejects unsupported versions and unknown keys", () => { @@ -1250,6 +1252,18 @@ function findHttpActionsSchema(value: unknown): { minProperties?: number } | und ); } +function findCliActionsSchema(value: unknown): { minProperties?: number } | undefined { + return ( + schemaPath(value, [ + "properties", + "cliTools", + "additionalProperties", + "properties", + "actions", + ]) ?? schemaPath(value, ["properties", "cliTools", "properties", "actions"]) + ); +} + function schemaPath(value: unknown, path: string[]): T | undefined { let current = value; for (const segment of path) { diff --git a/test/openapi.test.ts b/test/openapi.test.ts index 584ef080..b3b5db0d 100644 --- a/test/openapi.test.ts +++ b/test/openapi.test.ts @@ -647,6 +647,7 @@ describe("native OpenAPI Caplets", () => { openapiEndpoints: { remote: endpoint }, graphqlEndpoints: {}, httpApis: {}, + cliTools: {}, }); const openapi = new OpenApiManager(registry, { authDir }); diff --git a/test/registry.test.ts b/test/registry.test.ts index 3322bc83..938568d3 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -57,6 +57,15 @@ describe("registry", () => { }, }, }, + cliTools: { + repo: { + name: "Repo CLI", + description: "Run curated repository CLI workflows.", + actions: { + status: { command: "git", args: ["status", "--short"] }, + }, + }, + }, }); const registry = new ServerRegistry(config); expect(registry.enabledServers().map((server) => server.server)).toEqual([ @@ -65,6 +74,7 @@ describe("registry", () => { "users", "catalog", "status", + "repo", ]); expect(registry.get("disabled")).toBeUndefined(); expect(registry.get("status")?.backend).toBe("http"); @@ -139,5 +149,22 @@ describe("registry", () => { }, }); expect(JSON.stringify(httpDetail)).not.toContain("secret-http"); + + const cliDescription = capabilityDescription(config.cliTools.repo!); + expect(cliDescription).toContain("CLI tools backend"); + expect(cliDescription).toContain('"operation":"check_backend"'); + const cliDetail = registry.detail(config.cliTools.repo!); + expect(cliDetail).toEqual({ + caplet: "repo", + name: "Repo CLI", + description: "Run curated repository CLI workflows.", + backend: { + type: "cli", + disabled: false, + timeoutMs: 60000, + maxOutputBytes: 1000000, + configuredActions: 1, + }, + }); }); }); diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 5ea8fe44..6b8e4d48 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -66,6 +66,35 @@ describe("CapletsRuntime", () => { await runtime.close(); }); + it("registers CLI tools Caplets", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + cliTools: { + repo: { + name: "Repo CLI", + description: "Run curated repository CLI workflows.", + actions: { + status: { + command: process.execPath, + args: ["--version"], + }, + }, + }, + }, + }); + dirs.push(dir); + const server = mockServer(); + const runtime = new CapletsRuntime({ configPath, projectConfigPath, server }); + + expect(runtime.registeredToolIds()).toEqual(["repo"]); + expect(server.registerTool).toHaveBeenCalledWith( + "repo", + expect.objectContaining({ title: "Repo CLI" }), + expect.any(Function), + ); + + await runtime.close(); + }); + it("adds, updates, and removes tools across successful reloads", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { From cf85c5da78e4c23667e74a088fb8c66c19b0be80 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Thu, 14 May 2026 20:14:42 -0400 Subject: [PATCH 2/2] fix: address cli tools review feedback --- README.md | 4 ++-- caplets/repo-cli/CAPLET.md | 2 -- src/cli-tools.ts | 35 ++++++++++++++++----------- src/cli/author.ts | 11 ++++++--- test/author-cli.test.ts | 9 +++---- test/cli-tools.test.ts | 49 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 83 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index a6ab928a..df8b869f 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ exposed two ways: direct flat MCP aggregation versus Caplets progressive disclos | Initial Agent Surface | Direct Flat MCP | Caplets | Reduction | | ------------------------- | ----------------: | -----------: | ------------: | | Visible tools | 106 | 3 | 97.2% fewer | -| Serialized MCP payload | 32,090 bytes | 8,358 bytes | 74.0% smaller | -| Approx. context surface | 8,023 tokens | 2,090 tokens | 5,933 fewer | +| Serialized MCP payload | 32,090 bytes | 8,400 bytes | 73.8% smaller | +| Approx. context surface | 8,023 tokens | 2,100 tokens | 5,923 fewer | | Top-level name collisions | 3 duplicate names | 0 | eliminated | The important part: Caplets does not remove access to the downstream tools. It hides diff --git a/caplets/repo-cli/CAPLET.md b/caplets/repo-cli/CAPLET.md index 676ed551..af0869ea 100644 --- a/caplets/repo-cli/CAPLET.md +++ b/caplets/repo-cli/CAPLET.md @@ -30,8 +30,6 @@ cliTools: - run - test timeoutMs: 120000 - annotations: - readOnlyHint: true --- # Repository CLI diff --git a/src/cli-tools.ts b/src/cli-tools.ts index 94748ac7..e86fe7ea 100644 --- a/src/cli-tools.ts +++ b/src/cli-tools.ts @@ -29,14 +29,19 @@ export class CliToolsManager { const startedAt = Date.now(); try { for (const action of actionsFor(config)) { - const cwd = interpolateString(action.cwd ?? config.cwd, {}, "cwd"); - if (cwd && !existsSync(cwd)) { - throw new CapletsError( - "CONFIG_INVALID", - `CLI cwd does not exist for ${config.server}/${action.name}`, - ); + const cwdTemplate = action.cwd ?? config.cwd; + if (cwdTemplate && !cwdTemplate.includes("$input")) { + const cwd = interpolateRequiredString(cwdTemplate, {}, "cwd"); + if (!existsSync(cwd)) { + throw new CapletsError( + "CONFIG_INVALID", + `CLI cwd does not exist for ${config.server}/${action.name}`, + ); + } + } + if (!action.command.includes("$input")) { + resolveCommandPath(action.command); } - resolveCommandPath(action.command); } this.registry.setStatus(config.server, "available"); return { @@ -79,7 +84,7 @@ export class CliToolsManager { try { const result = await spawnCommand(execution, controller.signal, () => Date.now() - startedAt); - const structured = parseStructuredResult(action, result); + const structured = parseStructuredResult(action, result, result.exitCode !== 0); return { content: [{ type: "text", text: JSON.stringify(structured, null, 2) }], structuredContent: structured, @@ -274,7 +279,7 @@ function validateInput(action: CliToolAction, input: Record): v } const required = Array.isArray(schema.required) ? schema.required : []; for (const key of required) { - if (typeof key === "string" && input[key] === undefined) { + if (typeof key === "string" && (input[key] === undefined || input[key] === null)) { throw new CapletsError("REQUEST_INVALID", `CLI tool ${action.name} requires input ${key}`); } } @@ -353,6 +358,7 @@ function spawnCommand( function parseStructuredResult( action: CliToolAction, result: SpawnResult, + tolerateInvalidJson = false, ): Record { const structured: Record = { exitCode: result.exitCode, @@ -365,6 +371,10 @@ function parseStructuredResult( try { structured.json = JSON.parse(result.stdout); } catch (error) { + if (tolerateInvalidJson) { + structured.jsonParseError = toSafeError(error); + return structured; + } throw new CapletsError( "DOWNSTREAM_PROTOCOL_ERROR", `CLI tool ${action.name} stdout was not valid JSON`, @@ -376,7 +386,7 @@ function parseStructuredResult( } function resolveCommandPath(command: string): string { - if (isAbsolute(command) || command.includes("/")) { + if (isAbsolute(command) || /[\\/]/.test(command)) { assertExecutable(command); return command; } @@ -416,10 +426,7 @@ function isExecutable(path: string): boolean { } function isAbortError(error: unknown): boolean { - return ( - error instanceof Error && - (error.name === "AbortError" || error.message.toLowerCase().includes("abort")) - ); + return error instanceof Error && error.name === "AbortError"; } function isPlainObject(value: unknown): value is Record { diff --git a/src/cli/author.ts b/src/cli/author.ts index 6c4e19f5..b838f3c5 100644 --- a/src/cli/author.ts +++ b/src/cli/author.ts @@ -32,7 +32,12 @@ export function authorCliCaplet( text: string; } { const repo = resolve(options.repo ?? process.cwd()); - const include = parseInclude(options.include); + const include = + options.include !== undefined + ? parseInclude(options.include) + : options.command + ? new Set() + : new Set(["git", "gh", "package"]); const actions: Record = {}; if (include.has("git") || options.command === "git") { @@ -74,7 +79,7 @@ export function authorCliCaplet( function parseInclude(value: string | undefined): Set { if (!value) { - return new Set(["git", "gh", "package"]); + return new Set(); } return new Set( value @@ -158,7 +163,7 @@ function packageActions( command: manager, args: ["run", script], cwd: repo, - annotations: { readOnlyHint: script !== "build" }, + annotations: { readOnlyHint: false }, ...(script === "test" || script === "verify" ? { timeoutMs: 120_000 } : {}), } as CliAction; } diff --git a/test/author-cli.test.ts b/test/author-cli.test.ts index 29dad45a..ead55ac3 100644 --- a/test/author-cli.test.ts +++ b/test/author-cli.test.ts @@ -27,6 +27,7 @@ describe("CLI Caplet authoring", () => { expect(result.text).toContain("cliTools:"); expect(result.text).toContain("git_status:"); expect(result.text).toContain("package_test:"); + expect(result.text).toContain("readOnlyHint: false"); const path = join(repo, "CAPLET.md"); writeFileSync(path, result.text); expect(() => validateCapletFile(path)).not.toThrow(); @@ -44,11 +45,11 @@ describe("CLI Caplet authoring", () => { }); it("supports git and gh single command templates", () => { - const repo = tempRepo({}); + const repo = tempRepo({ scripts: { test: "vitest run" } }); - expect(authorCliCaplet("git-tools", { repo, command: "git", include: "" }).text).toContain( - "git_current_branch:", - ); + const git = authorCliCaplet("git-tools", { repo, command: "git" }).text; + expect(git).toContain("git_current_branch:"); + expect(git).not.toContain("package_test:"); expect(authorCliCaplet("gh-tools", { repo, command: "gh", include: "" }).text).toContain( "gh_pr_status:", ); diff --git a/test/cli-tools.test.ts b/test/cli-tools.test.ts index bf8703d2..ba7b7c89 100644 --- a/test/cli-tools.test.ts +++ b/test/cli-tools.test.ts @@ -26,10 +26,10 @@ describe("CliToolsManager", () => { expect(await manager.checkTools(caplet)).toMatchObject({ server: "local", status: "available", - toolCount: 2, + toolCount: 3, }); const tools = await manager.listTools(caplet); - expect(tools.map((tool) => tool.name)).toEqual(["echo_json", "fail"]); + expect(tools.map((tool) => tool.name)).toEqual(["echo_json", "fail", "fail_json"]); expect(manager.search(caplet, tools, "echo", 5)).toMatchObject([{ tool: "echo_json" }]); expect(await manager.getTool(caplet, "echo_json")).toMatchObject({ name: "echo_json", @@ -38,17 +38,38 @@ describe("CliToolsManager", () => { }); }); + it("does not fail checks for runtime-templated commands", async () => { + const { config, caplet } = cliConfig(); + caplet.actions.templated = { + command: "$input.command", + cwd: "$input.cwd", + }; + const manager = new CliToolsManager(new ServerRegistry(config)); + + expect(await manager.checkTools(caplet)).toMatchObject({ + status: "available", + toolCount: 4, + }); + }); + it("spawns commands without a shell and returns parsed JSON output", async () => { const { config, caplet } = cliConfig(); const manager = new CliToolsManager(new ServerRegistry(config)); const result = await manager.callTool(caplet, "echo_json", { message: "hello" }); + const literal = "$(echo owned) && rm -rf /"; + const literalResult = await manager.callTool(caplet, "echo_json", { message: literal }); expect(result.isError).toBe(false); expect(result.structuredContent).toMatchObject({ exitCode: 0, json: { message: "hello" }, }); + expect(literalResult.isError).toBe(false); + expect(literalResult.structuredContent).toMatchObject({ + exitCode: 0, + json: { message: literal }, + }); }); it("returns non-zero exits as tool errors with stdout and stderr", async () => { @@ -65,6 +86,21 @@ describe("CliToolsManager", () => { }); }); + it("returns non-zero invalid JSON output as a tool error", async () => { + const { config, caplet } = cliConfig(); + const manager = new CliToolsManager(new ServerRegistry(config)); + + const result = await manager.callTool(caplet, "fail_json", {}); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toMatchObject({ + exitCode: 7, + stdout: "out\n", + stderr: "err\n", + jsonParseError: expect.objectContaining({ message: expect.any(String) }), + }); + }); + it("validates basic input schemas before spawning", async () => { const { config, caplet } = cliConfig(); const manager = new CliToolsManager(new ServerRegistry(config)); @@ -75,6 +111,9 @@ describe("CliToolsManager", () => { await expect(manager.callTool(caplet, "echo_json", { message: 42 })).rejects.toMatchObject({ code: "REQUEST_INVALID", }); + await expect(manager.callTool(caplet, "echo_json", { message: null })).rejects.toMatchObject({ + code: "REQUEST_INVALID", + }); }); it("enforces output byte limits and timeouts", async () => { @@ -215,6 +254,12 @@ describe("CliToolsManager", () => { command: process.execPath, args: [script, "fail"], }, + fail_json: { + description: "Return a non-zero exit with invalid JSON.", + command: process.execPath, + args: [script, "fail"], + output: { type: "json" }, + }, }, }, },