@bunizao/cli-kit 0.5.0 → 0.6.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/README.md +4 -2
- package/dist/program.js +2 -1
- package/dist/ui.d.ts +21 -2
- package/dist/ui.js +90 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -187,8 +187,10 @@ spin.stop("Uploaded 2 files");
|
|
|
187
187
|
ui.outro("Done");
|
|
188
188
|
```
|
|
189
189
|
|
|
190
|
-
`select` switches to type-to-filter above `AUTOCOMPLETE_FROM` choices
|
|
191
|
-
|
|
190
|
+
`select` switches to type-to-filter above `AUTOCOMPLETE_FROM` choices, or always with
|
|
191
|
+
`{ search: true }`; every typed word must appear in the label or hint, in any order.
|
|
192
|
+
`{ initial }` highlights a row. A multi-step picker passes `{ back: true }` and gets `BACK`
|
|
193
|
+
when the person presses Escape. Ctrl+C inside a prompt throws a `cancelled` error (exit 130). `confirm(plan, options)` is built on the same
|
|
192
194
|
layer and keeps its contract: `--dry-run` prints the plan, `--yes` skips the question, a pipe
|
|
193
195
|
without `--yes` is a usage error.
|
|
194
196
|
|
package/dist/program.js
CHANGED
|
@@ -63,7 +63,8 @@ function describe(command, noun) {
|
|
|
63
63
|
aliases: command.aliases(),
|
|
64
64
|
description: command.description(),
|
|
65
65
|
positionals: command.registeredArguments.map(describeArgument),
|
|
66
|
-
|
|
66
|
+
// Match help output: options created with .hideHelp() stay out of the description.
|
|
67
|
+
options: command.options.filter((option) => !option.hidden).map(describeOption),
|
|
67
68
|
mutating: mutations.has(command),
|
|
68
69
|
commands: command.commands.filter((child) => child.name() !== "help").map((child) => describe(child, currentNoun)),
|
|
69
70
|
};
|
package/dist/ui.d.ts
CHANGED
|
@@ -9,6 +9,19 @@ export interface Choice<T> {
|
|
|
9
9
|
readonly label: string;
|
|
10
10
|
readonly hint?: string;
|
|
11
11
|
}
|
|
12
|
+
/** What `select` resolves when the person presses Escape on a prompt opened with `back: true`. */
|
|
13
|
+
export declare const BACK: unique symbol;
|
|
14
|
+
export interface SelectOptions<T> {
|
|
15
|
+
/** Type-to-filter at any length; a short list still wants it when the person knows the name. */
|
|
16
|
+
readonly search?: boolean;
|
|
17
|
+
/** The row highlighted when the list opens. */
|
|
18
|
+
readonly initial?: T;
|
|
19
|
+
/**
|
|
20
|
+
* Escape resolves `BACK` instead of cancelling, so a multi-step picker can return to the
|
|
21
|
+
* previous step. Ctrl+C still cancels.
|
|
22
|
+
*/
|
|
23
|
+
readonly back?: boolean;
|
|
24
|
+
}
|
|
12
25
|
export interface UiOptions {
|
|
13
26
|
/** Keyboard input. Defaults to `process.stdin`. */
|
|
14
27
|
readonly input?: NodeJS.ReadStream;
|
|
@@ -30,6 +43,8 @@ export interface Spinner {
|
|
|
30
43
|
/** End the step and leave `message` (or the last running text) as the permanent line. */
|
|
31
44
|
stop(message?: string): void;
|
|
32
45
|
error(message?: string): void;
|
|
46
|
+
/** End the step and erase it, for progress whose outcome is printed elsewhere. */
|
|
47
|
+
clear(): void;
|
|
33
48
|
}
|
|
34
49
|
export interface Ui {
|
|
35
50
|
readonly interactive: boolean;
|
|
@@ -47,10 +62,14 @@ export interface Ui {
|
|
|
47
62
|
spinner(): Spinner;
|
|
48
63
|
/**
|
|
49
64
|
* Let the person pick one row. Above `AUTOCOMPLETE_FROM` choices the list becomes
|
|
50
|
-
* type-to-filter, because arrowing through thirty forum threads is not a picker.
|
|
65
|
+
* type-to-filter, because arrowing through thirty forum threads is not a picker. The
|
|
66
|
+
* filter matches every typed word anywhere in the label or hint, in any order.
|
|
51
67
|
* Without a terminal this throws a usage error that lists the choices.
|
|
52
68
|
*/
|
|
53
|
-
select<T>(message: string, choices: readonly Choice<T>[]
|
|
69
|
+
select<T>(message: string, choices: readonly Choice<T>[], options: SelectOptions<T> & {
|
|
70
|
+
readonly back: true;
|
|
71
|
+
}): Promise<T | typeof BACK>;
|
|
72
|
+
select<T>(message: string, choices: readonly Choice<T>[], options?: SelectOptions<T>): Promise<T>;
|
|
54
73
|
/** Yes or no. Without a terminal this throws a usage error. */
|
|
55
74
|
confirm(message: string, options?: {
|
|
56
75
|
readonly initial?: boolean;
|
package/dist/ui.js
CHANGED
|
@@ -6,6 +6,8 @@ import * as clack from "@clack/prompts";
|
|
|
6
6
|
import { isAgentEnvironment } from "./audience.js";
|
|
7
7
|
import { colorEnabled, painter } from "./color.js";
|
|
8
8
|
import { CliError } from "./errors.js";
|
|
9
|
+
/** What `select` resolves when the person presses Escape on a prompt opened with `back: true`. */
|
|
10
|
+
export const BACK = Symbol("cli-kit.back");
|
|
9
11
|
export const AUTOCOMPLETE_FROM = 8;
|
|
10
12
|
/**
|
|
11
13
|
* Every CLI in the family draws the same way: prompts, steps and spinners on stderr
|
|
@@ -18,8 +20,15 @@ export function createUi(options = {}) {
|
|
|
18
20
|
const output = options.output ?? process.stderr;
|
|
19
21
|
const interactive = options.interactive
|
|
20
22
|
?? (Boolean(input.isTTY) && Boolean(output.isTTY) && !isAgentEnvironment(options.env));
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
// Escape erases the abandoned step and the answer above it, so going back looks like
|
|
24
|
+
// the earlier question was never answered. That needs to know how many rows each prompt
|
|
25
|
+
// drew; clack redraws with relative moves, so counting newlines and cursor-up/down
|
|
26
|
+
// sequences written through the ui gives it.
|
|
27
|
+
let row = 0;
|
|
28
|
+
const answered = [];
|
|
29
|
+
const drawn = interactive ? trackRows(output, delta => { row += delta; }) : output;
|
|
30
|
+
const common = { input, output: drawn, ...(options.signal ? { signal: options.signal } : {}) };
|
|
31
|
+
const plain = (text) => drawn.write(`${text}\n`);
|
|
23
32
|
const paint = painter(colorEnabled(output, options.env));
|
|
24
33
|
return {
|
|
25
34
|
interactive,
|
|
@@ -30,7 +39,7 @@ export function createUi(options = {}) {
|
|
|
30
39
|
return;
|
|
31
40
|
}
|
|
32
41
|
const lines = art.replace(/^\n+|\s+$/gu, "").split("\n").map(line => paint("cyan", line));
|
|
33
|
-
|
|
42
|
+
drawn.write(`${lines.join("\n")}\n${tagline ? `${paint("dim", tagline)}\n` : ""}\n`);
|
|
34
43
|
},
|
|
35
44
|
intro: title => (interactive ? clack.intro(title, common) : plain(title)),
|
|
36
45
|
outro: message => (interactive ? clack.outro(message, common) : plain(message)),
|
|
@@ -41,11 +50,31 @@ export function createUi(options = {}) {
|
|
|
41
50
|
spinner() {
|
|
42
51
|
if (interactive) {
|
|
43
52
|
const inner = clack.spinner(common);
|
|
53
|
+
// Clack's spinner holds stdin in raw mode and answers Ctrl+C with process.exit(0),
|
|
54
|
+
// which skips the caller's cleanup and leaves half-written files behind. Handing
|
|
55
|
+
// the key back to the terminal makes it a real SIGINT: the spinner erases itself,
|
|
56
|
+
// the CLI's own handler runs, and a CLI without one still dies as Ctrl+C always has.
|
|
57
|
+
const onInterrupt = () => {
|
|
58
|
+
process.off("SIGINT", onInterrupt);
|
|
59
|
+
inner.clear();
|
|
60
|
+
if (!process.listenerCount("SIGINT"))
|
|
61
|
+
process.kill(process.pid, "SIGINT");
|
|
62
|
+
};
|
|
63
|
+
const end = (finish) => {
|
|
64
|
+
process.off("SIGINT", onInterrupt);
|
|
65
|
+
finish();
|
|
66
|
+
};
|
|
44
67
|
return {
|
|
45
|
-
start: message =>
|
|
68
|
+
start: message => {
|
|
69
|
+
inner.start(message);
|
|
70
|
+
if (process.stdin.isTTY)
|
|
71
|
+
process.stdin.setRawMode(false);
|
|
72
|
+
process.prependListener("SIGINT", onInterrupt);
|
|
73
|
+
},
|
|
46
74
|
message: message => inner.message(message),
|
|
47
|
-
stop: message => inner.stop(message),
|
|
48
|
-
error: message => inner.error(message),
|
|
75
|
+
stop: message => end(() => inner.stop(message)),
|
|
76
|
+
error: message => end(() => inner.error(message)),
|
|
77
|
+
clear: () => end(() => inner.clear()),
|
|
49
78
|
};
|
|
50
79
|
}
|
|
51
80
|
// A pipe is read after the fact: only the outcome of a step is worth a line.
|
|
@@ -55,9 +84,10 @@ export function createUi(options = {}) {
|
|
|
55
84
|
message: message => { running = message; },
|
|
56
85
|
stop: message => plain(message ?? running),
|
|
57
86
|
error: message => plain(`error: ${message ?? running}`),
|
|
87
|
+
clear: () => { running = ""; },
|
|
58
88
|
};
|
|
59
89
|
},
|
|
60
|
-
async select(message, choices) {
|
|
90
|
+
async select(message, choices, selectOptions = {}) {
|
|
61
91
|
if (!choices.length)
|
|
62
92
|
throw new CliError("unexpected", `Nothing to choose for "${message}".`);
|
|
63
93
|
if (!interactive) {
|
|
@@ -65,10 +95,33 @@ export function createUi(options = {}) {
|
|
|
65
95
|
}
|
|
66
96
|
// `Option<T>` is conditional on T being primitive, which an open generic cannot resolve.
|
|
67
97
|
const list = choices.map(c => ({ value: c.value, label: c.label, ...(c.hint ? { hint: c.hint } : {}) }));
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
98
|
+
const initial = selectOptions.initial !== undefined ? { initialValue: selectOptions.initial } : {};
|
|
99
|
+
// Clack reports Escape and Ctrl+C as the same cancel, so the key itself tells them apart.
|
|
100
|
+
let escaped = false;
|
|
101
|
+
const onKey = (_, key) => { escaped = key?.name === "escape"; };
|
|
102
|
+
if (selectOptions.back)
|
|
103
|
+
input.on("keypress", onKey);
|
|
104
|
+
const start = row;
|
|
105
|
+
let picked;
|
|
106
|
+
try {
|
|
107
|
+
picked = selectOptions.search || choices.length >= AUTOCOMPLETE_FROM
|
|
108
|
+
? await clack.autocomplete({ message, options: list, placeholder: "Type to filter", maxItems: AUTOCOMPLETE_FROM, filter: matchesWords, ...initial, ...common })
|
|
109
|
+
: await clack.select({ message, options: list, ...initial, ...common });
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
input.off("keypress", onKey);
|
|
113
|
+
}
|
|
114
|
+
if (selectOptions.back && escaped && clack.isCancel(picked)) {
|
|
115
|
+
// The answer above is erased too only when nothing was drawn between it and this step.
|
|
116
|
+
const previous = answered.at(-1)?.end === start ? answered.pop() : undefined;
|
|
117
|
+
const up = row - (previous?.start ?? start);
|
|
118
|
+
if (up > 0)
|
|
119
|
+
drawn.write(`\u001b[${up}A\r\u001b[J`);
|
|
120
|
+
return BACK;
|
|
121
|
+
}
|
|
122
|
+
const value = unwrap(picked);
|
|
123
|
+
answered.push({ start, end: row });
|
|
124
|
+
return value;
|
|
72
125
|
},
|
|
73
126
|
async confirm(message, confirmOptions = {}) {
|
|
74
127
|
if (!interactive)
|
|
@@ -117,6 +170,32 @@ export function createUi(options = {}) {
|
|
|
117
170
|
},
|
|
118
171
|
};
|
|
119
172
|
}
|
|
173
|
+
// "2086 week 5" should find "FIT2086 · Week 5": each typed word on its own, anywhere in
|
|
174
|
+
// the label or hint. A number starts a run of digits, so "5" finds "Week 5" and "2086"
|
|
175
|
+
// finds "FIT2086", but "5" does not find "Week 15".
|
|
176
|
+
function matchesWords(search, option) {
|
|
177
|
+
const text = `${option.label ?? ""} ${option.hint ?? ""}`.toLowerCase();
|
|
178
|
+
return search.toLowerCase().split(/\s+/u).filter(Boolean).every(word => /^\d+$/u.test(word)
|
|
179
|
+
? new RegExp(`(?<!\\d)${word}`, "u").test(text)
|
|
180
|
+
: text.includes(word));
|
|
181
|
+
}
|
|
182
|
+
// Everything else about the stream (columns, isTTY, resize events) is the real one.
|
|
183
|
+
function trackRows(output, moved) {
|
|
184
|
+
const write = (chunk, ...rest) => {
|
|
185
|
+
for (const match of String(chunk).matchAll(/\u001b\[(\d*)([AB])|\n/gu)) {
|
|
186
|
+
moved(match[0] === "\n" ? 1 : (match[2] === "A" ? -1 : 1) * Number(match[1] || 1));
|
|
187
|
+
}
|
|
188
|
+
return output.write(chunk, ...rest);
|
|
189
|
+
};
|
|
190
|
+
return new Proxy(output, {
|
|
191
|
+
get(target, property) {
|
|
192
|
+
if (property === "write")
|
|
193
|
+
return write;
|
|
194
|
+
const value = Reflect.get(target, property, target);
|
|
195
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
120
199
|
/** Ctrl+C or Escape inside a prompt is the person's decision, reported with exit 130 like a signal. */
|
|
121
200
|
function unwrap(value) {
|
|
122
201
|
if (clack.isCancel(value))
|