chromeflow 0.8.0 → 0.9.8
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 +21 -0
- package/README.md +43 -148
- package/bin/chromeflow.mjs +25916 -0
- package/package.json +35 -20
- package/CLAUDE.md +0 -370
- package/dist/index.js +0 -115
- package/dist/setup.js +0 -493
- package/dist/tools/browser.js +0 -404
- package/dist/tools/capture.js +0 -216
- package/dist/tools/flow.js +0 -436
- package/dist/tools/highlight.js +0 -70
- package/dist/types.js +0 -0
- package/dist/ws-bridge.js +0 -116
package/dist/tools/browser.js
DELETED
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { writeFileSync, copyFileSync, readFileSync } from "fs";
|
|
3
|
-
import { tmpdir, homedir } from "os";
|
|
4
|
-
import { join } from "path";
|
|
5
|
-
import { execSync } from "child_process";
|
|
6
|
-
function registerBrowserTools(server, bridge) {
|
|
7
|
-
server.tool(
|
|
8
|
-
"open_page",
|
|
9
|
-
`Navigate to a URL. By default reuses the active tab. Set new_tab=true to open alongside the current tab without losing it. After navigating, call get_page_text to read the page \u2014 do NOT take a screenshot.
|
|
10
|
-
|
|
11
|
-
Set background=true (only with new_tab=true) to open the new tab WITHOUT switching focus to it. Use this when the current tab has a partially-filled form whose page auto-saves on focus loss (e.g. eBay seller listings) \u2014 switching away would trigger the auto-save and corrupt the in-progress draft.`,
|
|
12
|
-
{
|
|
13
|
-
url: z.string().url().describe("The URL to navigate to"),
|
|
14
|
-
new_tab: z.boolean().optional().describe("Open in a new tab instead of replacing the current one (default false)"),
|
|
15
|
-
background: z.boolean().optional().describe("If new_tab=true, do not switch focus to the new tab. Default false. Ignored when new_tab is false.")
|
|
16
|
-
},
|
|
17
|
-
async ({ url, new_tab, background }) => {
|
|
18
|
-
await bridge.request({ type: "navigate", url, newTab: new_tab ?? false, background: background ?? false });
|
|
19
|
-
return {
|
|
20
|
-
content: [{ type: "text", text: `Navigated to ${url}${new_tab ? background ? " (new background tab)" : " (new tab)" : ""}` }]
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
);
|
|
24
|
-
server.tool(
|
|
25
|
-
"switch_to_tab",
|
|
26
|
-
`Switch the active tab to a different open tab. Use this after open_page(new_tab=true) to switch back to the original tab, or to jump between tabs.
|
|
27
|
-
Accepts: a tab number (1-based), a URL substring, or a title substring.
|
|
28
|
-
Example: switch_to_tab("1") to go to the first tab, switch_to_tab("form") to find a tab whose URL or title contains "form".`,
|
|
29
|
-
{
|
|
30
|
-
query: z.string().describe("Tab number (1-based), URL substring, or title substring to match")
|
|
31
|
-
},
|
|
32
|
-
async ({ query }) => {
|
|
33
|
-
await bridge.request({ type: "switch_to_tab", query });
|
|
34
|
-
return {
|
|
35
|
-
content: [{ type: "text", text: `Switched to tab matching "${query}"` }]
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
);
|
|
39
|
-
server.tool(
|
|
40
|
-
"list_tabs",
|
|
41
|
-
"List all open tabs in the current window with their index, title, and URL. Use this before switch_to_tab if you're not sure which tab to switch to.",
|
|
42
|
-
{},
|
|
43
|
-
async () => {
|
|
44
|
-
const response = await bridge.request({ type: "list_tabs" });
|
|
45
|
-
if (response.type !== "tabs_response") throw new Error("Unexpected response");
|
|
46
|
-
const tabs = response.tabs;
|
|
47
|
-
const lines = tabs.map((t) => `${t.index}. ${t.active ? "[active] " : ""}${t.title} \u2014 ${t.url}`);
|
|
48
|
-
return {
|
|
49
|
-
content: [{ type: "text", text: `Open tabs:
|
|
50
|
-
${lines.join("\n")}` }]
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
);
|
|
54
|
-
server.tool(
|
|
55
|
-
"take_screenshot",
|
|
56
|
-
`Capture a screenshot of the current page. By default returns the image to Claude only; pass copy_to_clipboard or save_to to also share the image outside Claude (paste into a chat, upload to a form, keep as a file).
|
|
57
|
-
|
|
58
|
-
IMPORTANT: Do NOT use this to read page content \u2014 call get_page_text instead, which is faster and returns searchable text. Screenshots are ONLY for locating an element's pixel coordinates when DOM queries have already failed. Never take a screenshot immediately after open_page, scroll_page, or click_element. Never take more than 1-2 screenshots in a row.`,
|
|
59
|
-
{
|
|
60
|
-
copy_to_clipboard: z.boolean().optional().describe("Copy the PNG to the system clipboard (macOS only). Default false."),
|
|
61
|
-
save_to: z.enum(["downloads", "cwd", "none"]).optional().describe(`Save the PNG to disk: "downloads" (~/Downloads), "cwd" (Claude's working directory), or "none" (default \u2014 image returned only to Claude).`)
|
|
62
|
-
},
|
|
63
|
-
async ({ copy_to_clipboard = false, save_to = "none" }) => {
|
|
64
|
-
const sharing = copy_to_clipboard || save_to !== "none";
|
|
65
|
-
const response = await bridge.request({ type: "screenshot", grid: !sharing });
|
|
66
|
-
if (response.type !== "screenshot_response") {
|
|
67
|
-
throw new Error("Unexpected response from extension");
|
|
68
|
-
}
|
|
69
|
-
if (!sharing) {
|
|
70
|
-
return {
|
|
71
|
-
content: [
|
|
72
|
-
{ type: "image", data: response.image, mimeType: "image/png" },
|
|
73
|
-
{
|
|
74
|
-
type: "text",
|
|
75
|
-
text: `Screenshot captured (${response.width}x${response.height}). Analyze the image to identify element positions for highlighting.`
|
|
76
|
-
}
|
|
77
|
-
]
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
81
|
-
const filename = `chromeflow-${timestamp}.png`;
|
|
82
|
-
const imageBuffer = Buffer.from(response.image, "base64");
|
|
83
|
-
const tmpPath = join(tmpdir(), filename);
|
|
84
|
-
writeFileSync(tmpPath, imageBuffer);
|
|
85
|
-
const notes = [];
|
|
86
|
-
if (save_to !== "none") {
|
|
87
|
-
const savePath = save_to === "cwd" ? join(process.cwd(), filename) : join(homedir(), "Downloads", filename);
|
|
88
|
-
copyFileSync(tmpPath, savePath);
|
|
89
|
-
notes.push(`Saved to ${savePath}`);
|
|
90
|
-
}
|
|
91
|
-
if (copy_to_clipboard) {
|
|
92
|
-
try {
|
|
93
|
-
execSync(`osascript -e 'set the clipboard to (read (POSIX file "${tmpPath}") as \xABclass PNGf\xBB)'`);
|
|
94
|
-
notes.push("Copied to clipboard");
|
|
95
|
-
} catch {
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return {
|
|
99
|
-
content: [
|
|
100
|
-
{ type: "image", data: response.image, mimeType: "image/png" },
|
|
101
|
-
{ type: "text", text: notes.length ? notes.join(". ") + "." : `Screenshot captured (${response.width}x${response.height}).` }
|
|
102
|
-
]
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
);
|
|
106
|
-
server.tool(
|
|
107
|
-
"capture_terminal",
|
|
108
|
-
`Capture a screenshot of the terminal window (Terminal, iTerm2, Warp, VS Code, Ghostty, etc.) and save it as a PNG.
|
|
109
|
-
Use this when you need a screenshot of terminal output \u2014 e.g. test results, build logs, or command output \u2014 to upload to a form via set_file_input.
|
|
110
|
-
Auto-detects the terminal app. Returns the image to Claude AND saves the PNG file.
|
|
111
|
-
The saved file path can be passed directly to set_file_input(hint, file_path) to upload it.`,
|
|
112
|
-
{
|
|
113
|
-
save_to: z.enum(["downloads", "cwd"]).optional().describe('Where to save the PNG: "downloads" (~/Downloads, default) or "cwd" (working directory)')
|
|
114
|
-
},
|
|
115
|
-
async ({ save_to = "downloads" }) => {
|
|
116
|
-
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
117
|
-
const filename = `terminal-${timestamp}.png`;
|
|
118
|
-
const savePath = save_to === "cwd" ? join(process.cwd(), filename) : join(homedir(), "Downloads", filename);
|
|
119
|
-
let captured = false;
|
|
120
|
-
try {
|
|
121
|
-
const bounds = execSync(`osascript -e '
|
|
122
|
-
tell application "System Events"
|
|
123
|
-
set termApps to {"Terminal", "iTerm2", "Warp", "kitty", "Alacritty", "Ghostty", "Code", "Cursor", "Windsurf"}
|
|
124
|
-
repeat with appName in termApps
|
|
125
|
-
if exists process (contents of appName) then
|
|
126
|
-
tell process (contents of appName)
|
|
127
|
-
if (count of windows) > 0 then
|
|
128
|
-
set win to window 1
|
|
129
|
-
set pos to position of win
|
|
130
|
-
set sz to size of win
|
|
131
|
-
return (item 1 of pos as text) & "," & (item 2 of pos as text) & "," & (item 1 of sz as text) & "," & (item 2 of sz as text)
|
|
132
|
-
end if
|
|
133
|
-
end tell
|
|
134
|
-
end if
|
|
135
|
-
end repeat
|
|
136
|
-
error "No terminal window found"
|
|
137
|
-
end tell
|
|
138
|
-
'`, { timeout: 5e3 }).toString().trim();
|
|
139
|
-
execSync(`screencapture -x -R${bounds} "${savePath}"`, { timeout: 5e3 });
|
|
140
|
-
captured = true;
|
|
141
|
-
} catch {
|
|
142
|
-
try {
|
|
143
|
-
execSync(`screencapture -x "${savePath}"`, { timeout: 5e3 });
|
|
144
|
-
captured = true;
|
|
145
|
-
} catch {
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
if (!captured) {
|
|
149
|
-
return {
|
|
150
|
-
content: [{ type: "text", text: "Failed to capture terminal. Ensure Screen Recording permission is granted to your terminal app in System Settings > Privacy & Security > Screen Recording." }]
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
const imageBuffer = readFileSync(savePath);
|
|
154
|
-
const base64 = imageBuffer.toString("base64");
|
|
155
|
-
let clipboardNote = "";
|
|
156
|
-
try {
|
|
157
|
-
execSync(`osascript -e 'set the clipboard to (read (POSIX file "${savePath}") as \xABclass PNGf\xBB)'`);
|
|
158
|
-
clipboardNote = "Copied to clipboard. ";
|
|
159
|
-
} catch {
|
|
160
|
-
}
|
|
161
|
-
return {
|
|
162
|
-
content: [
|
|
163
|
-
{ type: "image", data: base64, mimeType: "image/png" },
|
|
164
|
-
{ type: "text", text: `${clipboardNote}Saved to ${savePath}` }
|
|
165
|
-
]
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
);
|
|
169
|
-
server.tool(
|
|
170
|
-
"set_dialog_response",
|
|
171
|
-
`Pre-set the return value for the next window.prompt() or window.confirm() dialog.
|
|
172
|
-
Call this BEFORE triggering an action that will show a dialog (e.g. a "Save As" button that calls prompt()).
|
|
173
|
-
The response is consumed once \u2014 after the dialog fires, it resets to default behavior.
|
|
174
|
-
For prompt: the value string is returned to the page. For confirm: true/false is returned.`,
|
|
175
|
-
{
|
|
176
|
-
type: z.enum(["prompt", "confirm"]).describe('Which dialog type to pre-fill: "prompt" or "confirm"'),
|
|
177
|
-
value: z.string().describe('For prompt: the string to return. For confirm: "true" or "false"')
|
|
178
|
-
},
|
|
179
|
-
async ({ type, value }) => {
|
|
180
|
-
const jsValue = type === "confirm" ? value === "true" : value;
|
|
181
|
-
const code = `window._chromeflowDialogResponse = window._chromeflowDialogResponse || {}; window._chromeflowDialogResponse.${type} = ${JSON.stringify(jsValue)}; "set"`;
|
|
182
|
-
await bridge.request({ type: "execute_script", code });
|
|
183
|
-
return {
|
|
184
|
-
content: [{ type: "text", text: `Next ${type}() will return ${JSON.stringify(jsValue)}. Now trigger the action that shows the dialog.` }]
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
);
|
|
188
|
-
server.tool(
|
|
189
|
-
"clear_overlays",
|
|
190
|
-
"Remove all highlights and callout annotations from the current page.",
|
|
191
|
-
{},
|
|
192
|
-
async () => {
|
|
193
|
-
await bridge.request({ type: "clear" });
|
|
194
|
-
return {
|
|
195
|
-
content: [{ type: "text", text: "All overlays cleared." }]
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
);
|
|
199
|
-
server.tool(
|
|
200
|
-
"get_elements",
|
|
201
|
-
`Get the exact pixel positions of all visible interactive elements on the page (inputs, buttons, links, selects).
|
|
202
|
-
Use this INSTEAD OF take_screenshot when you need coordinates for highlight_region \u2014 the coordinates are exact DOM values, not estimates.
|
|
203
|
-
Returns a numbered list with element type, label, and precise x/y/width/height in CSS pixels.
|
|
204
|
-
IMPORTANT: x/y are VIEWPORT-relative (0,0 = top-left of the visible area). Use these exact values directly in highlight_region \u2014 do not add window.scrollY.
|
|
205
|
-
Use get_form_fields instead if you need document y positions or fields below the fold.`,
|
|
206
|
-
{},
|
|
207
|
-
async () => {
|
|
208
|
-
const response = await bridge.request({ type: "get_elements" });
|
|
209
|
-
if (response.type !== "elements_response") throw new Error("Unexpected response");
|
|
210
|
-
const els = response.elements;
|
|
211
|
-
if (els.length === 0) {
|
|
212
|
-
return { content: [{ type: "text", text: "No visible interactive elements found on page." }] };
|
|
213
|
-
}
|
|
214
|
-
const lines = els.map((e) => {
|
|
215
|
-
const val = e.value ? ` [currently: "${e.value}"]` : "";
|
|
216
|
-
return `${e.index}. ${e.type} "${e.label}"${val} \u2014 x:${e.x} y:${e.y} w:${e.width} h:${e.height}`;
|
|
217
|
-
});
|
|
218
|
-
return {
|
|
219
|
-
content: [{ type: "text", text: `Visible interactive elements:
|
|
220
|
-
${lines.join("\n")}
|
|
221
|
-
|
|
222
|
-
Use these exact x/y values in highlight_region.` }]
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
);
|
|
226
|
-
server.tool(
|
|
227
|
-
"get_form_fields",
|
|
228
|
-
`Get a full inventory of all form fields on the page: inputs, textareas, selects, and CodeMirror editors.
|
|
229
|
-
Run this once at the start of a complex form to understand what fields exist, their labels, current values, and vertical positions.
|
|
230
|
-
Returns fields sorted by their y-position on the page (top to bottom).
|
|
231
|
-
Unlike get_elements, this includes ALL fields (even far below the fold) and is not limited to 60 items.`,
|
|
232
|
-
{},
|
|
233
|
-
async () => {
|
|
234
|
-
const response = await bridge.request({ type: "get_form_fields" });
|
|
235
|
-
if (response.type !== "form_fields_response") throw new Error("Unexpected response");
|
|
236
|
-
const r = response;
|
|
237
|
-
const fields = r.fields;
|
|
238
|
-
if (fields.length === 0) {
|
|
239
|
-
return { content: [{ type: "text", text: "No form fields found on page." + (r.warning ?? "") }] };
|
|
240
|
-
}
|
|
241
|
-
const lines = fields.map((f) => {
|
|
242
|
-
const val = f.value ? ` [currently: "${f.value}"]` : "";
|
|
243
|
-
const ctx = f.context ? ` [under: "${f.context}"]` : "";
|
|
244
|
-
return `${f.index}. [${f.type}] "${f.label}"${val}${ctx} \u2014 y:${f.y}`;
|
|
245
|
-
});
|
|
246
|
-
return {
|
|
247
|
-
content: [{ type: "text", text: `Form fields (${fields.length} total, sorted top-to-bottom):
|
|
248
|
-
${lines.join("\n")}${r.warning ?? ""}` }]
|
|
249
|
-
};
|
|
250
|
-
}
|
|
251
|
-
);
|
|
252
|
-
server.tool(
|
|
253
|
-
"type_text",
|
|
254
|
-
`Type text into the currently focused element using trusted keyboard events via Chrome DevTools Protocol.
|
|
255
|
-
Unlike fill_input (which sets .value programmatically), this produces real keystrokes that pass isTrusted checks. Use this when:
|
|
256
|
-
- fill_input fails because the site validates event.isTrusted (e.g. Outlier, DataAnnotation code editors)
|
|
257
|
-
- The target is a shadow DOM input, custom web component, or heavily guarded editor
|
|
258
|
-
- You need to type into a CodeMirror/Monaco/Ace editor that rejects programmatic value changes
|
|
259
|
-
- The target lives inside a same-origin iframe (e.g. eBay's "se-rte" rich-text description editor) \u2014 pass the iframe's CSS selector via the \`frame\` parameter
|
|
260
|
-
|
|
261
|
-
Usage: first click_element or execute_script to focus the target field, then call type_text with the content.
|
|
262
|
-
To clear existing content before typing, use execute_script("document.execCommand('selectAll')") first.
|
|
263
|
-
|
|
264
|
-
For iframe contenteditables: pass \`frame\` (a CSS selector for the iframe). type_text descends into the iframe, focuses its first editable element, types via CDP, then dispatches input/change in the iframe's context so React picks up the change. Same-origin iframes only \u2014 cross-origin iframes will return an error.`,
|
|
265
|
-
{
|
|
266
|
-
text: z.string().describe("The text to type into the focused element"),
|
|
267
|
-
frame: z.string().optional().describe(
|
|
268
|
-
"CSS selector for an iframe whose contents you want to type into (e.g. 'iframe.se-rte-frame__summary'). Same-origin only. Before typing, the first contenteditable/input inside the iframe is focused; after typing, input/change events are dispatched in the iframe's context."
|
|
269
|
-
)
|
|
270
|
-
},
|
|
271
|
-
async ({ text, frame }) => {
|
|
272
|
-
const timeoutMs = Math.max(3e4, text.length * 90 + 15e3);
|
|
273
|
-
const response = await bridge.request({ type: "type_text", text, frame }, timeoutMs);
|
|
274
|
-
const r = response;
|
|
275
|
-
return {
|
|
276
|
-
content: [{ type: "text", text: r.message ?? (r.success ? "Text typed successfully" : "Failed to type text") }]
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
);
|
|
280
|
-
server.tool(
|
|
281
|
-
"set_file_input",
|
|
282
|
-
"Upload a file to a file input \u2014 works even when the input is hidden behind a custom drag-and-drop zone. Returns success=true only after an observable commit (file count goes up, input gets reset, or verify_selector appears within wait_ms). See CLAUDE.md for batch-upload guidance.",
|
|
283
|
-
{
|
|
284
|
-
hint: z.string().describe("Label text, name, or surrounding text of the file input. Use empty string to target the first file input on the page."),
|
|
285
|
-
file_path: z.string().describe("Absolute path to the file to upload (e.g. /Users/you/Downloads/task.zip)"),
|
|
286
|
-
wait_ms: z.number().int().min(0).optional().describe("How long to wait for an observable change after setting the file (default 3000). Increase for slow uploaders that take a moment to render thumbnails."),
|
|
287
|
-
verify_selector: z.string().optional().describe('Optional CSS selector that should appear after a successful upload (e.g. ".photo-thumbnail", "[data-uploaded=true]"). When matched, set_file_input returns success immediately.')
|
|
288
|
-
},
|
|
289
|
-
async ({ hint, file_path, wait_ms, verify_selector }) => {
|
|
290
|
-
const wsTimeout = Math.max(3e4, (wait_ms ?? 3e3) + 1e4);
|
|
291
|
-
const response = await bridge.request(
|
|
292
|
-
{ type: "set_file_input", hint, filePath: file_path, waitMs: wait_ms, verifySelector: verify_selector },
|
|
293
|
-
wsTimeout
|
|
294
|
-
);
|
|
295
|
-
const r = response;
|
|
296
|
-
return {
|
|
297
|
-
content: [{ type: "text", text: r.message ?? (r.success ? "File set successfully" : "Failed to set file") }]
|
|
298
|
-
};
|
|
299
|
-
}
|
|
300
|
-
);
|
|
301
|
-
server.tool(
|
|
302
|
-
"react_set_input",
|
|
303
|
-
`Set the value of a React-controlled input via the native value-setter, dispatching the input/change events that React's onChange handler listens for.
|
|
304
|
-
|
|
305
|
-
Use this instead of writing your own \`Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set\` script \u2014 this helper handles the prototype-from-instance gotcha automatically (inputs inside iframes have their own HTMLInputElement constructor, and using the outer-window prototype throws "Illegal invocation").
|
|
306
|
-
|
|
307
|
-
Common cases:
|
|
308
|
-
- A standard input that fill_input fails on because the page validates event.isTrusted or uses an exotic React Hook Form setup.
|
|
309
|
-
- An input inside a same-origin iframe (pass frame="iframe.selector").
|
|
310
|
-
- A hidden React-Select combobox input (selector='input[id*="react-select-3-input"]').
|
|
311
|
-
|
|
312
|
-
Returns the matched element's tag/name/id/type so you can verify it was the right field, and the read-back value so you can spot when React rejected the new value.`,
|
|
313
|
-
{
|
|
314
|
-
selector: z.string().describe("CSS selector of the input to set (e.g. 'input[name=email]', '#promoted-rate-input')"),
|
|
315
|
-
value: z.string().describe("The value to set"),
|
|
316
|
-
frame: z.string().optional().describe('Optional CSS selector for a same-origin iframe whose contents contain the input (e.g. "iframe.se-rte-frame"). Cross-origin iframes are not supported.')
|
|
317
|
-
},
|
|
318
|
-
async ({ selector, value, frame }) => {
|
|
319
|
-
const response = await bridge.request({ type: "react_set_input", selector, value, frame });
|
|
320
|
-
const r = response;
|
|
321
|
-
return {
|
|
322
|
-
content: [{ type: "text", text: r.message ?? (r.success ? "Set" : "Failed to set") }]
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
);
|
|
326
|
-
server.tool(
|
|
327
|
-
"react_call_prop",
|
|
328
|
-
`Walk up the React fiber from a DOM element and call a named prop on the nearest component that has it. Use this as an escape hatch when the UI swallows clicks or a modal never renders \u2014 e.g. calling handleForceSubmitConfirmation directly to bypass a stuck submit modal.
|
|
329
|
-
|
|
330
|
-
Common cases:
|
|
331
|
-
- A submit button whose onClick opens a modal that never appears (validation thinks the form is incomplete because the form-level state is stale, even though the inputs look filled). Walk up to the page-level component and call the bypass handler directly.
|
|
332
|
-
- An onChange handler that the synthetic-event path didn't reach (when click_element fired but React's form-level store wasn't updated).
|
|
333
|
-
|
|
334
|
-
args MUST be JSON-serializable (primitives, arrays, plain objects). Functions, DOM nodes, and Promises cannot be passed in.
|
|
335
|
-
|
|
336
|
-
Returns the component name (when available), the fiber depth where the prop was found, and a stringified version of the return value. If the prop function returned a Promise, react_call_prop awaits it before returning.`,
|
|
337
|
-
{
|
|
338
|
-
selector: z.string().describe(`CSS selector of any element inside the target component's subtree (e.g. 'input[name="justification"]', '#submit-button')`),
|
|
339
|
-
prop_name: z.string().describe("Name of the prop function to call (e.g. 'handleForceSubmitConfirmation', 'onChange', 'onSubmit')"),
|
|
340
|
-
args: z.array(z.any()).optional().describe("Arguments to pass; must be JSON-serializable (primitives, arrays, plain objects). Default: empty."),
|
|
341
|
-
max_depth: z.number().int().min(1).optional().describe("How many fiber levels to walk up before giving up (default 30)"),
|
|
342
|
-
frame: z.string().optional().describe('Optional CSS selector for a same-origin iframe whose contents contain the element (e.g. "iframe.se-rte-frame"). Cross-origin iframes are not supported.')
|
|
343
|
-
},
|
|
344
|
-
async ({ selector, prop_name, args = [], max_depth = 30, frame }) => {
|
|
345
|
-
const response = await bridge.request({
|
|
346
|
-
type: "react_call_prop",
|
|
347
|
-
selector,
|
|
348
|
-
prop_name,
|
|
349
|
-
args,
|
|
350
|
-
max_depth,
|
|
351
|
-
frame
|
|
352
|
-
}, 3e4);
|
|
353
|
-
const r = response;
|
|
354
|
-
return {
|
|
355
|
-
content: [{ type: "text", text: r.message ?? (r.success ? "Called" : "Failed to call prop") }]
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
);
|
|
359
|
-
server.tool(
|
|
360
|
-
"execute_script",
|
|
361
|
-
`Execute JavaScript in the current page's context and return the result. Use for reading framework state or DOM properties not visible in text \u2014 prefer get_page_text for visible content. Top-level \`return\` and \`await\` are supported.
|
|
362
|
-
|
|
363
|
-
CSP-strict pages (Stripe, GitHub) silently fall through to a CDP eval path. Page alerts (alert/confirm/prompt) fired since the last script appear as PAGE ALERT in the result.`,
|
|
364
|
-
{
|
|
365
|
-
code: z.string().describe(
|
|
366
|
-
"JavaScript expression or multi-statement script to evaluate in the page. Top-level `return` is supported."
|
|
367
|
-
)
|
|
368
|
-
},
|
|
369
|
-
async ({ code }) => {
|
|
370
|
-
const response = await bridge.request({ type: "execute_script", code });
|
|
371
|
-
if (response.type !== "script_response") throw new Error("Unexpected response");
|
|
372
|
-
const { result, alert } = response;
|
|
373
|
-
let text = `Result: ${result}`;
|
|
374
|
-
if (alert) {
|
|
375
|
-
text += `
|
|
376
|
-
|
|
377
|
-
PAGE ALERT: "${alert}" \u2014 the page showed a dialog with this message. Read it and act on it before proceeding (e.g. fill a missing field, uncheck a checkbox).`;
|
|
378
|
-
}
|
|
379
|
-
return {
|
|
380
|
-
content: [{ type: "text", text }]
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
);
|
|
384
|
-
server.tool(
|
|
385
|
-
"inspect_request_headers",
|
|
386
|
-
`Navigate to a URL and capture the exact HTTP request headers Chrome sends for the main document request.
|
|
387
|
-
Use this to diagnose server-side bot detection \u2014 e.g. when a site returns a "mobile" or "switch devices" page despite the client reporting desktop.
|
|
388
|
-
Returns the request method, URL, and all headers including Sec-CH-UA-* client hints.
|
|
389
|
-
This tool DOES navigate the active tab to the URL.`,
|
|
390
|
-
{
|
|
391
|
-
url: z.string().url().describe("URL to navigate to and capture headers for")
|
|
392
|
-
},
|
|
393
|
-
async ({ url }) => {
|
|
394
|
-
const response = await bridge.request({ type: "inspect_request_headers", url }, 2e4);
|
|
395
|
-
const r = response;
|
|
396
|
-
return {
|
|
397
|
-
content: [{ type: "text", text: r.message ?? "(no headers captured)" }]
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
);
|
|
401
|
-
}
|
|
402
|
-
export {
|
|
403
|
-
registerBrowserTools
|
|
404
|
-
};
|
package/dist/tools/capture.js
DELETED
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import { appendFileSync, readFileSync, writeFileSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
|
-
import { join, resolve, relative, isAbsolute } from "path";
|
|
5
|
-
const PAGE_STATE_FILE = join(tmpdir(), "chromeflow_page_state.json");
|
|
6
|
-
function registerCaptureTools(server, bridge) {
|
|
7
|
-
server.tool(
|
|
8
|
-
"fill_input",
|
|
9
|
-
`Fill a form input field with a value automatically.
|
|
10
|
-
Use this for fields Claude knows the answer to (product name, price, description, tier name, URLs, etc.).
|
|
11
|
-
DO NOT use for: email address, password, payment/billing info, phone number \u2014 highlight those instead and tell the user what to enter.
|
|
12
|
-
After filling, call wait_for_click only if the user needs to review/confirm; otherwise proceed directly to the next step.
|
|
13
|
-
|
|
14
|
-
The response always includes the matched element's identifying attributes (e.g. \`<input name="title" id="..." placeholder="...">\`) and the match-strength (aria-eq, name-eq, fuzzy-text-walk, etc.). VERIFY this is the field you intended \u2014 fuzzy-text-walk matches are the lowest-confidence kind and have historically caused fill_input to land on the wrong field on dense forms.
|
|
15
|
-
|
|
16
|
-
Pass \`exact: true\` to refuse fuzzy text-walk matches entirely. Use this for short generic labels like "Rate", "Price", or "Amount" on dense forms with many similarly-labeled fields. If no exact match exists, fill_input returns success=false instead of silently filling the wrong field.`,
|
|
17
|
-
{
|
|
18
|
-
textHint: z.string().describe("The label, placeholder, or nearby text identifying the input (e.g. 'Product name', 'Amount', 'Description')"),
|
|
19
|
-
value: z.string().describe("The value to fill in"),
|
|
20
|
-
nth: z.number().int().min(1).optional().describe("Which match to fill when multiple inputs share the same label (1 = first/topmost, default 1)"),
|
|
21
|
-
exact: z.boolean().optional().describe("If true, only match aria-label/placeholder/name/id/label-text equal to the hint \u2014 refuse fuzzy text-walk matches. Default false.")
|
|
22
|
-
},
|
|
23
|
-
async ({ textHint, value, nth, exact }) => {
|
|
24
|
-
const response = await bridge.request({ type: "fill_input", textHint, value, nth, exact });
|
|
25
|
-
if (response.type !== "fill_response") throw new Error("Unexpected response");
|
|
26
|
-
const r = response;
|
|
27
|
-
return {
|
|
28
|
-
content: [{ type: "text", text: r.success ? `Filled "${textHint}": ${r.message}` : `Could not fill "${textHint}": ${r.message}` }]
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
);
|
|
32
|
-
server.tool(
|
|
33
|
-
"read_element",
|
|
34
|
-
"Read the text value of an element on the page, identified by nearby visible text. Use this to capture API keys, IDs, or other values shown on the page.",
|
|
35
|
-
{
|
|
36
|
-
textHint: z.string().describe(
|
|
37
|
-
"Visible text near or within the element whose value you want to read (e.g. 'Publishable key', 'sk-live')"
|
|
38
|
-
)
|
|
39
|
-
},
|
|
40
|
-
async ({ textHint }) => {
|
|
41
|
-
const response = await bridge.request({ type: "read_element", textHint });
|
|
42
|
-
if (response.type !== "read_response") {
|
|
43
|
-
throw new Error("Unexpected response from extension");
|
|
44
|
-
}
|
|
45
|
-
if (response.value === null) {
|
|
46
|
-
return {
|
|
47
|
-
content: [
|
|
48
|
-
{
|
|
49
|
-
type: "text",
|
|
50
|
-
text: `Could not find a value near "${textHint}". Try take_screenshot to locate it.`
|
|
51
|
-
}
|
|
52
|
-
]
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
return {
|
|
56
|
-
content: [
|
|
57
|
-
{
|
|
58
|
-
type: "text",
|
|
59
|
-
text: `Value captured: ${response.value}`
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
);
|
|
65
|
-
server.tool(
|
|
66
|
-
"get_page_text",
|
|
67
|
-
`Get the visible text content of the current page without taking a screenshot.
|
|
68
|
-
Use this instead of take_screenshot whenever you need to read what's on the page \u2014 errors, build status, form labels, confirmation messages, etc.
|
|
69
|
-
Returns up to 10,000 characters per call (~3k tokens). If the response ends with "... (N more characters)", call again with startIndex to read the next chunk.
|
|
70
|
-
Use the selector parameter to scope extraction to a specific section and avoid pulling unnecessary content.
|
|
71
|
-
Never use take_screenshot just to read page content \u2014 paginate with startIndex instead.`,
|
|
72
|
-
{
|
|
73
|
-
selector: z.string().optional().describe(
|
|
74
|
-
`CSS selector to scope the extraction (e.g. 'main', '.error-toast', '[data-testid="status"]'). Omit to auto-extract from the main content area.`
|
|
75
|
-
),
|
|
76
|
-
startIndex: z.number().optional().describe(
|
|
77
|
-
"Character offset to start from. Use this to read past the first 20,000 characters \u2014 the response will tell you the next startIndex when more content exists."
|
|
78
|
-
)
|
|
79
|
-
},
|
|
80
|
-
async ({ selector, startIndex }) => {
|
|
81
|
-
const response = await bridge.request({ type: "get_page_text", selector, startIndex });
|
|
82
|
-
if (response.type !== "page_text_response") throw new Error("Unexpected response");
|
|
83
|
-
const text = response.text;
|
|
84
|
-
return {
|
|
85
|
-
content: [{ type: "text", text: text || "(no text found on page)" }]
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
);
|
|
89
|
-
server.tool(
|
|
90
|
-
"save_page_state",
|
|
91
|
-
`Snapshot the current values of all form fields (inputs, textareas, checkboxes, selects, CodeMirror editors) to a local file.
|
|
92
|
-
Use this before a context window runs out or any time you want a checkpoint mid-form.
|
|
93
|
-
A future session can call restore_page_state to pick up exactly where you left off.`,
|
|
94
|
-
{},
|
|
95
|
-
async () => {
|
|
96
|
-
const response = await bridge.request({ type: "save_page_state" });
|
|
97
|
-
if (response.type !== "save_state_response") throw new Error("Unexpected response");
|
|
98
|
-
const state = response.state;
|
|
99
|
-
writeFileSync(PAGE_STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
|
|
100
|
-
return {
|
|
101
|
-
content: [{ type: "text", text: `Saved ${state.length} field values to ${PAGE_STATE_FILE}. Call restore_page_state in a future session to reload them.` }]
|
|
102
|
-
};
|
|
103
|
-
}
|
|
104
|
-
);
|
|
105
|
-
server.tool(
|
|
106
|
-
"restore_page_state",
|
|
107
|
-
`Restore form field values from a previously saved snapshot (created by save_page_state).
|
|
108
|
-
Use this at the start of a new session when resuming a long form-filling task.
|
|
109
|
-
The snapshot is read from the local temp file written by save_page_state.`,
|
|
110
|
-
{},
|
|
111
|
-
async () => {
|
|
112
|
-
let state;
|
|
113
|
-
try {
|
|
114
|
-
state = JSON.parse(readFileSync(PAGE_STATE_FILE, "utf-8"));
|
|
115
|
-
} catch {
|
|
116
|
-
return {
|
|
117
|
-
content: [{ type: "text", text: `No saved page state found at ${PAGE_STATE_FILE}. Call save_page_state first.` }]
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
const response = await bridge.request({ type: "restore_page_state", state });
|
|
121
|
-
const msg = response.message ?? "Done";
|
|
122
|
-
return {
|
|
123
|
-
content: [{ type: "text", text: msg }]
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
);
|
|
127
|
-
server.tool(
|
|
128
|
-
"get_console_logs",
|
|
129
|
-
`Read the browser console output (log, warn, error, info) captured since the page loaded.
|
|
130
|
-
Returns the last 200 messages with their level and timestamp.
|
|
131
|
-
Use this to check for JavaScript errors, debug React issues, or verify that an action produced the expected console output.
|
|
132
|
-
Pass level="error" to see only errors, or omit to see all levels.`,
|
|
133
|
-
{
|
|
134
|
-
level: z.enum(["log", "warn", "error", "info"]).optional().describe('Filter by log level (e.g. "error" to see only errors). Omit for all levels.')
|
|
135
|
-
},
|
|
136
|
-
async ({ level }) => {
|
|
137
|
-
const response = await bridge.request({ type: "execute_script", code: `JSON.stringify(window._consoleLogs || [])` });
|
|
138
|
-
if (response.type !== "script_response") throw new Error("Unexpected response");
|
|
139
|
-
let logs;
|
|
140
|
-
try {
|
|
141
|
-
logs = JSON.parse(response.result);
|
|
142
|
-
} catch {
|
|
143
|
-
return { content: [{ type: "text", text: "No console logs captured (console capture may not be injected on this page yet \u2014 navigate first)." }] };
|
|
144
|
-
}
|
|
145
|
-
if (level) logs = logs.filter((l) => l.level === level);
|
|
146
|
-
if (logs.length === 0) {
|
|
147
|
-
return { content: [{ type: "text", text: level ? `No ${level}-level console messages.` : "No console messages captured." }] };
|
|
148
|
-
}
|
|
149
|
-
const lines = logs.map((l) => {
|
|
150
|
-
const time = new Date(l.time).toISOString().slice(11, 23);
|
|
151
|
-
return `[${time}] ${l.level.toUpperCase()}: ${l.message.slice(0, 500)}`;
|
|
152
|
-
});
|
|
153
|
-
return { content: [{ type: "text", text: `Console logs (${logs.length} entries):
|
|
154
|
-
${lines.join("\n")}` }] };
|
|
155
|
-
}
|
|
156
|
-
);
|
|
157
|
-
server.tool(
|
|
158
|
-
"write_to_env",
|
|
159
|
-
"Write a key=value pair to a .env file. Use this after capturing an API key or ID from the page.",
|
|
160
|
-
{
|
|
161
|
-
key: z.string().describe("Environment variable name (e.g. STRIPE_SECRET_KEY)"),
|
|
162
|
-
value: z.string().describe("The value to write"),
|
|
163
|
-
envPath: z.string().describe(
|
|
164
|
-
"Absolute path to the .env file (e.g. /Users/me/myproject/.env)"
|
|
165
|
-
)
|
|
166
|
-
},
|
|
167
|
-
async ({ key, value, envPath }) => {
|
|
168
|
-
try {
|
|
169
|
-
const cwd = process.cwd();
|
|
170
|
-
const resolved = isAbsolute(envPath) ? envPath : resolve(cwd, envPath);
|
|
171
|
-
const rel = relative(cwd, resolved);
|
|
172
|
-
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
173
|
-
throw new Error(
|
|
174
|
-
`Refusing to write .env outside the project directory. Target "${resolved}" is not under "${cwd}". If this is intentional, move the project to include the target path.`
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
const filename = resolved.split("/").pop() ?? "";
|
|
178
|
-
if (!/^\.env(\.[\w-]+)?$/.test(filename)) {
|
|
179
|
-
throw new Error(
|
|
180
|
-
`Refusing to write: "${filename}" doesn't look like an env file. Expected .env, .env.local, .env.<name>, etc.`
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
envPath = resolved;
|
|
184
|
-
let existing = "";
|
|
185
|
-
try {
|
|
186
|
-
existing = readFileSync(envPath, "utf-8");
|
|
187
|
-
} catch {
|
|
188
|
-
}
|
|
189
|
-
const lines = existing.split("\n");
|
|
190
|
-
const keyPattern = new RegExp(`^${key}=`);
|
|
191
|
-
const existingIndex = lines.findIndex((l) => keyPattern.test(l));
|
|
192
|
-
if (existingIndex !== -1) {
|
|
193
|
-
lines[existingIndex] = `${key}=${value}`;
|
|
194
|
-
writeFileSync(envPath, lines.join("\n"), "utf-8");
|
|
195
|
-
} else {
|
|
196
|
-
const toAppend = (existing && !existing.endsWith("\n") ? "\n" : "") + `${key}=${value}
|
|
197
|
-
`;
|
|
198
|
-
appendFileSync(envPath, toAppend, "utf-8");
|
|
199
|
-
}
|
|
200
|
-
return {
|
|
201
|
-
content: [
|
|
202
|
-
{
|
|
203
|
-
type: "text",
|
|
204
|
-
text: `Written ${key}=<value> to ${envPath}`
|
|
205
|
-
}
|
|
206
|
-
]
|
|
207
|
-
};
|
|
208
|
-
} catch (err) {
|
|
209
|
-
throw new Error(`Failed to write to .env: ${err.message}`);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
);
|
|
213
|
-
}
|
|
214
|
-
export {
|
|
215
|
-
registerCaptureTools
|
|
216
|
-
};
|