agentbrowse 0.0.3 → 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 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
- - **Targeting `click`/`type`**, in priority order: (1) visible text `click "Sign in"`; (2) a number from the last `links`/`find` `click 2`; (3) a CSS selector`click "button.primary"`. Bare words are treated as visible text; use explicit CSS for elements without text.
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 changesfar 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 number from `links`/`find`, or a CSS selector |
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,6 +1,6 @@
1
1
  {
2
2
  "name": "agentbrowse",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
4
4
  "description": "Agent-browser CLI: drive any website from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {