botprint 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,758 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFile, writeFile } from "node:fs/promises";
4
+ import { compareProfiles } from "./compare";
5
+ import { EXIT_CODES, type ExitCode } from "./exit-codes";
6
+ import { BotprintError } from "./errors";
7
+ import { analyzeProfile } from "./findings";
8
+ import { runGuide } from "./guide";
9
+ import { readProfile } from "./profile";
10
+ import { renderAgentExplanation, renderCompareMarkdown, renderProfileMarkdown } from "./render";
11
+ import { listTargets, runProfile } from "./run";
12
+
13
+ // Layout: command types and flag specs, parser helpers, command dispatch, then help text.
14
+ type CommandName = "run" | "targets" | "report" | "compare" | "explain" | "guide" | "tui" | "help";
15
+
16
+ type ParsedCommand =
17
+ | {
18
+ command: "run";
19
+ options: {
20
+ cdp: string;
21
+ out: string;
22
+ json: boolean;
23
+ quiet: boolean;
24
+ label: string | undefined;
25
+ timeout: number | undefined;
26
+ probeTimeout: number | undefined;
27
+ authHeaders: string[];
28
+ diagnosticBind: string | undefined;
29
+ diagnosticHost: string | undefined;
30
+ context: string | undefined;
31
+ target: string | undefined;
32
+ targetUrl: string | undefined;
33
+ };
34
+ }
35
+ | {
36
+ command: "targets";
37
+ options: {
38
+ cdp: string;
39
+ json: boolean;
40
+ quiet: boolean;
41
+ timeout: number | undefined;
42
+ authHeaders: string[];
43
+ };
44
+ }
45
+ | {
46
+ command: "report";
47
+ options: {
48
+ profilePath: string;
49
+ markdown: string | undefined;
50
+ json: boolean;
51
+ quiet: boolean;
52
+ };
53
+ }
54
+ | {
55
+ command: "compare";
56
+ options: {
57
+ baselinePath: string;
58
+ candidatePath: string;
59
+ markdown: string | undefined;
60
+ json: boolean;
61
+ quiet: boolean;
62
+ };
63
+ }
64
+ | {
65
+ command: "explain";
66
+ options: {
67
+ inputPath: string;
68
+ forAgent: boolean;
69
+ json: boolean;
70
+ quiet: boolean;
71
+ };
72
+ }
73
+ | {
74
+ command: "guide" | "tui";
75
+ options: Record<string, never>;
76
+ }
77
+ | {
78
+ command: "help";
79
+ options: {
80
+ topic: CommandName | undefined;
81
+ };
82
+ };
83
+
84
+ type ParseResult =
85
+ | {
86
+ ok: true;
87
+ command: ParsedCommand;
88
+ }
89
+ | ParseError;
90
+
91
+ type ParseError = {
92
+ ok: false;
93
+ exitCode: ExitCode;
94
+ message: string;
95
+ };
96
+
97
+ type FlagValues = {
98
+ values: Map<string, string[]>;
99
+ positionals: string[];
100
+ };
101
+
102
+ const COMMANDS = new Set<CommandName>(["run", "targets", "report", "compare", "explain", "guide", "tui", "help"]);
103
+
104
+ const FLAG_SPEC: Record<string, { takesValue: boolean }> = {
105
+ cdp: { takesValue: true },
106
+ out: { takesValue: true },
107
+ markdown: { takesValue: true },
108
+ label: { takesValue: true },
109
+ timeout: { takesValue: true },
110
+ "probe-timeout": { takesValue: true },
111
+ "auth-header": { takesValue: true },
112
+ context: { takesValue: true },
113
+ "diagnostic-bind": { takesValue: true },
114
+ "diagnostic-host": { takesValue: true },
115
+ target: { takesValue: true },
116
+ "target-url": { takesValue: true },
117
+ json: { takesValue: false },
118
+ quiet: { takesValue: false },
119
+ "for-agent": { takesValue: false },
120
+ help: { takesValue: false },
121
+ };
122
+
123
+ const RUN_FLAGS = new Set(["auth-header", "cdp", "context", "diagnostic-bind", "diagnostic-host", "json", "label", "out", "probe-timeout", "quiet", "target", "target-url", "timeout"]);
124
+ const TARGETS_FLAGS = new Set(["auth-header", "cdp", "json", "quiet", "timeout"]);
125
+ const REPORT_FLAGS = new Set(["json", "markdown", "quiet"]);
126
+ const COMPARE_FLAGS = new Set(["json", "markdown", "quiet"]);
127
+ const EXPLAIN_FLAGS = new Set(["for-agent", "json", "quiet"]);
128
+ const GUIDE_FLAGS = new Set<string>();
129
+
130
+ export function parseArgs(argv: string[]): ParseResult {
131
+ const [rawCommand, ...rest] = argv;
132
+
133
+ if (rawCommand === undefined || rawCommand === "--help" || rawCommand === "-h") {
134
+ return { ok: true, command: { command: "help", options: { topic: undefined } } };
135
+ }
136
+
137
+ if (!isCommand(rawCommand)) {
138
+ return usageError(`Unknown command: ${rawCommand}`);
139
+ }
140
+
141
+ if (rawCommand === "help") {
142
+ const topic = rest[0];
143
+ if (topic !== undefined && !isCommand(topic)) {
144
+ return usageError(`Unknown help topic: ${topic}`);
145
+ }
146
+ return { ok: true, command: { command: "help", options: { topic } } };
147
+ }
148
+
149
+ const flags = collectFlags(rest);
150
+ if (!flags.ok) {
151
+ return flags;
152
+ }
153
+
154
+ if (flagEnabled(flags.parsed, "help")) {
155
+ return { ok: true, command: { command: "help", options: { topic: rawCommand } } };
156
+ }
157
+
158
+ switch (rawCommand) {
159
+ case "run":
160
+ return parseRun(flags.parsed);
161
+ case "targets":
162
+ return parseTargets(flags.parsed);
163
+ case "report":
164
+ return parseReport(flags.parsed);
165
+ case "compare":
166
+ return parseCompare(flags.parsed);
167
+ case "explain":
168
+ return parseExplain(flags.parsed);
169
+ case "guide":
170
+ case "tui":
171
+ return parseGuide(flags.parsed, rawCommand);
172
+ default:
173
+ return assertNever(rawCommand);
174
+ }
175
+ }
176
+
177
+ export function renderHelp(topic?: CommandName): string {
178
+ if (topic === "run") {
179
+ return [
180
+ "Usage: botprint run --cdp <url> --out <profile.json> [options]",
181
+ "",
182
+ "Collect a browser-visible profile from an existing CDP endpoint.",
183
+ "Default: creates a fresh diagnostic page inside the browser context of the first open page target,",
184
+ "so framework context options (locale, timezone, viewport, permissions) are measured.",
185
+ "Use --target to evaluate an existing page target in place.",
186
+ "",
187
+ "Options:",
188
+ " --cdp <url> CDP HTTP, WS, or WSS endpoint.",
189
+ " --out <path> Profile JSON output path.",
190
+ " --label <name> Label stored in source.label and report headings.",
191
+ " --timeout <ms> CDP operation timeout in milliseconds. Default: 10000.",
192
+ " --probe-timeout <ms> Per-check timeout inside the browser probe (IndexedDB, WebRTC, audio). Default: 2000.",
193
+ " --auth-header <header> Extra header for remote CDP endpoints. Repeatable.",
194
+ " --context <id|default> Browser context for the fresh page: a browserContextId from `targets --json`, or `default`.",
195
+ " --diagnostic-bind <addr> Interface for Botprint's diagnostic page. Default: 127.0.0.1.",
196
+ " --diagnostic-host <host> Hostname the browser uses to reach the diagnostic page (e.g. host.docker.internal).",
197
+ " --target <targetId> Existing CDP page target to evaluate without closing or navigating it.",
198
+ " --target-url <prefix> Resolve one existing page target by URL prefix.",
199
+ " --json Emit machine-readable command output.",
200
+ " --quiet Suppress non-essential output.",
201
+ ].join("\n");
202
+ }
203
+
204
+ if (topic === "targets") {
205
+ return [
206
+ "Usage: botprint targets --cdp <url> [options]",
207
+ "",
208
+ "List existing page targets exposed by a CDP endpoint.",
209
+ "",
210
+ "Options:",
211
+ " --cdp <url> CDP HTTP, WS, or WSS endpoint.",
212
+ " --timeout <ms> Operation timeout in milliseconds.",
213
+ " --auth-header <header> Extra header for remote CDP endpoints. Repeatable.",
214
+ " --json Emit machine-readable target list.",
215
+ " --quiet Suppress non-essential output.",
216
+ ].join("\n");
217
+ }
218
+
219
+ if (topic === "report") {
220
+ return [
221
+ "Usage: botprint report <profile.json> --markdown <report.md> [options]",
222
+ "",
223
+ "Render a human-readable report from a Botprint profile.",
224
+ "",
225
+ "Options:",
226
+ " --markdown <path> Markdown report output path.",
227
+ " --json Emit machine-readable command output.",
228
+ " --quiet Suppress non-essential output.",
229
+ ].join("\n");
230
+ }
231
+
232
+ if (topic === "compare") {
233
+ return [
234
+ "Usage: botprint compare <baseline.json> <candidate.json> [options]",
235
+ "",
236
+ "Compare two Botprint profiles and report material browser-environment drift.",
237
+ "Compare JSON includes per-diff level values and verdict warnings that may affect interpretation quality.",
238
+ "",
239
+ "Options:",
240
+ " --markdown <path> Markdown comparison output path.",
241
+ " --json Emit machine-readable command output.",
242
+ " --quiet Suppress non-essential output.",
243
+ "",
244
+ "Exit codes:",
245
+ " 0 No material differences found.",
246
+ " 1 Material differences found.",
247
+ " 2 Usage error.",
248
+ " 5 Schema or validation error.",
249
+ ].join("\n");
250
+ }
251
+
252
+ if (topic === "explain") {
253
+ return [
254
+ "Usage: botprint explain <profile-or-compare.json> --for-agent [options]",
255
+ "",
256
+ "Render a compact explanation for Codex, Claude Code, Cursor, or GitHub issues.",
257
+ "",
258
+ "Options:",
259
+ " --for-agent Emit agent-oriented Markdown.",
260
+ " --json Emit machine-readable command output.",
261
+ " --quiet Suppress non-essential output.",
262
+ ].join("\n");
263
+ }
264
+
265
+ if (topic === "guide" || topic === "tui") {
266
+ return [
267
+ "Usage: botprint guide",
268
+ "",
269
+ "Run a guided terminal flow over Botprint's existing commands.",
270
+ "",
271
+ "The guide helps collect profiles, list targets, compare profiles, render reports, and generate agent explanations.",
272
+ ].join("\n");
273
+ }
274
+
275
+ return [
276
+ "Usage: botprint <command> [options]",
277
+ "",
278
+ "Botprint shows what your automated browser looks like to the web, and what changed from your baseline.",
279
+ "",
280
+ "Commands:",
281
+ " run Collect a profile from an existing CDP endpoint.",
282
+ " targets List existing page targets from a CDP endpoint.",
283
+ " report Render a Markdown report from a profile.",
284
+ " compare Compare a baseline profile against a candidate profile.",
285
+ " explain Render a compact agent-friendly diagnosis.",
286
+ " guide Run a guided terminal flow. Alias: tui.",
287
+ "",
288
+ "Run `botprint help <command>` for command-specific help.",
289
+ ].join("\n");
290
+ }
291
+
292
+ export async function runCli(argv: string[], io: Pick<typeof console, "log" | "error"> = console): Promise<ExitCode> {
293
+ const parsed = parseArgs(argv);
294
+
295
+ if (!parsed.ok) {
296
+ io.error(parsed.message);
297
+ io.error("");
298
+ io.error(renderHelp());
299
+ return parsed.exitCode;
300
+ }
301
+
302
+ if (parsed.command.command === "help") {
303
+ io.log(renderHelp(parsed.command.options.topic));
304
+ return EXIT_CODES.success;
305
+ }
306
+
307
+ try {
308
+ return await runCommand(parsed.command, io);
309
+ } catch (error) {
310
+ if (error instanceof BotprintError) {
311
+ io.error(error.message);
312
+ return error.exitCode;
313
+ }
314
+
315
+ io.error(error instanceof Error ? error.message : String(error));
316
+ return EXIT_CODES.probeFailure;
317
+ }
318
+ }
319
+
320
+ async function runCommand(command: Exclude<ParsedCommand, { command: "help" }>, io: Pick<typeof console, "log">): Promise<ExitCode> {
321
+ switch (command.command) {
322
+ case "run": {
323
+ const profile = await runProfile(command.options);
324
+ if (!command.options.quiet) {
325
+ io.log(`Wrote Botprint profile to ${command.options.out}`);
326
+ io.log(`Browser: ${profile.source.browser_version || "unknown"}`);
327
+ io.log(`Target: ${profile.source.target_url}`);
328
+ io.log(`Findings: ${profile.findings.length}${profile.findings.length > 0 ? ` (${profile.findings.map((finding) => finding.id).join(", ")})` : ""}`);
329
+ for (const warning of profile.source.probe_warnings ?? []) {
330
+ io.log(`Note: ${warning}`);
331
+ }
332
+ }
333
+ return EXIT_CODES.success;
334
+ }
335
+ case "targets": {
336
+ const targets = await listTargets(command.options);
337
+ if (command.options.json) {
338
+ io.log(JSON.stringify(targets, null, 2));
339
+ } else if (!command.options.quiet) {
340
+ io.log(renderTargets(targets));
341
+ }
342
+ return EXIT_CODES.success;
343
+ }
344
+ case "report": {
345
+ const profile = await readProfile(command.options.profilePath);
346
+ const findings = analyzeProfile(profile);
347
+ const report = renderProfileMarkdown(profile, findings);
348
+
349
+ if (command.options.markdown !== undefined) {
350
+ await writeFile(command.options.markdown, report, "utf8");
351
+ }
352
+
353
+ if (command.options.json) {
354
+ io.log(JSON.stringify({ source: profile.source, findings }, null, 2));
355
+ } else if (!command.options.quiet) {
356
+ io.log(report.trimEnd());
357
+ }
358
+ return EXIT_CODES.success;
359
+ }
360
+ case "compare": {
361
+ const [baseline, candidate] = await Promise.all([
362
+ readProfile(command.options.baselinePath),
363
+ readProfile(command.options.candidatePath),
364
+ ]);
365
+ const result = compareProfiles({
366
+ baseline,
367
+ baselinePath: command.options.baselinePath,
368
+ candidate,
369
+ candidatePath: command.options.candidatePath,
370
+ });
371
+ const report = renderCompareMarkdown(result);
372
+
373
+ if (command.options.markdown !== undefined) {
374
+ await writeFile(command.options.markdown, report, "utf8");
375
+ }
376
+
377
+ if (command.options.json) {
378
+ io.log(JSON.stringify(result, null, 2));
379
+ } else if (!command.options.quiet) {
380
+ io.log(report.trimEnd());
381
+ }
382
+
383
+ return result.verdict.material ? EXIT_CODES.materialDifferences : EXIT_CODES.success;
384
+ }
385
+ case "explain": {
386
+ const input = await readJson(command.options.inputPath);
387
+ const explanation = renderAgentExplanation(input);
388
+
389
+ if (command.options.json) {
390
+ io.log(JSON.stringify({ explanation: explanation.trimEnd() }, null, 2));
391
+ } else if (!command.options.quiet) {
392
+ io.log(explanation.trimEnd());
393
+ }
394
+ return EXIT_CODES.success;
395
+ }
396
+ case "guide":
397
+ case "tui":
398
+ return runGuide();
399
+ default:
400
+ return assertNever(command);
401
+ }
402
+ }
403
+
404
+ function parseRun(flags: FlagValues): ParseResult {
405
+ const unsupported = rejectUnsupportedFlags(flags, "run", RUN_FLAGS);
406
+ if (unsupported !== undefined) {
407
+ return unsupported;
408
+ }
409
+
410
+ const positionalError = rejectPositionals(flags, "run");
411
+ if (positionalError !== undefined) {
412
+ return positionalError;
413
+ }
414
+ const cdp = requiredValue(flags, "cdp");
415
+ const out = requiredValue(flags, "out");
416
+ if (!cdp.ok) return cdp;
417
+ if (!out.ok) return out;
418
+
419
+ const timeout = optionalPositiveInteger(flags, "timeout");
420
+ if (!timeout.ok) return timeout;
421
+ const probeTimeout = optionalPositiveInteger(flags, "probe-timeout");
422
+ if (!probeTimeout.ok) return probeTimeout;
423
+ const target = optionalSingleValue(flags, "target");
424
+ const targetUrl = optionalSingleValue(flags, "target-url");
425
+ const context = optionalSingleValue(flags, "context");
426
+ if (target !== undefined && targetUrl !== undefined) {
427
+ return usageError("Use only one of --target or --target-url.");
428
+ }
429
+ if ((target !== undefined || targetUrl !== undefined) && (optionalSingleValue(flags, "diagnostic-bind") !== undefined || optionalSingleValue(flags, "diagnostic-host") !== undefined)) {
430
+ return usageError("--diagnostic-bind and --diagnostic-host are only valid for fresh diagnostic-page runs.");
431
+ }
432
+ if ((target !== undefined || targetUrl !== undefined) && context !== undefined) {
433
+ return usageError("--context is only valid for fresh diagnostic-page runs.");
434
+ }
435
+
436
+ return {
437
+ ok: true,
438
+ command: {
439
+ command: "run",
440
+ options: {
441
+ cdp: cdp.value,
442
+ out: out.value,
443
+ json: flagEnabled(flags, "json"),
444
+ quiet: flagEnabled(flags, "quiet"),
445
+ label: optionalSingleValue(flags, "label"),
446
+ timeout: timeout.value,
447
+ probeTimeout: probeTimeout.value,
448
+ authHeaders: flags.values.get("auth-header") ?? [],
449
+ diagnosticBind: optionalSingleValue(flags, "diagnostic-bind"),
450
+ diagnosticHost: optionalSingleValue(flags, "diagnostic-host"),
451
+ context,
452
+ target,
453
+ targetUrl,
454
+ },
455
+ },
456
+ };
457
+ }
458
+
459
+ function parseTargets(flags: FlagValues): ParseResult {
460
+ const unsupported = rejectUnsupportedFlags(flags, "targets", TARGETS_FLAGS);
461
+ if (unsupported !== undefined) {
462
+ return unsupported;
463
+ }
464
+
465
+ const positionalError = rejectPositionals(flags, "targets");
466
+ if (positionalError !== undefined) {
467
+ return positionalError;
468
+ }
469
+ const cdp = requiredValue(flags, "cdp");
470
+ if (!cdp.ok) return cdp;
471
+
472
+ const timeout = optionalPositiveInteger(flags, "timeout");
473
+ if (!timeout.ok) return timeout;
474
+
475
+ return {
476
+ ok: true,
477
+ command: {
478
+ command: "targets",
479
+ options: {
480
+ cdp: cdp.value,
481
+ json: flagEnabled(flags, "json"),
482
+ quiet: flagEnabled(flags, "quiet"),
483
+ timeout: timeout.value,
484
+ authHeaders: flags.values.get("auth-header") ?? [],
485
+ },
486
+ },
487
+ };
488
+ }
489
+
490
+ function parseReport(flags: FlagValues): ParseResult {
491
+ const unsupported = rejectUnsupportedFlags(flags, "report", REPORT_FLAGS);
492
+ if (unsupported !== undefined) {
493
+ return unsupported;
494
+ }
495
+
496
+ const profilePath = onePositional(flags, "report", "profile.json");
497
+ if (!profilePath.ok) return profilePath;
498
+
499
+ return {
500
+ ok: true,
501
+ command: {
502
+ command: "report",
503
+ options: {
504
+ profilePath: profilePath.value,
505
+ markdown: optionalSingleValue(flags, "markdown"),
506
+ json: flagEnabled(flags, "json"),
507
+ quiet: flagEnabled(flags, "quiet"),
508
+ },
509
+ },
510
+ };
511
+ }
512
+
513
+ function parseCompare(flags: FlagValues): ParseResult {
514
+ const unsupported = rejectUnsupportedFlags(flags, "compare", COMPARE_FLAGS);
515
+ if (unsupported !== undefined) {
516
+ return unsupported;
517
+ }
518
+
519
+ if (flags.positionals.length !== 2) {
520
+ return usageError("compare requires <baseline.json> and <candidate.json>.");
521
+ }
522
+
523
+ const [baselinePath, candidatePath] = flags.positionals;
524
+ if (baselinePath === undefined || candidatePath === undefined) {
525
+ return usageError("compare requires <baseline.json> and <candidate.json>.");
526
+ }
527
+
528
+ return {
529
+ ok: true,
530
+ command: {
531
+ command: "compare",
532
+ options: {
533
+ baselinePath,
534
+ candidatePath,
535
+ markdown: optionalSingleValue(flags, "markdown"),
536
+ json: flagEnabled(flags, "json"),
537
+ quiet: flagEnabled(flags, "quiet"),
538
+ },
539
+ },
540
+ };
541
+ }
542
+
543
+ function parseExplain(flags: FlagValues): ParseResult {
544
+ const unsupported = rejectUnsupportedFlags(flags, "explain", EXPLAIN_FLAGS);
545
+ if (unsupported !== undefined) {
546
+ return unsupported;
547
+ }
548
+
549
+ const inputPath = onePositional(flags, "explain", "profile-or-compare.json");
550
+ if (!inputPath.ok) return inputPath;
551
+
552
+ return {
553
+ ok: true,
554
+ command: {
555
+ command: "explain",
556
+ options: {
557
+ inputPath: inputPath.value,
558
+ forAgent: flagEnabled(flags, "for-agent"),
559
+ json: flagEnabled(flags, "json"),
560
+ quiet: flagEnabled(flags, "quiet"),
561
+ },
562
+ },
563
+ };
564
+ }
565
+
566
+ function parseGuide(flags: FlagValues, command: "guide" | "tui"): ParseResult {
567
+ const unsupported = rejectUnsupportedFlags(flags, command, GUIDE_FLAGS);
568
+ if (unsupported !== undefined) {
569
+ return unsupported;
570
+ }
571
+
572
+ const positionalError = rejectPositionals(flags, command);
573
+ if (positionalError !== undefined) {
574
+ return positionalError;
575
+ }
576
+
577
+ return {
578
+ ok: true,
579
+ command: {
580
+ command,
581
+ options: {},
582
+ },
583
+ };
584
+ }
585
+
586
+ function collectFlags(args: string[]): { ok: true; parsed: FlagValues } | ParseError {
587
+ const values = new Map<string, string[]>();
588
+ const positionals: string[] = [];
589
+
590
+ for (let index = 0; index < args.length; index += 1) {
591
+ const arg = args[index];
592
+ if (arg === undefined) {
593
+ continue;
594
+ }
595
+
596
+ if (!arg.startsWith("-")) {
597
+ positionals.push(arg);
598
+ continue;
599
+ }
600
+
601
+ if (!arg.startsWith("--") && arg !== "-h") {
602
+ return usageError(`Unknown short flag: ${arg}`);
603
+ }
604
+
605
+ const normalized = arg === "-h" ? "--help" : arg;
606
+ const body = normalized.slice(2);
607
+ const eqIndex = body.indexOf("=");
608
+ const rawName = eqIndex === -1 ? body : body.slice(0, eqIndex);
609
+ const inlineValue = eqIndex === -1 ? undefined : body.slice(eqIndex + 1);
610
+ if (rawName.length === 0) {
611
+ return usageError(`Invalid flag: ${arg}`);
612
+ }
613
+ const spec = FLAG_SPEC[rawName];
614
+ if (spec === undefined) {
615
+ return usageError(`Unknown flag: --${rawName}`);
616
+ }
617
+
618
+ let bucket = values.get(rawName);
619
+ if (bucket === undefined) {
620
+ bucket = [];
621
+ values.set(rawName, bucket);
622
+ }
623
+
624
+ if (!spec.takesValue) {
625
+ bucket.push("true");
626
+ continue;
627
+ }
628
+
629
+ const value = inlineValue ?? args[index + 1];
630
+ if (value === undefined || value === "" || value.startsWith("-")) {
631
+ return usageError(`Flag --${rawName} requires a value.`);
632
+ }
633
+
634
+ bucket.push(value);
635
+ if (inlineValue === undefined) {
636
+ index += 1;
637
+ }
638
+ }
639
+
640
+ return { ok: true, parsed: { values, positionals } };
641
+ }
642
+
643
+ function flagEnabled(flags: FlagValues, name: string): boolean {
644
+ return flags.values.has(name);
645
+ }
646
+
647
+ function rejectUnsupportedFlags(flags: FlagValues, command: CommandName, allowed: Set<string>): ParseError | undefined {
648
+ for (const name of flags.values.keys()) {
649
+ if (!allowed.has(name)) {
650
+ return usageError(`Flag --${name} is not valid for ${command}.`);
651
+ }
652
+ }
653
+ return undefined;
654
+ }
655
+
656
+ function requiredValue(flags: FlagValues, name: string): { ok: true; value: string } | ParseError {
657
+ const value = optionalSingleValue(flags, name);
658
+ if (value === undefined) {
659
+ return usageError(`Missing required flag --${name}.`);
660
+ }
661
+ return { ok: true, value };
662
+ }
663
+
664
+ function optionalSingleValue(flags: FlagValues, name: string): string | undefined {
665
+ const values = flags.values.get(name);
666
+ if (values === undefined) {
667
+ return undefined;
668
+ }
669
+ return values.at(-1);
670
+ }
671
+
672
+ function optionalPositiveInteger(flags: FlagValues, name: string): { ok: true; value: number | undefined } | ParseError {
673
+ const value = optionalSingleValue(flags, name);
674
+ if (value === undefined) {
675
+ return { ok: true, value: undefined };
676
+ }
677
+
678
+ const parsed = Number.parseInt(value, 10);
679
+ if (!Number.isSafeInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
680
+ return usageError(`Flag --${name} must be a positive integer.`);
681
+ }
682
+
683
+ return { ok: true, value: parsed };
684
+ }
685
+
686
+ function onePositional(flags: FlagValues, command: string, name: string): { ok: true; value: string } | ParseError {
687
+ if (flags.positionals.length !== 1) {
688
+ return usageError(`${command} requires <${name}>.`);
689
+ }
690
+
691
+ const value = flags.positionals[0];
692
+ if (value === undefined) {
693
+ return usageError(`${command} requires <${name}>.`);
694
+ }
695
+
696
+ return { ok: true, value };
697
+ }
698
+
699
+ function rejectPositionals(flags: FlagValues, command: string): ParseError | undefined {
700
+ if (flags.positionals.length > 0) {
701
+ return usageError(`${command} does not accept positional arguments.`);
702
+ }
703
+ return undefined;
704
+ }
705
+
706
+ function usageError(message: string): ParseError {
707
+ return {
708
+ ok: false,
709
+ exitCode: EXIT_CODES.usage,
710
+ message,
711
+ };
712
+ }
713
+
714
+ function renderTargets(targets: Awaited<ReturnType<typeof listTargets>>): string {
715
+ if (targets.length === 0) {
716
+ return "No page targets found.";
717
+ }
718
+
719
+ return targets
720
+ .map((target) => {
721
+ const title = target.title.length > 0 ? target.title : "(untitled)";
722
+ const url = target.url.length > 0 ? target.url : "about:blank";
723
+ const attached = target.attached ? " attached" : "";
724
+ const context = target.browserContextId === undefined ? "" : `\tcontext=${target.browserContextId}`;
725
+ return `${target.targetId}\t${target.type}${attached}\t${title}\t${url}${context}`;
726
+ })
727
+ .join("\n");
728
+ }
729
+
730
+ async function readJson(path: string): Promise<unknown> {
731
+ let raw: string;
732
+ try {
733
+ raw = await readFile(path, "utf8");
734
+ } catch (error) {
735
+ const message = error instanceof Error ? error.message : String(error);
736
+ throw new BotprintError(`Could not read input ${path}: ${message}`, EXIT_CODES.usage);
737
+ }
738
+
739
+ try {
740
+ return JSON.parse(raw);
741
+ } catch (error) {
742
+ const message = error instanceof Error ? error.message : String(error);
743
+ throw new BotprintError(`Input is not valid JSON: ${message}`, EXIT_CODES.schemaValidation);
744
+ }
745
+ }
746
+
747
+ function isCommand(value: string): value is CommandName {
748
+ return COMMANDS.has(value as CommandName);
749
+ }
750
+
751
+ function assertNever(value: never): never {
752
+ throw new Error(`Unexpected value: ${String(value)}`);
753
+ }
754
+
755
+ if (import.meta.main) {
756
+ const exitCode = await runCli(Bun.argv.slice(2));
757
+ process.exit(exitCode);
758
+ }