From f8c0f196722a34fbdad6372e8980c708d249e687 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 15 May 2026 00:01:04 -0400 Subject: [PATCH 1/2] feat: add project-first caplet workflows --- .changeset/project-first-caplets-add.md | 7 + README.md | 58 +- .../caplets-progressive-mcp-disclosure-prd.md | 30 +- .../2026-05-14-project-first-caplets-add.md | 194 ++++ src/caplet-files.ts | 24 +- src/cli.ts | 198 +++- src/cli/add.ts | 518 +++++++++ src/cli/author.ts | 6 +- src/cli/inspection.ts | 53 +- src/cli/install.ts | 255 ++++- src/config.ts | 147 ++- src/config/paths.ts | 5 - src/runtime.ts | 10 +- test/author-cli.test.ts | 261 ++++- test/cli.test.ts | 992 +++++++++++++++++- test/config.test.ts | 174 ++- test/runtime.test.ts | 31 + 17 files changed, 2793 insertions(+), 170 deletions(-) create mode 100644 .changeset/project-first-caplets-add.md create mode 100644 docs/superpowers/plans/2026-05-14-project-first-caplets-add.md create mode 100644 src/cli/add.ts diff --git a/.changeset/project-first-caplets-add.md b/.changeset/project-first-caplets-add.md new file mode 100644 index 00000000..d3182dd4 --- /dev/null +++ b/.changeset/project-first-caplets-add.md @@ -0,0 +1,7 @@ +--- +"caplets": minor +--- + +Add project-first Caplet authoring with `caplets add`, make `caplets install` write to `./.caplets` by default, and load project Caplets without an explicit trust gate. + +Project Caplets now override global Caplets with source and shadowing information surfaced through `caplets list`. Use `-g` or `--global` with `caplets add` and `caplets install` to write to the user Caplets root. diff --git a/README.md b/README.md index df8b869f..75bddb73 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,8 @@ This repository includes polished working examples under [`caplets/`](caplets/): - `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: +Install every example from a repo's `caplets/` directory into the current project's +`./.caplets` directory: ```sh caplets install spiritledsoftware/caplets @@ -348,22 +349,20 @@ caplets install spiritledsoftware/caplets github linear ``` `caplets install` accepts a GitHub `owner/repo` shorthand, a Git URL, or a local repository path. -It installs into your user Caplets root, which is `${XDG_CONFIG_HOME:-~/.config}/caplets` on Unix-like platforms, -`%APPDATA%\caplets` on Windows, or the parent directory of `CAPLETS_CONFIG` when that environment variable is set. -Existing Caplets are not overwritten unless `--force` is passed. +By default it writes to `./.caplets`, creating that directory when needed. Pass `-g` or +`--global` to write to your user Caplets root instead, which is +`${XDG_CONFIG_HOME:-~/.config}/caplets` on Unix-like platforms, `%APPDATA%\caplets` on Windows, +or the parent directory of `CAPLETS_CONFIG` when that environment variable is set. Existing +Caplets are not overwritten unless `--force` is passed. On Unix-like platforms, relative `XDG_CONFIG_HOME` and `XDG_STATE_HOME` values are ignored. -Caplets always loads user Caplet files from the user Caplets root. Project `./.caplets/config.json` -is still loaded as project config, but project Markdown Caplet files are executable -configuration and are ignored unless explicitly trusted: - -```sh -CAPLETS_TRUST_PROJECT_CAPLETS=1 caplets serve -``` - -Later sources override earlier ones in this order: user `config.json`, user Caplet files, -project `config.json`, and, only when trusted, project Caplet files. +Caplets loads user Caplet files from the user Caplets root and project Caplet files from the +current working directory's `./.caplets` directory. Later sources override earlier ones in this +order: user `config.json`, user Caplet files, project `config.json`, and project Caplet files. +That means a project-local Caplet can intentionally replace a user-level Caplet with the same ID. +Use `caplets list` to see each Caplet's winning source; when a project Caplet shadows a user-level +Caplet, the list output includes a warning naming the shadowed path. `caplets init` refuses to overwrite an existing config. To intentionally replace the file: @@ -597,12 +596,33 @@ supported inside `args`, `env`, and `cwd` strings. Caplets performs basic requir 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: +Generate and add a CLI Caplet manifest from a repository: ```sh -caplets author cli repo-tools --repo . --include git,gh,package --output - +caplets add cli repo-tools --repo . --include git,gh,package ``` +`caplets add` writes generated Markdown Caplet files to `./.caplets/.md` by default. +Pass `-g` or `--global` to write to the user Caplets root, `--print` to review the generated +manifest without writing, `--output ` for an explicit destination, or `--force` to overwrite +an existing destination file. + +Add MCP, OpenAPI, GraphQL, and HTTP API Caplets with the same destination options: + +```sh +caplets add mcp local-tools --command node --arg ./server.mjs +caplets add mcp remote-tools --url https://mcp.example.com/mcp --transport http --token-env MCP_TOKEN +caplets add openapi users --spec ./openapi.json --base-url https://api.example.com --token-env USERS_API_TOKEN +caplets add graphql catalog --endpoint-url https://api.example.com/graphql --schema ./schema.graphql +caplets add graphql catalog-live --endpoint-url https://api.example.com/graphql --introspection +caplets add http status-api --base-url https://api.example.com --action get_status:GET:/status/{service} +``` + +For `caplets add mcp`, use `--command` with repeated `--arg`, optional `--cwd`, and repeated +`--env KEY=VALUE` for stdio servers, or `--url` with `--transport http|sse` for remote servers. +For HTTP authentication, pass `--token-env ` so generated manifests reference `$env:ENV` +instead of embedding raw bearer tokens. + ### Authentication Remote servers can use: @@ -646,6 +666,10 @@ caplets list --all caplets list --json ``` +Human output includes a `source` column. JSON output includes each Caplet's `source`, `path`, and +`shadows` metadata. If a project source overrides a user source, human output prints a warning such +as `Warning: project Caplet github shadows global Caplet at /path/to/github.md`. + ### Optional Server Settings Every server can set: @@ -674,7 +698,7 @@ If your client starts the configured command directly, `caplets` without argumen starts the MCP server. `serve` is explicit and recommended for clarity. `caplets serve` watches the effective user config, project config, user Caplet files, and -trusted project Caplet files. Adding, editing, disabling, or removing a Caplet updates the +project Caplet files. Adding, editing, disabling, or removing a Caplet updates the top-level MCP tool list without restarting Caplets. When an MCP-backed Caplet changes or is removed, Caplets closes only that affected downstream connection; unrelated Caplets and their downstream connections keep running. diff --git a/docs/product/caplets-progressive-mcp-disclosure-prd.md b/docs/product/caplets-progressive-mcp-disclosure-prd.md index 37141e58..d7e50884 100644 --- a/docs/product/caplets-progressive-mcp-disclosure-prd.md +++ b/docs/product/caplets-progressive-mcp-disclosure-prd.md @@ -4,7 +4,7 @@ **Problem Statement**: MCP clients that connect directly to many servers receive a large, flat tool surface up front. This creates context bloat, weak tool selection, name-collision risk, and poor discoverability when an agent only needs to know which capability domain to inspect next. -**Proposed Solution**: Caplets is a local MCP server that reads downstream MCP server definitions, native OpenAPI endpoint definitions, native GraphQL endpoint definitions, and explicit HTTP API action definitions from `${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` on Unix-like platforms or `%APPDATA%\caplets\config.json` on Windows, user-owned Markdown Caplet files from the user Caplets root, project config from `./.caplets/config.json`, and explicitly trusted project Markdown Caplet files from `./.caplets`. It exposes each enabled Caplet as one top-level, skill-like MCP tool. Each generated Caplet tool uses the Caplet ID as the tool name and the configured `name`/`description` as its compact capability card, then progressively discloses the full Caplet card and the backing MCP tools, OpenAPI operations, GraphQL operations, or HTTP actions through operations such as `get_caplet`, `search_tools`, `list_tools`, `get_tool`, and `call_tool`. +**Proposed Solution**: Caplets is a local MCP server that reads downstream MCP server definitions, native OpenAPI endpoint definitions, native GraphQL endpoint definitions, explicit HTTP API action definitions, and explicit CLI tool/action definitions from `${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` on Unix-like platforms or `%APPDATA%\caplets\config.json` on Windows, user-owned Markdown Caplet files from the user Caplets root, project config from `./.caplets/config.json`, and project Markdown Caplet files from `./.caplets`. Project sources load by default and override global/user sources with the same Caplet ID, while inspection commands expose source metadata and shadow warnings. It exposes each enabled Caplet as one top-level, skill-like MCP tool. Each generated Caplet tool uses the Caplet ID as the tool name and the configured `name`/`description` as its compact capability card, then progressively discloses the full Caplet card and the backing MCP tools, OpenAPI operations, GraphQL operations, HTTP actions, or CLI tools/actions through operations such as `get_caplet`, `search_tools`, `list_tools`, `get_tool`, and `call_tool`. **Success Criteria**: @@ -25,13 +25,13 @@ ### Primary User Flow 1. User installs and configures Caplets as an MCP server in their MCP client. -2. User creates either `${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` on Unix-like platforms or `%APPDATA%\caplets\config.json` on Windows with downstream server, OpenAPI endpoint, GraphQL endpoint, or HTTP API action definitions and Caplets options, or Markdown Caplet files with exactly one executable backend. -3. Each downstream MCP server, OpenAPI endpoint, GraphQL endpoint, HTTP API, or Caplet file uses the supported backend configuration shape plus a required `description`. +2. User creates either `${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` on Unix-like platforms or `%APPDATA%\caplets\config.json` on Windows with downstream server, OpenAPI endpoint, GraphQL endpoint, HTTP API action, or CLI tool/action definitions and Caplets options, or Markdown Caplet files with exactly one executable backend. +3. Each downstream MCP server, OpenAPI endpoint, GraphQL endpoint, HTTP API, CLI tool/action set, or Caplet file uses the supported backend configuration shape plus a required `description`. 4. Client calls Caplets `tools/list` and sees one top-level tool per enabled downstream server, for example `linear`, `chrome-devtools`, and `context7`. 5. Agent chooses the relevant Caplet tool based on its skill-like tool name and description. 6. Agent calls that Caplet tool with an operation such as `get_caplet`, `search_tools`, `list_tools`, or `get_tool`. 7. Agent calls the same Caplet tool with `operation: "call_tool"`, an exact downstream tool name, and a JSON object of arguments. -8. Caplets forwards the request to that server's downstream MCP process or executes the selected OpenAPI, GraphQL, or explicit HTTP action and returns the result. +8. Caplets forwards the request to that server's downstream MCP process or executes the selected OpenAPI, GraphQL, explicit HTTP action, or explicit CLI action and returns the result. ### User Stories @@ -41,9 +41,11 @@ Acceptance Criteria: - Caplets reads `${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` on Unix-like platforms or `%APPDATA%\caplets\config.json` on Windows by default. - Caplets reads user-owned Markdown Caplet files from `${XDG_CONFIG_HOME:-~/.config}/caplets` on Unix-like platforms or `%APPDATA%\caplets` on Windows by default. -- Caplets reads project Markdown Caplet files from `./.caplets` only when `CAPLETS_TRUST_PROJECT_CAPLETS` is set to `1`, `true`, or `yes`. -- Caplets supports `mcpServers` for MCP backends, `openapiEndpoints` for native OpenAPI backends, `graphqlEndpoints` for native GraphQL backends, and `httpApis` for explicitly configured HTTP actions. -- Generated top-level tool names are unique across `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, `httpApis`, and Markdown Caplet files; duplicate IDs reject with `CONFIG_INVALID`. +- Caplets reads project Markdown Caplet files from `./.caplets` by default. +- Project config and project Markdown Caplet files override global/user sources with the same Caplet ID. +- `caplets list` and source inspection output expose the winning source and warn when a project source shadows a global/user source. +- Caplets supports `mcpServers` for MCP backends, `openapiEndpoints` for native OpenAPI backends, `graphqlEndpoints` for native GraphQL backends, `httpApis` for explicitly configured HTTP actions, and `cliTools` for explicitly configured CLI tools. +- Generated top-level tool names are unique across `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, `httpApis`, `cliTools`, and Markdown Caplet files; duplicate IDs reject with `CONFIG_INVALID`. - Every configured server requires a stable server key and non-empty `description`. - Caplets `tools/list` returns one generated top-level MCP tool per enabled server. - Each generated Caplet tool uses the configured server ID as the MCP tool name. @@ -79,6 +81,7 @@ Acceptance Criteria: - For OpenAPI-backed Caplets, `call_tool.arguments` uses grouped HTTP inputs: `path`, `query`, `header`, and `body`. - For GraphQL-backed Caplets, configured operation `call_tool.arguments` is the GraphQL variables object directly; auto-generated operation `call_tool.arguments` is the root field arguments object directly. - For HTTP action Caplets, `call_tool.arguments` is the action input object directly; path placeholders read top-level argument fields and configured request mappings can reference `$input.field` or `$input`. +- For CLI-backed Caplets, `call_tool.arguments` is the configured CLI action input object directly; Caplets maps those fields into configured argv, environment, stdin, or working-directory templates without shell interpolation. - `call_tool` requires the exact downstream tool name; Caplets does not use fuzzy matching, aliases, or auto-correction for execution. - The selected generated Caplet tool is the server namespace, so `call_tool` does not accept a `server` argument in MVP. - Caplets preserves downstream tool names exactly and does not support flattened namespaced identifiers such as `server.tool` in MVP. @@ -208,7 +211,7 @@ Requirements: - `mcpServers` is optional when one or more valid Markdown Caplet files are present. - `$schema` is optional and exists only for JSON Schema-aware editor validation. - `version` is optional for MVP and defaults to 1 when omitted. If present, MVP accepts only `1` and rejects unsupported versions with `CONFIG_INVALID`. -- Allowed top-level keys in MVP are `$schema`, `version`, `defaultSearchLimit`, `maxSearchLimit`, `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, and `httpApis`; unknown top-level keys are rejected with `CONFIG_INVALID`. +- Allowed top-level keys in MVP are `$schema`, `version`, `defaultSearchLimit`, `maxSearchLimit`, `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, `httpApis`, and `cliTools`; unknown top-level keys are rejected with `CONFIG_INVALID`. - The committed JSON Schema lives at `schemas/caplets-config.schema.json`, is generated from the Zod runtime config schema, and CI must fail when the committed schema drifts from the generated output. - Unknown keys inside each server config are rejected with `CONFIG_INVALID`, except fields that belong to the tracked standard MCP server schema plus Caplets-owned additions documented here. - `mcpServers` is the only accepted top-level server configuration key for plain JSON downstream MCP servers in MVP. @@ -223,7 +226,7 @@ Requirements: - `description` must be at most 1500 characters and is returned exactly as configured. - `description` may contain Markdown syntax because the primary reader is an LLM, but MVP treats it as opaque text: Caplets does not render, sanitize, transform, or interpret Markdown. - Caplets should reuse or closely track existing MCP server config validation semantics where practical rather than inventing a new dialect. -- Markdown Caplet files are executable configuration because their required `mcpServer` backend can start a downstream server. User Caplet files are trusted by location. Project Caplet files require explicit process-level trust through `CAPLETS_TRUST_PROJECT_CAPLETS`. +- Markdown Caplet files are executable configuration because they require exactly one backend among `mcpServer`, `openapiEndpoint`, `graphqlEndpoint`, `httpApi`, or `cliTools`, and some backends can start downstream processes. User Caplet files are trusted by location, and project Caplet files load by default from the current project's `./.caplets` directory. When a project Caplet shadows a global/user Caplet, CLI inspection must make the winning project source and shadowed source visible. - Each server config must define exactly one connection shape: - Stdio: `command` is required; optional `args`, `env`, and `cwd` are allowed. - Remote: `url` is required and `transport` must be `http` or `sse`. @@ -264,6 +267,7 @@ Requirements: - OpenAPI backends support local `specPath` or remote `specUrl`, explicit `auth`, optional `baseUrl`, and native HTTP execution for standard OpenAPI methods. - GraphQL backends support local `schemaPath`, remote `schemaUrl`, or endpoint introspection, configured operations, auto-generated query/mutation tools, explicit `auth`, and native GraphQL HTTP execution. - HTTP action backends support explicit `httpApis` config or Markdown `httpApi` frontmatter with `baseUrl`, `auth`, and one or more named actions. Each action defines `method`, `path`, optional `description`, optional `inputSchema`, and optional `query`, `headers`, and `jsonBody` mappings. +- CLI backends support explicit `cliTools` config or Markdown `cliTools` frontmatter with one or more named actions. Each action defines a command, optional fixed args, optional description, optional inputSchema, optional argument/environment/stdin mappings, optional cwd, timeout, and output byte limits; execution must avoid shell evaluation unless explicitly modeled as a command executable. - HTTP action `query` and `headers` mappings must resolve to object maps whose values are strings, numbers, or booleans. HTTP action `jsonBody` mappings support literal values, nested arrays/objects, `$input.field` references, and `$input` for the complete argument object. HTTP action paths may contain `{field}` placeholders resolved from top-level arguments. - HTTP action requests require HTTPS except loopback URLs, reject origin-changing paths and redirects, reject managed configured headers, enforce timeouts and response byte limits, and return structured `{ status, statusText, headers, body, elapsedMs }` content with `isError` set for non-2xx status. - OpenCode's `mcp` config shape is a compatibility reference, not Caplets' native MVP shape: OpenCode uses local/remote entries under `mcp`, local `command` as an array, `environment` instead of `env`, and `{env:NAME}`-style interpolation in its broader config system. Automatic OpenCode config import/normalization is deferred. @@ -298,6 +302,7 @@ Downstream interactions required for MVP: - Resolve configured bearer/header auth and stored OAuth tokens before remote HTTP/SSE operations. - Call downstream `tools/list` for selected servers. - Call downstream `tools/call` for `operation: "call_tool"` inside a generated Caplet tool. +- Execute selected OpenAPI operations, GraphQL operations, HTTP actions, and CLI actions for `operation: "call_tool"` inside a generated Caplet tool. - Refresh stale downstream tool metadata before `get_tool` and `call_tool` exact-name resolution according to `toolCacheTtlMs`. - Require downstream tools to be present in fresh-enough `tools/list` metadata before forwarding `call_tool`. - Preserve downstream schemas and result content. @@ -374,6 +379,7 @@ Core modules: - `openapiEndpoints: Record` - `graphqlEndpoints: Record` - `httpApis: Record` +- `cliTools: Record` `CapletServerConfig`: @@ -465,7 +471,7 @@ Core modules: - `description: string` - `tags?: string[]` - `body?: string` -- `backend: { type: "mcp"; transport: "stdio" | "http" | "sse"; disabled: boolean; startupTimeoutMs: number; callTimeoutMs: number; toolCacheTtlMs: number } | { type: "openapi"; disabled: boolean; requestTimeoutMs: number; operationCacheTtlMs: number; source: "specPath" | "specUrl" } | { type: "graphql"; disabled: boolean; requestTimeoutMs: number; operationCacheTtlMs: number; source: "schemaPath" | "schemaUrl" | "introspection"; configuredOperations: boolean } | { type: "http"; disabled: boolean; requestTimeoutMs: number; configuredActions: number }` +- `backend: { type: "mcp"; transport: "stdio" | "http" | "sse"; disabled: boolean; startupTimeoutMs: number; callTimeoutMs: number; toolCacheTtlMs: number } | { type: "openapi"; disabled: boolean; requestTimeoutMs: number; operationCacheTtlMs: number; source: "specPath" | "specUrl" } | { type: "graphql"; disabled: boolean; requestTimeoutMs: number; operationCacheTtlMs: number; source: "schemaPath" | "schemaUrl" | "introspection"; configuredOperations: boolean } | { type: "http"; disabled: boolean; requestTimeoutMs: number; configuredActions: number } | { type: "cli"; disabled: boolean; executionTimeoutMs: number; configuredActions: number }` - `mcpServer?: { transport: "stdio" | "http" | "sse"; disabled: boolean; startupTimeoutMs: number; callTimeoutMs: number; toolCacheTtlMs: number }` `CapletToolRef`: @@ -620,7 +626,7 @@ If no test runner exists yet, implementation must add one before claiming MVP co **MVP: Local progressive MCP gateway** - Read `${XDG_CONFIG_HOME:-~/.config}/caplets/config.json` on Unix-like platforms or `%APPDATA%\caplets\config.json` on Windows. -- Validate `mcpServers` config with required descriptions. +- Validate all supported backend config maps with required descriptions: `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, `httpApis`, and `cliTools`. - Require restart after config changes while keeping config loading, registry construction, and downstream client lifecycle modular enough for later hot reload. - Expose one generated top-level MCP tool per enabled downstream server. - Support server-scoped diagnostics, tool listing, lexical search, tool metadata lookup, and explicit tool invocation through each generated Caplet tool. @@ -650,7 +656,7 @@ If no test runner exists yet, implementation must add one before claiming MVP co ### Technical Risks -- **Config fragmentation**: MCP clients differ on config shape. MVP mitigates this by documenting and supporting exactly `mcpServers`. +- **Config fragmentation**: MCP clients differ on config shape. MVP mitigates this by documenting and supporting exactly the configured backend keys, including `mcpServers`, `openapiEndpoints`, `graphqlEndpoints`, `httpApis`, and `cliTools`. - **Client expectations**: Some clients may expect every downstream tool in `tools/list`. MVP intentionally exposes one generated server/caplet tool per downstream server instead. - **Lifecycle cost**: Keeping downstream stdio servers running improves freshness but consumes local resources and may surface startup failures earlier. MVP mitigates this with bounded startup, process reuse, status reporting, and partial failures. - **Unsafe downstream behavior**: Downstream tools may be destructive. MVP mitigates this by avoiding safety claims and preserving MCP client confirmation flows. diff --git a/docs/superpowers/plans/2026-05-14-project-first-caplets-add.md b/docs/superpowers/plans/2026-05-14-project-first-caplets-add.md new file mode 100644 index 00000000..7b89e8bb --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-project-first-caplets-add.md @@ -0,0 +1,194 @@ +# Project-First Caplets Add Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make project-local Caplets the default workflow by removing the project trust gate, making `add` and `install` write to `./.caplets` by default, and exposing source/shadowing information clearly. + +**Architecture:** Treat project `.caplets` as first-class project configuration loaded from the current working directory only. Project sources override global/user sources, but `caplets list` exposes source and shadowing information so users can see when project-local capabilities replace global ones. Keep generated Caplets as Markdown files and route all new scaffolding through `caplets add`. + +**Tech Stack:** TypeScript, Commander, Zod, Markdown Caplet files, Vitest, Node.js fs/path APIs. + +--- + +## Decisions Locked + +- Project Caplets load by default; remove `CAPLETS_TRUST_PROJECT_CAPLETS`. +- Project scope is current working directory only; do not walk parents or Git roots in v1. +- Project sources override global/user sources when IDs collide. +- `caplets list` shows source by default and warns when a Caplet shadows another source. +- `caplets add` and `caplets install` are project-first by default. +- `-g, --global` writes to the user Caplets root. +- `caplets add` replaces the public `caplets author cli` flow; keep `author cli` only as a temporary deprecated alias if compatibility is low-cost. +- Generated Caplets are Markdown files, not direct edits to `config.json`. + +--- + +## Files To Modify + +- `src/config/paths.ts`: remove project trust env constant and make project path helpers unconditional. +- `src/config.ts`: always load project Caplet files; add source/shadow metadata loader while keeping `loadConfig()` compatibility. +- `src/caplet-files.ts`: expose text validation for generated Caplet manifests if needed. +- `src/runtime.ts`: always watch project `.caplets`; remove trust checks from watched paths. +- `src/cli.ts`: add `add` commands, update `install` with `-g/--global`, keep/deprecate `author cli` if practical. +- `src/cli/install.ts`: support project-first destination selection. +- `src/cli/inspection.ts`: include source metadata and shadow warnings. +- `src/cli/author.ts` and/or new `src/cli/add.ts`: generate Caplet Markdown for CLI, MCP, OpenAPI, GraphQL, and HTTP. +- `test/config-paths.test.ts`: update removed trust behavior. +- `test/config.test.ts`: project loading, project override, and source/shadow metadata tests. +- `test/runtime.test.ts`: watcher behavior without trust gate. +- `test/cli.test.ts`: list source/warnings, install destination, add command wiring. +- `test/author-cli.test.ts` or new `test/add.test.ts`: add command generation and validation tests. +- `README.md`: document project-first add/install, removed trust gate, and source/shadowing behavior. + +--- + +## Task 1: Remove Project Trust Gate + +**Files:** + +- Modify: `src/config/paths.ts` +- Modify: `src/config.ts` +- Modify: `src/runtime.ts` +- Test: `test/config-paths.test.ts` +- Test: `test/config.test.ts` +- Test: `test/runtime.test.ts` + +- [ ] Remove `TRUST_PROJECT_CAPLETS_ENV` exports and all `isTrustedEnvEnabled()` usage related to project Caplet loading. +- [ ] Make `loadConfig(configPath, projectConfigPath)` always load project Markdown Caplet files from `resolveProjectCapletsRoot()`. +- [ ] Keep project config loading from `./.caplets/config.json` unchanged. +- [ ] Update runtime watcher setup so project `.caplets` is always included in watched paths. +- [ ] Remove tests that assert project Markdown files are ignored without env trust. +- [ ] Add tests that project Markdown files load without setting any environment variable. +- [ ] Run `pnpm test test/config-paths.test.ts test/config.test.ts test/runtime.test.ts`. + +--- + +## Task 2: Add Source And Shadow Metadata + +**Files:** + +- Modify: `src/config.ts` +- Modify: `src/caplet-files.ts` if source paths need to be returned from file discovery. +- Test: `test/config.test.ts` + +- [ ] Add source metadata types: `global-config`, `global-file`, `project-config`, and `project-file`. +- [ ] Add a loader such as `loadConfigWithSources()` that returns `{ config, sources, shadows }`. +- [ ] Keep `loadConfig()` as a wrapper returning only `config` so runtime behavior remains compatible. +- [ ] Track the winning source kind and path for each final Caplet ID. +- [ ] Track shadowed entries when a later source overrides an earlier source. +- [ ] Preserve the existing source precedence order: global config, global files, project config, project files. +- [ ] Add tests proving project files override global files. +- [ ] Add tests proving `sources` points at the winning project path and `shadows` includes the global path. +- [ ] Run `pnpm test test/config.test.ts`. + +--- + +## Task 3: Update `caplets list` Source UX + +**Files:** + +- Modify: `src/cli/inspection.ts` +- Modify: `src/cli.ts` +- Test: `test/cli.test.ts` + +- [ ] Change `caplets list` to use `loadConfigWithSources()`. +- [ ] Add a `source` column to human table output. +- [ ] Append short warning lines for shadowed Caplets, for example: `Warning: project Caplet github shadows global Caplet at /path/to/github.md`. +- [ ] Add `source`, `path`, and `shadows` fields to `caplets list --json` output. +- [ ] Keep disabled filtering behavior unchanged. +- [ ] Add tests for human source output. +- [ ] Add tests for JSON source/shadow output. +- [ ] Run `pnpm test test/cli.test.ts`. + +--- + +## Task 4: Make `caplets install` Project-First + +**Files:** + +- Modify: `src/cli.ts` +- Modify: `src/cli/install.ts` +- Test: `test/cli.test.ts` + +- [ ] Add `-g, --global` to `caplets install`. +- [ ] Make default destination `resolveProjectCapletsRoot()`. +- [ ] Use `resolveCapletsRoot(resolveConfigPath(envConfigPath()))` only when `--global` is passed. +- [ ] Preserve `--force` behavior. +- [ ] Ensure project `.caplets` is created when missing. +- [ ] Update install output text only if needed to make project/global destination clear. +- [ ] Add tests that default install writes under `./.caplets`. +- [ ] Add tests that `--global` writes under the user Caplets root. +- [ ] Run `pnpm test test/cli.test.ts`. + +--- + +## Task 5: Add `caplets add cli` + +**Files:** + +- Modify: `src/cli.ts` +- Modify: `src/cli/author.ts` or create `src/cli/add.ts` +- Test: `test/author-cli.test.ts` +- Test: `test/cli.test.ts` + +- [ ] Add public command `caplets add cli ` using the existing CLI Caplet generator. +- [ ] Make default output project `.caplets/.md`. +- [ ] Add `-g, --global` for user root output. +- [ ] Add `--print` for stdout-only review. +- [ ] Add `--output ` for explicit file output. +- [ ] Add `--force` to overwrite existing destination files. +- [ ] Validate generated Caplet text before writing. +- [ ] Keep `caplets author cli` as a deprecated alias that prints a warning to stderr, unless keeping it creates excessive complexity. +- [ ] Add tests for default project write, global write, print mode, output mode, and overwrite protection. +- [ ] Run `pnpm test test/author-cli.test.ts test/cli.test.ts`. + +--- + +## Task 6: Extend `caplets add` For MCP, OpenAPI, GraphQL, And HTTP + +**Files:** + +- Modify: `src/cli.ts` +- Modify: `src/cli/author.ts` or create focused modules under `src/cli/add/` +- Test: `test/author-cli.test.ts` or `test/add.test.ts` +- Test: `test/cli.test.ts` + +- [ ] Add `caplets add mcp ` supporting stdio via `--command`, repeated `--arg`, optional `--cwd`, optional `--env KEY=VALUE`, and remote via `--url --transport http|sse`. +- [ ] Add `caplets add openapi ` supporting `--spec `, optional `--base-url`, and auth flags. +- [ ] Add `caplets add graphql ` supporting `--endpoint-url` and exactly one of `--schema ` or `--introspection`. +- [ ] Add `caplets add http ` supporting `--base-url` and repeated `--action `. +- [ ] Share destination behavior with `add cli`: project default, `--global`, `--print`, `--output`, `--force`. +- [ ] Never embed raw bearer tokens; render `$env:ENV_NAME` from a `--token-env ` option. +- [ ] Validate generated Caplet text before printing or writing. +- [ ] Add tests for valid generation for each backend. +- [ ] Add tests for invalid connection shape, invalid action syntax, invalid schema/introspection combination, and overwrite protection. +- [ ] Run `pnpm test test/author-cli.test.ts test/cli.test.ts` or the new focused test file. + +--- + +## Task 7: Documentation And Verification + +**Files:** + +- Modify: `README.md` +- Modify: tests as needed. + +- [ ] Replace `caplets author cli` documentation with `caplets add cli`. +- [ ] Document `caplets add mcp`, `add openapi`, `add graphql`, and `add http`. +- [ ] Document that `caplets add` and `caplets install` write to `./.caplets` by default. +- [ ] Document `-g, --global` for user-level writes. +- [ ] Remove `CAPLETS_TRUST_PROJECT_CAPLETS` documentation. +- [ ] Document project override precedence and `caplets list` shadow warnings. +- [ ] Run targeted tests for changed areas. +- [ ] Run full verification: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm schema:check && pnpm test && pnpm build`. + +--- + +## Out Of Scope + +- Parent-directory or Git-root `.caplets` discovery. +- Remote marketplace search. +- AI-assisted generation. +- Live backend checks during `caplets add`. +- Direct edits to `config.json` from `caplets add`. +- Removing `caplets install` repo support. diff --git a/src/caplet-files.ts b/src/caplet-files.ts index 24d69ee0..e3bac5e8 100644 --- a/src/caplet-files.ts +++ b/src/caplet-files.ts @@ -578,7 +578,16 @@ export type CapletFileConfig = { cliTools?: Record; }; +export type CapletFileLoadResult = { + config: CapletFileConfig; + paths: Record; +}; + export function loadCapletFiles(root: string): CapletFileConfig | undefined { + return loadCapletFilesWithPaths(root)?.config; +} + +export function loadCapletFilesWithPaths(root: string): CapletFileLoadResult | undefined { if (!existsSync(root)) { return undefined; } @@ -588,6 +597,7 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { const graphqlEndpoints: Record = {}; const httpApis: Record = {}; const cliTools: Record = {}; + const paths: Record = {}; for (const candidate of discoverCapletFiles(root)) { if ( servers[candidate.id] || @@ -598,6 +608,7 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { ) { throw new CapletsError("CONFIG_INVALID", `Duplicate Caplet ID ${candidate.id} under ${root}`); } + paths[candidate.id] = candidate.path; const config = readCapletFile(candidate.path); if (isPlainObject(config) && config.backend === "openapi") { const { backend: _backend, ...endpoint } = config; @@ -623,11 +634,14 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { const hasCliTools = Object.keys(cliTools).length > 0; return hasServers || hasOpenApi || hasGraphQl || hasHttpApis || hasCliTools ? { - ...(hasServers ? { mcpServers: servers } : {}), - ...(hasOpenApi ? { openapiEndpoints } : {}), - ...(hasGraphQl ? { graphqlEndpoints } : {}), - ...(hasHttpApis ? { httpApis } : {}), - ...(hasCliTools ? { cliTools } : {}), + config: { + ...(hasServers ? { mcpServers: servers } : {}), + ...(hasOpenApi ? { openapiEndpoints } : {}), + ...(hasGraphQl ? { graphqlEndpoints } : {}), + ...(hasHttpApis ? { httpApis } : {}), + ...(hasCliTools ? { cliTools } : {}), + }, + paths, } : undefined; } diff --git a/src/cli.ts b/src/cli.ts index 770d5049..cc91a6ce 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,14 @@ import { Command, CommanderError } from "commander"; import { version as packageJsonVersion } from "../package.json"; +import { + addCliCaplet, + addGraphqlCaplet, + addHttpCaplet, + addMcpCaplet, + addOpenApiCaplet, + assertValidCapletId, +} from "./cli/add.js"; import { loginAuth, logoutAuth, listAuth } from "./cli/auth.js"; -import { authorCliCaplet } from "./cli/author.js"; import { initConfig } from "./cli/init.js"; import { formatCapletList, @@ -10,11 +17,23 @@ import { resolveCliConfigPaths, } from "./cli/inspection.js"; import { installCaplets } from "./cli/install.js"; -import { loadConfig, resolveCapletsRoot, resolveConfigPath } from "./config.js"; +import { + loadConfigWithSources, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectCapletsRoot, +} from "./config.js"; import { CapletsError } from "./errors.js"; export { initConfig, starterConfig } from "./cli/init.js"; export { installCaplets, normalizeGitRepo } from "./cli/install.js"; +export { + addCliCaplet, + addGraphqlCaplet, + addHttpCaplet, + addMcpCaplet, + addOpenApiCaplet, +} from "./cli/add.js"; type CliIO = { writeOut?: (value: string) => void; @@ -72,7 +91,7 @@ export function createProgram(io: CliIO = {}): Command { .option("--all", "include disabled Caplets") .option("--json", "print JSON output") .action((options: { all?: boolean; json?: boolean }) => { - const config = loadConfig(envConfigPath()); + const config = loadConfigWithSources(envConfigPath()); const rows = listCaplets(config, { includeDisabled: Boolean(options.all) }); if (options.json) { writeOut(`${JSON.stringify(rows, null, 2)}\n`); @@ -86,28 +105,34 @@ export function createProgram(io: CliIO = {}): Command { .description("Install Caplets from a repo's caplets directory.") .argument("", "local repo path, Git URL, or GitHub owner/repo") .argument("[caplets...]", "optional Caplet IDs to install") + .option("-g, --global", "install to the user Caplets root") .option("--force", "overwrite installed Caplets") - .action((repo: string, capletIds: string[], options: { force?: boolean }) => { + .action((repo: string, capletIds: string[], options: { global?: boolean; force?: boolean }) => { const result = installCaplets(repo, { capletIds, force: Boolean(options.force), - destinationRoot: resolveCapletsRoot(resolveConfigPath(envConfigPath())), + destinationRoot: options.global + ? resolveCapletsRoot(resolveConfigPath(envConfigPath())) + : resolveProjectCapletsRoot(), }); for (const caplet of result.installed) { writeOut(`Installed ${caplet.id} to ${caplet.destination}\n`); } }); - const author = program.command("author").description("Generate reviewable Caplet files."); + const add = program.command("add").description("Add generated Caplet files."); - author + add .command("cli") - .description("Generate a CLI tools Caplet.") + .description("Add 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", "-") + .option("-g, --global", "write to the user Caplets root") + .option("--print", "print generated Caplet text without writing a file") + .option("--output ", "output path") + .option("--force", "overwrite an existing destination file") .action( ( id: string, @@ -115,10 +140,19 @@ export function createProgram(io: CliIO = {}): Command { repo?: string; include?: string; command?: string; + global?: boolean; + print?: boolean; output?: string; + force?: boolean; }, ) => { - const result = authorCliCaplet(id, options); + assertValidCapletId(id); + const result = addCliCaplet(id, { + ...options, + destinationRoot: options.global + ? resolveCapletsRoot(resolveConfigPath(envConfigPath())) + : resolveProjectCapletsRoot(), + }); if (result.path) { writeOut(`Wrote CLI Caplet to ${result.path}\n`); return; @@ -127,6 +161,120 @@ export function createProgram(io: CliIO = {}): Command { }, ); + add + .command("mcp") + .description("Add an MCP backend Caplet.") + .argument("", "Caplet ID/display seed") + .option("--command ", "stdio command") + .option("--arg ", "stdio command argument", collect, []) + .option("--cwd ", "stdio working directory") + .option("--env ", "stdio environment variable", collect, []) + .option("--url ", "remote MCP server URL") + .option("--transport ", "remote transport: http or sse") + .option("--token-env ", "bearer token environment variable reference") + .option("-g, --global", "write to the user Caplets root") + .option("--print", "print generated Caplet text without writing a file") + .option("--output ", "output path") + .option("--force", "overwrite an existing destination file") + .action( + ( + id: string, + options: AddBackendCliOptions & { + command?: string; + arg?: string[]; + cwd?: string; + env?: string[]; + url?: string; + transport?: string; + tokenEnv?: string; + }, + ) => { + const result = addMcpCaplet(id, { + ...options, + destinationRoot: addDestinationRoot(options), + }); + writeAddResult(writeOut, "MCP", result); + }, + ); + + add + .command("openapi") + .description("Add an OpenAPI backend Caplet.") + .argument("", "Caplet ID/display seed") + .option("--spec ", "OpenAPI spec path or URL") + .option("--base-url ", "request base URL override") + .option("--token-env ", "bearer token environment variable reference") + .option("-g, --global", "write to the user Caplets root") + .option("--print", "print generated Caplet text without writing a file") + .option("--output ", "output path") + .option("--force", "overwrite an existing destination file") + .action( + ( + id: string, + options: AddBackendCliOptions & { spec?: string; baseUrl?: string; tokenEnv?: string }, + ) => { + const result = addOpenApiCaplet(id, { + ...options, + destinationRoot: addDestinationRoot(options), + }); + writeAddResult(writeOut, "OpenAPI", result); + }, + ); + + add + .command("graphql") + .description("Add a GraphQL backend Caplet.") + .argument("", "Caplet ID/display seed") + .option("--endpoint-url ", "GraphQL endpoint URL") + .option("--schema ", "GraphQL schema path or URL") + .option("--introspection", "load schema through endpoint introspection") + .option("--token-env ", "bearer token environment variable reference") + .option("-g, --global", "write to the user Caplets root") + .option("--print", "print generated Caplet text without writing a file") + .option("--output ", "output path") + .option("--force", "overwrite an existing destination file") + .action( + ( + id: string, + options: AddBackendCliOptions & { + endpointUrl?: string; + schema?: string; + introspection?: boolean; + tokenEnv?: string; + }, + ) => { + const result = addGraphqlCaplet(id, { + ...options, + destinationRoot: addDestinationRoot(options), + }); + writeAddResult(writeOut, "GraphQL", result); + }, + ); + + add + .command("http") + .description("Add an HTTP actions backend Caplet.") + .argument("", "Caplet ID/display seed") + .option("--base-url ", "HTTP API base URL") + .option("--action ", "HTTP action", collect, []) + .option("--token-env ", "bearer token environment variable reference") + .option("-g, --global", "write to the user Caplets root") + .option("--print", "print generated Caplet text without writing a file") + .option("--output ", "output path") + .option("--force", "overwrite an existing destination file") + .action( + ( + id: string, + options: AddBackendCliOptions & { baseUrl?: string; action?: string[]; tokenEnv?: string }, + ) => { + const result = addHttpCaplet(id, { + ...options, + destinationRoot: addDestinationRoot(options), + }); + writeAddResult(writeOut, "HTTP", result); + }, + ); + const config = program.command("config").description("Inspect Caplets config locations."); config @@ -198,3 +346,33 @@ export function createProgram(io: CliIO = {}): Command { function envConfigPath(): string | undefined { return process.env.CAPLETS_CONFIG?.trim() || undefined; } + +type AddBackendCliOptions = { + global?: boolean; + print?: boolean; + output?: string; + force?: boolean; +}; + +function collect(value: string, previous: string[]): string[] { + previous.push(value); + return previous; +} + +function addDestinationRoot(options: { global?: boolean }): string { + return options.global + ? resolveCapletsRoot(resolveConfigPath(envConfigPath())) + : resolveProjectCapletsRoot(); +} + +function writeAddResult( + writeOut: (value: string) => void, + label: string, + result: { path?: string; text: string }, +): void { + if (result.path) { + writeOut(`Wrote ${label} Caplet to ${result.path}\n`); + return; + } + writeOut(result.text); +} diff --git a/src/cli/add.ts b/src/cli/add.ts new file mode 100644 index 00000000..11fecda3 --- /dev/null +++ b/src/cli/add.ts @@ -0,0 +1,518 @@ +import { lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, parse, relative, resolve } from "node:path"; +import { validateCapletFile } from "../caplet-files.js"; +import { SERVER_ID_PATTERN } from "../config/validation.js"; +import { CapletsError, toSafeError } from "../errors.js"; +import { authorCliCaplet } from "./author.js"; + +type AddCliOptions = { + repo?: string; + include?: string; + command?: string; + output?: string; + print?: boolean; + force?: boolean; + destinationRoot: string; +}; + +type AddDestinationOptions = { + output?: string; + print?: boolean; + force?: boolean; + destinationRoot: string; +}; + +type AddMcpOptions = AddDestinationOptions & { + command?: string; + arg?: string[]; + cwd?: string; + env?: string[]; + url?: string; + transport?: string; + tokenEnv?: string; +}; + +type AddOpenApiOptions = AddDestinationOptions & { + spec?: string; + baseUrl?: string; + tokenEnv?: string; +}; + +type AddGraphqlOptions = AddDestinationOptions & { + endpointUrl?: string; + schema?: string; + introspection?: boolean; + tokenEnv?: string; +}; + +type AddHttpOptions = AddDestinationOptions & { + baseUrl?: string; + action?: string[]; + tokenEnv?: string; +}; + +export function addCliCaplet( + id: string, + options: AddCliOptions, +): { + path?: string; + text: string; +} { + assertValidCapletId(id); + + const text = authorCliCaplet(id, { ...options, output: "-" }).text; + validateCapletText(text); + + if (options.print) { + return { text }; + } + + const path = resolveAddOutputPath(id, options); + + writeCapletOutput(path, text); + return { path, text }; +} + +export function addMcpCaplet(id: string, options: AddMcpOptions): { path?: string; text: string } { + const hasCommand = Boolean(options.command); + const hasUrl = Boolean(options.url); + if (hasCommand === hasUrl) { + throw new CapletsError( + "REQUEST_INVALID", + "MCP Caplet requires exactly one connection shape: --command or --url", + ); + } + if (options.transport && !hasUrl) { + throw new CapletsError("REQUEST_INVALID", "--transport requires --url"); + } + if (options.transport && options.transport !== "http" && options.transport !== "sse") { + throw new CapletsError("REQUEST_INVALID", "--transport must be http or sse"); + } + const fields: YamlField[] = hasCommand + ? [ + ["transport", "stdio"], + ["command", options.command], + ["args", options.arg], + ["cwd", options.cwd], + ["env", parseEnv(options.env)], + ] + : [ + ["transport", options.transport ?? "http"], + ["url", options.url], + ["auth", authFromTokenEnv(options.tokenEnv)], + ]; + return writeGeneratedCaplet(id, "MCP", "mcpServer", fields, options); +} + +export function addOpenApiCaplet( + id: string, + options: AddOpenApiOptions, +): { path?: string; text: string } { + if (!options.spec) { + throw new CapletsError("REQUEST_INVALID", "OpenAPI Caplet requires --spec"); + } + return writeGeneratedCaplet( + id, + "OpenAPI", + "openapiEndpoint", + [ + [isUrlLike(options.spec) ? "specUrl" : "specPath", options.spec], + ["baseUrl", options.baseUrl], + ["auth", authFromTokenEnv(options.tokenEnv) ?? { type: "none" }], + ], + options, + ); +} + +export function addGraphqlCaplet( + id: string, + options: AddGraphqlOptions, +): { path?: string; text: string } { + if (!options.endpointUrl) { + throw new CapletsError("REQUEST_INVALID", "GraphQL Caplet requires --endpoint-url"); + } + if (Boolean(options.schema) === Boolean(options.introspection)) { + throw new CapletsError( + "REQUEST_INVALID", + "GraphQL Caplet requires exactly one of --schema or --introspection", + ); + } + const schemaField = options.schema + ? ([isUrlLike(options.schema) ? "schemaUrl" : "schemaPath", options.schema] as YamlField) + : (["introspection", true] as YamlField); + return writeGeneratedCaplet( + id, + "GraphQL", + "graphqlEndpoint", + [ + ["endpointUrl", options.endpointUrl], + schemaField, + ["auth", authFromTokenEnv(options.tokenEnv) ?? { type: "none" }], + ], + options, + ); +} + +export function addHttpCaplet( + id: string, + options: AddHttpOptions, +): { path?: string; text: string } { + if (!options.baseUrl) { + throw new CapletsError("REQUEST_INVALID", "HTTP Caplet requires --base-url"); + } + const actions = parseActions(options.action); + return writeGeneratedCaplet( + id, + "HTTP", + "httpApi", + [ + ["baseUrl", options.baseUrl], + ["auth", authFromTokenEnv(options.tokenEnv) ?? { type: "none" }], + ["actions", actions], + ], + options, + ); +} + +export function assertValidCapletId(id: string): void { + if (!SERVER_ID_PATTERN.test(id)) { + throw new CapletsError( + "REQUEST_INVALID", + `Invalid Caplet ID ${JSON.stringify(id)}; use 1-64 letters, numbers, underscores, or hyphens`, + ); + } +} + +function validateCapletText(text: string): void { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-")); + const path = join(dir, "CAPLET.md"); + try { + writeFileSync(path, text); + validateCapletFile(path); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +function writeGeneratedCaplet( + id: string, + label: string, + backend: string, + fields: YamlField[], + options: AddDestinationOptions, +): { path?: string; text: string } { + assertValidCapletId(id); + const path = options.print + ? resolvePrintOutputPath(id, options) + : resolveAddOutputPath(id, options); + const text = renderBackendCaplet(id, label, backend, renderLocalPaths(fields, dirname(path))); + validateCapletText(text); + if (options.print) { + return { text }; + } + writeCapletOutput(path, text); + return { path, text }; +} + +function writeCapletOutput(path: string, text: string): void { + try { + rejectUnsafeDestinationParents(path); + mkdirSync(dirname(path), { recursive: true }); + rejectUnsafeDestinationParents(path); + rejectSymlinkDestination(path); + writeFileSync(path, text); + } catch (error) { + if (error instanceof CapletsError) { + throw error; + } + if (isFsError(error, "EEXIST") || isFsError(error, "EISDIR")) { + throw new CapletsError( + "CONFIG_EXISTS", + `Output path ${path} already exists`, + toSafeError(error), + ); + } + throw new CapletsError( + "CONFIG_INVALID", + `Could not write Caplet file at ${path}`, + toSafeError(error), + ); + } +} + +function isFsError(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +function resolveAddOutputPath(id: string, options: AddDestinationOptions): string { + if (options.output) { + const outputStat = lstatIfExists(options.output); + if (outputStat?.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Output path ${options.output} is a symlink; remove it before writing`, + ); + } + if (outputStat?.isDirectory()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Output path ${options.output} is a directory; choose a file path`, + ); + } + if (outputStat && options.force && !outputStat.isFile()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Output path ${options.output} exists but is not a regular file; choose a file path`, + ); + } + if (outputStat && !options.force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet file already exists at ${options.output}; pass --force to overwrite it`, + ); + } + return options.output; + } + + const directoryPath = join(options.destinationRoot, id); + const directoryStat = lstatIfExists(directoryPath); + if (directoryStat) { + throw new CapletsError( + "CONFIG_EXISTS", + `Directory Caplet already exists at ${directoryPath}; remove it or choose --output`, + ); + } + + const path = join(options.destinationRoot, `${id}.md`); + const pathStat = lstatIfExists(path); + if (pathStat?.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet file at ${path} is a symlink; remove it before writing`, + ); + } + if (pathStat && !pathStat.isFile()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet file at ${path} exists but is not a regular file; choose --output`, + ); + } + if (pathStat && !options.force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet file already exists at ${path}; pass --force to overwrite it`, + ); + } + return path; +} + +function resolvePrintOutputPath(id: string, options: AddDestinationOptions): string { + return options.output ?? join(options.destinationRoot, `${id}.md`); +} + +function renderLocalPaths(fields: YamlField[], outputDir: string): YamlField[] { + return fields.map(([key, value]) => { + if ((key !== "specPath" && key !== "schemaPath") || typeof value !== "string") { + return [key, value]; + } + return [key, localPathRelativeToOutput(value, outputDir)]; + }); +} + +function localPathRelativeToOutput(path: string, outputDir: string): string { + const absolutePath = resolve(path); + const rendered = relative(outputDir, resolve(path)); + if (rendered.startsWith("../..") || rendered.startsWith("..\\..")) { + return absolutePath; + } + return rendered === "" ? "." : rendered; +} + +function rejectSymlinkDestination(path: string): void { + if (lstatIfExists(path)?.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet file at ${path} is a symlink; remove it before writing`, + ); + } +} + +function rejectUnsafeDestinationParents(path: string): void { + const parent = dirname(resolve(path)); + const root = parse(parent).root; + const segments = parent.slice(root.length).split(/[\\/]/).filter(Boolean); + let current = root; + + for (const segment of segments) { + current = join(current, segment); + const stats = lstatIfExists(current); + if (!stats) { + return; + } + if (stats.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Output parent path ${current} is a symlink; remove it before writing`, + ); + } + if (!stats.isDirectory()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Output parent path ${current} is not a directory; choose a file path`, + ); + } + } +} + +function lstatIfExists(path: string): ReturnType | undefined { + try { + return lstatSync(path); + } catch (error) { + if (isFsError(error, "ENOENT") || isFsError(error, "ENOTDIR")) { + return undefined; + } + throw new CapletsError( + "CONFIG_INVALID", + `Could not inspect output path ${path}`, + toSafeError(error), + ); + } +} + +type YamlField = [string, YamlValue | undefined]; +type YamlValue = string | number | boolean | string[] | YamlObject; +type YamlObject = { [key: string]: YamlValue | undefined }; + +function renderBackendCaplet( + id: string, + label: string, + backend: string, + fields: YamlField[], +): string { + const name = titleize(id); + const description = `${label} backend Caplet generated by caplets add.`; + const lines = [ + "---", + "$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json", + `name: ${yamlString(name)}`, + `description: ${yamlString(description)}`, + "tags:", + ` - ${yamlString(label.toLowerCase())}`, + `${backend}:`, + ]; + for (const [key, value] of fields) { + appendYaml(lines, key, value, 2); + } + lines.push("---", "", `# ${name}`, "", description, ""); + return lines.join("\n"); +} + +function appendYaml( + lines: string[], + key: string, + value: YamlValue | undefined, + indent: number, +): void { + if (value === undefined) { + return; + } + const padding = " ".repeat(indent); + if (Array.isArray(value)) { + if (value.length === 0) { + return; + } + lines.push(`${padding}${key}:`); + for (const entry of value) { + lines.push(`${padding} - ${yamlString(entry)}`); + } + return; + } + if (typeof value === "object") { + const entries = Object.entries(value).filter(([, nested]) => nested !== undefined); + if (entries.length === 0) { + return; + } + lines.push(`${padding}${key}:`); + for (const [nestedKey, nestedValue] of entries) { + appendYaml(lines, nestedKey, nestedValue, indent + 2); + } + return; + } + lines.push(`${padding}${key}: ${typeof value === "string" ? yamlString(value) : String(value)}`); +} + +function yamlString(value: string): string { + return JSON.stringify(value); +} + +function titleize(value: string): string { + return value + .split(/[-_]+/) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "); +} + +function parseEnv(values: string[] | undefined): Record | undefined { + if (!values?.length) { + return undefined; + } + const env: Record = {}; + for (const value of values) { + const separator = value.indexOf("="); + if (separator <= 0) { + throw new CapletsError( + "REQUEST_INVALID", + `Invalid --env value ${JSON.stringify(value)}; use KEY=VALUE`, + ); + } + env[value.slice(0, separator)] = value.slice(separator + 1); + } + return env; +} + +function authFromTokenEnv( + tokenEnv: string | undefined, +): { type: "bearer"; token: string } | undefined { + if (!tokenEnv) { + return undefined; + } + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tokenEnv)) { + throw new CapletsError( + "REQUEST_INVALID", + `Invalid environment variable name ${JSON.stringify(tokenEnv)}`, + ); + } + return { type: "bearer", token: `$env:${tokenEnv}` }; +} + +function parseActions( + values: string[] | undefined, +): Record { + if (!values?.length) { + throw new CapletsError("REQUEST_INVALID", "HTTP Caplet requires at least one --action"); + } + const actions: Record = {}; + for (const value of values) { + const match = /^([A-Za-z0-9_-]+):(GET|POST|PUT|PATCH|DELETE):(\/.*)$/.exec(value); + if (!match) { + throw new CapletsError( + "REQUEST_INVALID", + `Invalid --action value ${JSON.stringify(value)}; use name:METHOD:/path`, + ); + } + if (actions[match[1]!]) { + throw new CapletsError( + "REQUEST_INVALID", + `Duplicate HTTP action name ${JSON.stringify(match[1])}`, + ); + } + actions[match[1]!] = { method: match[2]!, path: match[3]! }; + } + return actions; +} + +function isUrlLike(value: string): boolean { + return /^https?:\/\//i.test(value); +} diff --git a/src/cli/author.ts b/src/cli/author.ts index b838f3c5..9207b534 100644 --- a/src/cli/author.ts +++ b/src/cli/author.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; +import { CapletsError } from "../errors.js"; type AuthorCliOptions = { repo?: string; @@ -60,7 +61,10 @@ export function authorCliCaplet( } if (Object.keys(actions).length === 0) { - throw new Error("No CLI actions could be generated for the requested options"); + throw new CapletsError( + "REQUEST_INVALID", + "No CLI actions could be generated for the requested options", + ); } const text = renderCaplet({ diff --git a/src/cli/inspection.ts b/src/cli/inspection.ts index b1c9ffe4..3cf86ac4 100644 --- a/src/cli/inspection.ts +++ b/src/cli/inspection.ts @@ -5,10 +5,10 @@ import { resolveConfigPath, resolveProjectCapletsRoot, resolveProjectConfigPath, - TRUST_PROJECT_CAPLETS_ENV, - isTrustedEnvEnabled, type CapletConfig, type CapletsConfig, + type ConfigSource, + type ConfigWithSources, } from "../config.js"; import type { ServerStatus } from "../registry.js"; @@ -19,6 +19,9 @@ type CapletListRow = { description: string; disabled: boolean; status: ServerStatus; + source: ConfigSource["kind"] | "unknown"; + path: string | null; + shadows: ConfigSource[]; }; type ConfigPaths = { @@ -29,14 +32,14 @@ type ConfigPaths = { projectRoot: string; authDir: string; envConfig: string | null; - projectCapletsTrusted: boolean; }; export function listCaplets( - config: CapletsConfig, + configWithSources: ConfigWithSources, options: { includeDisabled: boolean }, ): CapletListRow[] { - const rows = allCaplets(config) + const { config, sources, shadows } = configWithSources; + const rows: CapletListRow[] = allCaplets(config) .filter((server) => options.includeDisabled || !server.disabled) .map((server) => ({ server: server.server, @@ -45,6 +48,9 @@ export function listCaplets( description: server.description, disabled: server.disabled, status: initialServerStatus(server), + source: sources[server.server]?.kind ?? "unknown", + path: sources[server.server]?.path ?? null, + shadows: shadows[server.server] ?? [], })); return rows.sort((left, right) => left.server.localeCompare(right.server)); } @@ -68,10 +74,33 @@ export function formatCapletList(rows: CapletListRow[]): string { return "No configured Caplets found.\n"; } - return `${formatTable([ - ["server", "backend", "status", "name"], - ...rows.map((row) => [row.server, row.backend, row.status, row.name]), - ])}\n`; + const table = formatTable([ + ["server", "backend", "status", "source", "name"], + ...rows.map((row) => [row.server, row.backend, row.status, row.source, row.name]), + ]); + const warnings = rows.flatMap((row) => + row.shadows.map( + (shadow) => + `Warning: ${formatSourceKind(row.source)} Caplet ${row.server} shadows ${formatSourceKind( + shadow.kind, + )} Caplet at ${shadow.path}`, + ), + ); + + if (warnings.length === 0) { + return `${table}\n`; + } + return `${table}\n${warnings.join("\n")}\n`; +} + +function formatSourceKind(kind: ConfigSource["kind"] | "unknown"): string { + if (kind.startsWith("project")) { + return "project"; + } + if (kind.startsWith("global")) { + return "global"; + } + return kind; } export function resolveCliConfigPaths( @@ -88,7 +117,6 @@ export function resolveCliConfigPaths( projectRoot: resolveProjectCapletsRoot(), authDir: effectiveAuthDir, envConfig: envConfigPath ?? null, - projectCapletsTrusted: isTrustedProjectCapletsEnabled(), }; } @@ -102,15 +130,10 @@ export function formatConfigPaths(paths: ConfigPaths): string { `projectRoot: ${paths.projectRoot}`, `authDir: ${paths.authDir}`, `envConfig: ${paths.envConfig ?? "unset"}`, - `projectCapletsTrusted: ${paths.projectCapletsTrusted}`, ].join("\n") + "\n" ); } -function isTrustedProjectCapletsEnabled(): boolean { - return isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV]); -} - function formatTable(rows: string[][]): string { const firstRow = rows[0]; if (!firstRow) { diff --git a/src/cli/install.ts b/src/cli/install.ts index dd113676..ddc00a43 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -4,15 +4,17 @@ import { constants, cpSync, existsSync, + lstatSync, mkdirSync, mkdtempSync, rmSync, + type Stats, statSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join, relative } from "node:path"; +import { basename, dirname, join, parse, relative, resolve } from "node:path"; import { discoverCapletFiles, validateCapletFile } from "../caplet-files.js"; -import { resolveCapletsRoot, resolveConfigPath } from "../config.js"; +import { resolveProjectCapletsRoot } from "../config.js"; import { SERVER_ID_PATTERN } from "../config/validation.js"; import { CapletsError, toSafeError } from "../errors.js"; @@ -43,7 +45,7 @@ export function installCaplets( } const selectedIds = new Set(options.capletIds ?? []); - const destinationRoot = options.destinationRoot ?? resolveCapletsRoot(resolveConfigPath()); + const destinationRoot = options.destinationRoot ?? resolveProjectCapletsRoot(); const available = selectedIds.size === 0 ? discoverCapletFiles(sourceRoot) @@ -61,6 +63,7 @@ export function installCaplets( if (selected.length === 0) { throw new CapletsError("CONFIG_NOT_FOUND", `No Caplets found in ${sourceRoot}`); } + rejectDuplicateSourceIds(selected); for (const caplet of selected) { validateCapletFile(caplet.path); @@ -115,10 +118,10 @@ function resolveInstallSource(repo: string): { id: string; repoRoot: string; cle return { id: normalizedRepo, repoRoot, - cleanup: () => rmSync(repoRoot, { recursive: true, force: true }), + cleanup: () => removeInstallPath(repoRoot, `temporary install source ${repoRoot}`, true), }; } catch (error) { - rmSync(repoRoot, { recursive: true, force: true }); + removeInstallPath(repoRoot, `temporary install source ${repoRoot}`, true); throw new CapletsError("CONFIG_NOT_FOUND", `Could not clone repo ${repo}`, toSafeError(error)); } } @@ -136,28 +139,142 @@ function preflightInstallCaplets( options: { destinationRoot: string; force: boolean; repoRoot: string; sourceId: string }, ): InstallPlan[] { const plans = caplets.map((caplet) => installPlan(caplet, options)); + rejectUnsafeInstallParents(options.destinationRoot); + rejectUnsafeInstallRoot(options.destinationRoot); for (const plan of plans) { - if (existsSync(plan.destination) && !options.force) { - throw new CapletsError( - "CONFIG_EXISTS", - `Caplet ${plan.id} already exists at ${plan.destination}; pass --force to overwrite it`, - ); - } + rejectUnsafeInstallParents(plan.destination); + rejectUnsafeInstallDestination(plan, options.force); + rejectCrossKindDestinationCollision(plan, options.destinationRoot); } const writableRoot = nearestExistingParent(options.destinationRoot); - accessSync(writableRoot, constants.W_OK); + ensureWritable(writableRoot, `install destination parent ${writableRoot}`); for (const plan of plans) { - const destinationParent = existsSync(plan.destination) + const destinationParent = lstatIfExists(plan.destination) ? dirname(plan.destination) : nearestExistingParent(dirname(plan.destination)); - accessSync(destinationParent, constants.W_OK); + ensureWritable(destinationParent, `install destination parent ${destinationParent}`); } - mkdirSync(options.destinationRoot, { recursive: true, mode: 0o700 }); + makeInstallDirectory(options.destinationRoot); return plans; } +function rejectUnsafeInstallRoot(destinationRoot: string): void { + const stats = lstatIfExists(destinationRoot); + if (!stats) { + return; + } + if (stats.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Install destination ${destinationRoot} already exists and is a symlink`, + ); + } + if (!stats.isDirectory()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Install destination ${destinationRoot} already exists and is not a directory`, + ); + } +} + +function rejectUnsafeInstallParents(path: string): void { + const parent = dirname(resolve(path)); + const root = parse(parent).root; + const segments = parent.slice(root.length).split(/[\\/]/).filter(Boolean); + let current = root; + + for (const segment of segments) { + current = join(current, segment); + const stats = lstatIfExists(current); + if (!stats) { + return; + } + if (stats.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Install destination parent ${current} is a symlink; remove it before installing`, + ); + } + if (!stats.isDirectory()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Install destination parent ${current} is not a directory; choose another destination`, + ); + } + } +} + +function rejectUnsafeInstallDestination(plan: InstallPlan, force: boolean): void { + const stats = lstatIfExists(plan.destination); + if (!stats) { + return; + } + + rejectSymlinkDestination(plan.id, plan.destination, stats); + if (plan.kind === "file" && !stats.isFile()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Cannot install file Caplet ${plan.id}; destination already exists and is not a file at ${plan.destination}`, + ); + } + if (plan.kind === "directory" && !stats.isDirectory()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Cannot install directory Caplet ${plan.id}; destination already exists and is not a directory at ${plan.destination}`, + ); + } + if (!force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet ${plan.id} already exists at ${plan.destination}; pass --force to overwrite it`, + ); + } +} + +function rejectDuplicateSourceIds(caplets: Array<{ id: string; path: string }>): void { + const byId = new Map(); + for (const caplet of caplets) { + const existing = byId.get(caplet.id); + if (existing) { + throw new CapletsError( + "CONFIG_EXISTS", + `Source repo contains multiple Caplets with ID ${caplet.id}: ${existing} and ${caplet.path}`, + ); + } + byId.set(caplet.id, caplet.path); + } +} + +function rejectCrossKindDestinationCollision(plan: InstallPlan, destinationRoot: string): void { + if (plan.kind === "file") { + const directoryPath = join(destinationRoot, plan.id); + const directoryCapletPath = join(directoryPath, "CAPLET.md"); + const directoryStats = lstatIfExists(directoryPath); + const directoryCapletStats = lstatIfExists(directoryCapletPath); + rejectSymlinkDestination(plan.id, directoryPath, directoryStats); + rejectSymlinkDestination(plan.id, directoryCapletPath, directoryCapletStats); + if (directoryStats || directoryCapletStats) { + throw new CapletsError( + "CONFIG_EXISTS", + `Cannot install file Caplet ${plan.id}; directory Caplet destination already exists at ${directoryPath}`, + ); + } + return; + } + + const filePath = join(destinationRoot, `${plan.id}.md`); + const fileStats = lstatIfExists(filePath); + rejectSymlinkDestination(plan.id, filePath, fileStats); + if (fileStats) { + throw new CapletsError( + "CONFIG_EXISTS", + `Cannot install directory Caplet ${plan.id}; file Caplet destination already exists at ${filePath}`, + ); + } +} + function installPlan( caplet: { id: string; path: string }, options: { destinationRoot: string; repoRoot: string; sourceId: string }, @@ -179,21 +296,25 @@ function installPlan( } function installOneCaplet(plan: InstallPlan, options: { force: boolean }): InstallableCaplet { - if (existsSync(plan.destination)) { - if (!options.force) { + const stats = lstatIfExists(plan.destination); + if (stats) { + rejectSymlinkDestination(plan.id, plan.destination, stats); + if (!options.force || (plan.kind === "file" && !stats.isFile())) { throw new CapletsError( "CONFIG_EXISTS", `Caplet ${plan.id} already exists at ${plan.destination}; pass --force to overwrite it`, ); } - rmSync(plan.destination, { recursive: true, force: true }); + if (plan.kind === "directory" && !stats.isDirectory()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet ${plan.id} already exists at ${plan.destination}; pass --force to overwrite it`, + ); + } + removeInstallPath(plan.destination, `existing Caplet destination ${plan.destination}`, false); } - cpSync(plan.sourcePath, plan.destination, { - recursive: plan.kind === "directory", - force: false, - errorOnExist: true, - }); + copyInstallPath(plan); return { id: plan.id, source: plan.source, @@ -202,8 +323,94 @@ function installOneCaplet(plan: InstallPlan, options: { force: boolean }): Insta }; } +function rejectSymlinkDestination(id: string, path: string, stats: Stats | undefined): void { + if (stats?.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Cannot install Caplet ${id}; destination is a symlink at ${path}`, + ); + } +} + +function lstatIfExists(path: string): Stats | undefined { + try { + return lstatSync(path); + } catch (error) { + if (isFsError(error, "ENOENT")) { + return undefined; + } + throw new CapletsError( + "CONFIG_INVALID", + `Could not inspect install destination ${path}`, + toSafeError(error), + ); + } +} + +function ensureWritable(path: string, label: string): void { + try { + accessSync(path, constants.W_OK); + } catch (error) { + throw new CapletsError("CONFIG_INVALID", `Cannot write to ${label}`, toSafeError(error)); + } +} + +function makeInstallDirectory(path: string): void { + try { + mkdirSync(path, { recursive: true, mode: 0o700 }); + } catch (error) { + if (isFsError(error, "EEXIST")) { + throw new CapletsError( + "CONFIG_EXISTS", + `Install destination ${path} already exists and is not a directory`, + toSafeError(error), + ); + } + throw new CapletsError( + "CONFIG_INVALID", + `Could not create install destination ${path}`, + toSafeError(error), + ); + } +} + +function removeInstallPath(path: string, label: string, force: boolean): void { + try { + rmSync(path, { recursive: true, force }); + } catch (error) { + throw new CapletsError("CONFIG_INVALID", `Could not remove ${label}`, toSafeError(error)); + } +} + +function copyInstallPath(plan: InstallPlan): void { + try { + cpSync(plan.sourcePath, plan.destination, { + recursive: plan.kind === "directory", + force: false, + errorOnExist: true, + }); + } catch (error) { + if (isFsError(error, "EEXIST") || isFsError(error, "EISDIR")) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet ${plan.id} already exists at ${plan.destination}; pass --force to overwrite it`, + toSafeError(error), + ); + } + throw new CapletsError( + "CONFIG_INVALID", + `Could not install Caplet ${plan.id} to ${plan.destination}`, + toSafeError(error), + ); + } +} + +function isFsError(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + function nearestExistingParent(path: string): string { - if (existsSync(path)) { + if (lstatIfExists(path)) { return path; } const parent = dirname(path); diff --git a/src/config.ts b/src/config.ts index c80061b0..29a9495e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,14 +1,8 @@ import { existsSync, readFileSync } from "node:fs"; -import { dirname, isAbsolute, join } from "node:path"; +import { basename, dirname, isAbsolute, join } from "node:path"; import { z } from "zod"; -import { loadCapletFiles } from "./caplet-files.js"; -import { - TRUST_PROJECT_CAPLETS_ENV, - isTrustedEnvEnabled, - resolveCapletsRoot, - resolveConfigPath, - resolveProjectConfigPath, -} from "./config/paths.js"; +import { loadCapletFilesWithPaths } from "./caplet-files.js"; +import { resolveCapletsRoot, resolveConfigPath, resolveProjectConfigPath } from "./config/paths.js"; import { FORBIDDEN_HEADERS, HEADER_NAME_PATTERN, @@ -26,8 +20,6 @@ export { DEFAULT_AUTH_DIR, DEFAULT_CONFIG_PATH, PROJECT_CONFIG_FILE, - TRUST_PROJECT_CAPLETS_ENV, - isTrustedEnvEnabled, resolveCapletsRoot, resolveConfigPath, resolveProjectCapletsRoot, @@ -223,6 +215,19 @@ export type CapletsConfig = { cliTools: Record; }; +export type ConfigSourceKind = "global-config" | "global-file" | "project-config" | "project-file"; + +export type ConfigSource = { + kind: ConfigSourceKind; + path: string; +}; + +export type ConfigWithSources = { + config: CapletsConfig; + sources: Record; + shadows: Record; +}; + const NON_INTERPOLATED_SERVER_FIELDS = new Set(["name", "description", "tags", "body"]); const remoteAuthSchema = z @@ -1024,15 +1029,23 @@ export function loadConfig( path = resolveConfigPath(), projectPath = resolveProjectConfigPath(), ): CapletsConfig { + return loadConfigWithSources(path, projectPath).config; +} + +export function loadConfigWithSources( + path = resolveConfigPath(), + projectPath = resolveProjectConfigPath(), +): ConfigWithSources { const hasUserConfig = existsSync(path); const hasProjectConfig = existsSync(projectPath); const userConfig = hasUserConfig ? readPublicConfigInput(path) : undefined; - const userCaplets = loadCapletFiles(resolveCapletsRoot(path)); + const userCaplets = loadCapletFilesWithPaths(resolveCapletsRoot(path)); const projectConfig = hasProjectConfig - ? rejectUntrustedProjectExecutableBackends(readPublicConfigInput(projectPath), projectPath) + ? rejectProjectConfigExecutableBackendMaps(readPublicConfigInput(projectPath), projectPath) : undefined; - const projectCaplets = shouldLoadProjectCaplets() - ? loadCapletFiles(dirname(projectPath)) + const projectCapletsRoot = resolveProjectCapletsRootForConfigPath(projectPath); + const projectCaplets = projectCapletsRoot + ? loadCapletFilesWithPaths(projectCapletsRoot) : undefined; if (!hasUserConfig && !hasProjectConfig && !userCaplets && !projectCaplets) { @@ -1043,9 +1056,20 @@ export function loadConfig( } try { - const config = parseConfig( - mergeConfigInputs(userConfig, userCaplets, projectConfig, projectCaplets), + const { input, sources, shadows } = mergeConfigInputsWithSources( + { input: userConfig, source: { kind: "global-config", path } }, + userCaplets + ? { input: userCaplets.config, source: { kind: "global-file", path: userCaplets.paths } } + : undefined, + { input: projectConfig, source: { kind: "project-config", path: projectPath } }, + projectCaplets + ? { + input: projectCaplets.config, + source: { kind: "project-file", path: projectCaplets.paths }, + } + : undefined, ); + const config = parseConfig(input); if ( Object.keys(config.mcpServers).length === 0 && Object.keys(config.openapiEndpoints).length === 0 && @@ -1058,7 +1082,7 @@ export function loadConfig( "Caplets config must define at least one MCP server, OpenAPI endpoint, GraphQL endpoint, HTTP API, or CLI tools backend", ); } - return config; + return { config, sources, shadows }; } catch (error) { if (error instanceof CapletsError) { throw error; @@ -1071,8 +1095,20 @@ export function loadConfig( } } -function shouldLoadProjectCaplets(): boolean { - return isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV]); +type ConfigSourceInput = + | { kind: ConfigSourceKind; path: string } + | { kind: ConfigSourceKind; path: Record }; + +type ConfigInputWithSource = { + input: ConfigInput | undefined; + source: ConfigSourceInput; +}; + +function resolveProjectCapletsRootForConfigPath(projectPath: string): string | undefined { + const root = dirname(projectPath); + return basename(root) === ".caplets" && basename(projectPath) === "config.json" + ? root + : undefined; } function readPublicConfigInput(path: string): ConfigInput { @@ -1190,29 +1226,29 @@ function normalizeLocalPath(value: unknown, baseDir: string): unknown { return join(baseDir, value); } -function rejectUntrustedProjectExecutableBackends(input: ConfigInput, path: string): ConfigInput { +function rejectProjectConfigExecutableBackendMaps(input: ConfigInput, path: string): ConfigInput { if (input.openapiEndpoints && Object.keys(input.openapiEndpoints).length > 0) { throw new CapletsError( "CONFIG_INVALID", - `Project config at ${path} cannot define openapiEndpoints; use trusted project Caplet files or user config`, + `Project config at ${path} cannot define executable backend map openapiEndpoints; use project Markdown Caplet files or user config instead`, ); } if (input.graphqlEndpoints && Object.keys(input.graphqlEndpoints).length > 0) { throw new CapletsError( "CONFIG_INVALID", - `Project config at ${path} cannot define graphqlEndpoints; use trusted project Caplet files or user config`, + `Project config at ${path} cannot define executable backend map graphqlEndpoints; use project Markdown Caplet files or user config instead`, ); } if (input.httpApis && Object.keys(input.httpApis).length > 0) { throw new CapletsError( "CONFIG_INVALID", - `Project config at ${path} cannot define httpApis; use trusted project Caplet files or user config`, + `Project config at ${path} cannot define executable backend map httpApis; use project Markdown Caplet files or user config instead`, ); } 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`, + `Project config at ${path} cannot define executable backend map cliTools; use project Markdown Caplet files or user config instead`, ); } return input; @@ -1252,6 +1288,67 @@ function mergeConfigInputs(...inputs: Array): ConfigInp return merged; } +function mergeConfigInputsWithSources(...inputs: Array): { + input: ConfigInput | undefined; + sources: Record; + shadows: Record; +} { + let merged: ConfigInput = {}; + const sources: Record = {}; + const shadows: Record = {}; + + for (const entry of inputs) { + if (entry?.input === undefined) { + continue; + } + for (const id of capletIds(entry.input)) { + const source = sourceForId(entry.source, id); + if (sources[id]) { + shadows[id] = [...(shadows[id] ?? []), sources[id]]; + } + sources[id] = source; + merged = removeCapletId(merged, id); + } + merged = mergeConfigInputs(merged, entry.input) ?? {}; + } + + return { input: merged, sources, shadows }; +} + +function removeCapletId(input: ConfigInput, id: string): ConfigInput { + const { [id]: _mcpServer, ...mcpServers } = input.mcpServers ?? {}; + const { [id]: _openapiEndpoint, ...openapiEndpoints } = input.openapiEndpoints ?? {}; + const { [id]: _graphqlEndpoint, ...graphqlEndpoints } = input.graphqlEndpoints ?? {}; + const { [id]: _httpApi, ...httpApis } = input.httpApis ?? {}; + const { [id]: _cliTools, ...cliTools } = input.cliTools ?? {}; + + return { + ...input, + mcpServers, + openapiEndpoints, + graphqlEndpoints, + httpApis, + cliTools, + }; +} + +function capletIds(input: ConfigInput): string[] { + return [ + ...Object.keys(input.mcpServers ?? {}), + ...Object.keys(input.openapiEndpoints ?? {}), + ...Object.keys(input.graphqlEndpoints ?? {}), + ...Object.keys(input.httpApis ?? {}), + ...Object.keys(input.cliTools ?? {}), + ]; +} + +function sourceForId(source: ConfigSourceInput, id: string): ConfigSource { + return { + kind: source.kind, + path: typeof source.path === "string" ? source.path : (source.path[id] ?? ""), + }; +} + export function parseConfig(input: unknown): CapletsConfig { const parsed = normalizedConfigFileSchema.safeParse(interpolateConfig(input)); if (!parsed.success) { diff --git a/src/config/paths.ts b/src/config/paths.ts index f7c27917..5a5689e6 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -57,7 +57,6 @@ export function defaultAuthDir( export const DEFAULT_CONFIG_PATH = defaultConfigPath(); export const DEFAULT_AUTH_DIR = defaultAuthDir(); export const PROJECT_CONFIG_FILE = join(".caplets", "config.json"); -export const TRUST_PROJECT_CAPLETS_ENV = "CAPLETS_TRUST_PROJECT_CAPLETS"; export function resolveConfigPath(path?: string): string { return path ?? DEFAULT_CONFIG_PATH; @@ -74,7 +73,3 @@ export function resolveCapletsRoot(configPath = resolveConfigPath()): string { export function resolveProjectCapletsRoot(cwd = process.cwd()): string { return join(cwd, ".caplets"); } - -export function isTrustedEnvEnabled(value: string | undefined): boolean { - return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; -} diff --git a/src/runtime.ts b/src/runtime.ts index d00eeb9d..806dd55c 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -9,9 +9,8 @@ import { loadConfig, resolveCapletsRoot, resolveConfigPath, + resolveProjectCapletsRoot, resolveProjectConfigPath, - TRUST_PROJECT_CAPLETS_ENV, - isTrustedEnvEnabled, } from "./config.js"; import { CliToolsManager } from "./cli-tools.js"; import { DownstreamManager } from "./downstream.js"; @@ -388,10 +387,11 @@ function watchedPaths(paths: RuntimePaths): WatchedPath[] { { path: dirname(paths.configPath), reason: "config" }, { path: dirname(paths.projectConfigPath), reason: "config" }, { path: resolveCapletsRoot(paths.configPath), reason: "caplets" }, + { + path: resolveProjectCapletsRoot(dirname(dirname(paths.projectConfigPath))), + reason: "caplets", + }, ]; - if (isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV])) { - entries.push({ path: dirname(paths.projectConfigPath), reason: "caplets" }); - } return uniqueWatchedPaths(entries); } diff --git a/test/author-cli.test.ts b/test/author-cli.test.ts index ead55ac3..9519cc24 100644 --- a/test/author-cli.test.ts +++ b/test/author-cli.test.ts @@ -1,10 +1,17 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { authorCliCaplet } from "../src/cli/author.js"; +import { + addCliCaplet, + addGraphqlCaplet, + addHttpCaplet, + addMcpCaplet, + addOpenApiCaplet, +} from "../src/cli/add.js"; import { validateCapletFile } from "../src/caplet-files.js"; -import { runCli } from "../src/cli.js"; +import { CapletsError } from "../src/errors.js"; describe("CLI Caplet authoring", () => { const dirs: string[] = []; @@ -55,18 +62,250 @@ describe("CLI Caplet authoring", () => { ); }); - it("is exposed through caplets author cli", async () => { + it("adds generated CLI Caplets to the project root by default", () => { const repo = tempRepo({ scripts: { test: "vitest run" } }); - let output = ""; + const project = mkdtempSync(join(tmpdir(), "caplets-add-project-")); + dirs.push(project); - await runCli(["author", "cli", "repo-tools", "--repo", repo, "--include", "package"], { - writeOut: (value) => { - output += value; - }, + const result = addCliCaplet("repo-tools", { + repo, + include: "package", + destinationRoot: join(project, ".caplets"), }); - expect(output).toContain("cliTools:"); - expect(output).toContain("package_test:"); + const output = join(project, ".caplets", "repo-tools.md"); + expect(result.path).toBe(output); + expect(readFileSync(output, "utf8")).toContain("package_test:"); + expect(() => validateCapletFile(output)).not.toThrow(); + }); + + it("adds generated CLI Caplets to an explicit output path", () => { + const repo = tempRepo({ scripts: { build: "tsc" } }); + const output = join(repo, "nested", "repo-tools.md"); + + const result = addCliCaplet("repo-tools", { + repo, + include: "package", + output, + destinationRoot: join(repo, ".caplets"), + }); + + expect(result.path).toBe(output); + expect(readFileSync(output, "utf8")).toContain("package_build:"); + }); + + it("rejects explicit output paths that are directories even with force", () => { + const repo = tempRepo({ scripts: { build: "tsc" } }); + const output = join(repo, "nested"); + mkdirSync(output, { recursive: true }); + + expect(() => + addCliCaplet("repo-tools", { + repo, + include: "package", + output, + destinationRoot: join(repo, ".caplets"), + force: true, + }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + }); + + it("prints generated CLI Caplets without writing", () => { + const repo = tempRepo({ scripts: { test: "vitest run" } }); + + const result = addCliCaplet("repo-tools", { + repo, + include: "package", + print: true, + destinationRoot: join(repo, ".caplets"), + }); + + expect(result.path).toBeUndefined(); + expect(result.text).toContain("package_test:"); + expect(existsSync(join(repo, ".caplets", "repo-tools.md"))).toBe(false); + }); + + it("refuses to overwrite added CLI Caplets unless forced", () => { + const repo = tempRepo({ scripts: { test: "vitest run" } }); + const destinationRoot = join(repo, ".caplets"); + + addCliCaplet("repo-tools", { repo, include: "package", destinationRoot }); + + expect(() => addCliCaplet("repo-tools", { repo, include: "package", destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }), + ); + expect(() => + addCliCaplet("repo-tools", { repo, include: "package", destinationRoot, force: true }), + ).not.toThrow(); + }); + + it("refuses default file output when a directory Caplet exists for the same ID", () => { + const repo = tempRepo({ scripts: { test: "vitest run" } }); + const destinationRoot = join(repo, ".caplets"); + mkdirSync(join(destinationRoot, "repo-tools"), { recursive: true }); + writeFileSync(join(destinationRoot, "repo-tools", "CAPLET.md"), "existing"); + + expect(() => + addCliCaplet("repo-tools", { + repo, + include: "package", + destinationRoot, + force: true, + }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + }); + + it("rejects invalid Caplet IDs before writing default destinations", () => { + const repo = tempRepo({ scripts: { test: "vitest run" } }); + const project = mkdtempSync(join(tmpdir(), "caplets-add-invalid-")); + dirs.push(project); + + expect(() => + addCliCaplet("bad name", { + repo, + include: "package", + destinationRoot: join(project, ".caplets"), + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(() => + addCliCaplet("../escape", { + repo, + include: "package", + destinationRoot: join(project, ".caplets"), + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(existsSync(join(project, ".caplets"))).toBe(false); + }); + + it("generates valid MCP Caplets for stdio and remote servers", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-mcp-")); + dirs.push(dir); + + const stdio = addMcpCaplet("local-tools", { + command: "node", + arg: ["server.js", "--verbose"], + cwd: dir, + env: ["API_TOKEN=dev"], + print: true, + destinationRoot: join(dir, ".caplets"), + }); + const remote = addMcpCaplet("remote-tools", { + url: "https://mcp.example.com/mcp", + transport: "http", + tokenEnv: "MCP_TOKEN", + print: true, + destinationRoot: join(dir, ".caplets"), + }); + + expect(stdio.text).toContain('command: "node"'); + expect(stdio.text).toContain('- "server.js"'); + expect(stdio.text).toContain('API_TOKEN: "dev"'); + expect(remote.text).toContain('url: "https://mcp.example.com/mcp"'); + expect(remote.text).toContain('token: "$env:MCP_TOKEN"'); + expect(remote.text).not.toContain("Bearer"); + }); + + it("generates valid OpenAPI, GraphQL, and HTTP Caplets", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-backends-")); + dirs.push(dir); + + const openapi = addOpenApiCaplet("petstore", { + spec: "https://api.example.com/openapi.json", + baseUrl: "https://api.example.com/v1", + tokenEnv: "PETSTORE_TOKEN", + print: true, + destinationRoot: join(dir, ".caplets"), + }); + const graphql = addGraphqlCaplet("catalog", { + endpointUrl: "https://api.example.com/graphql", + schema: "./schema.graphql", + print: true, + destinationRoot: join(dir, ".caplets"), + }); + const http = addHttpCaplet("status-api", { + baseUrl: "https://api.example.com", + action: ["get_status:GET:/status", "restart:POST:/restart"], + print: true, + destinationRoot: join(dir, ".caplets"), + }); + + expect(openapi.text).toContain("openapiEndpoint:"); + expect(openapi.text).toContain('specUrl: "https://api.example.com/openapi.json"'); + expect(openapi.text).toContain('token: "$env:PETSTORE_TOKEN"'); + expect(graphql.text).toContain("graphqlEndpoint:"); + expect(graphql.text).toContain(`schemaPath: ${JSON.stringify(resolve("./schema.graphql"))}`); + expect(http.text).toContain("httpApi:"); + expect(http.text).toContain("get_status:"); + expect(http.text).toContain('method: "GET"'); + }); + + it("rejects invalid generated backend shapes", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-invalid-shapes-")); + dirs.push(dir); + const destinationRoot = join(dir, ".caplets"); + + expect(() => + addMcpCaplet("bad-mcp", { + command: "node", + url: "https://mcp.example.com/mcp", + transport: "http", + print: true, + destinationRoot, + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(() => + addHttpCaplet("bad-http", { + baseUrl: "https://api.example.com", + action: ["missing-parts"], + print: true, + destinationRoot, + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(() => + addHttpCaplet("duplicate-http", { + baseUrl: "https://api.example.com", + action: ["get_status:GET:/status", "get_status:POST:/status"], + print: true, + destinationRoot, + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(() => + addGraphqlCaplet("bad-graphql", { + endpointUrl: "https://api.example.com/graphql", + schema: "./schema.graphql", + introspection: true, + print: true, + destinationRoot, + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + }); + + it("refuses to overwrite added non-CLI Caplets unless forced", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-backend-overwrite-")); + dirs.push(dir); + const destinationRoot = join(dir, ".caplets"); + + addHttpCaplet("status-api", { + baseUrl: "https://api.example.com", + action: ["get_status:GET:/status"], + destinationRoot, + }); + + expect(() => + addHttpCaplet("status-api", { + baseUrl: "https://api.example.com", + action: ["get_status:GET:/status"], + destinationRoot, + }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(() => + addHttpCaplet("status-api", { + baseUrl: "https://api.example.com", + action: ["get_status:GET:/status"], + destinationRoot, + force: true, + }), + ).not.toThrow(); }); function tempRepo(packageJson: { packageManager?: string; scripts?: Record }) { diff --git a/test/cli.test.ts b/test/cli.test.ts index 61465b78..d5bd01a8 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,16 +1,23 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { version as packageJsonVersion } from "../package.json"; import { initConfig, installCaplets, normalizeGitRepo, runCli, starterConfig } from "../src/cli.js"; -import { parseConfig, TRUST_PROJECT_CAPLETS_ENV } from "../src/config.js"; +import { loadConfig, parseConfig } from "../src/config.js"; import { CapletsError } from "../src/errors.js"; import { writeTokenBundle } from "../src/auth.js"; describe("cli init", () => { const originalConfigPath = process.env.CAPLETS_CONFIG; - const originalTrustProjectCaplets = process.env[TRUST_PROJECT_CAPLETS_ENV]; afterEach(() => { vi.restoreAllMocks(); @@ -19,11 +26,6 @@ describe("cli init", () => { } else { process.env.CAPLETS_CONFIG = originalConfigPath; } - if (originalTrustProjectCaplets === undefined) { - delete process.env[TRUST_PROJECT_CAPLETS_ENV]; - } else { - process.env[TRUST_PROJECT_CAPLETS_ENV] = originalTrustProjectCaplets; - } }); it("writes a valid starter config and creates parent directories", () => { @@ -117,9 +119,11 @@ describe("cli init", () => { const text = out.join(""); expect(text).toContain("server"); + expect(text).toContain("source"); expect(text).toContain("filesystem"); expect(text).toContain("mcp"); expect(text).toContain("not_started"); + expect(text).toContain("global-config"); expect(text).toContain("Project Files"); expect(text).toContain("users"); expect(text).toContain("openapi"); @@ -168,6 +172,9 @@ describe("cli init", () => { description: string; disabled: boolean; status: string; + source: string; + path: string | null; + shadows: Array<{ kind: string; path: string }>; }>; expect(rows).toEqual([ expect.objectContaining({ @@ -175,18 +182,27 @@ describe("cli init", () => { backend: "graphql", disabled: false, status: "not_started", + source: "global-config", + path: configPath, + shadows: [], }), expect.objectContaining({ server: "filesystem", backend: "mcp", disabled: false, status: "not_started", + source: "global-config", + path: configPath, + shadows: [], }), expect.objectContaining({ server: "users", backend: "openapi", disabled: false, status: "not_started", + source: "global-config", + path: configPath, + shadows: [], }), ]); expect(out.join("")).not.toContain("secret-access-token"); @@ -196,6 +212,114 @@ describe("cli init", () => { } }); + it("prints source warnings when project Caplets shadow global Caplets", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-sources-")); + const cwd = process.cwd(); + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project"); + const configPath = join(userRoot, "config.json"); + const projectCapletPath = join(projectRoot, ".caplets", "github.md"); + const out: string[] = []; + try { + mkdirSync(userRoot, { recursive: true }); + mkdirSync(dirname(projectCapletPath), { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub Global", + description: "Use GitHub globally.", + command: "global-github", + }, + }, + }), + ); + writeFileSync( + projectCapletPath, + [ + "---", + "name: GitHub Project", + "description: Use GitHub from this project.", + "mcpServer:", + " command: project-github", + "---", + "# GitHub Project", + ].join("\n"), + ); + process.env.CAPLETS_CONFIG = configPath; + process.chdir(projectRoot); + + await runCli(["list"], { writeOut: (value) => out.push(value) }); + + const text = out.join(""); + expect(text).toContain("server"); + expect(text).toContain("source"); + expect(text).toContain("github"); + expect(text).toContain("project-file"); + expect(text).toContain( + `Warning: project Caplet github shadows global Caplet at ${configPath}`, + ); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints JSON source and shadow metadata", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-json-sources-")); + const cwd = process.cwd(); + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project"); + const configPath = join(userRoot, "config.json"); + const projectCapletPath = join(projectRoot, ".caplets", "github.md"); + const out: string[] = []; + try { + mkdirSync(userRoot, { recursive: true }); + mkdirSync(dirname(projectCapletPath), { recursive: true }); + writeFileSync( + configPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub Global", + description: "Use GitHub globally.", + command: "global-github", + }, + }, + }), + ); + writeFileSync( + projectCapletPath, + [ + "---", + "name: GitHub Project", + "description: Use GitHub from this project.", + "mcpServer:", + " command: project-github", + "---", + "# GitHub Project", + ].join("\n"), + ); + process.env.CAPLETS_CONFIG = configPath; + process.chdir(projectRoot); + + await runCli(["list", "--json"], { writeOut: (value) => out.push(value) }); + + expect(JSON.parse(out.join(""))).toEqual([ + expect.objectContaining({ + server: "github", + source: "project-file", + path: projectCapletPath, + shadows: [{ kind: "global-config", path: configPath }], + }), + ]); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + it("prints the effective config path", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-config-path-")); const configPath = join(dir, "custom.json"); @@ -218,7 +342,6 @@ describe("cli init", () => { const out: string[] = []; try { process.env.CAPLETS_CONFIG = configPath; - process.env[TRUST_PROJECT_CAPLETS_ENV] = "yes"; await runCli(["config", "paths", "--json"], { writeOut: (value) => out.push(value), @@ -233,7 +356,6 @@ describe("cli init", () => { projectRoot: join(process.cwd(), ".caplets"), authDir, envConfig: configPath, - projectCapletsTrusted: true, }); } finally { rmSync(dir, { recursive: true, force: true }); @@ -262,7 +384,6 @@ describe("cli init", () => { `projectRoot: ${join(process.cwd(), ".caplets")}`, `authDir: ${authDir}`, `envConfig: ${configPath}`, - "projectCapletsTrusted: false", "", ].join("\n"), ); @@ -274,42 +395,653 @@ describe("cli init", () => { it("installs all Caplets from a local repo caplets directory", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo"); + const cwd = process.cwd(); + const projectRoot = join(dir, "project"); const configPath = join(dir, "user", "config.json"); const out: string[] = []; try { writeInstallableRepo(repo); process.env.CAPLETS_CONFIG = configPath; + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); await runCli(["install", repo], { writeOut: (value) => out.push(value) }); - expect(readFileSync(join(dir, "user", "filesystem.md"), "utf8")).toContain( + expect(readFileSync(join(projectRoot, ".caplets", "filesystem.md"), "utf8")).toContain( "name: Project Files", ); - expect(readFileSync(join(dir, "user", "github", "CAPLET.md"), "utf8")).toContain( + expect(readFileSync(join(projectRoot, ".caplets", "github", "CAPLET.md"), "utf8")).toContain( "name: GitHub", ); expect(out.join("")).toContain("Installed filesystem"); expect(out.join("")).toContain("Installed github"); } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs all Caplets globally when requested", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-global-")); + const repo = join(dir, "repo"); + const projectRoot = join(dir, "project"); + const configPath = join(dir, "user", "config.json"); + const out: string[] = []; + const cwd = process.cwd(); + try { + writeInstallableRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.env.CAPLETS_CONFIG = configPath; + process.chdir(projectRoot); + + await runCli(["install", "--global", repo], { writeOut: (value) => out.push(value) }); + + expect(readFileSync(join(dir, "user", "filesystem.md"), "utf8")).toContain( + "name: Project Files", + ); + expect(readFileSync(join(dir, "user", "github", "CAPLET.md"), "utf8")).toContain( + "name: GitHub", + ); + expect(existsSync(join(projectRoot, ".caplets"))).toBe(false); + expect(out.join("")).toContain( + `Installed filesystem to ${join(dir, "user", "filesystem.md")}`, + ); + expect(out.join("")).toContain(`Installed github to ${join(dir, "user", "github")}`); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("adds CLI Caplets to the project root by default", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-")); + const projectRoot = join(dir, "project"); + const repo = join(dir, "repo"); + const cwd = process.cwd(); + const out: string[] = []; + try { + writeCliRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package"], { + writeOut: (value) => out.push(value), + }); + + const output = join(projectRoot, ".caplets", "repo-tools.md"); + expect(readFileSync(output, "utf8")).toContain("package_test:"); + expect(out.join("")).toBe(`Wrote CLI Caplet to ${output}\n`); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("adds CLI Caplets globally when requested", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-global-")); + const projectRoot = join(dir, "project"); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + const cwd = process.cwd(); + try { + writeCliRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.env.CAPLETS_CONFIG = configPath; + process.chdir(projectRoot); + + await runCli( + ["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--global"], + { + writeOut: () => {}, + }, + ); + + expect(readFileSync(join(dir, "user", "repo-tools.md"), "utf8")).toContain("package_test:"); + expect(existsSync(join(projectRoot, ".caplets"))).toBe(false); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints added CLI Caplets without writing", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-print-")); + const projectRoot = join(dir, "project"); + const repo = join(dir, "repo"); + const cwd = process.cwd(); + const out: string[] = []; + try { + writeCliRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli( + ["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--print"], + { + writeOut: (value) => out.push(value), + }, + ); + + expect(out.join("")).toContain("cliTools:"); + expect(out.join("")).toContain("package_test:"); + expect(existsSync(join(projectRoot, ".caplets"))).toBe(false); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects added CLI Caplets when no package scripts produce actions", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-empty-")); + const repo = join(dir, "repo"); + try { + mkdirSync(repo, { recursive: true }); + writeFileSync(join(repo, "package.json"), JSON.stringify({ name: "fixture", scripts: {} })); + + await expect( + runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package"], { + writeOut: () => {}, + writeErr: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints added MCP and OpenAPI backend Caplets", async () => { + const out: string[] = []; + + await runCli( + [ + "add", + "mcp", + "remote-tools", + "--url", + "https://mcp.example.com/mcp", + "--transport", + "sse", + "--token-env", + "MCP_TOKEN", + "--print", + ], + { writeOut: (value) => out.push(value) }, + ); + await runCli( + [ + "add", + "openapi", + "petstore", + "--spec", + "https://api.example.com/openapi.json", + "--base-url", + "https://api.example.com/v1", + "--print", + ], + { writeOut: (value) => out.push(value) }, + ); + + expect(out.join("\n")).toContain("mcpServer:"); + expect(out.join("\n")).toContain('transport: "sse"'); + expect(out.join("\n")).toContain('token: "$env:MCP_TOKEN"'); + expect(out.join("\n")).toContain("openapiEndpoint:"); + expect(out.join("\n")).toContain('baseUrl: "https://api.example.com/v1"'); + }); + + it("adds GraphQL and HTTP backend Caplets to the project root", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-backends-")); + const projectRoot = join(dir, "project"); + const cwd = process.cwd(); + try { + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli( + [ + "add", + "graphql", + "catalog", + "--endpoint-url", + "https://api.example.com/graphql", + "--introspection", + ], + { writeOut: () => {} }, + ); + await runCli( + [ + "add", + "http", + "status-api", + "--base-url", + "https://api.example.com", + "--action", + "get_status:GET:/status", + ], + { writeOut: () => {} }, + ); + + expect(readFileSync(join(projectRoot, ".caplets", "catalog.md"), "utf8")).toContain( + "graphqlEndpoint:", + ); + expect(readFileSync(join(projectRoot, ".caplets", "status-api.md"), "utf8")).toContain( + "httpApi:", + ); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes OpenAPI local spec paths that load from the original project file", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-openapi-path-")); + const projectRoot = join(dir, "project"); + const cwd = process.cwd(); + try { + mkdirSync(projectRoot, { recursive: true }); + writeFileSync(join(projectRoot, "openapi.json"), JSON.stringify({ openapi: "3.1.0" })); + process.chdir(projectRoot); + + await runCli(["add", "openapi", "users", "--spec", "./openapi.json"], { + writeOut: () => {}, + }); + + const config = loadConfig( + join(dir, "user", "config.json"), + join(projectRoot, ".caplets", "config.json"), + ); + expect(config.openapiEndpoints.users?.specPath).toBe(join(projectRoot, "openapi.json")); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes GraphQL local schema paths that load from the original project file", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-graphql-path-")); + const projectRoot = join(dir, "project"); + const cwd = process.cwd(); + try { + mkdirSync(projectRoot, { recursive: true }); + writeFileSync(join(projectRoot, "schema.graphql"), "type Query { viewer: String }\n"); + process.chdir(projectRoot); + + await runCli( + [ + "add", + "graphql", + "catalog", + "--endpoint-url", + "https://api.example.com/graphql", + "--schema", + "./schema.graphql", + ], + { writeOut: () => {} }, + ); + + const config = loadConfig( + join(dir, "user", "config.json"), + join(projectRoot, ".caplets", "config.json"), + ); + expect(config.graphqlEndpoints.catalog?.schemaPath).toBe(join(projectRoot, "schema.graphql")); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects invalid backend add options", async () => { + await expect( + runCli( + [ + "add", + "graphql", + "bad-graphql", + "--endpoint-url", + "https://api.example.com/graphql", + "--schema", + "./schema.graphql", + "--introspection", + "--print", + ], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + + await expect( + runCli( + ["add", "http", "bad-http", "--base-url", "https://api.example.com", "--action", "bad"], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + + await expect( + runCli( + [ + "add", + "http", + "duplicate-http", + "--base-url", + "https://api.example.com", + "--action", + "get_status:GET:/status", + "--action", + "get_status:POST:/status", + "--print", + ], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + }); + + it("adds CLI Caplets to an explicit output path", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-output-")); + const repo = join(dir, "repo"); + const output = join(dir, "custom", "tools.md"); + try { + writeCliRepo(repo); + + await runCli( + ["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--output", output], + { writeOut: () => {} }, + ); + + expect(readFileSync(output, "utf8")).toContain("package_test:"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns a controlled error when an add output parent is a file", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-output-parent-file-")); + const repo = join(dir, "repo"); + const parent = join(dir, "custom"); + const output = join(parent, "tools.md"); + try { + writeCliRepo(repo); + writeFileSync(parent, "not a directory\n"); + + await expect( + runCli( + ["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--output", output], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects explicit add output paths that are directories even with force", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-output-dir-")); + const repo = join(dir, "repo"); + const output = join(dir, "custom"); + try { + writeCliRepo(repo); + mkdirSync(output, { recursive: true }); + + await expect( + runCli( + [ + "add", + "cli", + "repo-tools", + "--repo", + repo, + "--include", + "package", + "--output", + output, + "--force", + ], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects explicit add output paths that are symlinks even with force", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-output-symlink-")); + const repo = join(dir, "repo"); + const target = join(dir, "outside.md"); + const output = join(dir, "custom", "tools.md"); + try { + writeCliRepo(repo); + writeFileSync(target, "protected\n"); + mkdirSync(join(dir, "custom"), { recursive: true }); + symlinkSync(target, output); + + await expect( + runCli( + [ + "add", + "cli", + "repo-tools", + "--repo", + repo, + "--include", + "package", + "--output", + output, + "--force", + ], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(readFileSync(target, "utf8")).toBe("protected\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects explicit add output paths with symlinked parents", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-output-parent-symlink-")); + const repo = join(dir, "repo"); + const realParent = join(dir, "real-parent"); + const symlinkParent = join(dir, "custom"); + const output = join(symlinkParent, "tools.md"); + try { + writeCliRepo(repo); + mkdirSync(realParent, { recursive: true }); + symlinkSync(realParent, symlinkParent); + + await expect( + runCli( + ["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--output", output], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(existsSync(join(realParent, "tools.md"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("wraps unexpected lstat failures while inspecting add output paths", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-lstat-failure-")); + const repo = join(dir, "repo"); + const output = join(dir, "a".repeat(300), "tools.md"); + try { + writeCliRepo(repo); + + await expect( + runCli( + ["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--output", output], + { writeOut: () => {}, writeErr: () => {} }, + ), + ).rejects.toThrow( + expect.objectContaining({ + code: "CONFIG_INVALID", + message: `Could not inspect output path ${output}`, + }) as CapletsError, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects default add output paths with a symlinked .caplets root", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-root-symlink-")); + const projectRoot = join(dir, "project"); + const realRoot = join(dir, "real-caplets"); + const repo = join(dir, "repo"); + const cwd = process.cwd(); + try { + writeCliRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + mkdirSync(realRoot, { recursive: true }); + symlinkSync(realRoot, join(projectRoot, ".caplets")); + process.chdir(projectRoot); + + await expect( + runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package"], { + writeOut: () => {}, + writeErr: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(existsSync(join(realRoot, "repo-tools.md"))).toBe(false); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses to overwrite added CLI Caplets without force", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-overwrite-")); + const projectRoot = join(dir, "project"); + const repo = join(dir, "repo"); + const cwd = process.cwd(); + try { + writeCliRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package"], { + writeOut: () => {}, + }); + + await expect( + runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package"], { + writeOut: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + + await runCli( + ["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--force"], + { writeOut: () => {} }, + ); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects default add output paths that are symlinks even with force", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-default-symlink-")); + const projectRoot = join(dir, "project"); + const repo = join(dir, "repo"); + const target = join(dir, "outside.md"); + const cwd = process.cwd(); + try { + writeCliRepo(repo); + mkdirSync(join(projectRoot, ".caplets"), { recursive: true }); + writeFileSync(target, "protected\n"); + symlinkSync(target, join(projectRoot, ".caplets", "repo-tools.md")); + process.chdir(projectRoot); + + await expect( + runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--force"], { + writeOut: () => {}, + writeErr: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(readFileSync(target, "utf8")).toBe("protected\n"); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses default add output when a directory Caplet exists for the same ID", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-directory-collision-")); + const projectRoot = join(dir, "project"); + const repo = join(dir, "repo"); + const cwd = process.cwd(); + try { + writeCliRepo(repo); + mkdirSync(join(projectRoot, ".caplets", "repo-tools"), { recursive: true }); + writeFileSync(join(projectRoot, ".caplets", "repo-tools", "CAPLET.md"), "existing"); + process.chdir(projectRoot); + + await expect( + runCli(["add", "cli", "repo-tools", "--repo", repo, "--include", "package", "--force"], { + writeOut: () => {}, + writeErr: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + } finally { + process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); } }); + it("rejects invalid add CLI Caplet IDs before deriving default destinations", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-add-cli-invalid-")); + const projectRoot = join(dir, "project"); + const repo = join(dir, "repo"); + const cwd = process.cwd(); + try { + writeCliRepo(repo); + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); + + await expect( + runCli(["add", "cli", "bad name", "--repo", repo, "--include", "package"], { + writeOut: () => {}, + writeErr: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + await expect( + runCli(["add", "cli", "../escape", "--repo", repo, "--include", "package"], { + writeOut: () => {}, + writeErr: () => {}, + }), + ).rejects.toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + + expect(existsSync(join(projectRoot, ".caplets"))).toBe(false); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not expose the removed author cli alias", async () => { + await expect(runCli(["author", "cli", "repo-tools"], { writeErr: () => {} })).rejects.toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError, + ); + }); + it("installs selected Caplets from a local repo caplets directory", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo"); + const cwd = process.cwd(); + const projectRoot = join(dir, "project"); const configPath = join(dir, "user", "config.json"); const out: string[] = []; try { writeInstallableRepo(repo); process.env.CAPLETS_CONFIG = configPath; + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); await runCli(["install", repo, "github"], { writeOut: (value) => out.push(value) }); - expect(existsSync(join(dir, "user", "github", "CAPLET.md"))).toBe(true); - expect(existsSync(join(dir, "user", "filesystem.md"))).toBe(false); - expect(out.join("")).toBe(`Installed github to ${join(dir, "user", "github")}\n`); + expect(existsSync(join(projectRoot, ".caplets", "github", "CAPLET.md"))).toBe(true); + expect(existsSync(join(projectRoot, ".caplets", "filesystem.md"))).toBe(false); + expect(out.join("")).toBe(`Installed github to ${join(projectRoot, ".caplets", "github")}\n`); } finally { + process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); } }); @@ -317,17 +1049,22 @@ describe("cli init", () => { it("installs a selected Caplet when an unrelated Caplet is invalid", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo"); + const cwd = process.cwd(); + const projectRoot = join(dir, "project"); const configPath = join(dir, "user", "config.json"); try { writeInstallableRepo(repo); writeFileSync(join(repo, "caplets", "broken.md"), "not frontmatter\n"); process.env.CAPLETS_CONFIG = configPath; + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); await runCli(["install", repo, "github"], { writeOut: () => {} }); - expect(existsSync(join(dir, "user", "github", "CAPLET.md"))).toBe(true); - expect(existsSync(join(dir, "user", "broken.md"))).toBe(false); + expect(existsSync(join(projectRoot, ".caplets", "github", "CAPLET.md"))).toBe(true); + expect(existsSync(join(projectRoot, ".caplets", "broken.md"))).toBe(false); } finally { + process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); } }); @@ -335,17 +1072,22 @@ describe("cli init", () => { it("installs a selected Caplet when an unrelated Caplet filename has an invalid ID", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo"); + const cwd = process.cwd(); + const projectRoot = join(dir, "project"); const configPath = join(dir, "user", "config.json"); try { writeInstallableRepo(repo); writeFileSync(join(repo, "caplets", "api.v2.md"), "not frontmatter\n"); process.env.CAPLETS_CONFIG = configPath; + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); await runCli(["install", repo, "github"], { writeOut: () => {} }); - expect(existsSync(join(dir, "user", "github", "CAPLET.md"))).toBe(true); - expect(existsSync(join(dir, "user", "api.v2.md"))).toBe(false); + expect(existsSync(join(projectRoot, ".caplets", "github", "CAPLET.md"))).toBe(true); + expect(existsSync(join(projectRoot, ".caplets", "api.v2.md"))).toBe(false); } finally { + process.chdir(cwd); rmSync(dir, { recursive: true, force: true }); } }); @@ -353,10 +1095,14 @@ describe("cli init", () => { it("refuses to overwrite installed Caplets without force", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo"); + const cwd = process.cwd(); + const projectRoot = join(dir, "project"); const configPath = join(dir, "user", "config.json"); try { writeInstallableRepo(repo); process.env.CAPLETS_CONFIG = configPath; + mkdirSync(projectRoot, { recursive: true }); + process.chdir(projectRoot); await runCli(["install", repo, "github"], { writeOut: () => {} }); @@ -365,6 +1111,184 @@ describe("cli init", () => { ); await runCli(["install", repo, "github", "--force"], { writeOut: () => {} }); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses file-vs-directory install destination collisions even with force", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-cross-kind-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + mkdirSync(destinationRoot, { recursive: true }); + writeFileSync(join(destinationRoot, "github.md"), "existing\n"); + mkdirSync(join(destinationRoot, "filesystem"), { recursive: true }); + writeFileSync(join(destinationRoot, "filesystem", "CAPLET.md"), "existing\n"); + + expect(() => + installCaplets(repo, { capletIds: ["github"], destinationRoot, force: true }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(() => + installCaplets(repo, { capletIds: ["filesystem"], destinationRoot, force: true }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(readFileSync(join(destinationRoot, "github.md"), "utf8")).toBe("existing\n"); + expect(readFileSync(join(destinationRoot, "filesystem", "CAPLET.md"), "utf8")).toBe( + "existing\n", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses broken symlink file install destinations even with force", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-broken-file-symlink-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + const destination = join(destinationRoot, "filesystem.md"); + try { + writeInstallableRepo(repo); + mkdirSync(destinationRoot, { recursive: true }); + symlinkSync(join(dir, "missing.md"), destination); + + expect(() => + installCaplets(repo, { capletIds: ["filesystem"], destinationRoot, force: true }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses broken symlink directory install destinations even with force", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-broken-dir-symlink-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + const destination = join(destinationRoot, "github"); + try { + writeInstallableRepo(repo); + mkdirSync(destinationRoot, { recursive: true }); + symlinkSync(join(dir, "missing-directory"), destination); + + expect(() => + installCaplets(repo, { capletIds: ["github"], destinationRoot, force: true }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses symlink file-vs-directory install destination collisions even with force", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-cross-kind-symlink-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + mkdirSync(destinationRoot, { recursive: true }); + symlinkSync(join(dir, "missing.md"), join(destinationRoot, "github.md")); + symlinkSync(join(dir, "missing-directory"), join(destinationRoot, "filesystem")); + + expect(() => + installCaplets(repo, { capletIds: ["github"], destinationRoot, force: true }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + expect(() => + installCaplets(repo, { capletIds: ["filesystem"], destinationRoot, force: true }), + ).toThrow(expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns a controlled error when the install destination root is a file", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-root-file-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + writeFileSync(destinationRoot, "not a directory\n"); + + expect(() => installCaplets(repo, { capletIds: ["github"], destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects install destination roots under symlinked parents", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-root-parent-symlink-")); + const repo = join(dir, "repo"); + const realParent = join(dir, "real-parent"); + const symlinkParent = join(dir, "linked-parent"); + const destinationRoot = join(symlinkParent, ".caplets"); + try { + writeInstallableRepo(repo); + mkdirSync(realParent, { recursive: true }); + symlinkSync(realParent, symlinkParent); + + expect(() => installCaplets(repo, { capletIds: ["github"], destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + expect(existsSync(join(realParent, ".caplets"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects nested install destinations under symlinked parents", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-nested-parent-symlink-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + const realParent = join(dir, "real-parent"); + const symlinkParent = join(destinationRoot, "github"); + try { + writeInstallableRepo(repo); + mkdirSync(destinationRoot, { recursive: true }); + mkdirSync(realParent, { recursive: true }); + symlinkSync(realParent, symlinkParent); + + expect(() => installCaplets(repo, { capletIds: ["github"], destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + expect(existsSync(join(realParent, "CAPLET.md"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects duplicate source IDs before all-install copies anything", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-duplicate-all-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + writeFileSync(join(repo, "caplets", "github.md"), capletFixture("GitHub File")); + + expect(() => installCaplets(repo, { destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + expect(existsSync(join(destinationRoot, "filesystem.md"))).toBe(false); + expect(existsSync(join(destinationRoot, "github"))).toBe(false); + expect(existsSync(join(destinationRoot, "github.md"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects duplicate source IDs before selected-install copies anything", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-duplicate-selected-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + writeFileSync(join(repo, "caplets", "github.md"), capletFixture("GitHub File")); + + expect(() => installCaplets(repo, { capletIds: ["github"], destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + expect(existsSync(join(destinationRoot, "github"))).toBe(false); + expect(existsSync(join(destinationRoot, "github.md"))).toBe(false); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -700,3 +1624,31 @@ function writeInstallableRepo(repo: string): void { "Extra files are copied with directory Caplets.\n", ); } + +function capletFixture(name: string): string { + return [ + "---", + `name: ${name}`, + "description: Test Caplet.", + "mcpServer:", + " command: npx", + " args:", + " - -y", + " - test-server", + "---", + `# ${name}`, + ].join("\n"); +} + +function writeCliRepo(repo: string): void { + mkdirSync(repo, { recursive: true }); + writeFileSync( + join(repo, "package.json"), + JSON.stringify({ + name: "fixture", + packageManager: "pnpm@11.0.9", + scripts: { test: "vitest run" }, + }), + ); + writeFileSync(join(repo, "pnpm-lock.yaml"), ""); +} diff --git a/test/config.test.ts b/test/config.test.ts index dc91dcee..a42ebcdf 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -11,16 +11,14 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { capletJsonSchema, loadCapletFiles } from "../src/caplet-files.js"; -import { configJsonSchema, loadConfig, parseConfig } from "../src/config.js"; +import { configJsonSchema, loadConfig, loadConfigWithSources, parseConfig } from "../src/config.js"; import { CapletsError } from "../src/errors.js"; describe("config", () => { const originalEnv = process.env.EXAMPLE_TOKEN; - const originalTrustProjectCaplets = process.env.CAPLETS_TRUST_PROJECT_CAPLETS; beforeEach(() => { process.env.EXAMPLE_TOKEN = "secret-value"; - delete process.env.CAPLETS_TRUST_PROJECT_CAPLETS; }); afterEach(() => { @@ -29,11 +27,6 @@ describe("config", () => { } else { process.env.EXAMPLE_TOKEN = originalEnv; } - if (originalTrustProjectCaplets === undefined) { - delete process.env.CAPLETS_TRUST_PROJECT_CAPLETS; - } else { - process.env.CAPLETS_TRUST_PROJECT_CAPLETS = originalTrustProjectCaplets; - } }); it("loads user config from a path with defaults and interpolation", () => { @@ -84,7 +77,7 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); }); - it("rejects executable OpenAPI endpoint config from untrusted project config", () => { + it("rejects OpenAPI executable backend maps from project config", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-project-openapi-")); const projectConfigPath = join(dir, ".caplets", "config.json"); mkdirSync(join(dir, ".caplets"), { recursive: true }); @@ -110,16 +103,22 @@ describe("config", () => { ); expect(() => loadConfig(join(dir, "missing-user-config.json"), projectConfigPath)).toThrow( - expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError, + expect.objectContaining({ + code: "CONFIG_INVALID", + message: expect.stringContaining( + "cannot define executable backend map openapiEndpoints; use project Markdown Caplet files or user config instead", + ) as string, + }) as CapletsError, ); rmSync(dir, { recursive: true, force: true }); delete process.env.PROJECT_OPENAPI_SECRET; }); - it("rejects executable GraphQL endpoint config from untrusted project config", () => { + it("rejects GraphQL executable backend maps from project config", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-project-graphql-")); const projectConfigPath = join(dir, ".caplets", "config.json"); mkdirSync(join(dir, ".caplets"), { recursive: true }); + process.env.PROJECT_GRAPHQL_SECRET = "must-not-leak"; writeFileSync( projectConfigPath, JSON.stringify({ @@ -141,15 +140,22 @@ describe("config", () => { ); expect(() => loadConfig(join(dir, "missing-user-config.json"), projectConfigPath)).toThrow( - expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError, + expect.objectContaining({ + code: "CONFIG_INVALID", + message: expect.stringContaining( + "cannot define executable backend map graphqlEndpoints; use project Markdown Caplet files or user config instead", + ) as string, + }) as CapletsError, ); rmSync(dir, { recursive: true, force: true }); + delete process.env.PROJECT_GRAPHQL_SECRET; }); - it("rejects executable HTTP API config from untrusted project config", () => { + it("rejects HTTP executable backend maps from project config", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-project-http-")); const projectConfigPath = join(dir, ".caplets", "config.json"); mkdirSync(join(dir, ".caplets"), { recursive: true }); + process.env.PROJECT_HTTP_SECRET = "must-not-leak"; writeFileSync( projectConfigPath, JSON.stringify({ @@ -173,9 +179,15 @@ describe("config", () => { ); expect(() => loadConfig(join(dir, "missing-user-config.json"), projectConfigPath)).toThrow( - expect.objectContaining({ code: "CONFIG_INVALID" }) as CapletsError, + expect.objectContaining({ + code: "CONFIG_INVALID", + message: expect.stringContaining( + "cannot define executable backend map httpApis; use project Markdown Caplet files or user config instead", + ) as string, + }) as CapletsError, ); rmSync(dir, { recursive: true, force: true }); + delete process.env.PROJECT_HTTP_SECRET; }); it("merges user config with project config and lets project config win", () => { @@ -235,7 +247,6 @@ describe("config", () => { const projectRoot = join(dir, "project", ".caplets"); mkdirSync(join(userRoot, "linear"), { recursive: true }); mkdirSync(join(projectRoot, "linear"), { recursive: true }); - process.env.CAPLETS_TRUST_PROJECT_CAPLETS = "1"; writeFileSync( join(userRoot, "github.md"), [ @@ -302,6 +313,129 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); }); + it("reports project Caplet file sources and shadowed global files", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-source-files-")); + const userRoot = join(dir, "user"); + const projectRoot = join(dir, "project", ".caplets"); + const globalPath = join(userRoot, "github.md"); + const projectPath = join(projectRoot, "github.md"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + globalPath, + [ + "---", + "name: GitHub Global", + "description: Use GitHub from the global Caplet file.", + "mcpServer:", + " command: global-github", + "---", + "# GitHub Global", + ].join("\n"), + ); + writeFileSync( + projectPath, + [ + "---", + "name: GitHub Project", + "description: Use GitHub from the project Caplet file.", + "mcpServer:", + " command: project-github", + "---", + "# GitHub Project", + ].join("\n"), + ); + + const { config, sources, shadows } = loadConfigWithSources( + join(userRoot, "config.json"), + join(projectRoot, "config.json"), + ); + + expect(config.mcpServers.github?.command).toBe("project-github"); + expect(sources.github).toEqual({ kind: "project-file", path: projectPath }); + expect(shadows.github).toContainEqual({ kind: "global-file", path: globalPath }); + rmSync(dir, { recursive: true, force: true }); + }); + + it("lets project Caplet files shadow global entries across backend maps", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cross-backend-source-files-")); + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectRoot = join(dir, "project", ".caplets"); + const projectPath = join(projectRoot, "github.md"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + github: { + name: "GitHub Global MCP", + description: "Use GitHub from global MCP config.", + command: "global-github", + }, + }, + }), + ); + writeFileSync( + projectPath, + [ + "---", + "name: GitHub Project API", + "description: Use GitHub from the project OpenAPI Caplet file.", + "openapiEndpoint:", + " specPath: ./github-openapi.json", + " auth:", + " type: none", + "---", + "# GitHub Project API", + ].join("\n"), + ); + + const { config, sources, shadows } = loadConfigWithSources( + userConfigPath, + join(projectRoot, "config.json"), + ); + + expect(config.mcpServers.github).toBeUndefined(); + expect(config.openapiEndpoints.github).toMatchObject({ + name: "GitHub Project API", + specPath: join(projectRoot, "github-openapi.json"), + }); + expect(sources.github).toEqual({ kind: "project-file", path: projectPath }); + expect(shadows.github).toEqual([{ kind: "global-config", path: userConfigPath }]); + rmSync(dir, { recursive: true, force: true }); + }); + + it("does not attribute global Caplet files as project files for nonstandard project config paths", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-nonstandard-project-path-")); + const userRoot = join(dir, ".caplets"); + const globalPath = join(userRoot, "github.md"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + globalPath, + [ + "---", + "name: GitHub Global", + "description: Use GitHub from the global Caplet file.", + "mcpServer:", + " command: global-github", + "---", + "# GitHub Global", + ].join("\n"), + ); + + const { config, sources, shadows } = loadConfigWithSources( + join(userRoot, "config.json"), + join(dir, "missing", "config.json"), + ); + + expect(config.mcpServers.github?.command).toBe("global-github"); + expect(sources.github).toEqual({ kind: "global-file", path: globalPath }); + expect(shadows.github).toBeUndefined(); + rmSync(dir, { recursive: true, force: true }); + }); + it("keeps repository example Caplets loadable", () => { const originalGithubToken = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; delete process.env.GITHUB_PERSONAL_ACCESS_TOKEN; @@ -371,7 +505,7 @@ describe("config", () => { } }); - it("does not load project Caplet files without explicit trust", () => { + it("loads project Caplet files without explicit trust", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-files-")); const userRoot = join(dir, "user"); const projectRoot = join(dir, "project", ".caplets"); @@ -383,7 +517,7 @@ describe("config", () => { mcpServers: { github: { name: "GitHub User", - description: "Use GitHub from trusted user config.", + description: "Use GitHub from user config.", command: "user-github", }, }, @@ -394,7 +528,7 @@ describe("config", () => { [ "---", "name: GitHub Project", - "description: Use GitHub from untrusted project Caplet.", + "description: Use GitHub from project Caplet.", "mcpServer:", " command: project-github", "---", @@ -403,8 +537,8 @@ describe("config", () => { ); const config = loadConfig(join(userRoot, "config.json"), join(projectRoot, "config.json")); - expect(config.mcpServers.github?.name).toBe("GitHub User"); - expect(config.mcpServers.github?.command).toBe("user-github"); + expect(config.mcpServers.github?.name).toBe("GitHub Project"); + expect(config.mcpServers.github?.command).toBe("project-github"); rmSync(dir, { recursive: true, force: true }); }); diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 6b8e4d48..7076bb12 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -265,6 +265,37 @@ describe("CapletsRuntime", () => { await runtime.close(); }); + it("watches project Caplet files without explicit trust", async () => { + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + alpha: { + name: "Alpha", + description: "Search alpha project documents.", + command: "node", + }, + }, + }); + dirs.push(dir); + const projectFile = join(dir, "project", ".caplets", "notes.txt"); + writeFileSync(projectFile, "before"); + const runtime = new CapletsRuntime({ + configPath, + projectConfigPath, + server: mockServer(), + watchDebounceMs: 10, + }); + let reloads = 0; + (runtime as unknown as { reload: () => Promise }).reload = vi.fn(async () => { + reloads += 1; + return true; + }); + + writeFileSync(projectFile, "after"); + await eventually(() => expect(reloads).toBeGreaterThan(0)); + + await runtime.close(); + }); + it("runs a follow-up reload when another reload is requested mid-flight", async () => { const { dir, configPath, projectConfigPath } = tempConfig({ mcpServers: { From 33bd0464c167946ed521d9b0885b8e0238fdea18 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 15 May 2026 00:24:18 -0400 Subject: [PATCH 2/2] fix: address project workflow review feedback --- README.md | 5 +- .../2026-05-14-project-first-caplets-add.md | 2 +- src/cli.ts | 2 - src/cli/add.ts | 75 +++++++++++++++---- src/config.ts | 2 +- src/runtime.ts | 6 +- test/author-cli.test.ts | 18 +++++ test/runtime.test.ts | 22 +++--- 8 files changed, 96 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 75bddb73..7dd3021f 100644 --- a/README.md +++ b/README.md @@ -668,7 +668,10 @@ caplets list --json Human output includes a `source` column. JSON output includes each Caplet's `source`, `path`, and `shadows` metadata. If a project source overrides a user source, human output prints a warning such -as `Warning: project Caplet github shadows global Caplet at /path/to/github.md`. +as `Warning: project Caplet GitHub shadows global Caplet at /path/to/github.md`. + +For safety, `caplets add` and `caplets install` reject symlinked output paths and symlinked parent +directories instead of following them. ### Optional Server Settings diff --git a/docs/superpowers/plans/2026-05-14-project-first-caplets-add.md b/docs/superpowers/plans/2026-05-14-project-first-caplets-add.md index 7b89e8bb..1e3871ca 100644 --- a/docs/superpowers/plans/2026-05-14-project-first-caplets-add.md +++ b/docs/superpowers/plans/2026-05-14-project-first-caplets-add.md @@ -93,7 +93,7 @@ - [ ] Change `caplets list` to use `loadConfigWithSources()`. - [ ] Add a `source` column to human table output. -- [ ] Append short warning lines for shadowed Caplets, for example: `Warning: project Caplet github shadows global Caplet at /path/to/github.md`. +- [ ] Append short warning lines for shadowed Caplets, for example: `Warning: project Caplet GitHub shadows global Caplet at /path/to/github.md`. - [ ] Add `source`, `path`, and `shadows` fields to `caplets list --json` output. - [ ] Keep disabled filtering behavior unchanged. - [ ] Add tests for human source output. diff --git a/src/cli.ts b/src/cli.ts index cc91a6ce..dfc12787 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,7 +6,6 @@ import { addHttpCaplet, addMcpCaplet, addOpenApiCaplet, - assertValidCapletId, } from "./cli/add.js"; import { loginAuth, logoutAuth, listAuth } from "./cli/auth.js"; import { initConfig } from "./cli/init.js"; @@ -146,7 +145,6 @@ export function createProgram(io: CliIO = {}): Command { force?: boolean; }, ) => { - assertValidCapletId(id); const result = addCliCaplet(id, { ...options, destinationRoot: options.global diff --git a/src/cli/add.ts b/src/cli/add.ts index 11fecda3..42786946 100644 --- a/src/cli/add.ts +++ b/src/cli/add.ts @@ -1,4 +1,14 @@ -import { lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + closeSync, + constants, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + rmSync, + writeFileSync, + writeSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, parse, relative, resolve } from "node:path"; import { validateCapletFile } from "../caplet-files.js"; @@ -70,7 +80,7 @@ export function addCliCaplet( const path = resolveAddOutputPath(id, options); - writeCapletOutput(path, text); + writeCapletOutput(path, text, Boolean(options.force)); return { path, text }; } @@ -86,6 +96,12 @@ export function addMcpCaplet(id: string, options: AddMcpOptions): { path?: strin if (options.transport && !hasUrl) { throw new CapletsError("REQUEST_INVALID", "--transport requires --url"); } + if (options.tokenEnv && !hasUrl) { + throw new CapletsError("REQUEST_INVALID", "--token-env requires --url"); + } + if (hasUrl && (options.arg?.length || options.cwd || options.env?.length)) { + throw new CapletsError("REQUEST_INVALID", "--arg, --cwd, and --env require --command"); + } if (options.transport && options.transport !== "http" && options.transport !== "sse") { throw new CapletsError("REQUEST_INVALID", "--transport must be http or sse"); } @@ -211,22 +227,24 @@ function writeGeneratedCaplet( if (options.print) { return { text }; } - writeCapletOutput(path, text); + writeCapletOutput(path, text, Boolean(options.force)); return { path, text }; } -function writeCapletOutput(path: string, text: string): void { +function writeCapletOutput(path: string, text: string, force: boolean): void { try { rejectUnsafeDestinationParents(path); mkdirSync(dirname(path), { recursive: true }); rejectUnsafeDestinationParents(path); - rejectSymlinkDestination(path); - writeFileSync(path, text); + if (force) { + removeExistingRegularFile(path); + } + writeFileNoFollow(path, text); } catch (error) { if (error instanceof CapletsError) { throw error; } - if (isFsError(error, "EEXIST") || isFsError(error, "EISDIR")) { + if (isFsError(error, "EEXIST") || isFsError(error, "EISDIR") || isFsError(error, "ELOOP")) { throw new CapletsError( "CONFIG_EXISTS", `Output path ${path} already exists`, @@ -241,6 +259,40 @@ function writeCapletOutput(path: string, text: string): void { } } +function removeExistingRegularFile(path: string): void { + const stats = lstatIfExists(path); + if (!stats) { + return; + } + if (stats.isSymbolicLink()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet file at ${path} is a symlink; remove it before writing`, + ); + } + if (!stats.isFile()) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet file at ${path} exists but is not a regular file; choose --output`, + ); + } + rmSync(path); +} + +function writeFileNoFollow(path: string, text: string): void { + const noFollowFlag = constants.O_NOFOLLOW ?? 0; + const fd = openSync( + path, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag, + 0o600, + ); + try { + writeSync(fd, text); + } finally { + closeSync(fd); + } +} + function isFsError(error: unknown, code: string): boolean { return typeof error === "object" && error !== null && "code" in error && error.code === code; } @@ -329,15 +381,6 @@ function localPathRelativeToOutput(path: string, outputDir: string): string { return rendered === "" ? "." : rendered; } -function rejectSymlinkDestination(path: string): void { - if (lstatIfExists(path)?.isSymbolicLink()) { - throw new CapletsError( - "CONFIG_EXISTS", - `Caplet file at ${path} is a symlink; remove it before writing`, - ); - } -} - function rejectUnsafeDestinationParents(path: string): void { const parent = dirname(resolve(path)); const root = parse(parent).root; diff --git a/src/config.ts b/src/config.ts index 29a9495e..fc375fd9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1289,7 +1289,7 @@ function mergeConfigInputs(...inputs: Array): ConfigInp } function mergeConfigInputsWithSources(...inputs: Array): { - input: ConfigInput | undefined; + input: ConfigInput; sources: Record; shadows: Record; } { diff --git a/src/runtime.ts b/src/runtime.ts index 806dd55c..7263160e 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -9,7 +9,6 @@ import { loadConfig, resolveCapletsRoot, resolveConfigPath, - resolveProjectCapletsRoot, resolveProjectConfigPath, } from "./config.js"; import { CliToolsManager } from "./cli-tools.js"; @@ -387,10 +386,7 @@ function watchedPaths(paths: RuntimePaths): WatchedPath[] { { path: dirname(paths.configPath), reason: "config" }, { path: dirname(paths.projectConfigPath), reason: "config" }, { path: resolveCapletsRoot(paths.configPath), reason: "caplets" }, - { - path: resolveProjectCapletsRoot(dirname(dirname(paths.projectConfigPath))), - reason: "caplets", - }, + { path: dirname(paths.projectConfigPath), reason: "caplets" }, ]; return uniqueWatchedPaths(entries); diff --git a/test/author-cli.test.ts b/test/author-cli.test.ts index 9519cc24..49e8ba59 100644 --- a/test/author-cli.test.ts +++ b/test/author-cli.test.ts @@ -253,6 +253,24 @@ describe("CLI Caplet authoring", () => { destinationRoot, }), ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(() => + addMcpCaplet("bad-stdio-token", { + command: "node", + tokenEnv: "MCP_TOKEN", + print: true, + destinationRoot, + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); + expect(() => + addMcpCaplet("bad-remote-stdio-flags", { + url: "https://mcp.example.com/mcp", + arg: ["server.js"], + cwd: dir, + env: ["TOKEN=value"], + print: true, + destinationRoot, + }), + ).toThrow(expect.objectContaining({ code: "REQUEST_INVALID" }) as CapletsError); expect(() => addHttpCaplet("bad-http", { baseUrl: "https://api.example.com", diff --git a/test/runtime.test.ts b/test/runtime.test.ts index 7076bb12..0d8dd258 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -284,16 +284,18 @@ describe("CapletsRuntime", () => { server: mockServer(), watchDebounceMs: 10, }); - let reloads = 0; - (runtime as unknown as { reload: () => Promise }).reload = vi.fn(async () => { - reloads += 1; - return true; - }); - - writeFileSync(projectFile, "after"); - await eventually(() => expect(reloads).toBeGreaterThan(0)); - - await runtime.close(); + try { + let reloads = 0; + (runtime as unknown as { reload: () => Promise }).reload = vi.fn(async () => { + reloads += 1; + return true; + }); + + writeFileSync(projectFile, "after"); + await eventually(() => expect(reloads).toBeGreaterThan(0)); + } finally { + await runtime.close(); + } }); it("runs a follow-up reload when another reload is requested mid-flight", async () => {