@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.
Files changed (46) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +72 -0
  3. package/dist/assets/system-prompt.md +147 -0
  4. package/dist/client.mjs +54 -0
  5. package/dist/index.mjs +1366 -0
  6. package/package.json +45 -0
  7. package/src/assets/system-prompt.md +147 -0
  8. package/src/client.ts +70 -0
  9. package/src/commands/ask.ts +202 -0
  10. package/src/commands/clean.ts +18 -0
  11. package/src/index.ts +34 -0
  12. package/src/lib/commands/_types.ts +24 -0
  13. package/src/lib/commands/_utils.ts +38 -0
  14. package/src/lib/commands/back.ts +14 -0
  15. package/src/lib/commands/check.ts +14 -0
  16. package/src/lib/commands/click.ts +14 -0
  17. package/src/lib/commands/close.ts +17 -0
  18. package/src/lib/commands/dblclick.ts +14 -0
  19. package/src/lib/commands/download.ts +36 -0
  20. package/src/lib/commands/eval.ts +23 -0
  21. package/src/lib/commands/fill.ts +18 -0
  22. package/src/lib/commands/forward.ts +14 -0
  23. package/src/lib/commands/frame.ts +106 -0
  24. package/src/lib/commands/get.ts +95 -0
  25. package/src/lib/commands/hover.ts +14 -0
  26. package/src/lib/commands/is.ts +53 -0
  27. package/src/lib/commands/open.ts +15 -0
  28. package/src/lib/commands/press.ts +13 -0
  29. package/src/lib/commands/reload.ts +14 -0
  30. package/src/lib/commands/resources.ts +37 -0
  31. package/src/lib/commands/screenshot.ts +26 -0
  32. package/src/lib/commands/scroll.ts +30 -0
  33. package/src/lib/commands/select.ts +18 -0
  34. package/src/lib/commands/snapshot.ts +30 -0
  35. package/src/lib/commands/source.ts +23 -0
  36. package/src/lib/commands/styles.ts +63 -0
  37. package/src/lib/commands/tab.ts +102 -0
  38. package/src/lib/commands/type-text.ts +18 -0
  39. package/src/lib/commands/uncheck.ts +14 -0
  40. package/src/lib/commands/wait.ts +93 -0
  41. package/src/lib/commands.ts +202 -0
  42. package/src/lib/debug.ts +11 -0
  43. package/src/lib/paths.ts +118 -0
  44. package/src/lib/server.ts +94 -0
  45. package/src/lib/session.ts +92 -0
  46. 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
+ };