get-tbd 0.1.21 → 0.1.23
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 +17 -19
- package/dist/bin.mjs +181 -12
- package/dist/bin.mjs.map +1 -1
- package/dist/cli.mjs +110 -644
- package/dist/cli.mjs.map +1 -1
- package/dist/config-CB1tcqTZ.mjs +3 -0
- package/dist/config-CmEAGaxz.mjs +637 -0
- package/dist/config-CmEAGaxz.mjs.map +1 -0
- package/dist/docs/README.md +17 -19
- package/dist/docs/guidelines/bun-monorepo-patterns.md +816 -80
- package/dist/docs/guidelines/pnpm-monorepo-patterns.md +586 -16
- package/dist/docs/guidelines/python-cli-patterns.md +2 -2
- package/dist/docs/guidelines/tbd-sync-troubleshooting.md +27 -0
- package/dist/docs/guidelines/typescript-cli-tool-rules.md +465 -196
- package/dist/docs/tbd-design.md +86 -46
- package/dist/docs/tbd-docs.md +0 -6
- package/dist/id-mapping-0-R0X8zb.mjs +3 -0
- package/dist/{id-mapping-CD5c_ZVA.mjs → id-mapping-JGow6Jk4.mjs} +57 -3
- package/dist/{id-mapping-CD5c_ZVA.mjs.map → id-mapping-JGow6Jk4.mjs.map} +1 -1
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +2 -2
- package/dist/{src-BjMRpmMh.mjs → src-7qUDeWJf.mjs} +3 -3
- package/dist/{src-BjMRpmMh.mjs.map → src-7qUDeWJf.mjs.map} +1 -1
- package/dist/tbd +181 -12
- package/dist/{yaml-utils-x_kr2IId.mjs → yaml-utils-U7l9hhkh.mjs} +7 -1
- package/dist/yaml-utils-U7l9hhkh.mjs.map +1 -0
- package/package.json +4 -4
- package/dist/id-mapping-BqSnxlxk.mjs +0 -3
- package/dist/yaml-utils-x_kr2IId.mjs.map +0 -1
|
@@ -6,6 +6,8 @@ author: Joshua Levy (github.com/jlevy) with LLM assistance
|
|
|
6
6
|
# CLI Tool Development Rules
|
|
7
7
|
|
|
8
8
|
These rules apply to all CLI tools, command-line scripts, and terminal utilities.
|
|
9
|
+
Examples may be inspired by modern TypeScript repos, but guidance here is intentionally
|
|
10
|
+
generic and reusable across projects.
|
|
9
11
|
|
|
10
12
|
## Color and Output Formatting
|
|
11
13
|
|
|
@@ -18,56 +20,74 @@ These rules apply to all CLI tools, command-line scripts, and terminal utilities
|
|
|
18
20
|
import pc from 'picocolors';
|
|
19
21
|
console.log(pc.green('Success!'));
|
|
20
22
|
console.log(pc.cyan('Info message'));
|
|
21
|
-
|
|
23
|
+
|
|
22
24
|
// BAD: Hardcoded ANSI codes
|
|
23
25
|
console.log('\x1b[32mSuccess!\x1b[0m');
|
|
24
26
|
console.log('\x1b[36mInfo message\x1b[0m');
|
|
25
27
|
```
|
|
26
28
|
|
|
27
|
-
- **Use
|
|
28
|
-
|
|
29
|
+
- **Use `pc.createColors()` for explicit color control:** When you need to honor a
|
|
30
|
+
`--color` option or disable colors programmatically, use picocolors’ `createColors()`
|
|
31
|
+
factory. This overrides picocolors’ automatic TTY detection and is the recommended
|
|
32
|
+
approach per picocolors documentation.
|
|
29
33
|
|
|
30
34
|
```ts
|
|
31
|
-
// lib/cliFormatting.ts - shared color utilities
|
|
32
35
|
import pc from 'picocolors';
|
|
33
|
-
|
|
34
|
-
export const colors = {
|
|
35
|
-
success: (s: string) => pc.green(s),
|
|
36
|
-
error: (s: string) => pc.red(s),
|
|
37
|
-
info: (s: string) => pc.cyan(s),
|
|
38
|
-
warn: (s: string) => pc.yellow(s),
|
|
39
|
-
muted: (s: string) => pc.gray(s),
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// Usage in commands:
|
|
43
|
-
import { colors } from '../lib/cliFormatting.js';
|
|
44
|
-
console.log(colors.success('Operation completed'));
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
- **Trust picocolors TTY detection:** Picocolors automatically detects when stdout is
|
|
48
|
-
not a TTY (e.g., piped to `cat` or redirected to a file) and disables colors.
|
|
49
|
-
DO NOT manually check `process.stdout.isTTY` unless you need special non-color
|
|
50
|
-
behavior.
|
|
51
36
|
|
|
52
|
-
|
|
37
|
+
// Create a color instance with explicit enable/disable control
|
|
38
|
+
const colors = pc.createColors(shouldColorize(colorOption));
|
|
53
39
|
|
|
54
|
-
|
|
40
|
+
// Now use it — colors are no-ops when disabled
|
|
41
|
+
console.log(colors.green('Success'));
|
|
42
|
+
console.log(colors.dim('Debug info'));
|
|
43
|
+
```
|
|
55
44
|
|
|
56
|
-
|
|
45
|
+
- **Use shared color utilities with semantic names:** Create a `createColors()` factory
|
|
46
|
+
that returns semantic color functions.
|
|
47
|
+
The `OutputManager` carries this and exposes it via `getColors()`.
|
|
57
48
|
|
|
58
|
-
|
|
49
|
+
```ts
|
|
50
|
+
// cli/lib/output.ts - shared color factory
|
|
51
|
+
import pc from 'picocolors';
|
|
52
|
+
import type { ColorOption } from './context.js';
|
|
53
|
+
import { shouldColorize } from './context.js';
|
|
54
|
+
|
|
55
|
+
export function createColors(colorOption: ColorOption) {
|
|
56
|
+
const enabled = shouldColorize(colorOption);
|
|
57
|
+
const colors = pc.createColors(enabled);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
// Status colors
|
|
61
|
+
success: colors.green,
|
|
62
|
+
error: colors.red,
|
|
63
|
+
warn: colors.yellow,
|
|
64
|
+
info: colors.blue,
|
|
65
|
+
// Text formatting
|
|
66
|
+
bold: colors.bold,
|
|
67
|
+
dim: colors.dim,
|
|
68
|
+
italic: colors.italic,
|
|
69
|
+
underline: colors.underline,
|
|
70
|
+
// Semantic colors
|
|
71
|
+
id: colors.cyan,
|
|
72
|
+
label: colors.magenta,
|
|
73
|
+
path: colors.blue,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
```
|
|
59
77
|
|
|
60
|
-
|
|
78
|
+
- **Respect `NO_COLOR`, `FORCE_COLOR`, and `--color` option:** Support a
|
|
79
|
+
`--color <when>` option with values `auto`, `always`, `never`, and define a clear
|
|
80
|
+
precedence order:
|
|
81
|
+
1. explicit `--color` flag, 2) `NO_COLOR`, 3) `FORCE_COLOR`, 4) TTY auto-detection.
|
|
61
82
|
|
|
62
83
|
```ts
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
console.log(msg);
|
|
84
|
+
export function shouldColorize(colorOption: ColorOption): boolean {
|
|
85
|
+
if (colorOption === 'always') return true;
|
|
86
|
+
if (colorOption === 'never') return false;
|
|
87
|
+
if (process.env.NO_COLOR) return false;
|
|
88
|
+
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') return true;
|
|
89
|
+
return process.stdout.isTTY === true;
|
|
90
|
+
}
|
|
71
91
|
```
|
|
72
92
|
|
|
73
93
|
## Commander.js Patterns
|
|
@@ -75,164 +95,411 @@ These rules apply to all CLI tools, command-line scripts, and terminal utilities
|
|
|
75
95
|
- **Use Commander.js for all CLI tools:** Import from `commander` and follow established
|
|
76
96
|
patterns for command registration and option handling.
|
|
77
97
|
|
|
78
|
-
- **Apply colored help
|
|
79
|
-
|
|
98
|
+
- **Apply colored help globally, not per-command:** Use Commander v14+ `configureHelp()`
|
|
99
|
+
with style functions, applied recursively to all commands at program initialization.
|
|
80
100
|
|
|
81
101
|
```ts
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
// cli/lib/output.ts
|
|
103
|
+
export function createColoredHelpConfig(colorOption: ColorOption = 'auto') {
|
|
104
|
+
const colors = pc.createColors(shouldColorize(colorOption));
|
|
105
|
+
return {
|
|
106
|
+
helpWidth: getTerminalWidth(),
|
|
107
|
+
styleTitle: (str: string) => colors.bold(colors.cyan(str)),
|
|
108
|
+
styleCommandText: (str: string) => colors.green(str),
|
|
109
|
+
styleOptionText: (str: string) => colors.yellow(str),
|
|
110
|
+
showGlobalOptions: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// cli/cli.ts - apply once at startup
|
|
115
|
+
configureColoredHelp(program);
|
|
116
|
+
// After all commands added:
|
|
117
|
+
applyColoredHelpToAllCommands(program);
|
|
90
118
|
```
|
|
91
119
|
|
|
92
|
-
- **
|
|
93
|
-
|
|
120
|
+
- **Define global options at the program level:** Common global options include
|
|
121
|
+
`--dry-run`, `--verbose`, `--quiet`, `--json`, `--color`, and `--debug`. Only add
|
|
122
|
+
`--non-interactive` and `--yes` if your CLI actually has interactive prompts or
|
|
123
|
+
confirmations. Add domain-specific flags only when they apply.
|
|
94
124
|
|
|
95
125
|
```ts
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
logDryRun('Would perform action', { details: 'here' });
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Actual implementation
|
|
108
|
-
});
|
|
126
|
+
program
|
|
127
|
+
.option('--dry-run', 'Show what would be done without making changes')
|
|
128
|
+
.option('--verbose', 'Enable verbose output')
|
|
129
|
+
.option('--quiet', 'Suppress non-essential output')
|
|
130
|
+
.option('--json', 'Output as JSON')
|
|
131
|
+
.option('--color <when>', 'Colorize output: auto, always, never', 'auto')
|
|
132
|
+
.option('--debug', 'Enable debug diagnostics');
|
|
109
133
|
```
|
|
110
134
|
|
|
111
|
-
- **
|
|
112
|
-
|
|
113
|
-
|
|
135
|
+
- **Validate enum-like options explicitly:** For options with fixed values, use
|
|
136
|
+
Commander validation (`Option(...).choices(...)`) so invalid input fails fast.
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { Option } from 'commander';
|
|
140
|
+
|
|
141
|
+
program.addOption(
|
|
142
|
+
new Option('--color <when>', 'Colorize output: auto, always, never')
|
|
143
|
+
.choices(['auto', 'always', 'never'])
|
|
144
|
+
.default('auto'),
|
|
145
|
+
);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- **Access global options via `getCommandContext()`:** Extract a typed `CommandContext`
|
|
149
|
+
from Commander’s `optsWithGlobals()`. Use this in the `BaseCommand` constructor (see
|
|
150
|
+
below), not directly in action handlers.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
export function getCommandContext(command: Command): CommandContext {
|
|
154
|
+
const opts = command.optsWithGlobals();
|
|
155
|
+
return {
|
|
156
|
+
dryRun: opts.dryRun ?? false,
|
|
157
|
+
verbose: opts.verbose ?? false,
|
|
158
|
+
quiet: opts.quiet ?? false,
|
|
159
|
+
json: opts.json ?? false,
|
|
160
|
+
color: (opts.color as ColorOption) ?? 'auto',
|
|
161
|
+
debug: opts.debug ?? false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
```
|
|
114
165
|
|
|
115
166
|
- **Handle negated boolean flags (`--no-X`) correctly:** Commander.js sets
|
|
116
|
-
`options.X = false`, NOT `options.noX = true`. This is a common gotcha
|
|
167
|
+
`options.X = false`, NOT `options.noX = true`. This is a common gotcha that causes
|
|
168
|
+
silent bugs where the flag has no effect.
|
|
117
169
|
|
|
118
170
|
```ts
|
|
119
|
-
// WRONG: options.noBrowser is ALWAYS undefined
|
|
171
|
+
// WRONG: options.noBrowser is ALWAYS undefined — the flag silently does nothing!
|
|
120
172
|
program.option('--no-browser', 'Disable browser auto-open');
|
|
121
173
|
if (options.noBrowser) { /* Never executes! */ }
|
|
122
|
-
|
|
174
|
+
|
|
123
175
|
// CORRECT: Check the positive property name
|
|
124
176
|
program.option('--no-browser', 'Disable browser auto-open');
|
|
125
177
|
if (options.browser === false) { /* This works */ }
|
|
126
|
-
|
|
127
|
-
// Best practice:
|
|
128
|
-
|
|
129
|
-
if (browser) {
|
|
178
|
+
|
|
179
|
+
// Best practice: use !== false for clarity
|
|
180
|
+
if (options.browser !== false) {
|
|
130
181
|
await openBrowser(url);
|
|
131
182
|
}
|
|
132
183
|
```
|
|
133
184
|
|
|
134
|
-
|
|
185
|
+
When typing the options interface, use the *positive* property name:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
// WRONG:
|
|
189
|
+
interface MyOptions {
|
|
190
|
+
noBrowser?: boolean; // Commander never sets this
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// CORRECT:
|
|
194
|
+
interface MyOptions {
|
|
195
|
+
browser?: boolean; // Commander: --no-browser sets this to false (default: true)
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## BaseCommand Pattern
|
|
200
|
+
|
|
201
|
+
All CLI command handlers extend a shared `BaseCommand` class that provides consistent
|
|
202
|
+
context, output, and error handling.
|
|
135
203
|
|
|
136
|
-
- **
|
|
137
|
-
|
|
204
|
+
- **BaseCommand provides `CommandContext` + `OutputManager`:** Every command handler
|
|
205
|
+
gets typed context and a shared output manager in its constructor.
|
|
138
206
|
|
|
139
207
|
```ts
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
208
|
+
// cli/lib/base-command.ts
|
|
209
|
+
export abstract class BaseCommand {
|
|
210
|
+
protected ctx: CommandContext;
|
|
211
|
+
protected output: OutputManager;
|
|
212
|
+
|
|
213
|
+
constructor(command: Command) {
|
|
214
|
+
this.ctx = getCommandContext(command);
|
|
215
|
+
this.output = new OutputManager(this.ctx);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
protected async execute<T>(action: () => Promise<T>, errorMessage: string): Promise<T> {
|
|
219
|
+
try {
|
|
220
|
+
return await action();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (error instanceof CLIError) {
|
|
223
|
+
this.output.error(error.message);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
// Wrap with cause chain for debugging
|
|
227
|
+
const wrapped = new CLIError(errorMessage);
|
|
228
|
+
wrapped.cause = error instanceof Error ? error : undefined;
|
|
229
|
+
throw wrapped;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
protected checkDryRun(message: string, details?: object): boolean {
|
|
234
|
+
if (this.ctx.dryRun) {
|
|
235
|
+
this.output.dryRun(message, details);
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
abstract run(...args: unknown[]): Promise<void>;
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
- **Action handlers instantiate a handler class:** Commands create a handler and
|
|
246
|
+
delegate to its `run()` method.
|
|
247
|
+
This separates command definition from implementation.
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
export const myCommand = new Command('my-command')
|
|
251
|
+
.description('Description here')
|
|
252
|
+
.option('--some-flag', 'Flag description')
|
|
253
|
+
.action(async (options, command) => {
|
|
254
|
+
const handler = new MyHandler(command);
|
|
255
|
+
await handler.run(options);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
class MyHandler extends BaseCommand {
|
|
259
|
+
async run(options: MyOptions): Promise<void> {
|
|
260
|
+
if (this.checkDryRun('Would perform action')) return;
|
|
261
|
+
// Implementation using this.output and this.ctx
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## Output and Feedback
|
|
267
|
+
|
|
268
|
+
- **Use `OutputManager` for all output:** The `OutputManager` class handles format
|
|
269
|
+
switching (text vs JSON), verbosity levels, and stream separation.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// cli/lib/output.ts
|
|
273
|
+
class OutputManager {
|
|
274
|
+
// Structured data — stdout
|
|
275
|
+
data<T>(data: T, textFormatter?: (data: T) => void): void;
|
|
276
|
+
|
|
277
|
+
// Status messages — stdout (suppressed by --quiet, --json)
|
|
278
|
+
success(message: string): void; // ✓ Green
|
|
279
|
+
notice(message: string): void; // • Blue
|
|
280
|
+
|
|
281
|
+
// Diagnostics — stderr
|
|
282
|
+
info(message: string): void; // Dim (--verbose or --debug only)
|
|
283
|
+
warn(message: string): void; // ⚠ Yellow (suppressed by --quiet)
|
|
284
|
+
error(message: string): void; // ✗ Red (always shown)
|
|
285
|
+
command(cmd: string, args?: string[]): void; // > Dim (--verbose or --debug only)
|
|
286
|
+
debug(message: string): void; // [debug] Dim (--debug only)
|
|
287
|
+
|
|
288
|
+
// Dry-run — stdout
|
|
289
|
+
dryRun(message: string, details?: object): void;
|
|
290
|
+
|
|
291
|
+
// Tabular/list output — stdout
|
|
292
|
+
table(headers, rows): void;
|
|
293
|
+
list(items: string[]): void;
|
|
294
|
+
count(count: number, singular: string): void;
|
|
295
|
+
|
|
296
|
+
// Progress indication — stderr (suppressed in JSON/quiet/non-TTY)
|
|
297
|
+
spinner(message: string): Spinner;
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
- **Use standard icons from the ICONS constant:** Use Unicode characters, not emojis,
|
|
302
|
+
for status indicators.
|
|
303
|
+
These render consistently across terminals.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
export const ICONS = {
|
|
307
|
+
SUCCESS: '✓', // U+2713
|
|
308
|
+
ERROR: '✗', // U+2717
|
|
309
|
+
WARN: '⚠', // U+26A0
|
|
310
|
+
NOTICE: '•', // U+2022
|
|
311
|
+
OPEN: '○', // U+25CB
|
|
312
|
+
IN_PROGRESS: '◐', // U+25D0
|
|
313
|
+
BLOCKED: '●', // U+25CF
|
|
314
|
+
CLOSED: '✓', // U+2713
|
|
315
|
+
} as const;
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
- **Custom inline spinner (no external dependency):** The spinner uses Braille
|
|
319
|
+
characters, writes to stderr, and is automatically suppressed in JSON/quiet/non-TTY
|
|
320
|
+
modes.
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
spinner(message: string): Spinner {
|
|
324
|
+
if (this.ctx.json || this.ctx.quiet || !process.stderr.isTTY) {
|
|
325
|
+
return noopSpinner;
|
|
326
|
+
}
|
|
327
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
328
|
+
// ... interval-based animation on stderr
|
|
329
|
+
}
|
|
150
330
|
```
|
|
151
331
|
|
|
152
|
-
- **Use
|
|
332
|
+
- **Use `OperationLogger` to bridge CLI and core layers:** Core logic (domain/service
|
|
333
|
+
layers) should not depend on CLI output code.
|
|
334
|
+
Define a simple logger interface in the types layer and wire it in commands via
|
|
335
|
+
`OutputManager.logger(spinner)`.
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
// lib/types.ts — node-free
|
|
339
|
+
export interface OperationLogger {
|
|
340
|
+
progress: (message: string) => void; // Drives the spinner
|
|
341
|
+
info: (message: string) => void; // --verbose detail
|
|
342
|
+
warn: (message: string) => void; // Non-fatal warnings
|
|
343
|
+
debug: (message: string) => void; // --debug only
|
|
344
|
+
}
|
|
153
345
|
|
|
154
|
-
|
|
346
|
+
// cli/lib/output.ts — wires to OutputManager + spinner
|
|
347
|
+
logger(spinner: Spinner): OperationLogger { ... }
|
|
348
|
+
```
|
|
155
349
|
|
|
156
|
-
|
|
350
|
+
## Stdout/Stderr Separation
|
|
157
351
|
|
|
158
|
-
|
|
352
|
+
Strict separation of data and diagnostics enables pipeline composability.
|
|
159
353
|
|
|
160
|
-
|
|
354
|
+
- **Data to stdout:** `data()`, `success()`, `notice()`, `dryRun()`, `table()`,
|
|
355
|
+
`list()`, `count()` — all go to `console.log` (stdout).
|
|
161
356
|
|
|
162
|
-
|
|
357
|
+
- **Diagnostics to stderr:** `info()`, `warn()`, `error()`, `command()`, `debug()`,
|
|
358
|
+
`spinner` — all go to `console.error` (stderr) or `process.stderr.write`.
|
|
163
359
|
|
|
164
|
-
- **
|
|
165
|
-
`
|
|
360
|
+
- **JSON mode wraps diagnostics:** `warn()` outputs `{"warning": "..."}` to stderr.
|
|
361
|
+
`error()` outputs `{"error": "..."}` to stderr.
|
|
362
|
+
`data()` outputs structured JSON to stdout.
|
|
166
363
|
|
|
167
|
-
|
|
364
|
+
- **Handle EPIPE for graceful pipe close:** Both stdout and stderr need EPIPE handlers
|
|
365
|
+
so piping to `head` or quitting a pager works cleanly.
|
|
168
366
|
|
|
169
|
-
|
|
367
|
+
```ts
|
|
368
|
+
// cli/bin.ts
|
|
369
|
+
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
|
370
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
371
|
+
throw err;
|
|
372
|
+
});
|
|
373
|
+
process.stderr.on('error', (err: NodeJS.ErrnoException) => {
|
|
374
|
+
if (err.code === 'EPIPE') process.exit(0);
|
|
375
|
+
throw err;
|
|
376
|
+
});
|
|
377
|
+
```
|
|
170
378
|
|
|
171
|
-
|
|
379
|
+
- **Support pagination with `PAGER`:** For long output, pipe through `$PAGER` (or
|
|
380
|
+
`less -R` for colored content) when stdout is a TTY. Fall through to `console.log()`
|
|
381
|
+
otherwise.
|
|
172
382
|
|
|
173
|
-
|
|
383
|
+
## Dual Output Mode (Text + JSON)
|
|
174
384
|
|
|
175
|
-
|
|
385
|
+
- **Use `OutputManager.data()` for all structured output:** The `data()` method switches
|
|
386
|
+
between JSON and text formatting based on the `--json` flag.
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
this.output.data(issues, () => {
|
|
390
|
+
// Text formatter — only called when NOT in JSON mode
|
|
391
|
+
for (const issue of issues) {
|
|
392
|
+
console.log(formatIssueLine(issue, colors));
|
|
393
|
+
}
|
|
394
|
+
this.output.count(issues.length, 'issue');
|
|
395
|
+
});
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- **Global `--json` flag:** Defined at the program level.
|
|
399
|
+
When active, `data()` outputs `JSON.stringify(data, null, 2)` to stdout.
|
|
400
|
+
All non-data output methods are suppressed or wrapped in JSON objects.
|
|
401
|
+
|
|
402
|
+
- **Pre-parse argv for early JSON detection:** For error output during Commander parsing
|
|
403
|
+
(before options are processed), check `process.argv.includes('--json')` directly.
|
|
404
|
+
|
|
405
|
+
## Error Handling Architecture
|
|
406
|
+
|
|
407
|
+
- **Use structured error classes with exit codes:**
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
class CLIError extends Error { exitCode = 1; }
|
|
411
|
+
class ValidationError extends CLIError { exitCode = 2; } // Usage/argument issues
|
|
412
|
+
class NotFoundError extends CLIError { } // Entity not found
|
|
413
|
+
class ExternalCommandError extends CLIError { } // git/docker/etc failures
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
- **`BaseCommand.execute()` wraps errors with cause chains:** Preserves the original
|
|
417
|
+
error for debugging while providing user-friendly messages.
|
|
418
|
+
|
|
419
|
+
- **Top-level try/catch in `runCli()`:** Catches `CLIError` subclasses (uses their
|
|
420
|
+
`exitCode`) and unexpected errors (exit 1). Handle SIGINT separately (exit 130).
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
export async function runCli(): Promise<void> {
|
|
424
|
+
try {
|
|
425
|
+
await program.parseAsync(process.argv);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof CLIError) {
|
|
428
|
+
process.exit(error.exitCode);
|
|
429
|
+
}
|
|
430
|
+
outputError(error);
|
|
431
|
+
process.exit(1);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Separate SIGINT handler
|
|
435
|
+
process.on('SIGINT', () => process.exit(130));
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
## Entry Point Architecture
|
|
439
|
+
|
|
440
|
+
A three-tier entry point optimizes startup time and error handling:
|
|
441
|
+
|
|
442
|
+
1. **CJS bootstrap (`bin-bootstrap.cjs`):** Enables Node.js compile cache (`node:module`
|
|
443
|
+
`enableCompileCache()`) BEFORE any ESM imports.
|
|
444
|
+
This must be CJS because ESM static imports resolve before module code runs.
|
|
445
|
+
|
|
446
|
+
2. **ESM binary (`bin.ts`):** Registers EPIPE handlers on stdout/stderr, then calls
|
|
447
|
+
`void runCli()`. Should be minimal.
|
|
448
|
+
|
|
449
|
+
3. **CLI setup (`cli.ts`):** Creates the Commander program, registers all commands,
|
|
450
|
+
defines global options, and handles the top-level try/catch with `process.exit()`.
|
|
176
451
|
|
|
177
452
|
## Timing and Performance
|
|
178
453
|
|
|
179
454
|
- **Display timing for long operations:** For operations that take multiple seconds,
|
|
180
|
-
display timing information
|
|
455
|
+
display timing information.
|
|
181
456
|
|
|
182
457
|
```ts
|
|
183
458
|
const start = Date.now();
|
|
184
459
|
// ... operation ...
|
|
185
460
|
const duration = ((Date.now() - start) / 1000).toFixed(1);
|
|
186
|
-
console.log(colors.
|
|
461
|
+
console.log(colors.info(`Operation completed: ${duration}s`));
|
|
187
462
|
```
|
|
188
463
|
|
|
189
|
-
- **
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
```ts
|
|
193
|
-
console.log(colors.cyan(`⏰ Phase 1: ${phase1Time}s`));
|
|
194
|
-
console.log(colors.cyan(`⏰ Phase 2: ${phase2Time}s`));
|
|
195
|
-
console.log('');
|
|
196
|
-
console.log(colors.green(`⏰ Total time: ${totalTime}s`));
|
|
197
|
-
```
|
|
464
|
+
- **Use `performance.now()` for benchmarks:** For precise timing in test scripts and
|
|
465
|
+
benchmarks, prefer `performance.now()` over `Date.now()`.
|
|
198
466
|
|
|
199
467
|
## Script Structure
|
|
200
468
|
|
|
201
|
-
- **Use TypeScript for
|
|
202
|
-
|
|
469
|
+
- **Use TypeScript for CLI scripts:** Write scripts as `.ts` files with proper types.
|
|
470
|
+
For executable TS scripts, use a shebang compatible with your runtime setup (for
|
|
471
|
+
example `#!/usr/bin/env npx tsx`). For compiled `.mjs` entry points, use
|
|
472
|
+
`#!/usr/bin/env node`.
|
|
203
473
|
|
|
204
474
|
```ts
|
|
205
|
-
#!/usr/bin/env tsx
|
|
206
|
-
|
|
475
|
+
#!/usr/bin/env npx tsx
|
|
476
|
+
|
|
207
477
|
/**
|
|
208
478
|
* Script description here.
|
|
209
479
|
*/
|
|
210
|
-
|
|
480
|
+
|
|
211
481
|
import { execSync } from 'node:child_process';
|
|
212
|
-
|
|
213
|
-
|
|
482
|
+
|
|
214
483
|
async function main() {
|
|
215
484
|
// Implementation
|
|
216
485
|
}
|
|
217
|
-
|
|
486
|
+
|
|
218
487
|
main().catch((err) => {
|
|
219
|
-
|
|
488
|
+
console.error(`Script failed: ${err.message || err}`);
|
|
220
489
|
process.exit(1);
|
|
221
490
|
});
|
|
222
491
|
```
|
|
223
492
|
|
|
493
|
+
- **For CLI binaries, use `void runCli()`:** The CLI entry point calls `runCli()` as a
|
|
494
|
+
void promise (error handling is inside `runCli()`). For standalone scripts, use the
|
|
495
|
+
`main().catch()` pattern.
|
|
496
|
+
|
|
224
497
|
- **Handle errors gracefully:** Always catch errors at the top level and provide clear
|
|
225
498
|
error messages before exiting.
|
|
226
499
|
|
|
227
|
-
```ts
|
|
228
|
-
main().catch((err) => {
|
|
229
|
-
p.log.error(`Operation failed: ${err.message || err}`);
|
|
230
|
-
process.exit(1);
|
|
231
|
-
});
|
|
232
|
-
```
|
|
233
|
-
|
|
234
500
|
- **Exit with proper codes:** Use `process.exit(0)` for success and `process.exit(1)`
|
|
235
|
-
for failures.
|
|
501
|
+
for failures. Use `process.exit(130)` for SIGINT. Use `process.exit(2)` for validation
|
|
502
|
+
errors. This is important for CI/CD pipelines and shell scripts.
|
|
236
503
|
|
|
237
504
|
## File Naming
|
|
238
505
|
|
|
@@ -242,18 +509,20 @@ These rules apply to all CLI tools, command-line scripts, and terminal utilities
|
|
|
242
509
|
|
|
243
510
|
- **Organize commands in a `commands/` directory:** Keep command implementations
|
|
244
511
|
organized with one file per command or command group.
|
|
512
|
+
Shared CLI utilities go in `lib/` (e.g., `base-command.ts`, `output.ts`, `context.ts`,
|
|
513
|
+
`errors.ts`).
|
|
245
514
|
|
|
246
515
|
## Documentation
|
|
247
516
|
|
|
248
|
-
- **Document CLI scripts with file-level comments:** Include a brief description
|
|
249
|
-
the script does at the top of the file.
|
|
517
|
+
- **Document CLI scripts with file-level JSDoc comments:** Include a brief description
|
|
518
|
+
of what the script does at the top of the file.
|
|
519
|
+
Reference relevant design docs when available.
|
|
250
520
|
|
|
251
521
|
```ts
|
|
252
522
|
/**
|
|
253
|
-
*
|
|
523
|
+
* `<tool> sync` - synchronization commands.
|
|
254
524
|
*
|
|
255
|
-
*
|
|
256
|
-
* and displays timing information for each phase.
|
|
525
|
+
* See: architecture/cli-sync.md
|
|
257
526
|
*/
|
|
258
527
|
```
|
|
259
528
|
|
|
@@ -265,26 +534,35 @@ These rules apply to all CLI tools, command-line scripts, and terminal utilities
|
|
|
265
534
|
.option('--output-dir <path>', 'Output directory', './runs')
|
|
266
535
|
```
|
|
267
536
|
|
|
537
|
+
- **Use command groups for organized help output:** Use `program.commandsGroup()` to
|
|
538
|
+
group commands under headings in the help text.
|
|
539
|
+
|
|
540
|
+
```ts
|
|
541
|
+
program.commandsGroup('Core Commands:');
|
|
542
|
+
program.addCommand(createCommand);
|
|
543
|
+
program.addCommand(listCommand);
|
|
544
|
+
```
|
|
545
|
+
|
|
268
546
|
## Environment Variables
|
|
269
547
|
|
|
270
548
|
When supporting environment variables, especially those used by SDK libraries (like
|
|
271
549
|
`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.), also support `.env` loading so CLIs work
|
|
272
550
|
seamlessly in local dev and in remote environments.
|
|
273
551
|
|
|
274
|
-
- **
|
|
275
|
-
|
|
552
|
+
- **Use dotenv only when needed:** Add `dotenv` only if your CLI should load local env
|
|
553
|
+
files automatically.
|
|
276
554
|
|
|
277
555
|
- **Load `.env.local` and `.env` automatically (recommended):** Support both
|
|
278
556
|
`.env.local` and `.env` automatically, with `.env.local` taking precedence over
|
|
279
557
|
`.env`.
|
|
280
558
|
|
|
281
|
-
- **Manual dotenv loading:** For
|
|
559
|
+
- **Manual dotenv loading:** For runtimes that don’t already load env files, load
|
|
282
560
|
environment files manually with explicit precedence:
|
|
283
561
|
|
|
284
562
|
```ts
|
|
285
563
|
import dotenv from 'dotenv';
|
|
286
564
|
import { existsSync } from 'node:fs';
|
|
287
|
-
|
|
565
|
+
|
|
288
566
|
// Load .env.local first (higher priority), then .env (lower priority).
|
|
289
567
|
// Note: dotenv does NOT override existing values by default, so load higher-priority
|
|
290
568
|
// first.
|
|
@@ -305,26 +583,13 @@ seamlessly in local dev and in remote environments.
|
|
|
305
583
|
- **Never commit secrets:** Use `.env.local` for secrets (it’s typically gitignored).
|
|
306
584
|
`.env` should only contain non-sensitive defaults.
|
|
307
585
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
- **Don’t reinvent the wheel:** Use established patterns from existing CLI commands in
|
|
311
|
-
this project or best practices from other modern open source CLI tools in Typescript.
|
|
312
|
-
|
|
313
|
-
- **Test with pipes:** Verify that scripts work correctly when output is piped (e.g.,
|
|
314
|
-
`npm test | cat` should have no ANSI codes).
|
|
315
|
-
|
|
316
|
-
- **Respect environment variables:**
|
|
586
|
+
- **Standard environment variables to respect:**
|
|
317
587
|
|
|
318
|
-
- `NO_COLOR`
|
|
319
|
-
|
|
320
|
-
- `
|
|
321
|
-
|
|
322
|
-
- `
|
|
323
|
-
|
|
324
|
-
- `QUIET_MODE` - suppress non-essential output
|
|
325
|
-
|
|
326
|
-
- **Make scripts composable:** Design scripts to work well in pipelines and automation.
|
|
327
|
-
Consider how they’ll be used in CI/CD and shell scripts.
|
|
588
|
+
- `NO_COLOR` — disable colors (standard)
|
|
589
|
+
- `FORCE_COLOR` — force colors
|
|
590
|
+
- `CI` — detect CI environment, force non-interactive
|
|
591
|
+
- `DEBUG` — enable debug logging (or a namespaced equivalent like `<TOOL>_DEBUG`)
|
|
592
|
+
- `PAGER` — custom pager command for long output
|
|
328
593
|
|
|
329
594
|
## Sub-Command Logging for Testability
|
|
330
595
|
|
|
@@ -334,6 +599,17 @@ swallowing bugs.
|
|
|
334
599
|
|
|
335
600
|
### The Pattern
|
|
336
601
|
|
|
602
|
+
Use `OutputManager.command()` to log executed commands at `--verbose` / `--debug` level:
|
|
603
|
+
|
|
604
|
+
```ts
|
|
605
|
+
// In your command handler:
|
|
606
|
+
this.output.command('git', ['push', 'origin', syncBranch]);
|
|
607
|
+
const result = await git('push', 'origin', syncBranch);
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
For more comprehensive sub-command logging (e.g., for golden tests), add a
|
|
611
|
+
`SHOW_COMMANDS=1` env var or `--show-commands` flag:
|
|
612
|
+
|
|
337
613
|
```ts
|
|
338
614
|
interface SubCommandLog {
|
|
339
615
|
command: string;
|
|
@@ -343,13 +619,10 @@ interface SubCommandLog {
|
|
|
343
619
|
stderr: string;
|
|
344
620
|
}
|
|
345
621
|
|
|
346
|
-
// Global log, populated when SHOW_COMMANDS=1 or --show-commands
|
|
347
622
|
const subCommandLog: SubCommandLog[] = [];
|
|
348
623
|
|
|
349
624
|
async function runCommand(cmd: string, args: string[]): Promise<ExecResult> {
|
|
350
625
|
const result = await exec(cmd, args);
|
|
351
|
-
|
|
352
|
-
// Log for golden tests
|
|
353
626
|
if (process.env.SHOW_COMMANDS === '1') {
|
|
354
627
|
subCommandLog.push({
|
|
355
628
|
command: cmd,
|
|
@@ -359,12 +632,8 @@ async function runCommand(cmd: string, args: string[]): Promise<ExecResult> {
|
|
|
359
632
|
stderr: result.stderr.trim(),
|
|
360
633
|
});
|
|
361
634
|
}
|
|
362
|
-
|
|
363
635
|
return result;
|
|
364
636
|
}
|
|
365
|
-
|
|
366
|
-
// Expose via flag: mycli sync --show-commands
|
|
367
|
-
program.option('--show-commands', 'Log all sub-command invocations');
|
|
368
637
|
```
|
|
369
638
|
|
|
370
639
|
### Why This Matters
|
|
@@ -374,37 +643,12 @@ show “Already in sync” with exit code 0, which looks correct.
|
|
|
374
643
|
With logging, you’d see that `git push` returned exit code 1 with “HTTP 403” - revealing
|
|
375
644
|
the bug.
|
|
376
645
|
|
|
377
|
-
|
|
378
|
-
output and exit codes.
|
|
379
|
-
|
|
380
|
-
### Usage in Tests
|
|
381
|
-
|
|
382
|
-
```bash
|
|
383
|
-
# In tryscripts/golden tests
|
|
384
|
-
export SHOW_COMMANDS=1
|
|
385
|
-
mycli sync # Output now includes all git operations with exit codes
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
### Auto-Assertions
|
|
389
|
-
|
|
390
|
-
Generate assertions from sub-command logs:
|
|
391
|
-
|
|
392
|
-
```ts
|
|
393
|
-
// If any sub-command failed, user should see an error
|
|
394
|
-
const failedOps = log.filter(op => op.exitCode !== 0);
|
|
395
|
-
if (failedOps.length > 0) {
|
|
396
|
-
expect(userOutput).toMatch(/error|fail/i);
|
|
397
|
-
expect(exitCode).not.toBe(0);
|
|
398
|
-
}
|
|
399
|
-
```
|
|
400
|
-
|
|
401
|
-
See `tbd guidelines golden-testing-guidelines` for full patterns on transparent box
|
|
402
|
-
testing.
|
|
646
|
+
See your project’s golden/snapshot testing guide for full transparent-box patterns.
|
|
403
647
|
|
|
404
648
|
## Library/CLI Hybrid Packages
|
|
405
649
|
|
|
406
650
|
When building a package that functions as both a library and a CLI tool, isolate all
|
|
407
|
-
Node.js dependencies to
|
|
651
|
+
Node.js dependencies to runtime-specific modules.
|
|
408
652
|
This allows the core library to be used in non-Node environments (browsers, edge
|
|
409
653
|
runtimes, Cloudflare Workers, etc.).
|
|
410
654
|
|
|
@@ -412,7 +656,8 @@ runtimes, Cloudflare Workers, etc.).
|
|
|
412
656
|
|
|
413
657
|
- Core library entry point (`index.ts`) must have no `node:` imports
|
|
414
658
|
|
|
415
|
-
- All `node:` imports must be
|
|
659
|
+
- All `node:` imports must be limited to Node-only modules (for example `cli/`,
|
|
660
|
+
`adapters/node/`, or `platform/node/`)
|
|
416
661
|
|
|
417
662
|
- Configuration constants go in node-free files
|
|
418
663
|
|
|
@@ -420,22 +665,46 @@ runtimes, Cloudflare Workers, etc.).
|
|
|
420
665
|
|
|
421
666
|
- Add guard tests to prevent future regressions
|
|
422
667
|
|
|
668
|
+
- Use an `OperationLogger` interface in the types layer to bridge progress reporting
|
|
669
|
+
from core logic to CLI output without introducing CLI dependencies
|
|
670
|
+
|
|
671
|
+
- Export separate package entry points: `"."` for the node-free library, `"./cli"` for
|
|
672
|
+
CLI-specific code
|
|
673
|
+
|
|
423
674
|
## CLI Architecture Patterns
|
|
424
675
|
|
|
425
676
|
**Key patterns:**
|
|
426
677
|
|
|
427
|
-
- **Base Command Pattern** —
|
|
678
|
+
- **Base Command Pattern** — All handlers extend `BaseCommand`, which provides
|
|
679
|
+
`CommandContext`, `OutputManager`, `execute()` error wrapping, and `checkDryRun()`
|
|
680
|
+
|
|
681
|
+
- **Dual Output Mode** — `OutputManager.data(data, textFormatter)` switches between JSON
|
|
682
|
+
and text formatting based on `--json` flag
|
|
683
|
+
|
|
684
|
+
- **Handler + Command Structure** — Command definition (`.option()`, `.action()`) is
|
|
685
|
+
separate from handler class implementation.
|
|
686
|
+
Action handlers do `new XxxHandler(command)` then `handler.run(options)`
|
|
687
|
+
|
|
688
|
+
- **Version Handling** — Prefer deterministic runtime version resolution: build-time
|
|
689
|
+
injection first, then environment override for dev/test, then `package.json` fallback
|
|
690
|
+
|
|
691
|
+
- **Global Options** — Define `--dry-run`, `--verbose`, `--quiet`, `--json`, `--color`,
|
|
692
|
+
and `--debug` at program level, plus tool-specific options as needed.
|
|
693
|
+
Only add `--non-interactive` and `--yes` if the CLI has interactive prompts
|
|
428
694
|
|
|
429
|
-
- **
|
|
695
|
+
- **Stdout/Stderr Separation** — Data to stdout, diagnostics to stderr for pipeline
|
|
696
|
+
compatibility. See the Stdout/Stderr Separation section above for details
|
|
430
697
|
|
|
431
|
-
- **
|
|
698
|
+
- **Terminal Width Management** — Cap help text and formatted output at a maximum width
|
|
699
|
+
(e.g., 88 chars) for readability, using narrower if the terminal is smaller
|
|
432
700
|
|
|
433
|
-
|
|
701
|
+
## Best Practices
|
|
434
702
|
|
|
435
|
-
- **
|
|
703
|
+
- **Don’t reinvent the wheel:** Use established patterns from your codebase and best
|
|
704
|
+
practices from mature open source TypeScript CLIs.
|
|
436
705
|
|
|
437
|
-
- **
|
|
438
|
-
|
|
706
|
+
- **Test with pipes:** Verify that scripts work correctly when output is piped (e.g.,
|
|
707
|
+
`npm test | cat` should have no ANSI codes).
|
|
439
708
|
|
|
440
|
-
- **
|
|
441
|
-
|
|
709
|
+
- **Make scripts composable:** Design scripts to work well in pipelines and automation.
|
|
710
|
+
Consider how they’ll be used in CI/CD and shell scripts.
|