diff --git a/.changeset/quiet-ravens-sneeze.md b/.changeset/quiet-ravens-sneeze.md new file mode 100644 index 00000000..dac03af7 --- /dev/null +++ b/.changeset/quiet-ravens-sneeze.md @@ -0,0 +1,5 @@ +--- +"@caplets/core": patch +--- + +Keep local overlay startup alive when a Caplet references a missing environment variable by skipping only the affected Caplet and warning with the missing variable and config path. diff --git a/.gitignore b/.gitignore index e6196865..7cc9f8bc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,8 @@ benchmark-results/ # autoresearch .auto/ -# Compound Engineering local config +# compound-engineering .compound-engineering/*.local.yaml + +# worktrees +.worktrees/ diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index d109a416..2e583c74 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -359,6 +359,7 @@ export type LocalOverlayConfigWarning = { kind: ConfigSourceKind; path: string; message: string; + recoverable?: boolean | undefined; }; export type LocalOverlayConfigWithSources = ConfigWithSources & { @@ -1074,6 +1075,23 @@ type ConfigInput = { [key: string]: unknown; }; +const CAPLET_BACKEND_KEYS = [ + "mcpServers", + "openapiEndpoints", + "googleDiscoveryApis", + "graphqlEndpoints", + "httpApis", + "cliTools", + "capletSets", +] as const satisfies ReadonlyArray; + +const CAPLET_BACKEND_KEY_SET = new Set(CAPLET_BACKEND_KEYS); + +type MissingEnvReference = { + name: string; + path: string; +}; + function configSchemaFor( serverValueSchema: z.ZodTypeAny, openApiEndpointValueSchema: z.ZodTypeAny, @@ -1712,14 +1730,107 @@ function readBestEffortConfigInput( transform?: (input: ConfigInput) => ConfigInput, ): ConfigInput | undefined { try { - const input = readPublicConfigInput(path); - return transform ? transform(input) : input; + const input = readBestEffortJsonConfigInput(path); + const normalized = normalizeLocalPaths(input, dirname(path)); + const transformed = transform ? transform(normalized) : normalized; + const filtered = quarantineMissingEnvCaplets(transformed, kind, path, warnings); + const parsed = configFileSchema.safeParse(interpolateConfig(filtered)); + if (!parsed.success) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplets config at ${path} is invalid`, + parsed.error.issues, + ); + } + return filtered; } catch (error) { warnings.push({ kind, path, message: errorMessage(error) }); return undefined; } } +function readBestEffortJsonConfigInput(path: string): ConfigInput { + try { + return JSON.parse(readFileSync(path, "utf8")) as ConfigInput; + } catch (error) { + throw new CapletsError( + "CONFIG_INVALID", + `Caplets config at ${path} is not valid JSON`, + redactSecrets(error), + ); + } +} + +function quarantineMissingEnvCaplets( + input: ConfigInput, + kind: ConfigSourceKind, + sourcePath: string | ((id: string) => string), + warnings: LocalOverlayConfigWarning[], +): ConfigInput { + let filtered = input; + + for (const backend of CAPLET_BACKEND_KEYS) { + const caplets = filtered[backend]; + if (!isPlainObject(caplets)) { + continue; + } + + for (const [id, caplet] of Object.entries(caplets)) { + const missing = missingEnvReferences(caplet, [backend, id]); + if (missing.length === 0) { + continue; + } + + filtered = removeCapletBackendId(filtered, backend, id); + warnings.push({ + kind, + path: typeof sourcePath === "function" ? sourcePath(id) : sourcePath, + message: formatMissingEnvWarning(id, missing), + recoverable: true, + }); + } + } + + return filtered; +} + +function missingEnvReferences(value: unknown, path: string[]): MissingEnvReference[] { + if (isPublicMetadataPath(path)) { + return []; + } + if (typeof value === "string") { + return missingEnvReferencesInString(value, path.join(".")); + } + if (Array.isArray(value)) { + return value.flatMap((item, index) => missingEnvReferences(item, [...path, String(index)])); + } + if (isPlainObject(value)) { + return Object.entries(value).flatMap(([key, nested]) => + missingEnvReferences(nested, [...path, key]), + ); + } + return []; +} + +function missingEnvReferencesInString(value: string, path: string): MissingEnvReference[] { + const missing: MissingEnvReference[] = []; + const pattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$env:([A-Za-z_][A-Za-z0-9_]*)/g; + for (const match of value.matchAll(pattern)) { + const name = match[1] ?? match[2]; + if (name && process.env[name] === undefined) { + missing.push({ name, path }); + } + } + return missing; +} + +function formatMissingEnvWarning(id: string, missing: MissingEnvReference[]): string { + const names = [...new Set(missing.map((reference) => reference.name))]; + const paths = [...new Set(missing.map((reference) => reference.path))]; + const variableLabel = names.length === 1 ? "environment variable" : "environment variables"; + return `Caplet ${id} references missing ${variableLabel} ${names.join(", ")} at ${paths.join(", ")}; skipping Caplet ${id}.`; +} + function loadBestEffortCapletFiles( root: string, kind: ConfigSourceKind, @@ -1732,7 +1843,17 @@ function loadBestEffortCapletFiles( for (const warning of result.warnings) { warnings.push({ kind, path: warning.path ?? root, message: warning.message }); } - return { config: result.config, paths: result.paths }; + const config = quarantineMissingEnvCaplets( + result.config, + kind, + (id) => result.paths[id] ?? root, + warnings, + ); + const retainedIds = new Set(capletIds(config)); + const paths = Object.fromEntries( + Object.entries(result.paths).filter(([id]) => retainedIds.has(id)), + ); + return { config, paths }; } function errorMessage(error: unknown): string { @@ -2049,6 +2170,19 @@ function mergeConfigInputsWithSources(...inputs: Array(value: T, path: string[] = []): T { } function isPublicMetadataPath(path: string[]): boolean { - if ( - path.length < 3 || - (path[0] !== "mcpServers" && - path[0] !== "openapiEndpoints" && - path[0] !== "googleDiscoveryApis" && - path[0] !== "graphqlEndpoints" && - path[0] !== "httpApis" && - path[0] !== "cliTools" && - path[0] !== "capletSets") - ) { + if (path.length < 3 || !CAPLET_BACKEND_KEY_SET.has(path[0] ?? "")) { return false; } return NON_INTERPOLATED_SERVER_FIELDS.has(path[2] ?? ""); diff --git a/packages/core/src/native/service.ts b/packages/core/src/native/service.ts index aca1f52a..93b16a47 100644 --- a/packages/core/src/native/service.ts +++ b/packages/core/src/native/service.ts @@ -1009,14 +1009,16 @@ function createLocalOverlayConfigLoader(options: NativeCapletsServiceOptions) { const path = typeof warning.path === "string" ? ` at ${warning.path}` : ""; writeErr(options, `Caplets local overlay warning${path}: ${warning.message}\n`); } - const warnings = new Set(result.warnings.map(warningKey)); - if (hasLoaded && [...warnings].some((warning) => !previousWarnings.has(warning))) { + const fatalWarnings = new Set( + result.warnings.filter((warning) => !warning.recoverable).map(warningKey), + ); + if (hasLoaded && [...fatalWarnings].some((warning) => !previousWarnings.has(warning))) { throw new CapletsError( "CONFIG_INVALID", "Caplets local overlay reload produced new warnings; keeping last known-good config.", ); } - previousWarnings = warnings; + previousWarnings = fatalWarnings; hasLoaded = true; return result.config; }; diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts index f7fc925c..9df5ef2e 100644 --- a/packages/core/test/config.test.ts +++ b/packages/core/test/config.test.ts @@ -886,6 +886,195 @@ describe("config", () => { } }); + it("quarantines Caplets with missing env refs in local overlays without dropping valid siblings", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-missing-env-")); + const missingEnvName = "CAPLETS_TEST_MISSING_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectRoot = join(dir, "project", ".caplets"); + const projectConfigPath = join(projectRoot, "config.json"); + mkdirSync(userRoot, { recursive: true }); + mkdirSync(projectRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + broken: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + healthy: { + name: "Healthy Local", + description: "A useful healthy downstream server.", + command: "healthy-server", + }, + }, + }), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config.mcpServers.healthy?.command).toBe("healthy-server"); + expect(config.mcpServers.broken).toBeUndefined(); + expect(sources.healthy).toEqual({ kind: "global-config", path: userConfigPath }); + expect(sources.broken).toBeUndefined(); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "global-config", + path: userConfigPath, + message: expect.stringContaining(missingEnvName), + }), + ]); + expect(warnings[0]?.message).toContain("mcpServers.broken.url"); + expect(warnings[0]?.message).toContain("skipping Caplet broken"); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("quarantines only the affected backend entry when another backend uses the same ID", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-missing-env-shared-id-")); + const missingEnvName = "CAPLETS_TEST_MISSING_SHARED_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + userConfigPath, + JSON.stringify({ + mcpServers: { + shared: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + }, + cliTools: { + shared: { + name: "Healthy CLI", + description: "Uses the same ID as the broken remote.", + actions: { + status: { + command: "git", + args: ["status", "--short"], + }, + }, + }, + }, + }), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config.mcpServers.shared).toBeUndefined(); + expect(config.cliTools.shared?.actions.status).toEqual( + expect.objectContaining({ command: "git" }), + ); + expect(sources.shared).toEqual({ kind: "global-config", path: userConfigPath }); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "global-config", + path: userConfigPath, + message: expect.stringContaining(missingEnvName), + recoverable: true, + }), + ]); + expect(warnings[0]?.message).toContain("mcpServers.shared.url"); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("quarantines Caplet files with missing env refs in local overlays without dropping valid siblings", () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-file-missing-env-")); + const missingEnvName = "CAPLETS_TEST_MISSING_FILE_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + try { + const userRoot = join(dir, "user"); + const userConfigPath = join(userRoot, "config.json"); + const projectConfigPath = join(dir, "project", ".caplets", "config.json"); + mkdirSync(userRoot, { recursive: true }); + writeFileSync( + join(userRoot, "broken.md"), + [ + "---", + "name: Broken Remote", + "description: References a missing startup URL.", + "mcpServer:", + " transport: http", + ` url: $env:${missingEnvName}`, + "---", + "# Broken Remote", + ].join("\n"), + ); + writeFileSync( + join(userRoot, "healthy.md"), + [ + "---", + "name: Healthy Local", + "description: A useful healthy downstream server.", + "mcpServer:", + " command: healthy-server", + "---", + "# Healthy Local", + ].join("\n"), + ); + + const { config, sources, warnings } = loadLocalOverlayConfigWithSources( + userConfigPath, + projectConfigPath, + ); + + expect(config.mcpServers.healthy?.command).toBe("healthy-server"); + expect(config.mcpServers.broken).toBeUndefined(); + expect(sources.healthy).toEqual({ kind: "global-file", path: join(userRoot, "healthy.md") }); + expect(sources.broken).toBeUndefined(); + expect(warnings).toEqual([ + expect.objectContaining({ + kind: "global-file", + path: join(userRoot, "broken.md"), + message: expect.stringContaining(missingEnvName), + recoverable: true, + }), + ]); + expect(warnings[0]?.message).toContain("mcpServers.broken.url"); + expect(warnings[0]?.message).toContain("skipping Caplet broken"); + } finally { + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + rmSync(dir, { recursive: true, force: true }); + } + }); + it("preserves local overlay source and shadow metadata", () => { const dir = mkdtempSync(join(tmpdir(), "caplets-overlay-shadows-")); try { diff --git a/packages/core/test/native-remote.test.ts b/packages/core/test/native-remote.test.ts index dfda869d..221b7d31 100644 --- a/packages/core/test/native-remote.test.ts +++ b/packages/core/test/native-remote.test.ts @@ -2298,6 +2298,53 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("starts valid local tools and loudly warns when a sibling Caplet references a missing env var", async () => { + const fixture = client([{ name: "remote", title: "Remote" }]); + const writeErr = vi.fn(); + const missingEnvName = "CAPLETS_NATIVE_TEST_MISSING_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + broken: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + + let service: NativeCapletsService | undefined; + try { + service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + writeErr, + }); + + await service.reload(); + + expect(configuredCapletIds(service.listTools())).toEqual(["remote", "local"]); + expect(writeErr).toHaveBeenCalledWith( + expect.stringContaining(`missing environment variable ${missingEnvName}`), + ); + expect(writeErr).toHaveBeenCalledWith(expect.stringContaining("mcpServers.broken.url")); + } finally { + await service?.close(); + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + } + }); + it("starts Cloud Project Binding when native service runs in cloud mode", async () => { const path = tempCloudAuthPath(); vi.stubEnv("CAPLETS_CLOUD_AUTH_PATH", path); @@ -2372,6 +2419,69 @@ describe("createNativeCapletsService remote mode", () => { await service.close(); }); + it("picks up healthy local overlay edits when reload introduces missing-env warnings", async () => { + const fixture = client([{ name: "remote", title: "Remote" }]); + const writeErr = vi.fn(); + const missingEnvName = "CAPLETS_NATIVE_TEST_RELOAD_MISSING_REMOTE_URL"; + const originalMissingEnv = process.env[missingEnvName]; + delete process.env[missingEnvName]; + const { dir, configPath, projectConfigPath } = tempConfig({ + mcpServers: { + local: { name: "Local", description: "Local Caplet.", command: process.execPath }, + }, + }); + dirs.push(dir); + + let service: NativeCapletsService | undefined; + try { + service = createNativeCapletsService({ + mode: "remote", + remote: { url: "http://127.0.0.1:5387" }, + remoteClientFactory: vi.fn(() => fixture.api), + configPath, + projectConfigPath, + writeErr, + }); + await service.reload(); + + writeFileSync( + configPath, + JSON.stringify( + progressiveTestConfig({ + mcpServers: { + broken: { + name: "Broken Remote", + description: "References a missing startup URL.", + transport: "http", + url: `$env:${missingEnvName}`, + }, + alpha: { name: "Alpha", description: "Alpha Caplet.", command: process.execPath }, + beta: { name: "Beta", description: "Beta Caplet.", command: process.execPath }, + }, + }), + ), + "utf8", + ); + + await expect(service.reload()).resolves.toBe(true); + + expect(configuredCapletIds(service.listTools())).toEqual(["remote", "alpha", "beta"]); + expect(writeErr).toHaveBeenCalledWith( + expect.stringContaining(`missing environment variable ${missingEnvName}`), + ); + expect(writeErr).not.toHaveBeenCalledWith( + expect.stringContaining("reload produced new warnings"), + ); + } finally { + await service?.close(); + if (originalMissingEnv === undefined) { + delete process.env[missingEnvName]; + } else { + process.env[missingEnvName] = originalMissingEnv; + } + } + }); + it("closes remote and local overlay services idempotently", async () => { vi.useFakeTimers(); const fixture = client(); diff --git a/skills/caplets/SKILL.md b/skills/caplets/SKILL.md new file mode 100644 index 00000000..b2cd74f7 --- /dev/null +++ b/skills/caplets/SKILL.md @@ -0,0 +1,131 @@ +--- +name: caplets +description: Always use when the user asks to use a capability that may not appear in your visible toolset, or when accessing configured backend capabilities through Caplets, including APIs, CLIs, resources, prompts, backend operations, capability handles, tool schemas, or compact Code Mode CLI results. +--- + +# Caplets + +## Overview + +Use `caplets code-mode <<'EOF'` as the default CLI workflow. Put discovery, schema inspection, tool calls, filtering, and summarization inside one TypeScript run, then return only the compact result the user needs. + +Do not turn Caplets work into a long sequence of `caplets search-tools`, `get-tool`, and `call-tool` shell commands unless the user specifically asks for progressive CLI commands. Code Mode is the primary surface. + +## Run Contract + +Before executing Code Mode, run `caplets code-mode types` to inspect the generated handles and method declarations available in the current config. + +When showing a reusable CLI script, use `caplets code-mode --json <<'EOF'` and `return` the compact value from the script. Do not use `console.log(...)` as the main output path; Code Mode returns the final expression/envelope for you. + +For unknown Caplets, unknown tools, or non-trivial argument shapes, discover first, describe when arguments matter, then call. + +## Default Pattern + +```sh +caplets code-mode --json <<'EOF' +const h = caplets.; +const ready = await h.check(); +if (!ready.ok) return ready; + +const tools = await h.searchTools("", { limit: 5 }); +const detail = await h.describeTool(""); +if (!detail.ok) return detail; + +const result = await h.callTool("", { + /* exact args from describeTool */ +}); +if (!result.ok) return result; + +return { + tools: tools.items.map((tool) => tool.name), + data: result.data, +}; +EOF +``` + +Keep bulky lists, schemas, and raw payloads inside the script. Return names, IDs, counts, selected fields, and error envelopes that drive the next decision. + +## Discover Handles + +When you do not know the configured Caplet ID, ask Code Mode first: + +```sh +caplets code-mode --json <<'EOF' +return Object.keys(caplets).filter((name) => name !== "debug").sort(); +EOF +``` + +For type hints and exact generated handle names: + +```sh +caplets code-mode types +``` + +## Handle API + +Use these methods inside the heredoc: + +| Need | Code Mode call | +| ----------------------------- | -------------------------------------------- | +| Inspect Caplet card | `await h.inspect()` | +| Check backend readiness | `await h.check()` | +| List tools | `await h.tools()` | +| Search tools | `await h.searchTools("query", { limit: 5 })` | +| Describe a tool | `await h.describeTool("tool_name")` | +| Call a tool | `await h.callTool("tool_name", args)` | +| List resources | `await h.resources()` | +| Search resources | `await h.searchResources("query")` | +| List resource templates | `await h.resourceTemplates()` | +| Read resource | `await h.readResource(uri)` | +| List prompts | `await h.prompts()` | +| Search prompts | `await h.searchPrompts("query")` | +| Get prompt | `await h.getPrompt("name", args)` | +| Complete prompt/resource args | `await h.complete(...)` | + +Never invent handle IDs, tool names, resource URIs, prompt names, argument names, or result fields. Discover first, describe when arguments matter, then call. + +## Output Discipline + +Return decision-ready JSON: + +```ts +return { + names: items.map((item) => item.name), + count: items.length, + nextCursor: page.nextCursor, +}; +``` + +On failure, return the exact Caplets envelope: + +```ts +const result = await h.callTool("", args); +if (!result.ok) return result; +``` + +Do not paste full schemas, full tool lists, logs, or raw downstream responses into chat unless the user asks for them. + +## Files And Long Scripts + +Use a heredoc for one-off workflows. Use `--file` when the script is long or should be checked into a repo: + +```sh +caplets code-mode --file scripts/caplets-workflow.ts --json +``` + +`--file` paths are relative to the current directory. One-shot CLI Code Mode runs do not reuse heap state across invocations. + +## Common Mistakes + +| Mistake | Fix | +| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| Running many progressive CLI commands for a multi-step task | Put discovery, call, and summarization inside one `caplets code-mode` heredoc. | +| Calling a guessed tool | `searchTools`, then `describeTool`, then `callTool`. | +| Using `console.log` for the answer | `return` the compact object; use `--json` on the CLI when structure matters. | +| Returning raw payloads | Project the fields the user needs inside the script. | +| Using direct network or filesystem APIs inside Code Mode | Use Caplet handles; direct I/O is intentionally unavailable. | +| Confusing CLI and Code Mode names | Shell commands are kebab-case; inside Code Mode use methods like `searchTools()` and `callTool()`. | + +## References + +- `references/troubleshooting.md` for missing Caplets, config paths, MCP registration, auth, and stdio startup checks. diff --git a/skills/caplets/references/troubleshooting.md b/skills/caplets/references/troubleshooting.md new file mode 100644 index 00000000..db33216d --- /dev/null +++ b/skills/caplets/references/troubleshooting.md @@ -0,0 +1,56 @@ +# Caplets CLI Troubleshooting + +Use this only when the Code Mode heredoc fails because setup, config, auth, or agent registration is suspect. + +## Smallest Local Checks + +```sh +caplets doctor +caplets config path +caplets config paths +caplets list +``` + +`caplets doctor` is the first health signal. `config path` and `config paths` prove which user, project, root, and auth paths are active before editing config. + +## Agent Registration + +For Codex: + +```sh +codex mcp list +codex mcp get caplets +``` + +For Claude Code: + +```sh +claude mcp list +claude mcp get caplets +``` + +The command should usually be `caplets serve` or `npx --yes caplets serve`. Remote-backed setups may use `caplets attach`. + +## Stdio Startup + +Do not treat a bare `caplets serve` process as a health check. Stdio MCP servers stay open waiting for JSON-RPC, so a hanging process is normal. + +If registration and config look correct but the client still cannot connect, probe the exact registered command with an `initialize` request and an external timeout: + +```sh +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"caplets-smoke","version":"1.0.0"}}}' \ + | timeout 5s caplets serve +``` + +Adapt the command and environment from the agent registration output. + +## Auth + +```sh +caplets auth list +caplets auth login +caplets auth refresh +caplets doctor +``` + +For Google Discovery Caplets, rerun `caplets auth login ` after changing operation filters or scopes.