@sdxc/cli 2026.10.16 → 2026.10.20

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/README.md CHANGED
@@ -157,7 +157,7 @@ What to ask when a flag or argument is missing on a terminal: `prompt.select({ m
157
157
 
158
158
  #### `createCLI({ name, version?, commands, middleware? })`
159
159
 
160
- The program. `--help` and `--version` come for free.
160
+ The program. `--help` and `--version` come for free, and `--help --json` prints the same facts as data.
161
161
 
162
162
  #### `cli.map(command, handler)`
163
163
 
@@ -167,6 +167,19 @@ Binds a handler, a `createCommand(...)` or a `lazy(...)` import to a command.
167
167
 
168
168
  Runs the command and answers its exit code; `io` replaces the process's `cwd`, `env`, streams or `exec`.
169
169
 
170
+ #### `cli.describe(path?)`
171
+
172
+ The contract as data: every visible command under `path` with its arguments, flags and examples, as `--help --json` prints it.
173
+
174
+ #### `cli.check(argv)`
175
+
176
+ Checks a command line without running it: the command, every flag and every value against its schema. A value a prompt would ask for passes. Answers the command's path, or a `UsageError` listing every problem.
177
+
178
+ ```typescript
179
+ let checked = await cli.check(["greet", "Ada", "--shuot"]);
180
+ if (isFailure(checked)) console.error(checked.error.message); // Unknown flag --shuot …
181
+ ```
182
+
170
183
  #### `createCommand(command, handler)`
171
184
 
172
185
  A handler typed from its command, for a module of its own; pass `{ middleware, handler }` for middleware this command alone runs.
package/dist/cli.d.ts CHANGED
@@ -6,12 +6,15 @@
6
6
  * @author [Sergio Xalambrí](https://sergiodxa.com)
7
7
  * @copyright Sergio Xalambrí 2026
8
8
  */
9
+ import type { Result } from "@sdxc/result";
9
10
  import type { ArgumentsOf, FlagsOf, Group, Node } from "./command.js";
11
+ import type { CommandDescription } from "./describe.js";
10
12
  import type { CommandAction, CommandHandler } from "./handler.js";
11
13
  import type { Lazy } from "./lazy.js";
12
14
  import type { AnyCommandMiddleware, ChainProperties } from "./middleware.js";
13
15
  import type { RunIO, Script } from "./session.js";
14
16
  import { CommandContext } from "./context.js";
17
+ import { UsageError } from "./errors.js";
15
18
  /** What a CLI is built from. */
16
19
  export interface CLIOptions<Commands extends Group, Chain extends readonly AnyCommandMiddleware[]> {
17
20
  /** The program name, as the usage line prints it. */
@@ -48,6 +51,28 @@ export interface CLI<Commands extends Group = Group, Chain extends readonly AnyC
48
51
  * @example process.exitCode = await cli.run(process.argv.slice(2));
49
52
  */
50
53
  run(argv: readonly string[], io?: RunIO): Promise<number>;
54
+ /**
55
+ * The contract as data, the facts `--help --json` prints: the whole program, or the command
56
+ * `path` names, with every visible member below it.
57
+ *
58
+ * @returns The description, or `undefined` when `path` names no command
59
+ * @example let make = cli.describe(["make"]);
60
+ */
61
+ describe(path?: readonly string[]): CommandDescription | undefined;
62
+ /**
63
+ * Checks `argv` the way a run does before it asks or runs anything: the command it names, every
64
+ * flag, and every value against its schema. A missing value that a prompt would ask for passes,
65
+ * since a terminal run asks for it. Nothing is loaded and no handler runs.
66
+ *
67
+ * @returns The path of the command `argv` reaches, or a usage failure listing every problem
68
+ * @example let checked = await cli.check(["make", "controller", "posts", "--method", "form"]);
69
+ */
70
+ check(argv: readonly string[]): Promise<Result<CheckedCommand, UsageError>>;
71
+ }
72
+ /** A command line `check()` accepted. */
73
+ export interface CheckedCommand {
74
+ /** The words that reached the command; empty for the root. */
75
+ path: string[];
51
76
  }
52
77
  /**
53
78
  * Builds a CLI over a command contract.
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { failure, isFailure, success } from "@sdxc/result";
10
10
  import { CommandContext } from "./context.js";
11
+ import { describeEntry } from "./describe.js";
11
12
  import { CancelledError, EXIT_CODES, exitCodeOf, UsageError } from "./errors.js";
12
13
  import { isAction } from "./handler.js";
13
14
  import { renderHelp } from "./help.js";
@@ -45,6 +46,15 @@ export function createCLI(options) {
45
46
  bindings.set(target, resolver(binding));
46
47
  },
47
48
  run: (argv, io = {}) => execute(cli, argv, io, undefined),
49
+ describe(path = []) {
50
+ let entry = tree.root;
51
+ for (let word of path)
52
+ entry = entry?.children.get(word);
53
+ if (entry === undefined)
54
+ return undefined;
55
+ return describeEntry(entry, { version: options.version, description: options.description });
56
+ },
57
+ check: (argv) => check(tree, options.version !== undefined, argv),
48
58
  };
49
59
  INTERNALS.set(cli, {
50
60
  tree,
@@ -82,6 +92,35 @@ function normalize(binding) {
82
92
  export function runScripted(cli, argv, io, script) {
83
93
  return execute(cli, argv, io, script);
84
94
  }
95
+ /**
96
+ * Whether `--json` rides along with `--help`, before any `--`, so the help prints as data. A
97
+ * command with a `--json` flag of its own reads the pair the same way.
98
+ */
99
+ function asksForJSON(argv) {
100
+ let end = argv.indexOf("--");
101
+ return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
102
+ }
103
+ /**
104
+ * The prompts of a check: every one answers its initial value, so a value a terminal run would
105
+ * ask for counts as given.
106
+ */
107
+ const CHECK_SESSION = {
108
+ interactive: true,
109
+ ask: (request) => Promise.resolve(success(request.initial)),
110
+ };
111
+ /** Parses and checks `argv` against `tree`, asking nothing and running nothing. */
112
+ async function check(tree, withVersion, argv) {
113
+ let parsed = parseArgv(tree, argv, withVersion);
114
+ let path = [...parsed.entry.path];
115
+ if (parsed.help || parsed.version)
116
+ return success({ path });
117
+ let inputs = await resolveInputs(parsed, CHECK_SESSION);
118
+ if (isFailure(inputs)) {
119
+ let error = inputs.error;
120
+ return failure(error instanceof UsageError ? error : new UsageError(error.message));
121
+ }
122
+ return success({ path });
123
+ }
85
124
  /** One run, from argument vector to exit code. */
86
125
  async function execute(cli, argv, io, script) {
87
126
  let internals = INTERNALS.get(cli);
@@ -91,6 +130,10 @@ async function execute(cli, argv, io, script) {
91
130
  let session = openSession(io, parsed.noProgress, script);
92
131
  try {
93
132
  let entry = parsed.entry;
133
+ if (parsed.help && asksForJSON(argv)) {
134
+ session.stdout.write(`${JSON.stringify(describeEntry(entry, program), null, 2)}\n`);
135
+ return EXIT_CODES.success;
136
+ }
94
137
  if (parsed.help) {
95
138
  session.stdout.write(renderHelp(entry, program, session.style));
96
139
  return EXIT_CODES.success;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The contract as plain data: every command with its arguments, flags and examples, the same
3
+ * facts the help prints. `--help --json` prints it, and a tool reads it to check what a script,
4
+ * a guide or an agent passes against what the program accepts.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { FlagKind } from "./flag.js";
10
+ import type { Entry } from "./tree.js";
11
+ /** One argument of a command, in the order the command line takes them. */
12
+ export interface ArgumentDescription {
13
+ name: string;
14
+ description: string | null;
15
+ /** A value is needed; a missing one is asked for on a terminal when the argument prompts. */
16
+ required: boolean;
17
+ /** Takes every remaining positional. */
18
+ variadic: boolean;
19
+ /** Takes every token after the command's name unparsed, flags included. */
20
+ rest: boolean;
21
+ /** The default, when one is declared. */
22
+ default?: unknown;
23
+ }
24
+ /** One flag of a command, or one every command answers to (`builtin`). */
25
+ export interface FlagDescription {
26
+ /** The long name, written `--<name>`. */
27
+ name: string;
28
+ aliases: string[];
29
+ /** The one-character alias, written `-<short>`. */
30
+ short: string | null;
31
+ kind: FlagKind;
32
+ /** A boolean's `--no-<name>` spelling is accepted. */
33
+ negatable: boolean;
34
+ /** What the help shows in `<…>` after the flag; `null` for a boolean. */
35
+ value: string | null;
36
+ /** The flag may appear bare or with `=value`. */
37
+ optionalValue: boolean;
38
+ /** One value holds several, separated by commas. */
39
+ list: boolean;
40
+ /** Repeating the flag collects every value. */
41
+ repeatable: boolean;
42
+ required: boolean;
43
+ description: string | null;
44
+ /** Answered by every command the program runs: `--help`, `--version`, `--no-progress`. */
45
+ builtin: boolean;
46
+ /** The default, when one is declared. */
47
+ default?: unknown;
48
+ }
49
+ /** One command or group, with every visible member below it. */
50
+ export interface CommandDescription {
51
+ /** The words typed after the program to reach it; empty for the root. */
52
+ path: string[];
53
+ /** The program name and the path, as the help prints it. */
54
+ title: string;
55
+ description: string | null;
56
+ arguments: ArgumentDescription[];
57
+ flags: FlagDescription[];
58
+ /** Example command lines, each starting with the program name. */
59
+ examples: string[];
60
+ /** Members of a group by name; empty for a command. */
61
+ commands: CommandDescription[];
62
+ }
63
+ /** The program's own facts the description includes beside the contract. */
64
+ export interface DescribeProgram {
65
+ version: string | undefined;
66
+ description: string | undefined;
67
+ }
68
+ /**
69
+ * Describes `entry` and every visible member under it. Hidden commands and flags stay out, as
70
+ * they do from the help.
71
+ */
72
+ export declare function describeEntry(entry: Entry, program: DescribeProgram): CommandDescription;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The contract as plain data: every command with its arguments, flags and examples, the same
3
+ * facts the help prints. `--help --json` prints it, and a tool reads it to check what a script,
4
+ * a guide or an agent passes against what the program accepts.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { groupSpec, isGroup } from "./command.js";
10
+ /**
11
+ * Describes `entry` and every visible member under it. Hidden commands and flags stay out, as
12
+ * they do from the help.
13
+ */
14
+ export function describeEntry(entry, program) {
15
+ let node = entry.node;
16
+ let spec = isGroup(node) ? groupSpec(node) : node;
17
+ let description = spec.description ?? (entry.parent === undefined ? program.description : undefined);
18
+ let flags = entry.flags.filter(({ spec }) => !spec.hidden).map(({ spec }) => describeFlag(spec));
19
+ if (entry.tasks)
20
+ flags.push(builtin("progress", "Print tasks without animation", true));
21
+ if (program.version !== undefined && entry.parent === undefined) {
22
+ flags.push(builtin("version", "Print the version", false));
23
+ }
24
+ flags.push({ ...builtin("help", "Show this help", false), short: "h" });
25
+ let programName = entry.title.split(" ")[0];
26
+ let commands = [];
27
+ for (let child of entry.children.values()) {
28
+ let childSpec = isGroup(child.node) ? groupSpec(child.node) : child.node;
29
+ if (childSpec.hidden)
30
+ continue;
31
+ commands.push(describeEntry(child, program));
32
+ }
33
+ return {
34
+ path: [...entry.path],
35
+ title: entry.title,
36
+ description: description ?? null,
37
+ arguments: entry.arguments.map(({ key, spec }) => ({
38
+ name: spec.valueName ?? key,
39
+ description: spec.description ?? null,
40
+ required: !spec.optional && !spec.hasDefault && (spec.variadic ? spec.min > 0 : true),
41
+ variadic: spec.variadic,
42
+ rest: spec.rest,
43
+ ...(spec.hasDefault ? { default: spec.default } : {}),
44
+ })),
45
+ flags,
46
+ examples: isGroup(node) ? [] : node.examples.map((example) => `${programName} ${example}`),
47
+ commands,
48
+ };
49
+ }
50
+ /** A declared flag as data. */
51
+ function describeFlag(spec) {
52
+ let boolean = spec.kind === "boolean";
53
+ return {
54
+ name: spec.name,
55
+ aliases: [...spec.aliases],
56
+ short: spec.short ?? null,
57
+ kind: spec.kind,
58
+ negatable: boolean,
59
+ value: boolean ? null : (spec.valueName ?? (spec.kind === "number" ? "number" : "value")),
60
+ optionalValue: spec.optionalValue,
61
+ list: spec.list,
62
+ repeatable: spec.multiple,
63
+ required: spec.required && spec.prompt === undefined,
64
+ description: spec.description ?? null,
65
+ builtin: false,
66
+ ...(spec.hasDefault && spec.default !== undefined ? { default: spec.default } : {}),
67
+ };
68
+ }
69
+ /**
70
+ * A flag the runtime answers for every command. `--no-progress` is the one spelled negated, so
71
+ * its name is `progress` and it is negatable.
72
+ */
73
+ function builtin(name, description, negatable) {
74
+ return {
75
+ name,
76
+ aliases: [],
77
+ short: null,
78
+ kind: "boolean",
79
+ negatable,
80
+ value: null,
81
+ optionalValue: false,
82
+ list: false,
83
+ repeatable: false,
84
+ required: false,
85
+ description,
86
+ builtin: true,
87
+ };
88
+ }
package/dist/index.d.ts CHANGED
@@ -7,9 +7,10 @@
7
7
  * @copyright Sergio Xalambrí 2026
8
8
  */
9
9
  export type { Argument, ArgumentOptions, ArgumentValue, RestArgumentOptions, SingleArgumentOptions, VariadicArgumentOptions, } from "./argument.js";
10
- export type { Binding, CLI, CLIOptions } from "./cli.js";
10
+ export type { Binding, CheckedCommand, CLI, CLIOptions } from "./cli.js";
11
11
  export type { ArgumentsOf, ArgumentsShape, Command, CommandDefinition, FlagsOf, FlagsShape, Group, GroupDefinition, GroupInputs, Node, } from "./command.js";
12
12
  export type { AnyCommandContext, CommandContextInit, Stdin } from "./context.js";
13
+ export type { ArgumentDescription, CommandDescription, FlagDescription } from "./describe.js";
13
14
  export type { Exec, ExecOptions, ExecOutput, ExecRequest, Stdio } from "./exec.js";
14
15
  export type { BooleanFlagOptions, Flag, FlagKind, FlagOptions, FlagValue, ValueFlag, ValueFlagOptions, } from "./flag.js";
15
16
  export type { ChainContext, CLITypes, CommandAction, CommandHandler, HandlerContext, } from "./handler.js";
package/dist/parse.js CHANGED
@@ -185,6 +185,11 @@ function skipValue(argv, index) {
185
185
  let next = argv[index];
186
186
  return next === undefined || isFlagToken(next) || next === "--" ? index : index + 1;
187
187
  }
188
+ /**
189
+ * How many commands declaring a misplaced flag its usage error names; past it the rest are
190
+ * counted, so a flag every member of a large group shares still fits on one line.
191
+ */
192
+ const NAMED_OWNERS = 3;
188
193
  /**
189
194
  * The message for a flag `entry` does not declare: the commands that do declare it, or the
190
195
  * closest name it does declare.
@@ -200,7 +205,9 @@ function unknownFlag(tree, entry, token) {
200
205
  let titles = owners.map((owner) => owner.title);
201
206
  let listed = titles.length === 1
202
207
  ? titles[0]
203
- : `${titles.slice(0, -1).join(", ")} and ${titles.at(-1)}`;
208
+ : titles.length > NAMED_OWNERS
209
+ ? `${titles.slice(0, NAMED_OWNERS).join(", ")} and ${titles.length - NAMED_OWNERS} more`
210
+ : `${titles.slice(0, -1).join(", ")} and ${titles.at(-1)}`;
204
211
  return `${spelled} is not a flag of ${entry.title}; ${listed} ${titles.length === 1 ? "takes" : "take"} it`;
205
212
  }
206
213
  let candidates = [];
package/dist/resolve.d.ts CHANGED
@@ -22,6 +22,6 @@ export interface Inputs {
22
22
  * @returns The inputs, a usage failure listing every problem, a cancelled failure, or the
23
23
  * failure of a scripted answer that was missing or rejected
24
24
  */
25
- export declare function resolveInputs(parsed: Parsed, session: Session): Promise<Result<Inputs, Error>>;
25
+ export declare function resolveInputs(parsed: Parsed, session: Pick<Session, "interactive" | "ask">): Promise<Result<Inputs, Error>>;
26
26
  /** The `ctx.prompt` of one run: asks on a terminal or from a script, else answers the default. */
27
27
  export declare function createPrompter(session: Session): Prompter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdxc/cli",
3
- "version": "2026.10.16",
3
+ "version": "2026.10.20",
4
4
  "description": "Build command-line tools with generated help, prompts bound to flags, tasks and a test harness",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,7 @@
20
20
  "optional": true
21
21
  }
22
22
  },
23
- "gitHead": "1e547d8431568f5ed9bdb079ca3046c936240fde",
23
+ "gitHead": "bd4473246f405c43055d2fc5e08c62ba35197258",
24
24
  "publishConfig": {
25
25
  "access": "public"
26
26
  },