argsbarg 5.1.15 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/README.md +32 -24
  3. package/docs/README.md +2 -1
  4. package/docs/api-server.md +141 -0
  5. package/docs/bundled-docs.md +24 -10
  6. package/docs/cli-program.md +17 -2
  7. package/docs/configure.md +2 -2
  8. package/docs/developing.md +1 -1
  9. package/docs/mcp.md +5 -5
  10. package/docs/output-schema.md +3 -3
  11. package/examples/full-example/README.md +8 -0
  12. package/examples/full-example/docs/README.md +27 -0
  13. package/examples/full-example/docs/api.md +511 -0
  14. package/examples/full-example/docs/cli-schema.json +453 -0
  15. package/examples/full-example/docs/http.md +81 -0
  16. package/examples/full-example/docs/mcp.md +159 -0
  17. package/examples/full-example/docs/openapi.json +222 -0
  18. package/examples/full-example/docs/skill.md +46 -0
  19. package/examples/full-example/justfile +6 -2
  20. package/examples/full-example/scripts/dev-formula.ts +1 -1
  21. package/examples/full-example/src/commands/echo/command.ts +6 -1
  22. package/examples/full-example/src/program.ts +3 -0
  23. package/examples/mcp-test.ts +13 -2
  24. package/examples/nested.ts +12 -3
  25. package/examples/servers.ts +72 -0
  26. package/index.d.ts +66 -7
  27. package/package.json +1 -1
  28. package/src/api/openapi.ts +115 -0
  29. package/src/api/result.ts +89 -0
  30. package/src/api/server.ts +120 -0
  31. package/src/api.integration.test.ts +358 -0
  32. package/src/builtins/api.ts +38 -0
  33. package/src/builtins/dispatch.ts +26 -0
  34. package/src/builtins/registry.ts +4 -0
  35. package/src/capabilities.ts +12 -1
  36. package/src/cli-tool/full-example-capabilities.test.ts +3 -0
  37. package/src/cli.ts +60 -8
  38. package/src/config/bootstrap.test.ts +46 -0
  39. package/src/config/bootstrap.ts +22 -5
  40. package/src/config.integration.test.ts +22 -4
  41. package/src/configure/index.ts +1 -1
  42. package/src/context.ts +29 -1
  43. package/src/docs/api-guide.ts +2 -2
  44. package/src/docs/builtin.ts +11 -1
  45. package/src/docs/docs.test.ts +70 -12
  46. package/src/docs/http-guide.ts +132 -0
  47. package/src/docs/mcp-guide.ts +3 -3
  48. package/src/docs/resolve.ts +26 -2
  49. package/src/docs/save.ts +5 -2
  50. package/src/headless/tool-call.ts +147 -0
  51. package/src/headless.test.ts +4 -2
  52. package/src/headless.ts +10 -5
  53. package/src/index.ts +5 -0
  54. package/src/mcp/result.ts +39 -34
  55. package/src/mcp/server.ts +18 -36
  56. package/src/mcp/tools.ts +14 -3
  57. package/src/mcp.integration.test.ts +46 -39
  58. package/src/parse.test.ts +16 -6
  59. package/src/respond.ts +48 -0
  60. package/src/schema.ts +1 -1
  61. package/src/skill/generate.ts +1 -1
  62. package/src/types.ts +46 -4
  63. package/src/validate.ts +7 -0
@@ -73,14 +73,20 @@ test("docs rejects reserved topic keys", () => {
73
73
  const root = docsFixture();
74
74
  const docs = root.docs;
75
75
  if (!docs) throw new Error("expected docs fixture");
76
- docs.topics.schema = { text: "nope" };
76
+ docs.topics["cli-schema"] = { text: "nope" };
77
77
  expect(() => cliValidateProgram(root)).toThrow(/reserved/);
78
- delete docs.topics.schema;
78
+ delete docs.topics["cli-schema"];
79
79
  docs.topics.skill = { text: "nope" };
80
80
  expect(() => cliValidateProgram(root)).toThrow(/reserved/);
81
81
  delete docs.topics.skill;
82
82
  docs.topics.api = { text: "nope" };
83
83
  expect(() => cliValidateProgram(root)).toThrow(/reserved/);
84
+ delete docs.topics.api;
85
+ docs.topics.openapi = { text: "nope" };
86
+ expect(() => cliValidateProgram(root)).toThrow(/reserved/);
87
+ delete docs.topics.openapi;
88
+ docs.topics.http = { text: "nope" };
89
+ expect(() => cliValidateProgram(root)).toThrow(/reserved/);
84
90
  });
