cli-kiss 0.1.8 → 0.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.
@@ -8,17 +8,82 @@ import {
8
8
  TypoText,
9
9
  } from "./Typo";
10
10
 
11
+ /**
12
+ * Describes a single positional argument — a bare (non-option) token on the command
13
+ * line — together with its parsing and usage-generation logic.
14
+ *
15
+ * Positionals are created with {@link positionalRequired}, {@link positionalOptional}, or
16
+ * {@link positionalVariadics} and are passed via the `positionals` array of
17
+ * {@link operation}, where they are consumed in declaration order.
18
+ *
19
+ * @typeParam Value - The TypeScript type of the parsed positional value.
20
+ */
11
21
  export type Positional<Value> = {
22
+ /** Returns human-readable metadata used to render the `Positionals:` section of help. */
12
23
  generateUsage(): PositionalUsage;
24
+ /**
25
+ * Consumes the next positional token(s) from `readerPositionals` and returns the
26
+ * decoded value.
27
+ *
28
+ * @param readerPositionals - The shared {@link ReaderArgs} that manages the queue of
29
+ * remaining positional tokens.
30
+ * @throws {@link TypoError} if the positional is required but absent, or if the raw
31
+ * value fails type decoding.
32
+ */
13
33
  consumePositionals(readerPositionals: ReaderPositionals): Value;
14
34
  };
15
35
 
36
+ /**
37
+ * Human-readable metadata for a single positional argument, used to render the
38
+ * `Positionals:` section of the help output produced by {@link usageToStyledLines}.
39
+ */
16
40
  export type PositionalUsage = {
41
+ /** Short description of what the positional represents. */
17
42
  description: string | undefined;
43
+ /**
44
+ * Optional supplementary note shown in parentheses next to the description.
45
+ * Suitable for short caveats such as `"defaults to 'world'"`.
46
+ */
18
47
  hint: string | undefined;
48
+ /**
49
+ * The placeholder label shown in the usage line and the `Positionals:` section.
50
+ * Required positionals use angle-bracket notation (e.g. `"<NAME>"`); optional ones
51
+ * use square-bracket notation (e.g. `"[FILE]"`); variadic ones append `...`
52
+ * (e.g. `"[ITEM]..."`).
53
+ */
19
54
  label: Uppercase<string>;
20
55
  };
21
56
 
