@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
@@ -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