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.
@@ -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 shared color utilities:** Create a shared formatting module for consistent color
28
- application across commands.
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
- Picocolors respects:
37
+ // Create a color instance with explicit enable/disable control
38
+ const colors = pc.createColors(shouldColorize(colorOption));
53
39
 
54
- - `NO_COLOR=1` environment variable (disables colors)
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
- - `FORCE_COLOR=1` environment variable (forces colors)
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
- - `--no-color` and `--color` command-line flags (if implemented)
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
- - TTY detection via `process.stdout.isTTY`
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
- // GOOD: Let picocolors handle it automatically
64
- import pc from 'picocolors';
65
- console.log(pc.green('This works correctly in all contexts'));
66
-
67
- // BAD: Manual TTY checking (redundant with picocolors)
68
- const useColors = process.stdout.isTTY;
69
- const msg = useColors ? '\x1b[32mSuccess\x1b[0m' : 'Success';
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 to all commands:** Use `withColoredHelp()` wrapper from shared
79
- utilities to ensure consistent help text formatting.
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
- import { Command } from 'commander';
83
- import { withColoredHelp } from '../lib/shared.js';
84
-
85
- export const myCommand = withColoredHelp(new Command('my-command'))
86
- .description('Description here')
87
- .action(async (options, command) => {
88
- // Implementation
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
- - **Use shared context helpers:** Create utilities like `getCommandContext()`,
93
- `setupDebug()`, and `logDryRun()` in a shared module for consistent behavior.
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
- import { getCommandContext, setupDebug, logDryRun } from '../lib/shared.js';
97
-
98
- .action(async (options, command) => {
99
- const ctx = getCommandContext(command);
100
- setupDebug(ctx);
101
-
102
- if (ctx.dryRun) {
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
- - **Support `--dry-run`, `--verbose`, and `--quiet` flags:** These are global options
112
- defined at the program level.
113
- Access them via `getCommandContext()`.
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: destructure with default true
128
- const { browser = true } = options;
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
- ## Progress and Feedback
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
- - **Use @clack/prompts for interactive UI:** Import `@clack/prompts` as `p` for
137
- spinners, prompts, and status messages.
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
- import * as p from '@clack/prompts';
141
-
142
- p.intro('🧪 Starting test suite');
143
-
144
- const spinner = p.spinner();
145
- spinner.start('Processing data');
146
- // ... work ...
147
- spinner.stop('✅ Data processed');
148
-
149
- p.outro('All done!');
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 consistent logging methods:**
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
- - `p.log.info()` for informational messages
346
+ // cli/lib/output.ts wires to OutputManager + spinner
347
+ logger(spinner: Spinner): OperationLogger { ... }
348
+ ```
155
349
 
156
- - `p.log.success()` for successful operations
350
+ ## Stdout/Stderr Separation
157
351
 
158
- - `p.log.warn()` for warnings
352
+ Strict separation of data and diagnostics enables pipeline composability.
159
353
 
160
- - `p.log.error()` for errors
354
+ - **Data to stdout:** `data()`, `success()`, `notice()`, `dryRun()`, `table()`,
355
+ `list()`, `count()` — all go to `console.log` (stdout).
161
356
 
162
- - `p.log.step()` for step-by-step progress
357
+ - **Diagnostics to stderr:** `info()`, `warn()`, `error()`, `command()`, `debug()`,
358
+ `spinner` — all go to `console.error` (stderr) or `process.stderr.write`.
163
359
 
164
- - **Use appropriate emojis for status:** Follow emoji conventions from
165
- `tbd guidelines general-style-rules`:
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
- - for success
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
- - ❌ for failure/error
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
- - ⚠️ for warnings
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
- - for timing information
383
+ ## Dual Output Mode (Text + JSON)
174
384
 
175
- - 🧪 for tests
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 using the ⏰ emoji and colored output.
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.cyan(`⏰ Operation completed: ${duration}s`));
461
+ console.log(colors.info(`Operation completed: ${duration}s`));
187
462
  ```
188
463
 
189
- - **Show total time for multi-step operations:** For scripts that run multiple phases
190
- (like test suites), show individual phase times and a total.
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 all CLI scripts:** Write scripts as `.ts` files with proper
202
- types. Use `#!/usr/bin/env tsx` shebang for executable scripts.
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
- import * as p from '@clack/prompts';
213
-
482
+
214
483
  async function main() {
215
484
  // Implementation
216
485
  }
217
-
486
+
218
487
  main().catch((err) => {
219
- p.log.error(`Script failed: ${err}`);
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. This is important for CI/CD pipelines and shell scripts.
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 of what
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
- * Test Runner with Timing
523
+ * `<tool> sync` - synchronization commands.
254
524
  *
255
- * Runs the full test suite (codegen, format, lint, unit, integration)
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
- - **Add dotenv as a dependency:** Add `dotenv` to your project dependencies for `.env`
275
- file loading.
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 standalone scripts that don’t use `vite-node`, load
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
- ## Best Practices
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` - disable colors
319
-
320
- - `FORCE_COLOR` - force colors
321
-
322
- - `DEBUG` or `VERBOSE` - enable verbose logging
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
- **Correlation**: Golden tests can verify that sub-command failures are reflected in user
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 CLI-only code.
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 in `cli/` directory only
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** — Eliminate boilerplate with a base class
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
- - **Dual Output Mode** — Support both text and JSON output via OutputManager
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
- - **Handler + Command Structure** — Separate definitions from implementation
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
- - **Formatter Pattern** — Pair text and JSON formatters for each domain
701
+ ## Best Practices
434
702
 
435
- - **Version Handling** Git-based dynamic versioning (`X.Y.Z-dev.N.hash`)
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
- - **Global Options** Define `--dry-run`, `--verbose`, `--quiet`, `--format` at program
438
- level
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
- - **Stdout/Stderr Separation** Data to stdout, errors to stderr for pipeline
441
- compatibility
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.