@sdxc/cli 0.0.0-stage → 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.
Files changed (57) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +376 -2
  3. package/dist/argument.d.ts +120 -0
  4. package/dist/argument.js +104 -0
  5. package/dist/cli.d.ts +93 -0
  6. package/dist/cli.js +229 -0
  7. package/dist/command.d.ts +139 -0
  8. package/dist/command.js +79 -0
  9. package/dist/context.d.ts +138 -0
  10. package/dist/context.js +175 -0
  11. package/dist/describe.d.ts +72 -0
  12. package/dist/describe.js +88 -0
  13. package/dist/errors.d.ts +67 -0
  14. package/dist/errors.js +82 -0
  15. package/dist/exec.d.ts +83 -0
  16. package/dist/exec.js +136 -0
  17. package/dist/flag.d.ts +175 -0
  18. package/dist/flag.js +91 -0
  19. package/dist/handler.d.ts +73 -0
  20. package/dist/handler.js +30 -0
  21. package/dist/help.d.ts +18 -0
  22. package/dist/help.js +141 -0
  23. package/dist/index.d.ts +36 -0
  24. package/dist/index.js +20 -0
  25. package/dist/interactions.d.ts +27 -0
  26. package/dist/interactions.js +373 -0
  27. package/dist/keys.d.ts +49 -0
  28. package/dist/keys.js +146 -0
  29. package/dist/lazy.d.ts +29 -0
  30. package/dist/lazy.js +23 -0
  31. package/dist/middleware.d.ts +78 -0
  32. package/dist/middleware.js +9 -0
  33. package/dist/parse.d.ts +38 -0
  34. package/dist/parse.js +225 -0
  35. package/dist/prompt.d.ts +153 -0
  36. package/dist/prompt.js +53 -0
  37. package/dist/resolve.d.ts +27 -0
  38. package/dist/resolve.js +294 -0
  39. package/dist/schema.d.ts +27 -0
  40. package/dist/schema.js +34 -0
  41. package/dist/screen.d.ts +42 -0
  42. package/dist/screen.js +114 -0
  43. package/dist/session.d.ts +84 -0
  44. package/dist/session.js +219 -0
  45. package/dist/style.d.ts +50 -0
  46. package/dist/style.js +72 -0
  47. package/dist/suggest.d.ts +15 -0
  48. package/dist/suggest.js +50 -0
  49. package/dist/task.d.ts +47 -0
  50. package/dist/task.js +213 -0
  51. package/dist/terminal.d.ts +21 -0
  52. package/dist/terminal.js +101 -0
  53. package/dist/test.d.ts +89 -0
  54. package/dist/test.js +140 -0
  55. package/dist/tree.d.ts +58 -0
  56. package/dist/tree.js +97 -0
  57. package/package.json +40 -5
