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/.claude/skills/agent-browser-loop/REFERENCE.md +374 -0
- package/.claude/skills/agent-browser-loop/SKILL.md +211 -0
- package/LICENSE +9 -0
- package/README.md +168 -0
- package/package.json +73 -0
- package/src/actions.ts +267 -0
- package/src/browser.ts +564 -0
- package/src/chrome.ts +45 -0
- package/src/cli.ts +795 -0
- package/src/commands.ts +455 -0
- package/src/config.ts +59 -0
- package/src/context.ts +20 -0
- package/src/daemon-entry.ts +4 -0
- package/src/daemon.ts +626 -0
- package/src/id.ts +109 -0
- package/src/index.ts +58 -0
- package/src/log.ts +42 -0
- package/src/server.ts +927 -0
- package/src/state.ts +602 -0
- package/src/types.ts +229 -0
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
|
+
});
|