From 41be71d91aa91263364a3adcd35938a15d3f3842 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 06:22:18 -0400 Subject: [PATCH 1/9] fix(cli): support split targets and public auth callbacks --- .brv | 2 +- .changeset/local-completion-split-tools.md | 6 + README.md | 12 +- ...21-completion-discovery-refactor-design.md | 11 +- packages/core/src/cli.ts | 143 +++++++++------ packages/core/src/cli/completion.ts | 38 ++-- packages/core/src/serve/http.ts | 5 +- packages/core/src/serve/options.ts | 2 + packages/core/test/cli-completion.test.ts | 33 +++- packages/core/test/cli.test.ts | 163 +++++++++++++++++- packages/core/test/serve-http.test.ts | 35 ++++ packages/core/test/serve-options.test.ts | 16 ++ 12 files changed, 383 insertions(+), 83 deletions(-) create mode 100644 .changeset/local-completion-split-tools.md diff --git a/.brv b/.brv index 41e76ea4..56aa062e 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 41e76ea438ff4bf5b6e9a4a0a942fbab6b18464b +Subproject commit 56aa062e6310837ef3b4f8c06070b939d08fec25 diff --git a/.changeset/local-completion-split-tools.md b/.changeset/local-completion-split-tools.md new file mode 100644 index 00000000..ad2a72cb --- /dev/null +++ b/.changeset/local-completion-split-tools.md @@ -0,0 +1,6 @@ +--- +"caplets": patch +"@caplets/core": patch +--- + +Fix local shell completion discovery for downstream tool names and support split `caplets get-tool `, `caplets call-tool `, and `caplets get-prompt ` command forms while preserving existing qualified targets. diff --git a/README.md b/README.md index 33b076df..7445c6f9 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,18 @@ You can also invoke configured Caplets directly from the CLI for agent-friendly ```sh caplets get-caplet context7 caplets list-tools context7 -caplets get-tool context7.resolve-library-id -caplets call-tool context7.resolve-library-id --args '{"libraryName":"react"}' -caplets call-tool context7.resolve-library-id --args '{"libraryName":"react"}' --field result.id --format json +caplets get-tool context7 resolve-library-id +caplets call-tool context7 resolve-library-id --args '{"libraryName":"react"}' +caplets call-tool context7 resolve-library-id --args '{"libraryName":"react"}' --field result.id --format json caplets list-resources docs caplets read-resource docs file:///repo/README.md caplets list-prompts linear -caplets get-prompt linear.review_issue --args '{"issueId":"CAP-123"}' +caplets get-prompt linear review_issue --args '{"issueId":"CAP-123"}' caplets complete docs --resource-template 'file:///repo/{path}' --argument path --value src/ ``` +The older qualified form, such as `caplets call-tool context7.resolve-library-id` or `caplets get-prompt linear.review_issue`, remains supported for scripts and existing usage. + Direct CLI operation commands print Markdown summaries by default. Add `--format plain` for plain text or `--format json` for machine-readable JSON (`md` is accepted as an alias for `markdown`). If a downstream tool returns `isError: true`, Caplets still exits with status code 1. ### Shell completions @@ -117,7 +119,7 @@ caplets completion cmd > %USERPROFILE%\caplets-completion.cmd %USERPROFILE%\caplets-completion.cmd ``` -Completions include command names, options, common enum values, configured Caplet IDs, and cache-backed downstream names for qualified targets such as `caplets call-tool repo.`. Downstream discovery is bounded by the `completion` config timeouts and a platform-native cache directory. Generated shell scripts suppress completion stderr; run the underlying CLI command directly when debugging completion behavior. +Completions include command names, options, common enum values, configured Caplet IDs, and cache-backed downstream names for split targets such as `caplets call-tool repo ` and qualified targets such as `caplets call-tool repo.`. Downstream discovery is bounded by the `completion` config timeouts and a platform-native cache directory. Generated shell scripts suppress completion stderr; run the underlying CLI command directly when debugging completion behavior. Backends that require OAuth or token auth may need `caplets auth login ` before live downstream completions can return richer results. Completion never starts interactive login flows. diff --git a/docs/specs/2026-05-21-completion-discovery-refactor-design.md b/docs/specs/2026-05-21-completion-discovery-refactor-design.md index 83243898..561fdc02 100644 --- a/docs/specs/2026-05-21-completion-discovery-refactor-design.md +++ b/docs/specs/2026-05-21-completion-discovery-refactor-design.md @@ -156,18 +156,19 @@ Completion may start normal MCP stdio processes because MCP discovery requires i For `call-tool` and `get-tool`: -- `caplets call-tool ` suggests enabled backend IDs with a trailing dot: `github.`, `repo.`. -- `caplets call-tool repo.` suggests `repo.status`, `repo.build`, etc. -- `caplets call-tool gh.se` filters by the full typed prefix. +- `caplets call-tool ` suggests enabled backend IDs such as `github`, `repo`. +- `caplets call-tool repo ` suggests unqualified tool names such as `status`, `build`. +- `caplets call-tool repo.` remains supported and suggests qualified tool names such as `repo.status`, `repo.build`. - CLI and HTTP backends can provide config-defined action names without live probing. - OpenAPI, GraphQL, MCP, and Caplet-set backends may use live discovery through the cache manager. -- Failures/timeouts degrade to `server.` suggestions or no extra target names. +- Failures/timeouts degrade to backend ID suggestions or no extra target names. ### Prompts For `get-prompt`: -- `caplets get-prompt ` suggests MCP backend IDs with trailing dots when prompts are possible. +- `caplets get-prompt ` suggests MCP backend IDs when prompts are possible. +- `caplets get-prompt docs ` suggests unqualified prompt names such as `summarize`. - `caplets get-prompt docs.` discovers/caches MCP prompt names and suggests `docs.promptName`. - Non-MCP backends should not suggest prompt names. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 35c597a8..05529ada 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -11,7 +11,6 @@ import { loginAuth, logoutAuth, listAuth, formatAuthRows, type AuthStatusRow } f import { cliCommands } from "./cli/commands"; import { initConfig } from "./cli/init"; import { - completeCliWords, completionScript, completionShells, trailingSpaceCompletionToken, @@ -142,7 +141,10 @@ export function createProgram(io: CliIO = {}): Command { shell, words: completionWords, })) as string[]) - : await completeCliWords(completionWords, configPath ? { configPath } : {}); + : await completeCliWordsLocally(completionWords, { + ...(configPath ? { configPath } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }); } catch { suggestions = []; } @@ -581,38 +583,47 @@ export function createProgram(io: CliIO = {}): Command { program .command(cliCommands.getTool) .description("Print one downstream tool schema.") - .argument("", "qualified target, split on the first dot") + .argument("", "Caplet ID or qualified target") + .argument("[tool]", "downstream tool name when caplet is provided separately") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) - .action(async (target: string, options: { format?: CliOutputFormat }) => { - const { caplet, tool } = parseQualifiedTarget(target); - await executeOperation( - caplet, - { operation: "get_tool", tool }, - { - writeOut, - writeErr, - setExitCode, - authDir: io.authDir, - env, - remote: remoteClientForCli(io), - format: options.format, - }, - ); - }); + .action( + async ( + capletOrTarget: string, + toolArgument: string | undefined, + options: { format?: CliOutputFormat }, + ) => { + const { caplet, tool } = parseQualifiedTarget(capletOrTarget, toolArgument); + await executeOperation( + caplet, + { operation: "get_tool", tool }, + { + writeOut, + writeErr, + setExitCode, + authDir: io.authDir, + env, + remote: remoteClientForCli(io), + format: options.format, + }, + ); + }, + ); program .command(cliCommands.callTool) .description("Call one downstream tool.") - .argument("", "qualified target, split on the first dot") + .argument("", "Caplet ID or qualified target") + .argument("[tool]", "downstream tool name when caplet is provided separately") .option("--args ", "JSON object of downstream tool arguments") .option("--field ", "project a field from structured output", collect, []) .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) .action( async ( - target: string, + capletOrTarget: string, + toolArgument: string | undefined, options: { args?: string; field?: string[]; format?: CliOutputFormat }, ) => { - const { caplet, tool } = parseQualifiedTarget(target); + const { caplet, tool } = parseQualifiedTarget(capletOrTarget, toolArgument); const request = { operation: "call_tool", tool, @@ -782,29 +793,36 @@ export function createProgram(io: CliIO = {}): Command { program .command(cliCommands.getPrompt) .description("Get one MCP prompt by name.") - .argument("", "qualified target, split on the first dot") + .argument("", "MCP Caplet ID or qualified target") + .argument("[prompt]", "prompt name when caplet is provided separately") .option("--args ", "JSON object of prompt arguments") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) - .action(async (target: string, options: { args?: string; format?: CliOutputFormat }) => { - const { caplet, tool: prompt } = parseQualifiedTarget(target); - await executeOperation( - caplet, - { - operation: "get_prompt", - prompt, - arguments: parseJsonObjectOption(options.args, "get-prompt --args"), - }, - { - writeOut, - writeErr, - setExitCode, - authDir: io.authDir, - env, - remote: remoteClientForCli(io), - format: options.format, - }, - ); - }); + .action( + async ( + capletOrTarget: string, + promptArgument: string | undefined, + options: { args?: string; format?: CliOutputFormat }, + ) => { + const { caplet, tool: prompt } = parseQualifiedTarget(capletOrTarget, promptArgument); + await executeOperation( + caplet, + { + operation: "get_prompt", + prompt, + arguments: parseJsonObjectOption(options.args, "get-prompt --args"), + }, + { + writeOut, + writeErr, + setExitCode, + authDir: io.authDir, + env, + remote: remoteClientForCli(io), + format: options.format, + }, + ); + }, + ); program .command(cliCommands.complete) .description("Complete an MCP prompt or resource-template argument.") @@ -1079,15 +1097,44 @@ function parseOutputFormat(value: string): CliOutputFormat { } } -function parseQualifiedTarget(target: string): { caplet: string; tool: string } { - const dot = target.indexOf("."); - if (dot <= 0 || dot === target.length - 1) { +function parseQualifiedTarget( + capletOrTarget: string, + toolArgument?: string | undefined, +): { caplet: string; tool: string } { + if (toolArgument !== undefined) { + if (capletOrTarget.length === 0 || toolArgument.length === 0) { + throw new CapletsError( + "REQUEST_INVALID", + "Expected target in the form or .", + ); + } + return { caplet: capletOrTarget, tool: toolArgument }; + } + + const dot = capletOrTarget.indexOf("."); + if (dot <= 0 || dot === capletOrTarget.length - 1) { throw new CapletsError( "REQUEST_INVALID", - "Expected qualified target in the form .", + "Expected target in the form or .", ); } - return { caplet: target.slice(0, dot), tool: target.slice(dot + 1) }; + return { caplet: capletOrTarget.slice(0, dot), tool: capletOrTarget.slice(dot + 1) }; +} + +async function completeCliWordsLocally( + words: string[], + options: { configPath?: string | undefined; authDir?: string | undefined }, +): Promise { + const engine = new CapletsEngine({ + ...(options.configPath ? { configPath: options.configPath } : {}), + ...(options.authDir ? { authDir: options.authDir } : {}), + watch: false, + }); + try { + return await engine.completeCliWords(words); + } finally { + await engine.close(); + } } function parseCallToolArgs(value: string | undefined): Record { diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index cfa24d74..c790682c 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -109,27 +109,33 @@ export async function completeCliWords( return prefixFilter(ids, current); } - if ( - normalized.length === 2 && - (qualifiedToolCommands.has(command) || qualifiedPromptCommands.has(command)) - ) { - if (current.includes(".")) { - const serverId = current.slice(0, current.indexOf(".")); - const kind = qualifiedToolCommands.has(command) ? "tools" : "prompts"; + if (qualifiedToolCommands.has(command) || qualifiedPromptCommands.has(command)) { + const kind = qualifiedToolCommands.has(command) ? "tools" : "prompts"; + const idFilter = qualifiedPromptCommands.has(command) + ? { backend: "mcp" as const } + : undefined; + + if (normalized.length === 2) { + if (current.includes(".")) { + const serverId = current.slice(0, current.indexOf(".")); + return prefixFilter( + (await discoverCompletionCandidates(serverId, kind, discoveryOptions(options))).map( + (candidate) => candidate.value, + ), + current, + ); + } + return prefixFilter(configuredCapletIds(options, idFilter), current); + } + + if (normalized.length === 3 && subcommand && !subcommand.includes(".")) { return prefixFilter( - (await discoverCompletionCandidates(serverId, kind, discoveryOptions(options))).map( - (candidate) => candidate.value, + (await discoverCompletionCandidates(subcommand, kind, discoveryOptions(options))).map( + (candidate) => candidate.value.replace(`${subcommand}.`, ""), ), current, ); } - return prefixFilter( - configuredCapletIds( - options, - qualifiedPromptCommands.has(command) ? { backend: "mcp" } : undefined, - ).map((id) => `${id}.`), - current, - ); } if (command === cliCommands.readResource && normalized.length === 3) { diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index ed45c8da..cbc1bc95 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -141,6 +141,7 @@ export function createHttpServeApp( authFlowStore, c.req.url, paths.control, + options.publicOrigin, options.trustProxy, (name) => c.req.header(name), ), @@ -158,6 +159,7 @@ export function createHttpServeApp( authFlowStore, c.req.url, paths.control, + options.publicOrigin, options.trustProxy, (name) => c.req.header(name), ), @@ -196,6 +198,7 @@ function controlContext( authFlowStore: RemoteAuthFlowStore, requestUrl: string, controlPath: string, + publicOrigin: string | undefined, trustProxy: boolean, header: (name: string) => string | undefined, ): RemoteControlDispatchContext { @@ -205,7 +208,7 @@ function controlContext( authFlowStore, controlCallbackBaseUrl: new URL( controlPath, - publicRequestOrigin(requestUrl, trustProxy, header), + publicOrigin ?? publicRequestOrigin(requestUrl, trustProxy, header), ).toString(), writeErr, }; diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts index 74d4d13a..b36bdc43 100644 --- a/packages/core/src/serve/options.ts +++ b/packages/core/src/serve/options.ts @@ -23,6 +23,7 @@ export type HttpServeOptions = { host: string; port: number; path: string; + publicOrigin?: string | undefined; auth: HttpBasicAuthOptions; warnUnauthenticatedNetwork: boolean; loopback: boolean; @@ -103,6 +104,7 @@ export function resolveServeOptions( host, port, path, + ...(serverUrl ? { publicOrigin: serverUrl.origin } : {}), auth, warnUnauthenticatedNetwork: !loopback && !auth.enabled, loopback, diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 3975b1cc..897f0199 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -117,7 +117,7 @@ describe("CLI completion resolver", () => { "repo", ]); await expect(completeCliWords(["call-tool", "git"], { configPath })).resolves.toEqual([ - "github.", + "github", ]); await expect(completeCliWords(["auth", "login", ""], { configPath })).resolves.toEqual([ "github", @@ -153,7 +153,7 @@ describe("CLI completion resolver", () => { }); dirs.push(dir); - await expect(completeCliWords(["get-prompt", ""], { configPath })).resolves.toEqual(["docs."]); + await expect(completeCliWords(["get-prompt", ""], { configPath })).resolves.toEqual(["docs"]); await expect(completeCliWords(["read-resource", ""], { configPath })).resolves.toEqual([ "docs", ]); @@ -192,6 +192,17 @@ describe("CLI completion resolver", () => { }); dirs.push(dir); + await expect(completeCliWords(["call-tool", ""], { configPath })).resolves.toEqual([ + "repo", + "status_api", + ]); + await expect(completeCliWords(["call-tool", "repo", ""], { configPath })).resolves.toEqual([ + "status", + "build", + ]); + await expect(completeCliWords(["get-tool", "status_api", ""], { configPath })).resolves.toEqual( + ["check"], + ); await expect(completeCliWords(["call-tool", "repo."], { configPath })).resolves.toEqual([ "repo.status", "repo.build", @@ -355,6 +366,24 @@ describe("CLI completion resolver", () => { ).resolves.toEqual(["file:///repo/{path}"]); }); + it("suggests split and dotted prompt targets", async () => { + const { dir, configPath } = writeMcpConfigWithDir("docs"); + dirs.push(dir); + + await expect( + completeCliWords(["get-prompt", "docs", ""], { + configPath, + managers: { listPrompts: async () => [{ name: "summarize" }] }, + }), + ).resolves.toEqual(["summarize"]); + await expect( + completeCliWords(["get-prompt", "docs."], { + configPath, + managers: { listPrompts: async () => [{ name: "summarize" }] }, + }), + ).resolves.toEqual(["docs.summarize"]); + }); + it("returns no suggestions instead of throwing when config loading fails", async () => { await expect( completeCliWords(["get-caplet", ""], { configPath: "/missing/config.json" }), diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index cbee7135..a998add0 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -539,6 +539,105 @@ describe("cli init", () => { } }); + it("gets a CLI tool with split caplet and tool arguments", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-tool-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeCliOperationConfig(configPath); + await runCli(["get-tool", "local", "echo_json", "--format", "json"], { + env: { CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ tool: { name: "echo_json" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("calls a CLI tool with split caplet and tool arguments", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-tool-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeCliOperationConfig(configPath); + await runCli( + [ + "call-tool", + "local", + "echo_json", + "--args", + JSON.stringify({ message: "hi" }), + "--format", + "json", + ], + { + env: { CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }, + ); + + expect(JSON.parse(out.join(""))).toMatchObject({ + structuredContent: { json: { message: "hi" } }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("gets an MCP prompt with split caplet and prompt arguments", async () => { + const requests: unknown[] = []; + const out: string[] = []; + const fetchMock = vi.fn( + async (_url: Parameters[0], init?: Parameters[1]) => { + requests.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ + ok: true, + result: { + description: "Review an issue.", + messages: [{ role: "user", content: { type: "text", text: "Review CAP-123" } }], + }, + }), + { headers: { "content-type": "application/json" } }, + ); + }, + ); + + await runCli( + [ + "get-prompt", + "linear", + "review_issue", + "--args", + JSON.stringify({ issueId: "CAP-123" }), + "--format", + "json", + ], + { + env: { CAPLETS_MODE: "remote", CAPLETS_SERVER_URL: "http://127.0.0.1:5387" }, + fetch: fetchMock, + writeOut: (value) => out.push(value), + }, + ); + + expect(requests).toEqual([ + { + command: "get_prompt", + arguments: { + caplet: "linear", + request: { + operation: "get_prompt", + prompt: "review_issue", + arguments: { issueId: "CAP-123" }, + }, + }, + }, + ]); + expect(JSON.parse(out.join(""))).toMatchObject({ description: "Review an issue." }); + }); + it("prints agent-first summaries by default for direct Caplet operation commands", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-call-summary-")); const configPath = join(dir, "config.json"); @@ -1906,11 +2005,7 @@ describe("cli completion commands", () => { }, ); - expect(out.join("").split("\n").filter(Boolean)).toEqual([ - "catalog.", - "filesystem.", - "users.", - ]); + expect(out.join("").split("\n").filter(Boolean)).toEqual(["catalog", "filesystem", "users"]); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1932,6 +2027,23 @@ describe("cli completion commands", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("uses engine-backed discovery for local hidden tool completion", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-completion-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeOpenApiCompletionConfig(configPath); + await runCli(["__complete", "--shell", "bash", "--", "get-tool", "users."], { + env: { CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }); + + expect(out.join("").split("\n").filter(Boolean)).toEqual(["users.lookupUser"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); function writeInspectionConfig(path: string): void { @@ -1976,6 +2088,47 @@ function writeInspectionConfig(path: string): void { ); } +function writeOpenApiCompletionConfig(path: string): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true }); + const specPath = join(dir, "openapi.json"); + writeFileSync( + specPath, + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Users API", version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + paths: { + "/users/{id}": { + get: { + operationId: "lookupUser", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }), + ); + writeFileSync( + path, + JSON.stringify({ + openapiEndpoints: { + users: { + name: "Users API", + description: "Manage users through OpenAPI.", + specPath, + auth: { type: "none" }, + }, + }, + completion: { + discoveryTimeoutMs: 250, + overallTimeoutMs: 500, + cacheTtlMs: 0, + negativeCacheTtlMs: 0, + }, + }), + ); +} + function writeCliOperationConfig(path: string): void { const dir = dirname(path); mkdirSync(dir, { recursive: true }); diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index 0183a9ed..b42d3ddd 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -304,6 +304,40 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("uses CAPLETS_SERVER_URL public scheme for remote auth callback URLs", async () => { + const context = testContext({ oauth: true }); + const engine = new CapletsEngine({ + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, + watch: false, + }); + const app = createHttpServeApp( + httpOptions({ path: "/caplets", publicOrigin: "https://caplets.example.com" }), + engine, + { + writeErr: () => {}, + control: context, + }, + ); + + const response = await app.request("http://127.0.0.1:5387/caplets/control", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command: "auth_login_start", arguments: { server: "remote" } }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual(expect.objectContaining({ ok: true })); + const result = (body as { result: { authorizationUrl: string } }).result; + const authorizationUrl = new URL(result.authorizationUrl); + expect(authorizationUrl.searchParams.get("redirect_uri")).toMatch( + /^https:\/\/caplets\.example\.com\/caplets\/control\/auth\/callback\//u, + ); + + await engine.close(); + }); + it("ignores forwarded host and proto for remote auth callback URLs by default", async () => { const context = testContext({ oauth: true }); const engine = new CapletsEngine({ @@ -457,6 +491,7 @@ function httpOptions(overrides: Partial = {}): HttpServeOption host: "127.0.0.1", port: 5387, path: "/", + publicOrigin: undefined, auth: { enabled: false, user: "caplets" }, warnUnauthenticatedNetwork: false, loopback: true, diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts index 51b1e1b0..cbe6400e 100644 --- a/packages/core/test/serve-options.test.ts +++ b/packages/core/test/serve-options.test.ts @@ -34,6 +34,22 @@ describe("resolveServeOptions", () => { port: 7890, path: "/caplets", auth: { enabled: true, user: "caplets", password: testPassword }, + publicOrigin: "http://localhost:7890", + }); + }); + + it("preserves HTTPS CAPLETS_SERVER_URL as the public origin", () => { + expect( + resolveServeOptions( + { transport: "http", allowUnauthenticatedHttp: true }, + { CAPLETS_SERVER_URL: "https://caplets.example.com/caplets" }, + ), + ).toMatchObject({ + transport: "http", + host: "caplets.example.com", + port: 5387, + path: "/caplets", + publicOrigin: "https://caplets.example.com", }); }); From c52ef138a397b7b5d50936d0edb92d1a75decd95 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 06:23:06 -0400 Subject: [PATCH 2/9] chore: exclude memory pointer from cli fix --- .brv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.brv b/.brv index 56aa062e..41e76ea4 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 56aa062e6310837ef3b4f8c06070b939d08fec25 +Subproject commit 41e76ea438ff4bf5b6e9a4a0a942fbab6b18464b From 07b644c1f980c2cfac9aaa0794b566d8290ee79e Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 06:25:47 -0400 Subject: [PATCH 3/9] chore: update opencode dev config --- .brv | 2 +- .opencode/opencode.json | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.brv b/.brv index 41e76ea4..56aa062e 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 41e76ea438ff4bf5b6e9a4a0a942fbab6b18464b +Subproject commit 56aa062e6310837ef3b4f8c06070b939d08fec25 diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 2c9f88e4..59632c53 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -14,7 +14,9 @@ "permission": { "external_directory": { "~/.config/caplets/": "allow", - "~/.config/caplets/*": "allow" + "~/.config/caplets/*": "allow", + "~/.local/state/caplets/": "allow", + "~/.local/state/caplets/*": "allow" } } } From af84c315134d5b946072ee68219923e0daefbd34 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 06:26:01 -0400 Subject: [PATCH 4/9] chore: update byterover memory pointer --- .brv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.brv b/.brv index 56aa062e..70a3e455 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 56aa062e6310837ef3b4f8c06070b939d08fec25 +Subproject commit 70a3e455c42b3307311c311feca76d0c309cbb54 From 73562b80bd18f4c4321bedb0111e95bd7e82f000 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 06:30:54 -0400 Subject: [PATCH 5/9] test(cli): force local mode for completion smoke test --- .opencode/opencode.json | 4 +--- packages/core/test/cli.test.ts | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 59632c53..2c9f88e4 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -14,9 +14,7 @@ "permission": { "external_directory": { "~/.config/caplets/": "allow", - "~/.config/caplets/*": "allow", - "~/.local/state/caplets/": "allow", - "~/.local/state/caplets/*": "allow" + "~/.config/caplets/*": "allow" } } } diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index a998add0..b5823a21 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -1979,6 +1979,7 @@ describe("cli completion commands", () => { const out: string[] = []; await runCli(["__complete", "--shell", "bash", "--", "add", ""], { + env: { CAPLETS_MODE: "local" }, writeOut: (value) => out.push(value), }); From c7023bc518b22cb427fce58cef8710f7f207e2c5 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 06:31:30 -0400 Subject: [PATCH 6/9] chore: keep byterover pointer out of PR --- .brv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.brv b/.brv index 70a3e455..41e76ea4 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 70a3e455c42b3307311c311feca76d0c309cbb54 +Subproject commit 41e76ea438ff4bf5b6e9a4a0a942fbab6b18464b From a4a2c3f029f80692c4f1d9b6d5c50852c3111963 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 06:39:15 -0400 Subject: [PATCH 7/9] test(cli): provide config for hidden completion smoke test --- packages/core/test/cli.test.ts | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index b5823a21..69b311a0 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -1976,20 +1976,27 @@ describe("cli completion commands", () => { }); it("runs the hidden completion endpoint", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-completion-")); + const configPath = join(dir, "config.json"); const out: string[] = []; + try { + writeInspectionConfig(configPath); - await runCli(["__complete", "--shell", "bash", "--", "add", ""], { - env: { CAPLETS_MODE: "local" }, - writeOut: (value) => out.push(value), - }); + await runCli(["__complete", "--shell", "bash", "--", "add", ""], { + env: { CAPLETS_MODE: "local", CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }); - expect(out.join("").split("\n").filter(Boolean)).toEqual([ - "cli", - "mcp", - "openapi", - "graphql", - "http", - ]); + expect(out.join("").split("\n").filter(Boolean)).toEqual([ + "cli", + "mcp", + "openapi", + "graphql", + "http", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); it("maps the PowerShell trailing-space sentinel before resolving completions", async () => { From a5c74dfbb617f4bf7b82bfaf0e38c87d174fa5a2 Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 07:12:51 -0400 Subject: [PATCH 8/9] fix(cli): address completion review feedback --- .brv | 2 +- .changeset/local-completion-split-tools.md | 2 +- packages/core/src/cli.ts | 5 ++++- packages/core/src/cli/completion.ts | 1 + packages/core/test/cli-completion.test.ts | 22 ++++++++++++++++++++++ 5 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.brv b/.brv index 41e76ea4..a77ca988 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit 41e76ea438ff4bf5b6e9a4a0a942fbab6b18464b +Subproject commit a77ca988c6fc363f565f628aac0274e1d3d2ae5c diff --git a/.changeset/local-completion-split-tools.md b/.changeset/local-completion-split-tools.md index ad2a72cb..3f63e184 100644 --- a/.changeset/local-completion-split-tools.md +++ b/.changeset/local-completion-split-tools.md @@ -3,4 +3,4 @@ "@caplets/core": patch --- -Fix local shell completion discovery for downstream tool names and support split `caplets get-tool `, `caplets call-tool `, and `caplets get-prompt ` command forms while preserving existing qualified targets. +Fix local shell completion discovery for downstream tool names and support split `caplets get-tool `, `caplets call-tool `, and `caplets get-prompt ` command forms while preserving existing qualified targets. Preserve the public `CAPLETS_SERVER_URL` origin for remote OAuth callback redirects. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 05529ada..dbaf9e8a 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -11,6 +11,7 @@ import { loginAuth, logoutAuth, listAuth, formatAuthRows, type AuthStatusRow } f import { cliCommands } from "./cli/commands"; import { initConfig } from "./cli/init"; import { + completeCliWords, completionScript, completionShells, trailingSpaceCompletionToken, @@ -146,7 +147,9 @@ export function createProgram(io: CliIO = {}): Command { ...(io.authDir ? { authDir: io.authDir } : {}), }); } catch { - suggestions = []; + suggestions = remote + ? [] + : await completeCliWords(completionWords, configPath ? { configPath } : {}); } if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`); }); diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index c790682c..f6c03ec1 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -129,6 +129,7 @@ export async function completeCliWords( } if (normalized.length === 3 && subcommand && !subcommand.includes(".")) { + if (current.startsWith("-")) return []; return prefixFilter( (await discoverCompletionCandidates(subcommand, kind, discoveryOptions(options))).map( (candidate) => candidate.value.replace(`${subcommand}.`, ""), diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 897f0199..90eb7b5e 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -212,6 +212,28 @@ describe("CLI completion resolver", () => { ]); }); + it("does not discover tool names when completing option flags after a split target", async () => { + const { dir, configPath } = writeCompletionConfig({ + mcpServers: { + repo: { + name: "Repo", + description: "Repository MCP server for completion tests.", + command: "node", + }, + }, + }); + dirs.push(dir); + const listTools = vi.fn(async () => [{ name: "status" }]); + + await expect( + completeCliWords(["call-tool", "repo", "--format"], { + configPath, + managers: { listTools }, + }), + ).resolves.toEqual([]); + expect(listTools).not.toHaveBeenCalled(); + }); + it("uses cached discovered tool names when live discovery times out", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-")); dirs.push(dir); From 5a741dde1f040ae7fd25e2e17fa9a0d63291464e Mon Sep 17 00:00:00 2001 From: Ian Pascoe Date: Fri, 22 May 2026 07:14:18 -0400 Subject: [PATCH 9/9] chore: keep memory pointer out of review fixes --- .brv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.brv b/.brv index a77ca988..41e76ea4 160000 --- a/.brv +++ b/.brv @@ -1 +1 @@ -Subproject commit a77ca988c6fc363f565f628aac0274e1d3d2ae5c +Subproject commit 41e76ea438ff4bf5b6e9a4a0a942fbab6b18464b