From 3065b0188a3381ebf055432012c774fbd24d4438 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 15:58:44 -0400 Subject: [PATCH 1/8] feat: add cli inspection commands --- .changeset/clean-spoons-tell.md | 5 + README.md | 17 ++ .../plans/2026-05-12-cli-inspection-polish.md | 88 ++++++++ src/cli.ts | 169 ++++++++++++++- test/cli.test.ts | 193 +++++++++++++++++- 5 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 .changeset/clean-spoons-tell.md create mode 100644 docs/plans/2026-05-12-cli-inspection-polish.md diff --git a/.changeset/clean-spoons-tell.md b/.changeset/clean-spoons-tell.md new file mode 100644 index 00000000..e453cf13 --- /dev/null +++ b/.changeset/clean-spoons-tell.md @@ -0,0 +1,5 @@ +--- +"caplets": minor +--- + +Add CLI inspection commands for version, configured Caplets, and resolved config paths. diff --git a/README.md b/README.md index 3443f944..fd361d86 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,15 @@ CAPLETS_CONFIG=/path/to/config.json caplets init CAPLETS_CONFIG=/path/to/config.json caplets serve ``` +Inspect the installed CLI version and resolved config locations: + +```sh +caplets --version +caplets config path +caplets config paths +caplets config paths --json +``` + Caplets validates this file at startup. Config changes take effect after restarting the Caplets MCP server. @@ -390,6 +399,14 @@ caplets auth list caplets auth logout ``` +To list configured Caplets without starting downstream backends: + +```sh +caplets list +caplets list --all +caplets list --json +``` + ### Optional Server Settings Every server can set: diff --git a/docs/plans/2026-05-12-cli-inspection-polish.md b/docs/plans/2026-05-12-cli-inspection-polish.md new file mode 100644 index 00000000..851ac039 --- /dev/null +++ b/docs/plans/2026-05-12-cli-inspection-polish.md @@ -0,0 +1,88 @@ +# Caplets CLI Inspection Polish + +## Summary + +Add a small read-only inspection suite to the existing Commander-based CLI: + +- `caplets --version` prints the package version from `package.json`. +- `caplets list` lists enabled configured Caplets by default. +- `caplets list --all` includes disabled entries. +- `caplets list --json` emits stable machine-readable JSON. +- `caplets config path` prints the effective user config path, honoring `CAPLETS_CONFIG`. +- `caplets config paths` prints user config, project config, Caplets roots, auth directory, and trust/env state. + +No runtime MCP server behavior changes. + +## Key Changes + +- Update `src/cli.ts` to import `package.json` version and register Commander version support. +- Add a `list` command that loads config through existing `loadConfig(envConfigPath())`, builds a `ServerRegistry`, and prints: + - text columns: `server`, `backend`, `status`, `name` + - JSON array objects: `{ server, backend, name, description, disabled, status }` +- Add `config` subcommands: + - `config path`: prints only `resolveConfigPath(envConfigPath())` + - `config paths`: prints resolved user config path, project config path, user root, project root, auth dir, whether `CAPLETS_CONFIG` is set, and whether `CAPLETS_TRUST_PROJECT_CAPLETS` is enabled + - `--json` on `config paths` for scriptable output +- Keep existing `init` and `auth` behavior unchanged. +- Keep disabled Caplets excluded from `caplets list` unless `--all` is passed. + +## Interfaces + +Public CLI additions: + +```sh +caplets --version +caplets list +caplets list --all +caplets list --json +caplets config path +caplets config paths +caplets config paths --json +``` + +Recommended text output shapes: + +```txt +server backend status name +docs mcp not_started Hosted Docs +users openapi not_started Users API +``` + +```txt +userConfig: /home/you/.caplets/config.json +projectConfig: /repo/.caplets/config.json +userRoot: /home/you/.caplets +projectRoot: /repo/.caplets +authDir: /home/you/.caplets/auth +envConfig: unset +projectCapletsTrusted: false +``` + +## Test Plan + +Add focused tests in `test/cli.test.ts`: + +- `runCli(["--version"])` prints `package.json` version and does not throw. +- `runCli(["list"])` prints only enabled MCP/OpenAPI/GraphQL Caplets. +- `runCli(["list", "--all"])` includes disabled Caplets with `disabled` status. +- `runCli(["list", "--json"])` emits parseable JSON and redacts/no-ops on auth secrets. +- `runCli(["config", "path"])` honors `CAPLETS_CONFIG`. +- `runCli(["config", "paths", "--json"])` returns stable resolved paths and env/trust flags. +- Unsupported options still raise `REQUEST_INVALID`. + +Run: + +```sh +pnpm format:check +pnpm lint +pnpm typecheck +pnpm test +pnpm build +``` + +## Assumptions + +- "Servers" means all configured Caplet backends: MCP servers, OpenAPI endpoints, and GraphQL endpoints. +- Human-readable text is the default; `--json` is the stable automation contract. +- The inspection commands must be read-only and must not start downstream servers, load remote specs, or validate live OAuth. +- `caplets serve` remains handled by `src/index.ts` as the MCP server entrypoint. diff --git a/src/cli.ts b/src/cli.ts index 9290a89c..0c51f1a6 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,8 +3,20 @@ import { stdin as input, stdout as output } from "node:process"; import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { Command, CommanderError } from "commander"; -import { loadConfig, resolveConfigPath } from "./config.js"; +import { version as packageJsonVersion } from "../package.json"; +import { + DEFAULT_AUTH_DIR, + loadConfig, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectCapletsRoot, + resolveProjectConfigPath, + TRUST_PROJECT_CAPLETS_ENV, + type CapletConfig, + type CapletsConfig, +} from "./config.js"; import { CapletsError, toSafeError } from "./errors.js"; +import type { ServerStatus } from "./registry.js"; import { deleteTokenBundle, isTokenBundleExpired, @@ -20,6 +32,25 @@ type CliIO = { authDir?: string; }; +type CapletListRow = { + server: string; + backend: CapletConfig["backend"]; + name: string; + description: string; + disabled: boolean; + status: ServerStatus; +}; + +type ConfigPaths = { + userConfig: string; + projectConfig: string; + userRoot: string; + projectRoot: string; + authDir: string; + envConfig: string | null; + projectCapletsTrusted: boolean; +}; + export async function runCli(args: string[], io: CliIO = {}): Promise { const program = createProgram(io); try { @@ -43,6 +74,7 @@ export function createProgram(io: CliIO = {}): Command { program .name("caplets") .description("Progressive-disclosure gateway for MCP servers.") + .version(packageJsonVersion) .exitOverride() .configureOutput({ writeOut, @@ -63,6 +95,43 @@ export function createProgram(io: CliIO = {}): Command { writeOut(`Created Caplets config at ${path}\n`); }); + program + .command("list") + .description("List configured Caplets.") + .option("--all", "include disabled Caplets") + .option("--json", "print JSON output") + .action((options: { all?: boolean; json?: boolean }) => { + const config = loadConfig(envConfigPath()); + const rows = listCaplets(config, { includeDisabled: Boolean(options.all) }); + if (options.json) { + writeOut(`${JSON.stringify(rows, null, 2)}\n`); + return; + } + writeOut(formatCapletList(rows)); + }); + + const config = program.command("config").description("Inspect Caplets config locations."); + + config + .command("path") + .description("Print the effective user config path.") + .action(() => { + writeOut(`${resolveConfigPath(envConfigPath())}\n`); + }); + + config + .command("paths") + .description("Print resolved Caplets config, root, and auth paths.") + .option("--json", "print JSON output") + .action((options: { json?: boolean }) => { + const paths = resolveCliConfigPaths(io.authDir); + if (options.json) { + writeOut(`${JSON.stringify(paths, null, 2)}\n`); + return; + } + writeOut(formatConfigPaths(paths)); + }); + const auth = program.command("auth").description("Manage OAuth credentials for remote servers."); auth @@ -131,6 +200,104 @@ export function createProgram(io: CliIO = {}): Command { return program; } +function listCaplets( + config: CapletsConfig, + options: { includeDisabled: boolean }, +): CapletListRow[] { + const rows = allCaplets(config) + .filter((server) => options.includeDisabled || !server.disabled) + .map((server) => ({ + server: server.server, + backend: server.backend, + name: server.name, + description: server.description, + disabled: server.disabled, + status: initialServerStatus(server), + })); + return rows.sort((left, right) => left.server.localeCompare(right.server)); +} + +function initialServerStatus(server: CapletConfig): ServerStatus { + return server.disabled ? "disabled" : "not_started"; +} + +function allCaplets(config: CapletsConfig): CapletConfig[] { + return [ + ...Object.values(config.mcpServers), + ...Object.values(config.openapiEndpoints), + ...Object.values(config.graphqlEndpoints), + ]; +} + +function formatCapletList(rows: CapletListRow[]): string { + if (rows.length === 0) { + return "No configured Caplets found.\n"; + } + + return `${formatTable([ + ["server", "backend", "status", "name"], + ...rows.map((row) => [row.server, row.backend, row.status, row.name]), + ])}\n`; +} + +function resolveCliConfigPaths(authDir?: string): ConfigPaths { + const envConfig = envConfigPath(); + const configPath = resolveConfigPath(envConfig); + return { + userConfig: configPath, + projectConfig: resolveProjectConfigPath(), + userRoot: resolveCapletsRoot(configPath), + projectRoot: resolveProjectCapletsRoot(), + authDir: authDir ?? DEFAULT_AUTH_DIR, + envConfig: envConfig ?? null, + projectCapletsTrusted: isTrustedProjectCapletsEnabled(), + }; +} + +function formatConfigPaths(paths: ConfigPaths): string { + return ( + [ + `userConfig: ${paths.userConfig}`, + `projectConfig: ${paths.projectConfig}`, + `userRoot: ${paths.userRoot}`, + `projectRoot: ${paths.projectRoot}`, + `authDir: ${paths.authDir}`, + `envConfig: ${paths.envConfig ?? "unset"}`, + `projectCapletsTrusted: ${paths.projectCapletsTrusted}`, + ].join("\n") + "\n" + ); +} + +function isTrustedProjectCapletsEnabled(): boolean { + const value = process.env[TRUST_PROJECT_CAPLETS_ENV]; + return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; +} + +function formatTable(rows: string[][]): string { + const firstRow = rows[0]; + if (!firstRow) { + return ""; + } + + const widths = firstRow.map((_, column) => + Math.max(...rows.map((row) => row[column]?.length ?? 0)), + ); + + return rows.map((row) => formatTableRow(row, widths)).join("\n"); +} + +function formatTableRow(row: string[], widths: number[]): string { + return row + .map((value, column) => { + if (column === row.length - 1) { + return value; + } + return value.padEnd((widths[column] ?? 0) + 2); + }) + .join("") + .trimEnd(); +} + async function loginAuth( serverId: string, options: { diff --git a/test/cli.test.ts b/test/cli.test.ts index c3cab81a..ed6b26ef 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -2,13 +2,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { version as packageJsonVersion } from "../package.json"; import { initConfig, runCli, starterConfig } from "../src/cli.js"; -import { parseConfig } from "../src/config.js"; +import { parseConfig, TRUST_PROJECT_CAPLETS_ENV } 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(); @@ -17,6 +19,11 @@ 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", () => { @@ -90,6 +97,148 @@ describe("cli init", () => { ); }); + it("prints the package version", async () => { + const out: string[] = []; + + await runCli(["--version"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toBe(`${packageJsonVersion}\n`); + }); + + it("lists enabled Caplets by default", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeInspectionConfig(configPath); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["list"], { writeOut: (value) => out.push(value) }); + + const text = out.join(""); + expect(text).toContain("server"); + expect(text).toContain("filesystem"); + expect(text).toContain("mcp"); + expect(text).toContain("not_started"); + expect(text).toContain("Project Files"); + expect(text).toContain("users"); + expect(text).toContain("openapi"); + expect(text).toContain("catalog"); + expect(text).toContain("graphql"); + expect(text).not.toContain("disabled_remote"); + expect(text).not.toContain("secret-access-token"); + expect(text).not.toContain("openapi-client"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("can include disabled Caplets in the list", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeInspectionConfig(configPath); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["list", "--all"], { writeOut: (value) => out.push(value) }); + + const text = out.join(""); + expect(text).toContain("disabled_remote"); + expect(text).toContain("disabled"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints listed Caplets as JSON", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-list-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeInspectionConfig(configPath); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["list", "--json"], { writeOut: (value) => out.push(value) }); + + const rows = JSON.parse(out.join("")) as Array<{ + server: string; + backend: string; + name: string; + description: string; + disabled: boolean; + status: string; + }>; + expect(rows).toEqual([ + expect.objectContaining({ + server: "catalog", + backend: "graphql", + disabled: false, + status: "not_started", + }), + expect.objectContaining({ + server: "filesystem", + backend: "mcp", + disabled: false, + status: "not_started", + }), + expect.objectContaining({ + server: "users", + backend: "openapi", + disabled: false, + status: "not_started", + }), + ]); + expect(out.join("")).not.toContain("secret-access-token"); + expect(out.join("")).not.toContain("openapi-client"); + } finally { + 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"); + const out: string[] = []; + try { + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["config", "path"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toBe(`${configPath}\n`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints resolved config paths as JSON", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-config-paths-")); + const configPath = join(dir, "custom.json"); + const authDir = join(dir, "auth"); + 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), + authDir, + }); + + expect(JSON.parse(out.join(""))).toEqual({ + userConfig: configPath, + projectConfig: join(process.cwd(), ".caplets", "config.json"), + userRoot: dir, + projectRoot: join(process.cwd(), ".caplets"), + authDir, + envConfig: configPath, + projectCapletsTrusted: true, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("lists configured OAuth servers without printing token values", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); const authDir = join(dir, "auth"); @@ -239,3 +388,45 @@ describe("cli init", () => { } }); }); + +function writeInspectionConfig(path: string): void { + writeFileSync( + path, + JSON.stringify({ + mcpServers: { + filesystem: { + name: "Project Files", + description: "Read and search local project files.", + command: "node", + env: { + TOKEN: "secret-access-token", + }, + }, + disabled_remote: { + name: "Disabled Remote", + description: "A disabled remote server for testing.", + transport: "http", + url: "https://disabled.example.com/mcp", + disabled: true, + }, + }, + openapiEndpoints: { + users: { + name: "Users API", + description: "Manage users through the internal HTTP API.", + specPath: "/tmp/users-openapi.json", + auth: { type: "oauth2", clientId: "openapi-client" }, + }, + }, + graphqlEndpoints: { + catalog: { + name: "Catalog GraphQL", + description: "Query and update catalog data through GraphQL.", + endpointUrl: "https://api.example.com/graphql", + schemaPath: "/tmp/catalog.graphql", + auth: { type: "none" }, + }, + }, + }), + ); +} From 48eb0ba716caa64ffbddb933e3a665b4fa728e57 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 16:50:24 -0400 Subject: [PATCH 2/8] feat: install caplets from repositories --- .changeset/clean-spoons-tell.md | 2 +- README.md | 24 ++++++ caplets/context7.md | 33 ++++++++ caplets/github/CAPLET.md | 54 +++++++++++++ caplets/github/README.md | 13 +++ caplets/linear/CAPLET.md | 46 +++++++++++ caplets/linear/workflows.md | 9 +++ package.json | 1 + src/caplet-files.ts | 2 +- src/cli.ts | 139 +++++++++++++++++++++++++++++++- test/cli.test.ts | 121 ++++++++++++++++++++++++++- test/config.test.ts | 36 ++++++++- 12 files changed, 474 insertions(+), 6 deletions(-) create mode 100644 caplets/context7.md create mode 100644 caplets/github/CAPLET.md create mode 100644 caplets/github/README.md create mode 100644 caplets/linear/CAPLET.md create mode 100644 caplets/linear/workflows.md diff --git a/.changeset/clean-spoons-tell.md b/.changeset/clean-spoons-tell.md index e453cf13..2d18852c 100644 --- a/.changeset/clean-spoons-tell.md +++ b/.changeset/clean-spoons-tell.md @@ -2,4 +2,4 @@ "caplets": minor --- -Add CLI inspection commands for version, configured Caplets, and resolved config paths. +Add CLI inspection commands for version, configured Caplets, resolved config paths, and installing Caplets from a repo. diff --git a/README.md b/README.md index fd361d86..1e72d1f8 100644 --- a/README.md +++ b/README.md @@ -210,6 +210,30 @@ Top-level files derive their Caplet ID from the filename. Directory-style Caplet `linear/CAPLET.md`, which is exposed as `linear`; sibling files can be referenced with normal Markdown links from `CAPLET.md`. +This repository includes polished working examples under [`caplets/`](caplets/): + +- `github`: GitHub's official MCP server container, using `GITHUB_PERSONAL_ACCESS_TOKEN`. +- `linear`: Linear's hosted OAuth MCP endpoint. +- `context7`: Context7 documentation lookup through `@upstash/context7-mcp`. + +Install every example from a repo's `caplets/` directory: + +```sh +caplets install spiritledsoftware/caplets +``` + +Install one or more individual Caplets by ID: + +```sh +caplets install spiritledsoftware/caplets github +caplets install spiritledsoftware/caplets github linear +``` + +`caplets install` accepts a GitHub `owner/repo` shorthand, a Git URL, or a local repository path. +It installs into your user Caplets root, which is `~/.caplets` by default or the parent directory +of `CAPLETS_CONFIG` when that environment variable is set. Existing Caplets are not overwritten +unless `--force` is passed. + Caplets always loads user Caplet files from `~/.caplets`. Project `./.caplets/config.json` is still loaded as project config, but project Markdown Caplet files are executable configuration and are ignored unless explicitly trusted: diff --git a/caplets/context7.md b/caplets/context7.md new file mode 100644 index 00000000..b3930868 --- /dev/null +++ b/caplets/context7.md @@ -0,0 +1,33 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: Context7 Documentation +description: Fetch current library and framework documentation through Context7 before using version-sensitive APIs. +tags: + - docs + - libraries + - frameworks + - api-reference +mcpServer: + command: npx + args: + - -y + - "@upstash/context7-mcp" +--- + +# Context7 Documentation + +Use this Caplet when the agent needs up-to-date library, SDK, framework, CLI, or cloud-service +documentation before writing code or giving technical instructions. + +## Good Fits + +- Check current API signatures for fast-moving JavaScript, TypeScript, Python, or cloud libraries. +- Look up migration notes before changing framework configuration. +- Retrieve official examples for a specific package version. +- Resolve uncertainty about CLI flags, config files, or SDK initialization. + +## Use Carefully + +- Prefer primary documentation over snippets when implementation risk is high. +- Record the library or package name clearly before searching. +- Do not use this as a substitute for project-local types and tests. diff --git a/caplets/github/CAPLET.md b/caplets/github/CAPLET.md new file mode 100644 index 00000000..594e34b4 --- /dev/null +++ b/caplets/github/CAPLET.md @@ -0,0 +1,54 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: GitHub +description: Inspect and manage GitHub repositories, issues, pull requests, branches, commits, and code review workflows. +tags: + - code + - github + - pull-requests + - issues + - reviews +mcpServer: + command: docker + args: + - run + - -i + - --rm + - -e + - GITHUB_PERSONAL_ACCESS_TOKEN + - ghcr.io/github/github-mcp-server + env: + GITHUB_PERSONAL_ACCESS_TOKEN: $env:GITHUB_PERSONAL_ACCESS_TOKEN +--- + +# GitHub + +Use this Caplet when the agent needs live GitHub repository context or needs to act on +issues, pull requests, branches, commits, or review feedback. + +## Good Fits + +- Summarize recent pull request activity before a code review. +- Inspect open issues and identify implementation work. +- Create or update issues from an implementation plan. +- Compare branches, inspect commits, or review pull request files. +- Leave review comments after the agent has inspected the relevant diff. + +## Use Carefully + +- Mutating operations can affect real repositories. Prefer read operations first. +- Use a least-privilege `GITHUB_PERSONAL_ACCESS_TOKEN`. +- Do not ask the agent to expose token values, repository secrets, or private issue contents outside + the intended conversation. + +## Setup + +Create a GitHub personal access token with the minimum repository scopes needed for your workflow, +then export it before starting Caplets: + +```sh +export GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_... +caplets serve +``` + +This Caplet uses GitHub's official MCP server container, so Docker must be available on the host. diff --git a/caplets/github/README.md b/caplets/github/README.md new file mode 100644 index 00000000..6b7a2ce1 --- /dev/null +++ b/caplets/github/README.md @@ -0,0 +1,13 @@ +# GitHub Caplet + +This Caplet wraps GitHub's official MCP server container: + +```sh +docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server +``` + +Install it from this repo: + +```sh +caplets install spiritledsoftware/caplets github +``` diff --git a/caplets/linear/CAPLET.md b/caplets/linear/CAPLET.md new file mode 100644 index 00000000..5814a68a --- /dev/null +++ b/caplets/linear/CAPLET.md @@ -0,0 +1,46 @@ +--- +$schema: https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplet.schema.json +name: Linear +description: Plan and track product work in Linear by reading teams, projects, cycles, issues, comments, and workflow state. +tags: + - planning + - linear + - issues + - projects + - triage +mcpServer: + transport: http + url: https://mcp.linear.app/mcp + auth: + type: oauth2 +--- + +# Linear + +Use this Caplet when the agent needs live product planning context from Linear or needs to keep +implementation work synchronized with issues, projects, and team workflows. + +## Good Fits + +- Find the current issue or project that matches a requested feature. +- Summarize open work by team, project, cycle, label, or assignee. +- Draft issue breakdowns from a technical plan. +- Add implementation notes or status comments after code changes. +- Check whether a bug or feature already has active work before creating a new issue. + +## Use Carefully + +- Linear issue updates are visible to teammates. Read first, then write deliberately. +- Keep issue titles and comments concise; use links to detailed implementation artifacts when useful. +- Avoid broad, noisy searches when a team key, issue ID, project, or label is available. + +## Setup + +Authenticate once through Caplets: + +```sh +caplets auth login linear +``` + +The Linear MCP endpoint supports OAuth. Caplets stores the resulting token bundle in your local +Caplets auth store. diff --git a/caplets/linear/workflows.md b/caplets/linear/workflows.md new file mode 100644 index 00000000..65f50b71 --- /dev/null +++ b/caplets/linear/workflows.md @@ -0,0 +1,9 @@ +# Linear Workflows + +Useful agent flows: + +- **Issue lookup**: search by issue key first, then by title or project if no key is provided. +- **Planning**: read project context, summarize constraints, then create child issues only when the + requested breakdown is clear. +- **Status updates**: comment with what changed, verification run, and remaining risk. +- **Triage**: group candidate issues by urgency, owner, and whether they are blocked. diff --git a/package.json b/package.json index de5bf9ba..5cafafb6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "files": [ "dist", + "caplets", "schemas", "README.md", "LICENSE" diff --git a/src/caplet-files.ts b/src/caplet-files.ts index fc7efbff..3a89e001 100644 --- a/src/caplet-files.ts +++ b/src/caplet-files.ts @@ -459,7 +459,7 @@ export function loadCapletFiles(root: string): CapletFileConfig | undefined { : undefined; } -function discoverCapletFiles(root: string): Array<{ id: string; path: string }> { +export function discoverCapletFiles(root: string): Array<{ id: string; path: string }> { const entries = readdirSync(root, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name), ); diff --git a/src/cli.ts b/src/cli.ts index 0c51f1a6..697cfc2d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,9 +1,21 @@ import { createInterface } from "node:readline/promises"; import { stdin as input, stdout as output } from "node:process"; -import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; +import { execFileSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; import { Command, CommanderError } from "commander"; import { version as packageJsonVersion } from "../package.json"; +import { discoverCapletFiles, loadCapletFiles } from "./caplet-files.js"; import { DEFAULT_AUTH_DIR, loadConfig, @@ -51,6 +63,13 @@ type ConfigPaths = { projectCapletsTrusted: boolean; }; +type InstallableCaplet = { + id: string; + source: string; + destination: string; + kind: "file" | "directory"; +}; + export async function runCli(args: string[], io: CliIO = {}): Promise { const program = createProgram(io); try { @@ -110,6 +129,23 @@ export function createProgram(io: CliIO = {}): Command { writeOut(formatCapletList(rows)); }); + program + .command("install") + .description("Install Caplets from a repo's caplets directory.") + .argument("", "local repo path, Git URL, or GitHub owner/repo") + .argument("[caplets...]", "optional Caplet IDs to install") + .option("--force", "overwrite installed Caplets") + .action((repo: string, capletIds: string[], options: { force?: boolean }) => { + const result = installCaplets(repo, { + capletIds, + force: Boolean(options.force), + destinationRoot: resolveCapletsRoot(resolveConfigPath(envConfigPath())), + }); + for (const caplet of result.installed) { + writeOut(`Installed ${caplet.id} to ${caplet.destination}\n`); + } + }); + const config = program.command("config").description("Inspect Caplets config locations."); config @@ -200,6 +236,105 @@ export function createProgram(io: CliIO = {}): Command { return program; } +export function installCaplets( + repo: string, + options: { + capletIds?: string[]; + destinationRoot?: string; + force?: boolean; + } = {}, +): { installed: InstallableCaplet[] } { + const source = resolveInstallSource(repo); + try { + const sourceRoot = join(source.repoRoot, "caplets"); + if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) { + throw new CapletsError("CONFIG_NOT_FOUND", `No caplets directory found at ${sourceRoot}`); + } + + const selectedIds = new Set(options.capletIds ?? []); + const destinationRoot = options.destinationRoot ?? resolveCapletsRoot(resolveConfigPath()); + const available = discoverCapletFiles(sourceRoot); + const selected = + selectedIds.size === 0 ? available : available.filter((caplet) => selectedIds.has(caplet.id)); + const missing = [...selectedIds].filter((id) => !available.some((caplet) => caplet.id === id)); + if (missing.length > 0) { + throw new CapletsError( + "CONFIG_NOT_FOUND", + `Caplet ${missing.join(", ")} not found in ${sourceRoot}`, + ); + } + if (selected.length === 0) { + throw new CapletsError("CONFIG_NOT_FOUND", `No Caplets found in ${sourceRoot}`); + } + + loadCapletFiles(sourceRoot); + mkdirSync(destinationRoot, { recursive: true, mode: 0o700 }); + + const installed = selected.map((caplet) => + installOneCaplet(caplet, { destinationRoot, force: Boolean(options.force) }), + ); + return { installed }; + } finally { + source.cleanup(); + } +} + +function resolveInstallSource(repo: string): { repoRoot: string; cleanup: () => void } { + if (existsSync(repo) && statSync(repo).isDirectory()) { + return { repoRoot: repo, cleanup: () => {} }; + } + + const repoRoot = mkdtempSync(join(tmpdir(), "caplets-install-")); + try { + execFileSync("git", ["clone", "--depth", "1", normalizeGitRepo(repo), repoRoot], { + stdio: "ignore", + }); + return { + repoRoot, + cleanup: () => rmSync(repoRoot, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(repoRoot, { recursive: true, force: true }); + throw new CapletsError("CONFIG_NOT_FOUND", `Could not clone repo ${repo}`, toSafeError(error)); + } +} + +function normalizeGitRepo(repo: string): string { + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + return `https://github.com/${repo}.git`; + } + return repo; +} + +function installOneCaplet( + caplet: { id: string; path: string }, + options: { destinationRoot: string; force: boolean }, +): InstallableCaplet { + const isDirectory = basename(caplet.path) === "CAPLET.md"; + const source = isDirectory ? dirname(caplet.path) : caplet.path; + const destination = isDirectory + ? join(options.destinationRoot, caplet.id) + : join(options.destinationRoot, `${caplet.id}.md`); + + if (existsSync(destination)) { + if (!options.force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplet ${caplet.id} already exists at ${destination}; pass --force to overwrite it`, + ); + } + rmSync(destination, { recursive: true, force: true }); + } + + cpSync(source, destination, { recursive: isDirectory, force: false, errorOnExist: true }); + return { + id: caplet.id, + source, + destination, + kind: isDirectory ? "directory" : "file", + }; +} + function listCaplets( config: CapletsConfig, options: { includeDisabled: boolean }, diff --git a/test/cli.test.ts b/test/cli.test.ts index ed6b26ef..fb37f37d 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -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 { version as packageJsonVersion } from "../package.json"; @@ -239,6 +239,85 @@ 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 configPath = join(dir, "user", "config.json"); + const out: string[] = []; + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["install", 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(out.join("")).toContain("Installed filesystem"); + expect(out.join("")).toContain("Installed github"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("installs selected Caplets from a local repo caplets directory", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + const out: string[] = []; + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + 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`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses to overwrite installed Caplets without force", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["install", repo, "github"], { writeOut: () => {} }); + + await expect(runCli(["install", repo, "github"], { writeOut: () => {} })).rejects.toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + + await runCli(["install", repo, "github", "--force"], { writeOut: () => {} }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects selected Caplets that are not in the repo", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + process.env.CAPLETS_CONFIG = configPath; + + await expect(runCli(["install", repo, "missing"], { writeOut: () => {} })).rejects.toThrow( + expect.objectContaining({ code: "CONFIG_NOT_FOUND" }) as CapletsError, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("lists configured OAuth servers without printing token values", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); const authDir = join(dir, "auth"); @@ -430,3 +509,43 @@ function writeInspectionConfig(path: string): void { }), ); } + +function writeInstallableRepo(repo: string): void { + const root = join(repo, "caplets"); + mkdirSync(join(root, "github"), { recursive: true }); + writeFileSync( + join(root, "filesystem.md"), + [ + "---", + "name: Project Files", + "description: Read and search local project files.", + "mcpServer:", + " command: npx", + " args:", + " - -y", + " - '@modelcontextprotocol/server-filesystem'", + " - .", + "---", + "# Project Files", + ].join("\n"), + ); + writeFileSync( + join(root, "github", "CAPLET.md"), + [ + "---", + "name: GitHub", + "description: Work with GitHub repositories and pull requests.", + "mcpServer:", + " command: npx", + " args:", + " - -y", + " - github-mcp-server", + "---", + "# GitHub", + ].join("\n"), + ); + writeFileSync( + join(root, "github", "README.md"), + "Extra files are copied with directory Caplets.\n", + ); +} diff --git a/test/config.test.ts b/test/config.test.ts index 1654ce16..47d6e8bc 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { capletJsonSchema } from "../src/caplet-files.js"; +import { capletJsonSchema, loadCapletFiles } from "../src/caplet-files.js"; import { configJsonSchema, loadConfig, parseConfig } from "../src/config.js"; import { CapletsError } from "../src/errors.js"; @@ -262,6 +262,40 @@ describe("config", () => { rmSync(dir, { recursive: true, force: true }); }); + it("keeps repository example Caplets loadable", () => { + const examples = loadCapletFiles(join(process.cwd(), "caplets")); + + const config = parseConfig(examples); + + expect(config.mcpServers.context7).toMatchObject({ + server: "context7", + name: "Context7 Documentation", + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }); + expect(config.mcpServers.github).toMatchObject({ + server: "github", + name: "GitHub", + command: "docker", + args: [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server", + ], + env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" }, + }); + expect(config.mcpServers.linear).toMatchObject({ + server: "linear", + name: "Linear", + transport: "http", + url: "https://mcp.linear.app/mcp", + auth: { type: "oauth2" }, + }); + }); + it("does not load project Caplet files without explicit trust", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-files-")); const userRoot = join(dir, "user"); From 5683b12d8056a5e3be1511eed3e086e661a57a67 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 16:53:42 -0400 Subject: [PATCH 3/8] test: require example caplet reference links --- caplets/linear/CAPLET.md | 4 ++++ test/config.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/caplets/linear/CAPLET.md b/caplets/linear/CAPLET.md index 5814a68a..df5046ab 100644 --- a/caplets/linear/CAPLET.md +++ b/caplets/linear/CAPLET.md @@ -28,6 +28,10 @@ implementation work synchronized with issues, projects, and team workflows. - Add implementation notes or status comments after code changes. - Check whether a bug or feature already has active work before creating a new issue. +## Reference Files + +- [Workflows](./workflows.md): recommended lookup, planning, status update, and triage flows. + ## Use Carefully - Linear issue updates are visible to teammates. Read first, then write deliberately. diff --git a/test/config.test.ts b/test/config.test.ts index 47d6e8bc..0c24427d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { capletJsonSchema, loadCapletFiles } from "../src/caplet-files.js"; @@ -296,6 +304,28 @@ describe("config", () => { }); }); + it("keeps repository Caplet reference files linked from CAPLET.md", () => { + const examplesRoot = join(process.cwd(), "caplets"); + const capletDirs = readdirSync(examplesRoot, { withFileTypes: true }).filter((entry) => + entry.isDirectory(), + ); + + for (const entry of capletDirs) { + const capletPath = join(examplesRoot, entry.name, "CAPLET.md"); + if (!existsSync(capletPath)) { + continue; + } + const caplet = readFileSync(capletPath, "utf8"); + const referenceFiles = readdirSync(join(examplesRoot, entry.name)).filter( + (file) => file.endsWith(".md") && file !== "CAPLET.md" && file !== "README.md", + ); + + for (const file of referenceFiles) { + expect(caplet, `${entry.name}/CAPLET.md should link ${file}`).toContain(`./${file}`); + } + } + }); + it("does not load project Caplet files without explicit trust", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-files-")); const userRoot = join(dir, "user"); From 29d0383348690df70ef3fca11554343e95241ece Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 16:56:15 -0400 Subject: [PATCH 4/8] fix: address caplet install review feedback --- .changeset/clean-spoons-tell.md | 2 ++ src/caplet-files.ts | 4 ++++ src/cli.ts | 15 +++++++++------ src/config.ts | 2 +- test/cli.test.ts | 32 +++++++++++++++++++++++++++++++- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.changeset/clean-spoons-tell.md b/.changeset/clean-spoons-tell.md index 2d18852c..b2525038 100644 --- a/.changeset/clean-spoons-tell.md +++ b/.changeset/clean-spoons-tell.md @@ -2,4 +2,6 @@ "caplets": minor --- +# CLI inspection and Caplet installation + Add CLI inspection commands for version, configured Caplets, resolved config paths, and installing Caplets from a repo. diff --git a/src/caplet-files.ts b/src/caplet-files.ts index 3a89e001..7ba4a4a8 100644 --- a/src/caplet-files.ts +++ b/src/caplet-files.ts @@ -519,6 +519,10 @@ function readCapletFile(path: string): unknown { return capletToServerConfig(parsed.data, body, dirname(path)); } +export function validateCapletFile(path: string): void { + readCapletFile(path); +} + function capletToServerConfig( frontmatter: CapletFileFrontmatter, body: string, diff --git a/src/cli.ts b/src/cli.ts index 697cfc2d..d54ce58b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,7 +15,7 @@ import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { Command, CommanderError } from "commander"; import { version as packageJsonVersion } from "../package.json"; -import { discoverCapletFiles, loadCapletFiles } from "./caplet-files.js"; +import { discoverCapletFiles, validateCapletFile } from "./caplet-files.js"; import { DEFAULT_AUTH_DIR, loadConfig, @@ -24,6 +24,7 @@ import { resolveProjectCapletsRoot, resolveProjectConfigPath, TRUST_PROJECT_CAPLETS_ENV, + isTrustedEnvEnabled, type CapletConfig, type CapletsConfig, } from "./config.js"; @@ -267,7 +268,9 @@ export function installCaplets( throw new CapletsError("CONFIG_NOT_FOUND", `No Caplets found in ${sourceRoot}`); } - loadCapletFiles(sourceRoot); + for (const caplet of selected) { + validateCapletFile(caplet.path); + } mkdirSync(destinationRoot, { recursive: true, mode: 0o700 }); const installed = selected.map((caplet) => @@ -299,9 +302,10 @@ function resolveInstallSource(repo: string): { repoRoot: string; cleanup: () => } } -function normalizeGitRepo(repo: string): string { +export function normalizeGitRepo(repo: string): string { if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { - return `https://github.com/${repo}.git`; + const normalized = repo.endsWith(".git") ? repo.slice(0, -4) : repo; + return `https://github.com/${normalized}.git`; } return repo; } @@ -404,8 +408,7 @@ function formatConfigPaths(paths: ConfigPaths): string { } function isTrustedProjectCapletsEnabled(): boolean { - const value = process.env[TRUST_PROJECT_CAPLETS_ENV]; - return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; + return isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV]); } function formatTable(rows: string[][]): string { diff --git a/src/config.ts b/src/config.ts index 102db70f..5801e134 100644 --- a/src/config.ts +++ b/src/config.ts @@ -723,7 +723,7 @@ function shouldLoadProjectCaplets(): boolean { return isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV]); } -function isTrustedEnvEnabled(value: string | undefined): boolean { +export function isTrustedEnvEnabled(value: string | undefined): boolean { return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; } diff --git a/test/cli.test.ts b/test/cli.test.ts index fb37f37d..6ba91668 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { version as packageJsonVersion } from "../package.json"; -import { initConfig, runCli, starterConfig } from "../src/cli.js"; +import { initConfig, normalizeGitRepo, runCli, starterConfig } from "../src/cli.js"; import { parseConfig, TRUST_PROJECT_CAPLETS_ENV } from "../src/config.js"; import { CapletsError } from "../src/errors.js"; import { writeTokenBundle } from "../src/auth.js"; @@ -282,6 +282,24 @@ 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 configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + writeFileSync(join(repo, "caplets", "broken.md"), "not frontmatter\n"); + process.env.CAPLETS_CONFIG = configPath; + + 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); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("refuses to overwrite installed Caplets without force", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo"); @@ -318,6 +336,18 @@ describe("cli init", () => { } }); + it("normalizes GitHub shorthand repos without double appending git suffixes", () => { + expect(normalizeGitRepo("spiritledsoftware/caplets")).toBe( + "https://github.com/spiritledsoftware/caplets.git", + ); + expect(normalizeGitRepo("spiritledsoftware/caplets.git")).toBe( + "https://github.com/spiritledsoftware/caplets.git", + ); + expect(normalizeGitRepo("https://github.com/spiritledsoftware/caplets.git")).toBe( + "https://github.com/spiritledsoftware/caplets.git", + ); + }); + it("lists configured OAuth servers without printing token values", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); const authDir = join(dir, "auth"); From ca92d91c3cfc281132d5c3e20fa739ae37dc97a8 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 17:04:34 -0400 Subject: [PATCH 5/8] fix: harden caplet install review cases --- src/cli.ts | 3 +- test/config.test.ts | 74 +++++++++++++++++++++++++-------------------- 2 files changed, 44 insertions(+), 33 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index d54ce58b..d059cccb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -289,8 +289,9 @@ function resolveInstallSource(repo: string): { repoRoot: string; cleanup: () => const repoRoot = mkdtempSync(join(tmpdir(), "caplets-install-")); try { - execFileSync("git", ["clone", "--depth", "1", normalizeGitRepo(repo), repoRoot], { + execFileSync("git", ["clone", "--depth", "1", "--", normalizeGitRepo(repo), repoRoot], { stdio: "ignore", + timeout: 60_000, }); return { repoRoot, diff --git a/test/config.test.ts b/test/config.test.ts index 0c24427d..f58118a1 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -271,41 +271,51 @@ describe("config", () => { }); it("keeps repository example Caplets loadable", () => { - const examples = loadCapletFiles(join(process.cwd(), "caplets")); - - const config = parseConfig(examples); - - expect(config.mcpServers.context7).toMatchObject({ - server: "context7", - name: "Context7 Documentation", - command: "npx", - args: ["-y", "@upstash/context7-mcp"], - }); - expect(config.mcpServers.github).toMatchObject({ - server: "github", - name: "GitHub", - command: "docker", - args: [ - "run", - "-i", - "--rm", - "-e", - "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server", - ], - env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" }, - }); - expect(config.mcpServers.linear).toMatchObject({ - server: "linear", - name: "Linear", - transport: "http", - url: "https://mcp.linear.app/mcp", - auth: { type: "oauth2" }, - }); + const originalGithubToken = process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + delete process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + try { + const examples = loadCapletFiles(join(import.meta.dirname, "..", "caplets")); + + const config = parseConfig(examples); + + expect(config.mcpServers.context7).toMatchObject({ + server: "context7", + name: "Context7 Documentation", + command: "npx", + args: ["-y", "@upstash/context7-mcp"], + }); + expect(config.mcpServers.github).toMatchObject({ + server: "github", + name: "GitHub", + command: "docker", + args: [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server", + ], + env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" }, + }); + expect(config.mcpServers.linear).toMatchObject({ + server: "linear", + name: "Linear", + transport: "http", + url: "https://mcp.linear.app/mcp", + auth: { type: "oauth2" }, + }); + } finally { + if (originalGithubToken === undefined) { + delete process.env.GITHUB_PERSONAL_ACCESS_TOKEN; + } else { + process.env.GITHUB_PERSONAL_ACCESS_TOKEN = originalGithubToken; + } + } }); it("keeps repository Caplet reference files linked from CAPLET.md", () => { - const examplesRoot = join(process.cwd(), "caplets"); + const examplesRoot = join(import.meta.dirname, "..", "caplets"); const capletDirs = readdirSync(examplesRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory(), ); From 54690430910f00a57e0538fe04248acfd02a792c Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 17:43:55 -0400 Subject: [PATCH 6/8] refactor: modularize cli and shared utilities --- src/auth.ts | 103 ++------- src/auth/store.ts | 85 ++++++++ src/caplet-files.ts | 36 +-- src/cli.ts | 460 +++------------------------------------ src/cli/auth.ts | 142 ++++++++++++ src/cli/init.ts | 49 +++++ src/cli/inspection.ts | 130 +++++++++++ src/cli/install.ts | 186 ++++++++++++++++ src/config.ts | 81 +++---- src/config/paths.ts | 27 +++ src/config/validation.ts | 30 +++ src/graphql.ts | 64 +----- src/http/utils.ts | 48 ++++ src/openapi.ts | 72 +----- test/cli.test.ts | 44 +++- test/config.test.ts | 11 +- 16 files changed, 848 insertions(+), 720 deletions(-) create mode 100644 src/auth/store.ts create mode 100644 src/cli/auth.ts create mode 100644 src/cli/init.ts create mode 100644 src/cli/inspection.ts create mode 100644 src/cli/install.ts create mode 100644 src/config/paths.ts create mode 100644 src/config/validation.ts create mode 100644 src/http/utils.ts diff --git a/src/auth.ts b/src/auth.ts index 07064e9e..600fb0ad 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,16 +1,4 @@ -import { - chmodSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; import { createServer } from "node:http"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; import { randomBytes, createHash } from "node:crypto"; import { auth, @@ -23,8 +11,24 @@ import type { OAuthClientMetadata, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth"; -import { CapletsError, redactSecrets } from "./errors.js"; +import { + isTokenBundleExpired, + readTokenBundle, + writeTokenBundle, + type StoredOAuthTokenBundle, +} from "./auth/store.js"; import type { CapletServerConfig } from "./config.js"; +import { CapletsError, redactSecrets } from "./errors.js"; + +export { + authStorePath, + deleteTokenBundle, + isTokenBundleExpired, + listTokenBundles, + readTokenBundle, + writeTokenBundle, + type StoredOAuthTokenBundle, +} from "./auth/store.js"; type OAuthLikeAuthConfig = { type: "oauth2" | "oidc"; @@ -50,79 +54,6 @@ export type GenericAuthTarget = { requestTimeoutMs?: number | undefined; }; -export type StoredOAuthTokenBundle = { - server: string; - authType?: "oauth2" | "oidc" | undefined; - accessToken: string; - refreshToken?: string | undefined; - tokenType?: string | undefined; - expiresAt?: string | undefined; - scope?: string | undefined; - idToken?: string | undefined; - issuer?: string | undefined; - subject?: string | undefined; - clientId?: string | undefined; - clientSecret?: string | undefined; - protectedResourceOrigin?: string | undefined; - metadata?: Record; -}; - -export function authStorePath( - server: string, - authDir = join(homedir(), ".caplets", "auth"), -): string { - return join(authDir, `${server}.json`); -} - -export function readTokenBundle( - server: string, - authDir?: string, -): StoredOAuthTokenBundle | undefined { - const path = authStorePath(server, authDir); - if (!existsSync(path)) { - return undefined; - } - return JSON.parse(readFileSync(path, "utf8")) as StoredOAuthTokenBundle; -} - -export function listTokenBundles(authDir?: string): StoredOAuthTokenBundle[] { - const dir = authDir ?? join(homedir(), ".caplets", "auth"); - if (!existsSync(dir)) { - return []; - } - return readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) - .map((entry) => readTokenBundle(entry.name.slice(0, -".json".length), dir)) - .filter((bundle): bundle is StoredOAuthTokenBundle => Boolean(bundle)) - .sort((left, right) => left.server.localeCompare(right.server)); -} - -export function deleteTokenBundle(server: string, authDir?: string): boolean { - const path = authStorePath(server, authDir); - if (!existsSync(path)) { - return false; - } - rmSync(path, { force: true }); - return true; -} - -export function isTokenBundleExpired(bundle: StoredOAuthTokenBundle): boolean { - return Boolean(bundle.expiresAt && Date.parse(bundle.expiresAt) <= Date.now()); -} - -export function writeTokenBundle(bundle: StoredOAuthTokenBundle, authDir?: string): void { - const path = authStorePath(bundle.server, authDir); - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 }); - try { - chmodSync(tempPath, 0o600); - } catch { - // Best effort on platforms without POSIX permissions. - } - renameSync(tempPath, path); -} - export function staticRemoteHeaders(server: CapletServerConfig): Record { if (server.auth?.type === "bearer") { return { authorization: `Bearer ${server.auth.token}` }; diff --git a/src/auth/store.ts b/src/auth/store.ts new file mode 100644 index 00000000..787da0bc --- /dev/null +++ b/src/auth/store.ts @@ -0,0 +1,85 @@ +import { + chmodSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export type StoredOAuthTokenBundle = { + server: string; + authType?: "oauth2" | "oidc" | undefined; + accessToken: string; + refreshToken?: string | undefined; + tokenType?: string | undefined; + expiresAt?: string | undefined; + scope?: string | undefined; + idToken?: string | undefined; + issuer?: string | undefined; + subject?: string | undefined; + clientId?: string | undefined; + clientSecret?: string | undefined; + protectedResourceOrigin?: string | undefined; + metadata?: Record; +}; + +export function authStorePath( + server: string, + authDir = join(homedir(), ".caplets", "auth"), +): string { + return join(authDir, `${server}.json`); +} + +export function readTokenBundle( + server: string, + authDir?: string, +): StoredOAuthTokenBundle | undefined { + const path = authStorePath(server, authDir); + if (!existsSync(path)) { + return undefined; + } + return JSON.parse(readFileSync(path, "utf8")) as StoredOAuthTokenBundle; +} + +export function listTokenBundles(authDir?: string): StoredOAuthTokenBundle[] { + const dir = authDir ?? join(homedir(), ".caplets", "auth"); + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => readTokenBundle(entry.name.slice(0, -".json".length), dir)) + .filter((bundle): bundle is StoredOAuthTokenBundle => Boolean(bundle)) + .sort((left, right) => left.server.localeCompare(right.server)); +} + +export function deleteTokenBundle(server: string, authDir?: string): boolean { + const path = authStorePath(server, authDir); + if (!existsSync(path)) { + return false; + } + rmSync(path, { force: true }); + return true; +} + +export function isTokenBundleExpired(bundle: StoredOAuthTokenBundle): boolean { + return Boolean(bundle.expiresAt && Date.parse(bundle.expiresAt) <= Date.now()); +} + +export function writeTokenBundle(bundle: StoredOAuthTokenBundle, authDir?: string): void { + const path = authStorePath(bundle.server, authDir); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(bundle, null, 2)}\n`, { mode: 0o600 }); + try { + chmodSync(tempPath, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + renameSync(tempPath, path); +} diff --git a/src/caplet-files.ts b/src/caplet-files.ts index 7ba4a4a8..a5db383a 100644 --- a/src/caplet-files.ts +++ b/src/caplet-files.ts @@ -3,29 +3,16 @@ import { basename, dirname, extname, isAbsolute, join } from "node:path"; import { VFile } from "vfile"; import { matter as parseMatter } from "vfile-matter"; import { z } from "zod"; +import { + FORBIDDEN_HEADERS, + HEADER_NAME_PATTERN, + SERVER_ID_PATTERN, + isAllowedRemoteUrl, +} from "./config/validation.js"; import { CapletsError, redactSecrets } from "./errors.js"; const MAX_CAPLET_FILE_BYTES = 128 * 1024; const MAX_CAPLET_BODY_CHARS = 64 * 1024; -const SERVER_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; -const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; -const FORBIDDEN_HEADERS = new Set([ - "accept", - "authorization", - "connection", - "content-length", - "content-type", - "host", - "keep-alive", - "mcp-protocol-version", - "mcp-session-id", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]); const capletRemoteAuthSchema = z .discriminatedUnion("type", [ @@ -658,14 +645,3 @@ function isUrl(value: string): boolean { return false; } } - -function isAllowedRemoteUrl(value: string): boolean { - const url = new URL(value); - if (url.protocol === "https:") { - return true; - } - if (url.protocol !== "http:") { - return false; - } - return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); -} diff --git a/src/cli.ts b/src/cli.ts index d059cccb..e1465ce4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,43 +1,19 @@ -import { createInterface } from "node:readline/promises"; -import { stdin as input, stdout as output } from "node:process"; -import { execFileSync } from "node:child_process"; -import { - chmodSync, - cpSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - statSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; import { Command, CommanderError } from "commander"; import { version as packageJsonVersion } from "../package.json"; -import { discoverCapletFiles, validateCapletFile } from "./caplet-files.js"; -import { - DEFAULT_AUTH_DIR, - loadConfig, - resolveCapletsRoot, - resolveConfigPath, - resolveProjectCapletsRoot, - resolveProjectConfigPath, - TRUST_PROJECT_CAPLETS_ENV, - isTrustedEnvEnabled, - type CapletConfig, - type CapletsConfig, -} from "./config.js"; -import { CapletsError, toSafeError } from "./errors.js"; -import type { ServerStatus } from "./registry.js"; +import { loginAuth, logoutAuth, listAuth } from "./cli/auth.js"; +import { initConfig } from "./cli/init.js"; import { - deleteTokenBundle, - isTokenBundleExpired, - readTokenBundle, - runGenericOAuthFlow, - runOAuthFlow, - type GenericAuthTarget, -} from "./auth.js"; + formatCapletList, + formatConfigPaths, + listCaplets, + resolveCliConfigPaths, +} from "./cli/inspection.js"; +import { installCaplets } from "./cli/install.js"; +import { loadConfig, resolveCapletsRoot, resolveConfigPath } from "./config.js"; +import { CapletsError } from "./errors.js"; + +export { initConfig, starterConfig } from "./cli/init.js"; +export { installCaplets, normalizeGitRepo } from "./cli/install.js"; type CliIO = { writeOut?: (value: string) => void; @@ -45,32 +21,6 @@ type CliIO = { authDir?: string; }; -type CapletListRow = { - server: string; - backend: CapletConfig["backend"]; - name: string; - description: string; - disabled: boolean; - status: ServerStatus; -}; - -type ConfigPaths = { - userConfig: string; - projectConfig: string; - userRoot: string; - projectRoot: string; - authDir: string; - envConfig: string | null; - projectCapletsTrusted: boolean; -}; - -type InstallableCaplet = { - id: string; - source: string; - destination: string; - kind: "file" | "directory"; -}; - export async function runCli(args: string[], io: CliIO = {}): Promise { const program = createProgram(io); try { @@ -161,7 +111,7 @@ export function createProgram(io: CliIO = {}): Command { .description("Print resolved Caplets config, root, and auth paths.") .option("--json", "print JSON output") .action((options: { json?: boolean }) => { - const paths = resolveCliConfigPaths(io.authDir); + const paths = resolveCliConfigPaths(envConfigPath(), io.authDir); if (options.json) { writeOut(`${JSON.stringify(paths, null, 2)}\n`); return; @@ -177,10 +127,12 @@ export function createProgram(io: CliIO = {}): Command { .argument("", "configured server ID") .option("--no-open", "print the authorization URL without opening a browser") .action(async (serverId: string, options: { open?: boolean }) => { + const configPath = envConfigPath(); await loginAuth(serverId, { noOpen: options.open === false, writeOut, writeErr, + ...(configPath ? { configPath } : {}), ...(io.authDir ? { authDir: io.authDir } : {}), }); }); @@ -190,385 +142,29 @@ export function createProgram(io: CliIO = {}): Command { .description("Delete stored OAuth credentials for a server.") .argument("", "configured server ID") .action((serverId: string) => { - const target = findAuthTarget(serverId); - assertLoginTarget(target, serverId); - - if (deleteTokenBundle(serverId, io.authDir)) { - writeOut(`Deleted OAuth credentials for ${serverId}\n`); - } else { - writeOut(`No OAuth credentials found for ${serverId}\n`); - } + const configPath = envConfigPath(); + logoutAuth(serverId, { + writeOut, + ...(configPath ? { configPath } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }); }); auth .command("list") .description("List servers with stored OAuth credentials.") .action(() => { - const config = loadConfig(envConfigPath()); - const servers = authTargets(config).sort((left, right) => - left.server.localeCompare(right.server), - ); - - if (servers.length === 0) { - writeOut("No configured remote OAuth servers found.\n"); - return; - } - for (const server of servers) { - const bundle = readTokenBundle(server.server, io.authDir); - const status = !bundle - ? "missing" - : isTokenBundleExpired(bundle) - ? "expired" - : "authenticated"; - writeOut( - [ - server.server, - status, - bundle?.expiresAt ? `expires ${bundle.expiresAt}` : undefined, - bundle?.scope ? `scope ${bundle.scope}` : undefined, - ] - .filter(Boolean) - .join("\t"), - ); - writeOut("\n"); - } + const configPath = envConfigPath(); + listAuth({ + writeOut, + ...(configPath ? { configPath } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }); }); return program; } -export function installCaplets( - repo: string, - options: { - capletIds?: string[]; - destinationRoot?: string; - force?: boolean; - } = {}, -): { installed: InstallableCaplet[] } { - const source = resolveInstallSource(repo); - try { - const sourceRoot = join(source.repoRoot, "caplets"); - if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) { - throw new CapletsError("CONFIG_NOT_FOUND", `No caplets directory found at ${sourceRoot}`); - } - - const selectedIds = new Set(options.capletIds ?? []); - const destinationRoot = options.destinationRoot ?? resolveCapletsRoot(resolveConfigPath()); - const available = discoverCapletFiles(sourceRoot); - const selected = - selectedIds.size === 0 ? available : available.filter((caplet) => selectedIds.has(caplet.id)); - const missing = [...selectedIds].filter((id) => !available.some((caplet) => caplet.id === id)); - if (missing.length > 0) { - throw new CapletsError( - "CONFIG_NOT_FOUND", - `Caplet ${missing.join(", ")} not found in ${sourceRoot}`, - ); - } - if (selected.length === 0) { - throw new CapletsError("CONFIG_NOT_FOUND", `No Caplets found in ${sourceRoot}`); - } - - for (const caplet of selected) { - validateCapletFile(caplet.path); - } - mkdirSync(destinationRoot, { recursive: true, mode: 0o700 }); - - const installed = selected.map((caplet) => - installOneCaplet(caplet, { destinationRoot, force: Boolean(options.force) }), - ); - return { installed }; - } finally { - source.cleanup(); - } -} - -function resolveInstallSource(repo: string): { repoRoot: string; cleanup: () => void } { - if (existsSync(repo) && statSync(repo).isDirectory()) { - return { repoRoot: repo, cleanup: () => {} }; - } - - const repoRoot = mkdtempSync(join(tmpdir(), "caplets-install-")); - try { - execFileSync("git", ["clone", "--depth", "1", "--", normalizeGitRepo(repo), repoRoot], { - stdio: "ignore", - timeout: 60_000, - }); - return { - repoRoot, - cleanup: () => rmSync(repoRoot, { recursive: true, force: true }), - }; - } catch (error) { - rmSync(repoRoot, { recursive: true, force: true }); - throw new CapletsError("CONFIG_NOT_FOUND", `Could not clone repo ${repo}`, toSafeError(error)); - } -} - -export function normalizeGitRepo(repo: string): string { - if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { - const normalized = repo.endsWith(".git") ? repo.slice(0, -4) : repo; - return `https://github.com/${normalized}.git`; - } - return repo; -} - -function installOneCaplet( - caplet: { id: string; path: string }, - options: { destinationRoot: string; force: boolean }, -): InstallableCaplet { - const isDirectory = basename(caplet.path) === "CAPLET.md"; - const source = isDirectory ? dirname(caplet.path) : caplet.path; - const destination = isDirectory - ? join(options.destinationRoot, caplet.id) - : join(options.destinationRoot, `${caplet.id}.md`); - - if (existsSync(destination)) { - if (!options.force) { - throw new CapletsError( - "CONFIG_EXISTS", - `Caplet ${caplet.id} already exists at ${destination}; pass --force to overwrite it`, - ); - } - rmSync(destination, { recursive: true, force: true }); - } - - cpSync(source, destination, { recursive: isDirectory, force: false, errorOnExist: true }); - return { - id: caplet.id, - source, - destination, - kind: isDirectory ? "directory" : "file", - }; -} - -function listCaplets( - config: CapletsConfig, - options: { includeDisabled: boolean }, -): CapletListRow[] { - const rows = allCaplets(config) - .filter((server) => options.includeDisabled || !server.disabled) - .map((server) => ({ - server: server.server, - backend: server.backend, - name: server.name, - description: server.description, - disabled: server.disabled, - status: initialServerStatus(server), - })); - return rows.sort((left, right) => left.server.localeCompare(right.server)); -} - -function initialServerStatus(server: CapletConfig): ServerStatus { - return server.disabled ? "disabled" : "not_started"; -} - -function allCaplets(config: CapletsConfig): CapletConfig[] { - return [ - ...Object.values(config.mcpServers), - ...Object.values(config.openapiEndpoints), - ...Object.values(config.graphqlEndpoints), - ]; -} - -function formatCapletList(rows: CapletListRow[]): string { - if (rows.length === 0) { - return "No configured Caplets found.\n"; - } - - return `${formatTable([ - ["server", "backend", "status", "name"], - ...rows.map((row) => [row.server, row.backend, row.status, row.name]), - ])}\n`; -} - -function resolveCliConfigPaths(authDir?: string): ConfigPaths { - const envConfig = envConfigPath(); - const configPath = resolveConfigPath(envConfig); - return { - userConfig: configPath, - projectConfig: resolveProjectConfigPath(), - userRoot: resolveCapletsRoot(configPath), - projectRoot: resolveProjectCapletsRoot(), - authDir: authDir ?? DEFAULT_AUTH_DIR, - envConfig: envConfig ?? null, - projectCapletsTrusted: isTrustedProjectCapletsEnabled(), - }; -} - -function formatConfigPaths(paths: ConfigPaths): string { - return ( - [ - `userConfig: ${paths.userConfig}`, - `projectConfig: ${paths.projectConfig}`, - `userRoot: ${paths.userRoot}`, - `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) { - return ""; - } - - const widths = firstRow.map((_, column) => - Math.max(...rows.map((row) => row[column]?.length ?? 0)), - ); - - return rows.map((row) => formatTableRow(row, widths)).join("\n"); -} - -function formatTableRow(row: string[], widths: number[]): string { - return row - .map((value, column) => { - if (column === row.length - 1) { - return value; - } - return value.padEnd((widths[column] ?? 0) + 2); - }) - .join("") - .trimEnd(); -} - -async function loginAuth( - serverId: string, - options: { - noOpen: boolean; - writeOut: (value: string) => void; - writeErr: (value: string) => void; - authDir?: string; - }, -): Promise { - const config = loadConfig(envConfigPath()); - const server = findAuthTarget(serverId, config); - assertLoginTarget(server, serverId); - - try { - const flowOptions = { - noOpen: options.noOpen, - ...(options.authDir ? { authDir: options.authDir } : {}), - ...(options.noOpen ? { readManualInput: maybeReadManualInput } : {}), - print: (line: string) => options.writeOut(`${line}\n`), - }; - if (server.backend === "mcp") { - await runOAuthFlow(server, flowOptions); - } else { - await runGenericOAuthFlow(server, flowOptions); - } - options.writeOut(`Authenticated ${serverId}\n`); - } catch (error) { - options.writeErr(`${JSON.stringify(toSafeError(error, "AUTH_FAILED"), null, 2)}\n`); - process.exitCode = 1; - } -} - -type AuthTarget = ReturnType[number]; - -function findAuthTarget( - serverId: string, - config = loadConfig(envConfigPath()), -): AuthTarget | undefined { - return authTargets(config).find((server) => server.server === serverId); -} - -function authTargets(config: ReturnType) { - const graphqlEndpoints = ( - config as unknown as { graphqlEndpoints?: Record } - ).graphqlEndpoints; - return [ - ...Object.values(config.mcpServers).filter( - (server) => - server.transport !== "stdio" && - (server.auth?.type === "oauth2" || server.auth?.type === "oidc"), - ), - ...Object.values(config.openapiEndpoints).filter( - (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", - ), - ...Object.values(graphqlEndpoints ?? {}).filter( - (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", - ), - ]; -} - -function assertLoginTarget( - target: AuthTarget | undefined, - serverId: string, -): asserts target is AuthTarget { - if (!target) { - throw new CapletsError("SERVER_NOT_FOUND", `Server ${serverId} is not configured for OAuth`); - } - if ("disabled" in target && target.disabled) { - throw new CapletsError("SERVER_UNAVAILABLE", `Server ${serverId} is disabled`); - } -} - -export function initConfig(options: { path?: string; force?: boolean } = {}): string { - const path = resolveConfigPath(options.path); - if (existsSync(path) && !options.force) { - throw new CapletsError( - "CONFIG_EXISTS", - `Caplets config already exists at ${path}; pass --force to overwrite it`, - ); - } - - mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); - writeFileSync(path, `${starterConfig()}\n`, { - mode: 0o600, - flag: options.force ? "w" : "wx", - }); - try { - chmodSync(path, 0o600); - } catch { - // Best effort on platforms without POSIX permissions. - } - return path; -} - -export function starterConfig(): string { - return JSON.stringify( - { - $schema: - "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplets-config.schema.json", - version: 1, - defaultSearchLimit: 20, - maxSearchLimit: 50, - mcpServers: { - example: { - name: "Example MCP Server", - description: "Replace this with a real MCP server and what agents should use it for.", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-everything"], - disabled: true, - }, - }, - }, - null, - 2, - ); -} - function envConfigPath(): string | undefined { return process.env.CAPLETS_CONFIG?.trim() || undefined; } - -async function maybeReadManualInput(): Promise { - if (!input.isTTY) { - return undefined; - } - const rl = createInterface({ input, output }); - try { - const answer = await rl.question( - "Paste callback URL or authorization code after completing authorization, or press Enter to wait for loopback callback: ", - ); - return answer.trim() || undefined; - } finally { - rl.close(); - } -} diff --git a/src/cli/auth.ts b/src/cli/auth.ts new file mode 100644 index 00000000..98d608c0 --- /dev/null +++ b/src/cli/auth.ts @@ -0,0 +1,142 @@ +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; +import { + deleteTokenBundle, + isTokenBundleExpired, + readTokenBundle, + runGenericOAuthFlow, + runOAuthFlow, + type GenericAuthTarget, +} from "../auth.js"; +import { loadConfig } from "../config.js"; +import { CapletsError, toSafeError } from "../errors.js"; + +type AuthTarget = ReturnType[number]; + +export async function loginAuth( + serverId: string, + options: { + configPath?: string; + noOpen: boolean; + writeOut: (value: string) => void; + writeErr: (value: string) => void; + authDir?: string; + }, +): Promise { + const config = loadConfig(options.configPath); + const server = findAuthTarget(serverId, config); + assertLoginTarget(server, serverId); + + try { + const flowOptions = { + noOpen: options.noOpen, + ...(options.authDir ? { authDir: options.authDir } : {}), + ...(options.noOpen ? { readManualInput: maybeReadManualInput } : {}), + print: (line: string) => options.writeOut(`${line}\n`), + }; + if (server.backend === "mcp") { + await runOAuthFlow(server, flowOptions); + } else { + await runGenericOAuthFlow(server, flowOptions); + } + options.writeOut(`Authenticated ${serverId}\n`); + } catch (error) { + options.writeErr(`${JSON.stringify(toSafeError(error, "AUTH_FAILED"), null, 2)}\n`); + process.exitCode = 1; + } +} + +export function logoutAuth( + serverId: string, + options: { authDir?: string; configPath?: string; writeOut: (value: string) => void }, +): void { + const target = findAuthTarget(serverId, loadConfig(options.configPath)); + assertLoginTarget(target, serverId); + + if (deleteTokenBundle(serverId, options.authDir)) { + options.writeOut(`Deleted OAuth credentials for ${serverId}\n`); + } else { + options.writeOut(`No OAuth credentials found for ${serverId}\n`); + } +} + +export function listAuth(options: { + authDir?: string; + configPath?: string; + writeOut: (value: string) => void; +}): void { + const config = loadConfig(options.configPath); + const servers = authTargets(config).sort((left, right) => + left.server.localeCompare(right.server), + ); + + if (servers.length === 0) { + options.writeOut("No configured remote OAuth servers found.\n"); + return; + } + for (const server of servers) { + const bundle = readTokenBundle(server.server, options.authDir); + const status = !bundle ? "missing" : isTokenBundleExpired(bundle) ? "expired" : "authenticated"; + options.writeOut( + [ + server.server, + status, + bundle?.expiresAt ? `expires ${bundle.expiresAt}` : undefined, + bundle?.scope ? `scope ${bundle.scope}` : undefined, + ] + .filter(Boolean) + .join("\t"), + ); + options.writeOut("\n"); + } +} + +function findAuthTarget(serverId: string, config = loadConfig()): AuthTarget | undefined { + return authTargets(config).find((server) => server.server === serverId); +} + +function authTargets(config: ReturnType) { + const graphqlEndpoints = ( + config as unknown as { graphqlEndpoints?: Record } + ).graphqlEndpoints; + return [ + ...Object.values(config.mcpServers).filter( + (server) => + server.transport !== "stdio" && + (server.auth?.type === "oauth2" || server.auth?.type === "oidc"), + ), + ...Object.values(config.openapiEndpoints).filter( + (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", + ), + ...Object.values(graphqlEndpoints ?? {}).filter( + (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", + ), + ]; +} + +function assertLoginTarget( + target: AuthTarget | undefined, + serverId: string, +): asserts target is AuthTarget { + if (!target) { + throw new CapletsError("SERVER_NOT_FOUND", `Server ${serverId} is not configured for OAuth`); + } + if ("disabled" in target && target.disabled) { + throw new CapletsError("SERVER_UNAVAILABLE", `Server ${serverId} is disabled`); + } +} + +async function maybeReadManualInput(): Promise { + if (!input.isTTY) { + return undefined; + } + const rl = createInterface({ input, output }); + try { + const answer = await rl.question( + "Paste callback URL or authorization code after completing authorization, or press Enter to wait for loopback callback: ", + ); + return answer.trim() || undefined; + } finally { + rl.close(); + } +} diff --git a/src/cli/init.ts b/src/cli/init.ts new file mode 100644 index 00000000..5e8a3580 --- /dev/null +++ b/src/cli/init.ts @@ -0,0 +1,49 @@ +import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { resolveConfigPath } from "../config.js"; +import { CapletsError } from "../errors.js"; + +export function initConfig(options: { path?: string; force?: boolean } = {}): string { + const path = resolveConfigPath(options.path); + if (existsSync(path) && !options.force) { + throw new CapletsError( + "CONFIG_EXISTS", + `Caplets config already exists at ${path}; pass --force to overwrite it`, + ); + } + + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${starterConfig()}\n`, { + mode: 0o600, + flag: options.force ? "w" : "wx", + }); + try { + chmodSync(path, 0o600); + } catch { + // Best effort on platforms without POSIX permissions. + } + return path; +} + +export function starterConfig(): string { + return JSON.stringify( + { + $schema: + "https://raw.githubusercontent.com/spiritledsoftware/caplets/main/schemas/caplets-config.schema.json", + version: 1, + defaultSearchLimit: 20, + maxSearchLimit: 50, + mcpServers: { + example: { + name: "Example MCP Server", + description: "Replace this with a real MCP server and what agents should use it for.", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-everything"], + disabled: true, + }, + }, + }, + null, + 2, + ); +} diff --git a/src/cli/inspection.ts b/src/cli/inspection.ts new file mode 100644 index 00000000..2beb11fc --- /dev/null +++ b/src/cli/inspection.ts @@ -0,0 +1,130 @@ +import { + DEFAULT_AUTH_DIR, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectCapletsRoot, + resolveProjectConfigPath, + TRUST_PROJECT_CAPLETS_ENV, + isTrustedEnvEnabled, + type CapletConfig, + type CapletsConfig, +} from "../config.js"; +import type { ServerStatus } from "../registry.js"; + +type CapletListRow = { + server: string; + backend: CapletConfig["backend"]; + name: string; + description: string; + disabled: boolean; + status: ServerStatus; +}; + +type ConfigPaths = { + userConfig: string; + projectConfig: string; + userRoot: string; + projectRoot: string; + authDir: string; + envConfig: string | null; + projectCapletsTrusted: boolean; +}; + +export function listCaplets( + config: CapletsConfig, + options: { includeDisabled: boolean }, +): CapletListRow[] { + const rows = allCaplets(config) + .filter((server) => options.includeDisabled || !server.disabled) + .map((server) => ({ + server: server.server, + backend: server.backend, + name: server.name, + description: server.description, + disabled: server.disabled, + status: initialServerStatus(server), + })); + return rows.sort((left, right) => left.server.localeCompare(right.server)); +} + +function initialServerStatus(server: CapletConfig): ServerStatus { + return server.disabled ? "disabled" : "not_started"; +} + +function allCaplets(config: CapletsConfig): CapletConfig[] { + return [ + ...Object.values(config.mcpServers), + ...Object.values(config.openapiEndpoints), + ...Object.values(config.graphqlEndpoints), + ]; +} + +export function formatCapletList(rows: CapletListRow[]): string { + if (rows.length === 0) { + return "No configured Caplets found.\n"; + } + + return `${formatTable([ + ["server", "backend", "status", "name"], + ...rows.map((row) => [row.server, row.backend, row.status, row.name]), + ])}\n`; +} + +export function resolveCliConfigPaths( + envConfigPath: string | undefined, + authDir?: string, +): ConfigPaths { + const configPath = resolveConfigPath(envConfigPath); + return { + userConfig: configPath, + projectConfig: resolveProjectConfigPath(), + userRoot: resolveCapletsRoot(configPath), + projectRoot: resolveProjectCapletsRoot(), + authDir: authDir ?? DEFAULT_AUTH_DIR, + envConfig: envConfigPath ?? null, + projectCapletsTrusted: isTrustedProjectCapletsEnabled(), + }; +} + +export function formatConfigPaths(paths: ConfigPaths): string { + return ( + [ + `userConfig: ${paths.userConfig}`, + `projectConfig: ${paths.projectConfig}`, + `userRoot: ${paths.userRoot}`, + `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) { + return ""; + } + + const widths = firstRow.map((_, column) => + Math.max(...rows.map((row) => row[column]?.length ?? 0)), + ); + + return rows.map((row) => formatTableRow(row, widths)).join("\n"); +} + +function formatTableRow(row: string[], widths: number[]): string { + return row + .map((value, column) => { + if (column === row.length - 1) { + return value; + } + return value.padEnd((widths[column] ?? 0) + 2); + }) + .join("") + .trimEnd(); +} diff --git a/src/cli/install.ts b/src/cli/install.ts new file mode 100644 index 00000000..48cf712e --- /dev/null +++ b/src/cli/install.ts @@ -0,0 +1,186 @@ +import { execFileSync } from "node:child_process"; +import { + accessSync, + constants, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + statSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, relative } from "node:path"; +import { discoverCapletFiles, validateCapletFile } from "../caplet-files.js"; +import { resolveCapletsRoot, resolveConfigPath } from "../config.js"; +import { CapletsError, toSafeError } from "../errors.js"; + +type InstallableCaplet = { + id: string; + source: string; + destination: string; + kind: "file" | "directory"; +}; + +type InstallPlan = InstallableCaplet & { + sourcePath: string; +}; + +export function installCaplets( + repo: string, + options: { + capletIds?: string[]; + destinationRoot?: string; + force?: boolean; + } = {}, +): { installed: InstallableCaplet[] } { + const source = resolveInstallSource(repo); + try { + const sourceRoot = join(source.repoRoot, "caplets"); + if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) { + throw new CapletsError("CONFIG_NOT_FOUND", `No caplets directory found at ${sourceRoot}`); + } + + const selectedIds = new Set(options.capletIds ?? []); + const destinationRoot = options.destinationRoot ?? resolveCapletsRoot(resolveConfigPath()); + const available = discoverCapletFiles(sourceRoot); + const selected = + selectedIds.size === 0 ? available : available.filter((caplet) => selectedIds.has(caplet.id)); + const missing = [...selectedIds].filter((id) => !available.some((caplet) => caplet.id === id)); + if (missing.length > 0) { + throw new CapletsError( + "CONFIG_NOT_FOUND", + `Caplet ${missing.join(", ")} not found in ${sourceRoot}`, + ); + } + if (selected.length === 0) { + throw new CapletsError("CONFIG_NOT_FOUND", `No Caplets found in ${sourceRoot}`); + } + + for (const caplet of selected) { + validateCapletFile(caplet.path); + } + const installed = preflightInstallCaplets(selected, { + destinationRoot, + force: Boolean(options.force), + repoRoot: source.repoRoot, + sourceId: source.id, + }).map((plan) => installOneCaplet(plan, { force: Boolean(options.force) })); + return { installed }; + } finally { + source.cleanup(); + } +} + +function resolveInstallSource(repo: string): { id: string; repoRoot: string; cleanup: () => void } { + if (existsSync(repo) && statSync(repo).isDirectory()) { + return { id: repo, repoRoot: repo, cleanup: () => {} }; + } + + const normalizedRepo = normalizeGitRepo(repo); + const repoRoot = mkdtempSync(join(tmpdir(), "caplets-install-")); + try { + execFileSync("git", ["clone", "--depth", "1", "--", normalizedRepo, repoRoot], { + stdio: "ignore", + timeout: 60_000, + }); + return { + id: normalizedRepo, + repoRoot, + cleanup: () => rmSync(repoRoot, { recursive: true, force: true }), + }; + } catch (error) { + rmSync(repoRoot, { recursive: true, force: true }); + throw new CapletsError("CONFIG_NOT_FOUND", `Could not clone repo ${repo}`, toSafeError(error)); + } +} + +export function normalizeGitRepo(repo: string): string { + if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + const normalized = repo.endsWith(".git") ? repo.slice(0, -4) : repo; + return `https://github.com/${normalized}.git`; + } + return repo; +} + +function preflightInstallCaplets( + caplets: Array<{ id: string; path: string }>, + options: { destinationRoot: string; force: boolean; repoRoot: string; sourceId: string }, +): InstallPlan[] { + const plans = caplets.map((caplet) => installPlan(caplet, options)); + 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`, + ); + } + } + + const writableRoot = nearestExistingParent(options.destinationRoot); + accessSync(writableRoot, constants.W_OK); + for (const plan of plans) { + const destinationParent = existsSync(plan.destination) + ? dirname(plan.destination) + : nearestExistingParent(dirname(plan.destination)); + accessSync(destinationParent, constants.W_OK); + } + + mkdirSync(options.destinationRoot, { recursive: true, mode: 0o700 }); + return plans; +} + +function installPlan( + caplet: { id: string; path: string }, + options: { destinationRoot: string; repoRoot: string; sourceId: string }, +): InstallPlan { + const isDirectory = basename(caplet.path) === "CAPLET.md"; + const sourcePath = isDirectory ? dirname(caplet.path) : caplet.path; + const sourcePathRelative = relative(options.repoRoot, sourcePath); + const destination = isDirectory + ? join(options.destinationRoot, caplet.id) + : join(options.destinationRoot, `${caplet.id}.md`); + + return { + id: caplet.id, + source: `${options.sourceId}#${sourcePathRelative}`, + sourcePath, + destination, + kind: isDirectory ? "directory" : "file", + }; +} + +function installOneCaplet(plan: InstallPlan, options: { force: boolean }): InstallableCaplet { + if (existsSync(plan.destination)) { + if (!options.force) { + 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 }); + } + + cpSync(plan.sourcePath, plan.destination, { + recursive: plan.kind === "directory", + force: false, + errorOnExist: true, + }); + return { + id: plan.id, + source: plan.source, + destination: plan.destination, + kind: plan.kind, + }; +} + +function nearestExistingParent(path: string): string { + if (existsSync(path)) { + return path; + } + const parent = dirname(path); + if (parent === path) { + return parent; + } + return nearestExistingParent(parent); +} diff --git a/src/config.ts b/src/config.ts index 5801e134..3e9cd45f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,10 +1,34 @@ import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { 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 { + FORBIDDEN_HEADERS, + HEADER_NAME_PATTERN, + SERVER_ID_PATTERN, + isAllowedRemoteUrl, +} from "./config/validation.js"; import { CapletsError, redactSecrets } from "./errors.js"; +export { + DEFAULT_AUTH_DIR, + DEFAULT_CONFIG_PATH, + PROJECT_CONFIG_FILE, + TRUST_PROJECT_CAPLETS_ENV, + isTrustedEnvEnabled, + resolveCapletsRoot, + resolveConfigPath, + resolveProjectCapletsRoot, + resolveProjectConfigPath, +} from "./config/paths.js"; + export type RemoteAuthConfig = | { type: "none" } | { type: "bearer"; token: string } @@ -119,32 +143,8 @@ export type CapletsConfig = { graphqlEndpoints: Record; }; -const SERVER_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; -const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; -const FORBIDDEN_HEADERS = new Set([ - "accept", - "authorization", - "connection", - "content-length", - "content-type", - "host", - "keep-alive", - "mcp-protocol-version", - "mcp-session-id", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]); const NON_INTERPOLATED_SERVER_FIELDS = new Set(["name", "description", "tags", "body"]); -export const DEFAULT_CONFIG_PATH = join(homedir(), ".caplets", "config.json"); -export const DEFAULT_AUTH_DIR = join(homedir(), ".caplets", "auth"); -export const PROJECT_CONFIG_FILE = join(".caplets", "config.json"); -export const TRUST_PROJECT_CAPLETS_ENV = "CAPLETS_TRUST_PROJECT_CAPLETS"; - const remoteAuthSchema = z .discriminatedUnion("type", [ z.object({ type: z.literal("none") }).strict(), @@ -654,22 +654,6 @@ export function configJsonSchema(): unknown { }; } -export function resolveConfigPath(path?: string): string { - return path ?? join(homedir(), ".caplets", "config.json"); -} - -export function resolveProjectConfigPath(cwd = process.cwd()): string { - return join(cwd, PROJECT_CONFIG_FILE); -} - -export function resolveCapletsRoot(configPath = resolveConfigPath()): string { - return dirname(configPath); -} - -export function resolveProjectCapletsRoot(cwd = process.cwd()): string { - return join(cwd, ".caplets"); -} - export function loadConfig( path = resolveConfigPath(), projectPath = resolveProjectConfigPath(), @@ -723,10 +707,6 @@ function shouldLoadProjectCaplets(): boolean { return isTrustedEnvEnabled(process.env[TRUST_PROJECT_CAPLETS_ENV]); } -export function isTrustedEnvEnabled(value: string | undefined): boolean { - return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes"; -} - function readPublicConfigInput(path: string): ConfigInput { try { const input = JSON.parse(readFileSync(path, "utf8")); @@ -978,14 +958,3 @@ export function interpolateEnv(value: string): string { .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name: string) => process.env[name] ?? "") .replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name: string) => process.env[name] ?? ""); } - -function isAllowedRemoteUrl(value: string): boolean { - const url = new URL(value); - if (url.protocol === "https:") { - return true; - } - if (url.protocol !== "http:") { - return false; - } - return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); -} diff --git a/src/config/paths.ts b/src/config/paths.ts new file mode 100644 index 00000000..1f0bdbea --- /dev/null +++ b/src/config/paths.ts @@ -0,0 +1,27 @@ +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +export const DEFAULT_CONFIG_PATH = join(homedir(), ".caplets", "config.json"); +export const DEFAULT_AUTH_DIR = join(homedir(), ".caplets", "auth"); +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; +} + +export function resolveProjectConfigPath(cwd = process.cwd()): string { + return join(cwd, PROJECT_CONFIG_FILE); +} + +export function resolveCapletsRoot(configPath = resolveConfigPath()): string { + return dirname(configPath); +} + +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/config/validation.ts b/src/config/validation.ts new file mode 100644 index 00000000..848a9b0d --- /dev/null +++ b/src/config/validation.ts @@ -0,0 +1,30 @@ +export const SERVER_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; +export const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +export const FORBIDDEN_HEADERS = new Set([ + "accept", + "authorization", + "connection", + "content-length", + "content-type", + "host", + "keep-alive", + "mcp-protocol-version", + "mcp-session-id", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]); + +export function isAllowedRemoteUrl(value: string): boolean { + const url = new URL(value); + if (url.protocol === "https:") { + return true; + } + if (url.protocol !== "http:") { + return false; + } + return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); +} diff --git a/src/graphql.ts b/src/graphql.ts index dd9b7d9c..2857d99a 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -27,13 +27,14 @@ import { type TypeNode, validate, } from "graphql"; -import type { GraphQlEndpointConfig } from "./config.js"; import { genericOAuthHeaders } from "./auth.js"; +import type { GraphQlEndpointConfig } from "./config.js"; +import { isAllowedRemoteUrl } from "./config/validation.js"; import type { CompactTool } from "./downstream.js"; import { CapletsError, toSafeError } from "./errors.js"; +import { isAbortError, parseHttpBody, readLimitedText } from "./http/utils.js"; import type { ServerRegistry } from "./registry.js"; -const MAX_RESPONSE_BYTES = 1024 * 1024; const GRAPHQL_METHOD = "POST"; const SCALAR_JSON_SCHEMA: Record> = { String: { type: "string" }, @@ -165,9 +166,9 @@ export class GraphQLManager { }, ); } - const body = parseGraphQlBody( + const body = parseHttpBody( response.headers.get("content-type") ?? "", - await readLimitedText(response), + await readGraphQlText(response), ); const result = { status: response.status, @@ -334,7 +335,7 @@ async function loadSchema( status: response.status, }); } - const parsed = JSON.parse(await readLimitedText(response)) as { + const parsed = JSON.parse(await readGraphQlText(response)) as { data?: unknown; errors?: unknown; }; @@ -659,7 +660,7 @@ async function fetchGraphQlText( status: response.status, }); } - return readLimitedText(response); + return readGraphQlText(response); } function schemaAuthHeaders( @@ -698,53 +699,12 @@ function shouldSendSchemaAuth(endpoint: GraphQlEndpointConfig): boolean { ); } -function parseGraphQlBody(contentType: string, text: string): unknown { - if (!text) { - return undefined; - } - if (!contentType.includes("application/json")) { - return text; - } - try { - return JSON.parse(text); - } catch { - return text; - } -} - -async function readLimitedText(response: Response): Promise { - if (!response.body) { - return ""; - } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let bytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (value) { - bytes += value.byteLength; - if (bytes > MAX_RESPONSE_BYTES) { - await reader.cancel(); - throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", "GraphQL response exceeded byte limit"); - } - chunks.push(value); - } - } - return new TextDecoder().decode(Buffer.concat(chunks)); +async function readGraphQlText(response: Response): Promise { + return readLimitedText(response, { errorMessage: "GraphQL response exceeded byte limit" }); } function validateEndpointUrl(value: string): void { - const url = new URL(value); - if (url.protocol === "https:") { - return; - } - if ( - url.protocol === "http:" && - ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname) - ) { + if (isAllowedRemoteUrl(value)) { return; } throw new CapletsError( @@ -753,10 +713,6 @@ function validateEndpointUrl(value: string): void { ); } -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; -} - function graphQlCacheKey(endpoint: GraphQlEndpointConfig): string { return JSON.stringify({ endpointUrl: endpoint.endpointUrl, diff --git a/src/http/utils.ts b/src/http/utils.ts new file mode 100644 index 00000000..88c3c61f --- /dev/null +++ b/src/http/utils.ts @@ -0,0 +1,48 @@ +import { CapletsError } from "../errors.js"; + +export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024; + +export function parseHttpBody(contentType: string, text: string): unknown { + if (!text) { + return undefined; + } + if (!contentType.includes("application/json")) { + return text; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +export async function readLimitedText( + response: Response, + options: { maxBytes?: number; errorMessage: string }, +): Promise { + if (!response.body) { + return ""; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value) { + bytes += value.byteLength; + if (bytes > (options.maxBytes ?? DEFAULT_MAX_RESPONSE_BYTES)) { + await reader.cancel(); + throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", options.errorMessage); + } + chunks.push(value); + } + } + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +export function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === "AbortError"; +} diff --git a/src/openapi.ts b/src/openapi.ts index 839e41de..78b0ddfd 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,14 +1,15 @@ import SwaggerParser from "@apidevtools/swagger-parser"; import type { CompatibilityCallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { genericOAuthHeaders } from "./auth.js"; import type { OpenApiEndpointConfig } from "./config.js"; +import { isAllowedRemoteUrl } from "./config/validation.js"; +import type { CompactTool } from "./downstream.js"; import { CapletsError, toSafeError } from "./errors.js"; +import { isAbortError, parseHttpBody, readLimitedText } from "./http/utils.js"; import type { ServerRegistry } from "./registry.js"; -import type { CompactTool } from "./downstream.js"; -import { genericOAuthHeaders } from "./auth.js"; const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; const JSON_CONTENT_TYPES = ["application/json"]; -const MAX_RESPONSE_BYTES = 1024 * 1024; const FORBIDDEN_ARGUMENT_HEADERS = new Set([ "accept", "authorization", @@ -564,8 +565,10 @@ function shouldSendSpecAuth(endpoint: OpenApiEndpointConfig): boolean { async function readResponse(response: Response): Promise> { const contentType = response.headers.get("content-type") ?? ""; - const text = await readLimitedText(response); - const body = parseResponseBody(contentType, text); + const text = await readLimitedText(response, { + errorMessage: "OpenAPI response exceeded byte limit", + }); + const body = parseHttpBody(contentType, text); return { status: response.status, statusText: response.statusText, @@ -576,44 +579,6 @@ async function readResponse(response: Response): Promise }; } -function parseResponseBody(contentType: string, text: string): unknown { - if (!text) { - return undefined; - } - if (!contentType.includes("application/json")) { - return text; - } - try { - return JSON.parse(text); - } catch { - return text; - } -} - -async function readLimitedText(response: Response): Promise { - if (!response.body) { - return ""; - } - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let bytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (value) { - bytes += value.byteLength; - if (bytes > MAX_RESPONSE_BYTES) { - await reader.cancel(); - throw new CapletsError("DOWNSTREAM_PROTOCOL_ERROR", "OpenAPI response exceeded byte limit"); - } - chunks.push(value); - } - } - return new TextDecoder().decode(Buffer.concat(chunks)); -} - async function fetchWithLimit( url: string, timeoutMs: number, @@ -638,7 +603,9 @@ async function fetchWithLimit( status: response.status, }); } - return await readLimitedText(response); + return await readLimitedText(response, { + errorMessage: "OpenAPI response exceeded byte limit", + }); } catch (error) { if (isAbortError(error)) { throw new CapletsError("TOOL_CALL_TIMEOUT", "OpenAPI spec request timed out"); @@ -662,7 +629,7 @@ function validateOperationBaseUrl( `${endpoint.server} must configure baseUrl when using remote specUrl`, ); } - if (!isAllowedRequestBaseUrl(base)) { + if (!isAllowedRemoteUrl(base)) { throw new CapletsError("CONFIG_INVALID", `${endpoint.server} OpenAPI baseUrl is not allowed`); } const url = new URL(base); @@ -685,21 +652,6 @@ function buildOperationUrl(base: string, operationPath: string): URL { return baseUrl; } -function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; -} - -function isAllowedRequestBaseUrl(value: string): boolean { - const url = new URL(value); - if (url.protocol === "https:") { - return true; - } - if (url.protocol !== "http:") { - return false; - } - return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); -} - function openApiCacheKey(endpoint: OpenApiEndpointConfig): string { return JSON.stringify({ specPath: endpoint.specPath, diff --git a/test/cli.test.ts b/test/cli.test.ts index 6ba91668..98707d02 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { version as packageJsonVersion } from "../package.json"; -import { initConfig, normalizeGitRepo, runCli, starterConfig } from "../src/cli.js"; +import { initConfig, installCaplets, normalizeGitRepo, runCli, starterConfig } from "../src/cli.js"; import { parseConfig, TRUST_PROJECT_CAPLETS_ENV } from "../src/config.js"; import { CapletsError } from "../src/errors.js"; import { writeTokenBundle } from "../src/auth.js"; @@ -320,6 +320,48 @@ describe("cli init", () => { } }); + it("preflights selected installs before copying any Caplets", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + mkdirSync(join(destinationRoot, "github"), { recursive: true }); + writeFileSync(join(destinationRoot, "github", "CAPLET.md"), "existing\n"); + + expect(() => installCaplets(repo, { destinationRoot })).toThrow( + expect.objectContaining({ code: "CONFIG_EXISTS" }) as CapletsError, + ); + + expect(existsSync(join(destinationRoot, "filesystem.md"))).toBe(false); + expect(readFileSync(join(destinationRoot, "github", "CAPLET.md"), "utf8")).toBe("existing\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns stable install source identifiers", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); + const repo = join(dir, "repo"); + const destinationRoot = join(dir, "user"); + try { + writeInstallableRepo(repo); + + const result = installCaplets(repo, { capletIds: ["github"], destinationRoot }); + + expect(result.installed).toEqual([ + { + id: "github", + source: `${repo}#caplets/github`, + destination: join(destinationRoot, "github"), + kind: "directory", + }, + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("rejects selected Caplets that are not in the repo", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo"); diff --git a/test/config.test.ts b/test/config.test.ts index f58118a1..f9846e9c 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -331,7 +331,10 @@ describe("config", () => { ); for (const file of referenceFiles) { - expect(caplet, `${entry.name}/CAPLET.md should link ${file}`).toContain(`./${file}`); + expect( + markdownLinkTargets(caplet), + `${entry.name}/CAPLET.md should link ${file}`, + ).toContain(`./${file}`); } } }); @@ -905,3 +908,9 @@ describe("config", () => { ).toThrow(CapletsError); }); }); + +function markdownLinkTargets(markdown: string): string[] { + return [...markdown.matchAll(/\[[^\]]+\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)].flatMap((match) => + match[1] ? [match[1]] : [], + ); +} From 3a1f8ad9cae4899cafe01c2bce16c05c9143584d Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 17:54:16 -0400 Subject: [PATCH 7/8] fix: address review hardening comments --- src/auth/store.ts | 19 ++++++++++++++++--- src/cli/auth.ts | 20 +++++++++++++------- src/config/validation.ts | 14 +++++++++----- src/http/utils.ts | 3 ++- test/auth.test.ts | 26 +++++++++++++++++++++++++- test/cli.test.ts | 29 +++++++++++++++++++++++++++++ test/config-validation.test.ts | 14 ++++++++++++++ test/http-utils.test.ts | 15 +++++++++++++++ 8 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 test/config-validation.test.ts create mode 100644 test/http-utils.test.ts diff --git a/src/auth/store.ts b/src/auth/store.ts index 787da0bc..e2b98a8d 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -9,7 +9,8 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve, sep } from "node:path"; +import { CapletsError } from "../errors.js"; export type StoredOAuthTokenBundle = { server: string; @@ -32,7 +33,15 @@ export function authStorePath( server: string, authDir = join(homedir(), ".caplets", "auth"), ): string { - return join(authDir, `${server}.json`); + if (!server || server.includes("/") || server.includes("\\") || server.includes("..")) { + throw new CapletsError("REQUEST_INVALID", `Invalid auth store server name ${server}`); + } + const authRoot = resolve(authDir); + const candidate = resolve(authRoot, `${server}.json`); + if (candidate !== authRoot && candidate.startsWith(`${authRoot}${sep}`)) { + return candidate; + } + throw new CapletsError("REQUEST_INVALID", `Invalid auth store server name ${server}`); } export function readTokenBundle( @@ -43,7 +52,11 @@ export function readTokenBundle( if (!existsSync(path)) { return undefined; } - return JSON.parse(readFileSync(path, "utf8")) as StoredOAuthTokenBundle; + try { + return JSON.parse(readFileSync(path, "utf8")) as StoredOAuthTokenBundle; + } catch { + return undefined; + } } export function listTokenBundles(authDir?: string): StoredOAuthTokenBundle[] { diff --git a/src/cli/auth.ts b/src/cli/auth.ts index 98d608c0..6e129e74 100644 --- a/src/cli/auth.ts +++ b/src/cli/auth.ts @@ -8,7 +8,7 @@ import { runOAuthFlow, type GenericAuthTarget, } from "../auth.js"; -import { loadConfig } from "../config.js"; +import { loadConfig, type GraphQlEndpointConfig } from "../config.js"; import { CapletsError, toSafeError } from "../errors.js"; type AuthTarget = ReturnType[number]; @@ -96,9 +96,6 @@ function findAuthTarget(serverId: string, config = loadConfig()): AuthTarget | u } function authTargets(config: ReturnType) { - const graphqlEndpoints = ( - config as unknown as { graphqlEndpoints?: Record } - ).graphqlEndpoints; return [ ...Object.values(config.mcpServers).filter( (server) => @@ -108,12 +105,21 @@ function authTargets(config: ReturnType) { ...Object.values(config.openapiEndpoints).filter( (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", ), - ...Object.values(graphqlEndpoints ?? {}).filter( - (endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc", - ), + ...Object.values(config.graphqlEndpoints) + .filter((endpoint) => endpoint.auth?.type === "oauth2" || endpoint.auth?.type === "oidc") + .map(graphQlAuthTarget), ]; } +function graphQlAuthTarget( + endpoint: GraphQlEndpointConfig, +): GraphQlEndpointConfig & GenericAuthTarget { + return { + ...endpoint, + url: endpoint.endpointUrl, + }; +} + function assertLoginTarget( target: AuthTarget | undefined, serverId: string, diff --git a/src/config/validation.ts b/src/config/validation.ts index 848a9b0d..af9ecf37 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -19,12 +19,16 @@ export const FORBIDDEN_HEADERS = new Set([ ]); export function isAllowedRemoteUrl(value: string): boolean { - const url = new URL(value); + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } if (url.protocol === "https:") { return true; } - if (url.protocol !== "http:") { - return false; - } - return ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname); + return ( + url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]", "::1"].includes(url.hostname) + ); } diff --git a/src/http/utils.ts b/src/http/utils.ts index 88c3c61f..da790a8c 100644 --- a/src/http/utils.ts +++ b/src/http/utils.ts @@ -6,7 +6,8 @@ export function parseHttpBody(contentType: string, text: string): unknown { if (!text) { return undefined; } - if (!contentType.includes("application/json")) { + const mime = contentType.split(";")[0]?.toLowerCase().trim() ?? ""; + if (mime !== "application/json" && !mime.endsWith("+json") && !mime.endsWith("/json")) { return text; } try { diff --git a/test/auth.test.ts b/test/auth.test.ts index 011b620d..70559e48 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -4,13 +4,15 @@ import { genericOAuthHeaders, extractCompletion, FileOAuthProvider, + authStorePath, oauthHeaders, + readTokenBundle, runGenericOAuthFlow, writeTokenBundle, } from "../src/auth.js"; import { parseConfig } from "../src/config.js"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -63,6 +65,28 @@ describe("auth helpers", () => { } }); + it("ignores corrupt token bundle files", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); + try { + writeFileSync(join(dir, "remote.json"), "{not-json\n"); + + expect(readTokenBundle("remote", dir)).toBeUndefined(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects auth store path traversal", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); + try { + expect(() => authStorePath("../remote", dir)).toThrow( + expect.objectContaining({ code: "REQUEST_INVALID" }), + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("builds generic OAuth headers for OpenAPI and GraphQL auth targets", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); try { diff --git a/test/cli.test.ts b/test/cli.test.ts index 98707d02..17448df4 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -464,6 +464,35 @@ describe("cli init", () => { } }); + it("lists configured GraphQL OAuth endpoints", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeFileSync( + configPath, + JSON.stringify({ + graphqlEndpoints: { + catalog: { + name: "Catalog", + description: "Query catalog data through GraphQL.", + endpointUrl: "https://api.example.com/graphql", + introspection: true, + auth: { type: "oauth2", issuer: "https://issuer.example" }, + }, + }, + }), + ); + process.env.CAPLETS_CONFIG = configPath; + + await runCli(["auth", "list"], { writeOut: (value) => out.push(value) }); + + expect(out.join("")).toContain("catalog\tmissing"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("logs out configured OAuth servers", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-auth-")); const authDir = join(dir, "auth"); diff --git a/test/config-validation.test.ts b/test/config-validation.test.ts new file mode 100644 index 00000000..e62e1226 --- /dev/null +++ b/test/config-validation.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { isAllowedRemoteUrl } from "../src/config/validation.js"; + +describe("config validation helpers", () => { + it("returns false for malformed remote URLs", () => { + expect(isAllowedRemoteUrl("not a url")).toBe(false); + }); + + it("allows https and loopback http only", () => { + expect(isAllowedRemoteUrl("https://example.com/mcp")).toBe(true); + expect(isAllowedRemoteUrl("http://localhost:3000/mcp")).toBe(true); + expect(isAllowedRemoteUrl("http://example.com/mcp")).toBe(false); + }); +}); diff --git a/test/http-utils.test.ts b/test/http-utils.test.ts new file mode 100644 index 00000000..232e5bb6 --- /dev/null +++ b/test/http-utils.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { parseHttpBody } from "../src/http/utils.js"; + +describe("http utils", () => { + it("parses JSON and structured JSON media types", () => { + expect(parseHttpBody("application/json; charset=utf-8", '{"ok":true}')).toEqual({ ok: true }); + expect(parseHttpBody("application/graphql-response+json", '{"data":{}}')).toEqual({ + data: {}, + }); + }); + + it("leaves non-JSON bodies as text", () => { + expect(parseHttpBody("text/plain", "hello")).toBe("hello"); + }); +}); From ba31d9dfe44b31aacff8a756a27fd46ba74ae919 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Tue, 12 May 2026 17:57:09 -0400 Subject: [PATCH 8/8] fix: allow targeted installs around invalid filenames --- src/cli/install.ts | 34 +++++++++++++++++++++++++++++++--- test/cli.test.ts | 18 ++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/cli/install.ts b/src/cli/install.ts index 48cf712e..dd113676 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { basename, dirname, join, relative } from "node:path"; import { discoverCapletFiles, validateCapletFile } from "../caplet-files.js"; import { resolveCapletsRoot, resolveConfigPath } from "../config.js"; +import { SERVER_ID_PATTERN } from "../config/validation.js"; import { CapletsError, toSafeError } from "../errors.js"; type InstallableCaplet = { @@ -43,9 +44,13 @@ export function installCaplets( const selectedIds = new Set(options.capletIds ?? []); const destinationRoot = options.destinationRoot ?? resolveCapletsRoot(resolveConfigPath()); - const available = discoverCapletFiles(sourceRoot); - const selected = - selectedIds.size === 0 ? available : available.filter((caplet) => selectedIds.has(caplet.id)); + const available = + selectedIds.size === 0 + ? discoverCapletFiles(sourceRoot) + : discoverSelectedCapletFiles(sourceRoot, selectedIds); + const selected = available.filter( + (caplet) => selectedIds.size === 0 || selectedIds.has(caplet.id), + ); const missing = [...selectedIds].filter((id) => !available.some((caplet) => caplet.id === id)); if (missing.length > 0) { throw new CapletsError( @@ -72,6 +77,29 @@ export function installCaplets( } } +function discoverSelectedCapletFiles( + sourceRoot: string, + selectedIds: Set, +): Array<{ id: string; path: string }> { + const candidates: Array<{ id: string; path: string }> = []; + for (const id of selectedIds) { + if (!SERVER_ID_PATTERN.test(id)) { + continue; + } + + const filePath = join(sourceRoot, `${id}.md`); + if (existsSync(filePath) && statSync(filePath).isFile()) { + candidates.push({ id, path: filePath }); + } + + const directoryPath = join(sourceRoot, id, "CAPLET.md"); + if (existsSync(directoryPath) && statSync(directoryPath).isFile()) { + candidates.push({ id, path: directoryPath }); + } + } + return candidates.sort((left, right) => left.id.localeCompare(right.id)); +} + function resolveInstallSource(repo: string): { id: string; repoRoot: string; cleanup: () => void } { if (existsSync(repo) && statSync(repo).isDirectory()) { return { id: repo, repoRoot: repo, cleanup: () => {} }; diff --git a/test/cli.test.ts b/test/cli.test.ts index 17448df4..98b8caa9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -300,6 +300,24 @@ 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 configPath = join(dir, "user", "config.json"); + try { + writeInstallableRepo(repo); + writeFileSync(join(repo, "caplets", "api.v2.md"), "not frontmatter\n"); + process.env.CAPLETS_CONFIG = configPath; + + 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); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("refuses to overwrite installed Caplets without force", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-install-")); const repo = join(dir, "repo");