agentbrowse 0.0.2 → 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/AGENTS.md +2 -1
- package/README.md +2 -1
- package/dist/cli.js +135 -0
- package/package.json +6 -2
package/AGENTS.md
CHANGED
|
@@ -21,7 +21,8 @@ agentbrowse read --page 2 # next chunk if it was truncated
|
|
|
21
21
|
## Rules that make this reliable
|
|
22
22
|
|
|
23
23
|
- **Read with `--json`** when you need to parse: `read --json` gives `{ title, markdown, page, totalPages, state }`. Every command's text output ends with a `url | title | links` footer so you always know where you are.
|
|
24
|
-
- **
|
|
24
|
+
- **Prefer `snapshot` for acting.** `snapshot` returns every actionable element as `[ref] role "name" (state)` — e.g. `[3] button "Search"`. Then act by ref: `click 3`, `type 2 "shoes"`. Refs resolve by role+name, so they survive CSS/DOM changes — far more reliable than selectors. If the page changed and a ref is stale, the tool returns a **fresh snapshot** in the error (`stale_ref`); just re-pick from it.
|
|
25
|
+
- **Other `click`/`type` targeting**, in priority order: (1) a `snapshot` ref — `click 3`; (2) visible text — `click "Sign in"`; (3) a number from the last `links`/`find`; (4) a CSS selector — `click "button.primary"`. Bare words are visible text; use explicit CSS for elements without text.
|
|
25
26
|
- **Forms:** `fill -f email=me@x.com -f password=...` then `submit`. Or `type <field> <text>` for one field. Fields match by `name`, or pass a CSS selector.
|
|
26
27
|
- **Truncation:** `read` is capped (`--max-chars`, default 8000). If `truncated`, request `--page 2`, etc. Don't assume you've seen the whole page from page 1.
|
|
27
28
|
- **Errors:** non-zero exit codes mean failure; the reason is on **stderr** as `{ "error": { code, message } }`. `4` = target not found (re-run `links`/`find` to get fresh numbers), `3` = navigation problem, `2` = bad usage, `5` = daemon problem.
|
package/README.md
CHANGED
|
@@ -41,8 +41,9 @@ Sessions are isolated by `--session <id>` (default `default`), each with its own
|
|
|
41
41
|
| `open <url>` | Navigate the session to a URL |
|
|
42
42
|
| `read [url]` | Current page (or open `<url>` first) as token-bounded markdown (`--max-chars`, `--page`) |
|
|
43
43
|
| `links [url]` | Numbered, followable links (`--filter`) |
|
|
44
|
+
| `snapshot [url]` | **Accessibility-tree view**: every actionable element with a stable `[ref]`, role, name, state (`--filter`, `--max`, `--json`). The robust way to act |
|
|
44
45
|
| `find <text>` | Locate elements by visible text; numbers reusable by `click` |
|
|
45
|
-
| `click <target>` | Click by visible text, a
|
|
46
|
+
| `click <target>` | Click by a `snapshot` ref (robust), visible text, a `links`/`find` number, or a CSS selector |
|
|
46
47
|
| `type <field> <text>` | Type into a field (CSS selector or bare `name`) |
|
|
47
48
|
| `fill -f name=value …` | Fill form fields |
|
|
48
49
|
| `submit [form]` | Submit the current form |
|
package/dist/cli.js
CHANGED
|
@@ -134,6 +134,7 @@ var DAEMON_CODE_EXIT = {
|
|
|
134
134
|
bad_args: EXIT.usage,
|
|
135
135
|
unknown_cmd: EXIT.usage,
|
|
136
136
|
target_not_found: EXIT.targetNotFound,
|
|
137
|
+
stale_ref: EXIT.targetNotFound,
|
|
137
138
|
exec_error: EXIT.navigation
|
|
138
139
|
};
|
|
139
140
|
function fromDaemon(res) {
|
|
@@ -228,6 +229,31 @@ async function runLinks(opts) {
|
|
|
228
229
|
return data.links.map((l) => `${l.n}. ${l.text} -> ${l.href}`).join("\n");
|
|
229
230
|
}
|
|
230
231
|
|
|
232
|
+
// src/commands/snapshot.ts
|
|
233
|
+
async function runSnapshot(opts) {
|
|
234
|
+
if (opts.url) {
|
|
235
|
+
const o = await sendRequest(opts.session, { id: nextId(), cmd: "open", args: { url: opts.url } });
|
|
236
|
+
if (!o.ok) throw fromDaemon(o);
|
|
237
|
+
}
|
|
238
|
+
const res = await sendRequest(opts.session, {
|
|
239
|
+
id: nextId(),
|
|
240
|
+
cmd: "snapshot",
|
|
241
|
+
args: { filter: opts.filter, max: opts.max }
|
|
242
|
+
});
|
|
243
|
+
if (!res.ok) throw fromDaemon(res);
|
|
244
|
+
const d = res.data;
|
|
245
|
+
if (opts.json) return JSON.stringify(d, null, 2);
|
|
246
|
+
const lines = d.elements.map((e) => {
|
|
247
|
+
const state = [e.disabled ? "disabled" : "", e.checked ? "checked" : ""].filter(Boolean).join(" ");
|
|
248
|
+
return `[${e.ref}] ${e.role.padEnd(9)} "${e.name}"${e.href ? ` -> ${e.href}` : ""}${state ? ` (${state})` : ""}`;
|
|
249
|
+
});
|
|
250
|
+
const more = d.total > d.elements.length ? `
|
|
251
|
+
(+${d.total - d.elements.length} more \u2014 narrow with --filter)` : "";
|
|
252
|
+
return `${lines.join("\n") || "(no actionable elements)"}${more}
|
|
253
|
+
---
|
|
254
|
+
snapshot v${d.version} | url: ${d.url} | ${d.elements.length} actionable elements`;
|
|
255
|
+
}
|
|
256
|
+
|
|
231
257
|
// src/commands/stop.ts
|
|
232
258
|
async function runStop(session) {
|
|
233
259
|
try {
|
|
@@ -568,6 +594,60 @@ function extractLinks(html, baseUrl, filter) {
|
|
|
568
594
|
return out;
|
|
569
595
|
}
|
|
570
596
|
|
|
597
|
+
// src/core/snapshot.ts
|
|
598
|
+
var ACTIONABLE = /* @__PURE__ */ new Set([
|
|
599
|
+
"link",
|
|
600
|
+
"button",
|
|
601
|
+
"textbox",
|
|
602
|
+
"searchbox",
|
|
603
|
+
"checkbox",
|
|
604
|
+
"radio",
|
|
605
|
+
"combobox",
|
|
606
|
+
"listbox",
|
|
607
|
+
"menuitem",
|
|
608
|
+
"menuitemcheckbox",
|
|
609
|
+
"menuitemradio",
|
|
610
|
+
"tab",
|
|
611
|
+
"switch",
|
|
612
|
+
"slider",
|
|
613
|
+
"spinbutton",
|
|
614
|
+
"option"
|
|
615
|
+
]);
|
|
616
|
+
var LINE = /^\s*-\s+([a-z][\w-]*)(?:\s+"((?:[^"\\]|\\.)*)")?(?:\s+\[([^\]]*)\])?:?\s*$/;
|
|
617
|
+
var URL2 = /^\s*-\s+\/url:\s*"?([^"\n]*?)"?\s*$/;
|
|
618
|
+
function parseAriaSnapshot(yaml) {
|
|
619
|
+
const out = [];
|
|
620
|
+
const counts = /* @__PURE__ */ new Map();
|
|
621
|
+
for (const raw of yaml.split("\n")) {
|
|
622
|
+
const url = raw.match(URL2);
|
|
623
|
+
if (url) {
|
|
624
|
+
const last = out[out.length - 1];
|
|
625
|
+
if (last && last.role === "link") last.href = url[1];
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
const m = raw.match(LINE);
|
|
629
|
+
if (!m) continue;
|
|
630
|
+
const role = m[1];
|
|
631
|
+
const name = (m[2] ?? "").replace(/\\"/g, '"');
|
|
632
|
+
const states = (m[3] ?? "").split(/[\s,]+/).filter(Boolean);
|
|
633
|
+
const actionable = ACTIONABLE.has(role);
|
|
634
|
+
const el = { role, name, actionable, nth: 0 };
|
|
635
|
+
for (const s of states) {
|
|
636
|
+
if (s === "disabled") el.disabled = true;
|
|
637
|
+
else if (s === "checked") el.checked = true;
|
|
638
|
+
else if (s.startsWith("level=")) el.level = Number(s.slice(6));
|
|
639
|
+
}
|
|
640
|
+
if (actionable) {
|
|
641
|
+
const key = `${role} ${name}`;
|
|
642
|
+
const n = counts.get(key) ?? 0;
|
|
643
|
+
el.nth = n;
|
|
644
|
+
counts.set(key, n + 1);
|
|
645
|
+
}
|
|
646
|
+
out.push(el);
|
|
647
|
+
}
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
|
|
571
651
|
// src/core/target.ts
|
|
572
652
|
function looksLikeSelector(s) {
|
|
573
653
|
const t = s.trim();
|
|
@@ -599,6 +679,39 @@ async function startDaemon(sessionId, opts = {}) {
|
|
|
599
679
|
const context = await browser.newContext(statePath ? { storageState: statePath } : {});
|
|
600
680
|
const page = await context.newPage();
|
|
601
681
|
let lastRefs = [];
|
|
682
|
+
let snapshotVersion = 0;
|
|
683
|
+
let snapshotRefs = /* @__PURE__ */ new Map();
|
|
684
|
+
page.on("framenavigated", (f) => {
|
|
685
|
+
if (f === page.mainFrame()) snapshotVersion++;
|
|
686
|
+
});
|
|
687
|
+
async function freshSnapshot(filter, max = 150) {
|
|
688
|
+
const yaml = await page.locator("body").ariaSnapshot();
|
|
689
|
+
const all = parseAriaSnapshot(yaml).filter((e) => e.actionable);
|
|
690
|
+
const f = filter?.toLowerCase();
|
|
691
|
+
const shown = all.filter((e) => !f || `${e.role} ${e.name}`.toLowerCase().includes(f)).slice(0, max);
|
|
692
|
+
snapshotRefs = /* @__PURE__ */ new Map();
|
|
693
|
+
const elements = shown.map((e, i) => {
|
|
694
|
+
const ref = i + 1;
|
|
695
|
+
snapshotRefs.set(ref, { role: e.role, name: e.name, nth: e.nth });
|
|
696
|
+
return { ref, role: e.role, name: e.name, href: e.href, disabled: e.disabled, checked: e.checked };
|
|
697
|
+
});
|
|
698
|
+
return { elements, total: all.length };
|
|
699
|
+
}
|
|
700
|
+
async function staleRef(req) {
|
|
701
|
+
const fresh = await freshSnapshot();
|
|
702
|
+
return {
|
|
703
|
+
id: req.id,
|
|
704
|
+
ok: false,
|
|
705
|
+
error: { code: "stale_ref", message: "snapshot is stale; use the fresh refs" },
|
|
706
|
+
data: { version: snapshotVersion, url: page.url(), elements: fresh.elements }
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function refLocator(target) {
|
|
710
|
+
if (!/^\d+$/.test(target)) return null;
|
|
711
|
+
const entry = snapshotRefs.get(parseInt(target, 10));
|
|
712
|
+
if (!entry) return null;
|
|
713
|
+
return page.getByRole(entry.role, { name: entry.name, exact: true }).nth(entry.nth);
|
|
714
|
+
}
|
|
602
715
|
const settle = () => page.waitForLoadState("domcontentloaded").catch(() => {
|
|
603
716
|
});
|
|
604
717
|
async function dispatch(req) {
|
|
@@ -645,6 +758,13 @@ async function startDaemon(sessionId, opts = {}) {
|
|
|
645
758
|
case "click": {
|
|
646
759
|
const target = String(req.args?.target ?? "");
|
|
647
760
|
if (!target) return err(req, "bad_args", "click requires a target");
|
|
761
|
+
const snapLoc = refLocator(target);
|
|
762
|
+
if (snapLoc) {
|
|
763
|
+
if (await snapLoc.count() === 0) return staleRef(req);
|
|
764
|
+
await snapLoc.click();
|
|
765
|
+
await settle();
|
|
766
|
+
return ok(req, { url: page.url(), title: await page.title() });
|
|
767
|
+
}
|
|
648
768
|
if (/^\d+$/.test(target)) {
|
|
649
769
|
const ref = lastRefs[parseInt(target, 10) - 1];
|
|
650
770
|
if (!ref) return err(req, "target_not_found", `no ref #${target}; run links or find first`);
|
|
@@ -666,6 +786,12 @@ async function startDaemon(sessionId, opts = {}) {
|
|
|
666
786
|
const selector = String(req.args?.selector ?? "");
|
|
667
787
|
const text = String(req.args?.text ?? "");
|
|
668
788
|
if (!selector) return err(req, "bad_args", "type requires a selector");
|
|
789
|
+
const snapField = refLocator(selector);
|
|
790
|
+
if (snapField) {
|
|
791
|
+
if (await snapField.count() === 0) return staleRef(req);
|
|
792
|
+
await snapField.fill(text);
|
|
793
|
+
return ok(req, { typed: selector, url: page.url() });
|
|
794
|
+
}
|
|
669
795
|
const loc = resolveField(page, selector);
|
|
670
796
|
if (await loc.count() === 0) return err(req, "target_not_found", `no field matching: ${selector}`);
|
|
671
797
|
await loc.fill(text);
|
|
@@ -695,6 +821,12 @@ async function startDaemon(sessionId, opts = {}) {
|
|
|
695
821
|
await settle();
|
|
696
822
|
return ok(req, { url: page.url(), title: await page.title() });
|
|
697
823
|
}
|
|
824
|
+
case "snapshot": {
|
|
825
|
+
const filter = req.args?.filter ? String(req.args.filter) : void 0;
|
|
826
|
+
const max = Number(req.args?.max ?? 150);
|
|
827
|
+
const { elements, total } = await freshSnapshot(filter, max);
|
|
828
|
+
return ok(req, { version: snapshotVersion, url: page.url(), elements, total });
|
|
829
|
+
}
|
|
698
830
|
case "savestate": {
|
|
699
831
|
const p = ensureStatePath(sessionId);
|
|
700
832
|
await context.storageState({ path: p });
|
|
@@ -790,6 +922,9 @@ function buildProgram() {
|
|
|
790
922
|
program.command("links").description("List navigable links on the current page (or open <url> first).").argument("[url]", "optional URL to open before listing").option("--json", "structured JSON output", false).option("--filter <text>", "case-insensitive substring filter").action(
|
|
791
923
|
(url, opts, cmd) => emit(() => runLinks({ session: session(cmd), json: !!opts.json, filter: opts.filter, url }))
|
|
792
924
|
);
|
|
925
|
+
program.command("snapshot").description("List actionable elements (accessibility tree) with refs for click/type.").argument("[url]", "optional URL to open before snapshotting").option("--json", "structured JSON output", false).option("--filter <text>", "case-insensitive substring filter").option("--max <n>", "max elements", (v) => parseInt(v, 10), 150).action(
|
|
926
|
+
(url, opts, cmd) => emit(() => runSnapshot({ session: session(cmd), json: !!opts.json, filter: opts.filter, max: opts.max, url }))
|
|
927
|
+
);
|
|
793
928
|
program.command("find").description("Find elements on the current page by visible text (numbers reusable by click).").argument("<text...>", "visible text to search for").option("--json", "structured JSON output", false).action(
|
|
794
929
|
(text, opts, cmd) => emit(() => runFind({ session: session(cmd), json: !!opts.json, text: text.join(" ") }))
|
|
795
930
|
);
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentbrowse",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Agent-browser CLI: drive any website from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"agentbrowse": "
|
|
7
|
+
"agentbrowse": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist",
|
|
@@ -36,5 +36,9 @@
|
|
|
36
36
|
"tsx": "^4.16.0",
|
|
37
37
|
"typescript": "^5.5.0",
|
|
38
38
|
"vitest": "^2.0.0"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/mandarwagh9/agentbrowse.git"
|
|
39
43
|
}
|
|
40
44
|
}
|