@bunizao/cli-kit 0.1.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/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;
@@ -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
+ }
@@ -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
- process.stderr.write(`${plan.summary}\n`);
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
- process.stderr.write(`${plan.summary}\n`);
14
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
15
- try {
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,10 +7,20 @@ 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[];
12
15
  columns?: readonly [string, string][];
16
+ /** Terminal width the table has to fit into. Omitted means unlimited. */
17
+ width?: number;
18
+ /** Indent JSON. Readable on a terminal, wasted bytes in a pipe. */
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>>;
13
24
  }): string;
14
25
  export declare function writeOutput(text: string, options: {
15
26
  output?: string;
package/dist/output.js CHANGED
@@ -8,13 +8,25 @@ 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
- if (options.format === "json")
14
- return `${JSON.stringify(filtered, null, 2)}\n`;
24
+ if (options.format === "json") {
25
+ return `${options.pretty === false ? JSON.stringify(filtered) : JSON.stringify(filtered, null, 2)}\n`;
26
+ }
15
27
  if (options.format === "yaml")
16
28
  return stringifyYaml(filtered);
17
- return renderTable(filtered, options.columns);
29
+ return renderTable(filtered, options.columns, options.width, options.theme, options.tones);
18
30
  }
19
31
  export async function writeOutput(text, options) {
20
32
  if (options.output) {
@@ -41,22 +53,92 @@ function selectFields(value, fields) {
41
53
  return value;
42
54
  return Object.fromEntries(fields.filter((field) => field in value).map((field) => [field, value[field]]));
43
55
  }
44
- function renderTable(value, columns) {
56
+ /** Narrower than this and a column carries no information, so overflow is the lesser evil. */
57
+ const MIN_COLUMN = 8;
58
+ const GAP = " ";
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) {
45
62
  const rows = Array.isArray(value) ? value : [value];
46
63
  if (rows.length === 0)
47
64
  return "";
48
65
  if (!rows.every(isRecord))
49
66
  return `${rows.map(String).join("\n")}\n`;
50
- const selected = columns ?? Object.keys(rows[0] ?? {}).map((key) => [key, key]);
51
- const values = rows.map((row) => selected.map(([key]) => formatCell(row[key])));
52
- const widths = selected.map(([, label], index) => Math.max(label.length, ...values.map((row) => row[index]?.length ?? 0)));
53
- const line = (cells) => cells.map((cell, index) => cell.padEnd(widths[index] ?? 0)).join(" ").trimEnd();
54
- return `${line(selected.map(([, label]) => label))}\n${values.map(line).join("\n")}\n`;
67
+ const selected = columns ?? columnsOf(rows);
68
+ const values = rows.map((row) => selected.map(([key]) => formatCell(valueAt(row, key))));
69
+ const natural = selected.map(([, label], index) => Math.max(label.length, ...values.map((row) => row[index]?.length ?? 0)));
70
+ const widths = fitWidths(natural, width);
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`;
82
+ }
83
+ /** A column key may reach into a nested object, as "unit.code". */
84
+ function valueAt(row, key) {
85
+ if (key in row)
86
+ return row[key];
87
+ let current = row;
88
+ for (const step of key.split(".")) {
89
+ if (!isRecord(current))
90
+ return undefined;
91
+ current = current[step];
92
+ }
93
+ return current;
55
94
  }
95
+ /**
96
+ * Every key any row has, in the order the rows introduce them. Taking the keys of
97
+ * the first row alone drops whatever that row happened to leave out, which is a
98
+ * silent hole in the table for anyone who omits empty fields.
99
+ */
100
+ function columnsOf(rows) {
101
+ const keys = new Set();
102
+ for (const row of rows)
103
+ for (const key of Object.keys(row))
104
+ keys.add(key);
105
+ return [...keys].map((key) => [key, key]);
106
+ }
107
+ /**
108
+ * Shave the widest column a character at a time until the table fits. Truncation is
109
+ * visible; dropping a column is not, so columns are kept even when the budget runs out.
110
+ */
111
+ function fitWidths(natural, width) {
112
+ const widths = [...natural];
113
+ if (!width || width <= 0)
114
+ return widths;
115
+ const budget = width - GAP.length * (widths.length - 1);
116
+ let total = widths.reduce((sum, value) => sum + value, 0);
117
+ while (total > budget) {
118
+ const widest = widths.reduce((best, value, index) => (value > (widths[best] ?? 0) ? index : best), 0);
119
+ if ((widths[widest] ?? 0) <= MIN_COLUMN)
120
+ break;
121
+ widths[widest] = (widths[widest] ?? 0) - 1;
122
+ total -= 1;
123
+ }
124
+ return widths;
125
+ }
126
+ function truncate(cell, width) {
127
+ return width > 0 && cell.length > width ? `${cell.slice(0, width - 1)}…` : cell;
128
+ }
129
+ /** A cell is one line of a table, so nested values are flattened rather than dumped as JSON. */
56
130
  function formatCell(value) {
57
131
  if (value === null || value === undefined)
58
132
  return "";
59
- return typeof value === "object" ? JSON.stringify(value) : String(value);
133
+ if (Array.isArray(value))
134
+ return value.map(formatCell).filter(Boolean).join(", ");
135
+ if (isRecord(value)) {
136
+ return Object.entries(value)
137
+ .filter(([, item]) => item !== null && item !== undefined && item !== "")
138
+ .map(([key, item]) => `${key}=${formatCell(item)}`)
139
+ .join(" ");
140
+ }
141
+ return String(value);
60
142
  }
61
143
  function isRecord(value) {
62
144
  return typeof value === "object" && value !== null && !Array.isArray(value);
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,25 +10,30 @@ const ACTIONS = {
8
10
  skills: new Set(["generate", "add"]),
9
11
  };
10
12
  export function createProgram(meta) {
11
- return new Command()
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
- .addHelpCommand(true)
17
+ .helpOption("-h, --help", "Show help")
18
+ .helpCommand("help [command]", "Show help for a command")
17
19
  .showSuggestionAfterError()
18
20
  .exitOverride()
19
- .configureOutput({ outputError: () => undefined })
21
+ .configureHelp(styledHelp())
20
22
  .option("--json", "Emit JSON")
21
23
  .option("--yaml", "Emit YAML")
22
24
  .option("--table", "Emit a human-readable table")
23
25
  .option("--fields <fields>", "Select top-level fields")
24
26
  .option("-o, --output <file>", "Write output to a file")
25
- .option("-q, --quiet", "Suppress non-essential diagnostics")
26
27
  .option("--verbose", "Enable debug logging")
27
28
  .option("--no-color", "Disable color output")
28
29
  .option("-y, --yes", "Confirm mutations non-interactively")
29
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;
30
37
  }
31
38
  export function mutating(command) {
32
39
  mutations.add(command);
@@ -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.1.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": ">=18"
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
  },