package/dist/cli.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `createCLI()`: a command contract with handlers mapped onto it, run against an argument
3
+ * vector. A run parses, prints help or a usage error, asks for what is missing, runs the
4
+ * middleware chain and the handler, and answers the exit code its outcome maps to.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { ArgumentsOf, FlagsOf, Group, Node } from "./command.js";
11
+ import type { CommandDescription } from "./describe.js";
12
+ import type { CommandAction, CommandHandler } from "./handler.js";
13
+ import type { Lazy } from "./lazy.js";
14
+ import type { AnyCommandMiddleware, ChainProperties } from "./middleware.js";
15
+ import type { RunIO, Script } from "./session.js";
16
+ import { CommandContext } from "./context.js";
17
+ import { UsageError } from "./errors.js";
18
+ /** What a CLI is built from. */
19
+ export interface CLIOptions<Commands extends Group, Chain extends readonly AnyCommandMiddleware[]> {
20
+ /** The program name, as the usage line prints it. */
21
+ name: string;
22
+ /** Printed by `--version`; without one the flag does not exist. */
23
+ version?: string;
24
+ /** Shown under the root usage line when the root group has no description. */
25
+ description?: string;
26
+ commands: Commands;
27
+ /** Runs before every command's own middleware, in order. */
28
+ middleware?: Chain;
29
+ }
30
+ /** What `map()` accepts for one command or group. */
31
+ export type Binding<Target, Chain extends readonly AnyCommandMiddleware[]> = CommandHandler<CommandContext<ArgumentsOf<Target>, FlagsOf<Target>> & ChainProperties<Chain>> | CommandAction<Target, any> | Lazy<CommandAction<Target, any>>;
32
+ /** A command line: its contract, the handlers mapped onto it, and a way to run it. */
33
+ export interface CLI<Commands extends Group = Group, Chain extends readonly AnyCommandMiddleware[] = readonly AnyCommandMiddleware[]> {
34
+ readonly name: string;
35
+ readonly version: string | undefined;
36
+ readonly commands: Commands;
37
+ readonly middleware: Chain;
38
+ /**
39
+ * Binds a handler to a command. A group can be mapped too; it runs when the command line
40
+ * names the group and no member, which otherwise prints the group's help.
41
+ *
42
+ * @throws Error When the target is not in this CLI's contract, or is mapped twice.
43
+ */
44
+ map<Target extends Node>(target: Target, binding: Binding<Target, Chain>): void;
45
+ /**
46
+ * Runs the command `argv` names and answers its exit code: 0 success, 1 failure, 2 usage
47
+ * error, 130 cancelled, or the code an `exit()` failure carries.
48
+ *
49
+ * @param argv - The arguments after the program, as `process.argv.slice(2)` holds them
50
+ * @param io - Replaces any part of the process the run uses
51
+ * @example process.exitCode = await cli.run(process.argv.slice(2));
52
+ */
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[];
76
+ }
77
+ /**
78
+ * Builds a CLI over a command contract.
79
+ *
80
+ * @throws Error When the contract repeats a command, gives a flag a reserved or duplicate name,
81
+ * or puts a variadic argument before another one.
82
+ * @example
83
+ * let cli = createCLI({ name: "sdxc", version, commands: sdxc, middleware: [project()] });
84
+ * cli.map(sdxc.init, lazy(() => import("./commands/init.js")));
85
+ * process.exitCode = await cli.run(process.argv.slice(2));
86
+ */
87
+ export declare function createCLI<Commands extends Group, const Chain extends readonly AnyCommandMiddleware[] = []>(options: CLIOptions<Commands, Chain>): CLI<Commands, Chain>;
88
+ /**
89
+ * Runs `argv` on `cli` with a test's script. Used by `@sdxc/cli/test`.
90
+ *
91
+ * @internal
92
+ */
93
+ export declare function runScripted<Commands extends Group, Chain extends readonly AnyCommandMiddleware[]>(cli: CLI<Commands, Chain>, argv: readonly string[], io: RunIO, script: Script): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * `createCLI()`: a command contract with handlers mapped onto it, run against an argument
3
+ * vector. A run parses, prints help or a usage error, asks for what is missing, runs the
4
+ * middleware chain and the handler, and answers the exit code its outcome maps to.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { failure, isFailure, success } from "@sdxc/result";
10
+ import { CommandContext } from "./context.js";
11
+ import { describeEntry } from "./describe.js";
12
+ import { CancelledError, EXIT_CODES, exitCodeOf, UsageError } from "./errors.js";
13
+ import { isAction } from "./handler.js";
14
+ import { renderHelp } from "./help.js";
15
+ import { isLazy } from "./lazy.js";
16
+ import { parseArgv } from "./parse.js";
17
+ import { createPrompter, resolveInputs } from "./resolve.js";
18
+ import { openSession } from "./session.js";
19
+ import { buildTree } from "./tree.js";
20
+ /** Every CLI's internals, reachable by the test harness without being part of the API. */
21
+ const INTERNALS = new WeakMap();
22
+ /**
23
+ * Builds a CLI over a command contract.
24
+ *
25
+ * @throws Error When the contract repeats a command, gives a flag a reserved or duplicate name,
26
+ * or puts a variadic argument before another one.
27
+ * @example
28
+ * let cli = createCLI({ name: "sdxc", version, commands: sdxc, middleware: [project()] });
29
+ * cli.map(sdxc.init, lazy(() => import("./commands/init.js")));
30
+ * process.exitCode = await cli.run(process.argv.slice(2));
31
+ */
32
+ export function createCLI(options) {
33
+ let tree = buildTree(options.name, options.commands);
34
+ let bindings = new Map();
35
+ let cli = {
36
+ name: options.name,
37
+ version: options.version,
38
+ commands: options.commands,
39
+ middleware: options.middleware ?? [],
40
+ map(target, binding) {
41
+ let entry = tree.entries.get(target);
42
+ if (entry === undefined)
43
+ throw new Error(`map() was given a command outside ${options.name}`);
44
+ if (bindings.has(target))
45
+ throw new Error(`${entry.title} is mapped twice`);
46
+ bindings.set(target, resolver(binding));
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),
58
+ };
59
+ INTERNALS.set(cli, {
60
+ tree,
61
+ options: options,
62
+ bindings,
63
+ });
64
+ return cli;
65
+ }
66
+ /** A binding as a loader of its action, importing a lazy one at most once per CLI. */
67
+ function resolver(binding) {
68
+ if (isLazy(binding)) {
69
+ let loading;
70
+ return () => {
71
+ loading ??= binding.load().then((module) => normalize(module.default), (error) => {
72
+ loading = undefined;
73
+ throw error;
74
+ });
75
+ return loading;
76
+ };
77
+ }
78
+ let action = normalize(binding);
79
+ return () => Promise.resolve(action);
80
+ }
81
+ /** An action, or a bare handler wrapped as one with no middleware of its own. */
82
+ function normalize(binding) {
83
+ if (isAction(binding))
84
+ return binding;
85
+ return { middleware: [], handler: binding };
86
+ }
87
+ /**
88
+ * Runs `argv` on `cli` with a test's script. Used by `@sdxc/cli/test`.
89
+ *
90
+ * @internal
91
+ */
92
+ export function runScripted(cli, argv, io, script) {
93
+ return execute(cli, argv, io, script);
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
+ }
124
+ /** One run, from argument vector to exit code. */
125
+ async function execute(cli, argv, io, script) {
126
+ let internals = INTERNALS.get(cli);
127
+ let { tree, options, bindings } = internals;
128
+ let program = { version: options.version, description: options.description };
129
+ let parsed = parseArgv(tree, argv, options.version !== undefined);
130
+ let session = openSession(io, parsed.noProgress, script);
131
+ try {
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
+ }
137
+ if (parsed.help) {
138
+ session.stdout.write(renderHelp(entry, program, session.style));
139
+ return EXIT_CODES.success;
140
+ }
141
+ if (parsed.version) {
142
+ session.stdout.write(`${options.version}\n`);
143
+ return EXIT_CODES.success;
144
+ }
145
+ if (parsed.issues.length > 0 && entry.children.size > 0) {
146
+ return report(new UsageError(parsed.issues.join("\n")), entry, session, program);
147
+ }
148
+ let load = bindings.get(entry.node);
149
+ if (load === undefined) {
150
+ if (entry.children.size > 0) {
151
+ session.stderr.write(renderHelp(entry, program, session.style.stderr));
152
+ return EXIT_CODES.usage;
153
+ }
154
+ return report(new Error(`${entry.title} has no handler`), entry, session, program);
155
+ }
156
+ let inputs = await resolveInputs(parsed, session);
157
+ if (isFailure(inputs))
158
+ return report(inputs.error, entry, session, program);
159
+ let action;
160
+ try {
161
+ action = await load();
162
+ }
163
+ catch (error) {
164
+ return report(asError(error), entry, session, program);
165
+ }
166
+ let context = new CommandContext({
167
+ argv: [...argv],
168
+ path: entry.path,
169
+ arguments: inputs.data.arguments,
170
+ flags: inputs.data.flags,
171
+ session,
172
+ prompt: createPrompter(session),
173
+ });
174
+ let chain = [...(options.middleware ?? []), ...action.middleware];
175
+ let outcome = await dispatch(chain, context, action.handler);
176
+ if (isFailure(outcome))
177
+ return report(outcome.error, entry, session, program);
178
+ return EXIT_CODES.success;
179
+ }
180
+ finally {
181
+ session.close();
182
+ }
183
+ }
184
+ /**
185
+ * Runs the middleware chain and the handler. A thrown error becomes a failure, so a bug in a
186
+ * handler still exits 1 with its message.
187
+ */
188
+ async function dispatch(chain, context, handler) {
189
+ /** Runs the chain from `index`, answering the outcome the run exits with. */
190
+ let run = async (index) => {
191
+ let middleware = chain[index];
192
+ if (middleware === undefined)
193
+ return handler(context);
194
+ let downstream;
195
+ let returned = await middleware(context, async () => {
196
+ downstream = await run(index + 1);
197
+ return downstream;
198
+ });
199
+ if (returned !== undefined && (downstream === undefined || isFailure(returned))) {
200
+ return returned;
201
+ }
202
+ return downstream ?? success(undefined);
203
+ };
204
+ let outcome;
205
+ try {
206
+ outcome = await run(0);
207
+ }
208
+ catch (error) {
209
+ return failure(asError(error));
210
+ }
211
+ if (context.signal.aborted && isFailure(outcome))
212
+ return failure(new CancelledError());
213
+ return outcome;
214
+ }
215
+ /** A thrown value as an `Error`. */
216
+ function asError(error) {
217
+ return error instanceof Error ? error : new Error(String(error));
218
+ }
219
+ /** Prints a failed run's message and answers its exit code. */
220
+ function report(error, entry, session, program) {
221
+ if (error instanceof UsageError) {
222
+ let help = renderHelp(entry, program, session.style.stderr);
223
+ session.screen.write(session.stderr, `${error.message}\n\n${help}`);
224
+ }
225
+ else if (!(error instanceof CancelledError) && error.message !== "") {
226
+ session.screen.write(session.stderr, `${error.message}\n`);
227
+ }
228
+ return exitCodeOf(error);
229
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The command contract: `command()` declares what one command takes, and `commands()` groups
3
+ * commands under a name, nesting as deep as the command line does, so `sdxc ui add` is
4
+ * `contract.ui.add`. Declaration runs nothing; `createCLI` maps handlers onto it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Argument, ArgumentValue } from "./argument.js";
10
+ import type { Carried, Flag, FlagValue } from "./flag.js";
11
+ /** Brands a declared command. */
12
+ declare const COMMAND: unique symbol;
13
+ /** Brands a declared group. */
14
+ declare const GROUP: unique symbol;
15
+ /** Type-only slot carrying the inputs a group declares for its own handler. */
16
+ declare const INPUTS: unique symbol;
17
+ /** Keys of `commands()`' definition that describe the group instead of naming a member. */
18
+ type GroupOwnKey = "description" | "hidden" | "arguments" | "flags" | "tasks";
19
+ /** A command's positional arguments, filled in key order. */
20
+ export type ArgumentsShape = Readonly<Record<string, Argument>>;
21
+ /** A command's flags, keyed by the property its handler reads. */
22
+ export type FlagsShape = Readonly<Record<string, Flag>>;
23
+ /** What one command declares. */
24
+ export interface CommandDefinition<Arguments extends ArgumentsShape = ArgumentsShape, Flags extends FlagsShape = FlagsShape> {
25
+ /** One line, shown in the help and in the parent group's command list. */
26
+ description?: string;
27
+ arguments?: Arguments;
28
+ flags?: Flags;
29
+ /** Command lines the help lists under "Examples", written without the program name. */
30
+ examples?: readonly string[];
31
+ /** Left out of the parent group's command list; it still runs. */
32
+ hidden?: boolean;
33
+ /** Its handler runs `ctx.task`, so the help lists `--no-progress`. */
34
+ tasks?: boolean;
35
+ }
36
+ /** A declared command. */
37
+ export interface Command<Arguments extends ArgumentsShape = ArgumentsShape, Flags extends FlagsShape = FlagsShape> {
38
+ readonly [COMMAND]: true;
39
+ readonly description: string | undefined;
40
+ readonly arguments: Arguments;
41
+ readonly flags: Flags;
42
+ readonly examples: readonly string[];
43
+ readonly hidden: boolean;
44
+ readonly tasks: boolean;
45
+ }
46
+ /** What a group holds besides its members. */
47
+ export interface GroupSpec {
48
+ description: string | undefined;
49
+ hidden: boolean;
50
+ tasks: boolean;
51
+ /** The members, keyed by the name typed on the command line. */
52
+ members: Readonly<Record<string, Command | Group>>;
53
+ /** What the group's own handler reads when the command line names no member. */
54
+ arguments: ArgumentsShape;
55
+ flags: FlagsShape;
56
+ }
57
+ /** What a group's own handler reads, carried for `ctx.arguments` and `ctx.flags`. */
58
+ export interface GroupInputs<Arguments extends ArgumentsShape, Flags extends FlagsShape> {
59
+ arguments: Arguments;
60
+ flags: Flags;
61
+ }
62
+ /** A declared group: its members, reachable by name, plus the brand carrying its spec. */
63
+ export type Group<Members = Readonly<Record<string, unknown>>, Arguments extends ArgumentsShape = ArgumentsShape, Flags extends FlagsShape = FlagsShape> = Members & {
64
+ readonly [GROUP]: GroupSpec;
65
+ readonly [INPUTS]?: Carried<GroupInputs<Arguments, Flags>>;
66
+ };
67
+ /**
68
+ * What `commands()` takes: members keyed by name, plus the group's own description and the
69
+ * inputs its handler reads when the command line names no member.
70
+ */
71
+ export interface GroupDefinition {
72
+ /** One line, shown in the help and in the parent group's command list. */
73
+ readonly description?: string;
74
+ /** Left out of the parent group's command list; it still runs. */
75
+ readonly hidden?: boolean;
76
+ /**
77
+ * Read by the group's mapped handler. A word that names no member fills them, and one the
78
+ * first argument rejects is reported as an unknown command.
79
+ */
80
+ readonly arguments?: ArgumentsShape;
81
+ /** Read by the group's mapped handler; one written before a member's name moves to that member. */
82
+ readonly flags?: FlagsShape;
83
+ /** The group's handler runs `ctx.task`, so its help lists `--no-progress`. */
84
+ readonly tasks?: boolean;
85
+ readonly [name: string]: Command | Group | ArgumentsShape | FlagsShape | string | boolean | undefined;
86
+ }
87
+ /** A declared command or group, as the parser walks them. */
88
+ export type Node = Command | Group;
89
+ /**
90
+ * Declares one command.
91
+ *
92
+ * @example
93
+ * command({
94
+ * description: "Copy components into the project",
95
+ * arguments: { components: argument.variadic(s.string(), { min: 1 }) },
96
+ * flags: { dryRun: flag.boolean("dry-run") },
97
+ * })
98
+ */
99
+ export declare function command<const Arguments extends ArgumentsShape = Record<never, never>, const Flags extends FlagsShape = Record<never, never>>(definition?: CommandDefinition<Arguments, Flags>): Command<Arguments, Flags>;
100
+ /** The arguments a group definition declares for its own handler, or none. */
101
+ type DeclaredArguments<Definition> = Definition extends {
102
+ readonly arguments: infer Shape extends ArgumentsShape;
103
+ } ? Shape : Record<never, never>;
104
+ /** The flags a group definition declares for its own handler, or none. */
105
+ type DeclaredFlags<Definition> = Definition extends {
106
+ readonly flags: infer Shape extends FlagsShape;
107
+ } ? Shape : Record<never, never>;
108
+ /**
109
+ * Groups commands, and other groups, under the names typed on the command line.
110
+ * `description`, `hidden`, `arguments`, `flags` and `tasks` describe the group itself and are
111
+ * never members.
112
+ *
113
+ * @example export let sdxc = commands({ init: command({}), ui: commands({ description: "…", add }) });
114
+ * @example make: commands({ arguments: { kind: argument(s.enum_(KINDS), { prompt }) }, job, model })
115
+ */
116
+ export declare function commands<const Definition extends GroupDefinition>(definition: Definition): Group<Omit<Definition, GroupOwnKey>, DeclaredArguments<Definition>, DeclaredFlags<Definition>>;
117
+ /** Whether `value` is a declared command. */
118
+ export declare function isCommand(value: unknown): value is Command;
119
+ /** Whether `value` is a declared group. */
120
+ export declare function isGroup(value: unknown): value is Group;
121
+ /** The spec behind a group. */
122
+ export declare function groupSpec(group: Group): GroupSpec;
123
+ /** The inputs a group declared for its own handler. */
124
+ type GroupInputsOf<Target> = Target extends {
125
+ readonly [INPUTS]?: Carried<infer Inputs> | undefined;
126
+ } ? Inputs : never;
127
+ /** What `ctx.arguments` holds for a command or a group: each argument's parsed value by key. */
128
+ export type ArgumentsOf<Target> = Target extends Command<infer Arguments, FlagsShape> ? {
129
+ readonly [Key in keyof Arguments]: ArgumentValue<Arguments[Key]>;
130
+ } : GroupInputsOf<Target> extends GroupInputs<infer Arguments, FlagsShape> ? {
131
+ readonly [Key in keyof Arguments]: ArgumentValue<Arguments[Key]>;
132
+ } : Readonly<Record<never, never>>;
133
+ /** What `ctx.flags` holds for a command or a group: each flag's parsed value by key. */
134
+ export type FlagsOf<Target> = Target extends Command<ArgumentsShape, infer Flags> ? {
135
+ readonly [Key in keyof Flags]: FlagValue<Flags[Key]>;
136
+ } : GroupInputsOf<Target> extends GroupInputs<ArgumentsShape, infer Flags> ? {
137
+ readonly [Key in keyof Flags]: FlagValue<Flags[Key]>;
138
+ } : Readonly<Record<never, never>>;
139
+ export {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The command contract: `command()` declares what one command takes, and `commands()` groups
3
+ * commands under a name, nesting as deep as the command line does, so `sdxc ui add` is
4
+ * `contract.ui.add`. Declaration runs nothing; `createCLI` maps handlers onto it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** Brands a declared command. */
10
+ const COMMAND = Symbol.for("@sdxc/cli.command");
11
+ /** Brands a declared group. */
12
+ const GROUP = Symbol.for("@sdxc/cli.group");
13
+ /**
14
+ * Declares one command.
15
+ *
16
+ * @example
17
+ * command({
18
+ * description: "Copy components into the project",
19
+ * arguments: { components: argument.variadic(s.string(), { min: 1 }) },
20
+ * flags: { dryRun: flag.boolean("dry-run") },
21
+ * })
22
+ */
23
+ export function command(definition = {}) {
24
+ return {
25
+ [COMMAND]: true,
26
+ description: definition.description,
27
+ arguments: definition.arguments ?? {},
28
+ flags: definition.flags ?? {},
29
+ examples: definition.examples ?? [],
30
+ hidden: definition.hidden === true,
31
+ tasks: definition.tasks === true,
32
+ };
33
+ }
34
+ /**
35
+ * Groups commands, and other groups, under the names typed on the command line.
36
+ * `description`, `hidden`, `arguments`, `flags` and `tasks` describe the group itself and are
37
+ * never members.
38
+ *
39
+ * @example export let sdxc = commands({ init: command({}), ui: commands({ description: "…", add }) });
40
+ * @example make: commands({ arguments: { kind: argument(s.enum_(KINDS), { prompt }) }, job, model })
41
+ */
42
+ export function commands(definition) {
43
+ let members = {};
44
+ for (let [name, member] of Object.entries(definition)) {
45
+ if (OWN_KEYS.has(name))
46
+ continue;
47
+ if (isCommand(member) || isGroup(member))
48
+ members[name] = member;
49
+ }
50
+ let spec = {
51
+ description: definition.description,
52
+ hidden: definition.hidden === true,
53
+ tasks: definition.tasks === true,
54
+ members,
55
+ arguments: definition.arguments ?? {},
56
+ flags: definition.flags ?? {},
57
+ };
58
+ return { ...members, [GROUP]: spec };
59
+ }
60
+ /** The definition keys `commands()` reads as the group's own. */
61
+ const OWN_KEYS = new Set([
62
+ "description",
63
+ "hidden",
64
+ "arguments",
65
+ "flags",
66
+ "tasks",
67
+ ]);
68
+ /** Whether `value` is a declared command. */
69
+ export function isCommand(value) {
70
+ return typeof value === "object" && value !== null && COMMAND in value;
71
+ }
72
+ /** Whether `value` is a declared group. */
73
+ export function isGroup(value) {
74
+ return typeof value === "object" && value !== null && GROUP in value;
75
+ }
76
+ /** The spec behind a group. */
77
+ export function groupSpec(group) {
78
+ return group[GROUP];
79
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The context a command's middleware and handler receive: its parsed arguments and flags, the
3
+ * process it runs in (working directory, environment, output, prompts, tasks, child processes)
4
+ * and the values middleware published, readable by key or as installed properties.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ import type { ContextValue } from "remix/router";
11
+ import type { CancelledError } from "./errors.js";
12
+ import type { ExecError, ExecOptions, ExecOutput } from "./exec.js";
13
+ import type { Prompter } from "./prompt.js";
14
+ import type { Session } from "./session.js";
15
+ import type { Env, Style } from "./style.js";
16
+ import type { RunTask } from "./task.js";
17
+ /** Everything a context is built from. */
18
+ export interface CommandContextInit<Arguments, Flags> {
19
+ argv: readonly string[];
20
+ path: readonly string[];
21
+ arguments: Arguments;
22
+ flags: Flags;
23
+ session: Session;
24
+ prompt: Prompter;
25
+ }
26
+ /**
27
+ * One command's run. A test builds one through `invoke()` from `@sdxc/cli/test`; a middleware
28
+ * publishes services with `set()`, which a handler reads as `ctx.<property>`.
29
+ *
30
+ * @example ctx.set(Database, connect(ctx.env.DATABASE_URL), { property: "db" });
31
+ */
32
+ export declare class CommandContext<Arguments = unknown, Flags = unknown> {
33
+ #private;
34
+ /**
35
+ * The arguments the run was given, exactly as `cli.run` received them: the command's names,
36
+ * flags and values before parsing, for logging a run or starting it again.
37
+ */
38
+ readonly argv: readonly string[];
39
+ /** The command's names after the program, `["ui", "add"]` for `sdxc ui add`. */
40
+ readonly path: readonly string[];
41
+ /** The positional arguments, parsed, prompted for, or defaulted. */
42
+ readonly arguments: Arguments;
43
+ /** The flags, parsed, prompted for, or defaulted. */
44
+ readonly flags: Flags;
45
+ readonly cwd: string;
46
+ readonly env: Env;
47
+ /** Styling for stdout; `style.stderr` styles stderr, each off for a stream that is not a terminal. */
48
+ readonly style: Style & {
49
+ readonly stderr: Style;
50
+ };
51
+ /** Aborts on Ctrl+C. */
52
+ readonly signal: AbortSignal;
53
+ /**
54
+ * Questions a handler asks itself. Each takes a `default`, which answers without a
55
+ * terminal, so the command stays runnable in CI.
56
+ */
57
+ readonly prompt: Prompter;
58
+ /**
59
+ * Runs async work under a spinner, answering a `Result`; the label is optional.
60
+ *
61
+ * @example let posts = await ctx.task("Fetching posts", (task) => fetch(url, { signal: task.signal }));
62
+ */
63
+ readonly task: RunTask;
64
+ /**
65
+ * What the command was piped. `stdin.isTTY` is true at a terminal, where `text()` would wait
66
+ * for the user to end input, so a handler reads it only when `isTTY` is false.
67
+ *
68
+ * @example let sql = ctx.flags.execute ?? (ctx.stdin.isTTY ? undefined : await ctx.stdin.text());
69
+ */
70
+ readonly stdin: Stdin;
71
+ /** @param init - The parsed command and the run it belongs to */
72
+ constructor(init: CommandContextInit<Arguments, Flags>);
73
+ /** Writes to stdout, above any running task. */
74
+ readonly stdout: (text: string) => void;
75
+ /** Writes to stderr, above any running task. */
76
+ readonly stderr: (text: string) => void;
77
+ /**
78
+ * Runs a program with no shell, answering its exit code and output, or an `ExecError` when
79
+ * it fails to start or exits non-zero. `"inherit"` and `"stderr"` output pauses any spinner
80
+ * until the child exits.
81
+ *
82
+ * @param command - The program and its arguments, passed through exactly
83
+ * @example let status = await ctx.exec(["git", "status", "--short"], { stdio: "collect" });
84
+ */
85
+ exec(command: readonly string[], options?: ExecOptions): Promise<Result<ExecOutput, ExecError | CancelledError>>;
86
+ /**
87
+ * Adds variables every child `exec` starts with, over the run's `env` and under a call's own
88
+ * `options.env`, so a middleware hands children what it knows (a trace parent) and a caller
89
+ * still overrides it. An `undefined` value removes the variable from children.
90
+ *
91
+ * @param variables - Merged over what earlier calls added
92
+ * @example ctx.setExecEnv({ TRACEPARENT: toTraceParent(trace) });
93
+ */
94
+ setExecEnv(variables: Env): void;
95
+ /**
96
+ * Reads a value some middleware published.
97
+ *
98
+ * @param key - The context key to read
99
+ * @returns Its value, the key's default, or `undefined`
100
+ */
101
+ get<Key extends object>(key: Key): ContextValue<Key> | undefined;
102
+ /**
103
+ * Reads a value an earlier middleware must have published. A middleware sees the bare
104
+ * context, without the properties earlier ones installed, so this is how it reads them
105
+ * without an `undefined` it has no answer for; a missing value fails the run with exit 1.
106
+ *
107
+ * @param key - The context key to read
108
+ * @throws Error When nothing published a value for the key
109
+ * @example let db = ctx.require(Database);
110
+ */
111
+ require<Key extends object>(key: Key): NonNullable<ContextValue<Key>>;
112
+ /**
113
+ * Whether a value has been published for a key.
114
+ *
115
+ * @param key - The context key to check
116
+ */
117
+ has(key: object): boolean;
118
+ /**
119
+ * Publishes a value for a key, optionally installing it as a read-only property so handlers
120
+ * read `ctx.db` rather than `ctx.get(Database)`.
121
+ *
122
+ * @param key - The context key to write
123
+ * @param value - The value to publish
124
+ * @param options - The property name to install it under
125
+ */
126
+ set<Key extends object>(key: Key, value: ContextValue<Key>, options?: {
127
+ property: string;
128
+ }): void;
129
+ }
130
+ /** The run's standard input. */
131
+ export interface Stdin {
132
+ /** Whether stdin is a terminal, as opposed to a pipe or a file. */
133
+ readonly isTTY: boolean;
134
+ /** Everything piped in, read once to its end; later calls answer the same text. */
135
+ text(): Promise<string>;
136
+ }
137
+ /** A context whatever it parsed, for middleware that runs before every command. */
138
+ export type AnyCommandContext = CommandContext<any, any>;