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 +24 -1
- package/LICENSE +21 -0
- package/README.md +22 -27
- package/examples/minimal.ts +22 -27
- package/examples/option-required.ts +47 -0
- package/index.d.ts +31 -12
- package/package.json +5 -2
- package/src/help.ts +5 -4
- package/src/index.test.ts +87 -26
- package/src/index.ts +1 -0
- package/src/parse.ts +19 -0
- package/src/runtime.ts +34 -15
- package/src/types.ts +36 -16
- package/src/utils.ts +3 -0
- package/src/validate.ts +8 -17
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.
|
|
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
|
|
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);
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "argsbarg",
|
|
3
|
-
"version": "1.
|
|
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
|
-
".":
|
|
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
|
|
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 = {
|
|
@@ -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
|
-
|
|
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(
|