argsbarg 1.1.0 → 1.2.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 CHANGED
@@ -7,8 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-04-24
11
+
12
+ ### Added
13
+
14
+ - **`CliOption.required`** — makes an option required when parsing
15
+ - **`isInteractiveTty`** - a computed boolean of whether the app is running in an interactive tty
16
+ - **Single-command CLI support** - You can now define a `handler` directly on the root of your CLI configuration to quickly build single-command apps without nesting them in subcommands.
17
+
18
+ ### Changed
19
+
20
+ - **`CliCommand` Strict Union** - (Breaking TS Change) `CliCommand` is now a Discriminated Union type. A command must be *either* a Router (with `commands`) or a Leaf (with `handler`), but not both. This catches structural mistakes at compile time.
21
+
22
+ ## [1.1.1] - 2026-04-23
23
+
24
+ ### Changed
25
+
26
+ - fix exports in package.json
27
+
10
28
  ## [1.1.0] - 2026-04-23
11
29
 
30
+ ### Changed
31
+
32
+ - gen index.d.ts with `dts-bundle-generator` so that consumers don't typecheck the source files
12
33
 
13
34
  ## [1.0.1] - 2026-04-22
14
35
 
@@ -43,7 +64,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
64
  - 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`).
44
65
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
45
66
 
46
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.1.0...HEAD
67
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.2.0...HEAD
68
+ [1.2.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.2.0
69
+ [1.1.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.1.1
47
70
  [1.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.1.0
48
71
  [1.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.0.1
49
72
  [1.0.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.0.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Dombrowski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -29,40 +29,35 @@ Shell completions! -->
29
29
  ## Usage
30
30
 
31
31
  ```typescript
32
- import { cliRun, CliCommand, CliOptionKind, CliFallbackMode } from "argsbarg";
32
+ import { cliRun, CliCommand, CliOptionKind } from "argsbarg";
33
33
 
