argsbarg 5.1.16 → 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 (59) hide show
  1. package/CHANGELOG.md +32 -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/developing.md +1 -1
  8. package/docs/mcp.md +5 -5
  9. package/docs/output-schema.md +3 -3
  10. package/examples/full-example/README.md +8 -0
  11. package/examples/full-example/docs/README.md +27 -0
  12. package/examples/full-example/docs/api.md +511 -0
  13. package/examples/full-example/docs/cli-schema.json +453 -0
  14. package/examples/full-example/docs/http.md +81 -0
  15. package/examples/full-example/docs/mcp.md +159 -0
  16. package/examples/full-example/docs/openapi.json +222 -0
  17. package/examples/full-example/docs/skill.md +46 -0
  18. package/examples/full-example/justfile +6 -2
  19. package/examples/full-example/scripts/dev-formula.ts +1 -1
  20. package/examples/full-example/src/commands/echo/command.ts +6 -1
  21. package/examples/full-example/src/program.ts +3 -0
  22. package/examples/mcp-test.ts +13 -2
  23. package/examples/nested.ts +12 -3
  24. package/examples/servers.ts +72 -0
  25. package/index.d.ts +66 -7
  26. package/package.json +1 -1
  27. package/src/api/openapi.ts +115 -0
  28. package/src/api/result.ts +89 -0
  29. package/src/api/server.ts +120 -0
  30. package/src/api.integration.test.ts +358 -0
  31. package/src/builtins/api.ts +38 -0
  32. package/src/builtins/dispatch.ts +26 -0
  33. package/src/builtins/registry.ts +4 -0
  34. package/src/capabilities.ts +12 -1
  35. package/src/cli-tool/full-example-capabilities.test.ts +3 -0
  36. package/src/cli.ts +60 -8
  37. package/src/config.integration.test.ts +22 -4
  38. package/src/context.ts +29 -1
  39. package/src/docs/api-guide.ts +2 -2
  40. package/src/docs/builtin.ts +11 -1
  41. package/src/docs/docs.test.ts +70 -12
  42. package/src/docs/http-guide.ts +132 -0
  43. package/src/docs/mcp-guide.ts +3 -3
  44. package/src/docs/resolve.ts +26 -2
  45. package/src/docs/save.ts +5 -2
  46. package/src/headless/tool-call.ts +147 -0
  47. package/src/headless.test.ts +4 -2
  48. package/src/headless.ts +10 -5
  49. package/src/index.ts +5 -0
  50. package/src/mcp/result.ts +39 -34
  51. package/src/mcp/server.ts +18 -36
  52. package/src/mcp/tools.ts +14 -3
  53. package/src/mcp.integration.test.ts +46 -39
  54. package/src/parse.test.ts +16 -6
  55. package/src/respond.ts +48 -0
  56. package/src/schema.ts +1 -1
  57. package/src/skill/generate.ts +1 -1
  58. package/src/types.ts +46 -4
  59. package/src/validate.ts +7 -0
