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.
- package/CHANGELOG.md +37 -1
- package/README.md +32 -24
- package/docs/README.md +2 -1
- package/docs/api-server.md +141 -0
- package/docs/bundled-docs.md +24 -10
- package/docs/cli-program.md +17 -2
- package/docs/configure.md +2 -2
- package/docs/developing.md +1 -1
- package/docs/mcp.md +5 -5
- package/docs/output-schema.md +3 -3
- package/examples/full-example/README.md +8 -0
- package/examples/full-example/docs/README.md +27 -0
- package/examples/full-example/docs/api.md +511 -0
- package/examples/full-example/docs/cli-schema.json +453 -0
- package/examples/full-example/docs/http.md +81 -0
- package/examples/full-example/docs/mcp.md +159 -0
- package/examples/full-example/docs/openapi.json +222 -0
- package/examples/full-example/docs/skill.md +46 -0
- package/examples/full-example/justfile +6 -2
- package/examples/full-example/scripts/dev-formula.ts +1 -1
- package/examples/full-example/src/commands/echo/command.ts +6 -1
- package/examples/full-example/src/program.ts +3 -0
- package/examples/mcp-test.ts +13 -2
- package/examples/nested.ts +12 -3
- package/examples/servers.ts +72 -0
- package/index.d.ts +66 -7
- package/package.json +1 -1
- package/src/api/openapi.ts +115 -0
- package/src/api/result.ts +89 -0
- package/src/api/server.ts +120 -0
- package/src/api.integration.test.ts +358 -0
- package/src/builtins/api.ts +38 -0
- package/src/builtins/dispatch.ts +26 -0
- package/src/builtins/registry.ts +4 -0
- package/src/capabilities.ts +12 -1
- package/src/cli-tool/full-example-capabilities.test.ts +3 -0
- package/src/cli.ts +60 -8
- package/src/config/bootstrap.test.ts +46 -0
- package/src/config/bootstrap.ts +22 -5
- package/src/config.integration.test.ts +22 -4
- package/src/configure/index.ts +1 -1
- package/src/context.ts +29 -1
- package/src/docs/api-guide.ts +2 -2
- package/src/docs/builtin.ts +11 -1
- package/src/docs/docs.test.ts +70 -12
- package/src/docs/http-guide.ts +132 -0
- package/src/docs/mcp-guide.ts +3 -3
- package/src/docs/resolve.ts +26 -2
- package/src/docs/save.ts +5 -2
- package/src/headless/tool-call.ts +147 -0
- package/src/headless.test.ts +4 -2
- package/src/headless.ts +10 -5
- package/src/index.ts +5 -0
- package/src/mcp/result.ts +39 -34
- package/src/mcp/server.ts +18 -36
- package/src/mcp/tools.ts +14 -3
- package/src/mcp.integration.test.ts +46 -39
- package/src/parse.test.ts +16 -6
- package/src/respond.ts +48 -0
- package/src/schema.ts +1 -1
- package/src/skill/generate.ts +1 -1
- package/src/types.ts +46 -4
- package/src/validate.ts +7 -0
package/src/docs/docs.test.ts
CHANGED
|
@@ -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
|
|
76
|
+
docs.topics["cli-schema"] = { text: "nope" };
|
|
77
77
|
expect(() => cliValidateProgram(root)).toThrow(/reserved/);
|
|
78
|
-
delete docs.topics
|
|
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
|
+
}
|
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -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
|
);
|
package/src/docs/resolve.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/headless.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|