agent-browser-loop 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.
package/src/cli.ts ADDED
@@ -0,0 +1,795 @@
1
+ #!/usr/bin/env bun
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import {
5
+ command,
6
+ flag,
7
+ number,
8
+ option,
9
+ optional,
10
+ positional,
11
+ restPositionals,
12
+ run,
13
+ string,
14
+ subcommands,
15
+ } from "cmd-ts";
16
+ import type { AgentBrowserOptions } from "./browser";
17
+ import type { StepAction } from "./commands";
18
+ import { parseBrowserConfig } from "./config";
19
+ import {
20
+ cleanupDaemonFiles,
21
+ DaemonClient,
22
+ ensureDaemon,
23
+ isDaemonRunning,
24
+ } from "./daemon";
25
+ import { log, withLog } from "./log";
26
+ import { startBrowserServer } from "./server";
27
+ import type { BrowserCliConfig } from "./types";
28
+
29
+ // ============================================================================
30
+ // Config Loading
31
+ // ============================================================================
32
+
33
+ const CONFIG_CANDIDATES = [
34
+ "agent.browser.config.ts",
35
+ "agent.browser.config.js",
36
+ "agent.browser.config.mjs",
37
+ "agent.browser.config.cjs",
38
+ "agent.browser.config.json",
39
+ ];
40
+
41
+ async function findConfigPath(explicitPath?: string): Promise<string | null> {
42
+ if (explicitPath) {
43
+ const resolved = path.resolve(explicitPath);
44
+ if (!(await Bun.file(resolved).exists())) {
45
+ throw new Error(`Config not found: ${resolved}`);
46
+ }
47
+ return resolved;
48
+ }
49
+
50
+ for (const candidate of CONFIG_CANDIDATES) {
51
+ const resolved = path.resolve(process.cwd(), candidate);
52
+ if (await Bun.file(resolved).exists()) {
53
+ return resolved;
54
+ }
55
+ }
56
+
57
+ return null;
58
+ }
59
+
60
+ async function loadConfig(configPath: string): Promise<BrowserCliConfig> {
61
+ const ext = path.extname(configPath).toLowerCase();
62
+ if (ext === ".json") {
63
+ const text = await Bun.file(configPath).text();
64
+ return parseBrowserConfig(JSON.parse(text));
65
+ }
66
+
67
+ const mod = await import(pathToFileURL(configPath).toString());
68
+ const exported = mod?.default ?? mod?.config ?? mod ?? null;
69
+
70
+ if (typeof exported === "function") {
71
+ const resolved = await exported();
72
+ return parseBrowserConfig(resolved);
73
+ }
74
+
75
+ if (!exported || typeof exported !== "object") {
76
+ throw new Error(`Config ${configPath} did not export a config object`);
77
+ }
78
+
79
+ return parseBrowserConfig(exported);
80
+ }
81
+
82
+ // ============================================================================
83
+ // Shared CLI Options
84
+ // ============================================================================
85
+
86
+ const sessionOption = option({
87
+ long: "session",
88
+ short: "s",
89
+ type: string,
90
+ defaultValue: () => "default",
91
+ description: "Session name (default: default)",
92
+ });
93
+
94
+ const headlessFlag = flag({
95
+ long: "headless",
96
+ description: "Run browser in headless mode",
97
+ });
98
+
99
+ const headedFlag = flag({
100
+ long: "headed",
101
+ description: "Run browser in headed mode",
102
+ });
103
+
104
+ const configOption = option({
105
+ long: "config",
106
+ short: "c",
107
+ type: optional(string),
108
+ description: "Path to config file",
109
+ });
110
+
111
+ const jsonFlag = flag({
112
+ long: "json",
113
+ description: "Output as JSON instead of text",
114
+ });
115
+
116
+ // ============================================================================
117
+ // Browser Options Resolution
118
+ // ============================================================================
119
+
120
+ async function resolveBrowserOptions(args: {
121
+ configPath?: string;
122
+ headless?: boolean;
123
+ headed?: boolean;
124
+ bundled?: boolean;
125
+ }): Promise<AgentBrowserOptions> {
126
+ const configPath = await findConfigPath(args.configPath);
127
+ const config = configPath ? await loadConfig(configPath) : undefined;
128
+
129
+ let headless: boolean | undefined;
130
+ if (args.headed) {
131
+ headless = false;
132
+ } else if (args.headless) {
133
+ headless = true;
134
+ } else {
135
+ headless = config?.headless;
136
+ }
137
+
138
+ const useSystemChrome = args.bundled ? false : config?.useSystemChrome;
139
+
140
+ return {
141
+ headless,
142
+ executablePath: config?.executablePath,
143
+ useSystemChrome,
144
+ viewportWidth: config?.viewportWidth,
145
+ viewportHeight: config?.viewportHeight,
146
+ userDataDir: config?.userDataDir,
147
+ timeout: config?.timeout,
148
+ captureNetwork: config?.captureNetwork,
149
+ networkLogLimit: config?.networkLogLimit,
150
+ storageStatePath: config?.storageStatePath,
151
+ };
152
+ }
153
+
154
+ // ============================================================================
155
+ // Action Parsing
156
+ // ============================================================================
157
+
158
+ /**
159
+ * Parse action strings into StepAction objects
160
+ * Formats:
161
+ * navigate:http://localhost:3000
162
+ * click:button_0
163
+ * type:input_0:hello world
164
+ * press:Enter
165
+ * scroll:down
166
+ * scroll:down:500
167
+ */
168
+ function parseAction(actionStr: string): StepAction {
169
+ const parts = actionStr.split(":");
170
+ const type = parts[0];
171
+
172
+ switch (type) {
173
+ case "navigate":
174
+ return { type: "navigate", url: parts.slice(1).join(":") };
175
+
176
+ case "click":
177
+ return { type: "click", ref: parts[1] };
178
+
179
+ case "type": {
180
+ const ref = parts[1];
181
+ const text = parts.slice(2).join(":");
182
+ return { type: "type", ref, text };
183
+ }
184
+
185
+ case "press":
186
+ return { type: "press", key: parts[1] };
187
+
188
+ case "scroll": {
189
+ const direction = parts[1] as "up" | "down";
190
+ const amount = parts[2] ? Number.parseInt(parts[2], 10) : undefined;
191
+ return { type: "scroll", direction, amount };
192
+ }
193
+
194
+ case "hover":
195
+ return { type: "hover", ref: parts[1] };
196
+
197
+ case "select": {
198
+ const ref = parts[1];
199
+ const value = parts.slice(2).join(":");
200
+ return { type: "select", ref, value };
201
+ }
202
+
203
+ default:
204
+ throw new Error(`Unknown action type: ${type}`);
205
+ }
206
+ }
207
+
208
+ // ============================================================================
209
+ // Commands
210
+ // ============================================================================
211
+
212
+ // --- skill installation helper ---
213
+ async function installSkillFiles(targetDir: string): Promise<boolean> {
214
+ const skillDir = path.join(targetDir, ".claude/skills/agent-browser-loop");
215
+
216
+ // Find skills source directory
217
+ let skillSourceDir: string | null = null;
218
+ const candidates = [
219
+ path.join(
220
+ process.cwd(),
221
+ "node_modules/agent-browser-loop/.claude/skills/agent-browser-loop",
222
+ ),
223
+ path.join(
224
+ path.dirname(import.meta.path),
225
+ "../.claude/skills/agent-browser-loop",
226
+ ),
227
+ ];
228
+
229
+ for (const candidate of candidates) {
230
+ if (await Bun.file(path.join(candidate, "SKILL.md")).exists()) {
231
+ skillSourceDir = candidate;
232
+ break;
233
+ }
234
+ }
235
+
236
+ if (!skillSourceDir) {
237
+ return false;
238
+ }
239
+
240
+ await Bun.$`mkdir -p ${skillDir}`;
241
+
242
+ // Copy SKILL.md
243
+ const skillContent = await Bun.file(
244
+ path.join(skillSourceDir, "SKILL.md"),
245
+ ).text();
246
+ await Bun.write(path.join(skillDir, "SKILL.md"), skillContent);
247
+
248
+ // Copy REFERENCE.md if it exists
249
+ const refPath = path.join(skillSourceDir, "REFERENCE.md");
250
+ if (await Bun.file(refPath).exists()) {
251
+ const refContent = await Bun.file(refPath).text();
252
+ await Bun.write(path.join(skillDir, "REFERENCE.md"), refContent);
253
+ }
254
+
255
+ return true;
256
+ }
257
+
258
+ // --- setup ---
259
+ const setupCommand = command({
260
+ name: "setup",
261
+ description: "Install Playwright browser and AI agent skill files",
262
+ args: {
263
+ skipSkill: flag({
264
+ long: "skip-skill",
265
+ description: "Skip installing skill files",
266
+ }),
267
+ target: option({
268
+ long: "target",
269
+ short: "t",
270
+ type: optional(string),
271
+ description: "Target directory for skill files (default: cwd)",
272
+ }),
273
+ },
274
+ handler: async (args) => {
275
+ // 1. Install Playwright browser
276
+ console.log("Installing Playwright Chromium...");
277
+ const { $ } = await import("bun");
278
+ try {
279
+ await $`bunx playwright install chromium`.text();
280
+ console.log("Browser installed.");
281
+ } catch (err) {
282
+ console.error("Failed to install browser:", err);
283
+ process.exit(1);
284
+ }
285
+
286
+ // 2. Install skill files (unless skipped)
287
+ if (!args.skipSkill) {
288
+ const targetDir = args.target ?? process.cwd();
289
+ console.log("\nInstalling skill files...");
290
+ const installed = await installSkillFiles(targetDir);
291
+ if (installed) {
292
+ console.log(
293
+ `Skills installed to ${targetDir}/.claude/skills/agent-browser-loop/`,
294
+ );
295
+ } else {
296
+ console.warn("Warning: Could not find skill files to install.");
297
+ }
298
+ }
299
+
300
+ console.log("\nDone! Run 'agent-browser open <url>' to start.");
301
+ },
302
+ });
303
+
304
+ // --- open ---
305
+ const openCommand = command({
306
+ name: "open",
307
+ description: "Open URL in browser (auto-starts daemon)",
308
+ args: {
309
+ url: positional({ type: string, displayName: "url" }),
310
+ session: sessionOption,
311
+ headless: headlessFlag,
312
+ headed: headedFlag,
313
+ config: configOption,
314
+ json: jsonFlag,
315
+ },
316
+ handler: async (args) => {
317
+ const browserOptions = await resolveBrowserOptions(args);
318
+ const client = await ensureDaemon(args.session, browserOptions);
319
+
320
+ const response = await client.act([{ type: "navigate", url: args.url }]);
321
+
322
+ if (!response.success) {
323
+ console.error("Error:", response.error);
324
+ process.exit(1);
325
+ }
326
+
327
+ const data = response.data as { text?: string };
328
+ if (args.json) {
329
+ console.log(JSON.stringify(response.data, null, 2));
330
+ } else {
331
+ console.log(data.text ?? "Navigated successfully");
332
+ }
333
+ },
334
+ });
335
+
336
+ // --- act ---
337
+ const actCommand = command({
338
+ name: "act",
339
+ description:
340
+ "Execute actions: click:ref, type:ref:text, press:key, scroll:dir",
341
+ args: {
342
+ actions: restPositionals({ type: string, displayName: "actions" }),
343
+ session: sessionOption,
344
+ headless: headlessFlag,
345
+ headed: headedFlag,
346
+ config: configOption,
347
+ json: jsonFlag,
348
+ noState: flag({
349
+ long: "no-state",
350
+ description: "Don't return state after actions",
351
+ }),
352
+ },
353
+ handler: async (args) => {
354
+ if (args.actions.length === 0) {
355
+ console.error("No actions provided");
356
+ console.error(
357
+ "Usage: agent-browser act click:button_0 type:input_0:hello",
358
+ );
359
+ process.exit(1);
360
+ }
361
+
362
+ const browserOptions = await resolveBrowserOptions(args);
363
+ const client = await ensureDaemon(args.session, browserOptions);
364
+
365
+ const actions = args.actions.map(parseAction);
366
+ const response = await client.act(actions, {
367
+ includeStateText: !args.noState,
368
+ });
369
+
370
+ if (!response.success) {
371
+ console.error("Error:", response.error);
372
+ process.exit(1);
373
+ }
374
+
375
+ const data = response.data as { text?: string; error?: string };
376
+ if (args.json) {
377
+ console.log(JSON.stringify(response.data, null, 2));
378
+ } else {
379
+ console.log(data.text ?? "Actions completed");
380
+ }
381
+
382
+ if (data.error) {
383
+ process.exit(1);
384
+ }
385
+ },
386
+ });
387
+
388
+ // --- wait ---
389
+ const waitCommand = command({
390
+ name: "wait",
391
+ description: "Wait for --text, --selector, --url, or --not-* conditions",
392
+ args: {
393
+ session: sessionOption,
394
+ selector: option({
395
+ long: "selector",
396
+ type: optional(string),
397
+ description: "Wait for selector to be visible",
398
+ }),
399
+ text: option({
400
+ long: "text",
401
+ type: optional(string),
402
+ description: "Wait for text to appear",
403
+ }),
404
+ url: option({
405
+ long: "url",
406
+ type: optional(string),
407
+ description: "Wait for URL to match",
408
+ }),
409
+ notSelector: option({
410
+ long: "not-selector",
411
+ type: optional(string),
412
+ description: "Wait for selector to disappear",
413
+ }),
414
+ notText: option({
415
+ long: "not-text",
416
+ type: optional(string),
417
+ description: "Wait for text to disappear",
418
+ }),
419
+ timeout: option({
420
+ long: "timeout",
421
+ type: number,
422
+ defaultValue: () => 30000,
423
+ description: "Timeout in ms (default: 30000)",
424
+ }),
425
+ json: jsonFlag,
426
+ },
427
+ handler: async (args) => {
428
+ const condition = {
429
+ selector: args.selector,
430
+ text: args.text,
431
+ url: args.url,
432
+ notSelector: args.notSelector,
433
+ notText: args.notText,
434
+ };
435
+
436
+ if (
437
+ !condition.selector &&
438
+ !condition.text &&
439
+ !condition.url &&
440
+ !condition.notSelector &&
441
+ !condition.notText
442
+ ) {
443
+ console.error("No wait condition provided");
444
+ console.error(
445
+ 'Usage: agent-browser wait --text "Welcome" --timeout 5000',
446
+ );
447
+ process.exit(1);
448
+ }
449
+
450
+ const client = new DaemonClient(args.session);
451
+ if (!(await client.ping())) {
452
+ console.error(
453
+ "Daemon not running. Use 'agent-browser open <url>' first.",
454
+ );
455
+ process.exit(1);
456
+ }
457
+
458
+ const response = await client.wait(condition, { timeoutMs: args.timeout });
459
+
460
+ if (!response.success) {
461
+ console.error("Error:", response.error);
462
+ process.exit(1);
463
+ }
464
+
465
+ const data = response.data as { text?: string };
466
+ if (args.json) {
467
+ console.log(JSON.stringify(response.data, null, 2));
468
+ } else {
469
+ console.log(data.text ?? "Wait completed");
470
+ }
471
+ },
472
+ });
473
+
474
+ // --- state ---
475
+ const stateCommand = command({
476
+ name: "state",
477
+ description: "Get current browser state",
478
+ args: {
479
+ session: sessionOption,
480
+ json: jsonFlag,
481
+ },
482
+ handler: async (args) => {
483
+ const client = new DaemonClient(args.session);
484
+ if (!(await client.ping())) {
485
+ console.error(
486
+ "Daemon not running. Use 'agent-browser open <url>' first.",
487
+ );
488
+ process.exit(1);
489
+ }
490
+
491
+ const response = await client.state({
492
+ format: args.json ? "json" : "text",
493
+ });
494
+
495
+ if (!response.success) {
496
+ console.error("Error:", response.error);
497
+ process.exit(1);
498
+ }
499
+
500
+ const data = response.data as { text?: string; state?: unknown };
501
+ if (args.json) {
502
+ console.log(JSON.stringify(data.state, null, 2));
503
+ } else {
504
+ console.log(data.text);
505
+ }
506
+ },
507
+ });
508
+
509
+ // --- screenshot ---
510
+ const screenshotCommand = command({
511
+ name: "screenshot",
512
+ description: "Take a screenshot (outputs base64 or saves to file)",
513
+ args: {
514
+ session: sessionOption,
515
+ output: option({
516
+ long: "output",
517
+ short: "o",
518
+ type: optional(string),
519
+ description: "Save to file path instead of base64 output",
520
+ }),
521
+ fullPage: flag({
522
+ long: "full-page",
523
+ description: "Capture full scrollable page",
524
+ }),
525
+ },
526
+ handler: async (args) => {
527
+ const client = new DaemonClient(args.session);
528
+ if (!(await client.ping())) {
529
+ console.error(
530
+ "Daemon not running. Use 'agent-browser open <url>' first.",
531
+ );
532
+ process.exit(1);
533
+ }
534
+
535
+ const response = await client.screenshot({
536
+ fullPage: args.fullPage,
537
+ });
538
+
539
+ if (!response.success) {
540
+ console.error("Error:", response.error);
541
+ process.exit(1);
542
+ }
543
+
544
+ const data = response.data as { base64: string };
545
+
546
+ if (args.output) {
547
+ // Write to file
548
+ const buffer = Buffer.from(data.base64, "base64");
549
+ await Bun.write(args.output, buffer);
550
+ console.log(`Screenshot saved to ${args.output}`);
551
+ } else {
552
+ // Output base64
553
+ console.log(data.base64);
554
+ }
555
+ },
556
+ });
557
+
558
+ // --- close ---
559
+ const closeCommand = command({
560
+ name: "close",
561
+ description: "Close the browser and stop the daemon",
562
+ args: {
563
+ session: sessionOption,
564
+ },
565
+ handler: async (args) => {
566
+ const client = new DaemonClient(args.session);
567
+
568
+ if (!(await client.ping())) {
569
+ console.log("Daemon not running.");
570
+ cleanupDaemonFiles(args.session);
571
+ return;
572
+ }
573
+
574
+ try {
575
+ await client.shutdown();
576
+ console.log("Browser closed.");
577
+ } catch {
578
+ // Daemon may have already exited
579
+ cleanupDaemonFiles(args.session);
580
+ console.log("Browser closed.");
581
+ }
582
+ },
583
+ });
584
+
585
+ // --- status ---
586
+ const statusCommand = command({
587
+ name: "status",
588
+ description: "Check if daemon is running",
589
+ args: {
590
+ session: sessionOption,
591
+ },
592
+ handler: async (args) => {
593
+ const running = isDaemonRunning(args.session);
594
+ console.log(
595
+ `Session "${args.session}": ${running ? "running" : "not running"}`,
596
+ );
597
+ },
598
+ });
599
+
600
+ // --- server (renamed from start) ---
601
+ const serverCommand = command({
602
+ name: "server",
603
+ description: "Start the HTTP server (multi-session mode)",
604
+ args: {
605
+ configPath: configOption,
606
+ host: option({
607
+ long: "host",
608
+ type: string,
609
+ defaultValue: () => "",
610
+ description: "Hostname to bind (default: localhost)",
611
+ }),
612
+ port: option({
613
+ long: "port",
614
+ type: number,
615
+ defaultValue: () => 0,
616
+ description: "Port to bind (default: 3790)",
617
+ }),
618
+ sessionTtlMs: option({
619
+ long: "session-ttl",
620
+ type: number,
621
+ defaultValue: () => 0,
622
+ description: "Session TTL in ms (0 = no expiry)",
623
+ }),
624
+ headless: headlessFlag,
625
+ headed: headedFlag,
626
+ viewportWidth: option({
627
+ long: "viewport-width",
628
+ type: number,
629
+ defaultValue: () => 0,
630
+ description: "Viewport width (default: 1280)",
631
+ }),
632
+ viewportHeight: option({
633
+ long: "viewport-height",
634
+ type: number,
635
+ defaultValue: () => 0,
636
+ description: "Viewport height (default: 720)",
637
+ }),
638
+ executablePath: option({
639
+ long: "executable-path",
640
+ type: optional(string),
641
+ description: "Path to Chrome executable",
642
+ }),
643
+ userDataDir: option({
644
+ long: "user-data-dir",
645
+ type: optional(string),
646
+ description: "Path to Chrome user data directory",
647
+ }),
648
+ timeout: option({
649
+ long: "timeout",
650
+ type: number,
651
+ defaultValue: () => 0,
652
+ description: "Default timeout in ms (default: 30000)",
653
+ }),
654
+ noNetwork: flag({
655
+ long: "no-network",
656
+ description: "Disable network request capture",
657
+ }),
658
+ networkLogLimit: option({
659
+ long: "network-log-limit",
660
+ type: number,
661
+ defaultValue: () => 0,
662
+ description: "Max network events to keep (default: 100)",
663
+ }),
664
+ storageStatePath: option({
665
+ long: "storage-state",
666
+ type: optional(string),
667
+ description: "Path to storage state JSON file",
668
+ }),
669
+ bundled: flag({
670
+ long: "bundled",
671
+ description: "Use bundled Playwright Chromium",
672
+ }),
673
+ },
674
+ handler: (args) =>
675
+ withLog({ command: "server" }, async () => {
676
+ const configPath = await findConfigPath(args.configPath);
677
+ const config = configPath ? await loadConfig(configPath) : undefined;
678
+ if (configPath) {
679
+ console.log(`Using config: ${configPath}`);
680
+ }
681
+
682
+ let headless: boolean | undefined;
683
+ if (args.headed) {
684
+ headless = false;
685
+ } else if (args.headless) {
686
+ headless = true;
687
+ } else {
688
+ headless = config?.headless;
689
+ }
690
+
691
+ const useSystemChrome = args.bundled ? false : config?.useSystemChrome;
692
+
693
+ const browserOptions: AgentBrowserOptions = {
694
+ headless,
695
+ executablePath: args.executablePath ?? config?.executablePath,
696
+ useSystemChrome,
697
+ viewportWidth: args.viewportWidth || config?.viewportWidth,
698
+ viewportHeight: args.viewportHeight || config?.viewportHeight,
699
+ userDataDir: args.userDataDir ?? config?.userDataDir,
700
+ timeout: args.timeout || config?.timeout,
701
+ captureNetwork: args.noNetwork ? false : config?.captureNetwork,
702
+ networkLogLimit: args.networkLogLimit || config?.networkLogLimit,
703
+ storageStatePath: args.storageStatePath ?? config?.storageStatePath,
704
+ };
705
+
706
+ const host = args.host.trim() || config?.serverHost || "localhost";
707
+ const port = args.port || config?.serverPort || 3790;
708
+ const sessionTtlMs = args.sessionTtlMs || config?.serverSessionTtlMs;
709
+
710
+ const server = startBrowserServer({
711
+ host,
712
+ port,
713
+ sessionTtlMs,
714
+ browserOptions,
715
+ });
716
+
717
+ const serverUrl = `http://${server.host}:${server.port}`;
718
+ console.log(`Browser server running at ${serverUrl}`);
719
+ console.log(`Create session: POST ${serverUrl}/session`);
720
+
721
+ const shutdown = async () => {
722
+ console.log("\nShutting down...");
723
+ await server.close();
724
+ process.exit(0);
725
+ };
726
+ process.on("SIGINT", shutdown);
727
+ process.on("SIGTERM", shutdown);
728
+
729
+ await new Promise(() => {});
730
+ }),
731
+ });
732
+
733
+ // start is now just an alias - use server directly in the subcommands
734
+
735
+ // --- install-skill (kept for backwards compat, use setup instead) ---
736
+ const installSkillCommand = command({
737
+ name: "install-skill",
738
+ description:
739
+ "Install skill files only (prefer 'setup' for full installation)",
740
+ args: {
741
+ target: option({
742
+ long: "target",
743
+ short: "t",
744
+ type: optional(string),
745
+ description: "Target directory (default: cwd)",
746
+ }),
747
+ },
748
+ handler: async (args) => {
749
+ const targetDir = args.target ?? process.cwd();
750
+ const installed = await installSkillFiles(targetDir);
751
+ if (installed) {
752
+ console.log(
753
+ `Installed skills to ${targetDir}/.claude/skills/agent-browser-loop/`,
754
+ );
755
+ } else {
756
+ console.error("Could not find skill files");
757
+ process.exit(1);
758
+ }
759
+ },
760
+ });
761
+
762
+ // ============================================================================
763
+ // Main CLI
764
+ // ============================================================================
765
+
766
+ const cli = subcommands({
767
+ name: "agent-browser",
768
+ cmds: {
769
+ // Primary CLI commands (daemon-based)
770
+ open: openCommand,
771
+ act: actCommand,
772
+ wait: waitCommand,
773
+ state: stateCommand,
774
+ screenshot: screenshotCommand,
775
+ close: closeCommand,
776
+ status: statusCommand,
777
+
778
+ // Setup & configuration
779
+ setup: setupCommand,
780
+ "install-skill": installSkillCommand,
781
+
782
+ // HTTP server mode
783
+ server: serverCommand,
784
+ start: serverCommand, // backwards compat alias
785
+ },
786
+ });
787
+
788
+ run(cli, process.argv.slice(2)).catch((error) => {
789
+ log
790
+ .withError(error)
791
+ .withMetadata({ argv: process.argv.slice(2) })
792
+ .error("CLI failed");
793
+ console.error(error);
794
+ process.exit(1);
795
+ });