package/src/mcp/tools.ts CHANGED
@@ -41,8 +41,10 @@ export function mcpServerId(root: CliProgram): string {
41
41
 
42
42
  /** One MCP tool derived from a leaf CLI command. */
43
43
  export interface McpToolDef {
44
- /** MCP tool name (underscore-separated path). */
44
+ /** MCP tool name (underscore-separated, sanitized segments). */
45
45
  name: string;
46
+ /** HTTP API tool name (hyphen-separated path; preserves command key spelling). */
47
+ apiName: string;
46
48
  /** Tool description from the leaf command. */
47
49
  description: string;
48
50
  /** Command path segments from the program root. */
@@ -69,6 +71,14 @@ export function mcpToolName(root: CliProgram, path: string[]): string {
69
71
  return path.map(sanitizeToolSegment).join("_");
70
72
  }
71
73
 
74
+ /** Builds the HTTP API tool name for a leaf at the given path (hyphen-joined, unsanitized). */
75
+ export function apiToolName(root: CliProgram, path: string[]): string {
76
+ if (path.length === 0) {
77
+ return root.key;
78
+ }
79
+ return path.join("-");
80
+ }
81
+
72
82
  /** JSON Schema property for one option. */
73
83
  function optionProperty(opt: CliOption): Record<string, unknown> {
74
84
  const base: Record<string, unknown> = { description: opt.description };
@@ -197,7 +207,7 @@ export function allMcpResources(root: CliProgram): McpResourceEntry[] {
197
207
  const builtIn: McpResourceEntry = {
198
208
  uri: schemaUri,
199
209
  name: "cli-schema",
200
- description: "Full CLI command tree (same as docs schema).",
210
+ description: "Full CLI command tree (same as docs cli-schema).",
201
211
  mimeType: "application/json",
202
212
  load: () => cliSchemaJson(root),
203
213
  };
@@ -227,10 +237,11 @@ export function collectMcpTools(root: CliProgram): McpToolDef[] {
227
237
  const outputSchema = leafOutputSchema(cmd);
228
238
  out.push({
229
239
  name: mcpToolName(root, path),
240
+ apiName: apiToolName(root, path),
230
241
  description: resolveToolDescription(root, path, cmd),
231
242
  path,
232
243
  leaf: cmd,
233
- inputSchema: buildInputSchema(root, path, cmd),
244
+ inputSchema: cmd.inputSchema ?? buildInputSchema(root, path, cmd),
234
245
  ...(outputSchema === undefined ? {} : { outputSchema }),
235
246
  });
236
247
  return;
@@ -6,8 +6,15 @@ import { expect, test } from "bun:test";
6
6
  import { join } from "node:path";
7
7
  import { $ } from "bun";
8
8
  import type { CliProgram } from "./index.ts";
9
- import { buildToolCallSuccess } from "./mcp/result.ts";
10
- import { collectMcpTools, mcpToolCallToArgv, mcpToolDescription, sanitizeToolSegment } from "./mcp/tools.ts";
9
+ import { buildToolCallSuccessFromResponse } from "./mcp/result.ts";
10
+ import {
11
+ apiToolName,
12
+ collectMcpTools,
13
+ mcpToolCallToArgv,
14
+ mcpToolDescription,
15
+ mcpToolName,
16
+ sanitizeToolSegment,
17
+ } from "./mcp/tools.ts";
11
18
  import { cliSchemaExport } from "./schema.ts";
12
19
  import { mcpRequest, nestedMcpFixture, testProgram } from "./test-fixtures.ts";
13
20
  import { cliValidateProgram } from "./validate.ts";
@@ -24,17 +31,25 @@ test("mcpToolDescription formats CLI path and root-leaf prefix", () => {
24
31
  expect(mcpToolDescription([], "helloapp", "Tiny demo.")).toBe("helloapp — Tiny demo.");
25
32
  });
26
33
 
34
+ test("apiToolName hyphen-joins path; mcpToolName sanitizes to underscores", () => {
35
+ expect(apiToolName(nestedMcpFixture, ["stat", "owner", "lookup"])).toBe("stat-owner-lookup");
36
+ expect(mcpToolName(nestedMcpFixture, ["stat", "owner", "lookup"])).toBe("stat_owner_lookup");
37
+ expect(apiToolName(nestedMcpFixture, ["render-invoice"])).toBe("render-invoice");
38
+ expect(mcpToolName(nestedMcpFixture, ["render-invoice"])).toBe("render_invoice");
39
+ });
40
+
27
41
  test("collectMcpTools lists user leaf commands only", () => {
28
42
  const tools = collectMcpTools(nestedMcpFixture);
29
43
  const names = tools.map((t) => t.name);
30
44
  expect(names).toContain("stat_owner_lookup");
45
+ const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
46
+ expect(lookup.apiName).toBe("stat-owner-lookup");
47
+ expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
31
48
  expect(names).toContain("read");
32
49
  expect(names).not.toContain("hidden");
33
50
  expect(names).not.toContain("configure");
34
51
  expect(names).not.toContain("mcp");
35
52
  expect(names).not.toContain("completion");
36
- const lookup = tools.find((t) => t.name === "stat_owner_lookup")!;
37
- expect(lookup.description).toBe("stat owner lookup — Resolve owner info.");
38
53
  });
39
54
 
40
55
  /** Tests that collectMcpTools appends leaf notes to MCP tool description. */
@@ -345,44 +360,35 @@ test("mcpTool on routing node is rejected", () => {
345
360
  expect(() => cliValidateProgram(root)).toThrow(/mcpTool is only supported on leaf commands/);
346
361
  });
347
362
 
348
- test("buildToolCallSuccess returns stdout only", () => {
349
- const result = buildToolCallSuccess("hello\n", "");
363
+ test("buildToolCallSuccessFromResponse maps JSON object", () => {
364
+ const result = buildToolCallSuccessFromResponse({ body: { a: 1 } });
350
365
  expect(result.isError).toBe(false);
351
- expect(result.content).toEqual([{ type: "text", text: "hello\n" }]);
352
- expect(result.structuredContent).toBeUndefined();
353
- });
354
-
355
- test("buildToolCallSuccess adds stderr as second content block", () => {
356
- const result = buildToolCallSuccess("out\n", "warn\n");
357
- expect(result.content).toEqual([
358
- { type: "text", text: "out\n" },
359
- { type: "text", text: "warn" },
360
- ]);
361
- expect(result.structuredContent).toBeUndefined();
362
- });
363
-
364
- test("buildToolCallSuccess stderr-only still includes stdout slot", () => {
365
- const result = buildToolCallSuccess("", "warn\n");
366
- expect(result.content).toEqual([
367
- { type: "text", text: "" },
368
- { type: "text", text: "warn" },
369
- ]);
370
- });
371
-
372
- test("buildToolCallSuccess parses JSON structuredContent", () => {
373
- const result = buildToolCallSuccess('{"a":1}\n', "");
374
366
  expect(result.structuredContent).toEqual({ a: 1 });
375
- expect(result.content[0]?.text).toBe('{"a":1}\n');
367
+ expect(result.content[0]?.text).toBe("");
376
368
  });
377
369
 
378
- test("buildToolCallSuccess skips structuredContent for plain text", () => {
379
- const result = buildToolCallSuccess("lookup user=x\n", "");
380
- expect(result.structuredContent).toBeUndefined();
370
+ test("buildToolCallSuccessFromResponse maps string body", () => {
371
+ const result = buildToolCallSuccessFromResponse({
372
+ body: "lookup user=x",
373
+ contentType: "text/plain; charset=utf-8",
374
+ });
375
+ expect(result.structuredContent).toEqual({
376
+ content: "lookup user=x",
377
+ contentType: "text/plain; charset=utf-8",
378
+ });
381
379
  });
382
380
 
383
- test("buildToolCallSuccess parses JSON primitives", () => {
384
- const result = buildToolCallSuccess("true\n", "");
385
- expect(result.structuredContent).toBe(true);
381
+ test("buildToolCallSuccessFromResponse maps binary body as base64", () => {
382
+ const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]);
383
+ const result = buildToolCallSuccessFromResponse({
384
+ body: bytes,
385
+ contentType: "application/pdf",
386
+ });
387
+ expect(result.structuredContent).toEqual({
388
+ data: "JVBERg==",
389
+ contentType: "application/pdf",
390
+ encoding: "base64",
391
+ });
386
392
  });
387
393
 
388
394
  test("MCP initialize returns tools and resources capabilities", async () => {
@@ -425,9 +431,11 @@ test("MCP tools/call runs stat_owner_lookup", async () => {
425
431
  },
426
432
  },
427
433
  ]);
428
- const res = responses.get(4) as { result: { content: { text: string }[]; isError: boolean } };
434
+ const res = responses.get(4) as {
435
+ result: { content: { text: string }[]; structuredContent?: { content: string }; isError: boolean };
436
+ };
429
437
  expect(res.result.isError).toBe(false);
430
- expect(res.result.content[0]?.text).toContain("lookup user=test");
438
+ expect(res.result.structuredContent?.content).toContain("lookup user=test");
431
439
  });
432
440
 
433
441
  /** MCP tools/call returns structuredContent for JSON stdout. */
@@ -453,7 +461,6 @@ test("MCP tools/call returns structuredContent for JSON stdout", async () => {
453
461
  };
454
462
  expect(res.result.isError).toBe(false);
455
463
  expect(res.result.structuredContent).toEqual({ user: "test", path: readme });
456
- expect(JSON.parse(res.result.content[0]?.text.trim())).toEqual({ user: "test", path: readme });
457
464
  });
458
465
 
459
466
  /** MCP tools/call errors on missing required positional. */
package/src/parse.test.ts CHANGED
@@ -503,8 +503,8 @@ test("leaf completion help prints correctly", async () => {
503
503
  });
504
504
 
505
505
  /** Docs schema exports JSON for nested CLIs. */
506
- test("docs schema exports JSON for nested CLIs", async () => {
507
- const { stdout, stderr, exitCode } = await $`bun run examples/nested.ts docs schema`.nothrow().quiet();
506
+ test("docs cli-schema exports JSON for nested CLIs", async () => {
507
+ const { stdout, stderr, exitCode } = await $`bun run examples/nested.ts docs cli-schema`.nothrow().quiet();
508
508
  expect(exitCode).toBe(0);
509
509
  expect(stderr.toString()).toBe("");
510
510
 
@@ -520,8 +520,8 @@ test("docs schema exports JSON for nested CLIs", async () => {
520
520
  });
521
521
 
522
522
  /** Docs schema exports JSON for leaf roots. */
523
- test("docs schema exports JSON for leaf roots", async () => {
524
- const { stdout, exitCode } = await $`bun run examples/minimal.ts docs schema`.nothrow().quiet();
523
+ test("docs cli-schema exports JSON for leaf roots", async () => {
524
+ const { stdout, exitCode } = await $`bun run examples/minimal.ts docs cli-schema`.nothrow().quiet();
525
525
  expect(exitCode).toBe(0);
526
526
 
527
527
  const schema = JSON.parse(stdout.toString());
@@ -658,8 +658,8 @@ test("docs help lists schema, api, and skill subcommands", () => {
658
658
  ],
659
659
  });
660
660
  const help = cliHelpRender(cliPresentationRoot(root), ["docs"], false);
661
- expect(help).toContain("schema");
662
- expect(help).toContain("Print the full command tree as JSON.");
661
+ expect(help).toContain("cli-schema");
662
+ expect(help).toContain("Print the full CLI command tree as JSON.");
663
663
  expect(help).toContain("api");
664
664
  expect(help).toContain("markdown");
665
665
  expect(help).toContain("skill");
@@ -814,6 +814,16 @@ test("cliValidateProgram rejects empty mcpServer", () => {
814
814
  expect(() => cliValidateProgram(root)).toThrow(/mcpServer requires enabled: true/);
815
815
  });
816
816
 
817
+ test("cliValidateProgram rejects empty apiServer", () => {
818
+ const root = testProgram({
819
+ key: "app",
820
+ description: "",
821
+ apiServer: {} as { enabled: boolean },
822
+ handler: () => {},
823
+ });
824
+ expect(() => cliValidateProgram(root)).toThrow(/apiServer requires enabled: true/);
825
+ });
826
+
817
827
  test("resolveMcpSchemaUri uses sanitized root key", () => {
818
828
  const root = testProgram({
819
829
  key: "nested.ts",
package/src/respond.ts ADDED
@@ -0,0 +1,48 @@
1
+ /*
2
+ Helpers for ctx.respond(): content-type defaults and CLI stdout serialization.
3
+ */
4
+
5
+ import type { CliRespondBody, CliRespondOptions } from "./types.ts";
6
+
7
+ /** Fills default contentType on respond options based on body shape. */
8
+ export function normalizeRespondOptions(opts: CliRespondOptions): CliRespondOptions {
9
+ if (opts.contentType !== undefined) {
10
+ return opts;
11
+ }
12
+ const body = opts.body;
13
+ if (body instanceof Uint8Array) {
14
+ throw new Error("ctx.respond() with Uint8Array body requires an explicit contentType");
15
+ }
16
+ if (typeof body === "string") {
17
+ return { ...opts, contentType: "text/plain; charset=utf-8" };
18
+ }
19
+ return { ...opts, contentType: "application/json; charset=utf-8" };
20
+ }
21
+
22
+ /** Writes a respond body to process.stdout for CLI invocations. */
23
+ export function writeRespondBodyToStdout(body: CliRespondBody): void {
24
+ if (body instanceof Uint8Array) {
25
+ process.stdout.write(body);
26
+ return;
27
+ }
28
+ if (typeof body === "string") {
29
+ process.stdout.write(body);
30
+ if (!body.endsWith("\n")) {
31
+ process.stdout.write("\n");
32
+ }
33
+ return;
34
+ }
35
+ process.stdout.write(`${JSON.stringify(body, null, 2)}\n`);
36
+ }
37
+
38
+ /** Encodes binary respond bodies as base64 for MCP structuredContent. */
39
+ export function encodeRespondBodyBase64(body: Uint8Array): string {
40
+ if (typeof Buffer !== "undefined") {
41
+ return Buffer.from(body).toString("base64");
42
+ }
43
+ let binary = "";
44
+ for (const byte of body) {
45
+ binary += String.fromCharCode(byte);
46
+ }
47
+ return btoa(binary);
48
+ }
package/src/schema.ts CHANGED
@@ -7,7 +7,7 @@ import { cliResolveNotes } from "./help.ts";
7
7
  import { visibleOptions } from "./hidden.ts";
8
8
  import { type CliNode, type CliProgram, isCliLeaf, isCliRouter, leafOutputSchema } from "./types.ts";
9
9
 
10
- const RESERVED = new Set(["completion", "configure", "docs", "mcp", "version"]);
10
+ const RESERVED = new Set(["api", "completion", "configure", "docs", "mcp", "version"]);
11
11
 
12
12
  function exportCommand(cmd: CliNode, root: CliProgram): CliSchemaExport | null {
13
13
  if (cmd.hidden) {
@@ -218,7 +218,7 @@ function buildPluginSkillMd(root: CliProgram, dirName: string): string {
218
218
  "",
219
219
  `- Server id: \`${serverId}\` (configured in plugin \`.mcp.json\`)`,
220
220
  "- Tool names and argument shapes come from MCP `tools/list`",
221
- `- Full schema: \`${schemaUri}\` (same as \`${root.key} docs schema\`)`,
221
+ `- Full schema: \`${schemaUri}\` (same as \`${root.key} docs cli-schema\`)`,
222
222
  "",
223
223
  ];
224
224
 
package/src/types.ts CHANGED
@@ -9,7 +9,7 @@ import type { CliContext } from "./context.ts";
9
9
  /**
10
10
  * How a leaf handler was dispatched.
11
11
  */
12
- export type CliInvocation = "cli" | "mcp";
12
+ export type CliInvocation = "cli" | "mcp" | "api";
13
13
 
14
14
  /**
15
15
  * Option kinds: presence (boolean flag), string (free-form text), number (strict double), or enum (fixed choices).
@@ -154,6 +154,42 @@ export interface CliMcpServerConfig {
154
154
  bundle?: CliMcpBundleConfig;
155
155
  }
156
156
 
157
+ /**
158
+ * Enables `myapp api` and the HTTP tool server (program root only).
159
+ * Must include `enabled: true`; omit `apiServer` entirely to disable HTTP.
160
+ */
161
+ export interface CliApiServerConfig {
162
+ /** When `true`, enables the `api` built-in and HTTP tool server. */
163
+ enabled: boolean;
164
+ /** Listen host (default: `127.0.0.1`). */
165
+ host?: string;
166
+ /** Listen port (default: `3000`). */
167
+ port?: number;
168
+ }
169
+
170
+ /**
171
+ * Declarative HTTP response hints for a leaf (used by OpenAPI and default headers).
172
+ */
173
+ export interface CliApiResponseConfig {
174
+ /** Default success Content-Type (default: `application/json`). */
175
+ contentType?: string;
176
+ /** Optional Content-Disposition (e.g. `attachment; filename="invoice.pdf"`). */
177
+ contentDisposition?: string;
178
+ }
179
+
180
+ /** Body types accepted by {@link CliContext.respond}. */
181
+ export type CliRespondBody = string | Uint8Array | Record<string, unknown> | unknown[];
182
+
183
+ /** Options for {@link CliContext.respond} and headless invoke results. */
184
+ export interface CliRespondOptions {
185
+ body: CliRespondBody;
186
+ /** Default: `application/json` for objects/arrays, `text/plain` for strings; binary requires explicit type. */
187
+ contentType?: string;
188
+ /** HTTP status (default: 200). */
189
+ status?: number;
190
+ headers?: Record<string, string>;
191
+ }
192
+
157
193
  /**
158
194
  * A custom MCP resource exposed under resources/list and resources/read.
159
195
  */
@@ -385,9 +421,13 @@ export type CliLeaf = CliNodeBase & {
385
421
  positionals?: CliPositional[];
386
422
  /**
387
423
  * JSON Schema for structured stdout (e.g. with `--json` or MCP when the handler emits JSON).
388
- * Exported in `docs schema`, `docs api`, and MCP `tools/list`; not validated at runtime yet.
424
+ * Exported in `docs cli-schema`, `docs api`, and MCP `tools/list`; not validated at runtime yet.
389
425
  */
390
426
  outputSchema?: Record<string, unknown>;
427
+ /** JSON Schema for MCP/HTTP tool arguments (flat object). */
428
+ inputSchema?: Record<string, unknown>;
429
+ /** Declarative HTTP response metadata (Content-Type, Content-Disposition). */
430
+ apiResponse?: CliApiResponseConfig;
391
431
  /** Per-tool MCP exposure and metadata. */
392
432
  mcpTool?: CliMcpToolConfig;
393
433
  };
@@ -420,6 +460,8 @@ export type CliProgram = CliNode & {
420
460
  appConfig?: CliAppConfig;
421
461
  /** When set with `enabled: true`, enables the `mcp` built-in subcommand. */
422
462
  mcpServer?: CliMcpServerConfig;
463
+ /** When set with `enabled: true`, enables the `api` built-in HTTP server. */
464
+ apiServer?: CliApiServerConfig;
423
465
  /** Opt-out and defaults for `configure`. */
424
466
  configure?: CliConfigureConfig;
425
467
  /** Opt-out for shell completion generation (`completion bash|zsh|fish`). */
@@ -445,9 +487,9 @@ export function leafOutputSchema(leaf: CliLeaf): Record<string, unknown> | undef
445
487
 
446
488
  /**
447
489
  * Handler closure type for leaf commands.
448
- * Supports both sync and async handlers.
490
+ * Supports sync and async handlers; non-undefined return values become implicit JSON responses for headless invocations.
449
491
  */
450
- export type CliHandler = (ctx: CliContext) => void | Promise<void>;
492
+ export type CliHandler = (ctx: CliContext) => unknown | Promise<unknown>;
451
493
 
452
494
  /**
453
495
  * Error thrown when the static CLI tree violates ArgsBarg rules.
package/src/validate.ts CHANGED
@@ -180,6 +180,10 @@ export function cliValidateProgram(program: CliProgram): void {
180
180
  throw new CliSchemaValidationError("mcpServer requires enabled: true; omit mcpServer to disable MCP");
181
181
  }
182
182
 
183
+ if (program.apiServer !== undefined && program.apiServer.enabled !== true) {
184
+ throw new CliSchemaValidationError("apiServer requires enabled: true; omit apiServer to disable HTTP API");
185
+ }
186
+
183
187
  if (program.docs !== undefined && program.docs.enabled !== true) {
184
188
  throw new CliSchemaValidationError("docs requires enabled: true; omit docs to disable bundled documentation");
185
189
  }
@@ -216,6 +220,9 @@ function walkNode(node: CliNode, program: CliProgram, isRoot: boolean): void {
216
220
  if (rogue.mcpServer !== undefined) {
217
221
  throw new CliSchemaValidationError(`mcpServer is only supported on the program root (not on ${node.key})`);
218
222
  }
223
+ if (rogue.apiServer !== undefined) {
224
+ throw new CliSchemaValidationError(`apiServer is only supported on the program root (not on ${node.key})`);
225
+ }
219
226
  if (rogue.configure !== undefined) {
220
227
  throw new CliSchemaValidationError(`configure is only supported on the program root (not on ${node.key})`);
221
228
  }