staysfixed 0.1.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +529 -0
  4. package/bin/staysfixed.js +18 -0
  5. package/examples/guards/the-sidebar-still-collapses.js +91 -0
  6. package/examples/staysfixed.config.electron.js +172 -0
  7. package/examples/staysfixed.config.web.js +277 -0
  8. package/package.json +61 -0
  9. package/src/cli/approve.js +126 -0
  10. package/src/cli/check.js +73 -0
  11. package/src/cli/doctor.js +379 -0
  12. package/src/cli/flake.js +61 -0
  13. package/src/cli/index.js +519 -0
  14. package/src/cli/init.js +564 -0
  15. package/src/cli/mark.js +69 -0
  16. package/src/cli/status.js +19 -0
  17. package/src/cli/trace.js +73 -0
  18. package/src/cli/walk.js +57 -0
  19. package/src/core/config.js +226 -0
  20. package/src/core/errors.js +48 -0
  21. package/src/core/git.js +90 -0
  22. package/src/core/hash.js +32 -0
  23. package/src/core/history.js +173 -0
  24. package/src/core/log.js +144 -0
  25. package/src/core/paths.js +135 -0
  26. package/src/drive/browser.js +540 -0
  27. package/src/drive/cdp.js +382 -0
  28. package/src/drive/electron.js +326 -0
  29. package/src/drive/find.js +331 -0
  30. package/src/drive/launch.js +263 -0
  31. package/src/drive/page.js +1042 -0
  32. package/src/freeze/clock.js +213 -0
  33. package/src/freeze/fonts.js +243 -0
  34. package/src/freeze/index.js +234 -0
  35. package/src/freeze/mask.js +187 -0
  36. package/src/freeze/motion.js +206 -0
  37. package/src/freeze/network.js +455 -0
  38. package/src/freeze/random.js +87 -0
  39. package/src/freeze/settle.js +178 -0
  40. package/src/guard/api.js +197 -0
  41. package/src/guard/load.js +324 -0
  42. package/src/guard/name.js +327 -0
  43. package/src/guard/run.js +224 -0
  44. package/src/index.js +61 -0
  45. package/src/marker/mark.js +260 -0
  46. package/src/marker/trace.js +293 -0
  47. package/src/mcp/server.js +377 -0
  48. package/src/mcp/tools.js +978 -0
  49. package/src/picture/capture.js +276 -0
  50. package/src/picture/compare.js +103 -0
  51. package/src/picture/run.js +284 -0
  52. package/src/picture/store.js +208 -0
  53. package/src/report/console.js +540 -0
  54. package/src/report/html.js +579 -0
  55. package/src/run.js +614 -0
  56. package/src/types.js +471 -0
  57. package/src/walk/run.js +541 -0
