argsbarg 5.1.16 → 6.0.1

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 (73) hide show
  1. package/CHANGELOG.md +46 -1
  2. package/README.md +33 -25
  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/config-schema.md +18 -8
  8. package/docs/developing.md +1 -1
  9. package/docs/mcp.md +5 -5
  10. package/docs/output-schema.md +78 -47
  11. package/examples/full-example/README.md +15 -6
  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/schemas/generated/app-config.json +1 -1
  21. package/examples/full-example/schemas/generated/status.json +1 -1
  22. package/examples/full-example/scripts/dev-formula.ts +1 -1
  23. package/examples/full-example/scripts/schemagen/discover-schema-roots.test.ts +3 -3
  24. package/examples/full-example/scripts/schemagen/discover-schema-roots.ts +90 -35
  25. package/examples/full-example/scripts/schemagen/naming.ts +17 -0
  26. package/examples/full-example/scripts/schemagen.ts +14 -3
  27. package/examples/full-example/src/commands/echo/command.ts +6 -1
  28. package/examples/full-example/src/commands/status/schema-types.ts +14 -0
  29. package/examples/full-example/src/commands/status/types.ts +1 -11
  30. package/examples/full-example/src/{types.ts → config/schema-types.ts} +3 -2
  31. package/examples/full-example/src/program.ts +3 -0
  32. package/examples/mcp-test.ts +13 -2
  33. package/examples/nested.ts +12 -3
  34. package/examples/servers.ts +72 -0
  35. package/index.d.ts +66 -7
  36. package/package.json +17 -17
  37. package/src/api/openapi.ts +117 -0
  38. package/src/api/result.ts +111 -0
  39. package/src/api/schema-deref.test.ts +99 -0
  40. package/src/api/schema-deref.ts +76 -0
  41. package/src/api/server.ts +120 -0
  42. package/src/api.integration.test.ts +441 -0
  43. package/src/builtins/api.ts +38 -0
  44. package/src/builtins/dispatch.ts +26 -0
  45. package/src/builtins/registry.ts +4 -0
  46. package/src/capabilities.ts +12 -1
  47. package/src/cli-errors.ts +3 -0
  48. package/src/cli-tool/full-example-capabilities.test.ts +3 -0
  49. package/src/cli.ts +60 -8
  50. package/src/config.integration.test.ts +22 -4
  51. package/src/context.ts +29 -1
  52. package/src/docs/api-guide.ts +2 -2
  53. package/src/docs/builtin.ts +11 -1
  54. package/src/docs/docs.test.ts +70 -12
  55. package/src/docs/http-guide.ts +132 -0
  56. package/src/docs/mcp-guide.ts +3 -3
  57. package/src/docs/mcp-resources.ts +5 -2
  58. package/src/docs/resolve.ts +26 -2
  59. package/src/docs/save.ts +5 -2
  60. package/src/headless/tool-call.ts +157 -0
  61. package/src/headless.test.ts +4 -2
  62. package/src/headless.ts +10 -5
  63. package/src/index.ts +5 -0
  64. package/src/mcp/result.ts +39 -34
  65. package/src/mcp/server.ts +18 -36
  66. package/src/mcp/tools.ts +14 -3
  67. package/src/mcp.integration.test.ts +46 -39
  68. package/src/parse.test.ts +16 -6
  69. package/src/respond.ts +48 -0
  70. package/src/schema.ts +1 -1
  71. package/src/skill/generate.ts +1 -1
  72. package/src/types.ts +46 -4
  73. package/src/validate.ts +7 -0
