@bunizao/cli-kit 0.2.0 → 0.5.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/LICENSE +0 -0
- package/README.md +87 -0
- package/dist/arguments.d.ts +22 -0
- package/dist/arguments.js +117 -0
- package/dist/audience.d.ts +26 -0
- package/dist/audience.js +28 -0
- package/dist/color.d.ts +14 -0
- package/dist/color.js +19 -0
- package/dist/confirm.d.ts +7 -1
- package/dist/confirm.js +11 -11
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +9 -0
- package/dist/help.d.ts +20 -0
- package/dist/help.js +182 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/output.d.ts +7 -0
- package/dist/output.js +26 -4
- package/dist/program.js +13 -5
- package/dist/theme.d.ts +28 -0
- package/dist/theme.js +49 -0
- package/dist/ui.d.ts +83 -0
- package/dist/ui.js +125 -0
- package/package.json +3 -2
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -57,6 +57,57 @@ await program.parseAsync(args, { from: "user" });
|
|
|
57
57
|
`insertDefaultVerb` accepts user arguments, not the Node executable and script prefix. It is a
|
|
58
58
|
pure transform and does not mutate the provided array.
|
|
59
59
|
|
|
60
|
+
Prefer `parseWithPrompts` over calling `parseAsync` yourself. It builds the program, parses,
|
|
61
|
+
and when a person at a terminal left out a trailing positional or a required option, asks for
|
|
62
|
+
it and parses again with the answer appended. `moodle activities list` becomes a unit picker
|
|
63
|
+
instead of `missing required argument 'unit'`. Without a terminal the usage error is thrown
|
|
64
|
+
unchanged, with the command's usage line and argument descriptions as its hint.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
await parseWithPrompts(() => buildProgram(), args, {
|
|
68
|
+
ui,
|
|
69
|
+
fillers: {
|
|
70
|
+
unit: async ({ ui }) => ui.select("Which unit?", await unitChoices()),
|
|
71
|
+
task: async ({ provided, ui }) => ui.select("Which task?", await taskChoices(provided.unit)),
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A filler is looked up by argument name; without one, an argument with `.choices()` becomes a
|
|
77
|
+
picker and anything else a text prompt labelled with its description. Fillers see the
|
|
78
|
+
positionals typed so far, including earlier answers, so a task picker can load the chosen
|
|
79
|
+
unit. The factory is called once per round because Commander programs do not parse twice.
|
|
80
|
+
|
|
81
|
+
## Help
|
|
82
|
+
|
|
83
|
+
`createProgram` installs one help layout for the family: name and version, usage, the
|
|
84
|
+
commands, options, and a short "Try" list, coloured on a terminal and plain in a pipe.
|
|
85
|
+
`--no-color`, `NO_COLOR` and `FORCE_COLOR` are honoured. Group top-level commands with
|
|
86
|
+
`helpSection` the way `gh` does (core commands first, then the rest), add two or three
|
|
87
|
+
invocations with `examples` (text after two spaces and `#` renders as a comment), and
|
|
88
|
+
give the root a wordmark with `banner`; the art shows only to a person at a terminal.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
helpSection(program.command("submit"), "Core commands");
|
|
92
|
+
examples(program, ["example units", "example submit UNIT report.pdf # asks before uploading"]);
|
|
93
|
+
banner(program, EXAMPLE_WORDMARK);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Theme
|
|
97
|
+
|
|
98
|
+
`createTheme(enabled)` paints the roles every CLI's human output shares, so a person
|
|
99
|
+
learns once what each colour means: `key` (cyan) is something they can type back, such
|
|
100
|
+
as a unit code or task number; `subject` (bold) is what they gave, such as files or a
|
|
101
|
+
message; `target` (bold cyan) is where it goes; `dim` is a secondary fact; `status`
|
|
102
|
+
colours a word by its `toneOf`, the vocabulary learning sites share ("overdue" is
|
|
103
|
+
danger, "graded" success, "not started" muted, "announcement" accent). Pass the theme
|
|
104
|
+
to `render` and a table gets a dim header, a keyed first column and toned status
|
|
105
|
+
columns; pass `tones` for words the shared list would misread. `ui.banner(art, tagline)`
|
|
106
|
+
opens an onboarding flow with the wordmark.
|
|
107
|
+
|
|
108
|
+
`isInformationalExit(error)` is true for the help and version exits Commander throws under
|
|
109
|
+
`exitOverride`, including a bare noun with no verb, so the run loop can return 0 for them.
|
|
110
|
+
|
|
60
111
|
List option flags that consume a separate value in `valueFlags`. This lets the arity counter ignore
|
|
61
112
|
option values when flags are interleaved with positionals. Boolean flags and `--flag=value` do not
|
|
62
113
|
need to be listed.
|
|
@@ -118,3 +169,39 @@ a major package release.
|
|
|
118
169
|
The conformance suite is isolated under `conformance/`. Until all three CLIs have published their
|
|
119
170
|
normalized releases, missing binaries are reported as skipped tests. Its CI workflow installs the
|
|
120
171
|
published packages before running the suite.
|
|
172
|
+
|
|
173
|
+
## Prompts and progress
|
|
174
|
+
|
|
175
|
+
`createUi` gives every CLI the same interactive surface, drawn with `@clack/prompts` on
|
|
176
|
+
stderr so `--json` output on stdout stays clean. It only prompts when both stdin and the
|
|
177
|
+
output are terminals; in a pipe, log calls print plain completed lines, spinners print
|
|
178
|
+
their outcome, and every prompt throws a `usage` error that says what to pass instead.
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
const ui = createUi();
|
|
182
|
+
ui.intro("moodle submit");
|
|
183
|
+
const unit = await ui.select("Which unit?", candidates.map((c) => ({ value: c.id, label: c.name, hint: c.code })));
|
|
184
|
+
const spin = ui.spinner();
|
|
185
|
+
spin.start("Uploading 2 files");
|
|
186
|
+
spin.stop("Uploaded 2 files");
|
|
187
|
+
ui.outro("Done");
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`select` switches to type-to-filter above `AUTOCOMPLETE_FROM` choices. Ctrl+C inside a
|
|
191
|
+
prompt throws a `cancelled` error (exit 130). `confirm(plan, options)` is built on the same
|
|
192
|
+
layer and keeps its contract: `--dry-run` prints the plan, `--yes` skips the question, a pipe
|
|
193
|
+
without `--yes` is a usage error.
|
|
194
|
+
|
|
195
|
+
## Human or agent
|
|
196
|
+
|
|
197
|
+
`detectAudience({ stdin, stdout, env, format })` returns `"human"` only for a person at a
|
|
198
|
+
terminal reading a table: both streams are TTYs, the format is not JSON or YAML, and none
|
|
199
|
+
of `AGENT_ENV_VARS` (`CLI_AGENT`, `CLAUDECODE`, `CI`) is set. Everyone else is an agent and
|
|
200
|
+
gets machine output, no prompts, and errors that name the flag to pass. `createUi` applies
|
|
201
|
+
the same environment rule on its own, so an agent that allocates a pty still never sees a
|
|
202
|
+
prompt. Agents that run commands in a terminal should export `CLI_AGENT=1`.
|
|
203
|
+
|
|
204
|
+
`ui.password` masks a secret; without a terminal it throws so the caller can point at a
|
|
205
|
+
`--token-stdin` style flag instead. `ui.editor` collects several lines in `$VISUAL` or
|
|
206
|
+
`$EDITOR` the way git collects a commit message, and throws without a terminal so the
|
|
207
|
+
caller can point at a `--body-file` style flag.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { Ui } from "./ui.js";
|
|
3
|
+
export interface FillContext {
|
|
4
|
+
readonly command: Command;
|
|
5
|
+
/** Positionals the person already typed, by argument name, including earlier answers. */
|
|
6
|
+
readonly provided: Readonly<Record<string, string>>;
|
|
7
|
+
readonly ui: Ui;
|
|
8
|
+
}
|
|
9
|
+
/** Produces the value for one missing positional. A variadic one may return several tokens. */
|
|
10
|
+
export type ArgumentFiller = (context: FillContext) => Promise<string | readonly string[]>;
|
|
11
|
+
export interface PromptParseOptions {
|
|
12
|
+
readonly ui: Ui;
|
|
13
|
+
/** Fillers by argument name (`unit`), consulted before the generic choice or text prompt. */
|
|
14
|
+
readonly fillers?: Readonly<Record<string, ArgumentFiller>>;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse argv with a freshly built program. When a person at a terminal left out trailing
|
|
18
|
+
* positionals or a required option, ask for them and parse again with the answers appended,
|
|
19
|
+
* so `moodle activities list` becomes a unit picker instead of a usage error. Anyone else
|
|
20
|
+
* gets the usage error, with the command's usage line as the hint.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseWithPrompts(build: () => Command, argv: readonly string[], options: PromptParseOptions): Promise<void>;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
// Every round appends at least one token, so this only guards a filler that keeps failing.
|
|
3
|
+
const MAX_ROUNDS = 8;
|
|
4
|
+
/**
|
|
5
|
+
* Parse argv with a freshly built program. When a person at a terminal left out trailing
|
|
6
|
+
* positionals or a required option, ask for them and parse again with the answers appended,
|
|
7
|
+
* so `moodle activities list` becomes a unit picker instead of a usage error. Anyone else
|
|
8
|
+
* gets the usage error, with the command's usage line as the hint.
|
|
9
|
+
*/
|
|
10
|
+
export async function parseWithPrompts(build, argv, options) {
|
|
11
|
+
let args = [...argv];
|
|
12
|
+
for (let round = 0;; round += 1) {
|
|
13
|
+
const program = build();
|
|
14
|
+
annotate(program);
|
|
15
|
+
try {
|
|
16
|
+
await program.parseAsync(args, { from: "user" });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const failing = failingCommand(error);
|
|
21
|
+
if (!failing)
|
|
22
|
+
throw error;
|
|
23
|
+
if (!options.ui.interactive)
|
|
24
|
+
throw withUsageHint(error, failing.command);
|
|
25
|
+
if (round >= MAX_ROUNDS)
|
|
26
|
+
throw error;
|
|
27
|
+
const answers = await answersFor(failing.command, failing.code, options);
|
|
28
|
+
if (!answers.length)
|
|
29
|
+
throw error;
|
|
30
|
+
args = [...args, ...answers];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const FILLABLE = new Set(["commander.missingArgument", "commander.missingMandatoryOptionValue"]);
|
|
35
|
+
// Commander reports a missing argument without saying which command wanted it, so every
|
|
36
|
+
// command rethrows its own exit with itself attached.
|
|
37
|
+
function annotate(command) {
|
|
38
|
+
command.exitOverride(error => {
|
|
39
|
+
throw Object.assign(error, { command });
|
|
40
|
+
});
|
|
41
|
+
for (const child of command.commands)
|
|
42
|
+
annotate(child);
|
|
43
|
+
}
|
|
44
|
+
function failingCommand(error) {
|
|
45
|
+
if (!error || typeof error !== "object")
|
|
46
|
+
return undefined;
|
|
47
|
+
const { code, command } = error;
|
|
48
|
+
if (typeof code !== "string" || !FILLABLE.has(code) || !command)
|
|
49
|
+
return undefined;
|
|
50
|
+
return { command: command, code };
|
|
51
|
+
}
|
|
52
|
+
async function answersFor(command, code, options) {
|
|
53
|
+
// Commander reports a missing required option before it counts positionals, but a person
|
|
54
|
+
// expects to name the unit before the title; ask in that order, in one round.
|
|
55
|
+
const positionals = await fillPositionals(command, options);
|
|
56
|
+
if (code === "commander.missingArgument")
|
|
57
|
+
return positionals;
|
|
58
|
+
return [...positionals, ...(await fillOption(command, options.ui))];
|
|
59
|
+
}
|
|
60
|
+
async function fillPositionals(command, options) {
|
|
61
|
+
const typed = command.args;
|
|
62
|
+
const provided = {};
|
|
63
|
+
command.registeredArguments.forEach((argument, index) => {
|
|
64
|
+
const value = typed[index];
|
|
65
|
+
if (value !== undefined)
|
|
66
|
+
provided[argument.name()] = value;
|
|
67
|
+
});
|
|
68
|
+
const answers = [];
|
|
69
|
+
for (const argument of command.registeredArguments.slice(typed.length)) {
|
|
70
|
+
// Commander requires the required positionals to come first, so the first optional one ends the asking.
|
|
71
|
+
if (!argument.required)
|
|
72
|
+
break;
|
|
73
|
+
const answer = await answerFor(argument, { command, provided: { ...provided }, ui: options.ui }, options.fillers?.[argument.name()]);
|
|
74
|
+
const tokens = typeof answer === "string" ? (argument.variadic ? answer.split(/\s+/u) : [answer.trim()]).filter(Boolean) : [...answer];
|
|
75
|
+
if (!tokens.length)
|
|
76
|
+
throw new CliError("usage", `${argument.name()} is required.`);
|
|
77
|
+
provided[argument.name()] = tokens.join(" ");
|
|
78
|
+
answers.push(...tokens);
|
|
79
|
+
}
|
|
80
|
+
return answers;
|
|
81
|
+
}
|
|
82
|
+
function answerFor(argument, context, filler) {
|
|
83
|
+
if (filler)
|
|
84
|
+
return filler(context);
|
|
85
|
+
const message = argument.description || argument.name();
|
|
86
|
+
if (argument.argChoices)
|
|
87
|
+
return context.ui.select(message, argument.argChoices.map(value => ({ value, label: value })));
|
|
88
|
+
return context.ui.text(message, { validate: value => (value.trim() ? undefined : "Required.") });
|
|
89
|
+
}
|
|
90
|
+
async function fillOption(command, ui) {
|
|
91
|
+
const option = command.options.find(candidate => candidate.mandatory && command.getOptionValue(candidate.attributeName()) === undefined);
|
|
92
|
+
const flag = option?.long ?? option?.short;
|
|
93
|
+
if (!option || !flag)
|
|
94
|
+
return [];
|
|
95
|
+
const message = option.description || flag;
|
|
96
|
+
if (!option.required && !option.optional)
|
|
97
|
+
return (await ui.confirm(message)) ? [flag] : [];
|
|
98
|
+
const value = option.argChoices
|
|
99
|
+
? await ui.select(message, option.argChoices.map(choice => ({ value: choice, label: choice })))
|
|
100
|
+
: await ui.text(message, { validate: text => (text.trim() ? undefined : "Required.") });
|
|
101
|
+
return [flag, value.trim()];
|
|
102
|
+
}
|
|
103
|
+
/** The same usage error, plus the usage line an agent needs to correct the call. */
|
|
104
|
+
function withUsageHint(error, command) {
|
|
105
|
+
if (!(error instanceof Error))
|
|
106
|
+
return error;
|
|
107
|
+
const names = [];
|
|
108
|
+
for (let current = command; current; current = current.parent)
|
|
109
|
+
names.unshift(current.name());
|
|
110
|
+
const describe = (item, term) => (item.description ? ` ${term} ${item.description}` : undefined);
|
|
111
|
+
const lines = [
|
|
112
|
+
`Usage: ${names.join(" ")} ${command.usage()}`,
|
|
113
|
+
...command.registeredArguments.map(argument => describe(argument, argument.name())),
|
|
114
|
+
...command.options.filter(option => option.mandatory).map(option => describe(option, option.flags)),
|
|
115
|
+
].filter((line) => Boolean(line));
|
|
116
|
+
return new CliError("usage", error.message.replace(/^error:\s*/iu, ""), lines.join("\n"));
|
|
117
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { OutputFormat } from "./output.js";
|
|
2
|
+
/**
|
|
3
|
+
* Who is on the other end of the command. A human gets tables, prompts and progress;
|
|
4
|
+
* an agent gets machine output, no prompts, and errors that name the flag to pass.
|
|
5
|
+
*/
|
|
6
|
+
export type Audience = "human" | "agent";
|
|
7
|
+
/**
|
|
8
|
+
* Environment variables that mark a shell driven by a program even when it has a pty.
|
|
9
|
+
* `CLAUDECODE` is set by Claude Code's shell, `CI` by every hosted runner, and
|
|
10
|
+
* `CLI_AGENT` is the documented opt-in for any other agent that allocates a terminal.
|
|
11
|
+
* The Claude desktop terminal pane sets none of them, so a person there stays human.
|
|
12
|
+
*/
|
|
13
|
+
export declare const AGENT_ENV_VARS: readonly ["CLI_AGENT", "CLAUDECODE", "CI"];
|
|
14
|
+
export interface AudienceInput {
|
|
15
|
+
readonly stdin?: {
|
|
16
|
+
readonly isTTY?: boolean | undefined;
|
|
17
|
+
};
|
|
18
|
+
readonly stdout?: {
|
|
19
|
+
readonly isTTY?: boolean | undefined;
|
|
20
|
+
};
|
|
21
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
22
|
+
/** The resolved output format. JSON and YAML are asked for by programs. */
|
|
23
|
+
readonly format?: OutputFormat;
|
|
24
|
+
}
|
|
25
|
+
export declare function detectAudience(input?: AudienceInput): Audience;
|
|
26
|
+
export declare function isAgentEnvironment(env?: NodeJS.ProcessEnv): boolean;
|
package/dist/audience.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment variables that mark a shell driven by a program even when it has a pty.
|
|
3
|
+
* `CLAUDECODE` is set by Claude Code's shell, `CI` by every hosted runner, and
|
|
4
|
+
* `CLI_AGENT` is the documented opt-in for any other agent that allocates a terminal.
|
|
5
|
+
* The Claude desktop terminal pane sets none of them, so a person there stays human.
|
|
6
|
+
*/
|
|
7
|
+
export const AGENT_ENV_VARS = ["CLI_AGENT", "CLAUDECODE", "CI"];
|
|
8
|
+
export function detectAudience(input = {}) {
|
|
9
|
+
if (input.format && input.format !== "table")
|
|
10
|
+
return "agent";
|
|
11
|
+
const stdin = input.stdin ?? process.stdin;
|
|
12
|
+
const stdout = input.stdout ?? process.stdout;
|
|
13
|
+
if (!stdin.isTTY || !stdout.isTTY)
|
|
14
|
+
return "agent";
|
|
15
|
+
const env = input.env ?? process.env;
|
|
16
|
+
if (AGENT_ENV_VARS.some((name) => isSet(env[name])))
|
|
17
|
+
return "agent";
|
|
18
|
+
return "human";
|
|
19
|
+
}
|
|
20
|
+
export function isAgentEnvironment(env = process.env) {
|
|
21
|
+
return AGENT_ENV_VARS.some((name) => isSet(env[name]));
|
|
22
|
+
}
|
|
23
|
+
function isSet(value) {
|
|
24
|
+
if (value === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
const normalized = value.trim().toLowerCase();
|
|
27
|
+
return normalized !== "" && normalized !== "0" && normalized !== "false";
|
|
28
|
+
}
|
package/dist/color.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
export type Paint = (format: Parameters<typeof styleText>[0], text: string) => string;
|
|
3
|
+
interface ColorStream {
|
|
4
|
+
readonly isTTY?: boolean;
|
|
5
|
+
hasColors?(): boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether a stream should get ANSI colour. NO_COLOR and FORCE_COLOR win, as Commander
|
|
9
|
+
* and Node both agree; otherwise only a terminal that reports colour support.
|
|
10
|
+
*/
|
|
11
|
+
export declare function colorEnabled(stream: ColorStream | undefined, env?: NodeJS.ProcessEnv): boolean;
|
|
12
|
+
/** A paint function that colours when asked and is the identity otherwise, so callers never branch. */
|
|
13
|
+
export declare function painter(enabled: boolean): Paint;
|
|
14
|
+
export {};
|
package/dist/color.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { styleText } from "node:util";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a stream should get ANSI colour. NO_COLOR and FORCE_COLOR win, as Commander
|
|
4
|
+
* and Node both agree; otherwise only a terminal that reports colour support.
|
|
5
|
+
*/
|
|
6
|
+
export function colorEnabled(stream, env = process.env) {
|
|
7
|
+
if (env.NO_COLOR || env.FORCE_COLOR === "0" || env.FORCE_COLOR === "false")
|
|
8
|
+
return false;
|
|
9
|
+
if (env.FORCE_COLOR || env.CLICOLOR_FORCE !== undefined)
|
|
10
|
+
return true;
|
|
11
|
+
return Boolean(stream?.isTTY) && (stream?.hasColors?.() ?? true);
|
|
12
|
+
}
|
|
13
|
+
/** A paint function that colours when asked and is the identity otherwise, so callers never branch. */
|
|
14
|
+
export function painter(enabled) {
|
|
15
|
+
if (!enabled)
|
|
16
|
+
return (_format, text) => text;
|
|
17
|
+
// The caller has already decided from the right stream; styleText must not second-guess it.
|
|
18
|
+
return (format, text) => styleText(format, text, { validateStream: false });
|
|
19
|
+
}
|
package/dist/confirm.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
+
import { type UiOptions } from "./ui.js";
|
|
1
2
|
export interface MutationPlan {
|
|
2
3
|
readonly summary: string;
|
|
3
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* The gate every mutating command passes through. `--dry-run` prints the plan and
|
|
7
|
+
* stops, `--yes` skips the question, and a pipe without `--yes` is an error rather
|
|
8
|
+
* than a hang. Only a person at a terminal is ever asked.
|
|
9
|
+
*/
|
|
4
10
|
export declare function confirm(plan: MutationPlan, options: {
|
|
5
11
|
yes: boolean;
|
|
6
12
|
dryRun: boolean;
|
|
7
13
|
interactive: boolean;
|
|
8
|
-
}): Promise<boolean>;
|
|
14
|
+
} & Pick<UiOptions, "input" | "output">): Promise<boolean>;
|
package/dist/confirm.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { createInterface } from "node:readline/promises";
|
|
2
1
|
import { CliError } from "./errors.js";
|
|
2
|
+
import { createUi } from "./ui.js";
|
|
3
|
+
/**
|
|
4
|
+
* The gate every mutating command passes through. `--dry-run` prints the plan and
|
|
5
|
+
* stops, `--yes` skips the question, and a pipe without `--yes` is an error rather
|
|
6
|
+
* than a hang. Only a person at a terminal is ever asked.
|
|
7
|
+
*/
|
|
3
8
|
export async function confirm(plan, options) {
|
|
9
|
+
const output = options.output ?? process.stderr;
|
|
4
10
|
if (options.dryRun) {
|
|
5
|
-
|
|
11
|
+
output.write(`${plan.summary}\n`);
|
|
6
12
|
return false;
|
|
7
13
|
}
|
|
8
14
|
if (options.yes)
|
|
@@ -10,13 +16,7 @@ export async function confirm(plan, options) {
|
|
|
10
16
|
if (!options.interactive) {
|
|
11
17
|
throw new CliError("usage", "Mutation requires --yes when stdin is not interactive.");
|
|
12
18
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const answer = await prompt.question("Continue? y/N ");
|
|
17
|
-
return answer.trim().toLowerCase() === "y";
|
|
18
|
-
}
|
|
19
|
-
finally {
|
|
20
|
-
prompt.close();
|
|
21
|
-
}
|
|
19
|
+
const ui = createUi({ interactive: true, ...(options.input ? { input: options.input } : {}), output });
|
|
20
|
+
ui.note(plan.summary, "Plan");
|
|
21
|
+
return ui.confirm("Continue?");
|
|
22
22
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -6,5 +6,7 @@ export declare class CliError extends Error {
|
|
|
6
6
|
readonly hint: string | undefined;
|
|
7
7
|
constructor(code: ErrorCode, message: string, hint?: string);
|
|
8
8
|
}
|
|
9
|
+
/** Commander's help and version exits: the output is already written and nothing failed. */
|
|
10
|
+
export declare function isInformationalExit(error: unknown): boolean;
|
|
9
11
|
export declare function exitCodeFor(code: ErrorCode): number;
|
|
10
12
|
export declare function normalizeError(error: unknown): CliError;
|
package/dist/errors.js
CHANGED
|
@@ -28,6 +28,15 @@ export class CliError extends Error {
|
|
|
28
28
|
this.hint = hint;
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
+
/** Commander's help and version exits: the output is already written and nothing failed. */
|
|
32
|
+
export function isInformationalExit(error) {
|
|
33
|
+
if (!error || typeof error !== "object")
|
|
34
|
+
return false;
|
|
35
|
+
if ("exitCode" in error && error.exitCode === 0)
|
|
36
|
+
return true;
|
|
37
|
+
const code = "code" in error ? String(error.code) : "";
|
|
38
|
+
return code.startsWith("commander.help") || code === "commander.version";
|
|
39
|
+
}
|
|
31
40
|
export function exitCodeFor(code) {
|
|
32
41
|
return EXIT_CODES[code];
|
|
33
42
|
}
|
package/dist/help.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Command, Help } from "commander";
|
|
2
|
+
/**
|
|
3
|
+
* List a subcommand under `title` in its parent's help instead of the one flat list.
|
|
4
|
+
* Sections appear in the order they were first named and commands in the order they
|
|
5
|
+
* were placed, so the caller decides what comes first however the tree was registered.
|
|
6
|
+
*/
|
|
7
|
+
export declare function helpSection(command: Command, title: string): Command;
|
|
8
|
+
/** A few invocations shown under "Try" in the command's help; text after ` # ` renders as a comment. */
|
|
9
|
+
export declare function examples(command: Command, lines: readonly string[]): Command;
|
|
10
|
+
/**
|
|
11
|
+
* ASCII art shown above the root help page, but only to a person: a pipe and an agent
|
|
12
|
+
* get the plain header, because art in a transcript is noise.
|
|
13
|
+
*/
|
|
14
|
+
export declare function banner(command: Command, art: string): Command;
|
|
15
|
+
/**
|
|
16
|
+
* The help layout every CLI in the family shares: name and version, a usage line, the
|
|
17
|
+
* commands grouped by section, then options and examples. Colour follows the output
|
|
18
|
+
* stream, so a pipe and an agent get the same text without escape codes.
|
|
19
|
+
*/
|
|
20
|
+
export declare function styledHelp(): Partial<Help>;
|
package/dist/help.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { painter } from "./color.js";
|
|
2
|
+
const sections = new WeakMap();
|
|
3
|
+
const sectionOrder = new WeakMap();
|
|
4
|
+
const sectionRank = new WeakMap();
|
|
5
|
+
let nextRank = 0;
|
|
6
|
+
const exampleLines = new WeakMap();
|
|
7
|
+
const banners = new WeakMap();
|
|
8
|
+
/**
|
|
9
|
+
* List a subcommand under `title` in its parent's help instead of the one flat list.
|
|
10
|
+
* Sections appear in the order they were first named and commands in the order they
|
|
11
|
+
* were placed, so the caller decides what comes first however the tree was registered.
|
|
12
|
+
*/
|
|
13
|
+
export function helpSection(command, title) {
|
|
14
|
+
sections.set(command, title);
|
|
15
|
+
sectionRank.set(command, nextRank++);
|
|
16
|
+
if (command.parent) {
|
|
17
|
+
const order = sectionOrder.get(command.parent) ?? [];
|
|
18
|
+
if (!order.includes(title))
|
|
19
|
+
sectionOrder.set(command.parent, [...order, title]);
|
|
20
|
+
}
|
|
21
|
+
return command;
|
|
22
|
+
}
|
|
23
|
+
/** A few invocations shown under "Try" in the command's help; text after ` # ` renders as a comment. */
|
|
24
|
+
export function examples(command, lines) {
|
|
25
|
+
exampleLines.set(command, lines);
|
|
26
|
+
return command;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* ASCII art shown above the root help page, but only to a person: a pipe and an agent
|
|
30
|
+
* get the plain header, because art in a transcript is noise.
|
|
31
|
+
*/
|
|
32
|
+
export function banner(command, art) {
|
|
33
|
+
banners.set(command, art.replace(/^\n+|\s+$/gu, ""));
|
|
34
|
+
return command;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The help layout every CLI in the family shares: name and version, a usage line, the
|
|
38
|
+
* commands grouped by section, then options and examples. Colour follows the output
|
|
39
|
+
* stream, so a pipe and an agent get the same text without escape codes.
|
|
40
|
+
*/
|
|
41
|
+
export function styledHelp() {
|
|
42
|
+
return {
|
|
43
|
+
prepareContext(context) {
|
|
44
|
+
this.helpWidth = this.helpWidth ?? context.helpWidth ?? 80;
|
|
45
|
+
this.hasColors = context.outputHasColors ?? false;
|
|
46
|
+
// Commander reports a width only for a terminal; forced colour means a person asked for the styled page.
|
|
47
|
+
this.terminal = typeof context.helpWidth === "number" || Boolean(context.outputHasColors);
|
|
48
|
+
},
|
|
49
|
+
formatHelp(command, helper) {
|
|
50
|
+
const styled = helper;
|
|
51
|
+
return formatHelp(command, helper, painter(Boolean(styled.hasColors)), Boolean(styled.terminal));
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function formatHelp(command, helper, paint, terminal) {
|
|
56
|
+
const blocks = [];
|
|
57
|
+
const width = helper.helpWidth ?? 80;
|
|
58
|
+
const title = (text) => paint("bold", text);
|
|
59
|
+
const list = (items) => {
|
|
60
|
+
const termWidth = Math.max(...items.map(item => helper.displayWidth(item.term)));
|
|
61
|
+
return items.map(item => helper.formatItem(item.term, termWidth, tidy(item.description), helper));
|
|
62
|
+
};
|
|
63
|
+
const version = command.parent ? undefined : command.version();
|
|
64
|
+
const art = command.parent ? undefined : banners.get(command);
|
|
65
|
+
if (art && terminal) {
|
|
66
|
+
// The wordmark is the name; the version rides on the description line instead of a second header.
|
|
67
|
+
blocks.push(...art.split("\n").map(line => paint("cyan", line)), "");
|
|
68
|
+
if (command.description())
|
|
69
|
+
blocks.push(`${helper.boxWrap(tidy(command.description()), width)}${version ? ` ${paint("dim", `v${version}`)}` : ""}`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
blocks.push(`${paint("bold", commandPath(command))}${version ? ` ${paint("dim", `v${version}`)}` : ""}`);
|
|
73
|
+
if (command.description())
|
|
74
|
+
blocks.push(helper.boxWrap(tidy(command.description()), width));
|
|
75
|
+
}
|
|
76
|
+
blocks.push("");
|
|
77
|
+
// Commander's own usage line repeats the aliases ("units|courses"); the path is enough.
|
|
78
|
+
blocks.push(title("Usage"), ` ${styleUsage(`${commandPath(command)} ${command.usage()}`, paint)}`, "");
|
|
79
|
+
const args = helper.visibleArguments(command);
|
|
80
|
+
if (args.length) {
|
|
81
|
+
blocks.push(title("Arguments"), ...list(args.map(argument => ({ term: paint("bold", helper.argumentTerm(argument)), description: helper.argumentDescription(argument) }))), "");
|
|
82
|
+
}
|
|
83
|
+
// The implicit help command is not one of `command.commands`; the footer already says how to get help.
|
|
84
|
+
const commands = helper.visibleCommands(command).filter(child => command.commands.includes(child));
|
|
85
|
+
const groups = new Map();
|
|
86
|
+
for (const heading of sectionOrder.get(command) ?? [])
|
|
87
|
+
groups.set(heading, []);
|
|
88
|
+
const placed = [...commands].sort((a, b) => (sectionRank.get(a) ?? Infinity) - (sectionRank.get(b) ?? Infinity));
|
|
89
|
+
for (const child of placed) {
|
|
90
|
+
const heading = sections.get(child) ?? "Commands";
|
|
91
|
+
groups.set(heading, [...(groups.get(heading) ?? []), child]);
|
|
92
|
+
}
|
|
93
|
+
const termWidth = commands.length ? Math.max(...commands.map(child => helper.displayWidth(subcommandTerm(child, paint)))) : 0;
|
|
94
|
+
for (const [heading, children] of groups) {
|
|
95
|
+
if (!children.length)
|
|
96
|
+
continue;
|
|
97
|
+
blocks.push(title(heading), ...children.map(child => helper.formatItem(subcommandTerm(child, paint), termWidth, tidy(helper.subcommandDescription(child)), helper)), "");
|
|
98
|
+
}
|
|
99
|
+
// A section holding nothing but --help says nothing.
|
|
100
|
+
const options = helper.visibleOptions(command);
|
|
101
|
+
if (options.some(option => option.long !== "--help")) {
|
|
102
|
+
blocks.push(title("Options"), ...list(options.map(option => ({ term: paint("cyan", helper.optionTerm(option)), description: helper.optionDescription(option) }))), "");
|
|
103
|
+
}
|
|
104
|
+
const lines = exampleLines.get(command);
|
|
105
|
+
if (lines?.length) {
|
|
106
|
+
blocks.push(title("Try"), ...lines.map(line => ` ${styleExample(line, paint)}`), "");
|
|
107
|
+
}
|
|
108
|
+
// Root options apply after any subcommand too; a subcommand page names them once, without repeating the table.
|
|
109
|
+
const root = rootOf(command);
|
|
110
|
+
const globals = command.parent ? helper.visibleOptions(root).filter(option => option.long !== "--help" && option.long !== "--version") : [];
|
|
111
|
+
if (globals.length) {
|
|
112
|
+
blocks.push(title("Global options"), ...wrapItems(globals.map(longFlag), width - 2).map(line => ` ${paint("dim", line)}`), "");
|
|
113
|
+
}
|
|
114
|
+
if (commands.length)
|
|
115
|
+
blocks.push(paint("dim", `Run '${commandPath(command)} <command> --help' for details on a command.`), "");
|
|
116
|
+
return `${blocks.join("\n").trimEnd()}\n`;
|
|
117
|
+
}
|
|
118
|
+
// "-o, --output <file>" reads as "--output <file>": the long flag with its own placeholder.
|
|
119
|
+
function longFlag(option) {
|
|
120
|
+
return option.flags.split(/,\s*/u).find(part => part.startsWith("--")) ?? option.flags;
|
|
121
|
+
}
|
|
122
|
+
// Wrap flag by flag, so "--limit <number>" never splits across lines the way word wrapping would.
|
|
123
|
+
function wrapItems(items, width) {
|
|
124
|
+
const lines = [];
|
|
125
|
+
let line = "";
|
|
126
|
+
for (const item of items) {
|
|
127
|
+
const joined = line ? `${line}, ${item}` : item;
|
|
128
|
+
if (line && joined.length > width) {
|
|
129
|
+
lines.push(`${line},`);
|
|
130
|
+
line = item;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
line = joined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (line)
|
|
137
|
+
lines.push(line);
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
function rootOf(command) {
|
|
141
|
+
let current = command;
|
|
142
|
+
while (current.parent)
|
|
143
|
+
current = current.parent;
|
|
144
|
+
return current;
|
|
145
|
+
}
|
|
146
|
+
const EXTRAS = /( \((?:choices|default|env|preset):[^]*\))$/u;
|
|
147
|
+
/** A one-sentence description reads as a man page does, without its full stop; prose keeps it. */
|
|
148
|
+
function tidy(description) {
|
|
149
|
+
const match = EXTRAS.exec(description);
|
|
150
|
+
const body = match ? description.slice(0, match.index) : description;
|
|
151
|
+
const single = !/[.!?]\s+\S/u.test(body);
|
|
152
|
+
return `${single ? body.replace(/\.$/u, "") : body}${match ? match[1] : ""}`;
|
|
153
|
+
}
|
|
154
|
+
function commandPath(command) {
|
|
155
|
+
const names = [];
|
|
156
|
+
for (let current = command; current; current = current.parent)
|
|
157
|
+
names.unshift(current.name());
|
|
158
|
+
return names.join(" ");
|
|
159
|
+
}
|
|
160
|
+
function subcommandTerm(command, paint) {
|
|
161
|
+
const aliases = command.aliases().length ? paint("dim", `, ${command.aliases().join(", ")}`) : "";
|
|
162
|
+
const args = command.registeredArguments.map(argument => ` ${paint("dim", argumentTerm(argument))}`).join("");
|
|
163
|
+
return `${paint("bold", command.name())}${aliases}${args}`;
|
|
164
|
+
}
|
|
165
|
+
function argumentTerm(argument) {
|
|
166
|
+
const name = `${argument.name()}${argument.variadic ? "..." : ""}`;
|
|
167
|
+
return argument.required ? `<${name}>` : `[${name}]`;
|
|
168
|
+
}
|
|
169
|
+
function styleUsage(usage, paint) {
|
|
170
|
+
// "moodle activities list [options] <unit>": the command words in bold, the rest dimmed.
|
|
171
|
+
const words = usage.split(" ");
|
|
172
|
+
const first = words.findIndex(word => word.startsWith("[") || word.startsWith("<"));
|
|
173
|
+
const names = first === -1 ? words : words.slice(0, first);
|
|
174
|
+
const rest = first === -1 ? [] : words.slice(first);
|
|
175
|
+
return [paint("bold", names.join(" ")), ...rest.map(word => paint("dim", word))].join(" ");
|
|
176
|
+
}
|
|
177
|
+
function styleExample(line, paint) {
|
|
178
|
+
const comment = line.indexOf(" # ");
|
|
179
|
+
if (comment === -1)
|
|
180
|
+
return line;
|
|
181
|
+
return `${line.slice(0, comment)} ${paint("dim", line.slice(comment + 2))}`;
|
|
182
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
export * from "./arguments.js";
|
|
2
|
+
export * from "./audience.js";
|
|
3
|
+
export * from "./color.js";
|
|
1
4
|
export * from "./confirm.js";
|
|
2
5
|
export * from "./errors.js";
|
|
6
|
+
export * from "./help.js";
|
|
3
7
|
export * from "./nouns.js";
|
|
4
8
|
export * from "./output.js";
|
|
5
9
|
export * from "./program.js";
|
|
6
10
|
export * from "./report.js";
|
|
11
|
+
export * from "./ui.js";
|
|
12
|
+
export * from "./theme.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
export * from "./arguments.js";
|
|
2
|
+
export * from "./audience.js";
|
|
3
|
+
export * from "./color.js";
|
|
1
4
|
export * from "./confirm.js";
|
|
2
5
|
export * from "./errors.js";
|
|
6
|
+
export * from "./help.js";
|
|
3
7
|
export * from "./nouns.js";
|
|
4
8
|
export * from "./output.js";
|
|
5
9
|
export * from "./program.js";
|
|
6
10
|
export * from "./report.js";
|
|
11
|
+
export * from "./ui.js";
|
|
12
|
+
export * from "./theme.js";
|
package/dist/output.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Theme, Tone } from "./theme.js";
|
|
1
2
|
export type OutputFormat = "table" | "json" | "yaml";
|
|
2
3
|
export interface FormatOptions {
|
|
3
4
|
readonly json?: boolean;
|
|
@@ -6,6 +7,8 @@ export interface FormatOptions {
|
|
|
6
7
|
readonly fields?: string;
|
|
7
8
|
}
|
|
8
9
|
export declare function resolveFormat(options: FormatOptions, isTty: boolean): OutputFormat;
|
|
10
|
+
/** The format a command will use, read from raw argv before Commander has parsed anything. */
|
|
11
|
+
export declare function formatFromArgv(argv: readonly string[], isTty: boolean): OutputFormat;
|
|
9
12
|
export declare function render(value: unknown, options: {
|
|
10
13
|
format: OutputFormat;
|
|
11
14
|
fields?: readonly string[];
|
|
@@ -14,6 +17,10 @@ export declare function render(value: unknown, options: {
|
|
|
14
17
|
width?: number;
|
|
15
18
|
/** Indent JSON. Readable on a terminal, wasted bytes in a pipe. */
|
|
16
19
|
pretty?: boolean;
|
|
20
|
+
/** Colour the table: dim headers, the first column as a key, status-like columns by tone. */
|
|
21
|
+
theme?: Theme;
|
|
22
|
+
/** The caller's own status words, when the shared vocabulary would misread them. */
|
|
23
|
+
tones?: Readonly<Record<string, Tone>>;
|
|
17
24
|
}): string;
|
|
18
25
|
export declare function writeOutput(text: string, options: {
|
|
19
26
|
output?: string;
|
package/dist/output.js
CHANGED
|
@@ -8,6 +8,17 @@ export function resolveFormat(options, isTty) {
|
|
|
8
8
|
}
|
|
9
9
|
return selected[0] ?? (isTty ? "table" : "json");
|
|
10
10
|
}
|
|
11
|
+
/** The format a command will use, read from raw argv before Commander has parsed anything. */
|
|
12
|
+
export function formatFromArgv(argv, isTty) {
|
|
13
|
+
const end = argv.indexOf("--");
|
|
14
|
+
const tokens = new Set(end === -1 ? argv : argv.slice(0, end));
|
|
15
|
+
try {
|
|
16
|
+
return resolveFormat({ json: tokens.has("--json"), yaml: tokens.has("--yaml"), table: tokens.has("--table") }, isTty);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return isTty ? "table" : "json";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
11
22
|
export function render(value, options) {
|
|
12
23
|
const filtered = options.fields?.length ? selectFields(value, options.fields) : value;
|
|
13
24
|
if (options.format === "json") {
|
|
@@ -15,7 +26,7 @@ export function render(value, options) {
|
|
|
15
26
|
}
|
|
16
27
|
if (options.format === "yaml")
|
|
17
28
|
return stringifyYaml(filtered);
|
|
18
|
-
return renderTable(filtered, options.columns, options.width);
|
|
29
|
+
return renderTable(filtered, options.columns, options.width, options.theme, options.tones);
|
|
19
30
|
}
|
|
20
31
|
export async function writeOutput(text, options) {
|
|
21
32
|
if (options.output) {
|
|
@@ -45,7 +56,9 @@ function selectFields(value, fields) {
|
|
|
45
56
|
/** Narrower than this and a column carries no information, so overflow is the lesser evil. */
|
|
46
57
|
const MIN_COLUMN = 8;
|
|
47
58
|
const GAP = " ";
|
|
48
|
-
|
|
59
|
+
/** Columns whose cells are status words rather than names, so their colour carries meaning. */
|
|
60
|
+
const STATUS_COLUMN = /status|state|type|kind|category|grade|role|due/iu;
|
|
61
|
+
function renderTable(value, columns, width, theme, tones) {
|
|
49
62
|
const rows = Array.isArray(value) ? value : [value];
|
|
50
63
|
if (rows.length === 0)
|
|
51
64
|
return "";
|
|
@@ -55,8 +68,17 @@ function renderTable(value, columns, width) {
|
|
|
55
68
|
const values = rows.map((row) => selected.map(([key]) => formatCell(valueAt(row, key))));
|
|
56
69
|
const natural = selected.map(([, label], index) => Math.max(label.length, ...values.map((row) => row[index]?.length ?? 0)));
|
|
57
70
|
const widths = fitWidths(natural, width);
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
// Padding is measured on the plain text; colour is wrapped around the padded cell afterwards.
|
|
72
|
+
const line = (cells, paint) => cells.map((cell, index) => {
|
|
73
|
+
const fitted = truncate(cell, widths[index] ?? 0);
|
|
74
|
+
return paint(index === cells.length - 1 ? fitted : fitted.padEnd(widths[index] ?? 0), index);
|
|
75
|
+
}).join(GAP).trimEnd();
|
|
76
|
+
const plain = (cell) => cell;
|
|
77
|
+
const cellPaint = theme
|
|
78
|
+
? (cell, index) => index === 0 ? theme.key(cell) : STATUS_COLUMN.test(selected[index]?.[0] ?? "") ? theme.status(cell, tones) : cell
|
|
79
|
+
: plain;
|
|
80
|
+
const header = line(selected.map(([, label]) => label), theme ? cell => theme.dim(cell) : plain);
|
|
81
|
+
return `${header}\n${values.map(row => line(row, cellPaint)).join("\n")}\n`;
|
|
60
82
|
}
|
|
61
83
|
/** A column key may reach into a nested object, as "unit.code". */
|
|
62
84
|
function valueAt(row, key) {
|
package/dist/program.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
+
import { colorEnabled } from "./color.js";
|
|
3
|
+
import { styledHelp } from "./help.js";
|
|
2
4
|
export const VERBS = ["list", "show", "read", "get", "search", "send", "submit", "set", "mark-read"];
|
|
3
5
|
const VERB_SET = new Set(VERBS);
|
|
4
6
|
const mutations = new WeakSet();
|
|
@@ -8,15 +10,15 @@ const ACTIONS = {
|
|
|
8
10
|
skills: new Set(["generate", "add"]),
|
|
9
11
|
};
|
|
10
12
|
export function createProgram(meta) {
|
|
11
|
-
|
|
13
|
+
const program = new Command()
|
|
12
14
|
.name(meta.name)
|
|
13
|
-
.version(meta.version, "-V, --version")
|
|
15
|
+
.version(meta.version, "-V, --version", "Show the version")
|
|
14
16
|
.description(meta.description)
|
|
15
|
-
.helpOption("-h, --help")
|
|
16
|
-
.
|
|
17
|
+
.helpOption("-h, --help", "Show help")
|
|
18
|
+
.helpCommand("help [command]", "Show help for a command")
|
|
17
19
|
.showSuggestionAfterError()
|
|
18
20
|
.exitOverride()
|
|
19
|
-
.
|
|
21
|
+
.configureHelp(styledHelp())
|
|
20
22
|
.option("--json", "Emit JSON")
|
|
21
23
|
.option("--yaml", "Emit YAML")
|
|
22
24
|
.option("--table", "Emit a human-readable table")
|
|
@@ -26,6 +28,12 @@ export function createProgram(meta) {
|
|
|
26
28
|
.option("--no-color", "Disable color output")
|
|
27
29
|
.option("-y, --yes", "Confirm mutations non-interactively")
|
|
28
30
|
.option("--dry-run", "Print the mutation plan without applying it");
|
|
31
|
+
program.configureOutput({
|
|
32
|
+
outputError: () => undefined,
|
|
33
|
+
getOutHasColors: () => program.opts().color !== false && colorEnabled(process.stdout),
|
|
34
|
+
getErrHasColors: () => program.opts().color !== false && colorEnabled(process.stderr),
|
|
35
|
+
});
|
|
36
|
+
return program;
|
|
29
37
|
}
|
|
30
38
|
export function mutating(command) {
|
|
31
39
|
mutations.add(command);
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** What a status word means to the person reading it, independent of the site that produced it. */
|
|
2
|
+
export type Tone = "success" | "warning" | "danger" | "info" | "accent" | "muted";
|
|
3
|
+
/**
|
|
4
|
+
* The roles a line of human output is made of. Every CLI in the family paints the
|
|
5
|
+
* same role the same way, so a person learns once that cyan is something they can
|
|
6
|
+
* type back (a unit code, a task number, an option) and bold is what they gave.
|
|
7
|
+
*/
|
|
8
|
+
export interface Theme {
|
|
9
|
+
readonly enabled: boolean;
|
|
10
|
+
/** An identifier the person will type again: a unit code, a task number, an id. */
|
|
11
|
+
key(text: string): string;
|
|
12
|
+
/** The thing being acted on: the files, the message, the title. */
|
|
13
|
+
subject(text: string): string;
|
|
14
|
+
/** Where it goes: the assignment, the thread, the unit. */
|
|
15
|
+
target(text: string): string;
|
|
16
|
+
/** Secondary facts: dates, counts, urls, labels. */
|
|
17
|
+
dim(text: string): string;
|
|
18
|
+
tone(tone: Tone, text: string): string;
|
|
19
|
+
/** A status word coloured by what it means; unknown words stay plain. */
|
|
20
|
+
status(text: string, overrides?: Readonly<Record<string, Tone>>): string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The tone a status word carries. The vocabulary is the one learning sites share
|
|
24
|
+
* ("overdue", "graded", "not started", "announcement"); a caller with its own words
|
|
25
|
+
* passes them in `overrides`, matched whole and case-insensitively, before the shared list.
|
|
26
|
+
*/
|
|
27
|
+
export declare function toneOf(text: string, overrides?: Readonly<Record<string, Tone>>): Tone | undefined;
|
|
28
|
+
export declare function createTheme(enabled: boolean): Theme;
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { painter } from "./color.js";
|
|
2
|
+
const TONE_STYLE = {
|
|
3
|
+
success: "green",
|
|
4
|
+
warning: "yellow",
|
|
5
|
+
danger: "red",
|
|
6
|
+
info: "blue",
|
|
7
|
+
accent: "magenta",
|
|
8
|
+
muted: "dim",
|
|
9
|
+
};
|
|
10
|
+
// Checked in order: a negated word ("not graded") must win over the word it contains.
|
|
11
|
+
const TONE_WORDS = [
|
|
12
|
+
["muted", /^(?:-|—|none|n\/a|unknown)$|\b(?:not[ _](?:started|submitted|graded|attempted|available|yet)|no submission|un(?:attempted|graded|submitted)|archived|hidden|unlisted|inactive|closed|disabled)\b/iu],
|
|
13
|
+
["danger", /\b(?:overdue|late|fail(?:ed|ure)?|error|missing|rejected|need(?:s)?[ _]help|blocked|expired|redo|resubmit|demonstrate|exceeded)\b/iu],
|
|
14
|
+
["warning", /\b(?:pending|draft|working|in[ _]progress|due soon|today|tomorrow|awaiting|unanswered|scheduled|discuss|attempted|partial)\b/iu],
|
|
15
|
+
["success", /\b(?:complete[d]?|done|submitted|graded|accepted|passed|resolved|answered|ready[ _]for[ _]feedback|active|signed in|ok|success(?:ful)?|enrolled)\b/iu],
|
|
16
|
+
["accent", /\b(?:announcement|pinned|staff|endorsed|official|urgent)\b/iu],
|
|
17
|
+
["info", /\b(?:question|post|note|general|info)\b/iu],
|
|
18
|
+
];
|
|
19
|
+
/**
|
|
20
|
+
* The tone a status word carries. The vocabulary is the one learning sites share
|
|
21
|
+
* ("overdue", "graded", "not started", "announcement"); a caller with its own words
|
|
22
|
+
* passes them in `overrides`, matched whole and case-insensitively, before the shared list.
|
|
23
|
+
*/
|
|
24
|
+
export function toneOf(text, overrides) {
|
|
25
|
+
const normalized = text.trim().toLowerCase();
|
|
26
|
+
if (!normalized)
|
|
27
|
+
return undefined;
|
|
28
|
+
if (overrides) {
|
|
29
|
+
for (const [word, tone] of Object.entries(overrides))
|
|
30
|
+
if (word.toLowerCase() === normalized)
|
|
31
|
+
return tone;
|
|
32
|
+
}
|
|
33
|
+
return TONE_WORDS.find(([, pattern]) => pattern.test(normalized))?.[0];
|
|
34
|
+
}
|
|
35
|
+
export function createTheme(enabled) {
|
|
36
|
+
const paint = painter(enabled);
|
|
37
|
+
return {
|
|
38
|
+
enabled,
|
|
39
|
+
key: text => paint("cyan", text),
|
|
40
|
+
subject: text => paint("bold", text),
|
|
41
|
+
target: text => paint(["bold", "cyan"], text),
|
|
42
|
+
dim: text => paint("dim", text),
|
|
43
|
+
tone: (tone, text) => paint(TONE_STYLE[tone], text),
|
|
44
|
+
status(text, overrides) {
|
|
45
|
+
const tone = toneOf(text, overrides);
|
|
46
|
+
return tone ? paint(TONE_STYLE[tone], text) : text;
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Writable } from "node:stream";
|
|
2
|
+
/**
|
|
3
|
+
* One choice offered to `select`. `hint` renders dimmed next to the label, so it is
|
|
4
|
+
* the place for the secondary fact a person needs to tell two similar rows apart
|
|
5
|
+
* (a unit code, a due date), never for an id.
|
|
6
|
+
*/
|
|
7
|
+
export interface Choice<T> {
|
|
8
|
+
readonly value: T;
|
|
9
|
+
readonly label: string;
|
|
10
|
+
readonly hint?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface UiOptions {
|
|
13
|
+
/** Keyboard input. Defaults to `process.stdin`. */
|
|
14
|
+
readonly input?: NodeJS.ReadStream;
|
|
15
|
+
/** Where prompts and progress draw. Defaults to `process.stderr` so `--json` stdout stays clean. */
|
|
16
|
+
readonly output?: Writable;
|
|
17
|
+
/** Force the interactive or plain path instead of detecting it from the streams and environment. */
|
|
18
|
+
readonly interactive?: boolean;
|
|
19
|
+
/** Environment consulted for agent markers. Defaults to `process.env`. */
|
|
20
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
21
|
+
/** Aborting it cancels whichever prompt is open. */
|
|
22
|
+
readonly signal?: AbortSignal;
|
|
23
|
+
/** The command `editor` runs instead of $VISUAL or $EDITOR; the file path is appended. */
|
|
24
|
+
readonly editor?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface Spinner {
|
|
27
|
+
start(message: string): void;
|
|
28
|
+
/** Replace the running text without ending the step. */
|
|
29
|
+
message(message: string): void;
|
|
30
|
+
/** End the step and leave `message` (or the last running text) as the permanent line. */
|
|
31
|
+
stop(message?: string): void;
|
|
32
|
+
error(message?: string): void;
|
|
33
|
+
}
|
|
34
|
+
export interface Ui {
|
|
35
|
+
readonly interactive: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The CLI's wordmark, for the first screen a person sees (onboarding, a guided setup).
|
|
38
|
+
* A pipe gets the tagline alone, so a transcript never carries the art.
|
|
39
|
+
*/
|
|
40
|
+
banner(art: string, tagline?: string): void;
|
|
41
|
+
intro(title: string): void;
|
|
42
|
+
outro(message: string): void;
|
|
43
|
+
step(message: string): void;
|
|
44
|
+
info(message: string): void;
|
|
45
|
+
warn(message: string): void;
|
|
46
|
+
note(message: string, title?: string): void;
|
|
47
|
+
spinner(): Spinner;
|
|
48
|
+
/**
|
|
49
|
+
* 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.
|
|
51
|
+
* Without a terminal this throws a usage error that lists the choices.
|
|
52
|
+
*/
|
|
53
|
+
select<T>(message: string, choices: readonly Choice<T>[]): Promise<T>;
|
|
54
|
+
/** Yes or no. Without a terminal this throws a usage error. */
|
|
55
|
+
confirm(message: string, options?: {
|
|
56
|
+
readonly initial?: boolean;
|
|
57
|
+
}): Promise<boolean>;
|
|
58
|
+
/** One line of text. Without a terminal this returns `options.initial` or throws. */
|
|
59
|
+
text(message: string, options?: {
|
|
60
|
+
readonly placeholder?: string;
|
|
61
|
+
readonly initial?: string;
|
|
62
|
+
readonly validate?: (value: string) => string | undefined;
|
|
63
|
+
}): Promise<string>;
|
|
64
|
+
/** One secret, echoed as dots. Without a terminal this throws; callers offer a stdin flag instead. */
|
|
65
|
+
password(message: string): Promise<string>;
|
|
66
|
+
/**
|
|
67
|
+
* Several lines, written in the person's $VISUAL or $EDITOR the way git asks for a
|
|
68
|
+
* commit message. Returns the file as saved. Without a terminal this throws; callers
|
|
69
|
+
* offer a file or stdin flag instead.
|
|
70
|
+
*/
|
|
71
|
+
editor(message: string, options?: {
|
|
72
|
+
readonly initial?: string;
|
|
73
|
+
readonly extension?: string;
|
|
74
|
+
}): Promise<string>;
|
|
75
|
+
}
|
|
76
|
+
export declare const AUTOCOMPLETE_FROM = 8;
|
|
77
|
+
/**
|
|
78
|
+
* Every CLI in the family draws the same way: prompts, steps and spinners on stderr
|
|
79
|
+
* in the clack style when a person is at the keyboard; plain completed lines when the
|
|
80
|
+
* output is a pipe; and never a prompt without a terminal, because a hung agent is
|
|
81
|
+
* worse than an error that says what to pass.
|
|
82
|
+
*/
|
|
83
|
+
export declare function createUi(options?: UiOptions): Ui;
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import * as clack from "@clack/prompts";
|
|
6
|
+
import { isAgentEnvironment } from "./audience.js";
|
|
7
|
+
import { colorEnabled, painter } from "./color.js";
|
|
8
|
+
import { CliError } from "./errors.js";
|
|
9
|
+
export const AUTOCOMPLETE_FROM = 8;
|
|
10
|
+
/**
|
|
11
|
+
* Every CLI in the family draws the same way: prompts, steps and spinners on stderr
|
|
12
|
+
* in the clack style when a person is at the keyboard; plain completed lines when the
|
|
13
|
+
* output is a pipe; and never a prompt without a terminal, because a hung agent is
|
|
14
|
+
* worse than an error that says what to pass.
|
|
15
|
+
*/
|
|
16
|
+
export function createUi(options = {}) {
|
|
17
|
+
const input = options.input ?? process.stdin;
|
|
18
|
+
const output = options.output ?? process.stderr;
|
|
19
|
+
const interactive = options.interactive
|
|
20
|
+
?? (Boolean(input.isTTY) && Boolean(output.isTTY) && !isAgentEnvironment(options.env));
|
|
21
|
+
const common = { input, output, ...(options.signal ? { signal: options.signal } : {}) };
|
|
22
|
+
const plain = (text) => output.write(`${text}\n`);
|
|
23
|
+
const paint = painter(colorEnabled(output, options.env));
|
|
24
|
+
return {
|
|
25
|
+
interactive,
|
|
26
|
+
banner(art, tagline) {
|
|
27
|
+
if (!interactive) {
|
|
28
|
+
if (tagline)
|
|
29
|
+
plain(tagline);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const lines = art.replace(/^\n+|\s+$/gu, "").split("\n").map(line => paint("cyan", line));
|
|
33
|
+
output.write(`${lines.join("\n")}\n${tagline ? `${paint("dim", tagline)}\n` : ""}\n`);
|
|
34
|
+
},
|
|
35
|
+
intro: title => (interactive ? clack.intro(title, common) : plain(title)),
|
|
36
|
+
outro: message => (interactive ? clack.outro(message, common) : plain(message)),
|
|
37
|
+
step: message => (interactive ? clack.log.step(message, common) : plain(message)),
|
|
38
|
+
info: message => (interactive ? clack.log.info(message, common) : plain(message)),
|
|
39
|
+
warn: message => (interactive ? clack.log.warn(message, common) : plain(`warning: ${message}`)),
|
|
40
|
+
note: (message, title) => (interactive ? clack.note(message, title, common) : plain(title ? `${title}\n${message}` : message)),
|
|
41
|
+
spinner() {
|
|
42
|
+
if (interactive) {
|
|
43
|
+
const inner = clack.spinner(common);
|
|
44
|
+
return {
|
|
45
|
+
start: message => inner.start(message),
|
|
46
|
+
message: message => inner.message(message),
|
|
47
|
+
stop: message => inner.stop(message),
|
|
48
|
+
error: message => inner.error(message),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// A pipe is read after the fact: only the outcome of a step is worth a line.
|
|
52
|
+
let running = "";
|
|
53
|
+
return {
|
|
54
|
+
start: message => { running = message; },
|
|
55
|
+
message: message => { running = message; },
|
|
56
|
+
stop: message => plain(message ?? running),
|
|
57
|
+
error: message => plain(`error: ${message ?? running}`),
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
async select(message, choices) {
|
|
61
|
+
if (!choices.length)
|
|
62
|
+
throw new CliError("unexpected", `Nothing to choose for "${message}".`);
|
|
63
|
+
if (!interactive) {
|
|
64
|
+
throw new CliError("usage", `${message} needs a terminal to choose from ${choices.length} options.`, choices.map(c => c.label).join(", "));
|
|
65
|
+
}
|
|
66
|
+
// `Option<T>` is conditional on T being primitive, which an open generic cannot resolve.
|
|
67
|
+
const list = choices.map(c => ({ value: c.value, label: c.label, ...(c.hint ? { hint: c.hint } : {}) }));
|
|
68
|
+
const picked = choices.length >= AUTOCOMPLETE_FROM
|
|
69
|
+
? await clack.autocomplete({ message, options: list, placeholder: "Type to filter", maxItems: AUTOCOMPLETE_FROM, ...common })
|
|
70
|
+
: await clack.select({ message, options: list, ...common });
|
|
71
|
+
return unwrap(picked);
|
|
72
|
+
},
|
|
73
|
+
async confirm(message, confirmOptions = {}) {
|
|
74
|
+
if (!interactive)
|
|
75
|
+
throw new CliError("usage", `${message} needs a terminal to answer; pass --yes to confirm non-interactively.`);
|
|
76
|
+
return unwrap(await clack.confirm({ message, initialValue: confirmOptions.initial ?? false, ...common }));
|
|
77
|
+
},
|
|
78
|
+
async text(message, textOptions = {}) {
|
|
79
|
+
if (!interactive) {
|
|
80
|
+
if (textOptions.initial !== undefined)
|
|
81
|
+
return textOptions.initial;
|
|
82
|
+
throw new CliError("usage", `${message} needs a terminal to answer.`);
|
|
83
|
+
}
|
|
84
|
+
const answer = await clack.text({
|
|
85
|
+
message,
|
|
86
|
+
...(textOptions.placeholder !== undefined ? { placeholder: textOptions.placeholder } : {}),
|
|
87
|
+
...(textOptions.initial !== undefined ? { defaultValue: textOptions.initial, initialValue: textOptions.initial } : {}),
|
|
88
|
+
...(textOptions.validate ? { validate: (value) => textOptions.validate?.(value ?? "") } : {}),
|
|
89
|
+
...common,
|
|
90
|
+
});
|
|
91
|
+
return unwrap(answer);
|
|
92
|
+
},
|
|
93
|
+
async password(message) {
|
|
94
|
+
if (!interactive)
|
|
95
|
+
throw new CliError("usage", `${message} needs a terminal to enter a secret.`);
|
|
96
|
+
return unwrap(await clack.password({ message, ...common }));
|
|
97
|
+
},
|
|
98
|
+
async editor(message, editorOptions = {}) {
|
|
99
|
+
if (!interactive)
|
|
100
|
+
throw new CliError("usage", `${message} needs a terminal to open an editor.`);
|
|
101
|
+
const env = options.env ?? process.env;
|
|
102
|
+
const command = options.editor ?? env.VISUAL ?? env.EDITOR ?? (process.platform === "win32" ? "notepad" : "vi");
|
|
103
|
+
const dir = mkdtempSync(join(tmpdir(), "cli-kit-"));
|
|
104
|
+
const file = join(dir, `message${editorOptions.extension ?? ".md"}`);
|
|
105
|
+
try {
|
|
106
|
+
writeFileSync(file, editorOptions.initial ?? "", "utf8");
|
|
107
|
+
clack.log.step(`${message}: opening ${command}, save and close to continue.`, common);
|
|
108
|
+
// The editor owns the terminal until it exits; a shell lets "code --wait" style values work.
|
|
109
|
+
const result = spawnSync(`${command} "${file}"`, { stdio: "inherit", shell: true });
|
|
110
|
+
if (result.status !== 0)
|
|
111
|
+
throw new CliError("cancelled", `${command} exited with status ${result.status ?? "unknown"}.`);
|
|
112
|
+
return readFileSync(file, "utf8");
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
rmSync(dir, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Ctrl+C or Escape inside a prompt is the person's decision, reported with exit 130 like a signal. */
|
|
121
|
+
function unwrap(value) {
|
|
122
|
+
if (clack.isCancel(value))
|
|
123
|
+
throw new CliError("cancelled", "Cancelled.");
|
|
124
|
+
return value;
|
|
125
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bunizao/cli-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Shared command-line contract for Bunizao CLI tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
"prepack": "npm test"
|
|
23
23
|
},
|
|
24
24
|
"engines": {
|
|
25
|
-
"node": ">=
|
|
25
|
+
"node": ">=20.12"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
+
"@clack/prompts": "^1.8.1",
|
|
28
29
|
"commander": "^13.1.0",
|
|
29
30
|
"yaml": "^2.8.0"
|
|
30
31
|
},
|