argsbarg 6.1.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,12 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [6.1.1] - 2026-07-23
11
+
12
+ ### Added
13
+
14
+ - **`ctx.inputs`** — coerced, pre-validated leaf inputs (getter; preferred over `readLeafInputs()`).
15
+ - **`ctx.inputsAs<T>()`** — `ctx.inputs` cast to a schemagen or app input type (`T` unconstrained; consumer-asserted).
16
+
17
+ ### Changed
18
+
19
+ - **`leaf.inputSchema` validation** — runs before the leaf handler in `Cli.run()` and `Cli.invoke()` (same JSON Schema subset as `program.appConfig`). `LeafInputError` prints contextual help on CLI. `ctx.inputs` returns the cached, pre-validated result.
20
+ - **`ctx.readLeafInputs()`** — deprecated; use `ctx.inputs` or `ctx.inputsAs()`.
21
+
10
22
  ## [6.1.0] - 2026-07-22
11
23
 
12
24
  ### Added
13
25
 
14
26
  - **`CliOptionKind.Json`** and **`pipable`** — JSON object options with CLI `--name '<json>'` or piped stdin when the flag is omitted (flag wins if set). MCP/API values merge from `toolArgs`.
15
- - **`ctx.readLeafInputsAsync()`** — resolves Json options (flag, stdin, or `toolArgs`) and validates against `leaf.inputSchema` when set.
27
+ - **`ctx.jsonOpt(name)`** — parsed Json option from flag, preloaded stdin, or MCP/API `toolArgs`.
28
+ - **`ctx.readLeafInputs()`** — all coerced inputs; validates against `leaf.inputSchema` when set (stdin preloaded before handler).
29
+ - **`ctx.readLeafInputsAsync()`** — deprecated alias for sync `readLeafInputs()`.
16
30
 
17
31
  ### Changed
18
32
 
@@ -750,7 +764,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
750
764
  - 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`).
751
765
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
752
766
 
753
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.1.0...HEAD
767
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v6.1.1...HEAD
768
+ [6.1.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.1
754
769
  [6.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.1.0
755
770
  [6.0.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.0.2
756
771
  [6.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v6.0.1
package/README.md CHANGED
@@ -207,8 +207,11 @@ Add `CliPositional` entries to the command’s `positionals` list (separate from
207
207
  - `ctx.durationOpt("timeout")` — duration options (`format: CliValueFormat.Duration`) as milliseconds.
208
208
  - `ctx.commaListOpt("services")` — comma-list options as `string[] | undefined`.
209
209
  - `ctx.dateOpt("on")` / `ctx.dateTimeOpt("since")` — ISO date / date-time options.
210
- - `ctx.readLeafInputs()` — coerced option and positional values for the current leaf (schema-driven).
211
- - `ctx.readLeafInputsAsync()` — like `readLeafInputs()`, plus Json options (flag, piped stdin, MCP `toolArgs`) and `inputSchema` validation.
210
+ - `ctx.inputs` — coerced option and positional values for the current leaf; when `inputSchema` is set, validated before the handler runs and cached on `ctx`.
211
+ - `ctx.inputsAs<T>()` — `ctx.inputs` cast to a schemagen or app input type.
212
+ - `ctx.readLeafInputs()` — deprecated; use `ctx.inputs` or `ctx.inputsAs()`.
213
+ - `ctx.jsonOpt(name)` — parsed Json option (flag, preloaded stdin, or MCP/API `toolArgs`).
214
+ - `ctx.readLeafInputsAsync()` — deprecated alias for `readLeafInputs()`.
212
215
  - `ctx.typedOpt<T>("custom", parseFn)` — custom parsing for type-safe option resolution.
213
216
  - `ctx.args` — positional words in order as `string[]`.
214
217
  - `ctx.positional("name")` — named positional lookup; varargs slots return `string[]`, single slots return `string | undefined`.
@@ -138,4 +138,4 @@ Call `generateOpenApi(program)` or `openApiJson(program)` from the package, fetc
138
138
 
139
139
  ## Complex tool inputs
140
140
 
141
- For nested request bodies (e.g. invoice template data), set `inputSchema` on the leaf, declare a matching `kind: Json` option (optionally `pipable: true` for CLI stdin), and read inputs with `await ctx.readLeafInputsAsync()` — it merges flag values, piped stdin, and the original flat JSON body from `POST /tools/:name`.
141
+ For nested request bodies (e.g. invoice template data), set `inputSchema` on the leaf, declare a matching `kind: Json` option (optionally `pipable: true` for CLI stdin), and read inputs with `ctx.jsonOpt(...)` or `ctx.readLeafInputs()` — piped stdin is loaded before the handler runs.
@@ -203,14 +203,17 @@ handler: async (ctx) => {
203
203
 
204
204
  Read varargs with `ctx.positional("uids")` (returns `string[]`) or `ctx.args`. Do not comma-split argv tokens or use `format` on positionals.
205
205
 
206
- **`readLeafInputs()`** — for leaves with several flags, one schema-driven read instead of hand-rolled `hasFlag` / `stringOpt` lines:
206
+ **`ctx.inputs`** — coerced option and positional values for the current leaf. When `leaf.inputSchema` is set, argsbarg validates **before the handler runs** and caches the result on `ctx`:
207
207
 
208
208
  ```typescript