34
34
  const cli: CliCommand = {
35
35
  key: "helloapp",
36
36
  description: "Tiny demo.",
37
- commands: [
37
+ positionals: [
38
38
  {
39
- key: "hello",
40
- description: "Say hello.",
41
- options: [
42
- {
43
- name: "name",
44
- description: "Who to greet.",
45
- kind: CliOptionKind.String,
46
- shortName: "n",
47
- },
48
- {
49
- name: "verbose",
50
- description: "Enable extra logging.",
51
- kind: CliOptionKind.Presence,
52
- shortName: "v",
53
- },
54
- ],
55
- handler: async (ctx) => {
56
- const name = ctx.stringOpt("name") ?? "world";
57
- if (ctx.flag("verbose")) {
58
- console.log("verbose mode");
59
- }
60
- console.log(`hello ${name}`);
61
- },
39
+ name: "name",
40
+ description: "Who to greet.",
41
+ kind: CliOptionKind.String,
42
+ argMin: 0,
43
+ argMax: 1,
62
44
  },
63
45
  ],
64
- fallbackCommand: "hello",
65
- fallbackMode: CliFallbackMode.MissingOrUnknown,
46
+ options: [
47
+ {
48
+ name: "verbose",
49
+ description: "Enable extra logging.",
50
+ kind: CliOptionKind.Presence,
51
+ shortName: "v",
52
+ },
53
+ ],
54
+ handler: async (ctx) => {
55
+ const name = ctx.args[0] ?? "world";
56
+ if (ctx.hasFlag("verbose")) {
57
+ console.log("verbose mode");
58
+ }
59
+ console.log(`hello ${name}`);
60
+ },
66
61
  };
67
62
 
68
63
  await cliRun(cli);
@@ -7,40 +7,35 @@ readers can copy the pattern into their own scripts quickly.
7
7
  It demonstrates the minimal Bun integration path.
8
8
  */
9
9
 
10
- import { cliRun, CliCommand, CliOptionKind, CliFallbackMode } from "../src/index.ts";
10
+ import { cliRun, CliCommand, CliOptionKind } from "../src/index.ts";
11
11
 
12
12
  const cli: CliCommand = {
13
13
  key: "minimal.ts",
14
14
  description: "Tiny demo.",
15
- commands: [
15
+ positionals: [
16
16
  {
17
- key: "hello",
18
- description: "Say hello.",
19
- options: [
20
- {
21
- name: "name",
22
- description: "Who to greet.",
23
- kind: CliOptionKind.String,
24
- shortName: "n",
25
- },
26
- {
27
- name: "verbose",
28
- description: "Enable extra logging.",
29
- kind: CliOptionKind.Presence,
30
- shortName: "v",
31
- },
32
- ],
33
- handler: (ctx) => {
34
- const name = ctx.stringOpt("name") ?? "world";
35
- if (ctx.hasFlag("verbose")) {
36
- console.log("verbose mode");
37
- }
38
- console.log(`hello ${name}`);
39
- },
17
+ name: "name",
18
+ description: "Who to greet.",
19
+ kind: CliOptionKind.String,
20
+ argMin: 0,
21
+ argMax: 1,
40
22
  },
41
23
  ],
42
- fallbackCommand: "hello",
43
- fallbackMode: CliFallbackMode.MissingOrUnknown,
24
+ options: [
25
+ {
26
+ name: "verbose",
27
+ description: "Enable extra logging.",
28
+ kind: CliOptionKind.Presence,
29
+ shortName: "v",
30
+ },
31
+ ],
32
+ handler: (ctx) => {
33
+ const name = ctx.args[0] ?? "world";
34
+ if (ctx.hasFlag("verbose")) {
35
+ console.log("verbose mode");
36
+ }
37
+ console.log(`hello ${name}`);
38
+ },
44
39
  };
45
40
 
46
41
  await cliRun(cli);
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bun
2
+ /*
3
+ This example shows the smallest end-to-end CLI setup.
4
+ It includes one command, a couple of options, and a direct call to the runtime so
5
+ readers can copy the pattern into their own scripts quickly.
6
+
7
+ It demonstrates the minimal Bun integration path.
8
+ */
9
+
10
+ import { cliRun, CliCommand, CliOptionKind, CliFallbackMode, isInteractiveTty } from "../src/index.ts";
11
+
12
+ const cli: CliCommand = {
13
+ key: "option-required.ts",
14
+ description: "Demo of a required option.",
15
+ options: [
16
+ {
17
+ name: "requiredAlways",
18
+ description: "Always required string option.",
19
+ kind: CliOptionKind.String,
20
+ required: true,
21
+ shortName: "a",
22
+ },
23
+ {
24
+ name: "requiredNonTty",
25
+ description: "Required when not running in a tty.",
26
+ kind: CliOptionKind.String,
27
+ required: !isInteractiveTty,
28
+ shortName: "t",
29
+ },
30
+ {
31
+ name: "optional",
32
+ description: "optional string option.",
33
+ kind: CliOptionKind.String,
34
+ shortName: "o",
35
+ },
36
+ ],
37
+ handler: (ctx) => {
38
+ const requiredAlways = ctx.stringOpt("requiredAlways")!;
39
+ const requiredNonTty = ctx.stringOpt("requiredNonTty") ?? "valueWhenOmitted";
40
+ const optional = ctx.stringOpt("optional") ?? "valueWhenOmitted";
41
+ console.log(`requiredAlways: ${requiredAlways}`);
42
+ console.log(`requiredNonTty: ${requiredNonTty}`);
43
+ console.log(`optional: ${optional}`);
44
+ },
45
+ };
46
+
47
+ await cliRun(cli);
package/index.d.ts CHANGED
@@ -42,6 +42,8 @@ export interface CliOption {
42
42
  kind: CliOptionKind;
43
43
  /** Short option character (e.g., 'n' for -n). */
44
44
  shortName?: string;
45
+ /** Whether this option must be provided. Cannot be used with Presence kind. */
46
+ required?: boolean;
45
47
  }
46
48
  /**
47
49
  * An ordered positional argument slot, listed on `CliCommand.positionals`.
@@ -65,13 +67,9 @@ export interface CliPositional {
65
67
  argMax?: number;
66
68
  }
67
69
  /**
68
- * A command node: routing group (has commands) or leaf (has handler).
69
- *
70
- * The value passed to cliRun is the program root: name is the app/binary name,
71
- * commands are top-level subcommands, options are global flags.
72
- * The root must not set handler or declare positionals (validated at startup).
70
+ * Base properties shared by all command nodes.
73
71
  */
74
- export interface CliCommand {
72
+ export interface CliCommandBase {
75
73
  /** Program or command key (e.g., "myapp", "stat", "owner"). */
76
74
  key: string;
77
75
  /** Short description shown in help. */
@@ -80,17 +78,36 @@ export interface CliCommand {
80
78
  notes?: string;
81
79
  /** Global or command-level flags/options. */
82
80
  options?: CliOption[];
81
+ }
82
+ /**
83
+ * A command node: either a routing group (has commands) or a leaf (has handler).
84
+ *
85
+ * The value passed to cliRun is the program root: name is the app/binary name.
86
+ * The root may be a routing group or a leaf command.
87
+ */
88
+ export type CliCommand = (CliCommandBase & {
89
+ /** Handler function for leaf commands. */
90
+ handler: CliHandler;
83
91
  /** Positional argument definitions. */
84
92
  positionals?: CliPositional[];
85
93
  /** Nested subcommands (empty for leaf commands). */
86
- commands?: CliCommand[];
87
- /** Handler function for leaf commands. */
88
- handler?: CliHandler;
89
- /** Default top-level subcommand when argv omits a command or uses an unknown first token (root only). */
94
+ commands?: never;
95
+ /** Default top-level subcommand (routing commands only). */
96
+ fallbackCommand?: never;
97
+ /** How fallbackCommand is applied (routing commands only). */
98
+ fallbackMode?: never;
99
+ }) | (CliCommandBase & {
100
+ /** Nested subcommands. */
101
+ commands: CliCommand[];
102
+ /** Default top-level subcommand when argv omits a command or uses an unknown first token. */
90
103
  fallbackCommand?: string;
91
- /** How fallbackCommand is applied (root only). */
104
+ /** How fallbackCommand is applied. */
92
105
  fallbackMode?: CliFallbackMode;
93
- }
106
+ /** Handler function (leaf commands only). */
107
+ handler?: never;
108
+ /** Positional argument definitions (leaf commands only). */
109
+ positionals?: never;
110
+ });
94
111
  /**
95
112
  * Handler closure type for leaf commands.
96
113
  * Supports both sync and async handlers.
@@ -137,5 +154,7 @@ export declare function cliRun(root: CliCommand, argv?: string[]): Promise<never
137
154
  * Prints a red error line and contextual help on stderr, then exits with status 1.
138
155
  */
139
156
  export declare function cliErrWithHelp(ctx: CliContext, msg: string): never;
157
+ /** True when stdin is a TTY. */
158
+ export declare const isInteractiveTty: boolean;
140
159
 
141
160
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "//just": "echo this app uses justfile for development tasks"
@@ -12,7 +12,10 @@
12
12
  "argsbarg": "src/index.ts"
13
13
  },
14
14
  "exports": {
15
- ".": "./src/index.ts"
15
+ ".": {
16
+ "types": "./index.d.ts",
17
+ "default": "./src/index.ts"
18
+ }
16
19
  },
17
20
  "devDependencies": {
18
21
  "@types/bun": "^1.3.12"
package/src/help.ts CHANGED
@@ -64,8 +64,8 @@ function getHelpWidth(): number {
64
64
  }
65
65
 
66
66
  /** True when stdout is a TTY (used to decide on color). */
67
- function isTTY(): boolean {
68
- return process.stdout.isTTY !== undefined;
67
+ function isStdoutTTY(): boolean {
68
+ return !!process.stdout.isTTY;
69
69
  }
70
70
 
71
71
  // ── Width Helpers ─────────────────────────────────────────────────────────────
@@ -343,7 +343,8 @@ function rowsForOptions(defs: CliOption[], color: boolean): HelpRow[] {
343
343
  : "--help, -h";
344
344
  rows.push({ label: helpLabel, description: "Show help for this command." });
345
345
  for (const o of defs) {
346
- rows.push({ label: cliOptionLabel(o, color), description: o.description });
346
+ const desc = o.required ? "(required) " + o.description : o.description;
347
+ rows.push({ label: cliOptionLabel(o, color), description: desc });
347
348
  }
348
349
  return rows;
349
350
  }
@@ -368,7 +369,7 @@ function rowsForSubcommands(cmds: CliCommand[]): HelpRow[] {
368
369
  */
369
370
  export function cliHelpRender(schema: CliCommand, helpPath: string[], useStderr: boolean): string {
370
371
  const hw = getHelpWidth();
371
- const color = isTTY();
372
+ const color = isStdoutTTY();
372
373
 
373
374
  if (helpPath.length === 0) {
374
375
  const lines: string[] = [];
package/src/index.test.ts CHANGED
@@ -12,6 +12,7 @@ import { CliCommand, CliFallbackMode, CliOptionKind } from "./index.ts";
12
12
  import { ParseKind, parse, postParseValidate } from "./parse.ts";
13
13
  import { cliValidateRoot } from "./validate.ts";
14
14
  import { expect, test } from "bun:test";
15
+ import { $ } from "bun";
15
16
 
16
17
  test("bundled short presence flags", () => {
17
18
  const root: CliCommand = {
@@ -175,33 +176,7 @@ test("supports scientific notation in numbers", () => {
175
176
  expect(Number(pr.opts["n"])).toBe(12300);
176
177
  });
177
178
 
178
- test("root must not have handler", () => {
179
- const root: CliCommand = {
180
- key: "app",
181
- description: "",
182
- commands: [{ key: "x", description: "", handler: () => {} }],
183
- handler: () => {},
184
- };
185
- expect(() => cliValidateRoot(root)).toThrow(/Program root must not set handler/);
186
- });
187
179
 
188
- test("root must not have positionals", () => {
189
- const root: CliCommand = {
190
- key: "app",
191
- description: "",
192
- positionals: [
193
- {
194
- name: "p",
195
- description: "",
196
- kind: CliOptionKind.String,
197
- argMin: 1,
198
- argMax: 1,
199
- },
200
- ],
201
- commands: [{ key: "x", description: "", handler: () => {} }],
202
- };
203
- expect(() => cliValidateRoot(root)).toThrow(/Program root must not declare positionals/);
204
- });
205
180
 
206
181
  test("completion scripts contain app name", () => {
207
182
  const root: CliCommand = {
@@ -296,4 +271,90 @@ test("stops parsing options at --", () => {
296
271
  expect(pr.kind).toBe(ParseKind.Ok);
297
272
  expect(pr.opts["name"]).toBe("pat");
298
273
  expect(pr.args).toEqual(["--name", "bob", "-x"]);
274
+ });
275
+
276
+ test("missing required option returns error", () => {
277
+ const root: CliCommand = {
278
+ key: "app",
279
+ description: "",
280
+ options: [
281
+ {
282
+ name: "req",
283
+ description: "",
284
+ kind: CliOptionKind.String,
285
+ required: true,
286
+ },
287
+ ],
288
+ commands: [
289
+ {
290
+ key: "x",
291
+ description: "cmd",
292
+ handler: () => {},
293
+ },
294
+ ],
295
+ };
296
+ cliValidateRoot(root);
297
+ const pr = postParseValidate(root, parse(root, ["x"]));
298
+ expect(pr.kind).toBe(ParseKind.Error);
299
+ expect(pr.errorMsg).toContain("Missing required option: --req");
300
+ });
301
+
302
+ test("provided required option parses ok", () => {
303
+ const root: CliCommand = {
304
+ key: "app",
305
+ description: "",
306
+ commands: [
307
+ {
308
+ key: "x",
309
+ description: "cmd",
310
+ options: [
311
+ {
312
+ name: "req",
313
+ description: "",
314
+ kind: CliOptionKind.String,
315
+ required: true,
316
+ },
317
+ ],
318
+ handler: () => {},
319
+ },
320
+ ],
321
+ };
322
+ cliValidateRoot(root);
323
+ const pr = postParseValidate(root, parse(root, ["x", "--req", "val"]));
324
+ expect(pr.kind).toBe(ParseKind.Ok);
325
+ expect(pr.opts["req"]).toBe("val");
326
+ });
327
+
328
+ test("presence option cannot be required", () => {
329
+ const root: CliCommand = {
330
+ key: "app",
331
+ description: "",
332
+ options: [
333
+ {
334
+ name: "flag",
335
+ description: "",
336
+ kind: CliOptionKind.Presence,
337
+ required: true,
338
+ },
339
+ ],
340
+ commands: [
341
+ {
342
+ key: "x",
343
+ description: "cmd",
344
+ handler: () => {},
345
+ },
346
+ ],
347
+ };
348
+ expect(() => cliValidateRoot(root)).toThrow(/Presence option cannot be required/);
349
+ });
350
+
351
+ test("leaf completion help prints correctly", async () => {
352
+ // Test the fix where `completion zsh -h` on a leaf root was incorrectly ignored.
353
+ // We run this as a subprocess so we don't accidentally exit the test runner.
354
+ const { stdout, stderr, exitCode } = await $`bun run examples/minimal.ts completion zsh -h`.nothrow().quiet();
355
+ const out = stdout.toString();
356
+ expect(exitCode).toBe(0);
357
+ expect(out).toContain("Show help for this command.");
358
+ expect(out).toContain("Output is the whole script.");
359
+ expect(stderr.toString()).toBe("");
299
360
  });
package/src/index.ts CHANGED
@@ -11,3 +11,4 @@ export { CliContext } from "./context.ts";
11
11
  export { cliErrWithHelp, cliRun } from "./runtime";
12
12
  export { CliFallbackMode, CliOptionKind, CliSchemaValidationError } from "./types.ts";
13
13
  export type { CliCommand, CliHandler, CliOption, CliPositional } from "./types.ts";
14
+ export { isInteractiveTty } from "./utils.ts";
package/src/parse.ts CHANGED
@@ -328,6 +328,10 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
328
328
  let cmdName: string;
329
329
  let node: CliCommand | undefined;
330
330
 
331
+ if (root.handler) {
332
+ return finishLeaf(root as any, i, argv, path, opts);
333
+ }
334
+
331
335
  if (i >= argv.length) {
332
336
  if (root.fallbackCommand !== undefined && ((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOnly || (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown)) {
333
337
  cmdName = root.fallbackCommand;
@@ -484,6 +488,21 @@ export function postParseValidate(root: CliCommand, pr: ParseResult): ParseResul
484
488
  cmds = ch.commands ?? [];
485
489
  }
486
490
 
491
+ for (const d of defs) {
492
+ if (d.required && !(d.name in pr.opts)) {
493
+ return {
494
+ kind: ParseKind.Error,
495
+ path: pr.path,
496
+ opts: {},
497
+ args: [],
498
+ helpExplicit: false,
499
+ helpPath: [],
500
+ errorMsg: `Missing required option: --${d.name}`,
501
+ errorHelpPath: pr.path,
502
+ };
503
+ }
504
+ }
505
+
487
506
  for (const [k, v] of Object.entries(pr.opts)) {
488
507
  const d = findOptionByName(defs, k);
489
508
  if (!d) {
package/src/runtime.ts CHANGED
@@ -18,9 +18,12 @@ import { cliValidateRoot } from "./validate.ts";
18
18
  * Merges the caller's program root with the reserved `completion` subtree.
19
19
  */
20
20
  function cliRootMergedWithBuiltins(root: CliCommand): CliCommand {
21
- const merged = { ...root };
21
+ if (root.handler) {
22
+ return root;
23
+ }
24
+ const merged = { ...root } as any;
22
25
  merged.commands = [...(root.commands ?? []), cliBuiltinCompletionGroup(root.key)];
23
- return merged;
26
+ return merged as CliCommand;
24
27
  }
25
28
 
26
29
  /**
@@ -41,12 +44,27 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
41
44
  process.exit(1);
42
45
  }
43
46
 
44
- const merged = cliRootMergedWithBuiltins(root);
45
- let pr = parse(merged, argv);
46
- pr = postParseValidate(merged, pr);
47
+ let parseRoot = root;
48
+ let isLeafCompletionIntercept = false;
49
+
50
+ // Intercept completion for Leaf roots (since they can't natively have a completion subcommand)
51
+ // but wrap them in a dummy router so that the parser handles `-h` and errors correctly.
52
+ if (root.handler && argv.length >= 1 && argv[0] === "completion") {
53
+ isLeafCompletionIntercept = true;
54
+ parseRoot = {
55
+ key: root.key,
56
+ description: root.description,
57
+ commands: [cliBuiltinCompletionGroup(root.key)],
58
+ } as any;
59
+ } else {
60
+ parseRoot = cliRootMergedWithBuiltins(root);
61
+ }
62
+
63
+ let pr = parse(parseRoot, argv);
64
+ pr = postParseValidate(parseRoot, pr);
47
65
 
48
66
  if (pr.kind === "help") {
49
- process.stdout.write(cliHelpRender(merged, pr.helpPath, false));
67
+ process.stdout.write(cliHelpRender(parseRoot, pr.helpPath, false));
50
68
  process.exit(pr.helpExplicit ? 0 : 1);
51
69
  }
52
70
 
@@ -54,27 +72,28 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
54
72
  const color = process.stderr.isTTY;
55
73
  const msg = color ? `\u001B[31m${pr.errorMsg}\u001B[0m` : pr.errorMsg;
56
74
  process.stderr.write(msg + "\n");
57
- process.stderr.write(cliHelpRender(merged, pr.errorHelpPath, true));
75
+ process.stderr.write(cliHelpRender(parseRoot, pr.errorHelpPath, true));
58
76
  process.exit(1);
59
77
  }
60
78
 
61
- if (pr.path.length === 0) {
62
- process.stderr.write("Internal error: empty path.\n");
63
- process.exit(1);
64
- }
79
+ // Leaf roots have an empty path; that's normal.
65
80
 
66
81
  if (pr.path[0] === "completion") {
82
+ // If we intercepted a leaf, we MUST pass the original `root` to generate completions
83
+ // because `parseRoot` is just a dummy router!
84
+ const schemaForCompletion = isLeafCompletionIntercept ? root : parseRoot;
85
+
67
86
  if (pr.path[1] === "bash") {
68
- process.stdout.write(completionBashScript(merged));
87
+ process.stdout.write(completionBashScript(schemaForCompletion));
69
88
  process.exit(0);
70
89
  }
71
90
  if (pr.path[1] === "zsh") {
72
- process.stdout.write(completionZshScript(merged));
91
+ process.stdout.write(completionZshScript(schemaForCompletion));
73
92
  process.exit(0);
74
93
  }
75
94
  }
76
95
 
77
- let current = merged;
96
+ let current = parseRoot;
78
97
  for (const seg of pr.path) {
79
98
  const ch = (current.commands ?? []).find((candidate: CliCommand) => candidate.key === seg);
80
99
  if (!ch) {
@@ -89,7 +108,7 @@ export async function cliRun(root: CliCommand, argv: string[] = process.argv.sli
89
108
  process.exit(1);
90
109
  }
91
110
 
92
- const ctx = new CliContext(merged.key, pr.path, pr.args, pr.opts, merged);
111
+ const ctx = new CliContext(parseRoot.key, pr.path, pr.args, pr.opts, parseRoot);
93
112
  try {
94
113
  await Promise.resolve(current.handler(ctx));
95
114
  process.exit(0);
package/src/types.ts CHANGED
@@ -52,6 +52,8 @@ export interface CliOption {
52
52
  kind: CliOptionKind;
53
53
  /** Short option character (e.g., 'n' for -n). */
54
54
  shortName?: string;
55
+ /** Whether this option must be provided. Cannot be used with Presence kind. */
56
+ required?: boolean;
55
57
  }
56
58
 
57
59
  /**
@@ -77,13 +79,9 @@ export interface CliPositional {
77
79
  }
78
80
 
79
81
  /**
80
- * A command node: routing group (has commands) or leaf (has handler).
81
- *
82
- * The value passed to cliRun is the program root: name is the app/binary name,
83
- * commands are top-level subcommands, options are global flags.
84
- * The root must not set handler or declare positionals (validated at startup).
82
+ * Base properties shared by all command nodes.
85
83
  */
86
- export interface CliCommand {
84
+ export interface CliCommandBase {
87
85
  /** Program or command key (e.g., "myapp", "stat", "owner"). */
88
86
  key: string;
89
87
  /** Short description shown in help. */
@@ -92,18 +90,40 @@ export interface CliCommand {
92
90
  notes?: string;
93
91
  /** Global or command-level flags/options. */
94
92
  options?: CliOption[];
95
- /** Positional argument definitions. */
96
- positionals?: CliPositional[];
97
- /** Nested subcommands (empty for leaf commands). */
98
- commands?: CliCommand[];
99
- /** Handler function for leaf commands. */
100
- handler?: CliHandler;
101
- /** Default top-level subcommand when argv omits a command or uses an unknown first token (root only). */
102
- fallbackCommand?: string;
103
- /** How fallbackCommand is applied (root only). */
104
- fallbackMode?: CliFallbackMode;
105
93
  }
106
94
 
95
+ /**
96
+ * A command node: either a routing group (has commands) or a leaf (has handler).
97
+ *
98
+ * The value passed to cliRun is the program root: name is the app/binary name.
99
+ * The root may be a routing group or a leaf command.
100
+ */
101
+ export type CliCommand =
102
+ | (CliCommandBase & {
103
+ /** Handler function for leaf commands. */
104
+ handler: CliHandler;
105
+ /** Positional argument definitions. */
106
+ positionals?: CliPositional[];
107
+ /** Nested subcommands (empty for leaf commands). */
108
+ commands?: never;
109
+ /** Default top-level subcommand (routing commands only). */
110
+ fallbackCommand?: never;
111
+ /** How fallbackCommand is applied (routing commands only). */
112
+ fallbackMode?: never;
113
+ })
114
+ | (CliCommandBase & {
115
+ /** Nested subcommands. */
116
+ commands: CliCommand[];
117
+ /** Default top-level subcommand when argv omits a command or uses an unknown first token. */
118
+ fallbackCommand?: string;
119
+ /** How fallbackCommand is applied. */
120
+ fallbackMode?: CliFallbackMode;
121
+ /** Handler function (leaf commands only). */
122
+ handler?: never;
123
+ /** Positional argument definitions (leaf commands only). */
124
+ positionals?: never;
125
+ });
126
+
107
127
  /**
108
128
  * Handler closure type for leaf commands.
109
129
  * Supports both sync and async handlers.
package/src/utils.ts CHANGED
@@ -22,3 +22,6 @@ export function strictParseDouble(s: string): number | null {
22
22
  const num = Number(s);
23
23
  return Number.isNaN(num) ? null : num;
24
24
  }
25
+
26
+ /** True when stdin is a TTY. */
27
+ export const isInteractiveTty = !!process.stdin.isTTY;
package/src/validate.ts CHANGED
@@ -9,6 +9,7 @@ It fails early on structural problems so invalid trees never reach parsing or di
9
9
  import {
10
10
  CliCommand,
11
11
  CliFallbackMode,
12
+ CliOptionKind,
12
13
  CliSchemaValidationError,
13
14
  } from "./types.ts";
14
15
 
@@ -19,13 +20,6 @@ const reservedCommandNames = ["completion"];
19
20
  * Throws CliSchemaValidationError if rules are violated.
20
21
  */
21
22
  export function cliValidateRoot(root: CliCommand): void {
22
- // Root-level rules
23
- if (root.handler !== undefined) {
24
- throw new CliSchemaValidationError("Program root must not set handler");
25
- }
26
- if ((root.positionals ?? []).length > 0) {
27
- throw new CliSchemaValidationError("Program root must not declare positionals");
28
- }
29
23
 
30
24
  // Check for reserved command names at root
31
25
  for (const child of root.commands ?? []) {
@@ -56,15 +50,6 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
56
50
  );
57
51
  }
58
52
 
59
- if ((cmd.commands ?? []).length > 0) {
60
- if (cmd.handler !== undefined) {
61
- throw new CliSchemaValidationError(`Routing command must not set handler: ${cmd.key}`);
62
- }
63
- } else {
64
- if (cmd.handler === undefined) {
65
- throw new CliSchemaValidationError(`Leaf command requires handler: ${cmd.key}`);
66
- }
67
- }
68
53
 
69
54
  // Check for duplicate child names
70
55
  const seenNames = new Set<string>();
@@ -75,9 +60,15 @@ function walkCommand(cmd: CliCommand, isRoot: boolean = false): void {
75
60
  seenNames.add(child.key);
76
61
  }
77
62
 
78
- // Validate options (short name uniqueness, reserved -h)
63
+ // Validate options (short name uniqueness, reserved -h, required presence)
79
64
  const seenShorts = new Set<string>();
80
65
  for (const opt of cmd.options ?? []) {
66
+ if (opt.required && opt.kind === CliOptionKind.Presence) {
67
+ throw new CliSchemaValidationError(
68
+ `Presence option cannot be required: ${cmd.key}/${opt.name}`,
69
+ );
70
+ }
71
+
81
72
  if (opt.shortName !== undefined) {
82
73
  if (opt.shortName === "h") {
83
74
  throw new CliSchemaValidationError(