clap-ts 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Subcommands discovered from installed packages, the way git, eslint and kubectl
3
+ * grow: drop `my-tool-plugin-deploy` next to the CLI and `my-tool deploy` works.
4
+ *
5
+ * ```ts
6
+ * import { pluginSubCommands } from 'clap-ts/plugins';
7
+ *
8
+ * const main = defineCommand({
9
+ * meta: { name: 'my-tool' },
10
+ * lazySubCommands: pluginSubCommands('my-tool'),
11
+ * });
12
+ * ```
13
+ *
14
+ * Pairs with `lazySubCommands`: discovery is a directory scan and each plugin is
15
+ * a module import, so neither happens until a token could be a subcommand.
16
+ */
17
+ import type { CommandDef } from './types.js';
18
+ /** A plugin package found on disk, before it has been loaded. */
19
+ export interface DiscoveredPlugin {
20
+ /** Subcommand name, the part after the prefix. */
21
+ readonly name: string;
22
+ /** Full package name. */
23
+ readonly packageName: string;
24
+ /** Directory the package lives in. */
25
+ readonly dir: string;
26
+ }
27
+ export interface PluginOptions {
28
+ /**
29
+ * Package name prefix. Defaults to `<tool>-plugin-`, so `my-tool-plugin-deploy`
30
+ * becomes the `deploy` subcommand.
31
+ */
32
+ readonly prefix?: string;
33
+ /** Directories to scan. Defaults to every `node_modules` up from `cwd`. */
34
+ readonly searchPaths?: readonly string[];
35
+ /** Where to start the walk (default `process.cwd()`). */
36
+ readonly cwd?: string;
37
+ /** Load a package and return its command. Defaults to a dynamic import of the package. */
38
+ readonly load?: (plugin: DiscoveredPlugin) => CommandDef<any> | undefined;
39
+ /** Called when a plugin fails to load. Defaults to rethrowing. */
40
+ readonly onError?: (plugin: DiscoveredPlugin, error: unknown) => void;
41
+ }
42
+ /** Every `node_modules` directory from `cwd` up to the filesystem root. */
43
+ export declare function nodeModulesPaths(cwd?: string): string[];
44
+ /**
45
+ * Find plugin packages without loading any of them.
46
+ *
47
+ * Scoped packages are handled: `@acme/my-tool-plugin-deploy` is found under its
48
+ * scope directory and reported as `deploy`. The nearest copy of a package wins,
49
+ * matching how resolution works.
50
+ */
51
+ export declare function discoverPlugins(tool: string, opts?: PluginOptions): DiscoveredPlugin[];
52
+ /**
53
+ * A thunk for `lazySubCommands` that discovers and loads plugin packages.
54
+ *
55
+ * Nothing is scanned or imported until the thunk runs, which the parser only
56
+ * does when a token could be a subcommand.
57
+ */
58
+ export declare function pluginSubCommands(tool: string, opts?: PluginOptions): () => Record<string, CommandDef<any>>;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Subcommands discovered from installed packages, the way git, eslint and kubectl
3
+ * grow: drop `my-tool-plugin-deploy` next to the CLI and `my-tool deploy` works.
4
+ *
5
+ * ```ts
6
+ * import { pluginSubCommands } from 'clap-ts/plugins';
7
+ *
8
+ * const main = defineCommand({
9
+ * meta: { name: 'my-tool' },
10
+ * lazySubCommands: pluginSubCommands('my-tool'),
11
+ * });
12
+ * ```
13
+ *
14
+ * Pairs with `lazySubCommands`: discovery is a directory scan and each plugin is
15
+ * a module import, so neither happens until a token could be a subcommand.
16
+ */
17
+ import { readdirSync, existsSync, readFileSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { createRequire } from 'node:module';
20
+ /** Every `node_modules` directory from `cwd` up to the filesystem root. */
21
+ export function nodeModulesPaths(cwd = process.cwd()) {
22
+ const paths = [];
23
+ let dir = cwd;
24
+ for (;;) {
25
+ if (!dir.endsWith(`${'/'}node_modules`)) {
26
+ paths.push(join(dir, 'node_modules'));
27
+ }
28
+ const parent = join(dir, '..');
29
+ if (parent === dir) {
30
+ return paths;
31
+ }
32
+ dir = parent;
33
+ }
34
+ }
35
+ /**
36
+ * Find plugin packages without loading any of them.
37
+ *
38
+ * Scoped packages are handled: `@acme/my-tool-plugin-deploy` is found under its
39
+ * scope directory and reported as `deploy`. The nearest copy of a package wins,
40
+ * matching how resolution works.
41
+ */
42
+ export function discoverPlugins(tool, opts) {
43
+ const prefix = opts?.prefix ?? `${tool}-plugin-`;
44
+ const roots = opts?.searchPaths ?? nodeModulesPaths(opts?.cwd);
45
+ const found = new Map();
46
+ const consider = (packageName, dir) => {
47
+ const base = packageName.includes('/') ? packageName.slice(packageName.indexOf('/') + 1) : packageName;
48
+ if (!base.startsWith(prefix) || base.length === prefix.length) {
49
+ return;
50
+ }
51
+ const name = base.slice(prefix.length);
52
+ // The first root wins, so a local copy shadows one further up.
53
+ if (!found.has(name)) {
54
+ found.set(name, { name, packageName, dir });
55
+ }
56
+ };
57
+ for (const root of roots) {
58
+ let entries;
59
+ try {
60
+ entries = readdirSync(root);
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ for (const entry of entries) {
66
+ if (entry.startsWith('.')) {
67
+ continue;
68
+ }
69
+ if (entry.startsWith('@')) {
70
+ let scoped;
71
+ try {
72
+ scoped = readdirSync(join(root, entry));
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ for (const inner of scoped) {
78
+ consider(`${entry}/${inner}`, join(root, entry, inner));
79
+ }
80
+ continue;
81
+ }
82
+ consider(entry, join(root, entry));
83
+ }
84
+ }
85
+ return [...found.values()].sort((a, b) => a.name.localeCompare(b.name));
86
+ }
87
+ /** Read a discovered package's own name and description, for help before it loads. */
88
+ function packageMeta(plugin) {
89
+ const pkgPath = join(plugin.dir, 'package.json');
90
+ if (!existsSync(pkgPath)) {
91
+ return {};
92
+ }
93
+ try {
94
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
95
+ return pkg.description === undefined ? {} : { description: pkg.description };
96
+ }
97
+ catch {
98
+ return {};
99
+ }
100
+ }
101
+ function defaultLoad(plugin) {
102
+ const require = createRequire(join(plugin.dir, 'package.json'));
103
+ const loaded = require(plugin.packageName);
104
+ if ('meta' in loaded && loaded.meta !== undefined) {
105
+ return loaded;
106
+ }
107
+ const mod = loaded;
108
+ return mod.default ?? mod.command;
109
+ }
110
+ /**
111
+ * A thunk for `lazySubCommands` that discovers and loads plugin packages.
112
+ *
113
+ * Nothing is scanned or imported until the thunk runs, which the parser only
114
+ * does when a token could be a subcommand.
115
+ */
116
+ export function pluginSubCommands(tool, opts) {
117
+ return () => {
118
+ const load = opts?.load ?? defaultLoad;
119
+ const commands = {};
120
+ for (const plugin of discoverPlugins(tool, opts)) {
121
+ let command;
122
+ try {
123
+ command = load(plugin);
124
+ }
125
+ catch (error) {
126
+ if (opts?.onError === undefined) {
127
+ throw new Error(`plugin '${plugin.packageName}' failed to load: ${error instanceof Error ? error.message : String(error)}`);
128
+ }
129
+ opts.onError(plugin, error);
130
+ continue;
131
+ }
132
+ if (command === undefined) {
133
+ continue;
134
+ }
135
+ // Fall back to the package description so help says something useful
136
+ // even when the plugin did not set one.
137
+ const meta = command.meta.description === undefined ? packageMeta(plugin) : {};
138
+ commands[plugin.name] = {
139
+ ...command,
140
+ meta: { ...command.meta, name: plugin.name, ...meta },
141
+ };
142
+ }
143
+ return commands;
144
+ };
145
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Spinners and progress bars that know when nobody is watching.
3
+ *
4
+ * ```ts
5
+ * import { spinner, progressBar } from 'clap-ts/progress';
6
+ *
7
+ * const spin = spinner('Fetching');
8
+ * spin.start();
9
+ * await work();
10
+ * spin.succeed('Fetched 12 items');
11
+ * ```
12
+ *
13
+ * Both write to stderr and both go quiet when it is not a terminal, so piping a
14
+ * command's output never fills a log file with redraw escapes. `NO_COLOR` and
15
+ * `CI` are honoured the same way.
16
+ */
17
+ import type { OutputSink } from './types.js';
18
+ export interface ProgressOptions {
19
+ /** Where to draw (default process.stderr). */
20
+ readonly sink?: OutputSink;
21
+ /**
22
+ * Draw at all. Defaults to true only when the sink is a terminal and CI is
23
+ * unset, so redirected output stays clean.
24
+ */
25
+ readonly enabled?: boolean;
26
+ /** Use ASCII frames instead of braille. */
27
+ readonly ascii?: boolean;
28
+ /** Milliseconds between spinner frames (default 80). */
29
+ readonly interval?: number;
30
+ /** Force colour on or off. */
31
+ readonly color?: boolean;
32
+ /**
33
+ * Shortest gap between redraws, in milliseconds (default 16, about 60 a
34
+ * second). A loop that updates thousands of times a second would otherwise
35
+ * spend its time writing escape sequences nobody can read.
36
+ */
37
+ readonly throttle?: number;
38
+ }
39
+ export interface Spinner {
40
+ /** Begin animating. Safe to call twice. */
41
+ start(text?: string): Spinner;
42
+ /** Change the message without interrupting the animation. */
43
+ update(text: string): Spinner;
44
+ /** Stop and leave a green tick. */
45
+ succeed(text?: string): Spinner;
46
+ /** Stop and leave a red cross. */
47
+ fail(text?: string): Spinner;
48
+ /** Stop and leave a yellow warning mark. */
49
+ warn(text?: string): Spinner;
50
+ /** Stop and erase, leaving nothing behind. */
51
+ stop(): Spinner;
52
+ /** Whether this spinner draws anything at all. */
53
+ readonly enabled: boolean;
54
+ }
55
+ /**
56
+ * A spinner for work of unknown length.
57
+ *
58
+ * When drawing is off the terminating calls still print their message once, so
59
+ * a piped run reports what happened without any animation.
60
+ */
61
+ export declare function spinner(initialText?: string, opts?: ProgressOptions): Spinner;
62
+ export interface ProgressBar {
63
+ /** Move to an absolute position. */
64
+ update(current: number, text?: string): ProgressBar;
65
+ /** Move forward by an amount (default 1). */
66
+ tick(by?: number, text?: string): ProgressBar;
67
+ /** Fill the bar and finish the line. */
68
+ finish(text?: string): ProgressBar;
69
+ /** Abandon the bar, erasing it. */
70
+ stop(): ProgressBar;
71
+ /** Render the current line without drawing it, for tests. */
72
+ render(): string;
73
+ readonly enabled: boolean;
74
+ }
75
+ export interface ProgressBarOptions extends ProgressOptions {
76
+ /** Total the bar counts up to. */
77
+ readonly total: number;
78
+ /** Bar width in characters (default: a third of the terminal, 20 to 40). */
79
+ readonly width?: number;
80
+ /** Label shown before the bar. */
81
+ readonly text?: string;
82
+ }
83
+ /**
84
+ * A determinate progress bar.
85
+ *
86
+ * `render` returns the line it would draw, which is what makes this testable
87
+ * without a terminal.
88
+ */
89
+ export declare function progressBar(opts: ProgressBarOptions): ProgressBar;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Spinners and progress bars that know when nobody is watching.
3
+ *
4
+ * ```ts
5
+ * import { spinner, progressBar } from 'clap-ts/progress';
6
+ *
7
+ * const spin = spinner('Fetching');
8
+ * spin.start();
9
+ * await work();
10
+ * spin.succeed('Fetched 12 items');
11
+ * ```
12
+ *
13
+ * Both write to stderr and both go quiet when it is not a terminal, so piping a
14
+ * command's output never fills a log file with redraw escapes. `NO_COLOR` and
15
+ * `CI` are honoured the same way.
16
+ */
17
+ import { styleText } from 'node:util';
18
+ import { displayWidth, truncate } from './output.js';
19
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
20
+ const ASCII_FRAMES = ['-', '\\', '|', '/'];
21
+ const HIDE_CURSOR = '\x1b[?25l';
22
+ const SHOW_CURSOR = '\x1b[?25h';
23
+ const CLEAR_LINE = '\r\x1b[2K';
24
+ /** Columns available to draw into. */
25
+ function widthOf(sink) {
26
+ return typeof sink.columns === 'number' && sink.columns > 0 ? sink.columns : 80;
27
+ }
28
+ function resolveSink(opts) {
29
+ return (opts?.sink ?? process.stderr);
30
+ }
31
+ function shouldDraw(sink, opts) {
32
+ if (opts?.enabled !== undefined) {
33
+ return opts.enabled;
34
+ }
35
+ // A pipe, a file or a CI log gets no redraws.
36
+ return sink.isTTY === true && process.env['CI'] === undefined;
37
+ }
38
+ function paint(color, codes, text) {
39
+ return color ? styleText(codes, text, { validateStream: false }) : text;
40
+ }
41
+ /**
42
+ * A spinner for work of unknown length.
43
+ *
44
+ * When drawing is off the terminating calls still print their message once, so
45
+ * a piped run reports what happened without any animation.
46
+ */
47
+ export function spinner(initialText = '', opts) {
48
+ const sink = resolveSink(opts);
49
+ const enabled = shouldDraw(sink, opts);
50
+ const frames = opts?.ascii === true ? ASCII_FRAMES : FRAMES;
51
+ const interval = opts?.interval ?? 80;
52
+ const color = opts?.color ?? styleText('red', 'x') !== 'x';
53
+ let text = initialText;
54
+ let frame = 0;
55
+ let timer;
56
+ let cursorHidden = false;
57
+ const restoreCursor = () => {
58
+ if (cursorHidden) {
59
+ sink.write(SHOW_CURSOR);
60
+ cursorHidden = false;
61
+ }
62
+ };
63
+ const clear = () => {
64
+ sink.write(CLEAR_LINE);
65
+ };
66
+ const draw = () => {
67
+ clear();
68
+ // Two columns for the frame and its space; anything longer wraps, and a
69
+ // wrapped line cannot be erased by a single carriage return.
70
+ const room = widthOf(sink) - 2;
71
+ sink.write(`${paint(color, 'cyan', frames[frame])} ${truncate(text, room)}`);
72
+ frame = (frame + 1) % frames.length;
73
+ };
74
+ const finish = (mark, codes, done) => {
75
+ if (timer !== undefined) {
76
+ clearInterval(timer);
77
+ timer = undefined;
78
+ }
79
+ const message = done ?? text;
80
+ if (enabled) {
81
+ clear();
82
+ restoreCursor();
83
+ }
84
+ if (message.length > 0) {
85
+ sink.write(`${paint(color, codes, mark)} ${message}\n`);
86
+ }
87
+ return api;
88
+ };
89
+ const api = {
90
+ enabled,
91
+ start(next) {
92
+ if (next !== undefined) {
93
+ text = next;
94
+ }
95
+ if (!enabled || timer !== undefined) {
96
+ return api;
97
+ }
98
+ // A visible cursor flickers over the animating frame.
99
+ sink.write(HIDE_CURSOR);
100
+ cursorHidden = true;
101
+ draw();
102
+ timer = setInterval(draw, interval);
103
+ // Never hold the event loop open for a spinner.
104
+ timer.unref?.();
105
+ return api;
106
+ },
107
+ update(next) {
108
+ text = next;
109
+ if (enabled && timer !== undefined) {
110
+ draw();
111
+ }
112
+ return api;
113
+ },
114
+ succeed: (done) => finish('✔', 'green', done),
115
+ fail: (done) => finish('✖', 'red', done),
116
+ warn: (done) => finish('⚠', 'yellow', done),
117
+ stop() {
118
+ if (timer !== undefined) {
119
+ clearInterval(timer);
120
+ timer = undefined;
121
+ }
122
+ if (enabled) {
123
+ clear();
124
+ restoreCursor();
125
+ }
126
+ return api;
127
+ },
128
+ };
129
+ return api;
130
+ }
131
+ /**
132
+ * A determinate progress bar.
133
+ *
134
+ * `render` returns the line it would draw, which is what makes this testable
135
+ * without a terminal.
136
+ */
137
+ export function progressBar(opts) {
138
+ const sink = resolveSink(opts);
139
+ const enabled = shouldDraw(sink, opts);
140
+ const color = opts.color ?? styleText('red', 'x') !== 'x';
141
+ const total = Math.max(1, opts.total);
142
+ const columns = widthOf(sink);
143
+ const width = opts.width ?? Math.min(40, Math.max(20, Math.floor(columns / 3)));
144
+ const throttle = opts.throttle ?? 16;
145
+ let current = 0;
146
+ let text = opts.text ?? '';
147
+ let lastDraw = 0;
148
+ const render = () => {
149
+ const ratio = Math.min(1, current / total);
150
+ const filled = Math.round(ratio * width);
151
+ const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
152
+ const percent = `${String(Math.round(ratio * 100)).padStart(3)}%`;
153
+ const counts = `${String(current)}/${String(total)}`;
154
+ const line = `${bar} ${percent} ${counts}${text.length > 0 ? ` ${text}` : ''}`;
155
+ // Fit the terminal: a line that wraps cannot be erased by a carriage
156
+ // return, so the next redraw would stack instead of replacing.
157
+ const fitted = displayWidth(line) > columns ? truncate(line, columns) : line;
158
+ return color ? fitted.replace(bar, paint(color, 'cyan', bar)) : fitted;
159
+ };
160
+ const draw = (force) => {
161
+ const now = Date.now();
162
+ // The final frame always lands; the ones in between can be skipped.
163
+ if (!force && throttle > 0 && now - lastDraw < throttle) {
164
+ return;
165
+ }
166
+ lastDraw = now;
167
+ sink.write(`${CLEAR_LINE}${render()}`);
168
+ };
169
+ const api = {
170
+ enabled,
171
+ render,
172
+ update(next, nextText) {
173
+ current = Math.max(0, Math.min(total, next));
174
+ if (nextText !== undefined) {
175
+ text = nextText;
176
+ }
177
+ if (enabled) {
178
+ draw(false);
179
+ }
180
+ return api;
181
+ },
182
+ tick: (by = 1, nextText) => api.update(current + by, nextText),
183
+ finish(done) {
184
+ current = total;
185
+ if (done !== undefined) {
186
+ text = done;
187
+ }
188
+ if (enabled) {
189
+ draw(true);
190
+ sink.write('\n');
191
+ }
192
+ else if (text.length > 0) {
193
+ sink.write(`${text}\n`);
194
+ }
195
+ return api;
196
+ },
197
+ stop() {
198
+ if (enabled) {
199
+ sink.write(CLEAR_LINE);
200
+ }
201
+ return api;
202
+ },
203
+ };
204
+ return api;
205
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Interactive prompts, derived from the argument definitions a command already
3
+ * has.
4
+ *
5
+ * ```ts
6
+ * import { promptMissing } from 'clap-ts/prompt';
7
+ *
8
+ * await runMain(main, { fillMissing: promptMissing() });
9
+ * ```
10
+ *
11
+ * A required argument nobody supplied is asked for rather than rejected: a
12
+ * boolean becomes a confirm, an argument with possible values becomes a
13
+ * numbered list, and one marked `secret` is read without echoing. `ctx
14
+ * .valueSources` reports those as `'prompt'`.
15
+ *
16
+ * Nothing prompts when stdin is not a terminal, so a script or a CI job still
17
+ * fails fast with the usual error instead of hanging on input that will never
18
+ * arrive.
19
+ */
20
+ import type { ArgDef, CommandDef, MissingArg, PossibleValue } from './types.js';
21
+ /** Somewhere to ask questions. The default talks to the terminal. */
22
+ export interface PromptIO {
23
+ /** Show `text` and resolve with the reply. */
24
+ question(text: string): Promise<string>;
25
+ /** As `question`, but the reply is not echoed. */
26
+ secret(text: string): Promise<string>;
27
+ /** Write to the prompt's output, for lists and errors. */
28
+ write(text: string): void;
29
+ /** Release the terminal. */
30
+ close(): void;
31
+ /** Whether anyone is there to answer. */
32
+ readonly interactive: boolean;
33
+ }
34
+ /**
35
+ * The PromptIO backed by the real terminal.
36
+ *
37
+ * Shared, because a readline interface takes ownership of stdin: a second one
38
+ * finds the stream already consumed, so two prompts in a row would fail with
39
+ * the input reported as ended. `close()` releases it, and the next call builds
40
+ * a fresh one.
41
+ */
42
+ export declare function terminalIO(): PromptIO;
43
+ /** A PromptIO that answers from a list, for tests. */
44
+ export declare function scriptedIO(answers: readonly string[]): PromptIO & {
45
+ output: string;
46
+ };
47
+ /** Common to every prompt. */
48
+ export interface AskOptions {
49
+ /** Where to ask (default: the terminal). */
50
+ readonly io?: PromptIO;
51
+ }
52
+ /** A prompt that can offer a text default when the reply is empty. */
53
+ export interface TextAskOptions extends AskOptions {
54
+ readonly defaultValue?: string;
55
+ }
56
+ /** A confirm, whose default is a boolean rather than text. */
57
+ export interface ConfirmOptions extends AskOptions {
58
+ readonly defaultValue?: boolean;
59
+ }
60
+ /** Ask for a line of text, returning the default when the reply is empty. */
61
+ export declare function input(message: string, opts?: TextAskOptions): Promise<string>;
62
+ /** Ask for a secret, without echoing it. */
63
+ export declare function password(message: string, opts?: AskOptions): Promise<string>;
64
+ /** Ask a yes or no question. */
65
+ export declare function confirm(message: string, opts?: ConfirmOptions): Promise<boolean>;
66
+ /**
67
+ * Offer a numbered list and take an index or the value itself.
68
+ *
69
+ * A numbered list rather than an arrow-key menu, so it behaves the same over
70
+ * ssh, in a dumb terminal and under a test.
71
+ */
72
+ export declare function select(message: string, choices: readonly (string | PossibleValue)[], opts?: TextAskOptions): Promise<string>;
73
+ /** Offer a numbered list and take several answers, separated by commas. */
74
+ export declare function multiselect(message: string, choices: readonly (string | PossibleValue)[], opts?: AskOptions): Promise<string[]>;
75
+ /**
76
+ * Ask for one argument, choosing the prompt from its definition.
77
+ *
78
+ * A boolean is a confirm, an argument with possible values is a list, one
79
+ * marked `secret` is not echoed, and everything else is a line of text. The
80
+ * result is validated against the argument's own parser before being accepted.
81
+ */
82
+ export declare function promptForArg(key: string, def: ArgDef, opts?: AskOptions): Promise<string | boolean | string[]>;
83
+ export interface PromptMissingOptions {
84
+ /** Where to ask (default: the terminal). */
85
+ readonly io?: PromptIO;
86
+ /**
87
+ * Ask even when stdin is not a terminal. Off by default, so a script fails
88
+ * with the usual error rather than waiting for input that never comes.
89
+ */
90
+ readonly force?: boolean;
91
+ }
92
+ /**
93
+ * A `fillMissing` hook that asks for each required argument still empty.
94
+ *
95
+ * ```ts
96
+ * await runMain(main, { fillMissing: promptMissing() });
97
+ * ```
98
+ */
99
+ export declare function promptMissing(opts?: PromptMissingOptions): (missing: readonly MissingArg[], command: CommandDef<any>) => Promise<Record<string, unknown> | undefined>;