85
91
 
86
92
  test("docsEffectiveDefaultTopic uses first topic key", () => {
@@ -132,6 +138,46 @@ test("docs mcp absent from router when MCP disabled", async () => {
132
138
  expect(result.exitCode).not.toBe(0);
133
139
  });
134
140
 
141
+ test("docs http when API enabled", async () => {
142
+ const root = docsFixture(true);
143
+ root.apiServer = { enabled: true };
144
+ cliValidateProgram(root);
145
+ const result = await new Cli(root).invoke(["docs", "http"]);
146
+ expect(result.exitCode).toBe(0);
147
+ expect(result.stdout).toContain("HTTP API (myapp)");
148
+ expect(result.stdout).toContain("curl -s -X POST");
149
+ });
150
+
151
+ test("docs http absent from router when API disabled", async () => {
152
+ const root = docsFixture(false);
153
+ const presentation = cliPresentationRoot(root);
154
+ const docsNode = presentation.commands.find((c) => c.key === "docs");
155
+ expect(docsNode && "commands" in docsNode).toBe(true);
156
+ if (docsNode && "commands" in docsNode) {
157
+ expect(docsNode.commands.some((c) => c.key === "http")).toBe(false);
158
+ expect(docsNode.commands.some((c) => c.key === "openapi")).toBe(false);
159
+ }
160
+ const result = await new Cli(root).invoke(["docs", "http"]);
161
+ expect(result.exitCode).not.toBe(0);
162
+ });
163
+
164
+ test("docs openapi when API enabled", async () => {
165
+ const root = docsFixture(true);
166
+ root.apiServer = { enabled: true };
167
+ cliValidateProgram(root);
168
+ const result = await new Cli(root).invoke(["docs", "openapi"]);
169
+ expect(result.exitCode).toBe(0);
170
+ const doc = JSON.parse(result.stdout) as { openapi: string; paths: Record<string, unknown> };
171
+ expect(doc.openapi).toBe("3.1.0");
172
+ expect(doc.paths).toBeDefined();
173
+ });
174
+
175
+ test("docs openapi absent when API disabled", async () => {
176
+ const root = docsFixture(false);
177
+ const result = await new Cli(root).invoke(["docs", "openapi"]);
178
+ expect(result.exitCode).not.toBe(0);
179
+ });
180
+
135
181
  test("presentation includes docs subtree", () => {
136
182
  const presentation = cliPresentationRoot(docsFixture());
137
183
  const docsNode = presentation.commands.find((c) => c.key === "docs");
@@ -139,8 +185,8 @@ test("presentation includes docs subtree", () => {
139
185
  expect(docsNode && "commands" in docsNode && docsNode.commands.some((c) => c.key === "readme")).toBe(true);
140
186
  });
141
187
 
142
- test("docs schema prints JSON", async () => {
143
- const result = await new Cli(docsFixture()).invoke(["docs", "schema"]);
188
+ test("docs cli-schema prints JSON", async () => {
189
+ const result = await new Cli(docsFixture()).invoke(["docs", "cli-schema"]);
144
190
  expect(result.exitCode).toBe(0);
145
191
  const schema = JSON.parse(result.stdout);
146
192
  expect(schema.key).toBe("myapp");
@@ -153,7 +199,7 @@ test("docs api prints markdown reference", async () => {
153
199
  expect(result.stdout).toContain("# myapp — CLI API reference");
154
200
  expect(result.stdout).toContain("## `myapp run`");
155
201
  expect(result.stdout).toContain("Run something.");
156
- expect(result.stdout).toContain("myapp docs schema");
202
+ expect(result.stdout).toContain("myapp docs cli-schema");
157
203
  });
158
204
 
159
205
  test("skipsRequiredAppConfigExit includes docs and config builtins", () => {
@@ -195,12 +241,12 @@ test("docs skill help recommends configure", async () => {
195
241
  }
196
242
  });
197
243
 
198
- test("presentation includes docs schema and skill", () => {
244
+ test("presentation includes docs cli-schema and skill", () => {
199
245
  const presentation = cliPresentationRoot(docsFixture());
200
246
  const docsNode = presentation.commands.find((c) => c.key === "docs");
201
247
  expect(docsNode && "commands" in docsNode).toBe(true);
202
248
  if (docsNode && "commands" in docsNode) {
203
- expect(docsNode.commands.some((c) => c.key === "schema")).toBe(true);
249
+ expect(docsNode.commands.some((c) => c.key === "cli-schema")).toBe(true);
204
250
  expect(docsNode.commands.some((c) => c.key === "api")).toBe(true);
205
251
  expect(docsNode.commands.some((c) => c.key === "skill")).toBe(true);
206
252
  }
@@ -210,7 +256,7 @@ test("completions offer docs subcommands", () => {
210
256
  const bash = completionBashScript(cliPresentationRoot(docsFixture()));
211
257
  expect(bash).toContain("docs) echo");
212
258
  expect(bash).toContain("readme) echo");
213
- expect(bash).toContain("schema) echo");
259
+ expect(bash).toContain("cli-schema) echo");
214
260
  expect(bash).toContain("api) echo");
215
261
  expect(bash).toContain("skill) echo");
216
262
  });
@@ -255,16 +301,28 @@ test("docs skill --save keeps frontmatter first", async () => {
255
301
  expect(text.indexOf(hint)).toBeGreaterThan(text.indexOf("---\n", 4));
256
302
  });
257
303
 
258
- test("docs schema --save writes JSON file", async () => {
259
- const result = await new Cli(docsFixture()).invoke(["docs", "schema", "--save"]);
304
+ test("docs cli-schema --save writes JSON file", async () => {
305
+ const result = await new Cli(docsFixture()).invoke(["docs", "cli-schema", "--save"]);
260
306
  expect(result.exitCode).toBe(0);
261
- expect(result.stdout.trim()).toBe("docs/schema.json");
262
- const text = readFileSync(join(workDir, "docs/schema.json"), "utf8");
307
+ expect(result.stdout.trim()).toBe("docs/cli-schema.json");
308
+ const text = readFileSync(join(workDir, "docs/cli-schema.json"), "utf8");
263
309
  expect(text).not.toContain("Generated by");
264
310
  const schema = JSON.parse(text);
265
311
  expect(schema.key).toBe("myapp");
266
312
  });
267
313
 
314
+ test("docs openapi --save writes JSON file", async () => {
315
+ const root = docsFixture(true);
316
+ root.apiServer = { enabled: true };
317
+ const result = await new Cli(root).invoke(["docs", "openapi", "--save"]);
318
+ expect(result.exitCode).toBe(0);
319
+ expect(result.stdout.trim()).toBe("docs/openapi.json");
320
+ const text = readFileSync(join(workDir, "docs/openapi.json"), "utf8");
321
+ expect(text).not.toContain("Generated by");
322
+ const doc = JSON.parse(text) as { openapi: string };
323
+ expect(doc.openapi).toBe("3.1.0");
324
+ });
325
+
268
326
  test("saveDocsTopic returns relative path", () => {
269
327
  const path = saveDocsTopic(docsFixture(), "api");
270
328
  expect(path).toBe("docs/api.md");
@@ -0,0 +1,132 @@
1
+ import { resolveApiListenAddress } from "../api/server.ts";
2
+ import { defaultConfigEntryTitle } from "../config/entry.ts";
3
+ import { displayAppConfigPath } from "../config/file.ts";
4
+ import { collectMcpTools, type McpToolDef } from "../mcp/tools.ts";
5
+ import { collectOptionDefs } from "../parse.ts";
6
+ import { CliOptionKind, type CliProgram } from "../types.ts";
7
+
8
+ /** Formats one exposed tool for the auto-generated HTTP guide. */
9
+ function formatToolLine(root: CliProgram, tool: McpToolDef): string {
10
+ const cliPath = tool.path.length > 0 ? `${root.key} ${tool.path.join(" ")}` : root.key;
11
+ let line = `- \`${tool.apiName}\` (MCP: \`${tool.name}\`, CLI: \`${cliPath}\`) — ${tool.description}`;
12
+ const opts = collectOptionDefs(root, tool.path);
13
+ const flags = opts.filter((o) => o.kind === CliOptionKind.Presence).map((o) => `--${o.name}`);
14
+ if (flags.length > 0) {
15
+ line += ` (flags: ${flags.join(", ")})`;
16
+ }
17
+ return line;
18
+ }
19
+
20
+ /** Generates the auto `docs http` markdown guide from schema and API config. */
21
+ export function generateHttpGuide(root: CliProgram): string {
22
+ const api = root.apiServer;
23
+ if (!api) {
24
+ throw new Error("HTTP API server not enabled");
25
+ }
26
+
27
+ const tools = collectMcpTools(root);
28
+ const { hostname, port } = resolveApiListenAddress(root);
29
+ const baseUrl = `http://${hostname}:${port}`;
30
+
31
+ const lines: string[] = [
32
+ `# HTTP API (${root.key})`,
33
+ "",
34
+ `${root.key} exposes the same callable tools over HTTP as MCP.`,
35
+ "",
36
+ "## Running",
37
+ "",
38
+ "```bash",
39
+ `${root.key} api`,
40
+ "```",
41
+ "",
42
+ `Listens on **${baseUrl}** by default (\`apiServer.host\` / \`apiServer.port\`).`,
43
+ "",
44
+ "Bind is localhost-only in v0 — use a reverse proxy for remote access.",
45
+ "",
46
+ "## Endpoints",
47
+ "",
48
+ "| Method | Path | Purpose |",
49
+ "| --- | --- | --- |",
50
+ "| `GET` | `/health` | Liveness check |",
51
+ "| `GET` | `/openapi.json` | OpenAPI 3.1 document (tool paths and request shapes) |",
52
+ "| `GET` | `/openapi-browser` | Interactive Scalar API reference |",
53
+ "| `POST` | `/tools/:name` | Invoke with flat JSON args object in the body |",
54
+ "| `OPTIONS` | `*` | CORS preflight |",
55
+ "",
56
+ "Replace `{tool-key}` below with a path segment from `openapi.json` (`paths` keys are `/tools/{tool-key}`). Match body keys to that tool's `requestBody` schema in the spec.",
57
+ "",
58
+ "## Examples",
59
+ "",
60
+ "```bash",
61
+ `curl -s ${baseUrl}/health`,
62
+ `curl -s ${baseUrl}/openapi.json`,
63
+ `curl -s -X POST ${baseUrl}/tools/{tool-key} \\`,
64
+ ' -H "content-type: application/json" \\',
65
+ " -d '{...}'",
66
+ "```",
67
+ "",
68
+ "## Responses",
69
+ "",
70
+ "Success (`200`): raw response body (JSON object, string, or binary). No `{ ok, stdout }` envelope.",
71
+ "",
72
+ "Handlers must use `ctx.respond()` or return a value for API/MCP tool calls.",
73
+ "",
74
+ 'Errors use `{ "error": "..." }` with `400`, `404`, `503`, or `500`.',
75
+ "",
76
+ ];
77
+
78
+ if (root.appConfig?.entries && Object.keys(root.appConfig.entries).length > 0) {
79
+ lines.push("## Configuration", "");
80
+ lines.push(
81
+ `Configure before first use: \`${root.key} configure\`.`,
82
+ "",
83
+ `Default config file: \`${displayAppConfigPath(root)}\`.`,
84
+ "",
85
+ );
86
+ for (const [key, entry] of Object.entries(root.appConfig.entries)) {
87
+ const label = entry.title ?? defaultConfigEntryTitle(key);
88
+ const req = entry.required === false ? "optional" : "required";
89
+ const envNote = entry.env ? ` → env \`${entry.env}\`` : "";
90
+ lines.push(`- **${label}** (\`${key}\`, ${req}${envNote}) — ${entry.description}`);
91
+ }
92
+ lines.push("");
93
+ }
94
+
95
+ lines.push("## Exposed tools", "");
96
+
97
+ if (tools.length === 0) {
98
+ lines.push("(No tools exposed.)", "");
99
+ } else {
100
+ for (const tool of tools) {
101
+ lines.push(formatToolLine(root, tool));
102
+ }
103
+ lines.push("");
104
+ }
105
+
106
+ lines.push(
107
+ "## Tool arguments",
108
+ "",
109
+ "POST bodies are a flat JSON object keyed by long option and positional names (hyphenated option names are valid keys).",
110
+ "",
111
+ `For HTTP clients, use **\`GET /openapi.json\`** (or **\`GET /openapi-browser\`**) for per-tool request shapes — each \`POST /tools/{name}\` path has a \`requestBody\` schema.`,
112
+ "",
113
+ "Varargs positionals accept a JSON array of strings (not a comma-separated string).",
114
+ "Options with `format: comma-list` accept a comma-separated string or JSON array.",
115
+ "Options with a schema `default` are applied when omitted.",
116
+ "",
117
+ `Shell invocation reference: \`${root.key} docs api\`. Full CLI tree JSON: \`${root.key} docs cli-schema\`.`,
118
+ "",
119
+ "## OpenAPI",
120
+ "",
121
+ "The HTTP API is described in OpenAPI 3.1.",
122
+ "",
123
+ `- **Browse** — [${baseUrl}/openapi-browser](${baseUrl}/openapi-browser) (Scalar UI; loads \`/openapi.json\`)`,
124
+ `- **Fetch** — \`curl -s ${baseUrl}/openapi.json\``,
125
+ `- **Save offline** — \`${root.key} docs openapi --save\` → \`./docs/openapi.json\` (or \`just docgen\` in app repos)`,
126
+ "",
127
+ "Use the spec to discover tool names (`paths`) and request/response shapes before calling `POST /tools/:name`.",
128
+ "",
129
+ );
130
+
131
+ return lines.join("\n");
132
+ }
@@ -202,7 +202,7 @@ export function generateMcpGuide(root: CliProgram): string {
202
202
  "|-----------|---------|",
203
203
  "| `tools/list` | Callable tools for exposed leaf commands |",
204
204
  "| `tools/call` | Runs handlers headlessly; JSON stdout becomes `structuredContent` when valid |",
205
- `| Schema resource | \`${schemaUri}\` — same JSON as \`${root.key} docs schema\` |`,
205
+ `| Schema resource | \`${schemaUri}\` — same JSON as \`${root.key} docs cli-schema\` |`,
206
206
  );
207
207
  if (docsEnabled(root)) {
208
208
  const docs = root.docs;
@@ -228,7 +228,7 @@ export function generateMcpGuide(root: CliProgram): string {
228
228
  "## Tool arguments",
229
229
  "",
230
230
  "Arguments are a flat JSON object keyed by long option and positional names (hyphenated option names are valid keys).",
231
- `See \`${root.key} docs schema\` or the schema resource for per-tool shapes.`,
231
+ `See \`${root.key} docs cli-schema\` or the schema resource for per-tool shapes.`,
232
232
  "",
233
233
  "Varargs positionals accept a JSON array of strings (not a comma-separated string).",
234
234
  "Options with `format: comma-list` accept a comma-separated string or JSON array.",
@@ -236,7 +236,7 @@ export function generateMcpGuide(root: CliProgram): string {
236
236
  "",
237
237
  "## Protocol",
238
238
  "",
239
- "Stdio NDJSON JSON-RPC. Help and `docs schema` are not available through tool calls.",
239
+ "Stdio NDJSON JSON-RPC. Help and `docs cli-schema` are not available through tool calls.",
240
240
  `Run \`${root.key} docs\` for bundled user documentation.`,
241
241
  "",
242
242
  );
@@ -1,11 +1,13 @@
1
+ import { openApiJson } from "../api/openapi.ts";
1
2
  import { cliSchemaJson } from "../schema.ts";
2
3
  import { generateSkillBundle } from "../skill/generate.ts";
3
4
  import type { CliDocsConfig, CliProgram } from "../types.ts";
4
5
  import { generateApiGuide } from "./api-guide.ts";
6
+ import { generateHttpGuide } from "./http-guide.ts";
5
7
  import { generateMcpGuide } from "./mcp-guide.ts";
6
8
 
7
9
  /** Built-in docs subcommand keys not allowed in `docs.topics`. */
8
- export const DOCS_BUILTIN_TOPIC_KEYS = ["mcp", "all", "schema", "api", "skill"] as const;
10
+ export const DOCS_BUILTIN_TOPIC_KEYS = ["http", "mcp", "all", "cli-schema", "api", "skill", "openapi"] as const;
9
11
 
10
12
  export type DocsBuiltinTopicKey = (typeof DOCS_BUILTIN_TOPIC_KEYS)[number];
11
13
 
@@ -43,6 +45,16 @@ export function docsIncludesMcpTopic(program: CliProgram): boolean {
43
45
  return docsEnabled(program) && program.mcpServer?.enabled === true;
44
46
  }
45
47
 
48
+ /** Whether HTTP auto-guide topic is included. */
49
+ export function docsIncludesHttpTopic(program: CliProgram): boolean {
50
+ return docsEnabled(program) && program.apiServer?.enabled === true;
51
+ }
52
+
53
+ /** Whether OpenAPI export topic is included. */
54
+ export function docsIncludesOpenApiTopic(program: CliProgram): boolean {
55
+ return docsIncludesHttpTopic(program);
56
+ }
57
+
46
58
  /** Leaf help description for a user topic. */
47
59
  export function docsTopicDescription(key: string, custom?: string): string {
48
60
  if (custom) {
@@ -67,6 +79,12 @@ export function docsTopicText(program: CliProgram, topic: string): string {
67
79
  }
68
80
  return generateMcpGuide(program);
69
81
  }
82
+ if (topic === "http") {
83
+ if (!docsIncludesHttpTopic(program)) {
84
+ throw new Error("Unknown docs topic 'http'.");
85
+ }
86
+ return generateHttpGuide(program);
87
+ }
70
88
  const entry = docs.topics[topic];
71
89
  if (!entry) {
72
90
  throw new Error(`Unknown docs topic '${topic}'.`);
@@ -76,9 +94,15 @@ export function docsTopicText(program: CliProgram, topic: string): string {
76
94
 
77
95
  /** Full file body for a docs topic (stdout or `--save`). */
78
96
  export function docsTopicContent(program: CliProgram, topic: string): string {
79
- if (topic === "schema") {
97
+ if (topic === "cli-schema") {
80
98
  return cliSchemaJson(program);
81
99
  }
100
+ if (topic === "openapi") {
101
+ if (!docsIncludesOpenApiTopic(program)) {
102
+ throw new Error("Unknown docs topic 'openapi'.");
103
+ }
104
+ return openApiJson(program);
105
+ }
82
106
  if (topic === "api") {
83
107
  return generateApiGuide(program);
84
108
  }
package/src/docs/save.ts CHANGED
@@ -36,8 +36,11 @@ export function docsTopicContentForSave(program: CliProgram, topic: string): str
36
36
 
37
37
  /** Filename for a saved docs topic. */
38
38
  export function docsSaveFilename(topic: string): string {
39
- if (topic === "schema") {
40
- return "schema.json";
39
+ if (topic === "cli-schema") {
40
+ return "cli-schema.json";
41
+ }
42
+ if (topic === "openapi") {
43
+ return "openapi.json";
41
44
  }
42
45
  return `${topic}.md`;
43
46
  }
@@ -0,0 +1,147 @@
1
+ /*
2
+ Shared headless tool dispatch for MCP and HTTP: config bootstrap, argv conversion, and invoke.
3
+ */
4
+
5
+ import { apiErrorResponse, apiSuccessResponse } from "../api/result.ts";
6
+ import type { Cli, CliInvokeResult } from "../cli.ts";
7
+ import { bootstrapAppConfig } from "../config/bootstrap.ts";
8
+ import { formatMcpMissingConfigMessage, missingRequiredConfig } from "../config/resolve.ts";
9
+ import { buildToolCallSuccessFromResponse } from "../mcp/result.ts";
10
+ import { collectMcpTools, type McpToolDef, mcpToolCallToArgv } from "../mcp/tools.ts";
11
+ import type { CliInvocation, CliProgram } from "../types.ts";
12
+
13
+ /** Outcome of resolving a tool name against the program schema. */
14
+ export type ToolLookupResult =
15
+ | { ok: true; tool: McpToolDef }
16
+ | { ok: false; kind: "unknown"; message: string }
17
+ | { ok: false; kind: "missing_config"; message: string };
18
+
19
+ /** Successful headless tool invocation payload shared by MCP and HTTP. */
20
+ export interface HeadlessToolCallSuccess {
21
+ ok: true;
22
+ response: NonNullable<CliInvokeResult["response"]>;
23
+ mcpResult: ReturnType<typeof buildToolCallSuccessFromResponse>;
24
+ }
25
+
26
+ /** Failed headless tool invocation payload shared by MCP and HTTP. */
27
+ export interface HeadlessToolCallFailure {
28
+ ok: false;
29
+ kind: "argv" | "invoke" | "help";
30
+ message: string;
31
+ exitCode: number;
32
+ stdout: string;
33
+ stderr: string;
34
+ invokeResult?: CliInvokeResult;
35
+ }
36
+
37
+ export type HeadlessToolCallResult = HeadlessToolCallSuccess | HeadlessToolCallFailure;
38
+
39
+ /** Finds an exposed tool by MCP or HTTP API tool name. */
40
+ export function lookupHeadlessTool(
41
+ program: CliProgram,
42
+ toolName: string,
43
+ invocation: CliInvocation = "mcp",
44
+ ): ToolLookupResult {
45
+ const tools = collectMcpTools(program);
46
+ const tool =
47
+ invocation === "api" ? tools.find((t) => t.apiName === toolName) : tools.find((t) => t.name === toolName);
48
+ if (!tool) {
49
+ return { ok: false, kind: "unknown", message: `Unknown tool: ${toolName}` };
50
+ }
51
+ const { resolved } = bootstrapAppConfig(program, { validateFile: false });
52
+ const missingConfig = missingRequiredConfig(program, resolved);
53
+ if (missingConfig.length > 0) {
54
+ return {
55
+ ok: false,
56
+ kind: "missing_config",
57
+ message: formatMcpMissingConfigMessage(program, missingConfig),
58
+ };
59
+ }
60
+ return { ok: true, tool };
61
+ }
62
+
63
+ /**
64
+ * Converts flat tool arguments to argv and invokes the leaf handler headlessly.
65
+ */
66
+ export async function executeHeadlessToolCall(
67
+ cli: Cli,
68
+ tool: McpToolDef,
69
+ args: Record<string, unknown>,
70
+ invocation: CliInvocation,
71
+ ): Promise<HeadlessToolCallResult> {
72
+ const argvResult = mcpToolCallToArgv(cli.program, tool, args);
73
+ if ("error" in argvResult) {
74
+ return {
75
+ ok: false,
76
+ kind: "argv",
77
+ message: argvResult.error,
78
+ exitCode: 1,
79
+ stdout: "",
80
+ stderr: "",
81
+ };
82
+ }
83
+
84
+ const invokeResult = await cli.invoke(argvResult, { invocation, toolArgs: args });
85
+ if (invokeResult.kind === "help") {
86
+ return {
87
+ ok: false,
88
+ kind: "help",
89
+ message: invokeResult.errorMsg ?? "Help is not available via tool calls.",
90
+ exitCode: invokeResult.exitCode,
91
+ stdout: invokeResult.stdout,
92
+ stderr: invokeResult.stderr,
93
+ invokeResult,
94
+ };
95
+ }
96
+
97
+ if (invokeResult.kind === "ok" && invokeResult.exitCode === 0 && invokeResult.response) {
98
+ const mcpResult = buildToolCallSuccessFromResponse(invokeResult.response);
99
+ return {
100
+ ok: true,
101
+ response: invokeResult.response,
102
+ mcpResult,
103
+ };
104
+ }
105
+
106
+ if (invokeResult.kind === "ok" && invokeResult.exitCode === 0) {
107
+ return {
108
+ ok: false,
109
+ kind: "invoke",
110
+ message: "Handler did not call ctx.respond() or return a value",
111
+ exitCode: 1,
112
+ stdout: invokeResult.stdout,
113
+ stderr: invokeResult.stderr,
114
+ invokeResult,
115
+ };
116
+ }
117
+
118
+ const message = invokeResult.errorMsg ?? (invokeResult.stderr.trim() || `Exit code ${invokeResult.exitCode}`);
119
+ return {
120
+ ok: false,
121
+ kind: "invoke",
122
+ message,
123
+ exitCode: invokeResult.exitCode,
124
+ stdout: invokeResult.stdout,
125
+ stderr: invokeResult.stderr,
126
+ invokeResult,
127
+ };
128
+ }
129
+
130
+ /** Maps a headless success result to an HTTP Response. */
131
+ export function headlessSuccessToHttpResponse(
132
+ result: HeadlessToolCallSuccess,
133
+ leafApiResponse?: import("../types.ts").CliApiResponseConfig,
134
+ ): Response {
135
+ return apiSuccessResponse(result.response, leafApiResponse);
136
+ }
137
+
138
+ /** Maps a headless failure result to a JSON HTTP error Response. */
139
+ export function headlessFailureToHttpResponse(result: HeadlessToolCallFailure): Response {
140
+ const status = result.kind === "argv" || result.kind === "help" ? 400 : 500;
141
+ return apiErrorResponse(status, {
142
+ error: result.message,
143
+ exitCode: result.exitCode,
144
+ stdout: result.stdout,
145
+ stderr: result.stderr,
146
+ });
147
+ }
@@ -12,14 +12,16 @@ import {
12
12
  wantsExplicitJson,
13
13
  } from "./headless.ts";
14
14
 
15
- test("wantsExplicitJson includes MCP invocation", () => {
15
+ test("wantsExplicitJson includes MCP and API invocation", () => {
16
16
  expect(wantsExplicitJson({ invocation: "cli" }, false)).toBe(false);
17
17
  expect(wantsExplicitJson({ invocation: "mcp" }, false)).toBe(true);
18
+ expect(wantsExplicitJson({ invocation: "api" }, false)).toBe(true);
18
19
  expect(wantsExplicitJson({ invocation: "cli" }, true)).toBe(true);
19
20
  });
20
21
 
21
- test("shouldRunHeadless is true for MCP and json", () => {
22
+ test("shouldRunHeadless is true for MCP, API, and json", () => {
22
23
  expect(shouldRunHeadless({ invocation: "mcp" }, false)).toBe(true);
24
+ expect(shouldRunHeadless({ invocation: "api" }, false)).toBe(true);
23
25
  expect(shouldRunHeadless({ invocation: "cli" }, true)).toBe(true);
24
26
  expect(shouldRunHeadless({ invocation: "cli" }, false, true)).toBe(true);
25
27
  expect(shouldRunHeadless({ invocation: "cli" }, false, false, false)).toBe(true);
package/src/headless.ts CHANGED
@@ -4,9 +4,14 @@ import { isInteractiveTty } from "./utils.ts";
4
4
  /** Minimal context for headless routing helpers. */
5
5
  export type HeadlessContext = Pick<CliContext, "invocation">;
6
6
 
7
- /** True when `--json` was passed or the handler was invoked via MCP. */
7
+ /** True when the handler was invoked via MCP or HTTP API. */
8
+ function isToolInvocation(invocation: CliContext["invocation"]): boolean {
9
+ return invocation === "mcp" || invocation === "api";
10
+ }
11
+
12
+ /** True when `--json` was passed or the handler was invoked headlessly over MCP/HTTP. */
8
13
  export function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean {
9
- return hasJsonFlag || ctx.invocation === "mcp";
14
+ return hasJsonFlag || isToolInvocation(ctx.invocation);
10
15
  }
11
16
 
12
17
  /**
@@ -19,7 +24,7 @@ export function shouldRunHeadless(
19
24
  hasDryRunFlag = false,
20
25
  interactive: boolean = isInteractiveTty,
21
26
  ): boolean {
22
- if (ctx.invocation === "mcp") return true;
27
+ if (isToolInvocation(ctx.invocation)) return true;
23
28
  if (hasJsonFlag || hasDryRunFlag) return true;
24
29
  return !interactive;
25
30
  }
@@ -35,7 +40,7 @@ export function shouldRunHeadlessWithPositionals(
35
40
  hasDryRunFlag = false,
36
41
  interactive: boolean = isInteractiveTty,
37
42
  ): boolean {
38
- if (ctx.invocation === "mcp") return true;
43
+ if (isToolInvocation(ctx.invocation)) return true;
39
44
  if (hasJsonFlag || hasDryRunFlag) return true;
40
45
  return !interactive && positionals.length > 0;
41
46
  }
@@ -49,7 +54,7 @@ export function shouldRunHeadlessWithYes(
49
54
  opts: { yes: boolean; hasRequiredArgs: boolean; dryRun?: boolean },
50
55
  interactive: boolean = isInteractiveTty,
51
56
  ): boolean {
52
- if (ctx.invocation === "mcp") {
57
+ if (isToolInvocation(ctx.invocation)) {
53
58
  return opts.hasRequiredArgs && (opts.yes || Boolean(opts.dryRun));
54
59
  }
55
60
  if (opts.dryRun && opts.hasRequiredArgs) return true;
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ It gives consumers one stable import path without forcing them to know the inter
7
7
  module layout.
8
8
  */
9
9
 
10
+ export { generateOpenApi, openApiJson } from "./api/openapi.ts";
10
11
  export { Cli, type CliInvokeKind, type CliInvokeResult } from "./cli.ts";
11
12
  export { cliErrWithHelp } from "./cli-errors.ts";
12
13
  export { displayAppConfigPath, resolveAppConfigPath } from "./config/file.ts";
@@ -30,6 +31,8 @@ export {
30
31
  export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
31
32
  export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
32
33
  export type {
34
+ CliApiResponseConfig,
35
+ CliApiServerConfig,
33
36
  CliAppConfig,
34
37
  CliAppConfigEntry,
35
38
  CliAppConfigResolveContext,
@@ -47,6 +50,8 @@ export type {
47
50
  CliOption,
48
51
  CliPositional,
49
52
  CliProgram,
53
+ CliRespondBody,
54
+ CliRespondOptions,
50
55
  InstallAgentIntegration,
51
56
  InstallTargetSpec,
52
57
  ResolvedInstallTarget,