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 +15 -1
- package/README.md +3 -3
- package/docs/api-server.md +1 -1
- package/docs/config-schema.md +18 -8
- package/docs/output-schema.md +75 -44
- package/examples/full-example/README.md +7 -6
- package/examples/full-example/schemas/generated/app-config.json +1 -1
- package/examples/full-example/schemas/generated/status.json +1 -1
- package/examples/full-example/scripts/schemagen/discover-schema-roots.test.ts +3 -3
- package/examples/full-example/scripts/schemagen/discover-schema-roots.ts +90 -35
- package/examples/full-example/scripts/schemagen/naming.ts +17 -0
- package/examples/full-example/scripts/schemagen.ts +14 -3
- package/examples/full-example/src/commands/status/schema-types.ts +14 -0
- package/examples/full-example/src/commands/status/types.ts +1 -11
- package/examples/full-example/src/{types.ts → config/schema-types.ts} +3 -2
- package/package.json +17 -17
- package/src/api/openapi.ts +4 -2
- package/src/api/result.ts +23 -1
- package/src/api/schema-deref.test.ts +99 -0
- package/src/api/schema-deref.ts +76 -0
- package/src/api.integration.test.ts +85 -2
- package/src/cli-errors.ts +3 -0
- package/src/docs/mcp-resources.ts +5 -2
- package/src/headless/tool-call.ts +16 -6
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.
|
|
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 |
|
package/docs/api-server.md
CHANGED
|
@@ -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`).
|
|
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
|
|
package/docs/config-schema.md
CHANGED
|
@@ -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 [
|
|
146
|
-
|
|
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/
|
|
149
|
+
Script["scripts/schemagen.ts"]
|
|
151
150
|
Gen["ts-json-schema-generator"]
|
|
152
151
|
end
|
|
153
152
|
subgraph artifacts [Committed]
|
|
154
|
-
Json["
|
|
155
|
-
Bridge["
|
|
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
|
-
|
|
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 |
|
|
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 |
|
package/docs/output-schema.md
CHANGED
|
@@ -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 [
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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["
|
|
57
|
-
Script["
|
|
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["
|
|
62
|
-
Bridge["
|
|
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
|
-
|
|
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`;
|
|
79
|
-
| Artifacts | Commit `
|
|
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
|
|
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
|
|
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
|
-
###
|
|
89
|
+
### Declaring a schema root
|
|
91
90
|
|
|
92
|
-
Put schema-facing interfaces in **`
|
|
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
|
-
|
|
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
|
-
|
|
99
|
+
workspaces: WorkspaceStatus[];
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
/**
|
|
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
|
-
|
|
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
|
|
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` | `
|
|
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/
|
|
158
|
+
`scripts/schemagen.ts` rewrites `schemas/outputSchemas.ts` on every run:
|
|
126
159
|
|
|
127
160
|
```typescript
|
|
128
|
-
// Auto-generated by scripts/
|
|
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
|
|
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
|
|
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** `
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
- **`
|
|
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
|
|
181
|
-
2. `just schemagen` — refresh `
|
|
182
|
-
3. Import the bridge constant on the relevant leaf `outputSchema` fields.
|
|
183
|
-
4. Commit generated JSON and
|
|
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 `
|
|
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
|
|
63
|
+
## Schemagen roots
|
|
64
64
|
|
|
65
|
-
|
|
|
65
|
+
| Export in `schema-types.ts` | Artifact |
|
|
66
66
|
| --- | --- |
|
|
67
|
-
| `
|
|
68
|
-
| `
|
|
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": "
|
|
38
|
+
"description": "Application settings for `full-example` (`program.appConfig`).",
|
|
39
39
|
"definitions": {}
|
|
40
40
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
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
|
|
34
|
+
const ROLE_TO_KIND: Record<SchemaRole, SchemaRootKind> = {
|
|
35
|
+
configType: "config",
|
|
36
|
+
inputType: "input",
|
|
37
|
+
outputType: "output",
|
|
38
|
+
};
|
|
30
39
|
|
|
31
|
-
function
|
|
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
|
-
|
|
45
|
+
listSchemaTypesFiles(full, baseDir, out);
|
|
37
46
|
continue;
|
|
38
47
|
}
|
|
39
|
-
if (ent ===
|
|
48
|
+
if (ent === SCHEMA_TYPES_FILE) {
|
|
40
49
|
out.push(relative(baseDir, full));
|
|
41
50
|
}
|
|
42
51
|
}
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 (
|
|
52
|
-
return
|
|
59
|
+
if (new RegExp(`export\\s+type\\s+${typeName}\\s*=`).test(text)) {
|
|
60
|
+
return !["configType", "inputType", "outputType"].includes(typeName);
|
|
53
61
|
}
|
|
54
|
-
|
|
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
|
|
69
|
+
kind,
|
|
57
70
|
typeName,
|
|
58
|
-
|
|
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
|
|
86
|
+
kind,
|
|
65
87
|
typeName,
|
|
66
|
-
|
|
88
|
+
path,
|
|
67
89
|
outfile: outfileForOutputType(typeName),
|
|
68
90
|
exportName: outputSchemaExportName(typeName),
|
|
69
91
|
};
|
|
70
92
|
}
|
|
71
93
|
|
|
72
|
-
|
|
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
|
-
|
|
121
|
+
listSchemaTypesFiles(srcDir, projectRoot, files);
|
|
122
|
+
|
|
77
123
|
const roots: SchemaRoot[] = [];
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
"
|
|
14
|
-
|
|
15
|
-
"
|
|
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
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
33
|
+
"scripts": {
|
|
34
|
+
"//just": "echo this app uses justfile for development tasks"
|
|
35
|
+
},
|
|
36
|
+
"type": "module",
|
|
37
|
+
"types": "./index.d.ts"
|
|
38
38
|
}
|
package/src/api/openapi.ts
CHANGED
|
@@ -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:
|
|
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
|
-
<
|
|
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
|
-
|
|
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,
|
|
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: () =>
|
|
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
|
|
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
|
+
}
|