@@ -0,0 +1,76 @@
1
+ /*
2
+ Inline JSON Schema $ref dereferencing for OpenAPI embedding.
3
+ */
4
+
5
+ function decodeJsonPointerSegment(segment: string): string {
6
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~");
7
+ }
8
+
9
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
10
+ return value !== null && typeof value === "object" && !Array.isArray(value);
11
+ }
12
+
13
+ /** Resolves a same-document JSON Pointer (`#/definitions/Foo`). */
14
+ function resolveJsonPointer(root: Record<string, unknown>, ref: string): unknown {
15
+ if (!ref.startsWith("#/")) {
16
+ return undefined;
17
+ }
18
+ const segments = ref
19
+ .slice(2)
20
+ .split("/")
21
+ .filter((segment) => segment.length > 0)
22
+ .map(decodeJsonPointerSegment);
23
+ let current: unknown = root;
24
+ for (const segment of segments) {
25
+ if (!isPlainObject(current)) {
26
+ return undefined;
27
+ }
28
+ current = current[segment];
29
+ }
30
+ return current;
31
+ }
32
+
33
+ function derefValue(value: unknown, root: Record<string, unknown>, resolving: Set<string>): unknown {
34
+ if (Array.isArray(value)) {
35
+ return value.map((item) => derefValue(item, root, resolving));
36
+ }
37
+ if (!isPlainObject(value)) {
38
+ return value;
39
+ }
40
+
41
+ if (typeof value.$ref === "string") {
42
+ const { $ref, ...siblings } = value;
43
+ if (resolving.has($ref)) {
44
+ return value;
45
+ }
46
+ const target = resolveJsonPointer(root, $ref);
47
+ if (target === undefined) {
48
+ return value;
49
+ }
50
+ resolving.add($ref);
51
+ const resolved = derefValue(structuredClone(target), root, resolving);
52
+ resolving.delete($ref);
53
+ if (!isPlainObject(resolved)) {
54
+ return resolved;
55
+ }
56
+ if (Object.keys(siblings).length === 0) {
57
+ return resolved;
58
+ }
59
+ return { ...resolved, ...siblings };
60
+ }
61
+
62
+ const out: Record<string, unknown> = {};
63
+ for (const [key, child] of Object.entries(value)) {
64
+ if (key === "definitions" || key === "$defs") {
65
+ continue;
66
+ }
67
+ out[key] = derefValue(child, root, resolving);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Inlines internal `$ref` pointers and drops `definitions` / `$defs` from the output. */
73
+ export function dereferenceJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
74
+ const root = structuredClone(schema);
75
+ return derefValue(root, root, new Set()) as Record<string, unknown>;
76
+ }
@@ -0,0 +1,120 @@
1
+ /*
2
+ HTTP tool server for ArgsBarg programs: health, OpenAPI, and tool invocation.
3
+ */
4
+
5
+ import type { Cli } from "../cli.ts";
6
+ import {
7
+ executeHeadlessToolCall,
8
+ headlessFailureToHttpResponse,
9
+ headlessSuccessToHttpResponse,
10
+ lookupHeadlessTool,
11
+ } from "../headless/tool-call.ts";
12
+ import type { CliProgram } from "../types.ts";
13
+ import { generateOpenApi } from "./openapi.ts";
14
+ import { API_CORS_HEADERS, apiDocsHtml, apiErrorResponse, apiOptionsResponse } from "./result.ts";
15
+
16
+ const DEFAULT_HOST = "127.0.0.1";
17
+ const DEFAULT_PORT = 3000;
18
+
19
+ /** Resolved listen address for the HTTP API server. */
20
+ export function resolveApiListenAddress(program: CliProgram): { hostname: string; port: number } {
21
+ const config = program.apiServer;
22
+ return {
23
+ hostname: config?.host ?? DEFAULT_HOST,
24
+ port: config?.port ?? DEFAULT_PORT,
25
+ };
26
+ }
27
+
28
+ /** Writes a JSON HTTP response with CORS headers. */
29
+ function jsonResponse(status: number, body: unknown): Response {
30
+ return new Response(JSON.stringify(body), {
31
+ status,
32
+ headers: {
33
+ ...API_CORS_HEADERS,
34
+ "content-type": "application/json; charset=utf-8",
35
+ },
36
+ });
37
+ }
38
+
39
+ /** Handles one HTTP request for the API server. */
40
+ export async function handleApiRequest(cli: Cli, request: Request): Promise<Response> {
41
+ if (request.method === "OPTIONS") {
42
+ return apiOptionsResponse();
43
+ }
44
+
45
+ const root = cli.program;
46
+ const url = new URL(request.url);
47
+ const path = url.pathname;
48
+
49
+ if (request.method === "GET" && path === "/health") {
50
+ return jsonResponse(200, { ok: true });
51
+ }
52
+
53
+ if (request.method === "GET" && path === "/openapi.json") {
54
+ return jsonResponse(200, generateOpenApi(root));
55
+ }
56
+
57
+ if (request.method === "GET" && path === "/openapi-browser") {
58
+ return new Response(apiDocsHtml(), {
59
+ status: 200,
60
+ headers: {
61
+ ...API_CORS_HEADERS,
62
+ "content-type": "text/html; charset=utf-8",
63
+ },
64
+ });
65
+ }
66
+
67
+ const toolPathMatch = /^\/tools\/([^/]+)$/.exec(path);
68
+ if (request.method === "POST" && toolPathMatch) {
69
+ const toolName = decodeURIComponent(toolPathMatch[1] ?? "");
70
+ let body: unknown = {};
71
+ const rawBody = await request.text();
72
+ if (rawBody.trim().length > 0) {
73
+ try {
74
+ body = JSON.parse(rawBody);
75
+ } catch {
76
+ return apiErrorResponse(400, { error: "Invalid JSON body" });
77
+ }
78
+ }
79
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
80
+ return apiErrorResponse(400, { error: "Request body must be a JSON object" });
81
+ }
82
+ return invokeApiTool(cli, toolName, body as Record<string, unknown>);
83
+ }
84
+
85
+ if (path === "/tools" || path.startsWith("/tools/")) {
86
+ return apiErrorResponse(405, { error: "Method not allowed" });
87
+ }
88
+
89
+ return apiErrorResponse(404, { error: "Not found" });
90
+ }
91
+
92
+ /** Resolves a tool and runs it through the shared headless invoke path. */
93
+ async function invokeApiTool(cli: Cli, toolName: string, args: Record<string, unknown>): Promise<Response> {
94
+ const lookup = lookupHeadlessTool(cli.program, toolName, "api");
95
+ if (!lookup.ok) {
96
+ if (lookup.kind === "unknown") {
97
+ return apiErrorResponse(404, { error: lookup.message });
98
+ }
99
+ return apiErrorResponse(503, { error: lookup.message });
100
+ }
101
+
102
+ const result = await executeHeadlessToolCall(cli, lookup.tool, args, "api");
103
+ if (result.ok) {
104
+ return headlessSuccessToHttpResponse(result, lookup.tool.leaf.apiResponse);
105
+ }
106
+ return headlessFailureToHttpResponse(result);
107
+ }
108
+
109
+ /** Runs the HTTP API server until the process is interrupted. */
110
+ export async function apiServeHttp(cli: Cli): Promise<never> {
111
+ const { hostname, port } = resolveApiListenAddress(cli.program);
112
+ const server = Bun.serve({
113
+ hostname,
114
+ port,
115
+ fetch: (request) => handleApiRequest(cli, request),
116
+ });
117
+ process.stderr.write(`HTTP API listening on http://${server.hostname}:${server.port}\n`);
118
+ await new Promise<never>(() => {});
119
+ throw new Error("HTTP API server stopped unexpectedly");
120
+ }
@@ -0,0 +1,441 @@
1
+ /*
2
+ HTTP API integration tests: routes, tool invocation, CORS, OpenAPI, and validation.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { join } from "node:path";
7
+ import { $ } from "bun";
8
+ import { generateOpenApi } from "./api/openapi.ts";
9
+ import { API_CORS_HEADERS } from "./api/result.ts";
10
+ import { handleApiRequest } from "./api/server.ts";
11
+ import { Cli, CliContext, type CliContext as CliContextType, CliOptionKind, cliErrWithHelp } from "./index.ts";
12
+ import { nestedMcpFixture, testProgram } from "./test-fixtures.ts";
13
+ import { cliValidateProgram } from "./validate.ts";
14
+
15
+ /** Program with HTTP API enabled and handlers that return values. */
16
+ function nestedApiFixture() {
17
+ return testProgram({
18
+ ...nestedMcpFixture,
19
+ apiServer: { enabled: true },
20
+ commands: [
21
+ {
22
+ key: "stat",
23
+ description: "File metadata.",
24
+ options: [
25
+ {
26
+ name: "json",
27
+ description: "Emit handler output as JSON.",
28
+ kind: CliOptionKind.Presence,
29
+ },
30
+ ],
31
+ commands: [
32
+ {
33
+ key: "owner",
34
+ description: "Ownership helpers.",
35
+ commands: [
36
+ {
37
+ key: "lookup",
38
+ description: "Resolve owner info.",
39
+ options: [
40
+ {
41
+ name: "user-name",
42
+ description: "User to look up.",
43
+ kind: CliOptionKind.String,
44
+ shortName: "u",
45
+ },
46
+ ],
47
+ positionals: [
48
+ {
49
+ name: "path",
50
+ description: "File or directory.",
51
+ kind: CliOptionKind.String,
52
+ },
53
+ ],
54
+ handler: (ctx: CliContextType) => {
55
+ const user = ctx.stringOpt("user-name") ?? "unknown";
56
+ const path = ctx.positional("path") ?? "";
57
+ if (ctx.hasFlag("json")) {
58
+ return { user, path };
59
+ }
60
+ return `lookup user=${user} path=${path}`;
61
+ },
62
+ },
63
+ ],
64
+ },
65
+ ],
66
+ },
67
+ {
68
+ key: "read",
69
+ description: "Print the first line of each file.",
70
+ positionals: [
71
+ {
72
+ name: "files",
73
+ description: "Paths to read.",
74
+ kind: CliOptionKind.String,
75
+ argMax: 0,
76
+ },
77
+ ],
78
+ handler: () => ({ lines: [] }),
79
+ },
80
+ {
81
+ key: "pdf",
82
+ description: "Return a minimal PDF.",
83
+ apiResponse: { contentType: "application/pdf" },
84
+ handler: (ctx: CliContextType) => {
85
+ ctx.respond({
86
+ body: new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]),
87
+ contentType: "application/pdf",
88
+ });
89
+ },
90
+ },
91
+ {
92
+ key: "html",
93
+ description: "Return HTML.",
94
+ apiResponse: { contentType: "text/html; charset=utf-8" },
95
+ handler: (ctx: CliContextType) => {
96
+ ctx.respond({
97
+ body: "<!DOCTYPE html><html><body>hi</body></html>",
98
+ contentType: "text/html; charset=utf-8",
99
+ });
100
+ },
101
+ },
102
+ {
103
+ key: "silent",
104
+ description: "Returns nothing.",
105
+ handler: () => {},
106
+ },
107
+ ],
108
+ });
109
+ }
110
+
111
+ /** Sends one HTTP request through the in-process API handler. */
112
+ async function apiRequest(program: ReturnType<typeof nestedApiFixture>, request: Request) {
113
+ const cli = new Cli(program);
114
+ return handleApiRequest(cli, request);
115
+ }
116
+
117
+ describe("apiServer validation", () => {
118
+ test("rejects empty apiServer", () => {
119
+ const root = testProgram({
120
+ key: "app",
121
+ description: "",
122
+ apiServer: {} as { enabled: boolean },
123
+ handler: () => {},
124
+ });
125
+ expect(() => cliValidateProgram(root)).toThrow(/apiServer requires enabled: true/);
126
+ });
127
+
128
+ test("rejects top-level command name api when apiServer enabled", () => {
129
+ const root = testProgram({
130
+ key: "app",
131
+ description: "",
132
+ apiServer: { enabled: true },
133
+ commands: [{ key: "api", description: "user", handler: () => {} }],
134
+ });
135
+ expect(() => cliValidateProgram(root)).toThrow(/Reserved command name: api/);
136
+ });
137
+
138
+ test("allows top-level command name api without apiServer", () => {
139
+ const root = testProgram({
140
+ key: "app",
141
+ description: "",
142
+ commands: [{ key: "api", description: "user", handler: () => {} }],
143
+ });
144
+ expect(() => cliValidateProgram(root)).not.toThrow();
145
+ });
146
+
147
+ test("rejects apiServer on non-root node", () => {
148
+ const root = {
149
+ key: "app",
150
+ version: "0.0.0",
151
+ description: "",
152
+ commands: [
153
+ {
154
+ key: "x",
155
+ description: "cmd",
156
+ apiServer: { enabled: true },
157
+ handler: () => {},
158
+ },
159
+ ],
160
+ } as unknown as import("./types.ts").CliProgram;
161
+ expect(() => cliValidateProgram(root)).toThrow(/apiServer is only supported on the program root/);
162
+ });
163
+ });
164
+
165
+ describe("HTTP API routes", () => {
166
+ const program = nestedApiFixture();
167
+ cliValidateProgram(program);
168
+
169
+ test("GET /health includes CORS headers", async () => {
170
+ const res = await apiRequest(program, new Request("http://127.0.0.1/health"));
171
+ expect(res.status).toBe(200);
172
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
173
+ expect(await res.json()).toEqual({ ok: true });
174
+ });
175
+
176
+ test("OPTIONS returns 204 with CORS headers", async () => {
177
+ const res = await apiRequest(
178
+ program,
179
+ new Request("http://127.0.0.1/tools/stat-owner-lookup", { method: "OPTIONS" }),
180
+ );
181
+ expect(res.status).toBe(204);
182
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
183
+ expect(res.headers.get("access-control-allow-methods")).toContain("POST");
184
+ });
185
+
186
+ test("POST /tools/:name returns raw JSON body", async () => {
187
+ const readme = join(import.meta.dir, "..", "README.md");
188
+ const res = await apiRequest(
189
+ program,
190
+ new Request("http://127.0.0.1/tools/stat-owner-lookup", {
191
+ method: "POST",
192
+ headers: { "content-type": "application/json" },
193
+ body: JSON.stringify({ "user-name": "alice", path: readme, json: true }),
194
+ }),
195
+ );
196
+ expect(res.status).toBe(200);
197
+ expect(res.headers.get("content-type")).toContain("application/json");
198
+ expect(await res.json()).toEqual({ user: "alice", path: readme });
199
+ });
200
+
201
+ test("POST /tools/:name returns raw text body", async () => {
202
+ const readme = join(import.meta.dir, "..", "README.md");
203
+ const res = await apiRequest(
204
+ program,
205
+ new Request("http://127.0.0.1/tools/stat-owner-lookup", {
206
+ method: "POST",
207
+ headers: { "content-type": "application/json" },
208
+ body: JSON.stringify({ "user-name": "alice", path: readme }),
209
+ }),
210
+ );
211
+ expect(res.status).toBe(200);
212
+ const text = await res.text();
213
+ expect(text).toContain("lookup user=alice");
214
+ });
215
+
216
+ test("POST /tools returns 405", async () => {
217
+ const res = await apiRequest(
218
+ program,
219
+ new Request("http://127.0.0.1/tools", {
220
+ method: "POST",
221
+ headers: { "content-type": "application/json" },
222
+ body: "{}",
223
+ }),
224
+ );
225
+ expect(res.status).toBe(405);
226
+ });
227
+
228
+ test("POST /tools/:name returns PDF bytes", async () => {
229
+ const res = await apiRequest(
230
+ program,
231
+ new Request("http://127.0.0.1/tools/pdf", {
232
+ method: "POST",
233
+ headers: { "content-type": "application/json" },
234
+ body: "{}",
235
+ }),
236
+ );
237
+ expect(res.status).toBe(200);
238
+ expect(res.headers.get("content-type")).toBe("application/pdf");
239
+ const bytes = new Uint8Array(await res.arrayBuffer());
240
+ expect(String.fromCharCode(...bytes.slice(0, 4))).toBe("%PDF");
241
+ });
242
+
243
+ test("POST /tools/:name returns HTML", async () => {
244
+ const res = await apiRequest(
245
+ program,
246
+ new Request("http://127.0.0.1/tools/html", {
247
+ method: "POST",
248
+ body: "{}",
249
+ }),
250
+ );
251
+ expect(res.status).toBe(200);
252
+ expect(res.headers.get("content-type")).toContain("text/html");
253
+ expect(await res.text()).toContain("<!DOCTYPE html>");
254
+ });
255
+
256
+ test("POST /tools/:name returns 500 when handler has no response", async () => {
257
+ const res = await apiRequest(
258
+ program,
259
+ new Request("http://127.0.0.1/tools/silent", {
260
+ method: "POST",
261
+ body: "{}",
262
+ }),
263
+ );
264
+ expect(res.status).toBe(500);
265
+ const body = (await res.json()) as { error: string };
266
+ expect(body.error).toContain("ctx.respond()");
267
+ });
268
+
269
+ test("POST /tools returns 404 for unknown tool", async () => {
270
+ const res = await apiRequest(
271
+ program,
272
+ new Request("http://127.0.0.1/tools/missing_tool", {
273
+ method: "POST",
274
+ headers: { "content-type": "application/json" },
275
+ body: "{}",
276
+ }),
277
+ );
278
+ expect(res.status).toBe(404);
279
+ });
280
+
281
+ test("POST /tools returns 400 for bad args", async () => {
282
+ const res = await apiRequest(
283
+ program,
284
+ new Request("http://127.0.0.1/tools/stat-owner-lookup", {
285
+ method: "POST",
286
+ headers: { "content-type": "application/json" },
287
+ body: JSON.stringify({ "user-name": "alice" }),
288
+ }),
289
+ );
290
+ expect(res.status).toBe(400);
291
+ const body = (await res.json()) as { error: string };
292
+ expect(body.error).toContain("Missing argument: path");
293
+ expect(body).not.toHaveProperty("stderr");
294
+ expect(body.error).not.toContain("\u001B[");
295
+ });
296
+
297
+ test("POST /tools/:name returns plain JSON validation errors", async () => {
298
+ const failProgram = testProgram({
299
+ key: "app",
300
+ description: "Test app",
301
+ apiServer: { enabled: true },
302
+ commands: [
303
+ {
304
+ key: "fail",
305
+ description: "Fails with cliErrWithHelp.",
306
+ handler: (ctx: CliContextType) => {
307
+ cliErrWithHelp(ctx, "bad input");
308
+ },
309
+ },
310
+ ],
311
+ });
312
+ cliValidateProgram(failProgram);
313
+ const res = await apiRequest(
314
+ failProgram,
315
+ new Request("http://127.0.0.1/tools/fail", {
316
+ method: "POST",
317
+ headers: { "content-type": "application/json" },
318
+ body: "{}",
319
+ }),
320
+ );
321
+ expect(res.status).toBe(400);
322
+ const body = (await res.json()) as Record<string, unknown>;
323
+ expect(body).toEqual({ error: "bad input" });
324
+ });
325
+
326
+ test("GET /openapi.json lists tool paths", async () => {
327
+ const res = await apiRequest(program, new Request("http://127.0.0.1/openapi.json"));
328
+ expect(res.status).toBe(200);
329
+ const doc = (await res.json()) as { openapi: string; paths: Record<string, unknown> };
330
+ expect(doc.openapi).toBe("3.1.0");
331
+ expect(doc.paths["/tools/stat-owner-lookup"]).toBeDefined();
332
+ });
333
+
334
+ test("GET /openapi-browser returns Scalar HTML", async () => {
335
+ const res = await apiRequest(program, new Request("http://127.0.0.1/openapi-browser"));
336
+ expect(res.status).toBe(200);
337
+ expect(res.headers.get("content-type")).toContain("text/html");
338
+ const html = await res.text();
339
+ expect(html).toContain("@scalar/api-reference");
340
+ expect(html).toContain('orderSchemaPropertiesBy: "preserve"');
341
+ expect(html).toContain("orderRequiredPropertiesFirst: false");
342
+ });
343
+ });
344
+
345
+ test("generateOpenApi maps binary content types", () => {
346
+ const program = nestedApiFixture();
347
+ const doc = generateOpenApi(program) as {
348
+ paths: Record<string, { post: { responses: { "200": { content: Record<string, unknown> } } } }>;
349
+ };
350
+ const pdf = doc.paths["/tools/pdf"]?.post.responses["200"].content["application/pdf"] as {
351
+ schema: { format: string };
352
+ };
353
+ expect(pdf.schema.format).toBe("binary");
354
+ });
355
+
356
+ test("generateOpenApi dereferences nested inputSchema definitions", () => {
357
+ const program = testProgram({
358
+ key: "app",
359
+ description: "Test app",
360
+ apiServer: { enabled: true },
361
+ commands: [
362
+ {
363
+ key: "render",
364
+ description: "Render a document.",
365
+ inputSchema: {
366
+ type: "object",
367
+ properties: {
368
+ invoice: { $ref: "#/definitions/InvoiceData" },
369
+ },
370
+ definitions: {
371
+ InvoiceData: {
372
+ type: "object",
373
+ properties: {
374
+ id: { type: "string" },
375
+ },
376
+ required: ["id"],
377
+ },
378
+ },
379
+ },
380
+ handler: () => ({ ok: true }),
381
+ },
382
+ ],
383
+ });
384
+ cliValidateProgram(program);
385
+ const doc = generateOpenApi(program) as {
386
+ paths: Record<
387
+ string,
388
+ {
389
+ post: {
390
+ requestBody: {
391
+ content: Record<string, { schema: { properties: { invoice: Record<string, unknown> } } }>;
392
+ };
393
+ };
394
+ }
395
+ >;
396
+ };
397
+ const schema = doc.paths["/tools/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema;
398
+ expect(schema.properties.invoice).toEqual({
399
+ type: "object",
400
+ properties: { id: { type: "string" } },
401
+ required: ["id"],
402
+ });
403
+ });
404
+
405
+ test("ctx.respond throws when called twice", () => {
406
+ const program = testProgram({
407
+ key: "app",
408
+ description: "",
409
+ handler: () => {},
410
+ });
411
+ const context = new CliContext("app", [], [], {}, program, "api");
412
+ context.respond({ body: { ok: true } });
413
+ expect(() => context.respond({ body: { ok: true } })).toThrow(/already called/);
414
+ });
415
+
416
+ test("API_CORS_HEADERS are wide open", () => {
417
+ expect(API_CORS_HEADERS["access-control-allow-origin"]).toBe("*");
418
+ });
419
+
420
+ test("ctx.invocation is api via Cli.invoke", async () => {
421
+ let seen = "";
422
+ const root = testProgram({
423
+ key: "app",
424
+ description: "",
425
+ handler: (ctx: CliContextType) => {
426
+ seen = ctx.invocation;
427
+ return { invocation: ctx.invocation };
428
+ },
429
+ });
430
+ cliValidateProgram(root);
431
+ const result = await new Cli(root).invoke([], { invocation: "api" });
432
+ expect(result.kind).toBe("ok");
433
+ expect(seen).toBe("api");
434
+ expect(result.response?.body).toEqual({ invocation: "api" });
435
+ });
436
+
437
+ test("minimal.ts api without opt-in fails", async () => {
438
+ const { stderr, exitCode } = await $`bun run examples/minimal.ts api`.nothrow().quiet();
439
+ expect(exitCode).toBe(1);
440
+ expect(stderr.toString()).toContain("HTTP API is not available");
441
+ });
@@ -0,0 +1,38 @@
1
+ import { resolveApiListenAddress } from "../api/server.ts";
2
+ import { resolveCapabilities } from "../capabilities.ts";
3
+ import { docsEnabled } from "../docs/resolve.ts";
4
+ import { CliFallbackMode, type CliLeaf, type CliProgram, type CliRouter } from "../types.ts";
5
+
6
+ /** Built-in `api` router: bare `myapp api` runs the HTTP server (via hidden `serve` fallback). */
7
+ export function cliBuiltinApiCommand(program: CliProgram): CliRouter {
8
+ const caps = resolveCapabilities(program);
9
+ const { hostname, port } = resolveApiListenAddress(program);
10
+ const lines = [
11
+ `HTTP tool server on http://${hostname}:${port}.`,
12
+ "",
13
+ "Endpoints: GET /health, GET /openapi.json, GET /openapi-browser, POST /tools/:name",
14
+ "",
15
+ ];
16
+ if (caps.configure) {
17
+ lines.push("Configure app settings:", "", " {argsbarg:program} configure", "");
18
+ }
19
+ if (docsEnabled(program)) {
20
+ lines.push("Full setup guide: {argsbarg:program} docs http");
21
+ }
22
+
23
+ const serve: CliLeaf = {
24
+ key: "serve",
25
+ hidden: true,
26
+ description: "Run as an HTTP API server for tools.",
27
+ handler: () => {},
28
+ };
29
+
30
+ return {
31
+ key: "api",
32
+ description: "HTTP API server for tools.",
33
+ notes: lines.join("\n"),
34
+ fallbackCommand: "serve",
35
+ fallbackMode: CliFallbackMode.MissingOnly,
36
+ commands: [serve],
37
+ };
38
+ }