209
- const { limit, "skip-readiness": skipReadiness, timeout } = ctx.readLeafInputs();
210
- // duration → number (ms); comma-list → string[]; presence → boolean; number → number
209
+ const { limit, "skip-readiness": skipReadiness, timeout } = ctx.inputs;
210
+ // or, with a schemagen type:
211
+ const { format, invoice } = ctx.inputsAs<RenderInvoiceInput>();
211
212
  ```
212
213
 
213
- **`CliLeafInputs`** — return type of `readLeafInputs()` (exported from `"argsbarg"`). A flat record keyed by **schema option and positional names** (hyphens preserved, e.g. `"skip-readiness"`). Values are coerced per kind/format:
214
+ **`readLeafInputs()`** — deprecated alias for `ctx.inputs`.
215
+
216
+ **`CliLeafInputs`** — return type of `ctx.inputs` (exported from `"argsbarg"`). A flat record keyed by **schema option and positional names** (hyphens preserved, e.g. `"skip-readiness"`). Values are coerced per kind/format:
214
217
 
215
218
  | Schema | Value in `CliLeafInputs` |
216
219
  | --- | --- |
@@ -223,9 +226,9 @@ const { limit, "skip-readiness": skipReadiness, timeout } = ctx.readLeafInputs()
223
226
  | `format: date-time` | `string` (normalized UTC ISO) |
224
227
  | Single positional | `string` or `undefined` |
225
228
  | Varargs positional | `string[]` or `undefined` |
226
- | `kind: json` | parsed object/array or `undefined` (use `readLeafInputsAsync()` for pipable stdin and MCP merge) |
229
+ | `kind: json` | parsed object/array or `undefined` (`ctx.jsonOpt(name)`; piped stdin preloaded before handler) |
227
230
 
228
- Omitted options appear as `undefined` (not absent keys). Options with `default` are filled in post-parse before handlers run, so `readLeafInputs()` and `durationOpt` see defaults. **`ctx.opts` always holds raw strings** — use typed accessors or `readLeafInputs()` for coerced values.
231
+ Omitted options appear as `undefined` (not absent keys). Options with `default` are filled in post-parse before handlers run, so `ctx.inputs` and `durationOpt` see defaults. **`ctx.opts` always holds raw strings** — use typed accessors or `ctx.inputs` for coerced values.
229
232
 
230
233
  ### Json options and piped stdin
231
234
 
@@ -243,12 +246,12 @@ For nested tool bodies (e.g. invoice template data), declare a matching property
243
246
 
244
247
  | Surface | How `invoice` is supplied |
245
248
  | --- | --- |
246
- | CLI | `--invoice '<json>'` **or** omit the flag and pipe JSON to stdin |
249
+ | CLI | `--invoice '<json>'` **or** omit the flag and pipe JSON to stdin (preloaded before the handler) |
247
250
  | MCP / HTTP | `invoice` object in the tool JSON body (`ctx.toolArgs`) |
248
251
 
249
252
  **Precedence:** if `--invoice` is set, the flag value wins and stdin is not read.
250
253
 
251
- Use **`await ctx.readLeafInputsAsync()`** (not sync `readLeafInputs()`) so Json options resolve from flags, piped stdin, or `toolArgs`. When `leaf.inputSchema` is set, argsbarg validates the merged inputs (same JSON Schema subset as `program.appConfig`).
254
+ Use **`ctx.jsonOpt("invoice")`**, **`ctx.inputs`**, or **`ctx.inputsAs<MyInput>()`** — all synchronous. Argsbarg reads piped stdin before calling the handler when a `pipable` Json flag is omitted. When `leaf.inputSchema` is set, argsbarg validates merged inputs **before the handler runs** (same JSON Schema subset as `program.appConfig`); `ctx.inputs` returns the cached result.
252
255
 
253
256
  At most one `pipable` Json option per leaf. Json option names must appear in `inputSchema.properties` when a custom `inputSchema` is set.
254
257
 
@@ -133,7 +133,7 @@ export type outputType = RenderInvoiceOutput;
133
133
 
134
134
  **Domain helpers** and **role exports** live in `types.ts`. Commands without structured JSON omit role exports. Shared shapes (e.g. `HeadlessOpResult`) are defined once in `types.ts` with a single `outputType`; other commands import `{ outputSchema }` from that module’s `__generated__/index.ts`.
135
135
 
136
- For nested MCP/HTTP bodies, add a `kind: Json` option (same name as the `inputType` property) and use `await ctx.readLeafInputsAsync()` — see [cli-program.md](cli-program.md#json-options-and-piped-stdin).
136
+ For nested MCP/HTTP bodies, add a `kind: Json` option (same name as the `inputType` property) and use `ctx.jsonOpt(...)` or `ctx.readLeafInputs()` — see [cli-program.md](cli-program.md#json-options-and-piped-stdin).
137
137
 
138
138
  When you do **not** set `inputSchema`, argsbarg builds tool input from CLI `options` + `positionals`.
139
139
 
package/index.d.ts CHANGED
@@ -59,9 +59,12 @@ export declare class CliContext {
59
59
  readonly appConfig: AnyAppConfigSnapshot;
60
60
  /** Original flat tool arguments for API/MCP invocations (when provided). */
61
61
  readonly toolArgs?: Record<string, unknown>;
62
+ /** Pipable Json option values read from stdin before the handler (CLI only). */
63
+ readonly preloadedJson: Record<string, unknown>;
62
64
  private response?;
65
+ private leafInputsCache?;
63
66
  /** Captures the program root, routed path, positional words, and option map for a leaf handler. */
64
- constructor(appName: string, commandPath: string[], args: string[], opts: Record<string, string>, program: CliProgram, invocation?: CliInvocation, appConfig?: AnyAppConfigSnapshot, toolArgs?: Record<string, unknown>);
67
+ constructor(appName: string, commandPath: string[], args: string[], opts: Record<string, string>, program: CliProgram, invocation?: CliInvocation, appConfig?: AnyAppConfigSnapshot, toolArgs?: Record<string, unknown>, preloadedJson?: Record<string, unknown>);
65
68
  /**
66
69
  * Sets the machine-readable response for API/MCP invocations, or writes to stdout in CLI mode.
67
70
  * May only be called once per invocation.
@@ -88,16 +91,30 @@ export declare class CliContext {
88
91
  dateOpt(name: string): string | undefined;
89
92
  /** Date-time option as normalized ISO 8601 UTC (post-parse validated). */
90
93
  dateTimeOpt(name: string): string | undefined;
94
+ /**
95
+ * Parsed Json option: `--name '<json>'`, preloaded piped stdin (when `pipable`), or MCP/API toolArgs.
96
+ * Flag wins over stdin and toolArgs.
97
+ */
98
+ jsonOpt(name: string): unknown | undefined;
91
99
  /** Returns the value(s) for a named positional slot. Varargs slots return string[]; single slots return string | undefined. */
92
100
  positional(name: string): string | string[] | undefined;
93
- /** Reads coerced option and positional values for the current leaf from schema metadata. */
101
+ /**
102
+ * Coerced option and positional values for the current leaf.
103
+ * When `leaf.inputSchema` is set, argsbarg validates before the handler runs; this returns the cached result.
104
+ */
105
+ get inputs(): CliLeafInputs;
106
+ /**
107
+ * {@link inputs} cast to a schemagen or app-defined input type (consumer-asserted; not inferred from `inputSchema`).
108
+ */
109
+ inputsAs<T = CliLeafInputs>(): T;
110
+ /**
111
+ * @deprecated Use {@link inputs} or {@link inputsAs}.
112
+ */
94
113
  readLeafInputs(): CliLeafInputs;
95
114
  /**
96
- * Reads coerced leaf inputs, resolving Json options from flags, piped stdin (when `pipable`),
97
- * or MCP/API toolArgs, and validates against `leaf.inputSchema` when set.
115
+ * @deprecated Use sync {@link readLeafInputs} — stdin is preloaded before the handler runs.
98
116
  */
99
117
  readLeafInputsAsync(): Promise<CliLeafInputs>;
100
- private _readOptionValue;
101
118
  private _leafNode;
102
119
  private _posMap;
103
120
  private _positionalMap;
@@ -596,6 +613,8 @@ export declare class Cli {
596
613
  }): Promise<CliInvokeResult>;
597
614
  serveMcp(): Promise<never>;
598
615
  serveApi(): Promise<never>;
616
+ private ensureValidatedLeafInputs;
617
+ private exitLeafInputError;
599
618
  private prepareDispatch;
600
619
  private buildAppConfigSnapshot;
601
620
  }
@@ -643,14 +662,25 @@ dryRun?: boolean,
643
662
  interactive?: boolean): void;
644
663
  /** Prefixes a success message when running in dry-run mode. */
645
664
  export declare function formatDryRunMessage(message: string, dryRun: boolean): string;
646
- /** Thrown when {@link CliContext.readLeafInputsAsync} cannot resolve or validate inputs. */
665
+ /** Thrown when leaf input resolution or validation fails. */
647
666
  export declare class LeafInputError extends Error {
648
667
  constructor(message: string);
649
668
  }
669
+ /** Resolves a Json option from argv, preloaded stdin, or toolArgs (flag wins). */
670
+ export declare function readJsonOptionValue(ctx: CliContext, name: string): unknown | undefined;
671
+ /**
672
+ * Reads piped stdin for a pipable Json option when the flag is omitted (CLI only).
673
+ * Call from {@link Cli.run} before constructing the handler context.
674
+ */
675
+ export declare function preloadPipableJson(program: CliProgram, commandPath: string[], opts: Record<string, string>, invocation: CliInvocation): Promise<Record<string, unknown>>;
650
676
  /**
651
- * Reads coerced leaf inputs, resolving Json options from flags, piped stdin, or toolArgs,
652
- * and validates against `leaf.inputSchema` when set.
677
+ * Loads coerced leaf inputs and validates against `leaf.inputSchema` when set.
678
+ * Used by {@link CliContext.inputs}; prefer `ctx.inputs` or `ctx.inputsAs()` in handlers.
653
679
  */
680
+ export declare function loadLeafInputs(ctx: CliContext): CliLeafInputs;
681
+ /** @deprecated Use {@link CliContext.inputs} or {@link loadLeafInputs} via `ctx.inputs`. */
682
+ export declare function readLeafInputs(ctx: CliContext): CliLeafInputs;
683
+ /** @deprecated Use sync {@link readLeafInputs} — stdin is preloaded before the handler runs. */
654
684
  export declare function readLeafInputsAsync(ctx: CliContext): Promise<CliLeafInputs>;
655
685
  /** Resolved paths for `mcp bundle`. */
656
686
  export interface McpBundlePaths {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "6.1.0",
3
+ "version": "6.1.1",
4
4
  "main": "./src/index.ts",
5
5
  "module": "./src/index.ts",
6
6
  "dependencies": {
package/src/cli.ts CHANGED
@@ -18,6 +18,7 @@ import { readAppConfigFileRaw, resolveAppConfigPath } from "./config/file.ts";
18
18
  import { effectiveJsonSchema } from "./config/schema.ts";
19
19
  import { CliContext } from "./context.ts";
20
20
  import { cliHelpRender } from "./help.ts";
21
+ import { LeafInputError, preloadPipableJson } from "./leaf-inputs.ts";
21
22
  import { bootstrapMcpEnv } from "./mcp/env.ts";
22
23
  import { mcpServeStdioLoop } from "./mcp/server.ts";
23
24
  import { ParseKind, type ParseResult, parse, postParseValidate } from "./parse.ts";
@@ -124,14 +125,41 @@ export class Cli {
124
125
  exitOnMissing: !skipRequiredConfig,
125
126
  });
126
127
 
127
- const ctx = new CliContext(this.program.key, pr.path, pr.args, pr.opts, this.program, "cli", snapshot);
128
+ let preloadedJson: Record<string, unknown> = {};
128
129
  try {
130
+ preloadedJson = await preloadPipableJson(this.program, pr.path, pr.opts, "cli");
131
+ } catch (err) {
132
+ if (err instanceof LeafInputError) {
133
+ this.exitLeafInputError(err, pr.path);
134
+ }
135
+ const msg = err instanceof Error ? err.message : String(err);
136
+ const color = process.stderr.isTTY;
137
+ process.stderr.write(color ? `\u001B[31m${msg}\u001B[0m\n` : `${msg}\n`);
138
+ process.exit(1);
139
+ }
140
+
141
+ const ctx = new CliContext(
142
+ this.program.key,
143
+ pr.path,
144
+ pr.args,
145
+ pr.opts,
146
+ this.program,
147
+ "cli",
148
+ snapshot,
149
+ undefined,
150
+ preloadedJson,
151
+ );
152
+ try {
153
+ this.ensureValidatedLeafInputs(ctx, leaf);
129
154
  const handlerResult = await Promise.resolve(leaf.handler(ctx));
130
155
  if (handlerResult !== undefined && ctx.getResponse() === undefined) {
131
156
  ctx.respond({ body: handlerResult as CliRespondOptions["body"] });
132
157
  }
133
158
  process.exit(0);
134
159
  } catch (err) {
160
+ if (err instanceof LeafInputError) {
161
+ this.exitLeafInputError(err, pr.path);
162
+ }
135
163
  if (err instanceof Error) {
136
164
  process.stderr.write(`${err.message}\n`);
137
165
  }
@@ -232,6 +260,7 @@ export class Cli {
232
260
  });
233
261
  }
234
262
 
263
+ this.ensureValidatedLeafInputs(ctx, leaf);
235
264
  const handlerResult = await Promise.resolve(leaf.handler(ctx));
236
265
  if (handlerResult !== undefined && ctx.getResponse() === undefined) {
237
266
  ctx.respond({ body: handlerResult as CliRespondOptions["body"] });
@@ -260,6 +289,15 @@ export class Cli {
260
289
  const msg = stderr.trim() || `Exit code ${err.code}`;
261
290
  return { kind: "error", exitCode: err.code, stdout, stderr, errorMsg: msg };
262
291
  }
292
+ if (err instanceof LeafInputError) {
293
+ return {
294
+ kind: "error",
295
+ exitCode: 1,
296
+ stdout,
297
+ stderr: `${err.message}\n`,
298
+ errorMsg: err.message,
299
+ };
300
+ }
263
301
  if (err instanceof Error) {
264
302
  return {
265
303
  kind: "error",
@@ -320,6 +358,21 @@ export class Cli {
320
358
  }
321
359
  }
322
360
 
361
+ private ensureValidatedLeafInputs(ctx: CliContext, leaf: CliLeaf): void {
362
+ if (leaf.inputSchema === undefined) {
363
+ return;
364
+ }
365
+ ctx.inputs;
366
+ }
367
+
368
+ private exitLeafInputError(err: LeafInputError, helpPath: string[]): never {
369
+ const color = process.stderr.isTTY;
370
+ const msg = color ? `\u001B[31m${err.message}\u001B[0m` : err.message;
371
+ process.stderr.write(`${msg}\n`);
372
+ process.stderr.write(cliHelpRender(this.presentationRoot, helpPath, true));
373
+ process.exit(1);
374
+ }
375
+
323
376
  private prepareDispatch(
324
377
  argv: string[],
325
378
  opts?: { presentationFallback?: boolean },
package/src/context.ts CHANGED
@@ -10,11 +10,10 @@ parsed values.
10
10
  import type { AnyAppConfigSnapshot } from "./config/context.ts";
11
11
  import { EmptyAppConfigSnapshot } from "./config/context.ts";
12
12
  import { parseCommaList, parseDate, parseDateTime, parseDurationMs } from "./formats.ts";
13
- import { readLeafInputsAsync as loadLeafInputsAsync } from "./leaf-inputs.ts";
14
- import { collectOptionDefs } from "./parse.ts";
13
+ import { loadLeafInputs, readJsonOptionValue } from "./leaf-inputs.ts";
15
14
  import { normalizeRespondOptions, writeRespondBodyToStdout } from "./respond.ts";
16
- import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram, CliRespondOptions } from "./types.ts";
17
- import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter } from "./types.ts";
15
+ import type { CliInvocation, CliLeaf, CliNode, CliProgram, CliRespondOptions } from "./types.ts";
16
+ import { isCliLeaf, isCliRouter } from "./types.ts";
18
17
  import { strictParseDouble } from "./utils.ts";
19
18
 
20
19
  /** Coerced leaf inputs keyed by option and positional names. */
@@ -33,8 +32,11 @@ export class CliContext {
33
32
  readonly appConfig: AnyAppConfigSnapshot;
34
33
  /** Original flat tool arguments for API/MCP invocations (when provided). */
35
34
  readonly toolArgs?: Record<string, unknown>;
35
+ /** Pipable Json option values read from stdin before the handler (CLI only). */
36
+ readonly preloadedJson: Record<string, unknown>;
36
37
 
37
38
  private response?: CliRespondOptions;
39
+ private leafInputsCache?: CliLeafInputs;
38
40
 
39
41
  /** Captures the program root, routed path, positional words, and option map for a leaf handler. */
40
42
  constructor(
@@ -46,6 +48,7 @@ export class CliContext {
46
48
  invocation: CliInvocation = "cli",
47
49
  appConfig: AnyAppConfigSnapshot = new EmptyAppConfigSnapshot(program),
48
50
  toolArgs?: Record<string, unknown>,
51
+ preloadedJson: Record<string, unknown> = {},
49
52
  ) {
50
53
  this.appName = appName;
51
54
  this.commandPath = commandPath;
@@ -55,6 +58,7 @@ export class CliContext {
55
58
  this.invocation = invocation;
56
59
  this.appConfig = appConfig;
57
60
  this.toolArgs = toolArgs;
61
+ this.preloadedJson = preloadedJson;
58
62
  }
59
63
 
60
64
  /**
@@ -137,71 +141,50 @@ export class CliContext {
137
141
  return parseDateTime(s);
138
142
  }
139
143
 
144
+ /**
145
+ * Parsed Json option: `--name '<json>'`, preloaded piped stdin (when `pipable`), or MCP/API toolArgs.
146
+ * Flag wins over stdin and toolArgs.
147
+ */
148
+ jsonOpt(name: string): unknown | undefined {
149
+ return readJsonOptionValue(this, name);
150
+ }
151
+
140
152
  /** Returns the value(s) for a named positional slot. Varargs slots return string[]; single slots return string | undefined. */
141
153
  positional(name: string): string | string[] | undefined {
142
154
  return this._positionalMap()[name];
143
155
  }
144
156
 
145
- /** Reads coerced option and positional values for the current leaf from schema metadata. */
146
- readLeafInputs(): CliLeafInputs {
147
- const leaf = this._leafNode();
148
- if (!leaf) return {};
149
-
150
- const out: CliLeafInputs = {};
151
- for (const opt of collectOptionDefs(this.program, this.commandPath)) {
152
- out[opt.name] = this._readOptionValue(opt);
153
- }
154
- for (const p of leaf.positionals ?? []) {
155
- const val = this.positional(p.name);
156
- if (val === undefined) {
157
- out[p.name] = undefined;
158
- } else if (Array.isArray(val)) {
159
- out[p.name] = val;
160
- } else {
161
- out[p.name] = val;
162
- }
157
+ /**
158
+ * Coerced option and positional values for the current leaf.
159
+ * When `leaf.inputSchema` is set, argsbarg validates before the handler runs; this returns the cached result.
160
+ */
161
+ get inputs(): CliLeafInputs {
162
+ if (this.leafInputsCache !== undefined) {
163
+ return this.leafInputsCache;
163
164
  }
164
- return out;
165
+ this.leafInputsCache = loadLeafInputs(this);
166
+ return this.leafInputsCache;
165
167
  }
166
168
 
167
169
  /**
168
- * Reads coerced leaf inputs, resolving Json options from flags, piped stdin (when `pipable`),
169
- * or MCP/API toolArgs, and validates against `leaf.inputSchema` when set.
170
+ * {@link inputs} cast to a schemagen or app-defined input type (consumer-asserted; not inferred from `inputSchema`).
170
171
  */
171
- async readLeafInputsAsync(): Promise<CliLeafInputs> {
172
- return loadLeafInputsAsync(this);
172
+ inputsAs<T = CliLeafInputs>(): T {
173
+ return this.inputs as T;
173
174
  }
174
175
 
175
- private _readOptionValue(opt: CliOption): boolean | number | string | string[] | unknown | undefined {
176
- if (opt.kind === CliOptionKind.Presence) {
177
- return this.hasFlag(opt.name);
178
- }
179
- if (opt.kind === CliOptionKind.Number) {
180
- const n = this.numberOpt(opt.name);
181
- return n === null ? undefined : n;
182
- }
183
- if (opt.kind === CliOptionKind.Json) {
184
- const raw = this.stringOpt(opt.name);
185
- if (raw === undefined) return undefined;
186
- try {
187
- return JSON.parse(raw) as unknown;
188
- } catch {
189
- return undefined;
190
- }
191
- }
192
- if (opt.format === CliValueFormat.Duration) {
193
- return this.durationOpt(opt.name);
194
- }
195
- if (opt.format === CliValueFormat.CommaList) {
196
- return this.commaListOpt(opt.name);
197
- }
198
- if (opt.format === CliValueFormat.Date) {
199
- return this.dateOpt(opt.name);
200
- }
201
- if (opt.format === CliValueFormat.DateTime) {
202
- return this.dateTimeOpt(opt.name);
203
- }
204
- return this.stringOpt(opt.name);
176
+ /**
177
+ * @deprecated Use {@link inputs} or {@link inputsAs}.
178
+ */
179
+ readLeafInputs(): CliLeafInputs {
180
+ return this.inputs;
181
+ }
182
+
183
+ /**
184
+ * @deprecated Use sync {@link readLeafInputs} — stdin is preloaded before the handler runs.
185
+ */
186
+ readLeafInputsAsync(): Promise<CliLeafInputs> {
187
+ return Promise.resolve(this.readLeafInputs());
205
188
  }
206
189
 
207
190
  private _leafNode(): CliLeaf | undefined {
package/src/index.ts CHANGED
@@ -28,7 +28,14 @@ export {
28
28
  shouldRunHeadlessWithYes,
29
29
  wantsExplicitJson,
30
30
  } from "./headless.ts";
31
- export { LeafInputError, readLeafInputsAsync } from "./leaf-inputs.ts";
31
+ export {
32
+ LeafInputError,
33
+ loadLeafInputs,
34
+ preloadPipableJson,
35
+ readJsonOptionValue,
36
+ readLeafInputs,
37
+ readLeafInputsAsync,
38
+ } from "./leaf-inputs.ts";
32
39
  export type { McpBundlePaths, PackMcpBundleOpts } from "./mcp/bundle.ts";
33
40
  export { defaultMcpBundlePaths, generateMcpManifest, packMcpBundle } from "./mcp/bundle.ts";
34
41
  export type {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Cli, CliContext, CliOptionKind } from "./index.ts";
3
- import { LeafInputError, readLeafInputsAsync } from "./leaf-inputs.ts";
3
+ import { LeafInputError, readLeafInputs } from "./leaf-inputs.ts";
4
4
  import type { CliProgram } from "./types.ts";
5
5
 
6
6
  const invoiceSchema = {
@@ -46,16 +46,13 @@ function renderProgram(): CliProgram {
46
46
  required: true,
47
47
  },
48
48
  ],
49
- handler: async (ctx) => {
50
- const inputs = await ctx.readLeafInputsAsync();
51
- return inputs;
52
- },
49
+ handler: (ctx) => ctx.readLeafInputs(),
53
50
  },
54
51
  ],
55
52
  } satisfies CliProgram;
56
53
  }
57
54
 
58
- describe("readLeafInputsAsync", () => {
55
+ describe("readLeafInputs / jsonOpt", () => {
59
56
  test("reads Json option from MCP toolArgs", async () => {
60
57
  const cli = new Cli(renderProgram());
61
58
  const result = await cli.invoke(["render", "--format", "pdf"], {
@@ -83,6 +80,33 @@ describe("readLeafInputsAsync", () => {
83
80
  });
84
81
  });
85
82
 
83
+ test("jsonOpt reads from preloadedJson", () => {
84
+ const program = renderProgram();
85
+ const ctx = new CliContext(
86
+ "json-pipe-test",
87
+ ["render"],
88
+ [],
89
+ { format: "pdf" },
90
+ program,
91
+ "cli",
92
+ undefined,
93
+ undefined,
94
+ { invoice: { id: "piped" } },
95
+ );
96
+ expect(ctx.jsonOpt("invoice")).toEqual({ id: "piped" });
97
+ expect(ctx.inputs).toEqual({ format: "pdf", invoice: { id: "piped" } });
98
+ });
99
+
100
+ test("inputsAs returns schemagen-shaped inputs", () => {
101
+ type RenderInput = { format: "pdf" | "html"; invoice: { id: string } };
102
+ const program = renderProgram();
103
+ const ctx = new CliContext("json-pipe-test", ["render"], [], { format: "pdf" }, program, "mcp", undefined, {
104
+ format: "pdf",
105
+ invoice: { id: "INV-1" },
106
+ });
107
+ expect(ctx.inputsAs<RenderInput>()).toEqual({ format: "pdf", invoice: { id: "INV-1" } });
108
+ });
109
+
86
110
  test("rejects invalid Json flag at parse time", async () => {
87
111
  const cli = new Cli(renderProgram());
88
112
  const result = await cli.invoke(["render", "--format", "pdf", "--invoice", "not-json"], {
@@ -103,10 +127,95 @@ describe("readLeafInputsAsync", () => {
103
127
  expect(result.stderr).toContain("invoice.id");
104
128
  });
105
129
 
106
- test("readLeafInputsAsync throws LeafInputError when required Json is missing", async () => {
130
+ test("validates inputSchema before handler runs", async () => {
131
+ let handlerCalled = false;
132
+ const program = {
133
+ key: "json-pipe-test",
134
+ version: "1.0.0",
135
+ description: "pre-handler validation",
136
+ commands: [
137
+ {
138
+ key: "render",
139
+ description: "Render",
140
+ inputSchema: invoiceSchema,
141
+ options: [
142
+ {
143
+ name: "format",
144
+ description: "Output format",
145
+ kind: CliOptionKind.Enum,
146
+ choices: ["pdf", "html"],
147
+ required: true,
148
+ },
149
+ {
150
+ name: "invoice",
151
+ description: "Invoice JSON",
152
+ kind: CliOptionKind.Json,
153
+ pipable: true,
154
+ required: true,
155
+ },
156
+ ],
157
+ handler: () => {
158
+ handlerCalled = true;
159
+ return { ok: true };
160
+ },
161
+ },
162
+ ],
163
+ } satisfies CliProgram;
164
+ const cli = new Cli(program);
165
+ const result = await cli.invoke(["render", "--format", "pdf"], {
166
+ invocation: "mcp",
167
+ toolArgs: { format: "pdf", invoice: { id: 123 } },
168
+ });
169
+ expect(result.kind).toBe("error");
170
+ expect(handlerCalled).toBe(false);
171
+ });
172
+
173
+ test("inputs returns cached inputs after pre-handler validation", async () => {
174
+ const inputs: unknown[] = [];
175
+ const program = {
176
+ key: "json-pipe-test",
177
+ version: "1.0.0",
178
+ description: "cached inputs",
179
+ commands: [
180
+ {
181
+ key: "render",
182
+ description: "Render",
183
+ inputSchema: invoiceSchema,
184
+ options: [
185
+ {
186
+ name: "format",
187
+ description: "Output format",
188
+ kind: CliOptionKind.Enum,
189
+ choices: ["pdf", "html"],
190
+ required: true,
191
+ },
192
+ {
193
+ name: "invoice",
194
+ description: "Invoice JSON",
195
+ kind: CliOptionKind.Json,
196
+ required: true,
197
+ },
198
+ ],
199
+ handler: (ctx) => {
200
+ inputs.push(ctx.inputs);
201
+ inputs.push(ctx.inputs);
202
+ },
203
+ },
204
+ ],
205
+ } satisfies CliProgram;
206
+ const cli = new Cli(program);
207
+ await cli.invoke(["render", "--format", "pdf"], {
208
+ invocation: "mcp",
209
+ toolArgs: { format: "pdf", invoice: { id: "INV-1" } },
210
+ });
211
+ expect(inputs).toHaveLength(2);
212
+ expect(inputs[0]).toEqual(inputs[1]);
213
+ });
214
+
215
+ test("readLeafInputs throws LeafInputError when required Json is missing", () => {
107
216
  const program = renderProgram();
108
217
  const ctx = new CliContext("json-pipe-test", ["render"], [], { format: "pdf" }, program, "mcp", undefined, {});
109
- await expect(readLeafInputsAsync(ctx)).rejects.toThrow(LeafInputError);
218
+ expect(() => readLeafInputs(ctx)).toThrow(LeafInputError);
110
219
  });
111
220
 
112
221
  test("omits undefined optional properties before inputSchema validation", async () => {
@@ -155,7 +264,7 @@ describe("readLeafInputsAsync", () => {
155
264
  required: true,
156
265
  },
157
266
  ],
158
- handler: async (ctx) => ctx.readLeafInputsAsync(),
267
+ handler: (ctx) => ctx.readLeafInputs(),
159
268
  },
160
269
  ],
161
270
  } satisfies CliProgram;
@@ -1,15 +1,15 @@
1
1
  /*
2
- Async leaf input reads: Json options (flag, piped stdin, or toolArgs), optional inputSchema validation.
2
+ Leaf input reads: Json options (flag, preloaded stdin, or toolArgs), optional inputSchema validation.
3
3
  */
4
4
 
5
5
  import { validateConfigDocument } from "./config/validate.ts";
6
6
  import type { CliContext, CliLeafInputs } from "./context.ts";
7
7
  import { collectOptionDefs } from "./parse.ts";
8
- import type { CliLeaf, CliNode, CliOption } from "./types.ts";
8
+ import type { CliInvocation, CliLeaf, CliNode, CliOption, CliProgram } from "./types.ts";
9
9
  import { CliOptionKind, CliValueFormat, isCliLeaf, isCliRouter } from "./types.ts";
10
10
  import { isInteractiveTty } from "./utils.ts";
11
11
 
12
- /** Thrown when {@link CliContext.readLeafInputsAsync} cannot resolve or validate inputs. */
12
+ /** Thrown when leaf input resolution or validation fails. */
13
13
  export class LeafInputError extends Error {
14
14
  constructor(message: string) {
15
15
  super(message);
@@ -28,40 +28,8 @@ function leafNode(ctx: CliContext): CliLeaf | undefined {
28
28
  return isCliLeaf(node) ? node : undefined;
29
29
  }
30
30
 
31
- function readSyncOptionValue(
32
- ctx: CliContext,
33
- opt: CliOption,
34
- ): boolean | number | string | string[] | unknown | undefined {
35
- if (opt.kind === CliOptionKind.Presence) {
36
- return ctx.hasFlag(opt.name);
37
- }
38
- if (opt.kind === CliOptionKind.Number) {
39
- const n = ctx.numberOpt(opt.name);
40
- return n === null ? undefined : n;
41
- }
42
- if (opt.kind === CliOptionKind.Json) {
43
- const raw = ctx.stringOpt(opt.name);
44
- if (raw === undefined) return undefined;
45
- return parseJsonText(raw, `--${opt.name}`);
46
- }
47
- if (opt.format !== undefined) {
48
- if (opt.format === CliValueFormat.Duration) {
49
- return ctx.durationOpt(opt.name);
50
- }
51
- if (opt.format === CliValueFormat.CommaList) {
52
- return ctx.commaListOpt(opt.name);
53
- }
54
- if (opt.format === CliValueFormat.Date) {
55
- return ctx.dateOpt(opt.name);
56
- }
57
- if (opt.format === CliValueFormat.DateTime) {
58
- return ctx.dateTimeOpt(opt.name);
59
- }
60
- }
61
- return ctx.stringOpt(opt.name);
62
- }
63
-
64
- function parseJsonText(raw: string, label: string): unknown {
31
+ /** Parses a JSON string from a `--name` flag value. */
32
+ export function parseJsonText(raw: string, label: string): unknown {
65
33
  const trimmed = raw.trim();
66
34
  if (trimmed.length === 0) {
67
35
  throw new LeafInputError(`${label}: JSON value is empty`);
@@ -107,46 +75,85 @@ function validateAgainstInputSchema(out: CliLeafInputs, inputSchema: Record<stri
107
75
  }
108
76
  }
109
77
 
78
+ /** Resolves a Json option from argv, preloaded stdin, or toolArgs (flag wins). */
79
+ export function readJsonOptionValue(ctx: CliContext, name: string): unknown | undefined {
80
+ const flagValue = ctx.stringOpt(name);
81
+ if (flagValue !== undefined) {
82
+ return parseJsonText(flagValue, `--${name}`);
83
+ }
84
+ if (name in ctx.preloadedJson) {
85
+ return ctx.preloadedJson[name];
86
+ }
87
+ if (ctx.toolArgs !== undefined && name in ctx.toolArgs) {
88
+ return ctx.toolArgs[name];
89
+ }
90
+ return undefined;
91
+ }
92
+
93
+ /**
94
+ * Reads piped stdin for a pipable Json option when the flag is omitted (CLI only).
95
+ * Call from {@link Cli.run} before constructing the handler context.
96
+ */
97
+ export async function preloadPipableJson(
98
+ program: CliProgram,
99
+ commandPath: string[],
100
+ opts: Record<string, string>,
101
+ invocation: CliInvocation,
102
+ ): Promise<Record<string, unknown>> {
103
+ if (invocation !== "cli" || isInteractiveTty) {
104
+ return {};
105
+ }
106
+ for (const opt of collectOptionDefs(program, commandPath)) {
107
+ if (opt.kind === CliOptionKind.Json && opt.pipable && !(opt.name in opts)) {
108
+ return { [opt.name]: await readPipedJsonStdin() };
109
+ }
110
+ }
111
+ return {};
112
+ }
113
+
114
+ function readSyncOptionValue(
115
+ ctx: CliContext,
116
+ opt: CliOption,
117
+ ): boolean | number | string | string[] | unknown | undefined {
118
+ if (opt.kind === CliOptionKind.Presence) {
119
+ return ctx.hasFlag(opt.name);
120
+ }
121
+ if (opt.kind === CliOptionKind.Number) {
122
+ const n = ctx.numberOpt(opt.name);
123
+ return n === null ? undefined : n;
124
+ }
125
+ if (opt.kind === CliOptionKind.Json) {
126
+ return readJsonOptionValue(ctx, opt.name);
127
+ }
128
+ if (opt.format !== undefined) {
129
+ if (opt.format === CliValueFormat.Duration) {
130
+ return ctx.durationOpt(opt.name);
131
+ }
132
+ if (opt.format === CliValueFormat.CommaList) {
133
+ return ctx.commaListOpt(opt.name);
134
+ }
135
+ if (opt.format === CliValueFormat.Date) {
136
+ return ctx.dateOpt(opt.name);
137
+ }
138
+ if (opt.format === CliValueFormat.DateTime) {
139
+ return ctx.dateTimeOpt(opt.name);
140
+ }
141
+ }
142
+ return ctx.stringOpt(opt.name);
143
+ }
144
+
110
145
  /**
111
- * Reads coerced leaf inputs, resolving Json options from flags, piped stdin, or toolArgs,
112
- * and validates against `leaf.inputSchema` when set.
146
+ * Loads coerced leaf inputs and validates against `leaf.inputSchema` when set.
147
+ * Used by {@link CliContext.inputs}; prefer `ctx.inputs` or `ctx.inputsAs()` in handlers.
113
148
  */
114
- export async function readLeafInputsAsync(ctx: CliContext): Promise<CliLeafInputs> {
149
+ export function loadLeafInputs(ctx: CliContext): CliLeafInputs {
115
150
  const leaf = leafNode(ctx);
116
151
  if (!leaf) return {};
117
152
 
118
153
  const out: CliLeafInputs = {};
119
154
  const options = collectOptionDefs(ctx.program, ctx.commandPath);
120
- let pipedJson: unknown | undefined;
121
- let pipedJsonRead = false;
122
155
 
123
156
  for (const opt of options) {
124
- if (opt.kind === CliOptionKind.Json) {
125
- const flagValue = ctx.stringOpt(opt.name);
126
- if (flagValue !== undefined) {
127
- out[opt.name] = parseJsonText(flagValue, `--${opt.name}`);
128
- continue;
129
- }
130
- if (ctx.toolArgs !== undefined && opt.name in ctx.toolArgs) {
131
- out[opt.name] = ctx.toolArgs[opt.name];
132
- continue;
133
- }
134
- if (opt.pipable && ctx.invocation === "cli") {
135
- if (isInteractiveTty) {
136
- out[opt.name] = undefined;
137
- continue;
138
- }
139
- if (!pipedJsonRead) {
140
- pipedJson = await readPipedJsonStdin();
141
- pipedJsonRead = true;
142
- }
143
- out[opt.name] = pipedJson;
144
- continue;
145
- }
146
- out[opt.name] = undefined;
147
- continue;
148
- }
149
-
150
157
  out[opt.name] = readSyncOptionValue(ctx, opt);
151
158
  }
152
159
 
@@ -176,3 +183,13 @@ export async function readLeafInputsAsync(ctx: CliContext): Promise<CliLeafInput
176
183
 
177
184
  return omitUndefinedInputs(out);
178
185
  }
186
+
187
+ /** @deprecated Use {@link CliContext.inputs} or {@link loadLeafInputs} via `ctx.inputs`. */
188
+ export function readLeafInputs(ctx: CliContext): CliLeafInputs {
189
+ return ctx.inputs;
190
+ }
191
+
192
+ /** @deprecated Use sync {@link readLeafInputs} — stdin is preloaded before the handler runs. */
193
+ export function readLeafInputsAsync(ctx: CliContext): Promise<CliLeafInputs> {
194
+ return Promise.resolve(readLeafInputs(ctx));
195
+ }