@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
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Command lines declared as a contract and mapped to handlers: `commands`, `command`,
3
+ * `argument`, `flag` and `prompt` declare what a program takes, `createCLI` maps handlers onto
4
+ * it and runs it, and every handler answers a `Result` that decides the exit code.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export type { Argument, ArgumentOptions, ArgumentValue, RestArgumentOptions, SingleArgumentOptions, VariadicArgumentOptions, } from "./argument.js";
10
+ export type { Binding, CheckedCommand, CLI, CLIOptions } from "./cli.js";
11
+ export type { ArgumentsOf, ArgumentsShape, Command, CommandDefinition, FlagsOf, FlagsShape, Group, GroupDefinition, GroupInputs, Node, } from "./command.js";
12
+ export type { AnyCommandContext, CommandContextInit, Stdin } from "./context.js";
13
+ export type { ArgumentDescription, CommandDescription, FlagDescription } from "./describe.js";
14
+ export type { Exec, ExecOptions, ExecOutput, ExecRequest, Stdio } from "./exec.js";
15
+ export type { BooleanFlagOptions, Flag, FlagKind, FlagOptions, FlagValue, ValueFlag, ValueFlagOptions, } from "./flag.js";
16
+ export type { ChainContext, CLITypes, CommandAction, CommandHandler, HandlerContext, } from "./handler.js";
17
+ export type { Input } from "./keys.js";
18
+ export type { Lazy } from "./lazy.js";
19
+ export type { AnyCommandMiddleware, ChainProperties, CommandMiddleware, CommandResult, ContextEffect, EmptyContextEffect, NextFunction, } from "./middleware.js";
20
+ export type { Ask, AskOptions, Choice, Choices, ConfirmOptions, MultiselectOptions, NumberOptions, PasswordOptions, PromptDeclaration, PromptError, PromptKind, Prompter, SearchOptions, SelectOptions, TextOptions, } from "./prompt.js";
21
+ export type { Output } from "./screen.js";
22
+ export type { RunIO } from "./session.js";
23
+ export type { Env, Paint, Style } from "./style.js";
24
+ export type { RunTask, Task, TaskResult } from "./task.js";
25
+ export { argument } from "./argument.js";
26
+ export { createCLI } from "./cli.js";
27
+ export { command, commands } from "./command.js";
28
+ export { CommandContext } from "./context.js";
29
+ export { CancelledError, EXIT_CODES, ExitError, UsageError, cancelled, exit, exitCodeOf, success, usage, } from "./errors.js";
30
+ export { ExecError, spawnExec } from "./exec.js";
31
+ export { flag } from "./flag.js";
32
+ export { createCommand } from "./handler.js";
33
+ export { lazy } from "./lazy.js";
34
+ export { prompt } from "./prompt.js";
35
+ export { createStyle } from "./style.js";
36
+ export { failure } from "@sdxc/result";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Command lines declared as a contract and mapped to handlers: `commands`, `command`,
3
+ * `argument`, `flag` and `prompt` declare what a program takes, `createCLI` maps handlers onto
4
+ * it and runs it, and every handler answers a `Result` that decides the exit code.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { argument } from "./argument.js";
10
+ export { createCLI } from "./cli.js";
11
+ export { command, commands } from "./command.js";
12
+ export { CommandContext } from "./context.js";
13
+ export { CancelledError, EXIT_CODES, ExitError, UsageError, cancelled, exit, exitCodeOf, success, usage, } from "./errors.js";
14
+ export { ExecError, spawnExec } from "./exec.js";
15
+ export { flag } from "./flag.js";
16
+ export { createCommand } from "./handler.js";
17
+ export { lazy } from "./lazy.js";
18
+ export { prompt } from "./prompt.js";
19
+ export { createStyle } from "./style.js";
20
+ export { failure } from "@sdxc/result";
@@ -0,0 +1,27 @@
1
+ /**
2
+ * How each prompt kind draws and reacts to keys on a terminal: a line editor for text, password
3
+ * and number, a toggle for confirm, and a scrolling list for select, multiselect and search.
4
+ * Each is pure state over keys; `ask.ts` owns the terminal and the loop around them.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Key } from "./keys.js";
10
+ import type { PromptKind, PromptKinds } from "./prompt.js";
11
+ import type { Style } from "./style.js";
12
+ /** What a key did: answered the prompt, or found the answer wrong, or neither. */
13
+ export type Step = {
14
+ submit: unknown;
15
+ } | {
16
+ error: string;
17
+ } | undefined;
18
+ /** One prompt kind's state on a terminal. */
19
+ export interface Interaction {
20
+ /** The lines under the question, the question line itself included. */
21
+ render(style: Style, header: string): string[];
22
+ key(key: Key): Step;
23
+ /** The answer as the summary line prints it. */
24
+ summary(value: unknown): string;
25
+ }
26
+ /** The interaction for `kind`, starting from `initial` (the default, or a preselection). */
27
+ export declare function interaction<Kind extends PromptKind>(kind: Kind, options: PromptKinds[Kind], initial: unknown): Interaction;
@@ -0,0 +1,373 @@
1
+ /**
2
+ * How each prompt kind draws and reacts to keys on a terminal: a line editor for text, password
3
+ * and number, a toggle for confirm, and a scrolling list for select, multiselect and search.
4
+ * Each is pure state over keys; `ask.ts` owns the terminal and the loop around them.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { typed } from "./keys.js";
10
+ import { normalizeChoices } from "./prompt.js";
11
+ import { graphemes } from "./style.js";
12
+ /** How many list options show at once; the rest scroll. */
13
+ const LIST_LIMIT = 10;
14
+ /** How long typed characters keep adding to a select's jump-to text. */
15
+ const JUMP_RESET_MS = 1000;
16
+ /** The interaction for `kind`, starting from `initial` (the default, or a preselection). */
17
+ export function interaction(kind, options, initial) {
18
+ switch (kind) {
19
+ case "text":
20
+ return line(initial, { placeholder: options.placeholder });
21
+ case "password":
22
+ return line(initial, { mask: options.mask ?? true });
23
+ case "number":
24
+ return numberLine(options, initial);
25
+ case "confirm":
26
+ return confirm(initial === true, options);
27
+ case "select":
28
+ return list(options.options, initial, { mode: "one" });
29
+ case "multiselect": {
30
+ let multiselect = options;
31
+ return list(multiselect.options, initial, {
32
+ mode: "many",
33
+ min: multiselect.min,
34
+ max: multiselect.max,
35
+ });
36
+ }
37
+ default: {
38
+ let search = options;
39
+ return list(search.options, initial, {
40
+ mode: search.multiple === true ? "many" : "one",
41
+ search: true,
42
+ placeholder: search.placeholder,
43
+ min: search.min,
44
+ max: search.max,
45
+ });
46
+ }
47
+ }
48
+ }
49
+ /** A line of text being edited, with a cursor inside it. */
50
+ class Editor {
51
+ chars;
52
+ cursor;
53
+ /** @param text - What the line starts with */
54
+ constructor(text = "") {
55
+ this.chars = graphemes(text);
56
+ this.cursor = this.chars.length;
57
+ }
58
+ get text() {
59
+ return this.chars.join("");
60
+ }
61
+ /** Applies an editing key, answering whether it was one. */
62
+ edit(key, accept = () => true) {
63
+ let character = typed(key);
64
+ if (character !== undefined) {
65
+ if (!accept(character))
66
+ return false;
67
+ this.chars.splice(this.cursor, 0, character);
68
+ this.cursor += 1;
69
+ return true;
70
+ }
71
+ if (key.name === "backspace") {
72
+ if (this.cursor > 0)
73
+ this.chars.splice(this.cursor - 1, 1);
74
+ this.cursor = Math.max(0, this.cursor - 1);
75
+ }
76
+ else if (key.name === "delete")
77
+ this.chars.splice(this.cursor, 1);
78
+ else if (key.name === "left")
79
+ this.cursor = Math.max(0, this.cursor - 1);
80
+ else if (key.name === "right")
81
+ this.cursor = Math.min(this.chars.length, this.cursor + 1);
82
+ else if (key.name === "home" || (key.ctrl && key.name === "a"))
83
+ this.cursor = 0;
84
+ else if (key.name === "end" || (key.ctrl && key.name === "e"))
85
+ this.cursor = this.chars.length;
86
+ else if (key.ctrl && key.name === "u") {
87
+ this.chars.splice(0, this.cursor);
88
+ this.cursor = 0;
89
+ }
90
+ else
91
+ return false;
92
+ return true;
93
+ }
94
+ /** The line with the cursor drawn as an inverse cell, each character shown through `show`. */
95
+ render(style, show = (character) => character) {
96
+ let shown = this.chars.map(show);
97
+ let at = shown[this.cursor] ?? " ";
98
+ return `${shown.slice(0, this.cursor).join("")}${style.inverse(at)}${shown.slice(this.cursor + 1).join("")}`;
99
+ }
100
+ }
101
+ /** Text and password: a line editor whose empty answer takes `initial`. */
102
+ function line(initial, options) {
103
+ let editor = new Editor();
104
+ let fallback = typeof initial === "string" ? initial : undefined;
105
+ let secret = options.mask !== undefined;
106
+ return {
107
+ render(style, header) {
108
+ if (editor.chars.length === 0) {
109
+ let hint = secret ? undefined : (fallback ?? options.placeholder);
110
+ let shown = hint === undefined ? style.inverse(" ") : style.dim(hint);
111
+ return [`${header} › ${shown}`];
112
+ }
113
+ if (secret && options.mask === false)
114
+ return [`${header} › `];
115
+ let value = secret ? editor.render(style, () => "•") : editor.render(style);
116
+ return [`${header} › ${value}`];
117
+ },
118
+ key(key) {
119
+ if (key.name === "return") {
120
+ return {
121
+ submit: editor.chars.length === 0 && fallback !== undefined ? fallback : editor.text,
122
+ };
123
+ }
124
+ editor.edit(key);
125
+ return undefined;
126
+ },
127
+ summary(value) {
128
+ if (!secret)
129
+ return String(value);
130
+ return options.mask === false ? "" : "•".repeat(graphemes(String(value)).length);
131
+ },
132
+ };
133
+ }
134
+ /** Number: digits, `-` and `.`, with the arrows stepping by `step` within `min` and `max`. */
135
+ function numberLine(options, initial) {
136
+ let editor = new Editor();
137
+ let fallback = typeof initial === "number" ? initial : undefined;
138
+ let step = options.step ?? 1;
139
+ let clamp = (value) => Math.min(options.max ?? Infinity, Math.max(options.min ?? -Infinity, value));
140
+ return {
141
+ render(style, header) {
142
+ if (editor.chars.length === 0) {
143
+ let shown = fallback === undefined ? style.inverse(" ") : style.dim(String(fallback));
144
+ return [`${header} › ${shown}`];
145
+ }
146
+ return [`${header} › ${editor.render(style)}`];
147
+ },
148
+ key(key) {
149
+ if (key.name === "up" || key.name === "down") {
150
+ let current = editor.text === "" ? (fallback ?? options.min ?? 0) : Number(editor.text);
151
+ if (Number.isNaN(current))
152
+ current = fallback ?? 0;
153
+ let next = editor.text === "" && fallback === undefined
154
+ ? current
155
+ : current + (key.name === "up" ? step : -step);
156
+ editor = new Editor(String(clamp(next)));
157
+ return undefined;
158
+ }
159
+ if (key.name === "return") {
160
+ if (editor.text === "") {
161
+ return fallback === undefined ? { error: "Enter a number" } : { submit: fallback };
162
+ }
163
+ let value = Number(editor.text);
164
+ if (Number.isNaN(value))
165
+ return { error: "Enter a number" };
166
+ if (options.min !== undefined && value < options.min) {
167
+ return { error: `Enter a number of at least ${options.min}` };
168
+ }
169
+ if (options.max !== undefined && value > options.max) {
170
+ return { error: `Enter a number of at most ${options.max}` };
171
+ }
172
+ return { submit: value };
173
+ }
174
+ editor.edit(key, (character) => /^[\d.-]$/.test(character));
175
+ return undefined;
176
+ },
177
+ summary: (value) => String(value),
178
+ };
179
+ }
180
+ /** Yes or no: `y` and `n` answer at once, the arrows switch, and Enter takes the current one. */
181
+ function confirm(initial, options) {
182
+ let value = initial;
183
+ let active = options.active ?? "Yes";
184
+ let inactive = options.inactive ?? "No";
185
+ return {
186
+ render(style, header) {
187
+ let yes = value ? style.cyan(style.underline(active)) : style.dim(active);
188
+ let no = value ? style.dim(inactive) : style.cyan(style.underline(inactive));
189
+ let marker = value ? "(Y/n)" : "(y/N)";
190
+ return [
191
+ `${header} ${style.dim(marker)} › ${value ? "●" : "○"} ${yes} / ${value ? "○" : "●"} ${no}`,
192
+ ];
193
+ },
194
+ key(key) {
195
+ if (key.name === "y")
196
+ return { submit: true };
197
+ if (key.name === "n")
198
+ return { submit: false };
199
+ if (key.name === "return")
200
+ return { submit: value };
201
+ if (["left", "right", "up", "down", "tab"].includes(key.name))
202
+ value = !value;
203
+ return undefined;
204
+ },
205
+ summary: (answer) => (answer === true ? active : inactive),
206
+ };
207
+ }
208
+ /**
209
+ * Select, multiselect and search: a cursor over the options (only the ones matching the typed
210
+ * query, for search), skipping disabled ones, with Space toggling when the answer is a list.
211
+ */
212
+ function list(choices, initial, mode) {
213
+ let options = normalizeChoices(choices);
214
+ let selected = new Set();
215
+ if (mode.mode === "many" && Array.isArray(initial)) {
216
+ options.forEach((option, index) => {
217
+ if (initial.some((value) => Object.is(value, option.value)))
218
+ selected.add(index);
219
+ });
220
+ }
221
+ let query = new Editor();
222
+ let jump = "";
223
+ let jumpedAt = 0;
224
+ /** The options the query matches, as indices into `options`. */
225
+ let visible = () => {
226
+ let needle = query.text.toLowerCase();
227
+ return options
228
+ .map((_, index) => index)
229
+ .filter((index) => {
230
+ if (mode.search !== true || needle === "")
231
+ return true;
232
+ let option = options[index];
233
+ return (option.label.toLowerCase().includes(needle) ||
234
+ String(option.value).toLowerCase().includes(needle));
235
+ });
236
+ };
237
+ let initialIndex = options.findIndex((option) => !option.disabled && mode.mode === "one" && Object.is(option.value, initial));
238
+ let cursor = initialIndex === -1 ? options.findIndex((option) => !option.disabled) : initialIndex;
239
+ /** Moves the cursor `by` positions through the matching enabled options, wrapping around. */
240
+ let move = (by) => {
241
+ let shown = visible().filter((index) => !options[index].disabled);
242
+ if (shown.length === 0)
243
+ return;
244
+ let at = shown.indexOf(cursor);
245
+ let next = at === -1 ? 0 : (at + by + shown.length) % shown.length;
246
+ cursor = shown[next];
247
+ };
248
+ /** Puts the cursor on the first match after the query changed. */
249
+ let settle = () => {
250
+ let shown = visible().filter((index) => !options[index].disabled);
251
+ if (!shown.includes(cursor))
252
+ cursor = shown[0] ?? -1;
253
+ };
254
+ let labels = (value) => {
255
+ let values = Array.isArray(value) ? value : [value];
256
+ return values
257
+ .map((each) => options.find((option) => Object.is(option.value, each))?.label ?? String(each))
258
+ .join(", ");
259
+ };
260
+ return {
261
+ render(style, header) {
262
+ let lines = [];
263
+ if (mode.search === true) {
264
+ let shownQuery = query.chars.length === 0
265
+ ? mode.placeholder === undefined
266
+ ? style.inverse(" ")
267
+ : style.dim(mode.placeholder)
268
+ : query.render(style);
269
+ lines.push(`${header} › ${shownQuery}`);
270
+ }
271
+ else
272
+ lines.push(header);
273
+ let shown = visible();
274
+ if (shown.length === 0) {
275
+ lines.push(style.dim(" No option matches"));
276
+ return lines;
277
+ }
278
+ let at = Math.max(0, shown.indexOf(cursor));
279
+ let start = Math.min(Math.max(0, at - Math.floor(LIST_LIMIT / 2)), Math.max(0, shown.length - LIST_LIMIT));
280
+ let window = shown.slice(start, start + LIST_LIMIT);
281
+ if (start > 0)
282
+ lines.push(style.dim(` ↑ ${start} more`));
283
+ for (let index of window) {
284
+ let option = options[index];
285
+ let pointer = index === cursor ? style.cyan("❯") : " ";
286
+ let box = mode.mode === "many" ? `${selected.has(index) ? style.green("◉") : "◯"} ` : "";
287
+ let label = option.disabled
288
+ ? style.dim(`${option.label} (disabled)`)
289
+ : index === cursor
290
+ ? style.cyan(option.label)
291
+ : option.label;
292
+ let hint = option.hint === "" ? "" : ` ${style.dim(option.hint)}`;
293
+ lines.push(`${pointer} ${box}${label}${hint}`);
294
+ }
295
+ let below = shown.length - start - window.length;
296
+ if (below > 0)
297
+ lines.push(style.dim(` ↓ ${below} more`));
298
+ return lines;
299
+ },
300
+ key(key) {
301
+ if (key.name === "up")
302
+ move(-1);
303
+ else if (key.name === "down" || key.name === "tab")
304
+ move(1);
305
+ else if (key.name === "home" && mode.search !== true) {
306
+ cursor = -1;
307
+ move(1);
308
+ }
309
+ else if (key.name === "end" && mode.search !== true) {
310
+ cursor = -1;
311
+ move(-1);
312
+ }
313
+ else if (key.name === "space" && mode.mode === "many") {
314
+ let option = options[cursor];
315
+ if (option !== undefined && !option.disabled) {
316
+ if (selected.has(cursor))
317
+ selected.delete(cursor);
318
+ else
319
+ selected.add(cursor);
320
+ }
321
+ }
322
+ else if (key.name === "a" && !key.ctrl && mode.mode === "many" && mode.search !== true) {
323
+ let enabled = options
324
+ .map((_, index) => index)
325
+ .filter((index) => !options[index].disabled);
326
+ let all = enabled.every((index) => selected.has(index));
327
+ for (let index of enabled) {
328
+ if (all)
329
+ selected.delete(index);
330
+ else
331
+ selected.add(index);
332
+ }
333
+ }
334
+ else if (key.name === "return") {
335
+ if (mode.mode === "one") {
336
+ let option = options[cursor];
337
+ if (option === undefined || option.disabled || !visible().includes(cursor)) {
338
+ return { error: "No option matches" };
339
+ }
340
+ return { submit: option.value };
341
+ }
342
+ let values = options
343
+ .filter((_, index) => selected.has(index))
344
+ .map((option) => option.value);
345
+ if (mode.min !== undefined && values.length < mode.min) {
346
+ return { error: `Select at least ${mode.min}` };
347
+ }
348
+ if (mode.max !== undefined && values.length > mode.max) {
349
+ return { error: `Select at most ${mode.max}` };
350
+ }
351
+ return { submit: values };
352
+ }
353
+ else if (mode.search === true) {
354
+ if (query.edit(key))
355
+ settle();
356
+ }
357
+ else {
358
+ let character = typed(key);
359
+ if (character !== undefined) {
360
+ let now = Date.now();
361
+ jump = now - jumpedAt > JUMP_RESET_MS ? character : jump + character;
362
+ jumpedAt = now;
363
+ let needle = jump.toLowerCase();
364
+ let match = options.findIndex((option) => !option.disabled && option.label.toLowerCase().startsWith(needle));
365
+ if (match !== -1)
366
+ cursor = match;
367
+ }
368
+ }
369
+ return undefined;
370
+ },
371
+ summary: labels,
372
+ };
373
+ }
package/dist/keys.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Keyboard input for prompts: `node:readline`'s keypress events on a stdin in raw mode, which
3
+ * run the same on Node and Bun, behind a queue a prompt reads one key at a time. Closing the
4
+ * reader leaves raw mode and pauses stdin, so the process can exit once a prompt is answered.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ /** One key press, shaped as `node:readline` reports it. */
10
+ export interface Key {
11
+ /** `"return"`, `"up"`, `"a"`, `"space"`…; empty for a character readline does not name. */
12
+ name: string;
13
+ /** The characters the key produced. */
14
+ sequence: string;
15
+ ctrl: boolean;
16
+ meta: boolean;
17
+ shift: boolean;
18
+ }
19
+ /** Reads keys one at a time. */
20
+ export interface Keyboard {
21
+ /** The next key, or `null` once no more will arrive (`signal` aborted, input ended). */
22
+ read(signal: AbortSignal): Promise<Key | null>;
23
+ /** Stops reading and gives the input back. */
24
+ close(): void;
25
+ }
26
+ /** A readable stream a CLI reads keys from; `process.stdin` is one. */
27
+ export interface Input extends NodeJS.EventEmitter {
28
+ isTTY?: boolean;
29
+ setRawMode?(mode: boolean): unknown;
30
+ resume(): unknown;
31
+ pause(): unknown;
32
+ }
33
+ /**
34
+ * Starts reading keys from `input` in raw mode.
35
+ *
36
+ * @param onRaw - Told when raw mode turns on and off, so an exit handler can leave it
37
+ */
38
+ export declare function openKeyboard(input: Input, onRaw: (raw: boolean) => void): Keyboard;
39
+ /**
40
+ * The key a script names: `"enter"`, `"down"`, `"ctrl+c"`, or a single character. Longer text is
41
+ * not a key; {@link keysFromScript} types it one character at a time.
42
+ */
43
+ export declare function keyFromName(name: string): Key;
44
+ /** A test's key script as key presses: named keys as one press, other text one per character. */
45
+ export declare function keysFromScript(script: readonly string[]): Key[];
46
+ /** Whether `key` cancels a prompt: Ctrl+C or Escape. */
47
+ export declare function isCancel(key: Key): boolean;
48
+ /** The printable character `key` types, or `undefined` for a control key. */
49
+ export declare function typed(key: Key): string | undefined;
package/dist/keys.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Keyboard input for prompts: `node:readline`'s keypress events on a stdin in raw mode, which
3
+ * run the same on Node and Bun, behind a queue a prompt reads one key at a time. Closing the
4
+ * reader leaves raw mode and pauses stdin, so the process can exit once a prompt is answered.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import { emitKeypressEvents } from "node:readline";
10
+ import { graphemes } from "./style.js";
11
+ /**
12
+ * Starts reading keys from `input` in raw mode.
13
+ *
14
+ * @param onRaw - Told when raw mode turns on and off, so an exit handler can leave it
15
+ */
16
+ export function openKeyboard(input, onRaw) {
17
+ emitKeypressEvents(input);
18
+ input.setRawMode?.(true);
19
+ onRaw(true);
20
+ input.resume();
21
+ let queue = [];
22
+ let waiting;
23
+ let ended = false;
24
+ /** Queues one key, or hands it to the read waiting for it. */
25
+ function onKeypress(sequence, key) {
26
+ let pressed = {
27
+ name: key?.name ?? "",
28
+ sequence: key?.sequence ?? sequence ?? "",
29
+ ctrl: key?.ctrl === true,
30
+ meta: key?.meta === true,
31
+ shift: key?.shift === true,
32
+ };
33
+ if (waiting === undefined)
34
+ queue.push(pressed);
35
+ else {
36
+ let resolve = waiting;
37
+ waiting = undefined;
38
+ resolve(pressed);
39
+ }
40
+ }
41
+ /** Ends every pending read once the input closes. */
42
+ function onEnd() {
43
+ ended = true;
44
+ waiting?.(null);
45
+ waiting = undefined;
46
+ }
47
+ input.on("keypress", onKeypress);
48
+ input.on("end", onEnd);
49
+ return {
50
+ read(signal) {
51
+ let next = queue.shift();
52
+ if (next !== undefined)
53
+ return Promise.resolve(next);
54
+ if (ended || signal.aborted)
55
+ return Promise.resolve(null);
56
+ return new Promise((resolve) => {
57
+ let abort = () => {
58
+ waiting = undefined;
59
+ resolve(null);
60
+ };
61
+ signal.addEventListener("abort", abort, { once: true });
62
+ waiting = (key) => {
63
+ signal.removeEventListener("abort", abort);
64
+ resolve(key);
65
+ };
66
+ });
67
+ },
68
+ close() {
69
+ input.off("keypress", onKeypress);
70
+ input.off("end", onEnd);
71
+ input.setRawMode?.(false);
72
+ onRaw(false);
73
+ input.pause();
74
+ },
75
+ };
76
+ }
77
+ /** Keys a script names by something other than the character they type. */
78
+ const NAMED_KEYS = {
79
+ enter: "return",
80
+ return: "return",
81
+ space: "space",
82
+ up: "up",
83
+ down: "down",
84
+ left: "left",
85
+ right: "right",
86
+ home: "home",
87
+ end: "end",
88
+ tab: "tab",
89
+ backspace: "backspace",
90
+ delete: "delete",
91
+ escape: "escape",
92
+ pageup: "pageup",
93
+ pagedown: "pagedown",
94
+ };
95
+ /**
96
+ * The key a script names: `"enter"`, `"down"`, `"ctrl+c"`, or a single character. Longer text is
97
+ * not a key; {@link keysFromScript} types it one character at a time.
98
+ */
99
+ export function keyFromName(name) {
100
+ let ctrl = name.startsWith("ctrl+");
101
+ let bare = ctrl ? name.slice(5) : name;
102
+ let named = NAMED_KEYS[bare];
103
+ if (named !== undefined) {
104
+ let sequence = named === "space" ? " " : named === "return" ? "\r" : "";
105
+ return { name: named, sequence, ctrl, meta: false, shift: false };
106
+ }
107
+ let shift = bare !== bare.toLowerCase();
108
+ return {
109
+ name: /^[a-z0-9]$/i.test(bare) ? bare.toLowerCase() : "",
110
+ sequence: ctrl ? String.fromCharCode(bare.charCodeAt(0) & 31) : bare,
111
+ ctrl,
112
+ meta: false,
113
+ shift,
114
+ };
115
+ }
116
+ /** A test's key script as key presses: named keys as one press, other text one per character. */
117
+ export function keysFromScript(script) {
118
+ let keys = [];
119
+ for (let entry of script) {
120
+ if (entry.length === 1 || NAMED_KEYS[entry] !== undefined || entry.startsWith("ctrl+")) {
121
+ keys.push(keyFromName(entry));
122
+ }
123
+ else {
124
+ for (let character of entry)
125
+ keys.push(keyFromName(character));
126
+ }
127
+ }
128
+ return keys;
129
+ }
130
+ /** Whether `key` cancels a prompt: Ctrl+C or Escape. */
131
+ export function isCancel(key) {
132
+ return (key.ctrl && key.name === "c") || key.name === "escape";
133
+ }
134
+ /** The printable character `key` types, or `undefined` for a control key. */
135
+ export function typed(key) {
136
+ if (key.ctrl || key.meta)
137
+ return undefined;
138
+ if (key.name === "space")
139
+ return " ";
140
+ let sequence = key.sequence;
141
+ if (sequence.length === 0 || sequence.startsWith("\u001B"))
142
+ return undefined;
143
+ if (graphemes(sequence).length !== 1)
144
+ return undefined;
145
+ return sequence.codePointAt(0) >= 32 && sequence !== "\u007F" ? sequence : undefined;
146
+ }
package/dist/lazy.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Marks a command's implementation as a module imported on first use, so a run imports only
3
+ * the handler it reaches and `--help` imports none.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /** Brands a loader so `map()` can tell it from a handler, both being plain functions. */
9
+ declare const LAZY: unique symbol;
10
+ /** An implementation that arrives as the default export of a module imported on demand. */
11
+ export interface Lazy<Target> {
12
+ readonly [LAZY]: true;
13
+ readonly load: () => Promise<{
14
+ default: Target;
15
+ }>;
16
+ }
17
+ /**
18
+ * Wraps a dynamic import so `map()` defers it to the run that needs the module, which
19
+ * default-exports what `createCommand` returns.
20
+ *
21
+ * @param load - A function returning the module's `import()`
22
+ * @example cli.map(sdxc.ui.add, lazy(() => import("./commands/ui/add.js")));
23
+ */
24
+ export declare function lazy<Target>(load: () => Promise<{
25
+ default: Target;
26
+ }>): Lazy<Target>;
27
+ /** Whether a `map()` argument was wrapped by {@link lazy}. */
28
+ export declare function isLazy(value: unknown): value is Lazy<unknown>;
29
+ export {};