clap-ts 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/prompt.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Interactive prompts, derived from the argument definitions a command already
3
+ * has.
4
+ *
5
+ * ```ts
6
+ * import { promptMissing } from 'clap-ts/prompt';
7
+ *
8
+ * await runMain(main, { fillMissing: promptMissing() });
9
+ * ```
10
+ *
11
+ * A required argument nobody supplied is asked for rather than rejected: a
12
+ * boolean becomes a confirm, an argument with possible values becomes a
13
+ * numbered list, and one marked `secret` is read without echoing. `ctx
14
+ * .valueSources` reports those as `'prompt'`.
15
+ *
16
+ * Nothing prompts when stdin is not a terminal, so a script or a CI job still
17
+ * fails fast with the usual error instead of hanging on input that will never
18
+ * arrive.
19
+ */
20
+ import { createInterface } from 'node:readline/promises';
21
+ import { styleText } from 'node:util';
22
+ import { possibleValues } from './parser.js';
23
+ let sharedIO;
24
+ /**
25
+ * The PromptIO backed by the real terminal.
26
+ *
27
+ * Shared, because a readline interface takes ownership of stdin: a second one
28
+ * finds the stream already consumed, so two prompts in a row would fail with
29
+ * the input reported as ended. `close()` releases it, and the next call builds
30
+ * a fresh one.
31
+ */
32
+ export function terminalIO() {
33
+ sharedIO ??= createTerminalIO();
34
+ return sharedIO;
35
+ }
36
+ function createTerminalIO() {
37
+ const interactive = process.stdin.isTTY === true && process.stdout.isTTY === true;
38
+ let rl;
39
+ let lines;
40
+ const iface = () => {
41
+ // terminal: true against a pipe makes readline echo the whole buffer and
42
+ // reorder it, so this follows whatever stdin actually is.
43
+ rl ??= createInterface({
44
+ input: process.stdin,
45
+ output: process.stdout,
46
+ terminal: interactive,
47
+ });
48
+ return rl;
49
+ };
50
+ /**
51
+ * Pull one line.
52
+ *
53
+ * readline consumes a pipe greedily, so a second `question()` would find the
54
+ * stream already drained and never resolve. Reading through the async
55
+ * iterator keeps the lines queued and hands them out one at a time.
56
+ */
57
+ const nextLine = async () => {
58
+ lines ??= iface()[Symbol.asyncIterator]();
59
+ const { value, done } = await lines.next();
60
+ if (done === true) {
61
+ throw new Error('input ended while waiting for an answer');
62
+ }
63
+ return value;
64
+ };
65
+ const ask = async (text, mask) => {
66
+ const active = iface();
67
+ process.stdout.write(text);
68
+ if (!mask) {
69
+ return nextLine();
70
+ }
71
+ const hooked = active;
72
+ const original = hooked._writeToOutput;
73
+ // Echo a bullet per printable character, and nothing for control keys.
74
+ hooked._writeToOutput = (chunk) => {
75
+ if (chunk.length === 1 && chunk >= ' ') {
76
+ hooked.output?.write('*');
77
+ }
78
+ };
79
+ try {
80
+ return await nextLine();
81
+ }
82
+ finally {
83
+ if (original === undefined) {
84
+ delete hooked._writeToOutput;
85
+ }
86
+ else {
87
+ hooked._writeToOutput = original;
88
+ }
89
+ process.stdout.write('\n');
90
+ }
91
+ };
92
+ return {
93
+ interactive,
94
+ question: (text) => ask(text, false),
95
+ secret: (text) => ask(text, true),
96
+ write: (text) => {
97
+ process.stdout.write(text);
98
+ },
99
+ close: () => {
100
+ rl?.close();
101
+ rl = undefined;
102
+ lines = undefined;
103
+ sharedIO = undefined;
104
+ },
105
+ };
106
+ }
107
+ /** A PromptIO that answers from a list, for tests. */
108
+ export function scriptedIO(answers) {
109
+ let index = 0;
110
+ const io = {
111
+ interactive: true,
112
+ output: '',
113
+ question: (text) => {
114
+ io.output += text;
115
+ const answer = answers[index++] ?? '';
116
+ io.output += `${answer}\n`;
117
+ return Promise.resolve(answer);
118
+ },
119
+ secret: (text) => io.question(text),
120
+ write: (text) => {
121
+ io.output += text;
122
+ },
123
+ close: () => { },
124
+ };
125
+ return io;
126
+ }
127
+ function paint(text, codes) {
128
+ return styleText('red', 'x') === 'x' ? text : styleText(codes, text);
129
+ }
130
+ /** Ask for a line of text, returning the default when the reply is empty. */
131
+ export async function input(message, opts) {
132
+ const io = opts?.io ?? terminalIO();
133
+ const hint = opts?.defaultValue === undefined ? '' : ` (${opts.defaultValue})`;
134
+ const answer = (await io.question(`${paint('?', 'green')} ${message}${hint}: `)).trim();
135
+ return answer.length > 0 ? answer : (opts?.defaultValue ?? '');
136
+ }
137
+ /** Ask for a secret, without echoing it. */
138
+ export async function password(message, opts) {
139
+ const io = opts?.io ?? terminalIO();
140
+ return (await io.secret(`${paint('?', 'green')} ${message}: `)).trim();
141
+ }
142
+ /** Ask a yes or no question. */
143
+ export async function confirm(message, opts) {
144
+ const io = opts?.io ?? terminalIO();
145
+ const fallback = opts?.defaultValue ?? false;
146
+ const hint = fallback ? 'Y/n' : 'y/N';
147
+ const answer = (await io.question(`${paint('?', 'green')} ${message} (${hint}): `))
148
+ .trim()
149
+ .toLowerCase();
150
+ if (answer.length === 0) {
151
+ return fallback;
152
+ }
153
+ return answer === 'y' || answer === 'yes' || answer === 'true' || answer === '1';
154
+ }
155
+ /**
156
+ * Offer a numbered list and take an index or the value itself.
157
+ *
158
+ * A numbered list rather than an arrow-key menu, so it behaves the same over
159
+ * ssh, in a dumb terminal and under a test.
160
+ */
161
+ export async function select(message, choices, opts) {
162
+ const io = opts?.io ?? terminalIO();
163
+ const values = choices.map((c) => (typeof c === 'string' ? { name: c } : c));
164
+ if (values.length === 0) {
165
+ throw new Error('select needs at least one choice');
166
+ }
167
+ for (;;) {
168
+ io.write(`${paint('?', 'green')} ${message}\n`);
169
+ values.forEach((choice, i) => {
170
+ const help = choice.help === undefined ? '' : ` ${choice.help}`;
171
+ io.write(` ${String(i + 1)}) ${choice.name}${help}\n`);
172
+ });
173
+ const hint = opts?.defaultValue === undefined ? '' : ` (${opts.defaultValue})`;
174
+ const answer = (await io.question(` choice${hint}: `)).trim();
175
+ if (answer.length === 0 && opts?.defaultValue !== undefined) {
176
+ return opts.defaultValue;
177
+ }
178
+ const index = Number.parseInt(answer, 10);
179
+ if (!Number.isNaN(index) && index >= 1 && index <= values.length) {
180
+ return values[index - 1].name;
181
+ }
182
+ const named = values.find((c) => c.name === answer);
183
+ if (named !== undefined) {
184
+ return named.name;
185
+ }
186
+ io.write(` ${paint('not one of the choices', 'yellow')}\n`);
187
+ }
188
+ }
189
+ /** Offer a numbered list and take several answers, separated by commas. */
190
+ export async function multiselect(message, choices, opts) {
191
+ const io = opts?.io ?? terminalIO();
192
+ const values = choices.map((c) => (typeof c === 'string' ? { name: c } : c));
193
+ for (;;) {
194
+ io.write(`${paint('?', 'green')} ${message}\n`);
195
+ values.forEach((choice, i) => {
196
+ io.write(` ${String(i + 1)}) ${choice.name}\n`);
197
+ });
198
+ const answer = (await io.question(' choices (comma separated): ')).trim();
199
+ if (answer.length === 0) {
200
+ return [];
201
+ }
202
+ const picked = [];
203
+ let bad = false;
204
+ for (const part of answer.split(',').map((p) => p.trim())) {
205
+ const index = Number.parseInt(part, 10);
206
+ if (!Number.isNaN(index) && index >= 1 && index <= values.length) {
207
+ picked.push(values[index - 1].name);
208
+ continue;
209
+ }
210
+ const named = values.find((c) => c.name === part);
211
+ if (named === undefined) {
212
+ bad = true;
213
+ break;
214
+ }
215
+ picked.push(named.name);
216
+ }
217
+ if (!bad) {
218
+ return picked;
219
+ }
220
+ io.write(` ${paint('not one of the choices', 'yellow')}\n`);
221
+ }
222
+ }
223
+ /** The message shown when asking for an argument. */
224
+ function messageFor(key, def) {
225
+ return def.description ?? def.longDescription ?? `Value for ${def.long ?? key}`;
226
+ }
227
+ /**
228
+ * Ask for one argument, choosing the prompt from its definition.
229
+ *
230
+ * A boolean is a confirm, an argument with possible values is a list, one
231
+ * marked `secret` is not echoed, and everything else is a line of text. The
232
+ * result is validated against the argument's own parser before being accepted.
233
+ */
234
+ export async function promptForArg(key, def, opts) {
235
+ const io = opts?.io ?? terminalIO();
236
+ const message = messageFor(key, def);
237
+ const values = possibleValues(def).filter((v) => !v.hidden);
238
+ if (def.type === 'boolean') {
239
+ return confirm(message, { io });
240
+ }
241
+ if (values.length > 0) {
242
+ return def.action === 'append'
243
+ ? multiselect(message, values, { io })
244
+ : select(message, values, { io });
245
+ }
246
+ if (def.secret === true) {
247
+ return password(message, { io });
248
+ }
249
+ for (;;) {
250
+ const answer = await input(message, {
251
+ io,
252
+ ...(def.default === undefined ? {} : { defaultValue: String(def.default) }),
253
+ });
254
+ if (answer.length === 0) {
255
+ io.write(` ${paint('a value is required', 'yellow')}\n`);
256
+ continue;
257
+ }
258
+ if (typeof def.valueParser === 'function') {
259
+ try {
260
+ def.valueParser(answer);
261
+ }
262
+ catch (error) {
263
+ io.write(` ${paint(error instanceof Error ? error.message : String(error), 'yellow')}\n`);
264
+ continue;
265
+ }
266
+ }
267
+ if (def.type === 'number' && Number.isNaN(Number(answer))) {
268
+ io.write(` ${paint('expected a number', 'yellow')}\n`);
269
+ continue;
270
+ }
271
+ return answer;
272
+ }
273
+ }
274
+ /**
275
+ * A `fillMissing` hook that asks for each required argument still empty.
276
+ *
277
+ * ```ts
278
+ * await runMain(main, { fillMissing: promptMissing() });
279
+ * ```
280
+ */
281
+ export function promptMissing(opts) {
282
+ return async (missing) => {
283
+ const io = opts?.io ?? terminalIO();
284
+ if (!io.interactive && opts?.force !== true) {
285
+ io.close();
286
+ return undefined;
287
+ }
288
+ const filled = {};
289
+ try {
290
+ for (const { key, def } of missing) {
291
+ filled[key] = await promptForArg(key, def, { io });
292
+ }
293
+ }
294
+ finally {
295
+ io.close();
296
+ }
297
+ return filled;
298
+ };
299
+ }
package/dist/runner.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Supports inferSubcommands, subcommandRequired, allowExternalSubcommands,
5
5
  * argsConflictsWithSubcommands, argRequiredElseHelp, and custom styles.
