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
@@ -7,6 +7,7 @@ import type { ParseResult } from "../parse.ts";
7
7
  import { ParseKind } from "../parse.ts";
8
8
  import type { CliNode, CliProgram, CliRouter } from "../types.ts";
9
9
  import { isCliLeaf } from "../types.ts";
10
+ import { cliBuiltinApiCommand } from "./api.ts";
10
11
  import { completionBashScript } from "./completion-bash.ts";
11
12
  import { completionFishScript } from "./completion-fish.ts";
12
13
  import { cliBuiltinCompletionGroup as completionGroup } from "./completion-group.ts";
@@ -68,6 +69,20 @@ export async function dispatchBuiltin(program: CliProgram, pr: ParseResult, opts
68
69
  process.exit(0);
69
70
  }
70
71
 
72
+ if (pr.path[0] === "api") {
73
+ if (!caps.api) {
74
+ process.stderr.write(capabilityDeniedMessage("api"));
75
+ process.exit(1);
76
+ }
77
+ const sub = pr.path[1];
78
+ if (pr.path.length === 1 || sub === "serve") {
79
+ await new Cli(program).serveApi();
80
+ process.exit(0);
81
+ }
82
+ process.stderr.write(`Unknown subcommand: api ${pr.path.slice(1).join(" ")}\n`);
83
+ process.exit(1);
84
+ }
85
+
71
86
  if (pr.path[0] === "mcp") {
72
87
  if (!caps.mcp) {
73
88
  process.stderr.write(capabilityDeniedMessage("mcp"));
@@ -142,6 +157,17 @@ export function builtinInterceptRoot(
142
157
  };
143
158
  }
144
159
 
160
+ if (first === "api" && caps.api) {
161
+ return {
162
+ parseRoot: {
163
+ key: program.key,
164
+ description: program.description,
165
+ commands: [cliBuiltinApiCommand(program)],
166
+ },
167
+ isLeafCompletionIntercept: false,
168
+ };
169
+ }
170
+
145
171
  if (first === "mcp" && caps.mcp) {
146
172
  return {
147
173
  parseRoot: {
@@ -1,6 +1,7 @@
1
1
  import type { CliCapabilities } from "../capabilities.ts";
2
2
  import { cliBuiltinDocsGroupIfEnabled } from "../docs/builtin.ts";
3
3
  import type { CliNode, CliProgram } from "../types.ts";
4
+ import { cliBuiltinApiCommand } from "./api.ts";
4
5
  import { cliBuiltinCompletionGroup } from "./completion-group.ts";
5
6
  import { cliBuiltinConfigureCommand } from "./configure.ts";
6
7
  import { cliBuiltinMcpCommand } from "./mcp.ts";
@@ -32,5 +33,8 @@ export function resolveBuiltins(program: CliProgram, caps: CliCapabilities): Cli
32
33
  if (caps.mcp) {
33
34
  pushBuiltin(builtins, program, (p) => cliBuiltinMcpCommand(p));
34
35
  }
36
+ if (caps.api) {
37
+ pushBuiltin(builtins, program, (p) => cliBuiltinApiCommand(p));
38
+ }
35
39
  return builtins;
36
40
  }
@@ -8,6 +8,7 @@ import type { CliProgram } from "./types.ts";
8
8
 
9
9
  /** Platform builtins derived from program config and runtime. */
10
10
  export interface CliCapabilities {
11
+ api: boolean;
11
12
  completion: boolean;
12
13
  mcp: boolean;
13
14
  configure: boolean;
@@ -19,6 +20,7 @@ export interface CliCapabilities {
19
20
  export function resolveCapabilities(program: CliProgram): CliCapabilities {
20
21
  const configure = program.configure?.enabled !== false;
21
22
  return {
23
+ api: program.apiServer?.enabled === true,
22
24
  completion: program.completion?.enabled !== false,
23
25
  mcp: program.mcpServer?.enabled === true,
24
26
  configure,
@@ -42,6 +44,9 @@ export function reservedCommandNames(caps: CliCapabilities): string[] {
42
44
  if (caps.mcp) {
43
45
  names.push("mcp");
44
46
  }
47
+ if (caps.api) {
48
+ names.push("api");
49
+ }
45
50
  return names;
46
51
  }
47
52
 
@@ -60,13 +65,15 @@ export function skipsRequiredAppConfigExit(path: string[], caps: CliCapabilities
60
65
  return false;
61
66
  }
62
67
 
63
- export type CapabilityFeature = "mcp" | "configure" | "docs" | "completion";
68
+ export type CapabilityFeature = "api" | "mcp" | "configure" | "docs" | "completion";
64
69
 
65
70
  /** Stderr message when a disabled built-in is invoked from the CLI. */
66
71
  export function capabilityDeniedMessage(feature: CapabilityFeature): string {
67
72
  switch (feature) {
68
73
  case "completion":
69
74
  return "Shell completion is not available for this app.\n";
75
+ case "api":
76
+ return "HTTP API is not available for this app.\n";
70
77
  case "mcp":
71
78
  return "MCP is not available for this app.\n";
72
79
  case "configure":
@@ -90,6 +97,10 @@ export function assertBuiltinAllowed(argv: string[], caps: CliCapabilities): voi
90
97
  process.stderr.write(capabilityDeniedMessage("mcp"));
91
98
  process.exit(1);
92
99
  }
100
+ if (first === "api" && !caps.api) {
101
+ process.stderr.write(capabilityDeniedMessage("api"));
102
+ process.exit(1);
103
+ }
93
104
  if (first === "configure" && !caps.configure) {
94
105
  process.stderr.write(capabilityDeniedMessage("configure"));
95
106
  process.exit(1);
package/src/cli-errors.ts CHANGED
@@ -7,6 +7,9 @@ import type { CliContext } from "./context.ts";
7
7
  import { cliHelpRender } from "./help.ts";
8
8
 
9
9
  export function cliErrWithHelp(ctx: CliContext, msg: string): never {
10
+ if (ctx.invocation === "api" || ctx.invocation === "mcp") {
11
+ throw new Error(msg);
12
+ }
10
13
  const color = process.stderr.isTTY;
11
14
  const line = color ? `\u001B[31m${msg}\u001B[0m` : msg;
12
15
  process.stderr.write(`${line}\n`);
@@ -26,6 +26,7 @@ const sinkProgram = {
26
26
  topics: { readme: { text: "# readme\n" } },
27
27
  },
28
28
  mcpServer: { enabled: true },
29
+ apiServer: { enabled: true },
29
30
  configure: {},
30
31
  commands: [
31
32
  {
@@ -54,6 +55,7 @@ describe("full-example template", () => {
54
55
  /** Tests that program source enables every builtin flag. */
55
56
  test("program source enables every builtin flag", () => {
56
57
  expect(programSource).toContain("mcpServer: {");
58
+ expect(programSource).toContain("apiServer: {");
57
59
  expect(programSource).toContain("enabled: true");
58
60
  expect(programSource).toContain("docs:");
59
61
  expect(programSource).toContain("appConfig:");
@@ -66,6 +68,7 @@ describe("full-example template", () => {
66
68
 
67
69
  test("resolveCapabilities matches full sink shape", () => {
68
70
  expect(resolveCapabilities(sinkProgram)).toEqual({
71
+ api: true,
69
72
  completion: true,
70
73
  mcp: true,
71
74
  configure: true,
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ Runtime entry point: validate program, cache derived state, run / invoke / MCP s
3
3
  */
4
4
 
5
5
  import { format } from "node:util";
6
+ import { apiServeHttp } from "./api/server.ts";
6
7
  import { builtinInterceptRoot, dispatchBuiltin } from "./builtins/dispatch.ts";
7
8
  import { cliParseRoot, cliPresentationRoot } from "./builtins/presentation.ts";
8
9
  import {
@@ -21,7 +22,7 @@ import { bootstrapMcpEnv } from "./mcp/env.ts";
21
22
  import { mcpServeStdioLoop } from "./mcp/server.ts";
22
23
  import { ParseKind, type ParseResult, parse, postParseValidate } from "./parse.ts";
23
24
  import { type CliSchemaExport, cliSchemaExport } from "./schema.ts";
24
- import type { CliHandler, CliLeaf, CliNode, CliProgram, CliRouter } from "./types.ts";
25
+ import type { CliHandler, CliInvocation, CliLeaf, CliNode, CliProgram, CliRespondOptions, CliRouter } from "./types.ts";
25
26
  import { isCliLeaf, isCliRouter } from "./types.ts";
26
27
  import { cliValidateProgram } from "./validate.ts";
27
28
 
@@ -35,6 +36,8 @@ export interface CliInvokeResult {
35
36
  stdout: string;
36
37
  stderr: string;
37
38
  errorMsg?: string;
39
+ /** Headless response payload when invocation is `api` or `mcp` and the handler succeeded. */
40
+ response?: CliRespondOptions;
38
41
  }
39
42
 
40
43
  class CliInvokeExit extends Error {
@@ -123,7 +126,10 @@ export class Cli {
123
126
 
124
127
  const ctx = new CliContext(this.program.key, pr.path, pr.args, pr.opts, this.program, "cli", snapshot);
125
128
  try {
126
- await Promise.resolve(leaf.handler(ctx));
129
+ const handlerResult = await Promise.resolve(leaf.handler(ctx));
130
+ if (handlerResult !== undefined && ctx.getResponse() === undefined) {
131
+ ctx.respond({ body: handlerResult as CliRespondOptions["body"] });
132
+ }
127
133
  process.exit(0);
128
134
  } catch (err) {
129
135
  if (err instanceof Error) {
@@ -133,7 +139,11 @@ export class Cli {
133
139
  }
134
140
  }
135
141
 
136
- async invoke(argv: string[]): Promise<CliInvokeResult> {
142
+ async invoke(
143
+ argv: string[],
144
+ opts?: { invocation?: CliInvocation; toolArgs?: Record<string, unknown> },
145
+ ): Promise<CliInvokeResult> {
146
+ const invocation = opts?.invocation ?? "mcp";
137
147
  const prep = this.prepareDispatch(argv, { presentationFallback: true });
138
148
  if ("error" in prep) {
139
149
  if (prep.error.kind === ParseKind.Help) {
@@ -142,7 +152,7 @@ export class Cli {
142
152
  exitCode: 1,
143
153
  stdout: "",
144
154
  stderr: "",
145
- errorMsg: "Help is not available via MCP tool calls.",
155
+ errorMsg: "Help is not available via tool calls.",
146
156
  };
147
157
  }
148
158
  return {
@@ -160,7 +170,16 @@ export class Cli {
160
170
  exitOnMissing: false,
161
171
  });
162
172
 
163
- const ctx = new CliContext(this.program.key, pr.path, pr.args, pr.opts, this.program, "mcp", snapshot);
173
+ const ctx = new CliContext(
174
+ this.program.key,
175
+ pr.path,
176
+ pr.args,
177
+ pr.opts,
178
+ this.program,
179
+ invocation,
180
+ snapshot,
181
+ opts?.toolArgs,
182
+ );
164
183
 
165
184
  let stdout = "";
166
185
  let stderr = "";
@@ -213,12 +232,30 @@ export class Cli {
213
232
  });
214
233
  }
215
234
 
216
- await Promise.resolve(leaf.handler(ctx));
217
- return { kind: "ok", exitCode: 0, stdout, stderr };
235
+ const handlerResult = await Promise.resolve(leaf.handler(ctx));
236
+ if (handlerResult !== undefined && ctx.getResponse() === undefined) {
237
+ ctx.respond({ body: handlerResult as CliRespondOptions["body"] });
238
+ }
239
+
240
+ const response = ctx.getResponse();
241
+ return {
242
+ kind: "ok",
243
+ exitCode: 0,
244
+ stdout,
245
+ stderr,
246
+ ...(response ? { response } : {}),
247
+ };
218
248
  } catch (err) {
219
249
  if (err instanceof CliInvokeExit) {
220
250
  if (err.code === 0) {
221
- return { kind: "ok", exitCode: 0, stdout, stderr };
251
+ const response = ctx.getResponse();
252
+ return {
253
+ kind: "ok",
254
+ exitCode: 0,
255
+ stdout,
256
+ stderr,
257
+ ...(response ? { response } : {}),
258
+ };
222
259
  }
223
260
  const msg = stderr.trim() || `Exit code ${err.code}`;
224
261
  return { kind: "error", exitCode: err.code, stdout, stderr, errorMsg: msg };
@@ -268,6 +305,21 @@ export class Cli {
268
305
  }
269
306
  }
270
307
 
308
+ async serveApi(): Promise<never> {
309
+ try {
310
+ bootstrapAppConfig(this.program, { validateFile: false });
311
+ await apiServeHttp(this);
312
+ process.exit(0);
313
+ } catch (err) {
314
+ if (err instanceof Error) {
315
+ process.stderr.write(`${err.message}\n`);
316
+ } else {
317
+ process.stderr.write("HTTP API server error.\n");
318
+ }
319
+ process.exit(1);
320
+ }
321
+ }
322
+
271
323
  private prepareDispatch(
272
324
  argv: string[],
273
325
  opts?: { presentationFallback?: boolean },
@@ -75,9 +75,18 @@ test("MCP program.appConfig succeeds when env present", async () => {
75
75
  ],
76
76
  { script: "examples/mcp-test.ts", env: { ARGS_TEST_SECRET: "sekrit" } },
77
77
  );
78
- const res = responses.get(14) as { result: { isError: boolean; content: { text: string }[] } };
78
+ const res = responses.get(14) as {
79
+ result: {
80
+ isError: boolean;
81
+ structuredContent?: { content: string; contentType: string };
82
+ content: { text: string }[];
83
+ };
84
+ };
79
85
  expect(res.result.isError).toBe(false);
80
- expect(res.result.content[0]?.text.trim()).toBe("sekrit");
86
+ expect(res.result.structuredContent).toEqual({
87
+ content: "sekrit",
88
+ contentType: "text/plain; charset=utf-8",
89
+ });
81
90
  });
82
91
 
83
92
  /** MCP config file loads and exports vars for tool handlers. */
@@ -100,9 +109,18 @@ test("MCP config file loads and exports vars for tool handlers", async () => {
100
109
  env: { HOME: dir, ARGS_TEST_SECRET: "present" },
101
110
  },
102
111
  );
103
- const res = responses.get(15) as { result: { isError: boolean; content: { text: string }[] } };
112
+ const res = responses.get(15) as {
113
+ result: {
114
+ isError: boolean;
115
+ structuredContent?: { content: string; contentType: string };
116
+ content: { text: string }[];
117
+ };
118
+ };
104
119
  expect(res.result.isError).toBe(false);
105
- expect(res.result.content[0]?.text.trim()).toBe("present");
120
+ expect(res.result.structuredContent).toEqual({
121
+ content: "present",
122
+ contentType: "text/plain; charset=utf-8",
123
+ });
106
124
  rmSync(dir, { recursive: true, force: true });
107
125
  });
108
126
 
package/src/context.ts CHANGED
@@ -11,7 +11,8 @@ import type { AnyAppConfigSnapshot } from "./config/context.ts";
11
11
  import { EmptyAppConfigSnapshot } from "./config/context.ts";
12
12
  import { parseCommaList, parseDate, parseDateTime, parseDurationMs } from "./formats.ts";
13
13
  import { collectOptionDefs } from "./parse.ts";
14
- import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram } from "./types.ts";
14
+ import { normalizeRespondOptions, writeRespondBodyToStdout } from "./respond.ts";
15
+ import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram, CliRespondOptions } from "./types.ts";
15
16
  import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter } from "./types.ts";
16
17
  import { strictParseDouble } from "./utils.ts";
17
18
 
@@ -29,6 +30,10 @@ export class CliContext {
29
30
  readonly opts: Record<string, string>;
30
31
  readonly invocation: CliInvocation;
31
32
  readonly appConfig: AnyAppConfigSnapshot;
33
+ /** Original flat tool arguments for API/MCP invocations (when provided). */
34
+ readonly toolArgs?: Record<string, unknown>;
35
+
36
+ private response?: CliRespondOptions;
32
37
 
33
38
  /** Captures the program root, routed path, positional words, and option map for a leaf handler. */
34
39
  constructor(
@@ -39,6 +44,7 @@ export class CliContext {
39
44
  program: CliProgram,
40
45
  invocation: CliInvocation = "cli",
41
46
  appConfig: AnyAppConfigSnapshot = new EmptyAppConfigSnapshot(program),
47
+ toolArgs?: Record<string, unknown>,
42
48
  ) {
43
49
  this.appName = appName;
44
50
  this.commandPath = commandPath;
@@ -47,6 +53,28 @@ export class CliContext {
47
53
  this.program = program;
48
54
  this.invocation = invocation;
49
55
  this.appConfig = appConfig;
56
+ this.toolArgs = toolArgs;
57
+ }
58
+
59
+ /**
60
+ * Sets the machine-readable response for API/MCP invocations, or writes to stdout in CLI mode.
61
+ * May only be called once per invocation.
62
+ */
63
+ respond(opts: CliRespondOptions): void {
64
+ if (this.response !== undefined) {
65
+ throw new Error("ctx.respond() was already called for this invocation");
66
+ }
67
+ const normalized = normalizeRespondOptions(opts);
68
+ if (this.invocation === "cli") {
69
+ writeRespondBodyToStdout(normalized.body);
70
+ return;
71
+ }
72
+ this.response = normalized;
73
+ }
74
+
75
+ /** Returns the respond payload set by {@link respond}, if any. */
76
+ getResponse(): CliRespondOptions | undefined {
77
+ return this.response;
50
78
  }
51
79
 
52
80
  /** Returns whether a presence flag was set (including implicit "1" for boolean options). */
@@ -152,7 +152,7 @@ export function generateApiGuideBody(program: CliProgram): string {
152
152
  return `${lines.join("\n").trimEnd()}\n`;
153
153
  }
154
154
 
155
- /** Generates markdown API reference from the same export as `docs schema`. */
155
+ /** Generates markdown API reference from the same export as `docs cli-schema`. */
156
156
  export function generateApiGuide(program: CliProgram): string {
157
157
  const schema = cliSchemaExport(program);
158
158
  const lines: string[] = [
@@ -160,7 +160,7 @@ export function generateApiGuide(program: CliProgram): string {
160
160
  "",
161
161
  schema.description,
162
162
  "",
163
- `Machine-readable export: \`${program.key} docs schema\``,
163
+ `Machine-readable export: \`${program.key} docs cli-schema\``,
164
164
  "",
165
165
  ];
166
166
 
@@ -12,7 +12,9 @@ import {
12
12
  DOCS_ROUTER_DESCRIPTION,
13
13
  docsEffectiveDefaultTopic,
14
14
  docsEnabled,
15
+ docsIncludesHttpTopic,
15
16
  docsIncludesMcpTopic,
17
+ docsIncludesOpenApiTopic,
16
18
  docsTopicDescription,
17
19
  docsUserTopicKeys,
18
20
  printDocsTopic,
@@ -70,8 +72,16 @@ export function cliBuiltinDocsGroup(program: CliProgram): CliRouter {
70
72
  leaves.push(docsLeaf(program, "mcp", "Print MCP server setup and tool guidance."));
71
73
  }
72
74
 
75
+ if (docsIncludesHttpTopic(program)) {
76
+ leaves.push(docsLeaf(program, "http", "Print HTTP API setup and tool guidance."));
77
+ }
78
+
79
+ if (docsIncludesOpenApiTopic(program)) {
80
+ leaves.push(docsLeaf(program, "openapi", "Print the HTTP OpenAPI 3.1 document as JSON."));
81
+ }
82
+
73
83
  leaves.push(
74
- docsLeaf(program, "schema", "Print the full command tree as JSON."),
84
+ docsLeaf(program, "cli-schema", "Print the full CLI command tree as JSON."),
75
85
  docsLeaf(program, "api", "Print the full command reference as markdown."),
76
86
  docsLeaf(program, "skill", docsSkillTopicDescription(program, resolveCapabilities(program))),
77
87
  );
@@ -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");