argsbarg 1.1.1 → 1.2.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/.github/copilot-instructions.md.md +8 -0
- package/.github/pull_request_template.md +13 -0
- package/CHANGELOG.md +22 -1
- package/CLAUDE.md +8 -0
- package/LICENSE +21 -0
- package/README.md +22 -27
- package/examples/minimal.ts +22 -27
- package/examples/nested.ts +12 -1
- package/examples/option-required.ts +47 -0
- package/index.d.ts +31 -12
- package/package.json +1 -1
- package/src/help.ts +5 -4
- package/src/index.test.ts +204 -26
- package/src/index.ts +1 -0
- package/src/parse.ts +67 -5
- package/src/runtime.ts +34 -15
- package/src/types.ts +36 -16
- package/src/utils.ts +3 -0
- package/src/validate.ts +8 -17
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
## Summary
|
|
2
|
+
|
|
3
|
+
<!-- What does this PR change and why? -->
|
|
4
|
+
|
|
5
|
+
## Changelog
|
|
6
|
+
|
|
7
|
+
**Every PR must update `CHANGELOG.md` under `## [Unreleased]`**: use `### Added`, `### Changed`, `### Fixed`, or `### Removed` as appropriate; one idea per bullet.
|
|
8
|
+
|
|
9
|
+
<!-- If you claimed no-op above, briefly say why no changelog entry is warranted. -->
|
|
10
|
+
|
|
11
|
+
## Testing
|
|
12
|
+
|
|
13
|
+
<!-- How did you verify this (local build, tests, manual run, etc.)? -->
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.2.1] - 2026-06-18
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Trailing options** — when a leaf command has only bounded positionals (`argMax !== 0`), options may appear after positional arguments (e.g. `cmd ./file --verbose`). Commands with a varargs tail (`argMax: 0`) keep the previous behavior.
|
|
15
|
+
- **`examples/nested.ts`** — `stat` accepts `--json`; `stat owner lookup` prints JSON when the flag is set.
|
|
16
|
+
|
|
17
|
+
## [1.2.0] - 2026-04-24
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`CliOption.required`** — makes an option required when parsing
|
|
22
|
+
- **`isInteractiveTty`** - a computed boolean of whether the app is running in an interactive tty
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`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.
|
|
28
|
+
|
|
10
29
|
## [1.1.1] - 2026-04-23
|
|
11
30
|
|
|
12
31
|
### Changed
|
|
@@ -52,7 +71,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
52
71
|
- 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`).
|
|
53
72
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
54
73
|
|
|
55
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.
|
|
74
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v1.2.1...HEAD
|
|
75
|
+
[1.2.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.2.1
|
|
76
|
+
[1.2.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.2.0
|
|
56
77
|
[1.1.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.1.1
|
|
57
78
|
[1.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.1.0
|
|
58
79
|
[1.0.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v1.0.1
|
package/CLAUDE.md
ADDED
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
|
|
32
|
+
import { cliRun, CliCommand, CliOptionKind } from "argsbarg";
|
|
33
33
|
|
|
34
34
|
const cli: CliCommand = {
|
|
35
35
|
key: "helloapp",
|
|
36
36
|
description: "Tiny demo.",
|
|
37
|
-
|
|
37
|
+
positionals: [
|
|
38
38
|
{
|
|
39
|
-
|
|
40
|
-
description: "
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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);
|
package/examples/minimal.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
15
|
+
positionals: [
|
|
16
16
|
{
|
|
17
|
-
|
|
18
|
-
description: "
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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);
|
package/examples/nested.ts
CHANGED
|
@@ -16,6 +16,13 @@ const cli: CliCommand = {
|
|
|
16
16
|
{
|
|
17
17
|
key: "stat",
|
|
18
18
|
description: "File metadata.",
|
|
19
|
+
options: [
|
|
20
|
+
{
|
|
21
|
+
name: "json",
|
|
22
|
+
description: "Emit handler output as JSON.",
|
|
23
|
+
kind: CliOptionKind.Presence,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
19
26
|
commands: [
|
|
20
27
|
{
|
|
21
28
|
key: "owner",
|
|
@@ -46,7 +53,11 @@ const cli: CliCommand = {
|
|
|
46
53
|
console.error("Missing path.");
|
|
47
54
|
process.exit(1);
|
|
48
55
|
}
|
|
49
|
-
|
|
56
|
+
if (ctx.hasFlag("json")) {
|
|
57
|
+
console.log(JSON.stringify({ user, path }));
|
|
58
|
+
} else {
|
|
59
|
+
console.log(`lookup user=${user} path=${path}`);
|
|
60
|
+
}
|
|
50
61
|
},
|
|
51
62
|
},
|
|
52
63
|
],
|
|
@@ -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
|
-
*
|
|
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
|
|
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?:
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
/**
|
|
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
|
|
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
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
|
|
68
|
-
return process.stdout.isTTY
|
|
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
|
-
|
|
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 =
|
|
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 = {
|
|
@@ -263,6 +238,123 @@ test("completion scripts keep dotted app names in registration names", () => {
|
|
|
263
238
|
expect(zsh).toContain("compdef _minimal_ts minimal.ts");
|
|
264
239
|
});
|
|
265
240
|
|
|
241
|
+
test("trailing options after bounded positionals", () => {
|
|
242
|
+
const root: CliCommand = {
|
|
243
|
+
key: "app",
|
|
244
|
+
description: "",
|
|
245
|
+
commands: [
|
|
246
|
+
{
|
|
247
|
+
key: "x",
|
|
248
|
+
description: "cmd",
|
|
249
|
+
options: [
|
|
250
|
+
{
|
|
251
|
+
name: "verbose",
|
|
252
|
+
description: "",
|
|
253
|
+
kind: CliOptionKind.Presence,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
positionals: [
|
|
257
|
+
{
|
|
258
|
+
name: "path",
|
|
259
|
+
description: "",
|
|
260
|
+
kind: CliOptionKind.String,
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
handler: () => {},
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
cliValidateRoot(root);
|
|
268
|
+
const pr = postParseValidate(root, parse(root, ["x", "./file", "--verbose"]));
|
|
269
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
270
|
+
expect(pr.args).toEqual(["./file"]);
|
|
271
|
+
expect(pr.opts["verbose"]).toBe("1");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("trailing options include parent-scoped flags", () => {
|
|
275
|
+
const root: CliCommand = {
|
|
276
|
+
key: "app",
|
|
277
|
+
description: "",
|
|
278
|
+
commands: [
|
|
279
|
+
{
|
|
280
|
+
key: "group",
|
|
281
|
+
description: "group",
|
|
282
|
+
options: [
|
|
283
|
+
{
|
|
284
|
+
name: "json",
|
|
285
|
+
description: "",
|
|
286
|
+
kind: CliOptionKind.Presence,
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
commands: [
|
|
290
|
+
{
|
|
291
|
+
key: "leaf",
|
|
292
|
+
description: "leaf",
|
|
293
|
+
options: [
|
|
294
|
+
{
|
|
295
|
+
name: "user",
|
|
296
|
+
description: "",
|
|
297
|
+
kind: CliOptionKind.String,
|
|
298
|
+
shortName: "u",
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
positionals: [
|
|
302
|
+
{
|
|
303
|
+
name: "path",
|
|
304
|
+
description: "",
|
|
305
|
+
kind: CliOptionKind.String,
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
handler: () => {},
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
cliValidateRoot(root);
|
|
315
|
+
const pr = postParseValidate(root, parse(root, ["group", "leaf", "-u", "alice", "./file", "--json"]));
|
|
316
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
317
|
+
expect(pr.path).toEqual(["group", "leaf"]);
|
|
318
|
+
expect(pr.args).toEqual(["./file"]);
|
|
319
|
+
expect(pr.opts["user"]).toBe("alice");
|
|
320
|
+
expect(pr.opts["json"]).toBe("1");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("varargs tail does not parse trailing options", () => {
|
|
324
|
+
const root: CliCommand = {
|
|
325
|
+
key: "app",
|
|
326
|
+
description: "",
|
|
327
|
+
commands: [
|
|
328
|
+
{
|
|
329
|
+
key: "x",
|
|
330
|
+
description: "cmd",
|
|
331
|
+
options: [
|
|
332
|
+
{
|
|
333
|
+
name: "json",
|
|
334
|
+
description: "",
|
|
335
|
+
kind: CliOptionKind.Presence,
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
positionals: [
|
|
339
|
+
{
|
|
340
|
+
name: "files",
|
|
341
|
+
description: "",
|
|
342
|
+
kind: CliOptionKind.String,
|
|
343
|
+
argMin: 0,
|
|
344
|
+
argMax: 0,
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
handler: () => {},
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
};
|
|
351
|
+
cliValidateRoot(root);
|
|
352
|
+
const pr = postParseValidate(root, parse(root, ["x", "./file", "--json"]));
|
|
353
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
354
|
+
expect(pr.args).toEqual(["./file", "--json"]);
|
|
355
|
+
expect(pr.opts["json"]).toBeUndefined();
|
|
356
|
+
});
|
|
357
|
+
|
|
266
358
|
test("stops parsing options at --", () => {
|
|
267
359
|
const root: CliCommand = {
|
|
268
360
|
key: "app",
|
|
@@ -296,4 +388,90 @@ test("stops parsing options at --", () => {
|
|
|
296
388
|
expect(pr.kind).toBe(ParseKind.Ok);
|
|
297
389
|
expect(pr.opts["name"]).toBe("pat");
|
|
298
390
|
expect(pr.args).toEqual(["--name", "bob", "-x"]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("missing required option returns error", () => {
|
|
394
|
+
const root: CliCommand = {
|
|
395
|
+
key: "app",
|
|
396
|
+
description: "",
|
|
397
|
+
options: [
|
|
398
|
+
{
|
|
399
|
+
name: "req",
|
|
400
|
+
description: "",
|
|
401
|
+
kind: CliOptionKind.String,
|
|
402
|
+
required: true,
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
commands: [
|
|
406
|
+
{
|
|
407
|
+
key: "x",
|
|
408
|
+
description: "cmd",
|
|
409
|
+
handler: () => {},
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
cliValidateRoot(root);
|
|
414
|
+
const pr = postParseValidate(root, parse(root, ["x"]));
|
|
415
|
+
expect(pr.kind).toBe(ParseKind.Error);
|
|
416
|
+
expect(pr.errorMsg).toContain("Missing required option: --req");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("provided required option parses ok", () => {
|
|
420
|
+
const root: CliCommand = {
|
|
421
|
+
key: "app",
|
|
422
|
+
description: "",
|
|
423
|
+
commands: [
|
|
424
|
+
{
|
|
425
|
+
key: "x",
|
|
426
|
+
description: "cmd",
|
|
427
|
+
options: [
|
|
428
|
+
{
|
|
429
|
+
name: "req",
|
|
430
|
+
description: "",
|
|
431
|
+
kind: CliOptionKind.String,
|
|
432
|
+
required: true,
|
|
433
|
+
},
|
|
434
|
+
],
|
|
435
|
+
handler: () => {},
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
};
|
|
439
|
+
cliValidateRoot(root);
|
|
440
|
+
const pr = postParseValidate(root, parse(root, ["x", "--req", "val"]));
|
|
441
|
+
expect(pr.kind).toBe(ParseKind.Ok);
|
|
442
|
+
expect(pr.opts["req"]).toBe("val");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("presence option cannot be required", () => {
|
|
446
|
+
const root: CliCommand = {
|
|
447
|
+
key: "app",
|
|
448
|
+
description: "",
|
|
449
|
+
options: [
|
|
450
|
+
{
|
|
451
|
+
name: "flag",
|
|
452
|
+
description: "",
|
|
453
|
+
kind: CliOptionKind.Presence,
|
|
454
|
+
required: true,
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
commands: [
|
|
458
|
+
{
|
|
459
|
+
key: "x",
|
|
460
|
+
description: "cmd",
|
|
461
|
+
handler: () => {},
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
};
|
|
465
|
+
expect(() => cliValidateRoot(root)).toThrow(/Presence option cannot be required/);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("leaf completion help prints correctly", async () => {
|
|
469
|
+
// Test the fix where `completion zsh -h` on a leaf root was incorrectly ignored.
|
|
470
|
+
// We run this as a subprocess so we don't accidentally exit the test runner.
|
|
471
|
+
const { stdout, stderr, exitCode } = await $`bun run examples/minimal.ts completion zsh -h`.nothrow().quiet();
|
|
472
|
+
const out = stdout.toString();
|
|
473
|
+
expect(exitCode).toBe(0);
|
|
474
|
+
expect(out).toContain("Show help for this command.");
|
|
475
|
+
expect(out).toContain("Output is the whole script.");
|
|
476
|
+
expect(stderr.toString()).toBe("");
|
|
299
477
|
});
|
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
|
@@ -207,6 +207,26 @@ function consumeOptions(
|
|
|
207
207
|
|
|
208
208
|
// ── Positional Collection ─────────────────────────────────────────────────────
|
|
209
209
|
|
|
210
|
+
/** Merges option defs from the program root along the routed command path. */
|
|
211
|
+
function collectOptionDefs(root: CliCommand, path: string[]): CliOption[] {
|
|
212
|
+
let defs = [...(root.options ?? [])];
|
|
213
|
+
let cmds = root.commands ?? [];
|
|
214
|
+
|
|
215
|
+
for (const seg of path) {
|
|
216
|
+
const ch = findChild(cmds, seg);
|
|
217
|
+
if (!ch) break;
|
|
218
|
+
defs.push(...(ch.options ?? []));
|
|
219
|
+
cmds = ch.commands ?? [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return defs;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** True when every positional slot has bounded arity (no `argMax: 0` varargs tail). */
|
|
226
|
+
function allowsTrailingOptions(positionals: CliCommand["positionals"]): boolean {
|
|
227
|
+
return (positionals ?? []).every((p) => (p.argMax ?? 1) !== 0);
|
|
228
|
+
}
|
|
229
|
+
|
|
210
230
|
/** Fills `args` for a leaf from `startIdx` according to `node.positionals`. */
|
|
211
231
|
function finishLeaf(
|
|
212
232
|
node: CliCommand,
|
|
@@ -214,6 +234,8 @@ function finishLeaf(
|
|
|
214
234
|
argv: string[],
|
|
215
235
|
path: string[],
|
|
216
236
|
opts: Record<string, string>,
|
|
237
|
+
optionDefs: CliOption[],
|
|
238
|
+
forcePositionals: boolean,
|
|
217
239
|
): ParseResult {
|
|
218
240
|
/** Builds a parse error for positional consumption failures. */
|
|
219
241
|
function errorResult(msg: string): ParseResult {
|
|
@@ -243,8 +265,13 @@ function finishLeaf(
|
|
|
243
265
|
args.push(argv[idx]);
|
|
244
266
|
idx += 1;
|
|
245
267
|
} else if (idx < argv.length) {
|
|
246
|
-
|
|
247
|
-
|
|
268
|
+
const tok = argv[idx];
|
|
269
|
+
if (argMin < 1 && tok.startsWith("-")) {
|
|
270
|
+
// Optional slot: leave `-` tokens for trailing option parsing.
|
|
271
|
+
} else {
|
|
272
|
+
args.push(tok);
|
|
273
|
+
idx += 1;
|
|
274
|
+
}
|
|
248
275
|
}
|
|
249
276
|
continue;
|
|
250
277
|
}
|
|
@@ -269,7 +296,23 @@ function finishLeaf(
|
|
|
269
296
|
}
|
|
270
297
|
|
|
271
298
|
if (idx < argv.length) {
|
|
272
|
-
|
|
299
|
+
if (forcePositionals || !allowsTrailingOptions(node.positionals)) {
|
|
300
|
+
return errorResult("Unexpected extra arguments");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (isHelpTok(argv[idx])) {
|
|
304
|
+
return helpResult(path, true);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const tailRep = consumeOptions(optionDefs, false, argv, idx, opts);
|
|
308
|
+
if (tailRep.report.err) {
|
|
309
|
+
return errorResult(tailRep.report.err);
|
|
310
|
+
}
|
|
311
|
+
idx = tailRep.nextIndex;
|
|
312
|
+
|
|
313
|
+
if (idx < argv.length) {
|
|
314
|
+
return errorResult("Unexpected extra arguments");
|
|
315
|
+
}
|
|
273
316
|
}
|
|
274
317
|
|
|
275
318
|
return { kind: ParseKind.Ok, path, opts, args, helpExplicit: false, helpPath: [], errorMsg: "", errorHelpPath: [] };
|
|
@@ -328,6 +371,10 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
328
371
|
let cmdName: string;
|
|
329
372
|
let node: CliCommand | undefined;
|
|
330
373
|
|
|
374
|
+
if (root.handler) {
|
|
375
|
+
return finishLeaf(root as CliCommand, i, argv, path, opts, root.options ?? [], forcePositionals);
|
|
376
|
+
}
|
|
377
|
+
|
|
331
378
|
if (i >= argv.length) {
|
|
332
379
|
if (root.fallbackCommand !== undefined && ((root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOnly || (root.fallbackMode ?? CliFallbackMode.MissingOnly) === CliFallbackMode.MissingOrUnknown)) {
|
|
333
380
|
cmdName = root.fallbackCommand;
|
|
@@ -411,7 +458,7 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
411
458
|
if ((current.commands ?? []).length > 0) {
|
|
412
459
|
return helpResult(path, false);
|
|
413
460
|
}
|
|
414
|
-
return finishLeaf(current, i, argv, path, opts);
|
|
461
|
+
return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
|
|
415
462
|
}
|
|
416
463
|
|
|
417
464
|
const tok = argv[i];
|
|
@@ -451,7 +498,7 @@ export function parse(root: CliCommand, argv: string[]): ParseResult {
|
|
|
451
498
|
};
|
|
452
499
|
}
|
|
453
500
|
|
|
454
|
-
return finishLeaf(current, i, argv, path, opts);
|
|
501
|
+
return finishLeaf(current, i, argv, path, opts, collectOptionDefs(root, path), forcePositionals);
|
|
455
502
|
}
|
|
456
503
|
}
|
|
457
504
|
|
|
@@ -484,6 +531,21 @@ export function postParseValidate(root: CliCommand, pr: ParseResult): ParseResul
|
|
|
484
531
|
cmds = ch.commands ?? [];
|
|
485
532
|
}
|
|
486
533
|
|
|
534
|
+
for (const d of defs) {
|
|
535
|
+
if (d.required && !(d.name in pr.opts)) {
|
|
536
|
+
return {
|
|
537
|
+
kind: ParseKind.Error,
|
|
538
|
+
path: pr.path,
|
|
539
|
+
opts: {},
|
|
540
|
+
args: [],
|
|
541
|
+
helpExplicit: false,
|
|
542
|
+
helpPath: [],
|
|
543
|
+
errorMsg: `Missing required option: --${d.name}`,
|
|
544
|
+
errorHelpPath: pr.path,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
487
549
|
for (const [k, v] of Object.entries(pr.opts)) {
|
|
488
550
|
const d = findOptionByName(defs, k);
|
|
489
551
|
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
|
-
|
|
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
|
-
|
|
45
|
-
let
|
|
46
|
-
|
|
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(
|
|
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(
|
|
75
|
+
process.stderr.write(cliHelpRender(parseRoot, pr.errorHelpPath, true));
|
|
58
76
|
process.exit(1);
|
|
59
77
|
}
|
|
60
78
|
|
|
61
|
-
|
|
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(
|
|
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(
|
|
91
|
+
process.stdout.write(completionZshScript(schemaForCompletion));
|
|
73
92
|
process.exit(0);
|
|
74
93
|
}
|
|
75
94
|
}
|
|
76
95
|
|
|
77
|
-
let current =
|
|
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(
|
|
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
|
-
*
|
|
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
|
|
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
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(
|