Skip to content
6 changes: 6 additions & 0 deletions .changeset/local-completion-split-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"caplets": patch
"@caplets/core": patch
---

Fix local shell completion discovery for downstream tool names and support split `caplets get-tool <caplet> <tool>`, `caplets call-tool <caplet> <tool>`, and `caplets get-prompt <caplet> <prompt>` command forms while preserving existing qualified targets. Preserve the public `CAPLETS_SERVER_URL` origin for remote OAuth callback redirects.
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.<TAB>`. 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 <TAB>` and qualified targets such as `caplets call-tool repo.<TAB>`. 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 <server>` before live downstream completions can return richer results. Completion never starts interactive login flows.

Expand Down
11 changes: 6 additions & 5 deletions docs/specs/2026-05-21-completion-discovery-refactor-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <TAB>` suggests enabled backend IDs with a trailing dot: `github.`, `repo.`.
- `caplets call-tool repo.<TAB>` suggests `repo.status`, `repo.build`, etc.
- `caplets call-tool gh.se<TAB>` filters by the full typed prefix.
- `caplets call-tool <TAB>` suggests enabled backend IDs such as `github`, `repo`.
- `caplets call-tool repo <TAB>` suggests unqualified tool names such as `status`, `build`.
- `caplets call-tool repo.<TAB>` 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 <TAB>` suggests MCP backend IDs with trailing dots when prompts are possible.
- `caplets get-prompt <TAB>` suggests MCP backend IDs when prompts are possible.
- `caplets get-prompt docs <TAB>` suggests unqualified prompt names such as `summarize`.
- `caplets get-prompt docs.<TAB>` discovers/caches MCP prompt names and suggests `docs.promptName`.
- Non-MCP backends should not suggest prompt names.

Expand Down
146 changes: 98 additions & 48 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,14 @@ 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 = [];
suggestions = remote
? []
: await completeCliWords(completionWords, configPath ? { configPath } : {});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`);
});
Expand Down Expand Up @@ -581,38 +586,47 @@ export function createProgram(io: CliIO = {}): Command {
program
.command(cliCommands.getTool)
.description("Print one downstream tool schema.")
.argument("<caplet.tool>", "qualified target, split on the first dot")
.argument("<caplet-or-target>", "Caplet ID or qualified <caplet.tool> target")
.argument("[tool]", "downstream tool name when caplet is provided separately")
.option("--format <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("<caplet.tool>", "qualified target, split on the first dot")
.argument("<caplet-or-target>", "Caplet ID or qualified <caplet.tool> target")
.argument("[tool]", "downstream tool name when caplet is provided separately")
.option("--args <json-object>", "JSON object of downstream tool arguments")
.option("--field <path>", "project a field from structured output", collect, [])
.option("--format <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,
Expand Down Expand Up @@ -782,29 +796,36 @@ export function createProgram(io: CliIO = {}): Command {
program
.command(cliCommands.getPrompt)
.description("Get one MCP prompt by name.")
.argument("<caplet.prompt>", "qualified target, split on the first dot")
.argument("<caplet-or-target>", "MCP Caplet ID or qualified <caplet.prompt> target")
.argument("[prompt]", "prompt name when caplet is provided separately")
.option("--args <json-object>", "JSON object of prompt arguments")
.option("--format <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.")
Expand Down Expand Up @@ -1079,15 +1100,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 <caplet> <tool> or <caplet>.<tool>",
);
}
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 <caplet>.<tool>",
"Expected target in the form <caplet> <tool> or <caplet>.<tool>",
);
}
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<string[]> {
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<string, unknown> {
Expand Down
39 changes: 23 additions & 16 deletions packages/core/src/cli/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,27 +109,34 @@ 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(".")) {
if (current.startsWith("-")) return [];
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,
);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return prefixFilter(
configuredCapletIds(
options,
qualifiedPromptCommands.has(command) ? { backend: "mcp" } : undefined,
).map((id) => `${id}.`),
current,
);
}

if (command === cliCommands.readResource && normalized.length === 3) {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/serve/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export function createHttpServeApp(
authFlowStore,
c.req.url,
paths.control,
options.publicOrigin,
options.trustProxy,
(name) => c.req.header(name),
),
Expand All @@ -158,6 +159,7 @@ export function createHttpServeApp(
authFlowStore,
c.req.url,
paths.control,
options.publicOrigin,
options.trustProxy,
(name) => c.req.header(name),
),
Expand Down Expand Up @@ -196,6 +198,7 @@ function controlContext(
authFlowStore: RemoteAuthFlowStore,
requestUrl: string,
controlPath: string,
publicOrigin: string | undefined,
trustProxy: boolean,
header: (name: string) => string | undefined,
): RemoteControlDispatchContext {
Expand All @@ -205,7 +208,7 @@ function controlContext(
authFlowStore,
controlCallbackBaseUrl: new URL(
controlPath,
publicRequestOrigin(requestUrl, trustProxy, header),
publicOrigin ?? publicRequestOrigin(requestUrl, trustProxy, header),
).toString(),
writeErr,
};
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/serve/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type HttpServeOptions = {
host: string;
port: number;
path: string;
publicOrigin?: string | undefined;
auth: HttpBasicAuthOptions;
warnUnauthenticatedNetwork: boolean;
loopback: boolean;
Expand Down Expand Up @@ -103,6 +104,7 @@ export function resolveServeOptions(
host,
port,
path,
...(serverUrl ? { publicOrigin: serverUrl.origin } : {}),
auth,
warnUnauthenticatedNetwork: !loopback && !auth.enabled,
loopback,
Expand Down
Loading