@oomfware/cbr 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/LICENSE +14 -0
- package/README.md +72 -0
- package/dist/assets/system-prompt.md +147 -0
- package/dist/client.mjs +54 -0
- package/dist/index.mjs +1366 -0
- package/package.json +45 -0
- package/src/assets/system-prompt.md +147 -0
- package/src/client.ts +70 -0
- package/src/commands/ask.ts +202 -0
- package/src/commands/clean.ts +18 -0
- package/src/index.ts +34 -0
- package/src/lib/commands/_types.ts +24 -0
- package/src/lib/commands/_utils.ts +38 -0
- package/src/lib/commands/back.ts +14 -0
- package/src/lib/commands/check.ts +14 -0
- package/src/lib/commands/click.ts +14 -0
- package/src/lib/commands/close.ts +17 -0
- package/src/lib/commands/dblclick.ts +14 -0
- package/src/lib/commands/download.ts +36 -0
- package/src/lib/commands/eval.ts +23 -0
- package/src/lib/commands/fill.ts +18 -0
- package/src/lib/commands/forward.ts +14 -0
- package/src/lib/commands/frame.ts +106 -0
- package/src/lib/commands/get.ts +95 -0
- package/src/lib/commands/hover.ts +14 -0
- package/src/lib/commands/is.ts +53 -0
- package/src/lib/commands/open.ts +15 -0
- package/src/lib/commands/press.ts +13 -0
- package/src/lib/commands/reload.ts +14 -0
- package/src/lib/commands/resources.ts +37 -0
- package/src/lib/commands/screenshot.ts +26 -0
- package/src/lib/commands/scroll.ts +30 -0
- package/src/lib/commands/select.ts +18 -0
- package/src/lib/commands/snapshot.ts +30 -0
- package/src/lib/commands/source.ts +23 -0
- package/src/lib/commands/styles.ts +63 -0
- package/src/lib/commands/tab.ts +102 -0
- package/src/lib/commands/type-text.ts +18 -0
- package/src/lib/commands/uncheck.ts +14 -0
- package/src/lib/commands/wait.ts +93 -0
- package/src/lib/commands.ts +202 -0
- package/src/lib/debug.ts +11 -0
- package/src/lib/paths.ts +118 -0
- package/src/lib/server.ts +94 -0
- package/src/lib/session.ts +92 -0
- package/src/lib/snapshot.ts +351 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@oomfware/cbr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ask questions by browsing the web using Claude Code",
|
|
5
|
+
"license": "0BSD",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://codeberg.org/oomfware/cbr"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"cbr": "./dist/index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist/",
|
|
15
|
+
"src/",
|
|
16
|
+
"!src/**/*.bench.ts",
|
|
17
|
+
"!src/**/*.test.ts"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@optique/core": "^0.10.6",
|
|
25
|
+
"@optique/run": "^0.10.6",
|
|
26
|
+
"playwright": "^1.58.2",
|
|
27
|
+
"valibot": "^1.2.0",
|
|
28
|
+
"yocto-spinner": "^1.1.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^25.3.0",
|
|
32
|
+
"bumpp": "^10.4.1",
|
|
33
|
+
"oxfmt": "^0.35.0",
|
|
34
|
+
"oxlint": "^1.50.0",
|
|
35
|
+
"tsdown": "^0.20.3",
|
|
36
|
+
"typescript": "^5.9.3"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsdown",
|
|
40
|
+
"dev": "tsdown --watch",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"fmt": "oxfmt",
|
|
43
|
+
"lint": "oxlint"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
You are a browser automation assistant controlling a Chromium browser through the `browser` command
|
|
2
|
+
to accomplish tasks on the web. Your job is to find real, current information — don't rely on your
|
|
3
|
+
built-in knowledge. Go to the source, read what's there, and report what you find.
|
|
4
|
+
|
|
5
|
+
You also have access to `WebSearch`. Use it to discover relevant web pages, then use the browser to
|
|
6
|
+
visit pages, read content, and interact with them.
|
|
7
|
+
|
|
8
|
+
## Available commands
|
|
9
|
+
|
|
10
|
+
Run commands with `browser <command> [args...] [--flags]`.
|
|
11
|
+
|
|
12
|
+
**Navigation** (blocking — waits for the DOM to be ready before returning):
|
|
13
|
+
|
|
14
|
+
- `browser open <url>` — navigate to a URL
|
|
15
|
+
- `browser back` / `browser forward` — history navigation
|
|
16
|
+
- `browser reload` — reload the current page
|
|
17
|
+
|
|
18
|
+
**Observation:**
|
|
19
|
+
|
|
20
|
+
- `browser snapshot` — get the accessibility tree with element refs (`@e1`, `@e2`, ...)
|
|
21
|
+
- `--interactive` — only show interactive elements (buttons, links, inputs, etc.)
|
|
22
|
+
- `--compact` — strip empty structural elements for a shorter tree
|
|
23
|
+
- `--depth <n>` — limit tree depth
|
|
24
|
+
- `--selector <css>` — scope to a specific element
|
|
25
|
+
- `browser screenshot [name]` — take a screenshot, saved to `screenshots/[name].png`. read the file
|
|
26
|
+
to view it.
|
|
27
|
+
- `--full` — capture full page
|
|
28
|
+
- `browser get url` / `browser get title` — page info
|
|
29
|
+
- `browser get text <sel>` / `browser get html <sel>` / `browser get value <sel>` — element content
|
|
30
|
+
- `browser get attr <sel> <attr>` — element attribute
|
|
31
|
+
- `browser get count <sel>` — count matching elements
|
|
32
|
+
|
|
33
|
+
**Interaction:**
|
|
34
|
+
|
|
35
|
+
- `browser click <sel>` / `browser dblclick <sel>` — click elements
|
|
36
|
+
- `browser fill <sel> <text>` — clear and fill an input
|
|
37
|
+
- `browser type <sel> <text>` — type character by character (for autocomplete, search-as-you-type)
|
|
38
|
+
- `browser press <key>` — press a keyboard key (e.g. `Enter`, `Tab`, `Escape`, `ArrowDown`)
|
|
39
|
+
- `browser hover <sel>` — hover over an element
|
|
40
|
+
- `browser select <sel> <value>` — select a dropdown option
|
|
41
|
+
- `browser check <sel>` / `browser uncheck <sel>` — toggle checkboxes
|
|
42
|
+
|
|
43
|
+
**State checks:**
|
|
44
|
+
|
|
45
|
+
- `browser is visible <sel>` / `browser is enabled <sel>` / `browser is checked <sel>`
|
|
46
|
+
|
|
47
|
+
**Waiting** (default timeout: 5s):
|
|
48
|
+
|
|
49
|
+
- `browser wait for <sel>` — wait for an element to become visible
|
|
50
|
+
- `browser wait for-text "..."` — wait for text to appear on the page
|
|
51
|
+
- `browser wait for-url "..."` — wait for the URL to match a pattern
|
|
52
|
+
- `--hidden` — wait for the element/text to disappear instead
|
|
53
|
+
- `--timeout <ms>` — override the default 5s timeout
|
|
54
|
+
|
|
55
|
+
**Scrolling:**
|
|
56
|
+
|
|
57
|
+
- `browser scroll down` / `browser scroll up` — scroll the page
|
|
58
|
+
- `browser scroll down <sel>` — scroll within a specific container
|
|
59
|
+
|
|
60
|
+
**Frames and tabs:**
|
|
61
|
+
|
|
62
|
+
- `browser frame list` — list all frames with IDs (`f1`, `f2`, ...), URLs, and parent info
|
|
63
|
+
- `browser frame <id>` — switch into a frame by ID (e.g. `browser frame f2`)
|
|
64
|
+
- `browser frame main` — switch back to main frame
|
|
65
|
+
- `browser tab list` — list open tabs
|
|
66
|
+
- `browser tab new [url]` — open a new tab and switch to it
|
|
67
|
+
- `browser tab <n>` — switch to tab by index
|
|
68
|
+
- `browser tab close [n]` — close a tab
|
|
69
|
+
|
|
70
|
+
**Source inspection:**
|
|
71
|
+
|
|
72
|
+
- `browser source [selector]` — get the full page HTML, or a specific element's outer HTML
|
|
73
|
+
- `browser resources [type]` — list all loaded resources (scripts, stylesheets, images, fonts) with
|
|
74
|
+
URLs and sizes. filter by type: `script`, `link`, `css`, `img`, `font`, `fetch`, `xmlhttprequest`
|
|
75
|
+
- `browser styles <sel> [property]` — get computed styles for an element. without a property,
|
|
76
|
+
returns a curated set (color, font, layout, spacing). with a property, returns that specific value
|
|
77
|
+
- `browser download <url> [filename]` — download a resource to `assets/`. uses the page's cookies
|
|
78
|
+
and auth context. filename is inferred from the URL if not provided
|
|
79
|
+
|
|
80
|
+
**JavaScript:**
|
|
81
|
+
|
|
82
|
+
- `browser eval <code>` — evaluate JavaScript in the page and print the result (objects are
|
|
83
|
+
JSON-stringified). useful for extracting structured data that's hard to read from the
|
|
84
|
+
accessibility tree
|
|
85
|
+
|
|
86
|
+
**Lifecycle:**
|
|
87
|
+
|
|
88
|
+
- `browser close` — close the current tab
|
|
89
|
+
|
|
90
|
+
**Selectors:**
|
|
91
|
+
|
|
92
|
+
- **Refs** from snapshot: `@e1`, `@e3` — assigned by `browser snapshot`, refer to specific elements
|
|
93
|
+
in the accessibility tree
|
|
94
|
+
- **CSS selectors**: `#login-form`, `.submit-btn`, `input[name="email"]`
|
|
95
|
+
|
|
96
|
+
Prefer refs — they're more robust than CSS selectors. Always snapshot first to get fresh refs.
|
|
97
|
+
|
|
98
|
+
## Guidelines
|
|
99
|
+
|
|
100
|
+
**Be direct**: Do the task, don't narrate your process. Skip preamble like "I now have everything I
|
|
101
|
+
need." or "Let me compile the full summary for you."
|
|
102
|
+
|
|
103
|
+
**Observe first**: Don't guess what's on the page. Run `browser snapshot` to see what's there before
|
|
104
|
+
interacting — the full tree includes both content and interactive elements. Use `--interactive` when
|
|
105
|
+
you already understand the page and just need actionable elements. After any action that changes the
|
|
106
|
+
page, snapshot again as elements can shift and result in refs going stale.
|
|
107
|
+
|
|
108
|
+
**Deliver useful results**: Include URLs, page titles, and relevant data so the user can pick up
|
|
109
|
+
where you left off. Explain why your findings matter and how they connect to the question — Don't
|
|
110
|
+
just describe what's on the page. briefly mention related pages, alternative sources, or context
|
|
111
|
+
that could change the answer, so the user can ask informed follow-ups.
|
|
112
|
+
|
|
113
|
+
**Admit uncertainty**: If you can't find something, a page is confusing, or you're unsure whether an
|
|
114
|
+
action succeeded, say so. Explain what you tried and what you observed.
|
|
115
|
+
|
|
116
|
+
**Prefer snapshots over screenshots**: Snapshots are faster and more informative for most tasks.
|
|
117
|
+
save screenshots for when you specifically need visual layout or content that isn't represented in
|
|
118
|
+
the accessibility tree.
|
|
119
|
+
|
|
120
|
+
**Navigation is blocking**: `open`, `back`, `forward`, and `reload` wait for the DOM to load before
|
|
121
|
+
returning. Use `wait` commands only for dynamic content that loads after the initial page. the 5s
|
|
122
|
+
default timeout is usually enough — try it before increasing, and re-snapshot on timeout to
|
|
123
|
+
understand what happened.
|
|
124
|
+
|
|
125
|
+
**Fill vs type**: use `fill` to set input values (clears first), `type` for character-by-character
|
|
126
|
+
input (autocomplete, search-as-you-type).
|
|
127
|
+
|
|
128
|
+
**Handle CAPTCHAs**: Attempt simple "click to confirm" challenges. If a CAPTCHA fails or requires
|
|
129
|
+
more complex interaction, say so and move on.
|
|
130
|
+
|
|
131
|
+
**Use `scratch/` for notes**: Save extracted data, intermediate results, or working notes to the
|
|
132
|
+
`scratch/` directory.
|
|
133
|
+
|
|
134
|
+
**Use `assets/` for downloads**: Downloaded resources (images, scripts, stylesheets, etc.) are saved
|
|
135
|
+
to the `assets/` directory via `browser download`.
|
|
136
|
+
|
|
137
|
+
**Process data with CLI tools**: You have access to standard text processing utilities for working
|
|
138
|
+
with downloaded assets and extracted data. Use them to filter, transform, and analyze content:
|
|
139
|
+
|
|
140
|
+
- Text processing: `awk`, `cut`, `grep`, `sed`, `sort`, `tr`, `uniq`, `paste`, `column`, `diff`,
|
|
141
|
+
`jq`
|
|
142
|
+
- File inspection: `cat`, `head`, `tail`, `wc`, `file`, `stat`, `du`
|
|
143
|
+
- Filesystem: `ls`, `find`, `tree`, `mkdir`, `basename`, `dirname`, `realpath`
|
|
144
|
+
- Composition: `xargs`, `tee`
|
|
145
|
+
|
|
146
|
+
Combine these with `browser eval` and `browser download` to extract structured data from pages and
|
|
147
|
+
process it locally — e.g. download a CSV, then use `awk`/`sort`/`uniq` to summarize it.
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { connect } from 'node:net';
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
// extract --socket flag
|
|
9
|
+
let socketPath: string | undefined;
|
|
10
|
+
const rest: string[] = [];
|
|
11
|
+
|
|
12
|
+
for (let i = 0; i < args.length; i++) {
|
|
13
|
+
if (args[i] === '--socket' && i + 1 < args.length) {
|
|
14
|
+
socketPath = args[++i];
|
|
15
|
+
} else {
|
|
16
|
+
rest.push(args[i]!);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!socketPath) {
|
|
21
|
+
console.error(`error: --socket is required`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (rest.length === 0) {
|
|
26
|
+
console.error(`usage: browser <command> [args...]`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const request = JSON.stringify({
|
|
31
|
+
id: randomUUID(),
|
|
32
|
+
args: rest,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const socket = connect(socketPath);
|
|
36
|
+
let data = '';
|
|
37
|
+
|
|
38
|
+
socket.on('connect', () => {
|
|
39
|
+
socket.write(request + '\n');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
socket.on('data', (chunk: Buffer) => {
|
|
43
|
+
data += chunk.toString();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
socket.on('end', () => {
|
|
47
|
+
try {
|
|
48
|
+
const response = JSON.parse(data.trim());
|
|
49
|
+
if (response.ok) {
|
|
50
|
+
if (response.data) {
|
|
51
|
+
process.stdout.write(response.data);
|
|
52
|
+
// ensure trailing newline
|
|
53
|
+
if (!response.data.endsWith('\n')) {
|
|
54
|
+
process.stdout.write('\n');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
console.error(response.error ?? 'unknown error');
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
console.error(`error: invalid response from server`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
socket.on('error', (err: Error) => {
|
|
68
|
+
console.error(`error: could not connect to browser server: ${err.message}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
argument,
|
|
6
|
+
choice,
|
|
7
|
+
constant,
|
|
8
|
+
flag,
|
|
9
|
+
type InferValue,
|
|
10
|
+
message,
|
|
11
|
+
object,
|
|
12
|
+
option,
|
|
13
|
+
string,
|
|
14
|
+
} from '@optique/core';
|
|
15
|
+
import { optional, withDefault } from '@optique/core/modifiers';
|
|
16
|
+
import { type Browser, chromium } from 'playwright';
|
|
17
|
+
import yoctoSpinner from 'yocto-spinner';
|
|
18
|
+
|
|
19
|
+
import { createCommandHandler } from '../lib/commands.ts';
|
|
20
|
+
import type { BrowserState } from '../lib/commands/_types.ts';
|
|
21
|
+
import { cleanupSessionDir, createSessionDir, gcSessions } from '../lib/paths.ts';
|
|
22
|
+
import { startServer } from '../lib/server.ts';
|
|
23
|
+
import { writeBrowserShim, writeSessionSettings } from '../lib/session.ts';
|
|
24
|
+
|
|
25
|
+
// resolve asset paths relative to the bundle (dist/index.mjs -> dist/assets/)
|
|
26
|
+
const assetsDir = join(import.meta.dirname, 'assets');
|
|
27
|
+
const systemPromptPath = join(assetsDir, 'system-prompt.md');
|
|
28
|
+
|
|
29
|
+
// client script lives next to index.mjs in dist/
|
|
30
|
+
const clientScriptPath = join(import.meta.dirname, 'client.mjs');
|
|
31
|
+
|
|
32
|
+
export const schema = object({
|
|
33
|
+
command: constant('ask'),
|
|
34
|
+
model: withDefault(
|
|
35
|
+
option('-m', '--model', choice(['opus', 'sonnet', 'haiku']), {
|
|
36
|
+
description: message`model to use`,
|
|
37
|
+
}),
|
|
38
|
+
'sonnet',
|
|
39
|
+
),
|
|
40
|
+
headful: withDefault(
|
|
41
|
+
flag('--headful', {
|
|
42
|
+
description: message`show browser window (default: headless)`,
|
|
43
|
+
}),
|
|
44
|
+
false,
|
|
45
|
+
),
|
|
46
|
+
url: optional(
|
|
47
|
+
option('--url', string(), {
|
|
48
|
+
description: message`starting URL to navigate to`,
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
task: argument(string({ metavar: 'TASK' }), {
|
|
52
|
+
description: message`what to accomplish in the browser`,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export type Args = InferValue<typeof schema>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* spawns Claude Code as a child process.
|
|
60
|
+
* @param cwd working directory
|
|
61
|
+
* @param args command arguments
|
|
62
|
+
* @param contextPrompt additional context to append to the system prompt
|
|
63
|
+
* @returns the child process
|
|
64
|
+
*/
|
|
65
|
+
const spawnClaude = (cwd: string, args: Args, contextPrompt: string): ChildProcess => {
|
|
66
|
+
const claudeArgs = [
|
|
67
|
+
'-p',
|
|
68
|
+
args.task,
|
|
69
|
+
'--no-session-persistence',
|
|
70
|
+
'--model',
|
|
71
|
+
args.model,
|
|
72
|
+
'--system-prompt-file',
|
|
73
|
+
systemPromptPath,
|
|
74
|
+
'--append-system-prompt',
|
|
75
|
+
contextPrompt,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
return spawn('claude', claudeArgs, {
|
|
79
|
+
cwd,
|
|
80
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
81
|
+
env: { ...process.env, CLAUDECODE: '' },
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* waits for a child process to exit, collecting its stdout.
|
|
87
|
+
* @param child the child process
|
|
88
|
+
* @returns promise that resolves with exit code and collected stdout
|
|
89
|
+
*/
|
|
90
|
+
const waitForExit = (child: ChildProcess): Promise<{ code: number; stdout: string }> => {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const chunks: Buffer[] = [];
|
|
93
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
94
|
+
chunks.push(chunk);
|
|
95
|
+
});
|
|
96
|
+
child.on('close', (code) => resolve({ code: code ?? 0, stdout: Buffer.concat(chunks).toString() }));
|
|
97
|
+
child.on('error', (err) => reject(new Error(`failed to summon claude: ${err}`)));
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* handles the ask command.
|
|
103
|
+
* launches a browser, starts the IPC server, and spawns Claude Code.
|
|
104
|
+
* @param args parsed command arguments
|
|
105
|
+
*/
|
|
106
|
+
export const handler = async (args: Args): Promise<void> => {
|
|
107
|
+
// fire-and-forget cleanup of orphaned sessions
|
|
108
|
+
gcSessions();
|
|
109
|
+
|
|
110
|
+
// create session directory with subdirectories
|
|
111
|
+
const sessionPath = await createSessionDir();
|
|
112
|
+
const socketPath = join(sessionPath, '.sock');
|
|
113
|
+
let exitCode = 1;
|
|
114
|
+
let claudeOutput = '';
|
|
115
|
+
let browser: Browser | undefined;
|
|
116
|
+
let claude: ChildProcess | undefined;
|
|
117
|
+
|
|
118
|
+
// handle Ctrl+C — kill child, clean up, and exit
|
|
119
|
+
const onSignal = () => {
|
|
120
|
+
if (claude) {
|
|
121
|
+
claude.kill('SIGTERM');
|
|
122
|
+
}
|
|
123
|
+
spin.stop('interrupted');
|
|
124
|
+
if (browser) {
|
|
125
|
+
browser.close().catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
cleanupSessionDir(sessionPath).finally(() => {
|
|
128
|
+
process.exit(130);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
process.on('SIGINT', onSignal);
|
|
132
|
+
process.on('SIGTERM', onSignal);
|
|
133
|
+
|
|
134
|
+
const spin = yoctoSpinner({
|
|
135
|
+
text: args.headful ? 'launching browser (headful)' : 'launching browser',
|
|
136
|
+
}).start();
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
browser = await chromium.launch({ headless: !args.headful });
|
|
140
|
+
const context = await browser.newContext();
|
|
141
|
+
const page = await context.newPage();
|
|
142
|
+
|
|
143
|
+
// navigate to starting URL if provided
|
|
144
|
+
if (args.url) {
|
|
145
|
+
spin.text = `navigating to ${args.url}`;
|
|
146
|
+
await page.goto(args.url, { waitUntil: 'domcontentloaded' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// set up browser state — spinner is shared so commands can update it
|
|
150
|
+
const state: BrowserState = {
|
|
151
|
+
context,
|
|
152
|
+
page,
|
|
153
|
+
refs: {},
|
|
154
|
+
frameRefs: {},
|
|
155
|
+
assetsDir: join(sessionPath, 'assets'),
|
|
156
|
+
screenshotDir: join(sessionPath, 'screenshots'),
|
|
157
|
+
screenshotCounter: 0,
|
|
158
|
+
spinner: spin,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// start IPC server
|
|
162
|
+
spin.text = 'starting session';
|
|
163
|
+
const cmdHandler = createCommandHandler(state);
|
|
164
|
+
const server = await startServer(socketPath, cmdHandler);
|
|
165
|
+
|
|
166
|
+
// write session files
|
|
167
|
+
await writeBrowserShim(sessionPath, clientScriptPath, socketPath);
|
|
168
|
+
await writeSessionSettings(sessionPath);
|
|
169
|
+
|
|
170
|
+
// build context prompt
|
|
171
|
+
const contextParts: string[] = [];
|
|
172
|
+
if (args.url) {
|
|
173
|
+
contextParts.push(`The browser is already open at: ${args.url}`);
|
|
174
|
+
}
|
|
175
|
+
const contextPrompt = contextParts.length > 0 ? contextParts.join('\n') : '';
|
|
176
|
+
|
|
177
|
+
spin.text = 'summoning claude';
|
|
178
|
+
|
|
179
|
+
// spawn Claude Code — stdout is piped and printed after cleanup
|
|
180
|
+
claude = spawnClaude(sessionPath, args, contextPrompt);
|
|
181
|
+
const result = await waitForExit(claude);
|
|
182
|
+
exitCode = result.code;
|
|
183
|
+
claudeOutput = result.stdout;
|
|
184
|
+
|
|
185
|
+
// teardown
|
|
186
|
+
server.close();
|
|
187
|
+
} finally {
|
|
188
|
+
process.off('SIGINT', onSignal);
|
|
189
|
+
process.off('SIGTERM', onSignal);
|
|
190
|
+
spin.stop();
|
|
191
|
+
|
|
192
|
+
if (browser) {
|
|
193
|
+
await browser.close().catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
await cleanupSessionDir(sessionPath);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (claudeOutput) {
|
|
199
|
+
process.stdout.write(claudeOutput);
|
|
200
|
+
}
|
|
201
|
+
process.exit(exitCode);
|
|
202
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { constant, type InferValue, object } from '@optique/core';
|
|
2
|
+
|
|
3
|
+
import { gcSessions } from '../lib/paths.ts';
|
|
4
|
+
|
|
5
|
+
export const schema = object({
|
|
6
|
+
command: constant('clean'),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type Args = InferValue<typeof schema>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* handles the clean command.
|
|
13
|
+
* garbage collects orphaned session directories.
|
|
14
|
+
* @param _args parsed command arguments
|
|
15
|
+
*/
|
|
16
|
+
export const handler = async (_args: Args): Promise<void> => {
|
|
17
|
+
await gcSessions();
|
|
18
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { command, message, or } from '@optique/core';
|
|
4
|
+
import { run } from '@optique/run';
|
|
5
|
+
|
|
6
|
+
import manifest from '../package.json' with { type: 'json' };
|
|
7
|
+
|
|
8
|
+
import * as ask from './commands/ask.ts';
|
|
9
|
+
import * as clean from './commands/clean.ts';
|
|
10
|
+
|
|
11
|
+
const parser = or(
|
|
12
|
+
command('ask', ask.schema, {
|
|
13
|
+
description: message`ask a question by browsing the web with Claude Code`,
|
|
14
|
+
}),
|
|
15
|
+
command('clean', clean.schema, {
|
|
16
|
+
description: message`remove cached session data`,
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const result = run(parser, {
|
|
21
|
+
programName: 'cbr',
|
|
22
|
+
help: 'both',
|
|
23
|
+
version: { value: manifest.version, mode: 'both' },
|
|
24
|
+
brief: message`ask questions by browsing the web using Claude Code`,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
switch (result.command) {
|
|
28
|
+
case 'ask':
|
|
29
|
+
await ask.handler(result);
|
|
30
|
+
break;
|
|
31
|
+
case 'clean':
|
|
32
|
+
await clean.handler(result);
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { BrowserContext, Frame, Page } from 'playwright';
|
|
2
|
+
import type { Spinner } from 'yocto-spinner';
|
|
3
|
+
|
|
4
|
+
import type { RefMap } from '../snapshot.ts';
|
|
5
|
+
|
|
6
|
+
/** mutable state shared across commands within a session */
|
|
7
|
+
export interface BrowserState {
|
|
8
|
+
context: BrowserContext;
|
|
9
|
+
page: Page;
|
|
10
|
+
refs: RefMap;
|
|
11
|
+
frameRefs: Record<string, Frame>;
|
|
12
|
+
assetsDir: string;
|
|
13
|
+
screenshotDir: string;
|
|
14
|
+
screenshotCounter: number;
|
|
15
|
+
spinner: Spinner;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** intentional, user-facing error thrown by command handlers */
|
|
19
|
+
export class CommandError extends Error {
|
|
20
|
+
constructor(message: string) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'CommandError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import type { Locator } from 'playwright';
|
|
4
|
+
|
|
5
|
+
import { resolveLocator } from '../snapshot.ts';
|
|
6
|
+
|
|
7
|
+
import type { BrowserState } from './_types.ts';
|
|
8
|
+
|
|
9
|
+
/** resolves a ref or CSS selector to a Playwright locator */
|
|
10
|
+
export const getLocator = (state: BrowserState, selectorOrRef: string): Locator => {
|
|
11
|
+
return resolveLocator(state.page, selectorOrRef, state.refs);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** formats a byte count into a human-readable string */
|
|
15
|
+
export const formatBytes = (bytes: number): string => {
|
|
16
|
+
if (bytes === 0) {
|
|
17
|
+
return '0 B';
|
|
18
|
+
}
|
|
19
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
20
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
21
|
+
const value = bytes / 1024 ** i;
|
|
22
|
+
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** extracts a filename from a URL, falling back to a generic name */
|
|
26
|
+
export const filenameFromUrl = (url: string): string => {
|
|
27
|
+
try {
|
|
28
|
+
const pathname = new URL(url).pathname;
|
|
29
|
+
const base = basename(pathname);
|
|
30
|
+
// strip query params that might sneak in and ensure it's a valid filename
|
|
31
|
+
if (base && base !== '/' && !base.startsWith('.')) {
|
|
32
|
+
return base.split('?')[0]!;
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// invalid URL — fall through
|
|
36
|
+
}
|
|
37
|
+
return 'download';
|
|
38
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { constant, object } from '@optique/core';
|
|
2
|
+
|
|
3
|
+
import type { BrowserState } from './_types.ts';
|
|
4
|
+
|
|
5
|
+
export const schema = object({
|
|
6
|
+
command: constant('back'),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const handler = async (state: BrowserState): Promise<string> => {
|
|
10
|
+
const start = performance.now();
|
|
11
|
+
await state.page.goBack({ waitUntil: 'domcontentloaded' });
|
|
12
|
+
const elapsed = ((performance.now() - start) / 1000).toFixed(1);
|
|
13
|
+
return `navigated back to ${state.page.url()} (${elapsed}s)`;
|
|
14
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { argument, constant, object, string } from '@optique/core';
|
|
2
|
+
|
|
3
|
+
import type { BrowserState } from './_types.ts';
|
|
4
|
+
import { getLocator } from './_utils.ts';
|
|
5
|
+
|
|
6
|
+
export const schema = object({
|
|
7
|
+
command: constant('check'),
|
|
8
|
+
selector: argument(string({ metavar: 'SELECTOR' })),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const handler = async (state: BrowserState, args: { selector: string }): Promise<string> => {
|
|
12
|
+
await getLocator(state, args.selector).check();
|
|
13
|
+
return 'checked';
|
|
14
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { argument, constant, object, string } from '@optique/core';
|
|
2
|
+
|
|
3
|
+
import type { BrowserState } from './_types.ts';
|
|
4
|
+
import { getLocator } from './_utils.ts';
|
|
5
|
+
|
|
6
|
+
export const schema = object({
|
|
7
|
+
command: constant('click'),
|
|
8
|
+
selector: argument(string({ metavar: 'SELECTOR' })),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const handler = async (state: BrowserState, args: { selector: string }): Promise<string> => {
|
|
12
|
+
await getLocator(state, args.selector).click();
|
|
13
|
+
return 'clicked';
|
|
14
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { constant, object } from '@optique/core';
|
|
2
|
+
|
|
3
|
+
import type { BrowserState } from './_types.ts';
|
|
4
|
+
|
|
5
|
+
export const schema = object({
|
|
6
|
+
command: constant('close'),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const handler = async (state: BrowserState): Promise<string> => {
|
|
10
|
+
await state.page.close();
|
|
11
|
+
const pages = state.context.pages();
|
|
12
|
+
if (pages.length > 0) {
|
|
13
|
+
state.page = pages[0]!;
|
|
14
|
+
return 'tab closed, switched to remaining tab';
|
|
15
|
+
}
|
|
16
|
+
return 'browser closed';
|
|
17
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { argument, constant, object, string } from '@optique/core';
|
|
2
|
+
|
|
3
|
+
import type { BrowserState } from './_types.ts';
|
|
4
|
+
import { getLocator } from './_utils.ts';
|
|
5
|
+
|
|
6
|
+
export const schema = object({
|
|
7
|
+
command: constant('dblclick'),
|
|
8
|
+
selector: argument(string({ metavar: 'SELECTOR' })),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const handler = async (state: BrowserState, args: { selector: string }): Promise<string> => {
|
|
12
|
+
await getLocator(state, args.selector).dblclick();
|
|
13
|
+
return 'double-clicked';
|
|
14
|
+
};
|