argsbarg 3.5.0 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -1
- package/README.md +24 -8
- package/docs/cli-program.md +75 -1
- package/docs/templates/cursor/rules/cli-program.mdc +15 -21
- package/index.d.ts +45 -0
- package/justfile +2 -5
- package/package.json +1 -1
- package/src/context.ts +91 -15
- package/src/docs/api-guide.ts +17 -3
- package/src/docs/mcp-guide.ts +3 -1
- package/src/formats.test.ts +35 -0
- package/src/formats.ts +135 -0
- package/src/index.test.ts +7 -7
- package/src/index.ts +13 -1
- package/src/mcp/tools.ts +67 -18
- package/src/parse.ts +33 -3
- package/src/types.ts +24 -0
- package/src/validate.ts +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.6.0] - 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`CliValueFormat`** — optional `format` on string options: `duration`, `comma-list`, `date`, `date-time`; optional `default` and `pattern` (mutually exclusive with `format`).
|
|
15
|
+
- **`CliContext`** — `durationOpt`, `commaListOpt`, `dateOpt`, `dateTimeOpt`, and `readLeafInputs()` for schema-driven handler reads.
|
|
16
|
+
- **`formats` exports** — `parseDurationMs`, `parseCommaList`, `parseDate`, `parseDateTime` for reuse outside handlers.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **Post-parse validation** — applies option `default` values and validates `format` / `pattern` before handlers run.
|
|
21
|
+
- **MCP varargs** — `tools/call` positional arrays must be JSON arrays (comma-separated strings no longer accepted).
|
|
22
|
+
- **MCP comma-list options** — `format: comma-list` accepts string or array in `tools/call`.
|
|
23
|
+
- **`docs mcp`**, **`docs api`**, and **`docs/cli-program.md`** — document value formats and varargs policy.
|
|
24
|
+
- **Cursor rule template** (`docs/templates/cursor/rules/cli-program.mdc`) — thin tripwire that directs agents to read `node_modules/argsbarg/docs/cli-program.md` instead of duplicating authoring guidance.
|
|
25
|
+
|
|
10
26
|
## [3.5.0] - 2026-06-22
|
|
11
27
|
|
|
12
28
|
### Added
|
|
@@ -371,7 +387,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
|
|
|
371
387
|
- 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`).
|
|
372
388
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
373
389
|
|
|
374
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.
|
|
390
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.6.0...HEAD
|
|
391
|
+
[3.6.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.6.0
|
|
375
392
|
[3.5.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.5.0
|
|
376
393
|
[3.4.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.4.2
|
|
377
394
|
[3.4.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.4.1
|
package/README.md
CHANGED
|
@@ -112,9 +112,9 @@ Opt in on the program root with `mcpServer: { enabled: true }`, then run `myapp
|
|
|
112
112
|
|
|
113
113
|
See **[docs/mcp.md](docs/mcp.md)** for configuration, env bootstrapping, custom resources, Cursor setup, and protocol details. See **[docs/cli-program.md](docs/cli-program.md)** for schema authoring (consumer apps: copy **`docs/templates/cursor/rules/cli-program.mdc`** to **`.cursor/rules/cli-program.mdc`**).
|
|
114
114
|
|
|
115
|
-
### Install
|
|
115
|
+
### Install CLI
|
|
116
116
|
|
|
117
|
-
After `bun build --compile` (or when running via `bun`), ship your CLI and let users run:
|
|
117
|
+
argsbarg includes CLI features to manage installation of your compiled bun app. After `bun build --compile` (or when running via `bun`), ship your CLI and let users run:
|
|
118
118
|
|
|
119
119
|
```bash
|
|
120
120
|
myapp install --all --yes
|
|
@@ -140,12 +140,23 @@ myapp completion fish > ~/.config/fish/completions/myapp.fish
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
|
|
143
|
-
##
|
|
143
|
+
## Quick Start
|
|
144
144
|
|
|
145
145
|
```bash
|
|
146
|
-
bun add
|
|
146
|
+
bun add argsbarg
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
### Cursor / AI agents
|
|
150
|
+
|
|
151
|
+
Argsbarg ships authoring docs in `node_modules/argsbarg/docs/`. Agents do not load them unless your repo points there — copy the thin Cursor rule after install (it tells agents to **read** `cli-program.md`, not duplicate it):
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
mkdir -p .cursor/rules
|
|
155
|
+
cp node_modules/argsbarg/docs/templates/cursor/rules/cli-program.mdc .cursor/rules/cli-program.mdc
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Add app-specific conventions in a second rule if needed. Full guide: **[docs/cli-program.md](docs/cli-program.md)**.
|
|
159
|
+
|
|
149
160
|
|
|
150
161
|
## How it works
|
|
151
162
|
|
|
@@ -178,9 +189,13 @@ Add `CliPositional` entries to the command’s `positionals` list (separate from
|
|
|
178
189
|
|
|
179
190
|
### Reading values (`CliContext`)
|
|
180
191
|
|
|
181
|
-
- `ctx.flag("verbose")` — presence options (`boolean`).
|
|
192
|
+
- `ctx.flag("verbose")` / `ctx.hasFlag("verbose")` — presence options (`boolean`).
|
|
182
193
|
- `ctx.stringOpt("name")` / `ctx.numberOpt("count")` — `string | undefined` / `number | null`.
|
|
183
|
-
- `ctx.
|
|
194
|
+
- `ctx.durationOpt("timeout")` — duration options (`format: CliValueFormat.Duration`) as milliseconds.
|
|
195
|
+
- `ctx.commaListOpt("services")` — comma-list options as `string[] | undefined`.
|
|
196
|
+
- `ctx.dateOpt("on")` / `ctx.dateTimeOpt("since")` — ISO date / date-time options.
|
|
197
|
+
- `ctx.readLeafInputs()` — coerced option and positional values for the current leaf (schema-driven).
|
|
198
|
+
- `ctx.typedOpt<T>("custom", parseFn)` — custom parsing for type-safe option resolution.
|
|
184
199
|
- `ctx.args` — positional words in order as `string[]`.
|
|
185
200
|
- `ctx.positional("name")` — named positional lookup; varargs slots return `string[]`, single slots return `string | undefined`.
|
|
186
201
|
- `ctx.program` — program root (`CliProgram`) for contextual help.
|
|
@@ -221,12 +236,13 @@ The package root (`argsbarg` / `src/index.ts`) exports the types and runtime you
|
|
|
221
236
|
| Symbol | Role |
|
|
222
237
|
| --- | --- |
|
|
223
238
|
| `CliProgram`, `CliOption`, `CliPositional`, `CliHandler` | Schema and handler types. |
|
|
224
|
-
| `CliOptionKind`, `CliFallbackMode` | Option kinds (`
|
|
239
|
+
| `CliOptionKind`, `CliValueFormat`, `CliFallbackMode` | Option kinds, value formats (`duration`, `comma-list`, `date`, `date-time`), and root fallback behavior. |
|
|
225
240
|
| `CliSchemaValidationError` | Thrown when the static command tree violates schema rules. |
|
|
226
|
-
| `CliContext` | Handler context (`ctx.flag`, `ctx.stringOpt`, `ctx.
|
|
241
|
+
| `CliContext` | Handler context (`ctx.flag`, `ctx.stringOpt`, `ctx.durationOpt`, `ctx.readLeafInputs`, `ctx.invocation`, …). |
|
|
227
242
|
| `cliRun(root, [argv])` | Validate, parse argv, dispatch, exit. |
|
|
228
243
|
| `cliInvoke(root, argv)` | Parse and dispatch without exiting; returns captured stdout/stderr. |
|
|
229
244
|
| `cliErrWithHelp(ctx, msg)` | Print error + scoped help on stderr, exit 1. |
|
|
245
|
+
| `parseDurationMs`, `parseCommaList`, `parseDate`, `parseDateTime` | Optional format parsers for use outside handlers. |
|
|
230
246
|
|
|
231
247
|
Reserved identifiers (validated at startup): root commands **`completion`**, **`version`**, **`install`**, **`docs`** (when `docs.enabled` is `true`), and **`mcp`** (when `mcpServer.enabled` is `true`).
|
|
232
248
|
|
package/docs/cli-program.md
CHANGED
|
@@ -136,6 +136,65 @@ Do **not** use `mcpTool.description` to paper over missing `--yes`, non-standard
|
|
|
136
136
|
|
|
137
137
|
If help text and MCP behavior match after your fixes, **omit `mcpTool` entirely**.
|
|
138
138
|
|
|
139
|
+
## Value formats
|
|
140
|
+
|
|
141
|
+
On **string options**, optional metadata improves validation, MCP `inputSchema`, and handler reads:
|
|
142
|
+
|
|
143
|
+
| Field | Purpose |
|
|
144
|
+
| --- | --- |
|
|
145
|
+
| `format: CliValueFormat.Duration` | Values like `30s`, `20m`, `1h`; read with `ctx.durationOpt(name)` (milliseconds) |
|
|
146
|
+
| `format: CliValueFormat.CommaList` | Single-flag lists (`--services a,b`); MCP may pass string or array; read with `ctx.commaListOpt(name)` |
|
|
147
|
+
| `format: CliValueFormat.Date` | `YYYY-MM-DD`; read with `ctx.dateOpt(name)` |
|
|
148
|
+
| `format: CliValueFormat.DateTime` | RFC 3339 instant; read with `ctx.dateTimeOpt(name)` |
|
|
149
|
+
| `default: "..."` | Applied in post-parse when the option is omitted (not valid with `required: true`) |
|
|
150
|
+
| `pattern: "..."` | Regex validation (mutually exclusive with `format`) |
|
|
151
|
+
|
|
152
|
+
`format` applies to **string options only** — not positionals. Post-parse keeps raw strings in `ctx.opts`; typed readers return coerced values.
|
|
153
|
+
|
|
154
|
+
**Example** (duration with default, comma-list flag):
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { CliOptionKind, CliValueFormat } from "argsbarg";
|
|
158
|
+
|
|
159
|
+
options: [
|
|
160
|
+
{
|
|
161
|
+
name: "timeout",
|
|
162
|
+
description: "Maximum wait time.",
|
|
163
|
+
kind: CliOptionKind.String,
|
|
164
|
+
format: CliValueFormat.Duration,
|
|
165
|
+
default: "20m",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "services",
|
|
169
|
+
description: "Service names to reset (single env only).",
|
|
170
|
+
kind: CliOptionKind.String,
|
|
171
|
+
format: CliValueFormat.CommaList,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
handler: async (ctx) => {
|
|
175
|
+
const timeoutMs = ctx.durationOpt("timeout")!; // always set via default
|
|
176
|
+
const services = ctx.commaListOpt("services"); // string[] | undefined
|
|
177
|
+
},
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Varargs positionals** (`argMax: 0`):
|
|
181
|
+
|
|
182
|
+
| Surface | Multiple values |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| CLI | Space-separated words: `myapp uids uid-a uid-b` |
|
|
185
|
+
| MCP | JSON array on the positional key: `{ "uids": ["uid-a", "uid-b"] }` |
|
|
186
|
+
|
|
187
|
+
Read varargs with `ctx.positional("uids")` (returns `string[]`) or `ctx.args`. Do not comma-split argv tokens or use `format` on positionals.
|
|
188
|
+
|
|
189
|
+
**`readLeafInputs()`** — for leaves with several flags, one schema-driven read instead of hand-rolled `hasFlag` / `stringOpt` lines:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
const { limit, "skip-readiness": skipReadiness, timeout } = ctx.readLeafInputs();
|
|
193
|
+
// duration → number (ms); comma-list → string[]; presence → boolean; number → number
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Cross-field rules (e.g. `--match-remote` requires `--branch`) stay in consumer `resolve*` layers — argsbarg does not validate those.
|
|
197
|
+
|
|
139
198
|
## Headless-capable handlers
|
|
140
199
|
|
|
141
200
|
Simple leaves (read args, print stdout) are already headless — no extra work. **Any handler that might mount Ink, prompt, or open a browser should also implement a scriptable fast path** for:
|
|
@@ -219,7 +278,22 @@ Do not declare user commands named `completion`, `install`, `mcp`, `version`, or
|
|
|
219
278
|
|
|
220
279
|
## Cursor rule for consumer repos
|
|
221
280
|
|
|
222
|
-
|
|
281
|
+
Argsbarg ships framework docs under `node_modules/argsbarg/docs/` (same files as this repo’s `docs/`). **This file is the authoritative guide** — the Cursor rule is a thin tripwire that tells agents to read it.
|
|
282
|
+
|
|
283
|
+
Agents do **not** discover package docs automatically. Wire them in after `bun add argsbarg`:
|
|
284
|
+
|
|
285
|
+
1. **Copy the Cursor rule** (recommended):
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
mkdir -p .cursor/rules
|
|
289
|
+
cp node_modules/argsbarg/docs/templates/cursor/rules/cli-program.mdc .cursor/rules/cli-program.mdc
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
The template is ~25 lines: when to read which doc, plus hard rules agents often get wrong. It does **not** duplicate this guide — re-copy after upgrading argsbarg when the template changes.
|
|
293
|
+
|
|
294
|
+
2. **Optional:** a second rule for app-only conventions (e.g. `src/cli/shared.ts` flag names, JSON-only handlers, Ink patterns).
|
|
295
|
+
|
|
296
|
+
3. **Optional:** `.cursor/argsbarg.mdc` or `AGENTS.md` pointing at `node_modules/argsbarg/docs/cli-program.md` for broader context.
|
|
223
297
|
|
|
224
298
|
## See also
|
|
225
299
|
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Argsbarg
|
|
3
|
-
globs: "
|
|
2
|
+
description: Argsbarg schema — read framework docs before editing CLI commands
|
|
3
|
+
globs: "src/**/commands/**/*.{ts,tsx},src/index.{ts,tsx}"
|
|
4
4
|
alwaysApply: false
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
When adding or changing argsbarg schema, leaf handlers, or MCP exposure:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- Reserved root commands (do not declare): `completion`, `install`, `mcp`, `version`, `docs`, `update`
|
|
9
|
+
1. **Read** `node_modules/argsbarg/docs/cli-program.md` (required — authoritative guide).
|
|
10
|
+
2. MCP tools, varargs, `inputSchema` → also `node_modules/argsbarg/docs/mcp.md`.
|
|
11
|
+
3. `install`, completions, skills → `node_modules/argsbarg/docs/install.md`.
|
|
12
|
+
4. Bundled `docs` built-in → `node_modules/argsbarg/docs/bundled-docs.md`.
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
- **Inline** options, positionals, and handler schema; extract to `export const …Command = { … } satisfies CliLeaf` (or router type) when shared flags, `mcpTool` spreads, or large handlers justify a module — not zero-arg wrapper functions
|
|
17
|
-
- Leaf descriptions: action-oriented, not UI jargon
|
|
18
|
-
- Prefer option names `yes`, `dry-run`, `json` when semantics match; describe non-interactive use on the option
|
|
14
|
+
**Hard rules** (details and examples are in the docs above — do not contradict them):
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
- Reserved root commands: `completion`, `install`, `mcp`, `version`, `docs`, `update`.
|
|
17
|
+
- `satisfies CliProgram` / `CliLeaf`; action-oriented `description` on root, commands, options, and positionals.
|
|
18
|
+
- Omit `mcpTool` unless genuinely CLI-only (`enabled: false`), `requiresEnv`, or an irreducible wire limit — fix schema and headless handlers first.
|
|
19
|
+
- Interactive leaves: one headless path for MCP, non-TTY CLI, and `--yes` / `--dry-run` / `--json` (`shouldRunHeadless*`, `requireYesInNonTty`); not raw `isTTY`.
|
|
20
|
+
- String options: `format` / `default` / `pattern` per `cli-program.md`; `readLeafInputs()` for multi-flag leaves.
|
|
21
|
+
- Varargs (`argMax: 0`): CLI space-separated; MCP JSON array only — no comma-splitting positionals.
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
Interactive commands need one fast path for MCP, non-TTY CLI, and `--yes` / `--dry-run` / `--json`.
|
|
26
|
-
- Mutators: `requireYesInNonTty`, `shouldRunHeadlessWithYes`
|
|
27
|
-
- Queries: `shouldRunHeadless`, `wantsExplicitJson`
|
|
28
|
-
- Varargs/positionals: `shouldRunHeadlessWithPositionals`
|
|
29
|
-
- `ctx.invocation === "mcp"` only for subprocess/TTY wire behavior; use argsbarg headless helpers, not raw `isTTY`
|
|
23
|
+
**App-specific conventions:** add below or in a separate `.cursor/rules/` file (shared flags path, Ink vs JSON-only, etc.).
|
package/index.d.ts
CHANGED
|
@@ -17,6 +17,20 @@ export declare enum CliOptionKind {
|
|
|
17
17
|
/** Fixed set of allowed string values. Requires non-empty `choices` on the option. */
|
|
18
18
|
Enum = "enum"
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Named validation/coercion for string options (`format` on `CliOption`).
|
|
22
|
+
* Positionals do not use `format`; varargs use space-separated CLI tokens and JSON arrays over MCP.
|
|
23
|
+
*/
|
|
24
|
+
export declare enum CliValueFormat {
|
|
25
|
+
/** Duration text such as `30s`, `20m`, `1h`, `2d` (default unit minutes when omitted). */
|
|
26
|
+
Duration = "duration",
|
|
27
|
+
/** Comma-separated list on a single option value (`--services a,b`). */
|
|
28
|
+
CommaList = "comma-list",
|
|
29
|
+
/** Calendar date `YYYY-MM-DD`. */
|
|
30
|
+
Date = "date",
|
|
31
|
+
/** RFC 3339 instant with `Z` or numeric offset. */
|
|
32
|
+
DateTime = "date-time"
|
|
33
|
+
}
|
|
20
34
|
/**
|
|
21
35
|
* When `fallbackCommand` is used for missing or unknown subcommand tokens at a routing node.
|
|
22
36
|
*/
|
|
@@ -56,6 +70,15 @@ export interface CliOption {
|
|
|
56
70
|
* Must be a non-empty array of distinct non-empty strings.
|
|
57
71
|
*/
|
|
58
72
|
choices?: string[];
|
|
73
|
+
/**
|
|
74
|
+
* Named string validation for `kind: String` options. Mutually exclusive with `pattern`.
|
|
75
|
+
* Not supported on positionals.
|
|
76
|
+
*/
|
|
77
|
+
format?: CliValueFormat;
|
|
78
|
+
/** Default value applied in post-parse when the option is omitted. */
|
|
79
|
+
default?: string;
|
|
80
|
+
/** Regex pattern for string options. Mutually exclusive with `format`. */
|
|
81
|
+
pattern?: string;
|
|
59
82
|
}
|
|
60
83
|
/**
|
|
61
84
|
* An ordered positional argument slot, listed on leaf `positionals`.
|
|
@@ -280,6 +303,8 @@ export declare class CliSchemaValidationError extends Error {
|
|
|
280
303
|
/** Creates a schema validation error with a human-readable rule violation. */
|
|
281
304
|
constructor(message: string);
|
|
282
305
|
}
|
|
306
|
+
/** Coerced leaf inputs keyed by option and positional names. */
|
|
307
|
+
export type CliLeafInputs = Record<string, boolean | number | string | string[] | undefined>;
|
|
283
308
|
/**
|
|
284
309
|
* Values passed to a leaf command handler after parsing: app name, routed path, args, and merged options.
|
|
285
310
|
*/
|
|
@@ -303,11 +328,31 @@ export declare class CliContext {
|
|
|
303
328
|
* This is the TypeScript-native advantage over the Swift version.
|
|
304
329
|
*/
|
|
305
330
|
typedOpt<T>(name: string, parse: (s: string) => T): T | null;
|
|
331
|
+
/** Duration option in milliseconds (post-parse validated). */
|
|
332
|
+
durationOpt(name: string): number | undefined;
|
|
333
|
+
/** Comma-list option as a string array (post-parse validated). */
|
|
334
|
+
commaListOpt(name: string): string[] | undefined;
|
|
335
|
+
/** Date option as canonical YYYY-MM-DD (post-parse validated). */
|
|
336
|
+
dateOpt(name: string): string | undefined;
|
|
337
|
+
/** Date-time option as normalized ISO 8601 UTC (post-parse validated). */
|
|
338
|
+
dateTimeOpt(name: string): string | undefined;
|
|
306
339
|
/** Returns the value(s) for a named positional slot. Varargs slots return string[]; single slots return string | undefined. */
|
|
307
340
|
positional(name: string): string | string[] | undefined;
|
|
341
|
+
/** Reads coerced option and positional values for the current leaf from schema metadata. */
|
|
342
|
+
readLeafInputs(): CliLeafInputs;
|
|
343
|
+
private _readOptionValue;
|
|
344
|
+
private _leafNode;
|
|
308
345
|
private _posMap;
|
|
309
346
|
private _positionalMap;
|
|
310
347
|
}
|
|
348
|
+
/** Parses a duration string (e.g. 20m, 1h, 30s) into milliseconds. */
|
|
349
|
+
export declare function parseDurationMs(durationStr: string): number;
|
|
350
|
+
/** Splits a comma-separated string into trimmed non-empty tokens. */
|
|
351
|
+
export declare function parseCommaList(s: string): string[];
|
|
352
|
+
/** Returns canonical YYYY-MM-DD after validation. */
|
|
353
|
+
export declare function parseDate(s: string): string;
|
|
354
|
+
/** Returns normalized ISO 8601 UTC after validation. */
|
|
355
|
+
export declare function parseDateTime(s: string): string;
|
|
311
356
|
/** Minimal context for headless routing helpers. */
|
|
312
357
|
export type HeadlessContext = Pick<CliContext, "invocation">;
|
|
313
358
|
/** True when `--json` was passed or the handler was invoked via MCP. */
|
package/justfile
CHANGED
|
@@ -13,14 +13,11 @@ consumers-sync *apps:
|
|
|
13
13
|
#!/usr/bin/env bash
|
|
14
14
|
root="$(cd "{{justfile_directory()}}" && pwd)"
|
|
15
15
|
ss="$root/../../ss"
|
|
16
|
-
apps=(
|
|
17
|
-
if [[ ${#apps[@]} -eq 0 ]]; then
|
|
18
|
-
apps=(idp-trees sqsp-qa-tools sqsp-i18n-tools)
|
|
19
|
-
fi
|
|
16
|
+
apps=(idp-trees sqsp-qa-tools sqsp-i18n-tools)
|
|
20
17
|
for app in "${apps[@]}"; do
|
|
21
18
|
dir="$(cd "$ss/$app" && pwd)"
|
|
22
19
|
echo "==> $app ($dir)"
|
|
23
|
-
(cd "$dir" &&
|
|
20
|
+
(cd "$dir" && bun i argsbarg@latest && just build && just docgen && just install)
|
|
24
21
|
done
|
|
25
22
|
|
|
26
23
|
# run the minimal example
|
package/package.json
CHANGED
package/src/context.ts
CHANGED
|
@@ -7,10 +7,15 @@ It keeps handlers small with a typed read API for flags, strings, numbers, and c
|
|
|
7
7
|
parsed values.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
10
|
+
import { parseCommaList, parseDate, parseDateTime, parseDurationMs } from "./formats.ts";
|
|
11
|
+
import { collectOptionDefs } from "./parse.ts";
|
|
12
|
+
import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram } from "./types.ts";
|
|
13
|
+
import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter } from "./types.ts";
|
|
12
14
|
import { strictParseDouble } from "./utils.ts";
|
|
13
15
|
|
|
16
|
+
/** Coerced leaf inputs keyed by option and positional names. */
|
|
17
|
+
export type CliLeafInputs = Record<string, boolean | number | string | string[] | undefined>;
|
|
18
|
+
|
|
14
19
|
/**
|
|
15
20
|
* Values passed to a leaf command handler after parsing: app name, routed path, args, and merged options.
|
|
16
21
|
*/
|
|
@@ -70,38 +75,109 @@ export class CliContext {
|
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/** Duration option in milliseconds (post-parse validated). */
|
|
79
|
+
durationOpt(name: string): number | undefined {
|
|
80
|
+
const s = this.opts[name];
|
|
81
|
+
if (s === undefined) return undefined;
|
|
82
|
+
return parseDurationMs(s);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Comma-list option as a string array (post-parse validated). */
|
|
86
|
+
commaListOpt(name: string): string[] | undefined {
|
|
87
|
+
const s = this.opts[name];
|
|
88
|
+
if (s === undefined) return undefined;
|
|
89
|
+
return parseCommaList(s);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Date option as canonical YYYY-MM-DD (post-parse validated). */
|
|
93
|
+
dateOpt(name: string): string | undefined {
|
|
94
|
+
const s = this.opts[name];
|
|
95
|
+
if (s === undefined) return undefined;
|
|
96
|
+
return parseDate(s);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Date-time option as normalized ISO 8601 UTC (post-parse validated). */
|
|
100
|
+
dateTimeOpt(name: string): string | undefined {
|
|
101
|
+
const s = this.opts[name];
|
|
102
|
+
if (s === undefined) return undefined;
|
|
103
|
+
return parseDateTime(s);
|
|
104
|
+
}
|
|
105
|
+
|
|
73
106
|
/** Returns the value(s) for a named positional slot. Varargs slots return string[]; single slots return string | undefined. */
|
|
74
107
|
positional(name: string): string | string[] | undefined {
|
|
75
108
|
return this._positionalMap()[name];
|
|
76
109
|
}
|
|
77
110
|
|
|
78
|
-
|
|
111
|
+
/** Reads coerced option and positional values for the current leaf from schema metadata. */
|
|
112
|
+
readLeafInputs(): CliLeafInputs {
|
|
113
|
+
const leaf = this._leafNode();
|
|
114
|
+
if (!leaf) return {};
|
|
79
115
|
|
|
80
|
-
|
|
81
|
-
|
|
116
|
+
const out: CliLeafInputs = {};
|
|
117
|
+
for (const opt of collectOptionDefs(this.program, this.commandPath)) {
|
|
118
|
+
out[opt.name] = this._readOptionValue(opt);
|
|
119
|
+
}
|
|
120
|
+
for (const p of leaf.positionals ?? []) {
|
|
121
|
+
const val = this.positional(p.name);
|
|
122
|
+
if (val === undefined) {
|
|
123
|
+
out[p.name] = undefined;
|
|
124
|
+
} else if (Array.isArray(val)) {
|
|
125
|
+
out[p.name] = val;
|
|
126
|
+
} else {
|
|
127
|
+
out[p.name] = val;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private _readOptionValue(opt: CliOption): boolean | number | string | string[] | undefined {
|
|
134
|
+
if (opt.kind === CliOptionKind.Presence) {
|
|
135
|
+
return this.hasFlag(opt.name);
|
|
136
|
+
}
|
|
137
|
+
if (opt.kind === CliOptionKind.Number) {
|
|
138
|
+
const n = this.numberOpt(opt.name);
|
|
139
|
+
return n === null ? undefined : n;
|
|
140
|
+
}
|
|
141
|
+
if (opt.format === CliValueFormat.Duration) {
|
|
142
|
+
return this.durationOpt(opt.name);
|
|
143
|
+
}
|
|
144
|
+
if (opt.format === CliValueFormat.CommaList) {
|
|
145
|
+
return this.commaListOpt(opt.name);
|
|
146
|
+
}
|
|
147
|
+
if (opt.format === CliValueFormat.Date) {
|
|
148
|
+
return this.dateOpt(opt.name);
|
|
149
|
+
}
|
|
150
|
+
if (opt.format === CliValueFormat.DateTime) {
|
|
151
|
+
return this.dateTimeOpt(opt.name);
|
|
152
|
+
}
|
|
153
|
+
return this.stringOpt(opt.name);
|
|
154
|
+
}
|
|
82
155
|
|
|
156
|
+
private _leafNode(): CliLeaf | undefined {
|
|
83
157
|
let node: CliNode = this.program;
|
|
84
158
|
for (const seg of this.commandPath) {
|
|
85
|
-
if (!isCliRouter(node))
|
|
86
|
-
this._posMap = {};
|
|
87
|
-
return {};
|
|
88
|
-
}
|
|
159
|
+
if (!isCliRouter(node)) return undefined;
|
|
89
160
|
const child = node.commands.find((c) => c.key === seg);
|
|
90
|
-
if (!child)
|
|
91
|
-
this._posMap = {};
|
|
92
|
-
return {};
|
|
93
|
-
}
|
|
161
|
+
if (!child) return undefined;
|
|
94
162
|
node = child;
|
|
95
163
|
}
|
|
164
|
+
return isCliLeaf(node) ? node : undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private _posMap: Record<string, string | string[]> | undefined;
|
|
168
|
+
|
|
169
|
+
private _positionalMap(): Record<string, string | string[]> {
|
|
170
|
+
if (this._posMap) return this._posMap;
|
|
96
171
|
|
|
97
|
-
|
|
172
|
+
const leaf = this._leafNode();
|
|
173
|
+
if (!leaf) {
|
|
98
174
|
this._posMap = {};
|
|
99
175
|
return {};
|
|
100
176
|
}
|
|
101
177
|
|
|
102
178
|
const map: Record<string, string | string[]> = {};
|
|
103
179
|
let argIdx = 0;
|
|
104
|
-
for (const p of
|
|
180
|
+
for (const p of leaf.positionals ?? []) {
|
|
105
181
|
const { argMax = 1 } = p;
|
|
106
182
|
if (argMax === 0) {
|
|
107
183
|
map[p.name] = this.args.slice(argIdx);
|
package/src/docs/api-guide.ts
CHANGED
|
@@ -23,6 +23,20 @@ function optionType(opt: CliOption): string {
|
|
|
23
23
|
return opt.kind;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function optionFormatDefault(opt: CliOption): string {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
if (opt.format !== undefined) {
|
|
29
|
+
parts.push(opt.format);
|
|
30
|
+
}
|
|
31
|
+
if (opt.default !== undefined) {
|
|
32
|
+
parts.push(`default \`${opt.default}\``);
|
|
33
|
+
}
|
|
34
|
+
if (opt.pattern !== undefined) {
|
|
35
|
+
parts.push(`pattern \`${opt.pattern}\``);
|
|
36
|
+
}
|
|
37
|
+
return parts.length > 0 ? parts.join("; ") : "—";
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
/** Markdown table cell for one option flag. */
|
|
27
41
|
function optionLabel(opt: CliOption): string {
|
|
28
42
|
const long = `\`--${opt.name}\``;
|
|
@@ -33,7 +47,7 @@ function optionLabel(opt: CliOption): string {
|
|
|
33
47
|
/** One options table row. */
|
|
34
48
|
function formatOptionRow(opt: CliOption): string {
|
|
35
49
|
const req = opt.required ? "required" : "optional";
|
|
36
|
-
return `| ${optionLabel(opt)} | ${optionType(opt)} | ${req} | ${opt.description} |`;
|
|
50
|
+
return `| ${optionLabel(opt)} | ${optionType(opt)} | ${req} | ${optionFormatDefault(opt)} | ${opt.description} |`;
|
|
37
51
|
}
|
|
38
52
|
|
|
39
53
|
/** One positionals table row. */
|
|
@@ -99,8 +113,8 @@ function renderCommandNode(
|
|
|
99
113
|
|
|
100
114
|
if ((node.options ?? []).length > 0) {
|
|
101
115
|
lines.push("#### Options", "");
|
|
102
|
-
lines.push("| Option | Type | Required | Description |");
|
|
103
|
-
lines.push("| --- | --- | --- | --- |");
|
|
116
|
+
lines.push("| Option | Type | Required | Format / default | Description |");
|
|
117
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
104
118
|
for (const opt of node.options ?? []) {
|
|
105
119
|
lines.push(formatOptionRow(opt));
|
|
106
120
|
}
|
package/src/docs/mcp-guide.ts
CHANGED
|
@@ -206,7 +206,9 @@ export function generateMcpGuide(root: CliProgram): string {
|
|
|
206
206
|
"Arguments are a flat JSON object keyed by long option and positional names (hyphenated option names are valid keys).",
|
|
207
207
|
`See \`${root.key} docs schema\` or the schema resource for per-tool shapes.`,
|
|
208
208
|
"",
|
|
209
|
-
"Varargs positionals accept a JSON array
|
|
209
|
+
"Varargs positionals accept a JSON array of strings (not a comma-separated string).",
|
|
210
|
+
"Options with `format: comma-list` accept a comma-separated string or JSON array.",
|
|
211
|
+
"Options with a schema `default` are applied when omitted.",
|
|
210
212
|
"",
|
|
211
213
|
"## Protocol",
|
|
212
214
|
"",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseCommaList,
|
|
4
|
+
parseDate,
|
|
5
|
+
parseDateTime,
|
|
6
|
+
parseDurationMs,
|
|
7
|
+
validateFormatValue,
|
|
8
|
+
} from "./formats.ts";
|
|
9
|
+
import { CliValueFormat } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
test("parseDurationMs parses minutes and hours", () => {
|
|
12
|
+
expect(parseDurationMs("30s")).toBe(30_000);
|
|
13
|
+
expect(parseDurationMs("5m")).toBe(5 * 60 * 1000);
|
|
14
|
+
expect(parseDurationMs("2h")).toBe(2 * 60 * 60 * 1000);
|
|
15
|
+
expect(parseDurationMs("1d")).toBe(24 * 60 * 60 * 1000);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("parseCommaList splits and trims", () => {
|
|
19
|
+
expect(parseCommaList("a,b")).toEqual(["a", "b"]);
|
|
20
|
+
expect(parseCommaList(" a , b , ")).toEqual(["a", "b"]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("parseDate validates calendar dates", () => {
|
|
24
|
+
expect(parseDate("2026-06-22")).toBe("2026-06-22");
|
|
25
|
+
expect(() => parseDate("2026-02-30")).toThrow();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("parseDateTime normalizes to UTC ISO", () => {
|
|
29
|
+
expect(parseDateTime("2026-06-22T15:00:00Z")).toBe("2026-06-22T15:00:00.000Z");
|
|
30
|
+
expect(() => parseDateTime("2026-06-22")).toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("validateFormatValue rejects invalid duration", () => {
|
|
34
|
+
expect(() => validateFormatValue("nope", CliValueFormat.Duration)).toThrow();
|
|
35
|
+
});
|
package/src/formats.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Named string format validation and parsing for CLI options.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CliValueFormat } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
const DURATION_RE = /^\d+[hdms]?$/i;
|
|
8
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
9
|
+
|
|
10
|
+
/** Parses a duration string (e.g. 20m, 1h, 30s) into milliseconds. */
|
|
11
|
+
export function parseDurationMs(durationStr: string): number {
|
|
12
|
+
const match = durationStr.trim().match(/^(\d+)([hdms]?)$/i);
|
|
13
|
+
if (!match) {
|
|
14
|
+
throw new Error("Invalid duration format. Use e.g. 30s, 20m, 1h, 2d");
|
|
15
|
+
}
|
|
16
|
+
const amountText = match[1];
|
|
17
|
+
if (!amountText) {
|
|
18
|
+
throw new Error("Invalid duration format. Use e.g. 30s, 20m, 1h, 2d");
|
|
19
|
+
}
|
|
20
|
+
const amount = Number.parseInt(amountText, 10);
|
|
21
|
+
const unit = (match[2] || "m").toLowerCase();
|
|
22
|
+
if (unit === "s") return amount * 1000;
|
|
23
|
+
if (unit === "m") return amount * 60 * 1000;
|
|
24
|
+
if (unit === "h") return amount * 60 * 60 * 1000;
|
|
25
|
+
if (unit === "d") return amount * 24 * 60 * 60 * 1000;
|
|
26
|
+
return amount * 60 * 1000;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateDuration(s: string): void {
|
|
30
|
+
if (!DURATION_RE.test(s.trim())) {
|
|
31
|
+
throw new Error("Invalid duration format. Use e.g. 30s, 20m, 1h, 2d");
|
|
32
|
+
}
|
|
33
|
+
parseDurationMs(s);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Splits a comma-separated string into trimmed non-empty tokens. */
|
|
37
|
+
export function parseCommaList(s: string): string[] {
|
|
38
|
+
return s
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((part) => part.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateCommaList(s: string): void {
|
|
45
|
+
if (parseCommaList(s).length === 0) {
|
|
46
|
+
throw new Error("Comma-separated list must contain at least one value");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Returns canonical YYYY-MM-DD after validation. */
|
|
51
|
+
export function parseDate(s: string): string {
|
|
52
|
+
const trimmed = s.trim();
|
|
53
|
+
if (!DATE_RE.test(trimmed)) {
|
|
54
|
+
throw new Error("Invalid date. Use YYYY-MM-DD");
|
|
55
|
+
}
|
|
56
|
+
const [y, m, d] = trimmed.split("-").map((part) => Number.parseInt(part, 10));
|
|
57
|
+
const year = y ?? 0;
|
|
58
|
+
const month = m ?? 0;
|
|
59
|
+
const day = d ?? 0;
|
|
60
|
+
const dt = new Date(Date.UTC(year, month - 1, day));
|
|
61
|
+
if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day) {
|
|
62
|
+
throw new Error("Invalid date. Use YYYY-MM-DD");
|
|
63
|
+
}
|
|
64
|
+
return trimmed;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function validateDate(s: string): void {
|
|
68
|
+
parseDate(s);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
72
|
+
|
|
73
|
+
/** Returns normalized ISO 8601 UTC after validation. */
|
|
74
|
+
export function parseDateTime(s: string): string {
|
|
75
|
+
const trimmed = s.trim();
|
|
76
|
+
if (!DATE_TIME_RE.test(trimmed)) {
|
|
77
|
+
throw new Error("Invalid date-time. Use RFC 3339, e.g. 2026-06-22T15:00:00Z");
|
|
78
|
+
}
|
|
79
|
+
const ms = Date.parse(trimmed);
|
|
80
|
+
if (Number.isNaN(ms)) {
|
|
81
|
+
throw new Error("Invalid date-time. Use RFC 3339, e.g. 2026-06-22T15:00:00Z");
|
|
82
|
+
}
|
|
83
|
+
return new Date(ms).toISOString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateDateTime(s: string): void {
|
|
87
|
+
parseDateTime(s);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function validatePattern(s: string, pattern: string): void {
|
|
91
|
+
const re = new RegExp(pattern);
|
|
92
|
+
if (!re.test(s)) {
|
|
93
|
+
throw new Error(`Value does not match required pattern: ${pattern}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatValidationError(format: CliValueFormat, value: string): string {
|
|
98
|
+
switch (format) {
|
|
99
|
+
case CliValueFormat.Duration:
|
|
100
|
+
return `Invalid duration: ${value} (use e.g. 30s, 20m, 1h, 2d)`;
|
|
101
|
+
case CliValueFormat.CommaList:
|
|
102
|
+
return `Invalid comma-separated list: ${value}`;
|
|
103
|
+
case CliValueFormat.Date:
|
|
104
|
+
return `Invalid date: ${value} (use YYYY-MM-DD)`;
|
|
105
|
+
case CliValueFormat.DateTime:
|
|
106
|
+
return `Invalid date-time: ${value} (use RFC 3339, e.g. 2026-06-22T15:00:00Z)`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Validates a string value against format and/or pattern metadata. */
|
|
111
|
+
export function validateFormatValue(
|
|
112
|
+
value: string,
|
|
113
|
+
format?: CliValueFormat,
|
|
114
|
+
pattern?: string,
|
|
115
|
+
): void {
|
|
116
|
+
if (format !== undefined) {
|
|
117
|
+
switch (format) {
|
|
118
|
+
case CliValueFormat.Duration:
|
|
119
|
+
validateDuration(value);
|
|
120
|
+
return;
|
|
121
|
+
case CliValueFormat.CommaList:
|
|
122
|
+
validateCommaList(value);
|
|
123
|
+
return;
|
|
124
|
+
case CliValueFormat.Date:
|
|
125
|
+
validateDate(value);
|
|
126
|
+
return;
|
|
127
|
+
case CliValueFormat.DateTime:
|
|
128
|
+
validateDateTime(value);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (pattern !== undefined) {
|
|
133
|
+
validatePattern(value, pattern);
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -1960,18 +1960,18 @@ test("ctx.positional varargs matches ctx.args", async () => {
|
|
|
1960
1960
|
expect(positional).toEqual(args);
|
|
1961
1961
|
});
|
|
1962
1962
|
|
|
1963
|
-
test("mcpToolCallToArgv
|
|
1963
|
+
test("mcpToolCallToArgv rejects comma-separated string for varargs", () => {
|
|
1964
1964
|
const tools = collectMcpTools(nestedMcpFixture);
|
|
1965
1965
|
const read = tools.find((t) => t.name === "read")!;
|
|
1966
1966
|
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a,b" });
|
|
1967
|
-
expect(argv).toEqual(
|
|
1967
|
+
expect(argv).toEqual({ error: expect.stringContaining("JSON array") });
|
|
1968
1968
|
});
|
|
1969
1969
|
|
|
1970
|
-
test("mcpToolCallToArgv
|
|
1970
|
+
test("mcpToolCallToArgv rejects bare string for varargs", () => {
|
|
1971
1971
|
const tools = collectMcpTools(nestedMcpFixture);
|
|
1972
1972
|
const read = tools.find((t) => t.name === "read")!;
|
|
1973
1973
|
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: "a" });
|
|
1974
|
-
expect(argv).toEqual(
|
|
1974
|
+
expect(argv).toEqual({ error: expect.stringContaining("JSON array") });
|
|
1975
1975
|
});
|
|
1976
1976
|
|
|
1977
1977
|
test("mcpToolCallToArgv array varargs unchanged", () => {
|
|
@@ -1981,11 +1981,11 @@ test("mcpToolCallToArgv array varargs unchanged", () => {
|
|
|
1981
1981
|
expect(argv).toEqual(["read", "a", "b"]);
|
|
1982
1982
|
});
|
|
1983
1983
|
|
|
1984
|
-
test("mcpToolCallToArgv empty
|
|
1984
|
+
test("mcpToolCallToArgv empty array varargs errors when required", () => {
|
|
1985
1985
|
const tools = collectMcpTools(nestedMcpFixture);
|
|
1986
1986
|
const read = tools.find((t) => t.name === "read")!;
|
|
1987
|
-
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files:
|
|
1988
|
-
expect(argv).toEqual(
|
|
1987
|
+
const argv = mcpToolCallToArgv(nestedMcpFixture, read, { files: [] });
|
|
1988
|
+
expect(argv).toEqual({ error: "Missing argument: files" });
|
|
1989
1989
|
});
|
|
1990
1990
|
|
|
1991
1991
|
// ── Skills ────────────────────────────────────────────────────────────────────
|
package/src/index.ts
CHANGED
|
@@ -7,7 +7,14 @@ It gives consumers one stable import path without forcing them to know the inter
|
|
|
7
7
|
module layout.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
export type { CliLeafInputs } from "./context.ts";
|
|
10
11
|
export { CliContext } from "./context.ts";
|
|
12
|
+
export {
|
|
13
|
+
parseCommaList,
|
|
14
|
+
parseDate,
|
|
15
|
+
parseDateTime,
|
|
16
|
+
parseDurationMs,
|
|
17
|
+
} from "./formats.ts";
|
|
11
18
|
export type { HeadlessContext } from "./headless.ts";
|
|
12
19
|
export {
|
|
13
20
|
formatDryRunMessage,
|
|
@@ -46,5 +53,10 @@ export type {
|
|
|
46
53
|
CliUpdateArtifact,
|
|
47
54
|
CliUpdateGetLatest,
|
|
48
55
|
} from "./types.ts";
|
|
49
|
-
export {
|
|
56
|
+
export {
|
|
57
|
+
CliFallbackMode,
|
|
58
|
+
CliOptionKind,
|
|
59
|
+
CliSchemaValidationError,
|
|
60
|
+
CliValueFormat,
|
|
61
|
+
} from "./types.ts";
|
|
50
62
|
export { isInteractiveTty } from "./utils.ts";
|
package/src/mcp/tools.ts
CHANGED
|
@@ -14,10 +14,13 @@ import {
|
|
|
14
14
|
CliOptionKind,
|
|
15
15
|
type CliPositional,
|
|
16
16
|
type CliProgram,
|
|
17
|
+
CliValueFormat,
|
|
17
18
|
isCliLeaf,
|
|
18
19
|
leafOutputSchema,
|
|
19
20
|
} from "../types.ts";
|
|
20
21
|
|
|
22
|
+
const DURATION_PATTERN = "^\\d+[hdms]?$";
|
|
23
|
+
|
|
21
24
|
/** Default URI pattern for the CLI schema MCP resource (`<mcpId>://schema`). */
|
|
22
25
|
export function defaultMcpSchemaUri(mcpId: string): string {
|
|
23
26
|
return `${mcpId}://schema`;
|
|
@@ -65,12 +68,37 @@ export function mcpToolName(root: CliProgram, path: string[]): string {
|
|
|
65
68
|
|
|
66
69
|
/** JSON Schema property for one option. */
|
|
67
70
|
function optionProperty(opt: CliOption): Record<string, unknown> {
|
|
68
|
-
const base = { description: opt.description };
|
|
71
|
+
const base: Record<string, unknown> = { description: opt.description };
|
|
72
|
+
if (opt.default !== undefined) {
|
|
73
|
+
base.default = opt.default;
|
|
74
|
+
}
|
|
69
75
|
switch (opt.kind) {
|
|
70
76
|
case CliOptionKind.Presence:
|
|
71
77
|
return { type: "boolean", ...base };
|
|
72
|
-
case CliOptionKind.String:
|
|
73
|
-
|
|
78
|
+
case CliOptionKind.String: {
|
|
79
|
+
if (opt.format === CliValueFormat.CommaList) {
|
|
80
|
+
return {
|
|
81
|
+
oneOf: [
|
|
82
|
+
{ type: "string", ...base },
|
|
83
|
+
{ type: "array", items: { type: "string" }, ...base },
|
|
84
|
+
],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const stringBase = { type: "string", ...base };
|
|
88
|
+
if (opt.format === CliValueFormat.Duration) {
|
|
89
|
+
return { ...stringBase, pattern: DURATION_PATTERN };
|
|
90
|
+
}
|
|
91
|
+
if (opt.format === CliValueFormat.Date) {
|
|
92
|
+
return { ...stringBase, format: "date" };
|
|
93
|
+
}
|
|
94
|
+
if (opt.format === CliValueFormat.DateTime) {
|
|
95
|
+
return { ...stringBase, format: "date-time" };
|
|
96
|
+
}
|
|
97
|
+
if (opt.pattern !== undefined) {
|
|
98
|
+
return { ...stringBase, pattern: opt.pattern };
|
|
99
|
+
}
|
|
100
|
+
return stringBase;
|
|
101
|
+
}
|
|
74
102
|
case CliOptionKind.Number:
|
|
75
103
|
return { type: "number", ...base };
|
|
76
104
|
case CliOptionKind.Enum:
|
|
@@ -78,6 +106,23 @@ function optionProperty(opt: CliOption): Record<string, unknown> {
|
|
|
78
106
|
}
|
|
79
107
|
}
|
|
80
108
|
|
|
109
|
+
function formatMcpOptionValue(opt: CliOption, val: unknown): string | { error: string } {
|
|
110
|
+
if (opt.format === CliValueFormat.CommaList) {
|
|
111
|
+
if (Array.isArray(val)) {
|
|
112
|
+
const items = val.map(String).filter(Boolean);
|
|
113
|
+
if (items.length === 0) {
|
|
114
|
+
return { error: `Option --${opt.name} requires at least one value` };
|
|
115
|
+
}
|
|
116
|
+
return items.join(",");
|
|
117
|
+
}
|
|
118
|
+
if (typeof val === "string") {
|
|
119
|
+
return val;
|
|
120
|
+
}
|
|
121
|
+
return { error: `Option --${opt.name} must be a string or array of strings` };
|
|
122
|
+
}
|
|
123
|
+
return String(val);
|
|
124
|
+
}
|
|
125
|
+
|
|
81
126
|
/** JSON Schema property for one positional slot. */
|
|
82
127
|
function positionalProperty(p: CliPositional): Record<string, unknown> {
|
|
83
128
|
const base = { description: p.description };
|
|
@@ -251,7 +296,11 @@ export function mcpToolCallToArgv(
|
|
|
251
296
|
}
|
|
252
297
|
continue;
|
|
253
298
|
}
|
|
254
|
-
|
|
299
|
+
const formatted = formatMcpOptionValue(opt, val);
|
|
300
|
+
if (typeof formatted !== "string") {
|
|
301
|
+
return formatted;
|
|
302
|
+
}
|
|
303
|
+
argv.push(`--${opt.name}`, formatted);
|
|
255
304
|
}
|
|
256
305
|
|
|
257
306
|
for (const p of tool.leaf.positionals ?? []) {
|
|
@@ -260,20 +309,20 @@ export function mcpToolCallToArgv(
|
|
|
260
309
|
|
|
261
310
|
if (argMax === 0) {
|
|
262
311
|
const raw = args[p.name];
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
if (raw === undefined) {
|
|
313
|
+
if (argMin >= 1) {
|
|
314
|
+
return { error: `Missing argument: ${p.name} (use a JSON array)` };
|
|
315
|
+
}
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (!Array.isArray(raw)) {
|
|
319
|
+
return {
|
|
320
|
+
error: `Argument ${p.name} must be a JSON array of strings (not a comma-separated string)`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const items = raw.map(String).filter(Boolean);
|
|
324
|
+
if (items.length === 0 && argMin >= 1) {
|
|
325
|
+
return { error: `Missing argument: ${p.name}` };
|
|
277
326
|
}
|
|
278
327
|
argv.push(...items);
|
|
279
328
|
continue;
|
package/src/parse.ts
CHANGED
|
@@ -7,6 +7,7 @@ It keeps handler dispatch and help on one parser so the CLI behavior stays consi
|
|
|
7
7
|
across every entry path.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { formatValidationError, validateFormatValue } from "./formats.ts";
|
|
10
11
|
import {
|
|
11
12
|
CliFallbackMode,
|
|
12
13
|
type CliLeaf,
|
|
@@ -683,8 +684,15 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
683
684
|
node = ch;
|
|
684
685
|
}
|
|
685
686
|
|
|
687
|
+
const opts = { ...pr.opts };
|
|
686
688
|
for (const d of defs) {
|
|
687
|
-
if (d.
|
|
689
|
+
if (d.default !== undefined && !(d.name in opts)) {
|
|
690
|
+
opts[d.name] = d.default;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
for (const d of defs) {
|
|
695
|
+
if (d.required && !(d.name in opts)) {
|
|
688
696
|
return {
|
|
689
697
|
kind: ParseKind.Error,
|
|
690
698
|
path: pr.path,
|
|
@@ -698,7 +706,7 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
698
706
|
}
|
|
699
707
|
}
|
|
700
708
|
|
|
701
|
-
for (const [k, v] of Object.entries(
|
|
709
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
702
710
|
const d = findOptionByName(defs, k);
|
|
703
711
|
if (!d) {
|
|
704
712
|
return {
|
|
@@ -741,7 +749,29 @@ export function postParseValidate(root: CliNode, pr: ParseResult): ParseResult {
|
|
|
741
749
|
};
|
|
742
750
|
}
|
|
743
751
|
}
|
|
752
|
+
if (d.kind === CliOptionKind.String && (d.format !== undefined || d.pattern !== undefined)) {
|
|
753
|
+
try {
|
|
754
|
+
validateFormatValue(v, d.format, d.pattern);
|
|
755
|
+
} catch (err) {
|
|
756
|
+
const msg =
|
|
757
|
+
d.format !== undefined
|
|
758
|
+
? formatValidationError(d.format, v)
|
|
759
|
+
: err instanceof Error
|
|
760
|
+
? err.message
|
|
761
|
+
: String(err);
|
|
762
|
+
return {
|
|
763
|
+
kind: ParseKind.Error,
|
|
764
|
+
path: pr.path,
|
|
765
|
+
opts: {},
|
|
766
|
+
args: [],
|
|
767
|
+
helpExplicit: false,
|
|
768
|
+
helpPath: [],
|
|
769
|
+
errorMsg: `Invalid value for option --${k}: ${msg}`,
|
|
770
|
+
errorHelpPath: pr.path,
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
}
|
|
744
774
|
}
|
|
745
775
|
|
|
746
|
-
return pr;
|
|
776
|
+
return { ...pr, opts };
|
|
747
777
|
}
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,21 @@ export enum CliOptionKind {
|
|
|
25
25
|
Enum = "enum",
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Named validation/coercion for string options (`format` on `CliOption`).
|
|
30
|
+
* Positionals do not use `format`; varargs use space-separated CLI tokens and JSON arrays over MCP.
|
|
31
|
+
*/
|
|
32
|
+
export enum CliValueFormat {
|
|
33
|
+
/** Duration text such as `30s`, `20m`, `1h`, `2d` (default unit minutes when omitted). */
|
|
34
|
+
Duration = "duration",
|
|
35
|
+
/** Comma-separated list on a single option value (`--services a,b`). */
|
|
36
|
+
CommaList = "comma-list",
|
|
37
|
+
/** Calendar date `YYYY-MM-DD`. */
|
|
38
|
+
Date = "date",
|
|
39
|
+
/** RFC 3339 instant with `Z` or numeric offset. */
|
|
40
|
+
DateTime = "date-time",
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
/**
|
|
29
44
|
* When `fallbackCommand` is used for missing or unknown subcommand tokens at a routing node.
|
|
30
45
|
*/
|
|
@@ -65,6 +80,15 @@ export interface CliOption {
|
|
|
65
80
|
* Must be a non-empty array of distinct non-empty strings.
|
|
66
81
|
*/
|
|
67
82
|
choices?: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Named string validation for `kind: String` options. Mutually exclusive with `pattern`.
|
|
85
|
+
* Not supported on positionals.
|
|
86
|
+
*/
|
|
87
|
+
format?: CliValueFormat;
|
|
88
|
+
/** Default value applied in post-parse when the option is omitted. */
|
|
89
|
+
default?: string;
|
|
90
|
+
/** Regex pattern for string options. Mutually exclusive with `format`. */
|
|
91
|
+
pattern?: string;
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -4,6 +4,7 @@ This module validates CLI schemas before execution.
|
|
|
4
4
|
|
|
5
5
|
import { reservedCommandNames, resolveCapabilities } from "./capabilities.ts";
|
|
6
6
|
import { DOCS_BUILTIN_TOPIC_KEYS } from "./docs/resolve.ts";
|
|
7
|
+
import { validateFormatValue } from "./formats.ts";
|
|
7
8
|
import { resolveMcpSchemaUri } from "./mcp/tools.ts";
|
|
8
9
|
import {
|
|
9
10
|
type CliLeaf,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
CliOptionKind,
|
|
12
13
|
type CliProgram,
|
|
13
14
|
CliSchemaValidationError,
|
|
15
|
+
CliValueFormat,
|
|
14
16
|
isCliLeaf,
|
|
15
17
|
isCliRouter,
|
|
16
18
|
} from "./types.ts";
|
|
@@ -226,6 +228,58 @@ function validateOptions(scopeKey: string, options: import("./types.ts").CliOpti
|
|
|
226
228
|
`Option '${opt.name}' on '${scopeKey}': choices is only valid for Enum kind`,
|
|
227
229
|
);
|
|
228
230
|
}
|
|
231
|
+
|
|
232
|
+
if (opt.format !== undefined || opt.pattern !== undefined || opt.default !== undefined) {
|
|
233
|
+
validateOptionValueMetadata(scopeKey, opt);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function validateOptionValueMetadata(scopeKey: string, opt: import("./types.ts").CliOption): void {
|
|
239
|
+
const label = `${scopeKey}/${opt.name}`;
|
|
240
|
+
|
|
241
|
+
if (opt.default !== undefined) {
|
|
242
|
+
if (opt.kind === CliOptionKind.Presence) {
|
|
243
|
+
throw new CliSchemaValidationError(`default is not valid on presence option ${label}`);
|
|
244
|
+
}
|
|
245
|
+
if (opt.required) {
|
|
246
|
+
throw new CliSchemaValidationError(`default cannot be set on required option ${label}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (opt.format !== undefined && opt.pattern !== undefined) {
|
|
251
|
+
throw new CliSchemaValidationError(
|
|
252
|
+
`Option ${label}: format and pattern are mutually exclusive`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (opt.format !== undefined) {
|
|
257
|
+
if (opt.kind !== CliOptionKind.String) {
|
|
258
|
+
throw new CliSchemaValidationError(`Option ${label}: format is only valid on String kind`);
|
|
259
|
+
}
|
|
260
|
+
if (!Object.values(CliValueFormat).includes(opt.format)) {
|
|
261
|
+
throw new CliSchemaValidationError(`Option ${label}: unknown format '${opt.format}'`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (opt.pattern !== undefined) {
|
|
266
|
+
if (opt.kind !== CliOptionKind.String) {
|
|
267
|
+
throw new CliSchemaValidationError(`Option ${label}: pattern is only valid on String kind`);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
new RegExp(opt.pattern);
|
|
271
|
+
} catch {
|
|
272
|
+
throw new CliSchemaValidationError(`Option ${label}: invalid pattern regex`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (opt.default !== undefined) {
|
|
277
|
+
try {
|
|
278
|
+
validateFormatValue(opt.default, opt.format, opt.pattern);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
281
|
+
throw new CliSchemaValidationError(`Option ${label}: invalid default: ${msg}`);
|
|
282
|
+
}
|
|
229
283
|
}
|
|
230
284
|
}
|
|
231
285
|
|