@@ -0,0 +1,519 @@
1
+ /**
2
+ * The command line.
3
+ *
4
+ * Argument parsing is hand-rolled on purpose: a tool whose whole promise is
5
+ * "nothing changes underneath you" should not take a dependency to read `--only`.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import { readFileSync } from 'node:fs';
10
+ import { StaysFixedError, EXIT } from '../core/errors.js';
11
+ import { setLogLevel } from '../core/log.js';
12
+
13
+ /** @type {{version?: string}} */
14
+ const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
15
+
16
+ /** The version printed by `--version` and reported to an agent over MCP. */
17
+ export const VERSION = pkg.version ?? '0.0.0';
18
+
19
+ /**
20
+ * @typedef {object} ArgSpec
21
+ * @property {string[]} [booleans]
22
+ * @property {string[]} [strings]
23
+ * @property {string[]} [arrays]
24
+ * @property {Record<string,string>} [alias]
25
+ */
26
+
27
+ /**
28
+ * @typedef {object} ParsedArgs
29
+ * @property {string[]} args Everything that was not a flag.
30
+ * @property {Record<string, string|boolean|string[]>} flags
31
+ * @property {string[]} passthrough Everything after a bare `--`.
32
+ */
33
+
34
+ /**
35
+ * What every command is handed. The three little readers exist so a command can
36
+ * ask for a value without repeating a type guard on every line.
37
+ *
38
+ * @typedef {object} CliContext
39
+ * @property {string[]} args
40
+ * @property {Record<string, string|boolean|string[]>} flags
41
+ * @property {string[]} passthrough
42
+ * @property {string} cwd
43
+ * @property {string|undefined} configFile
44
+ * @property {string} version
45
+ * @property {(name: string) => boolean} bool
46
+ * @property {(name: string) => string|undefined} str
47
+ * @property {(name: string) => string[]} list
48
+ */
49
+
50
+ /**
51
+ * @typedef {object} CommandEntry
52
+ * @property {string} summary One plain line, shown in the main help.
53
+ * @property {string} usage
54
+ * @property {string} describe A short paragraph, shown in the command's own help.
55
+ * @property {[string, string][]} [options]
56
+ * @property {string[]} [examples]
57
+ * @property {ArgSpec} [spec]
58
+ * @property {() => Promise<{run: (ctx: CliContext) => Promise<number>}>} [load]
59
+ */
60
+
61
+ /** @type {ArgSpec} */
62
+ const GLOBAL_SPEC = {
63
+ booleans: ['verbose', 'quiet', 'help', 'version', 'color'],
64
+ strings: ['config', 'cwd'],
65
+ alias: { v: 'verbose', q: 'quiet', h: 'help', V: 'version' },
66
+ };
67
+
68
+ /** Flags that swallow the next word, needed before we know which command it is. */
69
+ const GLOBAL_VALUE_FLAGS = new Set(['--config', '--cwd']);
70
+
71
+ /** @type {Record<string, CommandEntry>} */
72
+ const COMMANDS = {
73
+ init: {
74
+ summary: 'Set this project up. Takes about thirty seconds.',
75
+ usage: 'staysfixed init [--force] [--json]',
76
+ describe:
77
+ 'Looks at your project, writes a settings file you can read, makes the folders,\nand leaves you one starter guard to copy. It never overwrites a settings file\nyou already have unless you ask it to.',
78
+ options: [
79
+ ['--force', 'Overwrite a settings file that is already there.'],
80
+ ['--json', 'Print what it did as JSON and no prose. For agents.'],
81
+ ],
82
+ examples: ['staysfixed init'],
83
+ spec: { booleans: ['force', 'json'] },
84
+ load: () => import('./init.js'),
85
+ },
86
+ check: {
87
+ summary: 'Photograph the screens and run the guards. This is the one you run.',
88
+ usage: 'staysfixed check [--only <name>] [--guards] [--pictures] [--json]',
89
+ describe:
90
+ 'Opens the real app, takes a picture of every screen you named, compares each one\nagainst the picture a human approved, and runs every guard. It stops on nothing:\nyou get the whole list of what changed, and the exact command to accept it.',
91
+ options: [
92
+ ['--only <name>', 'Just this screen or guard. Repeat it for several.'],
93
+ ['--guards', 'Guards only, no pictures.'],
94
+ ['--pictures', 'Pictures only, no guards.'],
95
+ ['--record', 'Save the network replies this run, so later runs can replay them.'],
96
+ ['--no-report', 'Skip writing the side-by-side HTML report.'],
97
+ ['--json', 'Print the result as JSON and nothing else. For CI.'],
98
+ ],
99
+ examples: ['staysfixed check', 'staysfixed check --only sessions-empty', 'staysfixed check --guards'],
100
+ spec: { booleans: ['guards', 'pictures', 'record', 'report', 'json'], arrays: ['only'] },
101
+ load: () => import('./check.js'),
102
+ },
103
+ approve: {
104
+ summary: 'Say a new picture is correct. Only a person may do this.',
105
+ usage: 'staysfixed approve <name...> | --all [--reason "<why>"]',
106
+ describe:
107
+ 'Takes the picture from the last check and makes it the one everything is measured\nagainst from now on. Run it with nothing and it only lists what is waiting — it\nwill never approve anything you did not name.',
108
+ options: [
109
+ ['--all', 'Approve every picture that is waiting.'],
110
+ ['--reason "<why>"', 'Why this change is correct. Saved next to the picture.'],
111
+ ],
112
+ examples: ['staysfixed approve', 'staysfixed approve sessions-empty', 'staysfixed approve --all --reason "new empty state"'],
113
+ spec: { booleans: ['all'], strings: ['reason'] },
114
+ load: () => import('./approve.js'),
115
+ },
116
+ walk: {
117
+ summary: 'Open the real app and photograph every screen, in order, before you ship.',
118
+ usage: 'staysfixed walk [--only <name>] [--open]',
119
+ describe:
120
+ 'A walk is not a test. It opens the app you are about to release, visits each screen\nand photographs it into one page you can scroll — the last look before a release,\nwithout clicking through the app yourself.',
121
+ options: [
122
+ ['--only <name>', 'Just this screen. Repeat it for several.'],
123
+ ['--open', 'Open the contact sheet when it is done.'],
124
+ ],
125
+ examples: ['staysfixed walk --open'],
126
+ spec: { booleans: ['open'], arrays: ['only'] },
127
+ load: () => import('./walk.js'),
128
+ },
129
+ mark: {
130
+ summary: 'Pin today as a known-good version you can trace back to.',
131
+ usage: 'staysfixed mark <label> [--note "<text>"] | --list | --delete <label>',
132
+ describe:
133
+ 'A marker remembers what every screen looked like at one moment, so months later\n`staysfixed trace` can say which commit broke it. Pin one at every release.',
134
+ options: [
135
+ ['--note "<text>"', 'A line about this version, for the you of six months from now.'],
136
+ ['--force', 'Replace a marker with the same name.'],
137
+ ['--list', 'List every marker, newest first.'],
138
+ ['--delete <label>', 'Remove one marker.'],
139
+ ],
140
+ examples: ['staysfixed mark v0.15.0 --note "before the store work"', 'staysfixed mark --list'],
141
+ spec: { booleans: ['force', 'list'], strings: ['note', 'delete'] },
142
+ load: () => import('./mark.js'),
143
+ },
144
+ trace: {
145
+ summary: 'Find the commit where a screen stopped looking right.',
146
+ usage: 'staysfixed trace [screen]',
147
+ describe:
148
+ 'Walks backwards through your markers to the last one where this screen still looked\nright, then lists the commits between there and the first one where it did not.\nWith no screen named it traces whatever the last check said had changed.',
149
+ examples: ['staysfixed trace', 'staysfixed trace sessions-empty'],
150
+ spec: {},
151
+ load: () => import('./trace.js'),
152
+ },
153
+ status: {
154
+ summary: 'What is set up here and how the last check went. Instant.',
155
+ usage: 'staysfixed status',
156
+ describe: 'Reads what is already on disk. It launches nothing and changes nothing.',
157
+ examples: ['staysfixed status'],
158
+ spec: {},
159
+ load: () => import('./status.js'),
160
+ },
161
+ flake: {
162
+ summary: 'List the checks that keep changing their mind.',
163
+ usage: 'staysfixed flake [--clear <name>] [--json]',
164
+ describe:
165
+ 'A check that passes and fails without the code changing is worse than no check.\nThis is the register of them. Fix them or delete them — and once one is genuinely\nfixed, forgive it with --clear.',
166
+ options: [
167
+ ['--clear <name>', 'Forgive a check that has been fixed.'],
168
+ ['--json', 'Print the register as JSON.'],
169
+ ],
170
+ examples: ['staysfixed flake', 'staysfixed flake --clear sessions-empty'],
171
+ spec: { booleans: ['json'], strings: ['clear'] },
172
+ load: () => import('./flake.js'),
173
+ },
174
+ doctor: {
175
+ summary: 'Explain why Stays Fixed cannot run here, in plain words.',
176
+ usage: 'staysfixed doctor [--fix]',
177
+ describe:
178
+ 'Goes through everything that has to be true — settings, the app, a browser, the\nfolders, the guards, git — and says which of them is fine and which is not. It\nnever launches your app and it never fails; it reports.',
179
+ options: [['--fix', 'Repair the small things it can safely repair.']],
180
+ examples: ['staysfixed doctor', 'staysfixed doctor --fix'],
181
+ spec: { booleans: ['fix'] },
182
+ load: () => import('./doctor.js'),
183
+ },
184
+ mcp: {
185
+ summary: 'Run as an MCP server so a coding agent can check its own work.',
186
+ usage: 'staysfixed mcp',
187
+ describe:
188
+ 'Speaks the Model Context Protocol on standard input and output, so Claude Code,\nCodex, Gemini or Cursor can check the screens right after editing your code.\nApproving still belongs to you: an agent cannot approve its own work.',
189
+ examples: ['staysfixed mcp'],
190
+ spec: {},
191
+ },
192
+ };
193
+
194
+ /**
195
+ * @param {string[]} argv
196
+ * @returns {Promise<number>} the exit code
197
+ */
198
+ export async function main(argv) {
199
+ const { command, rest } = splitCommand(argv);
200
+
201
+ if (command === null) {
202
+ const parsed = parseArgs(rest, GLOBAL_SPEC);
203
+ if (parsed.flags.version === true) return printVersion();
204
+ printHelp();
205
+ return EXIT.ok;
206
+ }
207
+
208
+ const entry = COMMANDS[command];
209
+ if (!entry) {
210
+ const near = closest(command, Object.keys(COMMANDS));
211
+ throw new StaysFixedError(`There is no command called "${command}".`, {
212
+ hint: near ? `Did you mean \`staysfixed ${near}\`?` : 'Run `staysfixed --help` to see what it can do.',
213
+ });
214
+ }
215
+
216
+ const parsed = parseArgs(rest, mergeSpec(GLOBAL_SPEC, entry.spec ?? {}));
217
+ if (parsed.flags.help === true) {
218
+ printCommandHelp(command, entry);
219
+ return EXIT.ok;
220
+ }
221
+ if (parsed.flags.version === true) return printVersion();
222
+
223
+ const cwd = moveTo(parsed.flags.cwd);
224
+ const configFile = typeof parsed.flags.config === 'string' ? parsed.flags.config : undefined;
225
+
226
+ // An MCP server talks JSON-RPC on stdout. One stray friendly line would break
227
+ // the conversation, so the logger is silenced before the server ever starts.
228
+ if (command === 'mcp') {
229
+ setLogLevel({ quiet: true, verbose: false });
230
+ const { serveMcp } = await import('../mcp/server.js');
231
+ await serveMcp({ cwd, configFile, version: VERSION });
232
+ return EXIT.ok;
233
+ }
234
+
235
+ setLogLevel({ verbose: parsed.flags.verbose === true, quiet: parsed.flags.quiet === true });
236
+
237
+ if (!entry.load) throw new StaysFixedError(`The command "${command}" is not wired up.`);
238
+ const mod = await entry.load();
239
+ return await mod.run(contextFor(parsed, cwd, configFile));
240
+ }
241
+
242
+ /**
243
+ * @param {ParsedArgs} parsed
244
+ * @param {string} cwd
245
+ * @param {string|undefined} configFile
246
+ * @returns {CliContext}
247
+ */
248
+ function contextFor(parsed, cwd, configFile) {
249
+ const flags = parsed.flags;
250
+ return {
251
+ args: parsed.args,
252
+ flags,
253
+ passthrough: parsed.passthrough,
254
+ cwd,
255
+ configFile,
256
+ version: VERSION,
257
+ bool: (name) => flags[name] === true,
258
+ str: (name) => (typeof flags[name] === 'string' ? /** @type {string} */ (flags[name]) : undefined),
259
+ list: (name) => {
260
+ const value = flags[name];
261
+ if (Array.isArray(value)) return value;
262
+ if (typeof value === 'string') return [value];
263
+ return [];
264
+ },
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Change into `--cwd` so every relative path in the run means the same thing.
270
+ * @param {string|boolean|string[]|undefined} value
271
+ * @returns {string}
272
+ */
273
+ function moveTo(value) {
274
+ if (typeof value !== 'string') return process.cwd();
275
+ const target = path.resolve(process.cwd(), value);
276
+ try {
277
+ process.chdir(target);
278
+ } catch (cause) {
279
+ throw new StaysFixedError(`There is no folder at "${value}".`, {
280
+ hint: 'Check the path you passed to --cwd.',
281
+ cause,
282
+ });
283
+ }
284
+ return process.cwd();
285
+ }
286
+
287
+ /**
288
+ * Pull the command word out, without mistaking the value of a global flag for it.
289
+ * @param {string[]} argv
290
+ * @returns {{command: string|null, rest: string[]}}
291
+ */
292
+ function splitCommand(argv) {
293
+ for (let i = 0; i < argv.length; i++) {
294
+ const token = argv[i];
295
+ if (token === '--') break;
296
+ if (token.startsWith('-') && token !== '-') {
297
+ if (GLOBAL_VALUE_FLAGS.has(token)) i++;
298
+ continue;
299
+ }
300
+ return { command: token, rest: [...argv.slice(0, i), ...argv.slice(i + 1)] };
301
+ }
302
+ return { command: null, rest: [...argv] };
303
+ }
304
+
305
+ /**
306
+ * @param {ArgSpec} base
307
+ * @param {ArgSpec} extra
308
+ * @returns {ArgSpec}
309
+ */
310
+ function mergeSpec(base, extra) {
311
+ return {
312
+ booleans: [...(base.booleans ?? []), ...(extra.booleans ?? [])],
313
+ strings: [...(base.strings ?? []), ...(extra.strings ?? [])],
314
+ arrays: [...(base.arrays ?? []), ...(extra.arrays ?? [])],
315
+ alias: { ...(base.alias ?? {}), ...(extra.alias ?? {}) },
316
+ };
317
+ }
318
+
319
+ /**
320
+ * `--flag`, `--key=value`, `--key value`, `-v`, `--no-flag`, `--` passthrough,
321
+ * and a repeated flag collecting into a list.
322
+ *
323
+ * @param {string[]} argv
324
+ * @param {ArgSpec} spec
325
+ * @returns {ParsedArgs}
326
+ */
327
+ export function parseArgs(argv, spec) {
328
+ const booleans = new Set(spec.booleans ?? []);
329
+ const strings = new Set(spec.strings ?? []);
330
+ const arrays = new Set(spec.arrays ?? []);
331
+ const alias = spec.alias ?? {};
332
+ const known = [...booleans, ...strings, ...arrays];
333
+
334
+ /** @type {Record<string, string|boolean|string[]>} */
335
+ const flags = {};
336
+ /** @type {string[]} */
337
+ const args = [];
338
+ /** @type {string[]} */
339
+ const passthrough = [];
340
+
341
+ for (let i = 0; i < argv.length; i++) {
342
+ const token = argv[i];
343
+
344
+ if (token === '--') {
345
+ passthrough.push(...argv.slice(i + 1));
346
+ break;
347
+ }
348
+ if (token === '-' || !token.startsWith('-')) {
349
+ args.push(token);
350
+ continue;
351
+ }
352
+
353
+ let name = token.startsWith('--') ? token.slice(2) : token.slice(1);
354
+ /** @type {string|undefined} */
355
+ let inline;
356
+ const eq = name.indexOf('=');
357
+ if (eq !== -1) {
358
+ inline = name.slice(eq + 1);
359
+ name = name.slice(0, eq);
360
+ }
361
+ if (alias[name]) name = alias[name];
362
+
363
+ // `--no-report` is the plain-English way to turn a switch off.
364
+ if (!known.includes(name) && name.startsWith('no-') && booleans.has(name.slice(3))) {
365
+ flags[name.slice(3)] = false;
366
+ continue;
367
+ }
368
+
369
+ if (booleans.has(name)) {
370
+ flags[name] = inline === undefined ? true : !/^(0|false|no)$/i.test(inline);
371
+ continue;
372
+ }
373
+
374
+ if (strings.has(name) || arrays.has(name)) {
375
+ let value = inline;
376
+ if (value === undefined) {
377
+ const next = argv[i + 1];
378
+ if (next === undefined || (next.startsWith('--') && next !== '--')) {
379
+ throw new StaysFixedError(`${token} needs something after it.`, {
380
+ hint: `Write it as \`${token} <value>\`.`,
381
+ });
382
+ }
383
+ value = next;
384
+ i++;
385
+ }
386
+ if (arrays.has(name)) {
387
+ const previous = flags[name];
388
+ const list = /** @type {string[]} */ (Array.isArray(previous) ? previous : []);
389
+ list.push(value);
390
+ flags[name] = list;
391
+ } else {
392
+ flags[name] = value;
393
+ }
394
+ continue;
395
+ }
396
+
397
+ const near = closest(name, known);
398
+ throw new StaysFixedError(`I do not know the option ${token}.`, {
399
+ hint: near ? `Did you mean --${near}?` : 'Run the command with --help to see the options it takes.',
400
+ });
401
+ }
402
+
403
+ return { args, flags, passthrough };
404
+ }
405
+
406
+ /**
407
+ * How many single-character edits turn one word into the other. Only ever used
408
+ * to say "did you mean" — never to decide anything.
409
+ * @param {string} a
410
+ * @param {string} b
411
+ * @returns {number}
412
+ */
413
+ function editDistance(a, b) {
414
+ /** @type {number[]} */
415
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
416
+ for (let i = 1; i <= a.length; i++) {
417
+ /** @type {number[]} */
418
+ const row = [i];
419
+ for (let j = 1; j <= b.length; j++) {
420
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
421
+ row[j] = Math.min(previous[j] + 1, row[j - 1] + 1, previous[j - 1] + cost);
422
+ }
423
+ previous = row;
424
+ }
425
+ return previous[b.length];
426
+ }
427
+
428
+ /**
429
+ * @param {string} word
430
+ * @param {string[]} choices
431
+ * @returns {string|null}
432
+ */
433
+ function closest(word, choices) {
434
+ /** @type {string|null} */
435
+ let best = null;
436
+ let score = Number.POSITIVE_INFINITY;
437
+ for (const choice of choices) {
438
+ const d = editDistance(word.toLowerCase(), choice.toLowerCase());
439
+ if (d < score) {
440
+ score = d;
441
+ best = choice;
442
+ }
443
+ }
444
+ // Anything further away than a third of the word is a different word, not a typo.
445
+ return score <= Math.max(2, Math.floor(word.length / 3)) ? best : null;
446
+ }
447
+
448
+ /** @param {string} text */
449
+ function out(text) {
450
+ process.stdout.write(text + '\n');
451
+ }
452
+
453
+ /** @returns {number} */
454
+ function printVersion() {
455
+ out(VERSION);
456
+ return EXIT.ok;
457
+ }
458
+
459
+ /** Help always prints, even under --quiet. Somebody asking for help wants help. */
460
+ function printHelp() {
461
+ const width = Math.max(...Object.keys(COMMANDS).map((k) => k.length));
462
+ out('');
463
+ out('Stays Fixed — proves that what already worked still works after the code changed.');
464
+ out('');
465
+ out('Usage');
466
+ out(' staysfixed <command> [options]');
467
+ out('');
468
+ out('Commands');
469
+ for (const [name, entry] of Object.entries(COMMANDS)) {
470
+ out(` ${name.padEnd(width)} ${entry.summary}`);
471
+ }
472
+ out('');
473
+ out('Options');
474
+ out(' --config <file> Use this settings file instead of looking for one.');
475
+ out(' --cwd <dir> Work in this folder.');
476
+ out(' -v, --verbose Show the technical detail as well.');
477
+ out(' -q, --quiet Only say what went wrong.');
478
+ out(' --no-color Plain text, no colour.');
479
+ out(' --version Print the version.');
480
+ out(' -h, --help This help. Add it to a command for that command.');
481
+ out('');
482
+ out('Examples');
483
+ out(' staysfixed init set this project up');
484
+ out(' staysfixed check check every screen and guard');
485
+ out(' staysfixed approve sessions-empty accept one new picture as correct');
486
+ out(' staysfixed walk --open photograph the whole app before a release');
487
+ out('');
488
+ out('It answers with 0 when nothing changed, 1 when something changed or broke,');
489
+ out('and 2 when it could not run at all.');
490
+ out('');
491
+ }
492
+
493
+ /**
494
+ * @param {string} name
495
+ * @param {CommandEntry} entry
496
+ */
497
+ function printCommandHelp(name, entry) {
498
+ out('');
499
+ out(`staysfixed ${name} — ${entry.summary}`);
500
+ out('');
501
+ out('Usage');
502
+ out(` ${entry.usage}`);
503
+ if (entry.describe) {
504
+ out('');
505
+ for (const line of entry.describe.split('\n')) out(` ${line}`);
506
+ }
507
+ if (entry.options?.length) {
508
+ const width = Math.max(...entry.options.map(([flag]) => flag.length));
509
+ out('');
510
+ out('Options');
511
+ for (const [flag, text] of entry.options) out(` ${flag.padEnd(width)} ${text}`);
512
+ }
513
+ if (entry.examples?.length) {
514
+ out('');
515
+ out('Examples');
516
+ for (const example of entry.examples) out(` ${example}`);
517
+ }
518
+ out('');
519
+ }