57
+ /**
58
+ * Creates a required positional argument — one that must be present on the command line.
59
+ *
60
+ * The parser consumes the next available positional token and decodes it with
61
+ * `definition.type`. If no token is available, a {@link TypoError} is thrown immediately
62
+ * during parsing (i.e. inside {@link OperationDescriptor.createFactory}).
63
+ *
64
+ * The label displayed in the usage line defaults to the uppercased `type.content`
65
+ * wrapped in angle brackets (e.g. `<STRING>`). Supply `label` to override.
66
+ *
67
+ * @typeParam Value - The TypeScript type produced by the type decoder.
68
+ *
69
+ * @param definition - Configuration for the positional.
70
+ * @param definition.description - Human-readable description for the help output.
71
+ * @param definition.hint - Optional supplementary note shown in parentheses.
72
+ * @param definition.label - Custom label shown in the usage line (without angle brackets).
73
+ * Defaults to the uppercased `type.content`.
74
+ * @param definition.type - The {@link Type} used to decode the raw string token.
75
+ * @returns A {@link Positional}`<Value>`.
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * const namePositional = positionalRequired({
80
+ * type: typeString,
81
+ * label: "NAME",
82
+ * description: "The name to greet",
83
+ * });
84
+ * // Parses: my-cli Alice → "Alice"
85
+ * ```
86
+ */
22
87
  export function positionalRequired<Value>(definition: {
23
88
  description?: string;
24
89
  hint?: string;
@@ -49,6 +114,41 @@ export function positionalRequired<Value>(definition: {
49
114
  };
50
115
  }
51
116
 
117
+ /**
118
+ * Creates an optional positional argument — one that may or may not appear on the
119
+ * command line.
120
+ *
121
+ * The parser consumes the next available positional token. If no token is available,
122
+ * `definition.default()` is called to supply the fallback value. If the default factory
123
+ * throws, a {@link TypoError} is produced.
124
+ *
125
+ * The label displayed in the usage line defaults to the uppercased `type.content`
126
+ * wrapped in square brackets (e.g. `[STRING]`). Supply `label` to override.
127
+ *
128
+ * @typeParam Value - The TypeScript type produced by the type decoder (or the default).
129
+ *
130
+ * @param definition - Configuration for the positional.
131
+ * @param definition.description - Human-readable description for the help output.
132
+ * @param definition.hint - Optional supplementary note shown in parentheses.
133
+ * @param definition.label - Custom label shown in the usage line (without square brackets).
134
+ * Defaults to the uppercased `type.content`.
135
+ * @param definition.type - The {@link Type} used to decode the raw string token.
136
+ * @param definition.default - Factory called when the positional is absent to supply the
137
+ * default value. Throw from this factory to make omission an error.
138
+ * @returns A {@link Positional}`<Value>`.
139
+ *
140
+ * @example
141
+ * ```ts
142
+ * const greeteePositional = positionalOptional({
143
+ * type: typeString,
144
+ * label: "NAME",
145
+ * description: "Name to greet (default: world)",
146
+ * default: () => "world",
147
+ * });
148
+ * // my-cli → "world"
149
+ * // my-cli Alice → "Alice"
150
+ * ```
151
+ */
52
152
  export function positionalOptional<Value>(definition: {
53
153
  description?: string;
54
154
  hint?: string;
@@ -85,6 +185,45 @@ export function positionalOptional<Value>(definition: {
85
185
  };
86
186
  }
87
187
 
188
+ /**
189
+ * Creates a variadic positional argument — one that collects zero or more remaining
190
+ * positional tokens into an array.
191
+ *
192
+ * The parser greedily consumes tokens until either there are no more tokens or it
193
+ * encounters the optional `endDelimiter` sentinel string, which is consumed but not
194
+ * included in the result. Each token is decoded independently with `definition.type`.
195
+ *
196
+ * If absent entirely, the result is an empty array `[]`.
197
+ *
198
+ * The label displayed in the usage line defaults to the uppercased `type.content`
199
+ * wrapped in square brackets followed by `...` (e.g. `[STRING]...`). When an
200
+ * `endDelimiter` is configured, the delimiter is also shown (e.g. `[STRING]...["--"]`).
201
+ * Supply `label` to override the base label.
202
+ *
203
+ * @typeParam Value - The TypeScript type produced by the type decoder for each token.
204
+ *
205
+ * @param definition - Configuration for the variadic positional.
206
+ * @param definition.endDelimiter - Optional sentinel string that signals the end of
207
+ * the variadic sequence (e.g. `"--"`). When encountered it is consumed but not
208
+ * included in the result array.
209
+ * @param definition.description - Human-readable description for the help output.
210
+ * @param definition.hint - Optional supplementary note shown in parentheses.
211
+ * @param definition.label - Custom label shown in the usage line (without brackets).
212
+ * Defaults to the uppercased `type.content`.
213
+ * @param definition.type - The {@link Type} used to decode each raw string token.
214
+ * @returns A {@link Positional}`<Array<Value>>`.
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * const filesPositional = positionalVariadics({
219
+ * type: typeString,
220
+ * label: "FILE",
221
+ * description: "Files to process",
222
+ * });
223
+ * // my-cli a.ts b.ts c.ts → ["a.ts", "b.ts", "c.ts"]
224
+ * // my-cli → []
225
+ * ```
226
+ */
88
227
  export function positionalVariadics<Value>(definition: {
89
228
  endDelimiter?: string;
90
229
  description?: string;
package/src/lib/Reader.ts CHANGED
@@ -6,23 +6,88 @@ import {
6
6
  TypoText,
7
7
  } from "./Typo";
8
8
 
9
+ /**
10
+ * An opaque key that uniquely identifies a registered CLI option within a
11
+ * {@link ReaderArgs} instance.
12
+ *
13
+ * Keys are returned by {@link ReaderArgs.registerOption} and passed back to
14
+ * {@link ReaderArgs.getOptionValues} to retrieve the parsed values. The internal
15
+ * representation is intentionally opaque — treat it as a handle, not a string.
16
+ */
9
17
  export type ReaderOptionKey = (string | { __brand: "ReaderOptionKey" }) & {
10
18
  __brand: "ReaderOptionKey";
11
19
  };
12
20
 
21
+ /**
22
+ * Interface for registering and querying CLI options during argument parsing.
23
+ *
24
+ * {@link ReaderArgs} implements both `ReaderOptions` and {@link ReaderPositionals}.
25
+ * The two interfaces are exposed separately so that option and positional parsing logic
26
+ * can depend only on the capability they need.
27
+ */
13
28
  export type ReaderOptions = {
29
+ /**
30
+ * Registers a new option so the parser can recognise it when scanning argument tokens.
31
+ *
32
+ * @param definition.longs - The long-form names (without `--`) for this option.
33
+ * @param definition.shorts - The short-form names (without `-`) for this option.
34
+ * @param definition.valued - When `true`, the option consumes the following token as
35
+ * its value. When `false`, the option is a boolean flag.
36
+ * @returns An opaque {@link ReaderOptionKey} used to retrieve parsed values later.
37
+ * @throws `Error` if any of the given names has already been registered, or if a
38
+ * short name overlaps (is a prefix of, or has as a prefix, another registered short).
39
+ */
14
40
  registerOption(definition: {
15
41
  longs: Array<string>;
16
42
  shorts: Array<string>;
17
43
  valued: boolean;
18
44
  }): ReaderOptionKey;
45
+ /**
46
+ * Returns all values collected for the option identified by `key`.
47
+ *
48
+ * @param key - The key returned by a prior {@link ReaderOptions.registerOption} call.
49
+ * @returns An array of raw string values, one per occurrence of the option on the
50
+ * command line. Empty if the option was never provided.
51
+ * @throws `Error` if `key` was not previously registered on this instance.
52
+ */
19
53
  getOptionValues(key: ReaderOptionKey): Array<string>;
20
54
  };
21
55
 
56
+ /**
57
+ * Interface for consuming positional (non-option) argument tokens during parsing.
58
+ *
59
+ * {@link ReaderArgs} implements both {@link ReaderOptions} and `ReaderPositionals`.
60
+ */
22
61
  export type ReaderPositionals = {
62
+ /**
63
+ * Consumes and returns the next positional token from the argument list, skipping
64
+ * any option tokens (which are parsed as side-effects).
65
+ *
66
+ * @returns The next positional string value, or `undefined` if no more positionals
67
+ * are available.
68
+ * @throws {@link TypoError} if an unrecognised option token is encountered while
69
+ * scanning for the next positional.
70
+ */
23
71
  consumePositional(): string | undefined;
24
72
  };
25
73
 
74
+ /**
75
+ * The core argument parser for `cli-kiss`. Parses a flat array of raw CLI tokens into
76
+ * named options and positional values.
77
+ *
78
+ * Options must be registered with {@link ReaderArgs.registerOption} **before**
79
+ * {@link ReaderArgs.consumePositional} is called, because the parser needs to know
80
+ * whether each token is an option name, an option value, or a bare positional.
81
+ *
82
+ * **Supported argument syntax:**
83
+ * - Long options: `--name`, `--name value`, `--name=value`
84
+ * - Short options: `-n`, `-n value`, `-n=value`, `-nvalue`, `-abc` (stacked flags)
85
+ * - End-of-options separator: `--` — all subsequent tokens are treated as positionals.
86
+ *
87
+ * In most cases you do not need to use `ReaderArgs` directly; it is created internally
88
+ * by {@link runAndExit}. It is exposed for advanced use cases such as building
89
+ * custom runners.
90
+ */
26
91
  export class ReaderArgs {
27
92
  #args: ReadonlyArray<string>;
28
93
  #parsedIndex: number;
@@ -32,6 +97,10 @@ export class ReaderArgs {
32
97
  #valuedByKey: Map<ReaderOptionKey, boolean>;
33
98
  #resultByKey: Map<ReaderOptionKey, Array<string>>;
34
99
 
100
+ /**
101
+ * @param args - The raw command-line tokens to parse. Typically `process.argv.slice(2)`.
102
+ * The array is not modified; a read cursor is maintained internally.
103
+ */
35
104
  constructor(args: ReadonlyArray<string>) {
36
105
  this.#args = args;
37
106
  this.#parsedIndex = 0;
@@ -42,6 +111,24 @@ export class ReaderArgs {
42
111
  this.#resultByKey = new Map();
43
112
  }
44
113
 
114
+ /**
115
+ * Registers a CLI option so the parser can recognise it.
116
+ *
117
+ * All `longs` and `shorts` are associated with the same returned key. Calling
118
+ * `getOptionValues(key)` after parsing will return values collected under any of the
119
+ * registered names.
120
+ *
121
+ * Short names support stacking (e.g. `-abc` is parsed as `-a -b -c`) and inline
122
+ * values (e.g. `-nvalue`). Short names must not be a prefix of, nor have as a prefix,
123
+ * any other registered short name — the parser uses prefix matching to parse stacked
124
+ * shorts, so overlapping prefixes would be ambiguous.
125
+ *
126
+ * @param definition.longs - Long-form names (without `--`).
127
+ * @param definition.shorts - Short-form names (without `-`).
128
+ * @param definition.valued - `true` if the option consumes a value; `false` for flags.
129
+ * @returns An opaque {@link ReaderOptionKey} to pass to {@link ReaderArgs.getOptionValues}.
130
+ * @throws `Error` if any name is already registered or if two short names overlap.
131
+ */
45
132
  registerOption(definition: {
46
133
  longs: Array<string>;
47
134
  shorts: Array<string>;
@@ -83,6 +170,15 @@ export class ReaderArgs {
83
170
  return key;
84
171
  }
85
172
 
173
+ /**
174
+ * Returns all raw string values collected for the given option key.
175
+ *
176
+ * @param key - A key previously returned by {@link ReaderArgs.registerOption}.
177
+ * @returns An array of string values, one per occurrence on the command line. For
178
+ * flags this will be `["true"]` per occurrence; for valued options it will be the
179
+ * literal value strings.
180
+ * @throws `Error` if `key` was not registered on this instance.
181
+ */
86
182
  getOptionValues(key: ReaderOptionKey): Array<string> {
87
183
  const optionResult = this.#resultByKey.get(key);
88
184
  if (optionResult === undefined) {
@@ -91,6 +187,20 @@ export class ReaderArgs {
91
187
  return optionResult;
92
188
  }
93
189
 
190
+ /**
191
+ * Scans forward through the argument list and returns the next bare positional token,
192
+ * consuming and parsing any intervening option tokens as side-effects.
193
+ *
194
+ * Option tokens encountered during the scan are recorded in the internal results map
195
+ * (equivalent to recording their values against their key). Any unrecognised option token
196
+ * causes a {@link TypoError} to be thrown immediately.
197
+ *
198
+ * After `--` is encountered, all remaining tokens are treated as positionals.
199
+ *
200
+ * @returns The next positional string, or `undefined` when the argument list is
201
+ * exhausted.
202
+ * @throws {@link TypoError} if an unrecognised option (long or short) is encountered.
203
+ */
94
204
  consumePositional(): string | undefined {
95
205
  while (true) {
96
206
  const arg = this.#consumeArg();
package/src/lib/Run.ts CHANGED
@@ -3,7 +3,75 @@ import { ReaderArgs } from "./Reader";
3
3
  import { TypoSupport } from "./Typo";
4
4
  import { usageToStyledLines } from "./Usage";
5
5
 
6
- export async function runAsCliAndExit<Context>(
6
+ /**
7
+ * Parses the provided CLI arguments against the given command descriptor, executes
8
+ * the matched command, and exits the process with an appropriate exit code.
9
+ *
10
+ * This is the primary entry point for running a `cli-kiss`-based CLI application.
11
+ * It handles argument parsing, `--help` / `--version` flags, usage printing on errors,
12
+ * and exit code management.
13
+ *
14
+ * **Exit codes:**
15
+ * - `0` — Command executed successfully, or `--help` / `--version` was handled.
16
+ * - `1` — Argument parsing failed (a usage summary is also printed to stderr), or the
17
+ * command threw an unhandled execution error.
18
+ *
19
+ * **Built-in flags (opt-out):**
20
+ * - `--help` — Enabled by default (`usageOnHelp: true`). Prints the usage summary to
21
+ * stdout and exits with code `0`. This flag takes precedence over `--version`.
22
+ * - `--version` — Enabled when `buildVersion` is provided. Prints `<cliName> <version>`
23
+ * to stdout and exits with code `0`.
24
+ *
25
+ * @typeParam Context - Arbitrary value passed unchanged to the command's execution handler.
26
+ * Use this to inject dependencies (e.g. a database connection, a logger) into your commands.
27
+ *
28
+ * @param cliName - The name of the CLI program (e.g. `"my-cli"`). Used in the usage
29
+ * summary header and in the `--version` output.
30
+ * @param cliArgs - The raw command-line arguments to parse, typically `process.argv.slice(2)`.
31
+ * @param context - The context value forwarded to the command's execution handler.
32
+ * @param command - The root {@link CommandDescriptor} that describes how to parse and execute
33
+ * the CLI.
34
+ * @param options - Optional configuration for the runner.
35
+ * @param options.useTtyColors - Controls terminal color output in styled messages.
36
+ * - `true` — Always apply ANSI color codes.
37
+ * - `false` — Never apply color codes (plain text).
38
+ * - `"mock"` — Use a deterministic mock style useful for snapshot testing.
39
+ * - `undefined` (default) — Auto-detect based on `process.stdout.isTTY` and the
40
+ * `FORCE_COLOR` / `NO_COLOR` environment variables.
41
+ * @param options.usageOnHelp - When `true` (default), registers a `--help` flag that
42
+ * prints the usage summary and exits with code `0`.
43
+ * @param options.usageOnError - When `true` (default), prints the usage summary to
44
+ * stderr before the error message whenever argument parsing fails.
45
+ * @param options.buildVersion - When provided, registers a `--version` flag that prints
46
+ * `<cliName> <buildVersion>` to stdout and exits with code `0`.
47
+ * @param options.onError - Custom handler for errors thrown during command execution.
48
+ * If omitted, the error is printed to stderr via {@link TypoSupport}.
49
+ * @param options.onExit - Overrides the process exit function (default: `process.exit`).
50
+ * Useful for testing — supply a function that throws or captures the exit code instead
51
+ * of actually terminating the process.
52
+ *
53
+ * @returns A `Promise<never>` because the function always terminates by calling `onExit`.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * import { runAndExit, command, operation, positionalRequired, typeString } from "cli-kiss";
58
+ *
59
+ * const greetCommand = command(
60
+ * { description: "Greet someone" },
61
+ * operation(
62
+ * { options: {}, positionals: [positionalRequired({ type: typeString, label: "NAME" })] },
63
+ * async (_ctx, { positionals: [name] }) => {
64
+ * console.log(`Hello, ${name}!`);
65
+ * },
66
+ * ),
67
+ * );
68
+ *
69
+ * await runAndExit("greet", process.argv.slice(2), undefined, greetCommand, {
70
+ * buildVersion: "1.0.0",
71
+ * });
72
+ * ```
73
+ */
74
+ export async function runAndExit<Context>(
7
75
  cliName: Lowercase<string>,
8
76
  cliArgs: ReadonlyArray<string>,
9
77
  context: Context,
@@ -13,9 +81,7 @@ export async function runAsCliAndExit<Context>(
13
81
  usageOnHelp?: boolean | undefined;
14
82
  usageOnError?: boolean | undefined;
15
83
  buildVersion?: string | undefined;
16
- onExecutionError?: ((error: unknown) => void) | undefined;
17
- onLogStdOut?: ((message: string) => void) | undefined;
18
- onLogStdErr?: ((message: string) => void) | undefined;
84
+ onError?: ((error: unknown) => void) | undefined;
19
85
  onExit?: ((code: number) => never) | undefined;
20
86
  },
21
87
  ): Promise<never> {
@@ -44,17 +110,6 @@ export async function runAsCliAndExit<Context>(
44
110
  longs: ["completion"],
45
111
  });
46
112
  */
47
- const typoSupport =
48
- options?.useTtyColors === undefined
49
- ? TypoSupport.inferFromProcess()
50
- : options.useTtyColors === "mock"
51
- ? TypoSupport.mock()
52
- : options.useTtyColors
53
- ? TypoSupport.tty()
54
- : TypoSupport.none();
55
- const onLogStdOut = options?.onLogStdOut ?? console.log;
56
- const onLogStdErr = options?.onLogStdErr ?? console.error;
57
- const onExit = options?.onExit ?? process.exit;
58
113
  const commandFactory = command.createFactory(readerArgs);
59
114
  while (true) {
60
115
  try {
@@ -64,15 +119,24 @@ export async function runAsCliAndExit<Context>(
64
119
  }
65
120
  } catch (_) {}
66
121
  }
122
+ const onExit = options?.onExit ?? process.exit;
123
+ const typoSupport =
124
+ options?.useTtyColors === undefined
125
+ ? TypoSupport.inferFromProcess()
126
+ : options.useTtyColors === "mock"
127
+ ? TypoSupport.mock()
128
+ : options.useTtyColors
129
+ ? TypoSupport.tty()
130
+ : TypoSupport.none();
67
131
  if (usageOnHelp) {
68
132
  if (readerArgs.getOptionValues("--help" as any).length > 0) {
69
- onLogStdOut(computeUsageString(cliName, commandFactory, typoSupport));
133
+ console.log(computeUsageString(cliName, commandFactory, typoSupport));
70
134
  return onExit(0);
71
135
  }
72
136
  }
73
137
  if (buildVersion) {
74
138
  if (readerArgs.getOptionValues("--version" as any).length > 0) {
75
- onLogStdOut([cliName, buildVersion].join(" "));
139
+ console.log([cliName, buildVersion].join(" "));
76
140
  return onExit(0);
77
141
  }
78
142
  }
@@ -82,18 +146,18 @@ export async function runAsCliAndExit<Context>(
82
146
  await commandInstance.executeWithContext(context);
83
147
  return onExit(0);
84
148
  } catch (executionError) {
85
- if (options?.onExecutionError) {
86
- options.onExecutionError(executionError);
149
+ if (options?.onError) {
150
+ options.onError(executionError);
87
151
  } else {
88
- onLogStdErr(typoSupport.computeStyledErrorMessage(executionError));
152
+ console.error(typoSupport.computeStyledErrorMessage(executionError));
89
153
  }
90
154
  return onExit(1);
91
155
  }
92
156
  } catch (parsingError) {
93
157
  if (options?.usageOnError ?? true) {
94
- onLogStdErr(computeUsageString(cliName, commandFactory, typoSupport));
158
+ console.error(computeUsageString(cliName, commandFactory, typoSupport));
95
159
  }
96
- onLogStdErr(typoSupport.computeStyledErrorMessage(parsingError));
160
+ console.error(typoSupport.computeStyledErrorMessage(parsingError));
97
161
  return onExit(1);
98
162
  }
99
163
  }