patchrome 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 (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/bin/patchrome.js +10 -0
  4. package/dist/build-id.d.ts +2 -0
  5. package/dist/build-id.js +21 -0
  6. package/dist/challenges.d.ts +22 -0
  7. package/dist/challenges.js +97 -0
  8. package/dist/chrome-profiles.d.ts +17 -0
  9. package/dist/chrome-profiles.js +141 -0
  10. package/dist/cli-options.d.ts +131 -0
  11. package/dist/cli-options.js +43 -0
  12. package/dist/cli.d.ts +48 -0
  13. package/dist/cli.js +572 -0
  14. package/dist/client.d.ts +16 -0
  15. package/dist/client.js +210 -0
  16. package/dist/commands.d.ts +58 -0
  17. package/dist/commands.js +1076 -0
  18. package/dist/completions.d.ts +1 -0
  19. package/dist/completions.js +114 -0
  20. package/dist/copy-guard.d.ts +75 -0
  21. package/dist/copy-guard.js +167 -0
  22. package/dist/daemon.d.ts +7 -0
  23. package/dist/daemon.js +313 -0
  24. package/dist/diagnostics.d.ts +44 -0
  25. package/dist/diagnostics.js +117 -0
  26. package/dist/engine.d.ts +51 -0
  27. package/dist/engine.js +257 -0
  28. package/dist/events.d.ts +41 -0
  29. package/dist/events.js +106 -0
  30. package/dist/extract.d.ts +27 -0
  31. package/dist/extract.js +62 -0
  32. package/dist/focus.d.ts +1 -0
  33. package/dist/focus.js +44 -0
  34. package/dist/glob.d.ts +4 -0
  35. package/dist/glob.js +63 -0
  36. package/dist/har.d.ts +105 -0
  37. package/dist/har.js +88 -0
  38. package/dist/history.d.ts +35 -0
  39. package/dist/history.js +277 -0
  40. package/dist/host-platform.d.ts +5 -0
  41. package/dist/host-platform.js +19 -0
  42. package/dist/host-prompts-macos.d.ts +2 -0
  43. package/dist/host-prompts-macos.js +102 -0
  44. package/dist/host-prompts-wsl.d.ts +6 -0
  45. package/dist/host-prompts-wsl.js +64 -0
  46. package/dist/host-prompts.d.ts +3 -0
  47. package/dist/host-prompts.js +25 -0
  48. package/dist/index.d.ts +17 -0
  49. package/dist/index.js +47 -0
  50. package/dist/network.d.ts +54 -0
  51. package/dist/network.js +204 -0
  52. package/dist/origin-storage.d.ts +31 -0
  53. package/dist/origin-storage.js +82 -0
  54. package/dist/paths.d.ts +17 -0
  55. package/dist/paths.js +52 -0
  56. package/dist/pipe.d.ts +9 -0
  57. package/dist/pipe.js +73 -0
  58. package/dist/profile-mode.d.ts +10 -0
  59. package/dist/profile-mode.js +42 -0
  60. package/dist/protocol-help.d.ts +34 -0
  61. package/dist/protocol-help.js +66 -0
  62. package/dist/protocol.d.ts +49 -0
  63. package/dist/protocol.js +89 -0
  64. package/dist/refs.d.ts +9 -0
  65. package/dist/refs.js +46 -0
  66. package/dist/routes.d.ts +20 -0
  67. package/dist/routes.js +106 -0
  68. package/dist/runner.d.ts +20 -0
  69. package/dist/runner.js +81 -0
  70. package/dist/session-name.d.ts +9 -0
  71. package/dist/session-name.js +50 -0
  72. package/dist/session-store.d.ts +5 -0
  73. package/dist/session-store.js +58 -0
  74. package/dist/sessions.d.ts +47 -0
  75. package/dist/sessions.js +171 -0
  76. package/dist/tab-groups.d.ts +9 -0
  77. package/dist/tab-groups.js +13 -0
  78. package/dist/targets.d.ts +43 -0
  79. package/dist/targets.js +229 -0
  80. package/dist/validate.d.ts +3 -0
  81. package/dist/validate.js +31 -0
  82. package/dist/wait.d.ts +24 -0
  83. package/dist/wait.js +88 -0
  84. package/examples/go/go.mod +3 -0
  85. package/examples/go/main.go +104 -0
  86. package/examples/hn-front-page.sh +18 -0
  87. package/examples/hn-front-page.ts +24 -0
  88. package/examples/hn_front_page.py +56 -0
  89. package/extension/tab-groups/manifest.json +8 -0
  90. package/extension/tab-groups/service-worker.js +41 -0
  91. package/package.json +60 -0
  92. package/skills/patchrome/SKILL.md +74 -0
  93. package/skills/patchrome/references/commands.md +130 -0
  94. package/skills/patchrome/references/debugging.md +20 -0
  95. package/skills/patchrome/references/hard-pages.md +49 -0
  96. package/skills/patchrome/references/logins.md +46 -0
  97. package/skills/patchrome/references/scraping.md +51 -0
  98. package/skills/patchrome/references/scripting.md +79 -0
package/dist/wait.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { Page } from "patchright";
2
+ import { type CommandArgs } from "./protocol.ts";
3
+ import { type ElementLocator } from "./targets.ts";
4
+ export declare const loadStates: readonly ["load", "domcontentloaded", "networkidle"];
5
+ export type LoadState = (typeof loadStates)[number];
6
+ export type WaitCondition = {
7
+ kind: "element";
8
+ locator: ElementLocator;
9
+ isGone: boolean;
10
+ } | {
11
+ kind: "url";
12
+ glob: string;
13
+ isGone: boolean;
14
+ } | {
15
+ kind: "title";
16
+ text: string;
17
+ isGone: boolean;
18
+ } | {
19
+ kind: "load";
20
+ state: LoadState;
21
+ };
22
+ export declare function parseWaitCondition(args: CommandArgs): WaitCondition;
23
+ export declare function describeWaitCondition(condition: WaitCondition): string;
24
+ export declare function waitForCondition(page: Page, condition: WaitCondition, timeoutMs: number): Promise<void>;
package/dist/wait.js ADDED
@@ -0,0 +1,88 @@
1
+ import { urlGlobMatches } from "./glob.js";
2
+ import { CommandError } from "./protocol.js";
3
+ import { describeElementLocator, elementLocator, parseElementLocator } from "./targets.js";
4
+ export const loadStates = ["load", "domcontentloaded", "networkidle"];
5
+ const titlePollMs = 250;
6
+ const waitHint = "wait --selector <css> | --role <role> [--name <name>] | --text <text> | --label <text> | --url <glob> | --title <text> [--gone], or wait --load load|domcontentloaded|networkidle";
7
+ export function parseWaitCondition(args) {
8
+ const isGone = args.gone === true;
9
+ const pageConditions = ["url", "title", "load"].filter((name) => typeof args[name] === "string");
10
+ const elementConditions = ["selector", "role", "text", "label"].filter((name) => typeof args[name] === "string");
11
+ const given = [...elementConditions, ...pageConditions];
12
+ if (given.length !== 1) {
13
+ throw new CommandError("bad_args", given.length === 0
14
+ ? "wait needs a condition"
15
+ : `wait takes one condition, got ${given.map((option) => `--${option}`).join(" ")}`, waitHint);
16
+ }
17
+ const locator = parseElementLocator(args, waitHint);
18
+ if (locator !== undefined)
19
+ return { kind: "element", locator, isGone };
20
+ const [name] = pageConditions;
21
+ if (name === undefined)
22
+ throw new CommandError("bad_args", "wait needs a condition", waitHint);
23
+ const value = String(args[name]);
24
+ if (value === "")
25
+ throw new CommandError("bad_args", `wait --${name} needs a non-empty value`);
26
+ switch (name) {
27
+ case "url":
28
+ return { kind: "url", glob: value, isGone };
29
+ case "title":
30
+ return { kind: "title", text: value, isGone };
31
+ case "load":
32
+ if (isGone)
33
+ throw new CommandError("bad_args", "wait --load does not take --gone");
34
+ if (!loadStates.includes(value))
35
+ throw new CommandError("bad_args", `wait --load must be one of ${loadStates.join(", ")}, got ${value}`);
36
+ return { kind: "load", state: value };
37
+ }
38
+ }
39
+ export function describeWaitCondition(condition) {
40
+ switch (condition.kind) {
41
+ case "element":
42
+ return `${condition.isGone ? "no visible" : "a visible"} ${describeElementLocator(condition.locator)}`;
43
+ case "url":
44
+ return `url ${condition.isGone ? "leaving" : "matching"} ${condition.glob}`;
45
+ case "title":
46
+ return `title ${condition.isGone ? "without" : "with"} "${condition.text}"`;
47
+ case "load":
48
+ return `load state ${condition.state}`;
49
+ }
50
+ }
51
+ // Element and URL conditions use Patchright's own waits, which query from an isolated world: a probe page that
52
+ // wrapped querySelector, getClientRects and requestAnimationFrame saw no calls from them. waitForFunction
53
+ // does run in the page's realm (the probe saw its requestAnimationFrame polling), so the title is polled
54
+ // from here instead. Throws Playwright's TimeoutError when the time runs out.
55
+ export async function waitForCondition(page, condition, timeoutMs) {
56
+ switch (condition.kind) {
57
+ case "load":
58
+ return page.waitForLoadState(condition.state, { timeout: timeoutMs });
59
+ case "element": {
60
+ const locator = elementLocator(page, condition.locator);
61
+ // On a macOS CI runner waitFor took over a second to report an element that was already visible.
62
+ // One direct read answers that case at once; a bad locator falls through so waitFor reports it.
63
+ const isVisible = await locator.isVisible().catch(() => undefined);
64
+ if (isVisible !== undefined && isVisible !== condition.isGone)
65
+ return;
66
+ return locator.waitFor({
67
+ state: condition.isGone ? "hidden" : "visible",
68
+ timeout: timeoutMs,
69
+ });
70
+ }
71
+ case "url":
72
+ await page.waitForURL((url) => urlGlobMatches(condition.glob, url.href) !== condition.isGone, {
73
+ timeout: timeoutMs,
74
+ waitUntil: "commit",
75
+ });
76
+ return;
77
+ case "title": {
78
+ const deadlineMs = Date.now() + timeoutMs;
79
+ // A read that lands mid-navigation throws; it counts as not yet.
80
+ while ((await page.title().then((title) => title.includes(condition.text), () => condition.isGone)) === condition.isGone) {
81
+ if (Date.now() >= deadlineMs)
82
+ throw new CommandError("timeout", `no ${describeWaitCondition(condition)} within ${timeoutMs} ms`);
83
+ await new Promise((resolve) => setTimeout(resolve, titlePollMs));
84
+ }
85
+ return;
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,3 @@
1
+ module patchrome.example/hn
2
+
3
+ go 1.22
@@ -0,0 +1,104 @@
1
+ // Top stories on Hacker News from Go, over one `patchrome pipe`. Run with `go run .`.
2
+ package main
3
+
4
+ import (
5
+ "bufio"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "log"
10
+ "os"
11
+ "os/exec"
12
+ )
13
+
14
+ type response struct {
15
+ ID int `json:"id"`
16
+ OK bool `json:"ok"`
17
+ Data json.RawMessage `json:"data"`
18
+ Stream json.RawMessage `json:"stream"`
19
+ Error *struct {
20
+ Code string `json:"code"`
21
+ Message string `json:"message"`
22
+ } `json:"error"`
23
+ }
24
+
25
+ type patchrome struct {
26
+ stdin io.WriteCloser
27
+ stdout *bufio.Scanner
28
+ nextID int
29
+ cmd *exec.Cmd
30
+ }
31
+
32
+ func start(session string) (*patchrome, error) {
33
+ cmd := exec.Command("patchrome", "pipe")
34
+ cmd.Env = append(os.Environ(), "PATCHROME_SESSION="+session)
35
+ cmd.Stderr = os.Stderr
36
+ stdin, err := cmd.StdinPipe()
37
+ if err != nil {
38
+ return nil, err
39
+ }
40
+ stdout, err := cmd.StdoutPipe()
41
+ if err != nil {
42
+ return nil, err
43
+ }
44
+ scanner := bufio.NewScanner(stdout)
45
+ scanner.Buffer(make([]byte, 1<<20), 64<<20)
46
+ return &patchrome{stdin: stdin, stdout: scanner, cmd: cmd}, cmd.Start()
47
+ }
48
+
49
+ // run sends the words you would type after `patchrome` and decodes the response's data into out.
50
+ func (p *patchrome) run(out any, words ...string) error {
51
+ p.nextID++
52
+ request, _ := json.Marshal(map[string]any{"id": p.nextID, "argv": words})
53
+ if _, err := fmt.Fprintf(p.stdin, "%s\n", request); err != nil {
54
+ return err
55
+ }
56
+ for p.stdout.Scan() {
57
+ var message response
58
+ if err := json.Unmarshal(p.stdout.Bytes(), &message); err != nil {
59
+ return err
60
+ }
61
+ if message.ID != p.nextID || message.Stream != nil {
62
+ continue
63
+ }
64
+ if !message.OK {
65
+ return fmt.Errorf("patchrome %s: %s", message.Error.Code, message.Error.Message)
66
+ }
67
+ if out == nil {
68
+ return nil
69
+ }
70
+ return json.Unmarshal(message.Data, out)
71
+ }
72
+ return fmt.Errorf("patchrome pipe exited: %v", p.stdout.Err())
73
+ }
74
+
75
+ func (p *patchrome) close() {
76
+ _ = p.run(nil, "session", "close")
77
+ _ = p.stdin.Close()
78
+ _ = p.cmd.Wait()
79
+ }
80
+
81
+ func main() {
82
+ browser, err := start(fmt.Sprintf("hn-go-%d", os.Getpid()))
83
+ if err != nil {
84
+ log.Fatal(err)
85
+ }
86
+ defer browser.close()
87
+
88
+ if err := browser.run(nil, "open", "https://news.ycombinator.com/"); err != nil {
89
+ log.Fatal(err)
90
+ }
91
+ schema := `{"rows": "tr.athing", "fields": {"rank": ".rank", "title": ".titleline > a"}, "limit": 10}`
92
+ var extracted struct {
93
+ Rows []struct {
94
+ Rank string `json:"rank"`
95
+ Title string `json:"title"`
96
+ } `json:"rows"`
97
+ }
98
+ if err := browser.run(&extracted, "extract", schema, "--inline"); err != nil {
99
+ log.Fatal(err)
100
+ }
101
+ for _, story := range extracted.Rows {
102
+ fmt.Println(story.Rank, story.Title)
103
+ }
104
+ }
@@ -0,0 +1,18 @@
1
+ #!/bin/sh
2
+ # Top stories on Hacker News as JSON rows, one patchrome command per step.
3
+ # Needs patchrome on PATH and jq for reading --json output.
4
+ set -eu
5
+ export PATCHROME_SESSION="${PATCHROME_SESSION:-hn-sh-$$}"
6
+ trap 'patchrome session close >/dev/null' EXIT
7
+
8
+ patchrome open https://news.ycombinator.com/ >/dev/null
9
+ patchrome wait --selector 'tr.athing' >/dev/null
10
+ patchrome --json extract '{
11
+ "rows": "tr.athing",
12
+ "fields": {
13
+ "rank": ".rank",
14
+ "title": ".titleline > a",
15
+ "url": {"selector": ".titleline > a", "attr": "href"}
16
+ },
17
+ "limit": 10
18
+ }' --inline | jq -c '.data.rows[]'
@@ -0,0 +1,24 @@
1
+ // Top stories on Hacker News with the Node library. Run with `node hn-front-page.ts` after
2
+ // `npm i patchrome` in your project, or from a checkout after `npm run build`.
3
+ import { CommandError, connect } from "patchrome";
4
+
5
+ const browser = connect({ session: `hn-ts-${process.pid}` });
6
+ try {
7
+ await browser.run("open", "https://news.ycombinator.com/");
8
+ const { rows } = (await browser.run(
9
+ "extract",
10
+ JSON.stringify({
11
+ rows: "tr.athing",
12
+ fields: { rank: ".rank", title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } },
13
+ limit: 10,
14
+ }),
15
+ "--inline",
16
+ )) as { rows: Array<{ rank: string; title: string; url: string }> };
17
+ for (const story of rows) console.log(story.rank, story.title, story.url);
18
+ } catch (err) {
19
+ if (err instanceof CommandError) console.error(`patchrome ${err.code}: ${err.message}`);
20
+ throw err;
21
+ } finally {
22
+ await browser.run("session", "close");
23
+ browser.close();
24
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env python3
2
+ """Two pages of Hacker News stories, over one `patchrome pipe`."""
3
+
4
+ import itertools
5
+ import json
6
+ import os
7
+ import subprocess
8
+
9
+
10
+ class PatchromeError(Exception):
11
+ def __init__(self, error):
12
+ super().__init__(f"{error['code']}: {error['message']}")
13
+ self.code = error["code"]
14
+ self.hint = error.get("hint")
15
+
16
+
17
+ class Patchrome:
18
+ """Sends CLI words to `patchrome pipe` and returns the `data` of each response."""
19
+
20
+ def __init__(self, session):
21
+ env = {**os.environ, "PATCHROME_SESSION": session}
22
+ self._process = subprocess.Popen(["patchrome", "pipe"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, env=env)
23
+ self._ids = itertools.count(1)
24
+
25
+ def run(self, *words):
26
+ request_id = next(self._ids)
27
+ self._process.stdin.write(json.dumps({"id": request_id, "argv": list(words)}) + "\n")
28
+ self._process.stdin.flush()
29
+ for line in self._process.stdout:
30
+ message = json.loads(line)
31
+ # Stream events from `watch` carry the same id and come before the response.
32
+ if message["id"] != request_id or "stream" in message:
33
+ continue
34
+ if not message["ok"]:
35
+ raise PatchromeError(message["error"])
36
+ return message["data"]
37
+ raise PatchromeError({"code": "daemon_unreachable", "message": "patchrome pipe exited"})
38
+
39
+ def close(self):
40
+ self.run("session", "close")
41
+ self._process.stdin.close()
42
+ self._process.wait()
43
+
44
+
45
+ browser = Patchrome(session=f"hn-py-{os.getpid()}")
46
+ try:
47
+ browser.run("open", "https://news.ycombinator.com/")
48
+ schema = json.dumps({"rows": "tr.athing", "fields": {"rank": ".rank", "title": ".titleline > a"}})
49
+ for page in (1, 2):
50
+ if page > 1:
51
+ browser.run("click", "--role", "link", "--name", "More", "--exact")
52
+ browser.run("wait", "--url", f"*?p={page}")
53
+ for story in browser.run("extract", schema, "--inline")["rows"]:
54
+ print(story["rank"], story["title"])
55
+ finally:
56
+ browser.close()
@@ -0,0 +1,8 @@
1
+ {
2
+ "manifest_version": 3,
3
+ "name": "patchrome tab groups",
4
+ "description": "Groups each patchrome session's tabs under the session name.",
5
+ "version": "0.1.0",
6
+ "permissions": ["tabs", "tabGroups", "debugger"],
7
+ "background": { "service_worker": "service-worker.js" }
8
+ }
@@ -0,0 +1,41 @@
1
+ // The daemon calls these through its service worker handle. The extension injects nothing into pages.
2
+ // chrome.debugger.getTargets only lists targets, it attaches to none, so Chrome shows no debugging bar.
3
+
4
+ async function tabsOfTargets(targetIds) {
5
+ const tabIdByTarget = new Map((await chrome.debugger.getTargets()).map((target) => [target.id, target.tabId]));
6
+ const tabIds = targetIds.map((targetId) => tabIdByTarget.get(targetId)).filter((tabId) => tabId !== undefined);
7
+ return Promise.all(tabIds.map((tabId) => chrome.tabs.get(tabId)));
8
+ }
9
+
10
+ // A group cannot span windows, and isolated sessions open in their own window, so each window gets a group.
11
+ // A tab already in a group keeps that group, so a new tab joins its session's group instead of starting one.
12
+ globalThis.patchromeGroupTabs = async ({ targetIds, title, color }) => {
13
+ const tabsByWindow = new Map();
14
+ for (const tab of await tabsOfTargets(targetIds))
15
+ tabsByWindow.set(tab.windowId, [...(tabsByWindow.get(tab.windowId) ?? []), tab]);
16
+ for (const tabs of tabsByWindow.values()) {
17
+ const existingGroupId = tabs.find((tab) => tab.groupId !== chrome.tabGroups.TAB_GROUP_ID_NONE)?.groupId;
18
+ const groupId = await chrome.tabs.group({
19
+ tabIds: tabs.map((tab) => tab.id),
20
+ ...(existingGroupId === undefined ? {} : { groupId: existingGroupId }),
21
+ });
22
+ await chrome.tabGroups.update(groupId, { title, color });
23
+ }
24
+ };
25
+
26
+ globalThis.patchromeDescribeTabGroups = async ({ targetIds }) => {
27
+ const tabCountByGroup = new Map();
28
+ for (const tab of await tabsOfTargets(targetIds)) {
29
+ if (tab.groupId === chrome.tabGroups.TAB_GROUP_ID_NONE) continue;
30
+ tabCountByGroup.set(tab.groupId, (tabCountByGroup.get(tab.groupId) ?? 0) + 1);
31
+ }
32
+ return Promise.all(
33
+ [...tabCountByGroup].map(async ([groupId, tabCount]) => {
34
+ const group = await chrome.tabGroups.get(groupId);
35
+ return { title: group.title ?? "", color: group.color, tabCount };
36
+ }),
37
+ );
38
+ };
39
+
40
+ // Tab events wake the worker if Chrome ever suspends it.
41
+ chrome.tabs.onCreated.addListener(() => {});
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "patchrome",
3
+ "version": "0.1.0",
4
+ "description": "Stealth shared-browser CLI for coding agents: one Patchright Chrome, a tab set per agent session",
5
+ "homepage": "https://github.com/akaike-byob/patchrome#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/akaike-byob/patchrome/issues"
8
+ },
9
+ "license": "MIT",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/akaike-byob/patchrome.git"
13
+ },
14
+ "bin": {
15
+ "patchrome": "bin/patchrome.js"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "dist",
20
+ "extension",
21
+ "skills",
22
+ "examples",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ },
32
+ "./package.json": "./package.json"
33
+ },
34
+ "scripts": {
35
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
36
+ "prepack": "npm run build",
37
+ "typecheck": "tsc",
38
+ "test": "vitest run",
39
+ "test:unit": "vitest run test/unit",
40
+ "test:integration": "vitest run test/integration",
41
+ "lint": "oxlint --type-aware --deny-warnings",
42
+ "format": "oxfmt",
43
+ "format:check": "oxfmt --check"
44
+ },
45
+ "dependencies": {
46
+ "patchright": "^1.63.0",
47
+ "zod": "^4.6.4"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^26.5.1",
51
+ "oxfmt": "^0.67.0",
52
+ "oxlint": "^1.82.0",
53
+ "oxlint-tsgolint": "^7.0.2001",
54
+ "typescript": "^7.0.2",
55
+ "vitest": "^5.0.0"
56
+ },
57
+ "engines": {
58
+ "node": ">=24.2"
59
+ }
60
+ }
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: patchrome
3
+ description: Drives a real, stealth, headed Google Chrome from the shell through the `patchrome` CLI - opens tabs, reads pages as accessibility snapshots with @refs, clicks, fills, extracts text and rows, reads network responses, signs in once and reuses the login, and exports a working flow as a script. Many agent sessions share one Chrome and one logged-in profile, each on its own tabs. Use whenever a task needs a real browser - a page behind a bot wall or login, JS-rendered content that curl cannot see, scraping, clicking through a web app, a repeatable scrape script, or checking what a site shows.
4
+ allowed-tools: Bash(patchrome:*)
5
+ ---
6
+
7
+ # Browser from the CLI (`patchrome`)
8
+
9
+ One daemon owns one headed Chrome per profile. Every `patchrome` call is a short client; the first
10
+ starts Chrome. Other agent sessions browse the same Chrome at once, and you see only your own tabs.
11
+ If `patchrome` is not on PATH: `npm i -g patchrome`.
12
+
13
+ ## The loop
14
+
15
+ ```bash
16
+ patchrome session label "compare laptop prices" # first: the user sees it on your Chrome tab group
17
+ patchrome open https://example.com # new background tab, becomes your current tab
18
+ patchrome snapshot # accessibility tree to a file; prints its path
19
+ # grep the file for the node: - button "Sign in" [ref=f1e6]
20
+ patchrome fill @f1e5 "alice@example.com"
21
+ patchrome click @f1e6
22
+ patchrome wait --url '*/dashboard*' # click returns before the next page loads
23
+ patchrome snapshot # refs from before a navigation are stale
24
+ ```
25
+
26
+ Copy refs exactly from the latest snapshot, from a line that has `[ref=...]`. A locator works anywhere
27
+ a ref does and survives page changes: `--role button --name "Sign in"` (role and name as on the
28
+ snapshot line), `--text <text>`, `--label <form label>` or `--selector <css>`, with `--exact`,
29
+ `--nth <n>` and `--frame <iframe-css>`.
30
+
31
+ ## Read the reference for the task
32
+
33
+ | Task | Read |
34
+ |---|---|
35
+ | Any flag or command not shown here | [references/commands.md](references/commands.md) |
36
+ | Scraping: JSON API responses, `extract` rows, globs, HAR, blocking or mocking requests | [references/scraping.md](references/scraping.md) |
37
+ | A repeatable script, `session history`, `pipe`, the Node library, sh/Python/Go callers | [references/scripting.md](references/scripting.md) |
38
+ | A site needs sign-in: `login`, `state import`, `state save`/`load`, a separate account | [references/logins.md](references/logins.md) |
39
+ | iframes, closed shadow roots, canvas, CAPTCHAs, bot-wall interstitials | [references/hard-pages.md](references/hard-pages.md) |
40
+ | Debugging your own localhost app: console, page errors, traces, CDP | [references/debugging.md](references/debugging.md) |
41
+
42
+ ## Rules
43
+
44
+ - Never close, switch to or act on tabs you did not open. `tabs --all` is for looking only.
45
+ - Read snapshot and text files with your file tools and grep them; never `cat` a large one whole.
46
+ - Wait for a condition (`wait --url`, `wait --text`, `wait --selector`), never `sleep`. Quote globs:
47
+ zsh expands `*` and `?` itself.
48
+ - A bot wall's interstitial ("Just a moment") often clears by itself. Only a
49
+ `wait --title "<interstitial title>" --gone` that times out means blocked.
50
+ - Never solve a CAPTCHA yourself. When `challenge` reports `pending`, tell the user and run
51
+ `challenge --handoff`.
52
+ - Logins are shared by every session in the profile: never log out of a site another agent may use.
53
+ - Never run `daemon stop` or `session close <other session>` unless the user asks; both end other
54
+ agents' work.
55
+ - Keep request volume low on protected sites: no tight loops of `goto`.
56
+ - Session name comes from `--session`, `$PATCHROME_SESSION`, `$CLAUDE_CODE_SESSION_ID`, the tty, then
57
+ the parent process. If `patchrome session` prints a new name on each call, export
58
+ `PATCHROME_SESSION` for the whole task.
59
+
60
+ ## Errors
61
+
62
+ Exit code 0 ok, 1 command error, 2 bad usage. `--json` errors carry a `code`:
63
+
64
+ | Code | Do |
65
+ |---|---|
66
+ | `tab_gone` | current tab closed or none yet: `patchrome open <url>`; never grab another session's tab |
67
+ | `ref_stale` | page changed since the snapshot: `patchrome snapshot`, pick the ref again, or use a locator |
68
+ | `timeout` | snapshot to see the page state; raise `--timeout-ms` if the site is slow |
69
+ | `navigation_failed` | DNS, TLS or connection failure: check the URL, retry once |
70
+ | `daemon_unreachable` | `patchrome daemon logs`; the next command restarts the daemon |
71
+ | `daemon_outdated` | tell the user; `daemon stop` closes Chrome for every session |
72
+ | `bad_args` | wrong usage, a JS error in `eval`, a bad schema or selector: read the message and hint |
73
+ | `unsupported_in_stealth` | the command needs `--profile debug`, which is only for your own apps |
74
+ | `copy_denied` | the user refused a login copy: tell them, do not retry, never copy cookies another way |
@@ -0,0 +1,130 @@
1
+ # Command reference
2
+
3
+ ## Contents
4
+ - Global flags and output
5
+ - Elements: refs and locators
6
+ - Tabs and navigation
7
+ - Reading the page
8
+ - Acting
9
+ - Waiting and watching
10
+ - Network
11
+ - Logins and state
12
+ - Scripting
13
+ - Debug profile
14
+ - Sessions, profiles and the daemon
15
+
16
+ ## Global flags and output
17
+
18
+ Global flags go before the command: `--json` (one object, `{ok, data}` or `{ok: false, error}`),
19
+ `--timeout-ms <n>` (default 30000; `login`, `watch`, `challenge --handoff` and `console --follow`
20
+ default to 10 min), `--session <name>`, `--profile <name>` (default `stealth`).
21
+
22
+ Plain output is a few lines. A value over 2 KB goes to a file in the session folder and stdout carries
23
+ the path. `--inline` always prints the value; `--out <file>` always writes that file.
24
+
25
+ ## Elements: refs and locators
26
+
27
+ `<element>` is one of:
28
+
29
+ - `@e12` or `@f1e12`, a ref from the latest snapshot (`--ref <ref>` on commands that read one)
30
+ - `--role <role> [--name <name>]`, the role and accessible name on a snapshot line
31
+ - `--text <text>`, visible text, case-insensitive substring
32
+ - `--label <text>`, a form field's label
33
+ - `--selector <css>`, which also pierces closed shadow roots
34
+
35
+ Modifiers: `--exact` matches the whole name or text, `--nth <n>` picks match n from 0 instead of the
36
+ first, `--frame <iframe-css>` looks inside that iframe.
37
+
38
+ ## Tabs and navigation
39
+
40
+ | Command | Does |
41
+ |---|---|
42
+ | `open [url] [--wait load\|domcontentloaded\|networkidle] [--isolated]` | new background tab, becomes current; `--isolated` on your first tab gives your session its own cookies |
43
+ | `goto <url> [--wait ...]` | navigate the current tab |
44
+ | `tabs [--all]` | your tabs, `*` marks current; `--all` lists every session's, read-only |
45
+ | `switch <tab>`, `close [tab]` | tab ids look like `t3`; only your own tabs |
46
+
47
+ ## Reading the page
48
+
49
+ | Command | Does |
50
+ |---|---|
51
+ | `snapshot [--inline \| --out <file>]` | accessibility tree with refs, iframes included; `--inline` only for small pages |
52
+ | `text [<element>] [--inline \| --out <file>]` | visible text of the page or one element |
53
+ | `screenshot [--full] [<element>] [--out <file>]` | PNG to a file |
54
+ | `eval <js> [--main-world] [--inline \| --out <file>]` | JS expression, prints JSON; `--json` puts it under `value` |
55
+ | `extract <schema> [<element>] [--inline \| --out <file>]` | CSS selectors to JSON rows: `{"rows": "li.item", "fields": {"name": "h2", "url": {"selector": "a", "attr": "href"}}}` |
56
+
57
+ `eval` runs in an isolated world: the DOM works, page globals (`window.myApp`) read as undefined.
58
+ `--main-world` sees them but runs inside the page's own realm, which a protected site can notice.
59
+
60
+ ## Acting
61
+
62
+ | Command | Does |
63
+ |---|---|
64
+ | `click <ref> \| <element>` | click |
65
+ | `click --at <x>,<y>` | trusted click at viewport pixels read off a screenshot |
66
+ | `fill <ref> \| <element> <text>` | replace a field's value |
67
+ | `type <text>` | trusted keystrokes into whatever has focus; click the field first |
68
+ | `press <key>` | Playwright key names: `Enter`, `Tab`, `Control+a` |
69
+ | `challenge [--handoff]` | CAPTCHA state (`none`, `pending`, `solved`); `--handoff` raises the tab for the user |
70
+
71
+ ## Waiting and watching
72
+
73
+ | Command | Does |
74
+ |---|---|
75
+ | `wait <element> \| --url <glob> \| --title <text> [--gone]` | block until the page shows it, or with `--gone` stops showing it; a condition already true returns at once |
76
+ | `wait --load load\|domcontentloaded\|networkidle` | block until the tab reaches a load state |
77
+ | `watch [--events navigation,load,response,console,error] [--url <glob>] [--count <n>]` | stream your tabs' events, one line each; `console` and `error` need a debug profile |
78
+
79
+ ## Network
80
+
81
+ | Command | Does |
82
+ |---|---|
83
+ | `network list [--url <glob>] [--type xhr,fetch] [--status 4xx] [--inline \| --out <file>]` | your requests, newest last, ids like `n17`; last 1000 kept |
84
+ | `network get <id> \| --url <glob> [--body] [--inline \| --out <file>]` | headers, post data, and with `--body` the response body; `--url` takes the newest match |
85
+ | `network har start\|stop [--out <file>]` | record your requests; `stop` writes a HAR with text bodies |
86
+ | `route block <glob>`, `route mock <glob> <file>` | abort, or answer from a file, matching requests on your tabs |
87
+ | `route list\|clear` | your rules; the newest matching rule wins |
88
+
89
+ ## Logins and state
90
+
91
+ | Command | Does |
92
+ |---|---|
93
+ | `login <url> [--until <url-glob>]` | a visible tab for a person to sign in |
94
+ | `cookies [--domain <domain>] [--inline \| --out <file>]` | cookies with values |
95
+ | `state save <file>`, `state load <file>` | cookies and localStorage, Playwright storageState format |
96
+ | `state import <site> [--from <chrome-profile>]` | one site's login from the user's everyday Chrome |
97
+ | `audit [--count <n>]` | recent login copies and who approved them |
98
+
99
+ ## Scripting
100
+
101
+ | Command | Does |
102
+ |---|---|
103
+ | `session history [--format sh\|jsonl] [--out <file>]` | your session's working commands, refs rewritten as locators |
104
+ | `session history clear` | empty the recording |
105
+ | `pipe [--bail]` | JSON requests on stdin, one JSON response per line |
106
+
107
+ ## Debug profile
108
+
109
+ | Command | Does |
110
+ |---|---|
111
+ | `console [--level debug\|info\|warning\|error] [--follow]` | your tabs' console, that level and above |
112
+ | `errors` | uncaught page errors with stacks |
113
+ | `trace start\|stop [--out <file>]` | Playwright trace zip of every tab in the profile; one session at a time |
114
+ | `cdp <Domain.method> [params-json]` | raw CDP on your current tab |
115
+ | `cdp help [Domain\|Domain.method]` | the CDP domains, commands and params this Chrome implements |
116
+ | `devtools-url` | debugging URL for chrome-devtools, Lighthouse or heap snapshots |
117
+
118
+ ## Sessions, profiles and the daemon
119
+
120
+ | Command | Does |
121
+ |---|---|
122
+ | `session`, `session close` | your session name, label and tabs; close all your tabs |
123
+ | `session label <text>` | what you are doing, shown on your Chrome tab group |
124
+ | `sessions [pattern]`, `session close <session\|pattern>` | list sessions; close others by name or glob (`'work-*'`), only when the user asks |
125
+ | `profile create <name> --mode stealth\|debug` | a profile's mode is fixed once made |
126
+ | `daemon status\|stop\|logs` | `stop` closes Chrome for every session |
127
+ | `completions zsh` | zsh completion script, for people |
128
+
129
+ If the daemon restarts, your tabs come back on your next command under the same ids, reloaded at
130
+ their last URL. Typed input and refs are gone: take a fresh snapshot.
@@ -0,0 +1,20 @@
1
+ # Debugging your own app
2
+
3
+ For localhost apps you are building, use the debug profile: a separate Chrome with console capture,
4
+ page errors, tracing, raw CDP and a `127.0.0.1` debugging port. Never use it for third-party sites:
5
+ all of that is detectable. Stealth profiles refuse these commands with `unsupported_in_stealth`.
6
+
7
+ ```bash
8
+ patchrome --profile debug open http://localhost:3000
9
+ patchrome --profile debug console --level warning # c4 t1 error Failed to load user (http://localhost:3000/app.js:88)
10
+ patchrome --profile debug console --follow # streams until --timeout-ms (default 10 min)
11
+ patchrome --profile debug errors # uncaught exceptions with stacks
12
+ patchrome --profile debug trace start # covers every tab in the profile; one session at a time
13
+ patchrome --profile debug trace stop # prints the zip; npx playwright show-trace <file>
14
+ patchrome --profile debug cdp Performance.getMetrics
15
+ patchrome --profile debug cdp help Network # what this Chrome implements
16
+ patchrome --profile debug devtools-url # for chrome-devtools-mcp --browserUrl <url>, Lighthouse, heap snapshots
17
+ ```
18
+
19
+ `watch --events console,error` streams the same events alongside navigation and responses.
20
+ `profile create <name> --mode debug` makes another debug profile; `debug` is one by default.