@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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { chmod, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* writes the per-session `.claude/settings.json` with permissions and hooks.
|
|
6
|
+
* paths are baked in at generation time so the settings work regardless of cwd.
|
|
7
|
+
* @param sessionPath the session directory path
|
|
8
|
+
*/
|
|
9
|
+
export const writeSessionSettings = async (sessionPath: string): Promise<void> => {
|
|
10
|
+
const settings = {
|
|
11
|
+
permissions: {
|
|
12
|
+
allow: [
|
|
13
|
+
'Read(assets/*)',
|
|
14
|
+
'Read(screenshots/*)',
|
|
15
|
+
'Read(scratch/*)',
|
|
16
|
+
'Write(scratch/*)',
|
|
17
|
+
'Glob(assets/*)',
|
|
18
|
+
'Glob(screenshots/*)',
|
|
19
|
+
'Glob(scratch/*)',
|
|
20
|
+
'Grep(assets/*)',
|
|
21
|
+
'Grep(screenshots/*)',
|
|
22
|
+
'Grep(scratch/*)',
|
|
23
|
+
'Bash(browser:*)',
|
|
24
|
+
// filesystem
|
|
25
|
+
'Bash(ls:*)',
|
|
26
|
+
'Bash(cat:*)',
|
|
27
|
+
'Bash(head:*)',
|
|
28
|
+
'Bash(tail:*)',
|
|
29
|
+
'Bash(wc:*)',
|
|
30
|
+
'Bash(file:*)',
|
|
31
|
+
'Bash(find:*)',
|
|
32
|
+
'Bash(tree:*)',
|
|
33
|
+
'Bash(stat:*)',
|
|
34
|
+
'Bash(du:*)',
|
|
35
|
+
'Bash(mkdir:*)',
|
|
36
|
+
'Bash(basename:*)',
|
|
37
|
+
'Bash(dirname:*)',
|
|
38
|
+
'Bash(realpath:*)',
|
|
39
|
+
|
|
40
|
+
// text processing
|
|
41
|
+
'Bash(awk:*)',
|
|
42
|
+
'Bash(cut:*)',
|
|
43
|
+
'Bash(diff:*)',
|
|
44
|
+
'Bash(grep:*)',
|
|
45
|
+
'Bash(jq:*)',
|
|
46
|
+
'Bash(sed:*)',
|
|
47
|
+
'Bash(sort:*)',
|
|
48
|
+
'Bash(tr:*)',
|
|
49
|
+
'Bash(uniq:*)',
|
|
50
|
+
'Bash(xargs:*)',
|
|
51
|
+
'Bash(paste:*)',
|
|
52
|
+
'Bash(tee:*)',
|
|
53
|
+
'Bash(column:*)',
|
|
54
|
+
'WebSearch',
|
|
55
|
+
'WebFetch',
|
|
56
|
+
],
|
|
57
|
+
deny: ['*'],
|
|
58
|
+
},
|
|
59
|
+
hooks: {
|
|
60
|
+
SessionStart: [
|
|
61
|
+
{
|
|
62
|
+
matcher: '',
|
|
63
|
+
hooks: [
|
|
64
|
+
{
|
|
65
|
+
type: 'command',
|
|
66
|
+
command: `sh -c 'echo "export PATH=\\"${sessionPath}/bin:\\$PATH\\"" >> "$CLAUDE_ENV_FILE"'`,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
await writeFile(join(sessionPath, '.claude', 'settings.json'), JSON.stringify(settings, null, '\t') + '\n');
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* writes the `bin/browser` shim script that delegates to the bundled client.
|
|
79
|
+
* @param sessionPath the session directory path
|
|
80
|
+
* @param clientScriptPath absolute path to the bundled `dist/browser.mjs`
|
|
81
|
+
* @param socketPath absolute path to the Unix domain socket
|
|
82
|
+
*/
|
|
83
|
+
export const writeBrowserShim = async (
|
|
84
|
+
sessionPath: string,
|
|
85
|
+
clientScriptPath: string,
|
|
86
|
+
socketPath: string,
|
|
87
|
+
): Promise<void> => {
|
|
88
|
+
const shimPath = join(sessionPath, 'bin', 'browser');
|
|
89
|
+
const content = `#!/bin/sh\nexec node ${clientScriptPath} --socket ${socketPath} "$@"\n`;
|
|
90
|
+
await writeFile(shimPath, content);
|
|
91
|
+
await chmod(shimPath, 0o755);
|
|
92
|
+
};
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import type { Locator, Page } from 'playwright';
|
|
2
|
+
|
|
3
|
+
// #region role sets
|
|
4
|
+
|
|
5
|
+
/** roles that represent interactive elements — always get refs */
|
|
6
|
+
const INTERACTIVE_ROLES = new Set([
|
|
7
|
+
'button',
|
|
8
|
+
'link',
|
|
9
|
+
'textbox',
|
|
10
|
+
'checkbox',
|
|
11
|
+
'radio',
|
|
12
|
+
'combobox',
|
|
13
|
+
'listbox',
|
|
14
|
+
'menuitem',
|
|
15
|
+
'menuitemcheckbox',
|
|
16
|
+
'menuitemradio',
|
|
17
|
+
'option',
|
|
18
|
+
'searchbox',
|
|
19
|
+
'slider',
|
|
20
|
+
'spinbutton',
|
|
21
|
+
'switch',
|
|
22
|
+
'tab',
|
|
23
|
+
'treeitem',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** content roles — get refs only when they have a name */
|
|
27
|
+
const CONTENT_ROLES = new Set([
|
|
28
|
+
'heading',
|
|
29
|
+
'cell',
|
|
30
|
+
'gridcell',
|
|
31
|
+
'columnheader',
|
|
32
|
+
'rowheader',
|
|
33
|
+
'listitem',
|
|
34
|
+
'article',
|
|
35
|
+
'region',
|
|
36
|
+
'main',
|
|
37
|
+
'navigation',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** structural roles — stripped in compact mode when unnamed */
|
|
41
|
+
const STRUCTURAL_ROLES = new Set([
|
|
42
|
+
'generic',
|
|
43
|
+
'group',
|
|
44
|
+
'list',
|
|
45
|
+
'table',
|
|
46
|
+
'row',
|
|
47
|
+
'rowgroup',
|
|
48
|
+
'grid',
|
|
49
|
+
'treegrid',
|
|
50
|
+
'menu',
|
|
51
|
+
'menubar',
|
|
52
|
+
'toolbar',
|
|
53
|
+
'tablist',
|
|
54
|
+
'tree',
|
|
55
|
+
'directory',
|
|
56
|
+
'document',
|
|
57
|
+
'application',
|
|
58
|
+
'presentation',
|
|
59
|
+
'none',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// #endregion
|
|
63
|
+
|
|
64
|
+
// #region ref types
|
|
65
|
+
|
|
66
|
+
export interface RefEntry {
|
|
67
|
+
role: string;
|
|
68
|
+
name: string | undefined;
|
|
69
|
+
nth: number | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** mapping of ref id (e.g. "e1") to locator metadata */
|
|
73
|
+
export type RefMap = Record<string, RefEntry>;
|
|
74
|
+
|
|
75
|
+
// #endregion
|
|
76
|
+
|
|
77
|
+
// #region role name tracker
|
|
78
|
+
|
|
79
|
+
interface RoleNameTracker {
|
|
80
|
+
getNextIndex(role: string, name: string | undefined): number;
|
|
81
|
+
trackRef(role: string, name: string | undefined, ref: string): void;
|
|
82
|
+
getDuplicateKeys(): Set<string>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const makeKey = (role: string, name: string | undefined): string => `${role}:${name ?? ''}`;
|
|
86
|
+
|
|
87
|
+
const createRoleNameTracker = (): RoleNameTracker => {
|
|
88
|
+
const counts = new Map<string, number>();
|
|
89
|
+
const refsByKey = new Map<string, string[]>();
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
getNextIndex(role, name) {
|
|
93
|
+
const key = makeKey(role, name);
|
|
94
|
+
const index = counts.get(key) ?? 0;
|
|
95
|
+
counts.set(key, index + 1);
|
|
96
|
+
return index;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
trackRef(role, name, ref) {
|
|
100
|
+
const key = makeKey(role, name);
|
|
101
|
+
const existing = refsByKey.get(key);
|
|
102
|
+
if (existing) {
|
|
103
|
+
existing.push(ref);
|
|
104
|
+
} else {
|
|
105
|
+
refsByKey.set(key, [ref]);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
getDuplicateKeys() {
|
|
110
|
+
const dupes = new Set<string>();
|
|
111
|
+
for (const [key, refs] of refsByKey) {
|
|
112
|
+
if (refs.length > 1) {
|
|
113
|
+
dupes.add(key);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return dupes;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// #endregion
|
|
122
|
+
|
|
123
|
+
// #region snapshot options
|
|
124
|
+
|
|
125
|
+
export interface SnapshotOptions {
|
|
126
|
+
/** only show interactive elements */
|
|
127
|
+
interactive?: boolean;
|
|
128
|
+
/** strip unnamed structural roles and prune empty branches */
|
|
129
|
+
compact?: boolean;
|
|
130
|
+
/** max depth of the tree */
|
|
131
|
+
depth?: number;
|
|
132
|
+
/** CSS selector to scope the snapshot to */
|
|
133
|
+
selector?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// #endregion
|
|
137
|
+
|
|
138
|
+
// #region aria tree line regex
|
|
139
|
+
// matches: ` - role "name" [attr=val]` or ` - role:` etc.
|
|
140
|
+
const LINE_RE = /^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$/;
|
|
141
|
+
// #endregion
|
|
142
|
+
|
|
143
|
+
// #region snapshot parsing
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* takes a snapshot of the page's accessibility tree and assigns element refs.
|
|
147
|
+
* @param page the Playwright page
|
|
148
|
+
* @param options snapshot options
|
|
149
|
+
* @returns object with the formatted snapshot text and the ref map
|
|
150
|
+
*/
|
|
151
|
+
export const takeSnapshot = async (
|
|
152
|
+
page: Page,
|
|
153
|
+
options: SnapshotOptions = {},
|
|
154
|
+
): Promise<{ text: string; refs: RefMap }> => {
|
|
155
|
+
const locator: Locator = options.selector ? page.locator(options.selector) : page.locator('body');
|
|
156
|
+
|
|
157
|
+
const ariaTree = await locator.ariaSnapshot();
|
|
158
|
+
|
|
159
|
+
const refs: RefMap = {};
|
|
160
|
+
let refCounter = 0;
|
|
161
|
+
const tracker = createRoleNameTracker();
|
|
162
|
+
|
|
163
|
+
const nextRef = (): string => `e${++refCounter}`;
|
|
164
|
+
|
|
165
|
+
const lines = ariaTree.split('\n');
|
|
166
|
+
const output: Array<{ text: string; depth: number; hasRef: boolean; hasContent: boolean }> = [];
|
|
167
|
+
|
|
168
|
+
for (const line of lines) {
|
|
169
|
+
const match = line.match(LINE_RE);
|
|
170
|
+
|
|
171
|
+
if (!match) {
|
|
172
|
+
// non-role lines (plain text, metadata) — keep in full mode, skip in interactive
|
|
173
|
+
if (!options.interactive) {
|
|
174
|
+
const depth = Math.floor((line.search(/\S/) || 0) / 2);
|
|
175
|
+
output.push({ text: line, depth, hasRef: false, hasContent: line.trim().length > 0 });
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const [, prefix, role, name, suffix] = match;
|
|
181
|
+
const roleLower = role!.toLowerCase();
|
|
182
|
+
const depth = Math.floor((prefix!.search(/\S/) === -1 ? prefix!.length : prefix!.search(/\S/)) / 2);
|
|
183
|
+
|
|
184
|
+
// depth filtering
|
|
185
|
+
if (options.depth !== undefined && depth > options.depth) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const isInteractive = INTERACTIVE_ROLES.has(roleLower);
|
|
190
|
+
const isContent = CONTENT_ROLES.has(roleLower);
|
|
191
|
+
const isStructural = STRUCTURAL_ROLES.has(roleLower);
|
|
192
|
+
|
|
193
|
+
// interactive-only mode: skip non-interactive elements
|
|
194
|
+
if (options.interactive && !isInteractive) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// compact mode: drop unnamed structural elements (pruning happens later)
|
|
199
|
+
if (options.compact && isStructural && !name) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// determine if this element gets a ref
|
|
204
|
+
const shouldHaveRef = isInteractive || (isContent && !!name);
|
|
205
|
+
|
|
206
|
+
let refTag = '';
|
|
207
|
+
if (shouldHaveRef) {
|
|
208
|
+
const ref = nextRef();
|
|
209
|
+
const nth = tracker.getNextIndex(roleLower, name);
|
|
210
|
+
tracker.trackRef(roleLower, name, ref);
|
|
211
|
+
|
|
212
|
+
refs[ref] = { role: roleLower, name, nth };
|
|
213
|
+
refTag = ` [ref=${ref}]`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// reconstruct line with ref tag
|
|
217
|
+
const nthTag =
|
|
218
|
+
shouldHaveRef && refs[`e${refCounter}`]?.nth ? ` [nth=${refs[`e${refCounter}`]!.nth}]` : '';
|
|
219
|
+
const reconstructed = name
|
|
220
|
+
? `${prefix}${role} "${name}"${refTag}${nthTag}${suffix}`
|
|
221
|
+
: `${prefix}${role}${refTag}${suffix}`;
|
|
222
|
+
|
|
223
|
+
const hasInlineContent = !!suffix && suffix.includes(':') && !suffix.endsWith(':');
|
|
224
|
+
output.push({
|
|
225
|
+
text: reconstructed,
|
|
226
|
+
depth,
|
|
227
|
+
hasRef: shouldHaveRef,
|
|
228
|
+
hasContent: hasInlineContent || !!name,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// post-process: remove nth from non-duplicates
|
|
233
|
+
{
|
|
234
|
+
const dupes = tracker.getDuplicateKeys();
|
|
235
|
+
for (const [ref, entry] of Object.entries(refs)) {
|
|
236
|
+
const key = makeKey(entry.role, entry.name);
|
|
237
|
+
if (!dupes.has(key)) {
|
|
238
|
+
entry.nth = undefined;
|
|
239
|
+
// also clean up the [nth=0] from output lines
|
|
240
|
+
const idx = output.findIndex((l) => l.text.includes(`[ref=${ref}]`));
|
|
241
|
+
if (idx !== -1) {
|
|
242
|
+
output[idx]!.text = output[idx]!.text.replace(/\s*\[nth=\d+\]/, '');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// compact mode: prune branches that have no refs
|
|
249
|
+
if (options.compact) {
|
|
250
|
+
const pruned = compactTree(output);
|
|
251
|
+
return { text: pruned.map((l) => l.text).join('\n'), refs };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { text: output.map((l) => l.text).join('\n'), refs };
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* prunes branches from the tree that contain no refs.
|
|
259
|
+
* keeps: lines with refs, lines with inline content, and structural ancestors of ref-bearing lines.
|
|
260
|
+
*/
|
|
261
|
+
const compactTree = (
|
|
262
|
+
lines: Array<{ text: string; depth: number; hasRef: boolean; hasContent: boolean }>,
|
|
263
|
+
): typeof lines => {
|
|
264
|
+
// mark lines that should be kept: has ref, or has inline content
|
|
265
|
+
const keep = Array.from({ length: lines.length }, () => false);
|
|
266
|
+
|
|
267
|
+
for (let i = 0; i < lines.length; i++) {
|
|
268
|
+
if (lines[i]!.hasRef || lines[i]!.hasContent) {
|
|
269
|
+
keep[i] = true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// for each kept line, also keep all its ancestors
|
|
274
|
+
for (let i = 0; i < lines.length; i++) {
|
|
275
|
+
if (!keep[i]) {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const targetDepth = lines[i]!.depth;
|
|
280
|
+
// walk backwards to find ancestors at each shallower depth
|
|
281
|
+
for (let j = i - 1; j >= 0 && lines[j]!.depth < targetDepth; j--) {
|
|
282
|
+
if (!keep[j]) {
|
|
283
|
+
keep[j] = true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return lines.filter((_, i) => keep[i]);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// #endregion
|
|
292
|
+
|
|
293
|
+
// #region ref resolution
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* parses a ref string (e.g. "@e1", "ref=e1", "e1") into the bare ref id.
|
|
297
|
+
* @param input the ref string
|
|
298
|
+
* @returns the bare ref id or null if not a valid ref format
|
|
299
|
+
*/
|
|
300
|
+
export const parseRef = (input: string): string | null => {
|
|
301
|
+
if (input.startsWith('@')) {
|
|
302
|
+
return input.slice(1);
|
|
303
|
+
}
|
|
304
|
+
if (input.startsWith('ref=')) {
|
|
305
|
+
return input.slice(4);
|
|
306
|
+
}
|
|
307
|
+
if (/^e\d+$/.test(input)) {
|
|
308
|
+
return input;
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* resolves a ref or CSS selector to a Playwright locator.
|
|
315
|
+
* @param page the Playwright page
|
|
316
|
+
* @param selectorOrRef a ref string (e.g. "@e1") or CSS selector
|
|
317
|
+
* @param refs the current ref map
|
|
318
|
+
* @returns the resolved Playwright locator
|
|
319
|
+
* @throws if a ref is provided but not found in the ref map
|
|
320
|
+
*/
|
|
321
|
+
export const resolveLocator = (page: Page, selectorOrRef: string, refs: RefMap): Locator => {
|
|
322
|
+
const ref = parseRef(selectorOrRef);
|
|
323
|
+
|
|
324
|
+
if (ref) {
|
|
325
|
+
const entry = refs[ref];
|
|
326
|
+
if (!entry) {
|
|
327
|
+
throw new Error(`ref ${selectorOrRef} not found — run snapshot to refresh refs`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let locator: Locator;
|
|
331
|
+
if (entry.name) {
|
|
332
|
+
locator = page.getByRole(entry.role as Parameters<Page['getByRole']>[0], {
|
|
333
|
+
name: entry.name,
|
|
334
|
+
exact: true,
|
|
335
|
+
});
|
|
336
|
+
} else {
|
|
337
|
+
locator = page.getByRole(entry.role as Parameters<Page['getByRole']>[0]);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (entry.nth !== undefined) {
|
|
341
|
+
locator = locator.nth(entry.nth);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return locator;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// fall back to CSS selector
|
|
348
|
+
return page.locator(selectorOrRef);
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// #endregion
|