argsbarg 6.0.0 → 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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [6.0.1] - 2026-07-22
11
+
12
+ ### Added
13
+
14
+ - **OpenAPI schema dereferencing** — inline internal `$ref` pointers when building OpenAPI documents so API reference UIs show nested request shapes.
15
+
16
+ ### Changed
17
+
18
+ - **Schemagen discovery contract** — `examples/full-example` and docs now use `src/**/schema-types.ts` with `export type configType` / `outputType` / `inputType` instead of JSDoc markers (`Config schema`, `JSON payload`, `Tool input`) on `types.ts`.
19
+ - **HTTP tool errors** — return a plain `{ "error": "..." }` JSON body without ANSI color codes or appended CLI help text.
20
+ - **`cliErrWithHelp`** — on `api` / `mcp` invocations, throws a plain error instead of printing contextual help.
21
+ - **`GET /openapi-browser`** — Scalar config preserves schema property order from the OpenAPI document (`orderSchemaPropertiesBy: "preserve"`).
22
+
10
23
  ## [6.0.0] - 2026-07-22
11
24
 
12
25
  ### Added
@@ -715,7 +728,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
715
728
  - Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
716
729
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
717
730
 
718
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.0.0...HEAD
731
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.0.1...HEAD
732
+ [6.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.0.1
719
733
  [6.0.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.0.0
720
734
  [5.1.16]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.16
721
735
  [5.1.15]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.15
package/README.md CHANGED
@@ -167,7 +167,7 @@ Add app-specific conventions in a second rule if needed. Copy the rule from the
167
167
 
168
168
  1. Build a **program root** with `satisfies CliProgram` (or `: CliProgram`): `key` is the app name, `commands` are top-level subcommands, `options` are global flags. A router root must not set `handler` or declare `positionals` (validated at startup). A leaf root may set `handler` and `positionals` directly. Use `fallbackCommand` / `fallbackMode` on any **routing node** for default subcommand routing (not root-only).
169
169
  2. Call `await new Cli(program).run()` — validates, parses argv, renders help or errors, invokes the leaf handler, and `process.exit`s with status **0** on success, **1** on implicit help or error (explicit `--help` → **0**).
170
- 3. From a handler, `cliErrWithHelp(ctx, "message")` prints a red error line plus contextual help on stderr and exits **1**.
170
+ 3. From a handler, `cliErrWithHelp(ctx, "message")` prints a red error line plus contextual help on stderr and exits **1** (CLI only; API/MCP invocations throw a plain `Error`).
171
171
 
172
172
 
173
173
 
@@ -267,8 +267,8 @@ To refresh the Cursor rule in an existing consumer: `bun scripts/merge-cli-progr
267
267
  | Area | Files / wiring |
268
268
  | --------------------- | -------------------------------------------------------------------------------------- |
269
269
  | All builtins | `completion`, `version`, `configure`, `docs`, `mcp`, `api`, `configure get`/`set` |
270
- | `program.appConfig` | `src/types.ts` (`AppConfig`) → `schemas/configSchemas.ts` |
271
- | `outputSchema` | `src/commands/status/types.ts` → `schemas/outputSchemas.ts` |
270
+ | `program.appConfig` | `src/config/schema-types.ts` (`AppConfig`) → `schemas/configSchemas.ts` |
271
+ | `outputSchema` | `src/commands/status/schema-types.ts` → `schemas/outputSchemas.ts` |
272
272
  | Schemagen | `scripts/schemagen.ts` + `scripts/schemagen/discover-schema-roots.ts` |
273
273
  | Command layout | `src/commands/<name>/command.ts`; registration in `src/program.ts` |
274
274
  | MCP doc topics | `docs.topics` auto-exposed as `<key>://docs/<topic>` resources when docs + MCP enabled |
@@ -134,7 +134,7 @@ All responses include wide-open CORS headers (`Access-Control-Allow-Origin: *`).
134
134
 
135
135
  ## OpenAPI
136
136
 
137
- Call `generateOpenApi(program)` or `openApiJson(program)` from the package, fetch `GET /openapi.json` from a running server, or run `myapp docs openapi` / `myapp docs openapi --save` (writes `./docs/openapi.json` when `docs.enabled` and `apiServer.enabled`). Tools with custom `inputSchema` on the leaf are reflected in the document.
137
+ Call `generateOpenApi(program)` or `openApiJson(program)` from the package, fetch `GET /openapi.json` from a running server, or run `myapp docs openapi` / `myapp docs openapi --save` (writes `./docs/openapi.json` when `docs.enabled` and `apiServer.enabled`). Nested `inputSchema` / `outputSchema` `$ref` pointers are dereferenced when the OpenAPI document is built so API reference UIs can show nested request shapes.
138
138
 
139
139
  ## Complex tool inputs
140
140
 
@@ -142,33 +142,43 @@ Mirror the [output-schema.md](output-schema.md) pattern for config:
142
142
 
143
143
  ```mermaid
144
144
  flowchart LR
145
- subgraph types [Schema-facing TS + JSDoc]
146
- TypesTs["src/**/types.ts"]
147
- Marker["JSDoc contains Config schema"]
145
+ subgraph types [schema-types.ts]
146
+ Marker["export type configType = AppConfig"]
148
147
  end
149
148
  subgraph gen [just schemagen]
150
- Script["scripts/generate-config-schemas.ts"]
149
+ Script["scripts/schemagen.ts"]
151
150
  Gen["ts-json-schema-generator"]
152
151
  end
153
152
  subgraph artifacts [Committed]
154
- Json["src/schemas/generated/app-config.json"]
155
- Bridge["src/schemas/configSchemas.ts"]
153
+ Json["schemas/generated/app-config.json"]
154
+ Bridge["schemas/configSchemas.ts"]
156
155
  end
157
156
  subgraph runtime [Runtime]
158
157
  Program["program.appConfig.jsonSchema"]
159
158
  Validate["argsbarg runtime subset validator"]
160
159
  end
161
- TypesTs --> Marker --> Script --> Gen --> Json
160
+ types --> Script --> Gen --> Json
162
161
  Script --> Bridge --> Program --> Validate
163
162
  ```
164
163
 
165
164
  | Piece | Convention |
166
165
  | --- | --- |
167
166
  | Generator | [`ts-json-schema-generator`](https://github.com/vega/ts-json-schema-generator) |
168
- | Discovery | JSDoc marker **`Config schema`** on the root config interface |
167
+ | Discovery | `export type configType = …` in `src/config/schema-types.ts` (type defined in same file) |
169
168
  | Artifacts | Commit `app-config.json` and `configSchemas.ts` bridge exporting `APP_CONFIG_JSON_SCHEMA` |
170
169
  | Consumer CI | Optional: `ajv` + `ajv-formats` against the same committed JSON (not an argsbarg runtime dep) |
171
170
 
171
+ Example:
172
+
173
+ ```typescript
174
+ // src/config/schema-types.ts
175
+ export interface AppConfig {
176
+ apiToken: string;
177
+ }
178
+
179
+ export type configType = AppConfig;
180
+ ```
181
+
172
182
  ### Supported AppConfig shapes (argsbarg runtime validator)
173
183
 
174
184
  | Supported (v1) | Deferred |
@@ -23,8 +23,9 @@ export const status = {
23
23
  | `myapp docs api` | Markdown per-command **Output** section |
24
24
  | `myapp docs skill` | `reference.md` for agent skills |
25
25
  | MCP `tools/list` | Optional `outputSchema` on each tool |
26
+ | HTTP `GET /openapi.json` | Response schema per tool |
26
27
 
27
- **Not validated at runtime** — argsbarg does not parse or reject handler stdout against the schema today. The schema is documentation and MCP metadata.
28
+ **Not validated at runtime** — argsbarg does not parse or reject handler stdout against the schema today. The schema is documentation and MCP/HTTP metadata.
28
29
 
29
30
  **Set on the leaf only** — not under `mcpTool` (legacy `mcpTool.outputSchema` still resolves but is deprecated).
30
31
 
@@ -43,31 +44,29 @@ Production CLIs with several JSON commands tend to use **codegen** so types, han
43
44
 
44
45
  ## Recommended pipeline (copy per repo)
45
46
 
46
- No shared npm package — each app copies the same **contract**. Reference implementations: **sqsp-qa-manager-poc**, **sqsp-workspaces**, **sqsp-i18n-tools-poc** (see each repo’s `docs/architecture.md` for which commands use which schema root).
47
+ No shared npm package — each app copies the same **contract**. Reference implementations: **sqsp-qa-manager-poc**, **sqsp-workspaces**, **sqsp-i18n-tools-poc**, **pdf-gen** (see each repo’s `docs/architecture.md` for which commands use which schema root).
47
48
 
48
49
  ```mermaid
49
50
  flowchart LR
50
- subgraph types [Schema-facing TS + JSDoc]
51
- TypesTs["src/**/types.ts"]
52
- Marker["JSDoc contains JSON payload"]
53
- Narrow["Narrowed types where needed"]
51
+ subgraph types [schema-types.ts]
52
+ Config["export type configType = AppConfig"]
53
+ Output["export type outputType = StatusJsonOutput"]
54
+ Input["export type inputType = ToolInput (optional)"]
54
55
  end
55
56
  subgraph gen [just schemagen]
56
- Discover["scripts/schemagen/discover-schema-roots.ts"]
57
- Script["scripts/generate-output-schemas.ts"]
57
+ Discover["discover-schema-roots.ts"]
58
+ Script["schemagen.ts / generate-output-schemas.ts"]
58
59
  Gen["ts-json-schema-generator"]
59
60
  end
60
61
  subgraph artifacts [Committed]
61
- Json["src/schemas/generated/*.json"]
62
- Bridge["src/schemas/outputSchemas.ts"]
62
+ Json["schemas/generated/*.json"]
63
+ Bridge["outputSchemas.ts / inputSchemas.ts"]
63
64
  end
64
65
  subgraph runtime [Runtime]
65
- Leaves["leaf outputSchema"]
66
+ Leaves["leaf outputSchema / inputSchema"]
66
67
  Docgen["just docgen"]
67
68
  end
68
- TypesTs --> Marker --> Discover
69
- Narrow --> Discover
70
- Discover --> Script --> Gen --> Json
69
+ types --> Discover --> Script --> Gen --> Json
71
70
  Script --> Bridge --> Leaves --> Docgen
72
71
  ```
73
72
 
@@ -75,61 +74,95 @@ flowchart LR
75
74
  | --- | --- |
76
75
  | Generator | [`ts-json-schema-generator`](https://github.com/vega/ts-json-schema-generator) (`createGenerator` with `jsDoc: "extended"`) |
77
76
  | Config | `tsconfig: "tsconfig.json"`, `topRef: false`, `skipTypeCheck: false` |
78
- | Discovery | Walk `src/**/types.ts`; treat `export interface` as a schema root when its JSDoc contains **`JSON payload`** |
79
- | Artifacts | Commit `src/schemas/generated/*.json` **and** auto-generated `src/schemas/outputSchemas.ts` |
77
+ | Discovery | Walk `src/**/schema-types.ts`; generate from `export type outputType = …` / `inputType` / `configType` when the target type is **defined in that file** |
78
+ | Artifacts | Commit `schemas/generated/*.json` **and** auto-generated bridge modules |
80
79
  | tsconfig | `"resolveJsonModule": true` |
81
- | CI | `just check`: `schemagen` → `git diff --exit-code src/schemas/generated/ src/schemas/outputSchemas.ts` → typecheck |
80
+ | CI | `just check`: `schemagen` → `git diff --exit-code schemas/` → typecheck |
82
81
  | Docgen | `docgen` depends on `schemagen` so saved `./docs/api.md` and `./docs/cli-schema.json` are fresh |
83
82
 
84
83
  Copy these scripts into each consumer repo (they are intentionally duplicated, not published):
85
84
 
86
- - `scripts/generate-output-schemas.ts` — generate JSON + rewrite the bridge
85
+ - `scripts/schemagen.ts` or `scripts/generate-output-schemas.ts` — generate JSON + rewrite bridges
87
86
  - `scripts/schemagen/discover-schema-roots.ts` — find roots and map names → filenames / export constants
88
87
  - `scripts/schemagen/discover-schema-roots.test.ts` — lock discovery and naming per app
89
88
 
90
- ### Marking a schema root
89
+ ### Declaring a schema root
91
90
 
92
- Put schema-facing interfaces in **`src/**/types.ts`** (e.g. `src/commands/status/types.ts`, `src/ui/runHeadless/types.ts`, `src/core/types.ts`). Add a JSDoc line containing **`JSON payload`** on the exported interface:
91
+ Put schema-facing interfaces in **`schema-types.ts`** next to the command (or in a shared module when several commands reuse one shape). Export a role alias:
93
92
 
94
93
  ```typescript
95
- /** JSON payload for `myapp status --json`. */
94
+ // src/commands/status/schema-types.ts
95
+ import type { WorkspaceStatus } from "./types.ts";
96
+
97
+ /** JSON stdout for `myapp status --json`. */
96
98
  export interface StatusJsonOutput {
97
- items: StatusJsonItem[];
99
+ workspaces: WorkspaceStatus[];
98
100
  }
99
101
 
100
- /** JSON payload written to stdout after a headless mutating command. */
102
+ /** Schemagen root for leaf outputSchema. */
103
+ export type outputType = StatusJsonOutput;
104
+ ```
105
+
106
+ ```typescript
107
+ // src/ui/runHeadless/schema-types.ts — shared by many mutating commands
108
+ import type { HeadlessTaskResult } from "./types.ts";
109
+
101
110
  export interface HeadlessOpResult {
102
111
  command: string;
103
112
  exitCode: number;
104
113
  tasks: HeadlessTaskResult[];
105
114
  }
115
+
116
+ export type outputType = HeadlessOpResult;
106
117
  ```
107
118
 
108
- `discoverSchemaRoots` scans only files named `types.ts` under `src/`. Nested helper interfaces in the same file are included in the generated schema when referenced by a root; they are **not** separate JSON files unless they are also marked roots.
119
+ ```typescript
120
+ // src/commands/render-invoice/schema-types.ts — custom HTTP/MCP body (pdf-gen)
121
+ export interface RenderInvoiceToolInput {
122
+ format: "pdf" | "html";
123
+ invoice: InvoiceData;
124
+ }
125
+
126
+ export type inputType = RenderInvoiceToolInput;
127
+ export type outputType = RenderInvoiceWrittenOutput;
128
+ ```
129
+
130
+ | Export | Role |
131
+ | --- | --- |
132
+ | `export type configType = AppConfig` | `program.appConfig.jsonSchema` (one per repo, typically `src/config/schema-types.ts`) |
133
+ | `export type outputType = …` | `leaf.outputSchema` |
134
+ | `export type inputType = …` | `leaf.inputSchema` (only when the tool body is not flat CLI flags) |
135
+
136
+ **Domain helpers** stay in `types.ts` (or `core/types.ts`). Discovery scans only `schema-types.ts`. Re-export-only files (`export type outputType = HeadlessOpResult` pointing at another module) are **not** generation roots — generate once from the canonical definition file.
137
+
138
+ Commands without structured JSON omit `schema-types.ts` and import a shared bridge constant (e.g. `HEADLESS_OP_RESULT_OUTPUT_SCHEMA`).
139
+
140
+ When you do **not** set `inputSchema`, argsbarg builds tool input from CLI `options` + `positionals`.
109
141
 
110
142
  ### Stable naming (outfile + bridge export)
111
143
 
112
- Discovery maps each root type name to a generated filename and `outputSchemas.ts` constant. Suffix conventions (implemented in `outfileForType` / `schemaExportName`):
144
+ Discovery maps each root type name to a generated filename and bridge constant. Suffix conventions (implemented in `outfileForOutputType` / `outputSchemaExportName`):
113
145
 
114
146
  | Type suffix | Example type | Generated file | Bridge export |
115
147
  | --- | --- | --- | --- |
116
148
  | `JsonOutput` | `StatusJsonOutput` | `status.json` | `STATUS_JSON_OUTPUT_SCHEMA` |
117
149
  | `OpResult` | `HeadlessOpResult` | `headless-op-result.json` | `HEADLESS_OP_RESULT_OUTPUT_SCHEMA` |
118
150
  | `Output` | `OpenUrlOutput` | `open-url.json` | `OPEN_URL_OUTPUT_SCHEMA` |
119
- | `Result` | `UidsResult` | `uids.json` | `UIDS_OUTPUT_SCHEMA` |
151
+ | `Result` | `PrResult` | `pr.json` | `PR_RESULT_OUTPUT_SCHEMA` |
152
+ | `ToolInput` | `RenderInvoiceToolInput` | `render-invoice-tool-input.json` | `RENDER_INVOICE_TOOL_INPUT_SCHEMA` |
120
153
 
121
154
  Prefer these suffixes for new roots so filenames and import constants stay predictable across repos.
122
155
 
123
156
  ### Generated bridge
124
157
 
125
- `scripts/generate-output-schemas.ts` rewrites `src/schemas/outputSchemas.ts` on every run:
158
+ `scripts/schemagen.ts` rewrites `schemas/outputSchemas.ts` on every run:
126
159
 
127
160
  ```typescript
128
- // Auto-generated by scripts/generate-output-schemas.ts — do not edit by hand.
161
+ // Auto-generated by scripts/schemagen.ts — do not edit by hand.
129
162
 
130
163
  import status from "./generated/status.json";
131
164
 
132
- /** JSON Schema for `myapp status --json`. */
165
+ /** JSON Schema for leaf outputSchema from `StatusJsonOutput`. */
133
166
  export const STATUS_JSON_OUTPUT_SCHEMA = status as Record<string, unknown>;
134
167
  ```
135
168
 
@@ -139,26 +172,24 @@ Wire the constant on each leaf that emits that shape (several commands may share
139
172
 
140
173
  **Goal:** generated schemas match what handlers actually print, with descriptions agents can read in `docs api`.
141
174
 
142
- 1. **Schema roots** — `export interface` in a `types.ts` file, with **`JSON payload`** in the interface JSDoc naming which command(s) emit it.
175
+ 1. **Schema roots** — `export interface` in `schema-types.ts`, with `export type outputType = …` (or `inputType` / `configType`).
143
176
  2. **Per property** — `/** … */` on every field that should appear in JSON Schema `properties` (including nested named types).
144
177
  3. **Unions / enums** — document the alias; generator emits `enum` / `anyOf` with type-level description.
145
178
  4. **Formats** — property JSDoc can include `@format date-time` for ISO timestamps; add a smoke test that the generated property has `format: "date-time"`.
146
- 5. **Do not hand-edit** `src/schemas/generated/` or `src/schemas/outputSchemas.ts` — change types/JSDoc, run `just schemagen`, commit both.
179
+ 5. **Do not hand-edit** `schemas/generated/` or bridge modules — change types/JSDoc, run `just schemagen`, commit both.
147
180
 
148
181
  ### Narrowing when runtime ≠ stdout
149
182
 
150
- When a shared runtime type is **wider** than one command’s JSON, add a **schema-facing** root in `types.ts` (still marked `JSON payload`):
183
+ When a shared runtime type is **wider** than one command’s JSON, add a **schema-facing** root in `schema-types.ts`:
151
184
 
152
185
  ```typescript
153
- /** Runtime union across commands. */
154
- export type ResultSource = TranslationReadinessSource | { kind: "uids"; uids: string[] };
155
-
156
- /** JSON payload for `myapp pr` and `myapp file`. */
186
+ /** JSON stdout for `myapp pr` and `myapp file`. */
157
187
  export interface TranslationReadinessResult {
158
188
  source: TranslationReadinessSource;
159
189
  evaluatedAt: string;
160
- // ...
161
190
  }
191
+
192
+ export type outputType = TranslationReadinessResult;
162
193
  ```
163
194
 
164
195
  Patterns:
@@ -173,18 +204,18 @@ Handlers keep using runtime types; only discovered roots (and their type graph)
173
204
  Per repo:
174
205
 
175
206
  - **`scripts/schemagen/discover-schema-roots.test.ts`** — asserts which roots are discovered and stable outfile / export-name mapping.
176
- - **`src/schemas/outputSchemas.test.ts`** (optional) — schema shape smoke tests: object root, key `description` fields, enums, `@format date-time`.
207
+ - **`schemas/outputSchemas.test.ts`** (optional) — schema shape smoke tests: object root, key `description` fields, enums, `@format date-time`.
177
208
 
178
209
  ## Contributor workflow
179
210
 
180
- 1. Add or edit schema-facing interfaces in `src/**/types.ts` with **`JSON payload`** JSDoc and per-field descriptions.
181
- 2. `just schemagen` — refresh `src/schemas/generated/` and `src/schemas/outputSchemas.ts`.
182
- 3. Import the bridge constant on the relevant leaf `outputSchema` fields.
183
- 4. Commit generated JSON and the bridge with the type changes.
211
+ 1. Add or edit schema roots in `src/**/schema-types.ts` with `outputType` / `inputType` / `configType` and per-field JSDoc.
212
+ 2. `just schemagen` — refresh `schemas/generated/` and bridge modules.
213
+ 3. Import the bridge constant on the relevant leaf `outputSchema` / `inputSchema` fields.
214
+ 4. Commit generated JSON and bridges with the type changes.
184
215
  5. `just docgen` / `myapp docs api --save` — refresh consumer docs.
185
216
  6. Document which commands use which roots in **your** `docs/architecture.md` (argsbarg does not maintain per-app tables).
186
217
 
187
- Add a bullet under your app’s `**… conventions:**` block in `.cursor/rules/cli-program.mdc` pointing at `node_modules/argsbarg/docs/output-schema.md` and your `src/schemas/` layout.
218
+ Add a bullet under your app’s `**… conventions:**` block in `.cursor/rules/cli-program.mdc` pointing at `node_modules/argsbarg/docs/output-schema.md` and your `schemas/` layout.
188
219
 
189
220
  **Reference implementation:** [`examples/full-example/`](../examples/full-example/) in this repo (shipped in npm as `node_modules/argsbarg/examples/full-example/`) — discovery script, bridges, and `status` leaf with `outputSchema`.
190
221
 
@@ -193,10 +224,10 @@ Add a bullet under your app’s `**… conventions:**` block in `.cursor/rules/c
193
224
  - Shared codegen package or monorepo tooling
194
225
  - Runtime Zod / `.parse()` on stdout in argsbarg
195
226
  - `outputSchema` for plain-text, streaming, or Ink-only commands
196
- - Schema roots outside `src/**/types.ts` (use a dedicated `types.ts` next to handlers instead of `resolve.ts`)
197
227
 
198
228
  ## See also
199
229
 
230
+ - [config-schema.md](config-schema.md) — `configType` / `program.appConfig`
200
231
  - [cli-program.md](cli-program.md) — structured stdout, headless JSON, `read*Flags`
201
232
  - [mcp.md](mcp.md) — `tools/list`, `structuredContent`
202
233
  - [bundled-docs.md](bundled-docs.md) — `docs api` / `docs cli-schema` docgen
@@ -9,7 +9,7 @@ From a git checkout at this directory (requires [Homebrew](https://brew.sh), [ju
9
9
  ```bash
10
10
  brew install just bun
11
11
  just setup
12
- just schemagen # after changing src/**/types.ts
12
+ just schemagen # after changing src/**/schema-types.ts
13
13
  just run status --json
14
14
  just run docs readme
15
15
  ```
@@ -60,14 +60,15 @@ just test-release
60
60
 
61
61
  Undo a local dev install: `just uninstall` (formula + agent artifacts; app config removed by formula `uninstall`), `just uninstall-config` (app config only, without uninstalling the formula).
62
62
 
63
- ## Schemagen markers
63
+ ## Schemagen roots
64
64
 
65
- | Marker in interface JSDoc | Artifact |
65
+ | Export in `schema-types.ts` | Artifact |
66
66
  | --- | --- |
67
- | `Config schema` | `schemas/configSchemas.ts` + `schemas/generated/*-config.json` |
68
- | `JSON payload` | `schemas/outputSchemas.ts` + `schemas/generated/*.json` |
67
+ | `export type configType = …` | `schemas/configSchemas.ts` + `schemas/generated/*-config.json` |
68
+ | `export type outputType = …` | `schemas/outputSchemas.ts` + `schemas/generated/*.json` |
69
+ | `export type inputType = …` | `schemas/inputSchemas.ts` + `schemas/generated/*-tool-input.json` |
69
70
 
70
- Discovery walks `src/**/types.ts` only.
71
+ Discovery walks `src/**/schema-types.ts` only. Domain helpers stay in sibling `types.ts`.
71
72
 
72
73
  ## Consumer docs
73
74
 
@@ -35,6 +35,6 @@
35
35
  "maxRetries"
36
36
  ],
37
37
  "additionalProperties": false,
38
- "description": "Config schema\n\nApplication settings for `full-example` (`program.appConfig`).",
38
+ "description": "Application settings for `full-example` (`program.appConfig`).",
39
39
  "definitions": {}
40
40
  }
@@ -23,6 +23,6 @@
23
23
  "apiTokenSet",
24
24
  "version"
25
25
  ],
26
- "description": "JSON payload for `full-example status --json`.",
26
+ "description": "JSON stdout for `full-example status --json`.",
27
27
  "definitions": {}
28
28
  }
@@ -5,19 +5,19 @@ import { discoverSchemaRoots } from "./discover-schema-roots.ts";
5
5
  const projectRoot = join(import.meta.dir, "../..");
6
6
 
7
7
  describe("discover-schema-roots", () => {
8
- test("finds AppConfig and StatusJsonOutput in src types.ts files", () => {
8
+ test("finds AppConfig and StatusJsonOutput in schema-types.ts files", () => {
9
9
  const roots = discoverSchemaRoots(projectRoot);
10
10
  const config = roots.find((r) => r.kind === "config");
11
11
  const output = roots.find((r) => r.kind === "output");
12
12
  expect(config).toMatchObject({
13
13
  typeName: "AppConfig",
14
- relFile: "src/types.ts",
14
+ path: "src/config/schema-types.ts",
15
15
  outfile: "app-config.json",
16
16
  exportName: "APP_CONFIG_JSON_SCHEMA",
17
17
  });
18
18
  expect(output).toMatchObject({
19
19
  typeName: "StatusJsonOutput",
20
- relFile: "src/commands/status/types.ts",
20
+ path: "src/commands/status/schema-types.ts",
21
21
  outfile: "status.json",
22
22
  exportName: "STATUS_JSON_OUTPUT_SCHEMA",
23
23
  });
@@ -1,5 +1,5 @@
1
1
  /*
2
- Discovers schema roots in src (recursive) types.ts files by JSDoc markers.
2
+ Discovers schema roots in schema-types.ts files via configType / inputType / outputType exports.
3
3
  Copy per consumer repo — see docs/output-schema.md and docs/config-schema.md.
4
4
  */
5
5
 
@@ -7,87 +7,142 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
7
7
  import { join, relative } from "node:path";
8
8
  import {
9
9
  configSchemaExportName,
10
+ inputSchemaExportName,
10
11
  outfileForConfigType,
12
+ outfileForInputType,
11
13
  outfileForOutputType,
12
14
  outputSchemaExportName,
13
15
  } from "./naming.ts";
14
16
 
15
- export type SchemaRootKind = "config" | "output";
17
+ export type SchemaRootKind = "config" | "input" | "output";
18
+
19
+ export type SchemaRole = "configType" | "inputType" | "outputType";
16
20
 
17
21
  export interface SchemaRoot {
18
22
  kind: SchemaRootKind;
19
23
  typeName: string;
20
- /** Path relative to project root (e.g. src/types.ts). */
21
- relFile: string;
24
+ /** Path relative to project root (e.g. src/config/schema-types.ts). */
25
+ path: string;
22
26
  outfile: string;
23
27
  exportName: string;
24
28
  }
25
29
 
26
- const CONFIG_MARKER = "Config schema";
27
- const OUTPUT_MARKER = "JSON payload";
30
+ const SCHEMA_TYPES_FILE = "schema-types.ts";
31
+
32
+ const ROLE_EXPORT_RE = /export\s+type\s+(configType|inputType|outputType)\s*=\s*(\w+)/g;
28
33
 
29
- const INTERFACE_RE = /\/\*\*([\s\S]*?)\*\/\s*export\s+interface\s+(\w+)/g;
34
+ const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
35
+ configType: "config",
36
+ inputType: "input",
37
+ outputType: "output",
38
+ };
30
39
 
31
- function listTypesTsFiles(srcDir: string, baseDir: string, out: string[]): void {
40
+ function listSchemaTypesFiles(srcDir: string, baseDir: string, out: string[]): void {
32
41
  for (const ent of readdirSync(srcDir)) {
33
42
  const full = join(srcDir, ent);
34
43
  const st = statSync(full);
35
44
  if (st.isDirectory()) {
36
- listTypesTsFiles(full, baseDir, out);
45
+ listSchemaTypesFiles(full, baseDir, out);
37
46
  continue;
38
47
  }
39
- if (ent === "types.ts") {
48
+ if (ent === SCHEMA_TYPES_FILE) {
40
49
  out.push(relative(baseDir, full));
41
50
  }
42
51
  }
43
52
  }
44
53
 
45
- function classifyRoot(jsDoc: string, typeName: string, relFile: string): SchemaRoot | undefined {
46
- const hasConfig = jsDoc.includes(CONFIG_MARKER);
47
- const hasOutput = jsDoc.includes(OUTPUT_MARKER);
48
- if (hasConfig && hasOutput) {
49
- throw new Error(`${relFile}: ${typeName} has both Config schema and JSON payload markers`);
54
+ /** True when `typeName` is declared in this file (not a re-export alias to another module). */
55
+ function isTypeDefinedInFile(text: string, typeName: string): boolean {
56
+ if (new RegExp(`export\\s+interface\\s+${typeName}\\b`).test(text)) {
57
+ return true;
50
58
  }
51
- if (!hasConfig && !hasOutput) {
52
- return undefined;
59
+ if (new RegExp(`export\\s+type\\s+${typeName}\\s*=`).test(text)) {
60
+ return !["configType", "inputType", "outputType"].includes(typeName);
53
61
  }
54
- if (hasConfig) {
62
+ return false;
63
+ }
64
+
65
+ function rootForRole(role: SchemaRole, typeName: string, path: string): SchemaRoot {
66
+ const kind = ROLE_TO_KIND[role];
67
+ if (kind === "config") {
55
68
  return {
56
- kind: "config",
69
+ kind,
57
70
  typeName,
58
- relFile,
71
+ path,
59
72
  outfile: outfileForConfigType(typeName),
60
73
  exportName: configSchemaExportName(typeName),
61
74
  };
62
75
  }
76
+ if (kind === "input") {
77
+ return {
78
+ kind,
79
+ typeName,
80
+ path,
81
+ outfile: outfileForInputType(typeName),
82
+ exportName: inputSchemaExportName(typeName),
83
+ };
84
+ }
63
85
  return {
64
- kind: "output",
86
+ kind,
65
87
  typeName,
66
- relFile,
88
+ path,
67
89
  outfile: outfileForOutputType(typeName),
68
90
  exportName: outputSchemaExportName(typeName),
69
91
  };
70
92
  }
71
93
 
72
- /** Find all schema roots under `src/` in files named types.ts. */
94
+ function discoverFromFile(path: string, text: string): SchemaRoot[] {
95
+ const rolesSeen = new Set<SchemaRole>();
96
+ const roots: SchemaRoot[] = [];
97
+
98
+ for (const match of text.matchAll(ROLE_EXPORT_RE)) {
99
+ const role = match[1] as SchemaRole | undefined;
100
+ const typeName = match[2];
101
+ if (!role || !typeName) {
102
+ continue;
103
+ }
104
+ if (rolesSeen.has(role)) {
105
+ throw new Error(`${path}: duplicate export type ${role}`);
106
+ }
107
+ rolesSeen.add(role);
108
+ if (!isTypeDefinedInFile(text, typeName)) {
109
+ continue;
110
+ }
111
+ roots.push(rootForRole(role, typeName, path));
112
+ }
113
+
114
+ return roots;
115
+ }
116
+
117
+ /** Find all schema roots under `src/` in files named schema-types.ts. */
73
118
  export function discoverSchemaRoots(projectRoot: string): SchemaRoot[] {
74
119
  const srcDir = join(projectRoot, "src");
75
120
  const files: string[] = [];
76
- listTypesTsFiles(srcDir, projectRoot, files);
121
+ listSchemaTypesFiles(srcDir, projectRoot, files);
122
+
77
123
  const roots: SchemaRoot[] = [];
78
- for (const relFile of files.sort()) {
79
- const text = readFileSync(join(projectRoot, relFile), "utf8");
80
- for (const match of text.matchAll(INTERFACE_RE)) {
81
- const jsDoc = match[1] ?? "";
82
- const typeName = match[2];
83
- if (!typeName) {
84
- continue;
85
- }
86
- const root = classifyRoot(jsDoc, typeName, relFile);
87
- if (root) {
88
- roots.push(root);
124
+ const typeOwners = new Map<string, string>();
125
+
126
+ for (const relPath of files.sort()) {
127
+ const text = readFileSync(join(projectRoot, relPath), "utf8");
128
+ for (const root of discoverFromFile(relPath, text)) {
129
+ const prev = typeOwners.get(root.typeName);
130
+ if (prev) {
131
+ throw new Error(
132
+ `${relPath}: duplicate schema root type ${root.typeName} (already declared in ${prev})`,
133
+ );
89
134
  }
135
+ typeOwners.set(root.typeName, relPath);
136
+ roots.push(root);
90
137
  }
91
138
  }
139
+
140
+ const configRoots = roots.filter((r) => r.kind === "config");
141
+ if (configRoots.length > 1) {
142
+ throw new Error(
143
+ `multiple config schema roots: ${configRoots.map((r) => `${r.typeName} (${r.path})`).join(", ")}`,
144
+ );
145
+ }
146
+
92
147
  return roots;
93
148
  }
@@ -16,6 +16,23 @@ export function camelToScreamingSnake(name: string): string {
16
16
  return camelToKebab(name).replace(/-/g, "_").toUpperCase();
17
17
  }
18
18
 
19
+ /** Generated JSON filename for a tool-input schema root. */
20
+ export function outfileForInputType(typeName: string): string {
21
+ if (typeName.endsWith("ToolInput")) {
22
+ return `${camelToKebab(typeName.slice(0, -"ToolInput".length))}-tool-input.json`;
23
+ }
24
+ return `${camelToKebab(typeName)}-tool-input.json`;
25
+ }
26
+
27
+ /** Bridge export constant for a tool-input schema root. */
28
+ export function inputSchemaExportName(typeName: string): string {
29
+ if (typeName.endsWith("ToolInput")) {
30
+ const base = typeName.slice(0, -"ToolInput".length);
31
+ return `${camelToScreamingSnake(base)}_TOOL_INPUT_SCHEMA`;
32
+ }
33
+ return `${camelToScreamingSnake(typeName)}_TOOL_INPUT_SCHEMA`;
34
+ }
35
+
19
36
  /** Generated JSON filename for an output-schema root type. */
20
37
  export function outfileForOutputType(typeName: string): string {
21
38
  if (typeName.endsWith("JsonOutput")) {
@@ -1,5 +1,5 @@
1
1
  /*
2
- Generate JSON Schema artifacts and bridge modules from discovered types.ts roots.
2
+ Generate JSON Schema artifacts and bridge modules from discovered schema-types.ts roots.
3
3
  */
4
4
 
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
@@ -13,7 +13,7 @@ const generatedDir = join(projectRoot, "schemas", "generated");
13
13
 
14
14
  function generateJson(root: SchemaRoot): Record<string, unknown> {
15
15
  const generator = createGenerator({
16
- path: join(projectRoot, root.relFile),
16
+ path: join(projectRoot, root.path),
17
17
  type: root.typeName,
18
18
  tsconfig: join(projectRoot, "tsconfig.json"),
19
19
  topRef: false,
@@ -52,6 +52,7 @@ function writeBridge(path: string, scriptName: string, roots: SchemaRoot[], bann
52
52
 
53
53
  const roots = discoverSchemaRoots(projectRoot);
54
54
  const configRoots = roots.filter((r) => r.kind === "config");
55
+ const inputRoots = roots.filter((r) => r.kind === "input");
55
56
  const outputRoots = roots.filter((r) => r.kind === "output");
56
57
 
57
58
  mkdirSync(generatedDir, { recursive: true });
@@ -70,6 +71,14 @@ writeBridge(
70
71
  configRoots,
71
72
  "JSON Schema for program.appConfig.jsonSchema from",
72
73
  );
74
+ if (inputRoots.length > 0) {
75
+ writeBridge(
76
+ join(projectRoot, "schemas", "inputSchemas.ts"),
77
+ "schemagen.ts",
78
+ inputRoots,
79
+ "JSON Schema for leaf inputSchema from",
80
+ );
81
+ }
73
82
  writeBridge(
74
83
  join(projectRoot, "schemas", "outputSchemas.ts"),
75
84
  "schemagen.ts",
@@ -77,4 +86,6 @@ writeBridge(
77
86
  "JSON Schema for leaf outputSchema from",
78
87
  );
79
88
 
80
- console.log(`config roots: ${configRoots.length}, output roots: ${outputRoots.length}`);
89
+ console.log(
90
+ `config roots: ${configRoots.length}, input roots: ${inputRoots.length}, output roots: ${outputRoots.length}`,
91
+ );
@@ -0,0 +1,14 @@
1
+ /** JSON stdout for `full-example status --json`. */
2
+ export interface StatusJsonOutput {
3
+ /** Resolved AWS region. */
4
+ defaultRegion?: string;
5
+ /** Resolved retry count. */
6
+ maxRetries?: number;
7
+ /** Whether apiToken is set (value never included). */
8
+ apiTokenSet: boolean;
9
+ /** App version from program root. */
10
+ version: string;
11
+ }
12
+
13
+ /** Schemagen root for leaf outputSchema. */
14
+ export type outputType = StatusJsonOutput;
@@ -1,11 +1 @@
1
- /** JSON payload for `full-example status --json`. */
2
- export interface StatusJsonOutput {
3
- /** Resolved AWS region. */
4
- defaultRegion?: string;
5
- /** Resolved retry count. */
6
- maxRetries?: number;
7
- /** Whether apiToken is set (value never included). */
8
- apiTokenSet: boolean;
9
- /** App version from program root. */
10
- version: string;
11
- }
1
+ export type { StatusJsonOutput } from "./schema-types.ts";
@@ -1,6 +1,4 @@
1
1
  /**
2
- * Config schema
3
- *
4
2
  * Application settings for `full-example` (`program.appConfig`).
5
3
  */
6
4
  export interface AppConfig {
@@ -21,3 +19,6 @@ export interface AppConfig {
21
19
  ttl: number;
22
20
  };
23
21
  }
22
+
23
+ /** Schemagen root for program.appConfig.jsonSchema. */
24
+ export type configType = AppConfig;
package/package.json CHANGED
@@ -1,18 +1,13 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "6.0.0",
4
- "type": "module",
5
- "engines": {
6
- "bun": ">=1.3"
7
- },
8
- "scripts": {
9
- "//just": "echo this app uses justfile for development tasks"
10
- },
3
+ "version": "6.0.1",
11
4
  "main": "./src/index.ts",
12
5
  "module": "./src/index.ts",
13
- "types": "./index.d.ts",
14
- "bin": {
15
- "argsbarg": "src/cli-tool/main.ts"
6
+ "devDependencies": {
7
+ "@biomejs/biome": "^2.5.0",
8
+ "@types/bun": "^1.3.12",
9
+ "dts-bundle-generator": "^9.5.1",
10
+ "typescript": "^5.9.3"
16
11
  },
17
12
  "exports": {
18
13
  ".": {
@@ -20,6 +15,12 @@
20
15
  "default": "./src/index.ts"
21
16
  }
22
17
  },
18
+ "bin": {
19
+ "argsbarg": "src/cli-tool/main.ts"
20
+ },
21
+ "engines": {
22
+ "bun": ">=1.3"
23
+ },
23
24
  "files": [
24
25
  "src",
25
26
  "index.d.ts",
@@ -29,10 +30,9 @@
29
30
  "LICENSE",
30
31
  "CHANGELOG.md"
31
32
  ],
32
- "devDependencies": {
33
- "@biomejs/biome": "^2.5.0",
34
- "@types/bun": "^1.3.12",
35
- "dts-bundle-generator": "^9.5.1",
36
- "typescript": "^5.9.3"
37
- }
33
+ "scripts": {
34
+ "//just": "echo this app uses justfile for development tasks"
35
+ },
36
+ "type": "module",
37
+ "types": "./index.d.ts"
38
38
  }
@@ -4,6 +4,7 @@ Hand-built OpenAPI 3.1 document from exposed MCP tools.
4
4
 
5
5
  import { collectMcpTools } from "../mcp/tools.ts";
6
6
  import type { CliProgram } from "../types.ts";
7
+ import { dereferenceJsonSchema } from "./schema-deref.ts";
7
8
 
8
9
  const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
9
10
 
@@ -18,8 +19,9 @@ function buildSuccessResponse(tool: ReturnType<typeof collectMcpTools>[number]):
18
19
  const media: Record<string, unknown> = {};
19
20
 
20
21
  if (contentType.includes("application/json")) {
22
+ const outputSchema = tool.outputSchema ?? { type: "object" };
21
23
  media[contentType] = {
22
- schema: tool.outputSchema ?? { type: "object" },
24
+ schema: dereferenceJsonSchema(outputSchema),
23
25
  };
24
26
  } else if (contentType.includes("text/html")) {
25
27
  media[contentType] = { schema: { type: "string" } };
@@ -48,7 +50,7 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
48
50
  required: false,
49
51
  content: {
50
52
  [JSON_CONTENT_TYPE]: {
51
- schema: tool.inputSchema,
53
+ schema: dereferenceJsonSchema(tool.inputSchema),
52
54
  },
53
55
  },
54
56
  },
package/src/api/result.ts CHANGED
@@ -12,6 +12,21 @@ export interface ApiToolCallErrorBody {
12
12
  stderr?: string;
13
13
  }
14
14
 
15
+ /** Strips ANSI escape sequences from CLI-formatted text. */
16
+ export function stripAnsi(text: string): string {
17
+ const ansiEscape = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
18
+ return text.replace(ansiEscape, "");
19
+ }
20
+
21
+ /** Returns the first non-empty line of text with ANSI escapes removed. */
22
+ export function firstErrorLine(text: string): string {
23
+ const line = stripAnsi(text)
24
+ .split("\n")
25
+ .map((part) => part.trim())
26
+ .find((part) => part.length > 0);
27
+ return line ?? stripAnsi(text).trim();
28
+ }
29
+
15
30
  /** Wide-open CORS headers applied to all API responses. */
16
31
  export const API_CORS_HEADERS: Readonly<Record<string, string>> = {
17
32
  "access-control-allow-origin": "*",
@@ -82,8 +97,15 @@ export function apiDocsHtml(): string {
82
97
  <title>API Reference</title>
83
98
  </head>
84
99
  <body>
85
- <script id="api-reference" data-url="/openapi.json"></script>
100
+ <div id="api-reference"></div>
86
101
  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
102
+ <script>
103
+ Scalar.createApiReference("#api-reference", {
104
+ url: "/openapi.json",
105
+ orderSchemaPropertiesBy: "preserve",
106
+ orderRequiredPropertiesFirst: false,
107
+ });
108
+ </script>
87
109
  </body>
88
110
  </html>`;
89
111
  }
@@ -0,0 +1,99 @@
1
+ import { expect, test } from "bun:test";
2
+ import { dereferenceJsonSchema } from "./schema-deref.ts";
3
+
4
+ test("dereferenceJsonSchema inlines nested definitions", () => {
5
+ const schema = {
6
+ type: "object",
7
+ properties: {
8
+ invoice: { $ref: "#/definitions/InvoiceData" },
9
+ },
10
+ definitions: {
11
+ InvoiceData: {
12
+ type: "object",
13
+ properties: { id: { type: "string" } },
14
+ required: ["id"],
15
+ },
16
+ },
17
+ };
18
+ const out = dereferenceJsonSchema(schema);
19
+ expect(out.properties).toEqual({
20
+ invoice: {
21
+ type: "object",
22
+ properties: { id: { type: "string" } },
23
+ required: ["id"],
24
+ },
25
+ });
26
+ expect(out.definitions).toBeUndefined();
27
+ });
28
+
29
+ test("dereferenceJsonSchema supports $defs", () => {
30
+ const schema = {
31
+ type: "object",
32
+ properties: {
33
+ item: { $ref: "#/$defs/Item" },
34
+ },
35
+ $defs: {
36
+ Item: { type: "string" },
37
+ },
38
+ };
39
+ const out = dereferenceJsonSchema(schema);
40
+ expect(out.properties).toEqual({ item: { type: "string" } });
41
+ expect(out.$defs).toBeUndefined();
42
+ });
43
+
44
+ test("dereferenceJsonSchema merges $ref siblings", () => {
45
+ const schema = {
46
+ type: "object",
47
+ properties: {
48
+ invoice: {
49
+ $ref: "#/definitions/InvoiceData",
50
+ description: "Invoice payload",
51
+ },
52
+ },
53
+ definitions: {
54
+ InvoiceData: { type: "object" },
55
+ },
56
+ };
57
+ const out = dereferenceJsonSchema(schema) as {
58
+ properties: { invoice: { type: string; description: string } };
59
+ };
60
+ expect(out.properties.invoice).toEqual({
61
+ type: "object",
62
+ description: "Invoice payload",
63
+ });
64
+ });
65
+
66
+ test("dereferenceJsonSchema ignores circular refs", () => {
67
+ const schema = {
68
+ type: "object",
69
+ properties: {
70
+ self: { $ref: "#/definitions/Node" },
71
+ },
72
+ definitions: {
73
+ Node: {
74
+ type: "object",
75
+ properties: {
76
+ again: { $ref: "#/definitions/Node" },
77
+ },
78
+ },
79
+ },
80
+ };
81
+ const out = dereferenceJsonSchema(schema) as {
82
+ properties: { self: { type: string; properties: { again: { $ref: string } } } };
83
+ };
84
+ expect(out.properties.self.type).toBe("object");
85
+ expect(out.properties.self.properties.again).toEqual({ $ref: "#/definitions/Node" });
86
+ });
87
+
88
+ test("dereferenceJsonSchema leaves external refs unchanged", () => {
89
+ const schema = {
90
+ type: "object",
91
+ properties: {
92
+ remote: { $ref: "https://example.com/schema.json" },
93
+ },
94
+ };
95
+ const out = dereferenceJsonSchema(schema);
96
+ expect(out.properties).toEqual({
97
+ remote: { $ref: "https://example.com/schema.json" },
98
+ });
99
+ });
@@ -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
+ }
@@ -8,7 +8,7 @@ import { $ } from "bun";
8
8
  import { generateOpenApi } from "./api/openapi.ts";
9
9
  import { API_CORS_HEADERS } from "./api/result.ts";
10
10
  import { handleApiRequest } from "./api/server.ts";
11
- import { Cli, CliContext, type CliContext as CliContextType, CliOptionKind } from "./index.ts";
11
+ import { Cli, CliContext, type CliContext as CliContextType, CliOptionKind, cliErrWithHelp } from "./index.ts";
12
12
  import { nestedMcpFixture, testProgram } from "./test-fixtures.ts";
13
13
  import { cliValidateProgram } from "./validate.ts";
14
14
 
@@ -290,6 +290,37 @@ describe("HTTP API routes", () => {
290
290
  expect(res.status).toBe(400);
291
291
  const body = (await res.json()) as { error: string };
292
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" });
293
324
  });
294
325
 
295
326
  test("GET /openapi.json lists tool paths", async () => {
@@ -304,7 +335,10 @@ describe("HTTP API routes", () => {
304
335
  const res = await apiRequest(program, new Request("http://127.0.0.1/openapi-browser"));
305
336
  expect(res.status).toBe(200);
306
337
  expect(res.headers.get("content-type")).toContain("text/html");
307
- expect(await res.text()).toContain("@scalar/api-reference");
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");
308
342
  });
309
343
  });
310
344
 
@@ -319,6 +353,55 @@ test("generateOpenApi maps binary content types", () => {
319
353
  expect(pdf.schema.format).toBe("binary");
320
354
  });
321
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
+
322
405
  test("ctx.respond throws when called twice", () => {
323
406
  const program = testProgram({
324
407
  key: "app",
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`);
@@ -3,7 +3,7 @@ Auto MCP resources for user docs.topics when docs and MCP are both enabled.
3
3
  */
4
4
 
5
5
  import type { CliProgram } from "../types.ts";
6
- import { docsEnabled, docsTopicContent, docsTopicDescription, docsUserTopicKeys } from "./resolve.ts";
6
+ import { docsEnabled, docsTopicDescription, docsTopicText, docsUserTopicKeys } from "./resolve.ts";
7
7
 
8
8
  /** Default URI pattern for a docs topic MCP resource (`<mcpId>://docs/<topicKey>`). */
9
9
  export function defaultDocsTopicResourceUri(mcpId: string, topicKey: string): string {
@@ -45,7 +45,10 @@ export function docsMcpResources(program: CliProgram): {
45
45
  name: key,
46
46
  description: docsTopicDescription(key, topic.description),
47
47
  mimeType: "text/markdown",
48
- load: () => docsTopicContent(program, key),
48
+ load: () => {
49
+ const text = docsTopicText(program, key);
50
+ return text.endsWith("\n") ? text : `${text}\n`;
51
+ },
49
52
  };
50
53
  });
51
54
  }
@@ -2,7 +2,7 @@
2
2
  Shared headless tool dispatch for MCP and HTTP: config bootstrap, argv conversion, and invoke.
3
3
  */
4
4
 
5
- import { apiErrorResponse, apiSuccessResponse } from "../api/result.ts";
5
+ import { apiErrorResponse, apiSuccessResponse, firstErrorLine } from "../api/result.ts";
6
6
  import type { Cli, CliInvokeResult } from "../cli.ts";
7
7
  import { bootstrapAppConfig } from "../config/bootstrap.ts";
8
8
  import { formatMcpMissingConfigMessage, missingRequiredConfig } from "../config/resolve.ts";
@@ -137,11 +137,21 @@ export function headlessSuccessToHttpResponse(
137
137
 
138
138
  /** Maps a headless failure result to a JSON HTTP error Response. */
139
139
  export function headlessFailureToHttpResponse(result: HeadlessToolCallFailure): Response {
140
- const status = result.kind === "argv" || result.kind === "help" ? 400 : 500;
140
+ const status = resolveHttpErrorStatus(result);
141
141
  return apiErrorResponse(status, {
142
- error: result.message,
143
- exitCode: result.exitCode,
144
- stdout: result.stdout,
145
- stderr: result.stderr,
142
+ error: firstErrorLine(result.message),
146
143
  });
147
144
  }
145
+
146
+ function resolveHttpErrorStatus(result: HeadlessToolCallFailure): number {
147
+ if (result.kind === "argv" || result.kind === "help") {
148
+ return 400;
149
+ }
150
+ if (result.kind === "invoke" && result.message.includes("ctx.respond()")) {
151
+ return 500;
152
+ }
153
+ if (result.exitCode === 1) {
154
+ return 400;
155
+ }
156
+ return 500;
157
+ }