6
6
  */
7
- import type { ArgDef, ArgsDef, CommandDef, ParsedArgs, RunOptions } from './types.js';
7
+ import type { ArgDef, ArgsDef, CommandDef, ParsedArgs, OutputSink, RunOptions, ValueSource } from './types.js';
8
8
  /**
9
9
  * Define a command with full type inference on arguments.
10
10
  * This is the primary API for creating commands.
@@ -47,7 +47,10 @@ export declare function defineArg<const T extends ArgDef>(arg: T): T;
47
47
  * Run a specific command with pre-parsed arguments.
48
48
  * Executes the setup -> run -> cleanup lifecycle.
49
49
  */
50
- export declare function runCommand<T extends ArgsDef>(command: CommandDef<T>, args: ParsedArgs<T>, rawArgs?: readonly string[], subCommand?: string): Promise<void>;
50
+ export declare function runCommand<T extends ArgsDef>(command: CommandDef<T>, args: ParsedArgs<T>, rawArgs?: readonly string[], subCommand?: string, valueSources?: ReadonlyMap<string, ValueSource>, io?: {
51
+ stdout: OutputSink;
52
+ stderr: OutputSink;
53
+ }): Promise<void>;
51
54
  /**
52
55
  * Main entry point for CLI applications.
53
56
  * Parses args, resolves subcommands, validates, and runs.