diff --git a/SKILL.md b/SKILL.md index 8e05e3bc..cb9c877f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -10,11 +10,11 @@ Direct browser control via CDP. Read helpers.py — that's where the functions l ## Usage ```bash -browser-harness <<'PY' -new_tab("https://docs.browser-use.com") +browser-harness -c " +new_tab('https://docs.browser-use.com') wait_for_load() print(page_info()) -PY +" ``` - Invoke as browser-harness — it's on $PATH. No cd, no uv run. @@ -30,30 +30,32 @@ Available domain skills: ## Tool call shape ```bash -browser-harness <<'PY' +browser-harness -c " # any python. helpers pre-imported. daemon auto-starts. -PY +" ``` run.py calls ensure_daemon() before exec — you never start/stop manually unless you want to. +**Quoting:** The outer shell quotes are double-quotes, so use single quotes for Python string literals inside `-c "..."`. If you need a literal single quote inside a string, escape it as `\'`. + ### Remote browsers Use remote for parallel sub-agents (each gets its own isolated browser via a distinct BU_NAME) or on a headless server. BROWSER_USE_API_KEY must be set. start_remote_daemon, list_cloud_profiles, list_local_profiles, sync_local_profile are pre-imported. ```bash -browser-harness <<'PY' -start_remote_daemon("work") # default — clean browser, no profile -# start_remote_daemon("work", profileName="my-work") # reuse a cloud profile (already logged in) -# start_remote_daemon("work", profileId="") # same, but by UUID -# start_remote_daemon("work", proxyCountryCode="de", timeout=120) # DE proxy, 2-hour timeout -# start_remote_daemon("work", proxyCountryCode=None) # disable the Browser Use proxy -PY - -BU_NAME=work browser-harness <<'PY' -new_tab("https://example.com") +browser-harness -c " +start_remote_daemon('work') # default — clean browser, no profile +# start_remote_daemon('work', profileName='my-work') # reuse a cloud profile (already logged in) +# start_remote_daemon('work', profileId='') # same, but by UUID +# start_remote_daemon('work', proxyCountryCode='de', timeout=120) # DE proxy, 2-hour timeout +# start_remote_daemon('work', proxyCountryCode=None) # disable the Browser Use proxy +" + +BU_NAME=work browser-harness -c " +new_tab('https://example.com') print(page_info()) -PY +" ``` start_remote_daemon prints liveUrl and auto-opens it in the local browser (if a GUI is detected) so the user can watch along. Headless servers print only — share the URL with the user. The daemon PATCHes the cloud browser to stop on shutdown, which persists profile state. Running remote daemons bill until timeout. diff --git a/domain-skills/notion/db-layout.md b/domain-skills/notion/db-layout.md new file mode 100644 index 00000000..cb6b6502 --- /dev/null +++ b/domain-skills/notion/db-layout.md @@ -0,0 +1,86 @@ +# Notion — read a database's full layout schematic (v3 API) + +Given any Notion database URL, extract the complete page-layout schematic — pinned properties (in order), standalone/dedicated property modules, module order, named property groups + membership + order, property visibility, sub-items wiring, and automation ids — none of which the public REST API exposes. + +**Mechanism:** call Notion's internal v3 API from inside the user's logged-in tab via `js()` fetch. The session cookie rides along in-browser; no token is ever extracted, stored, or logged. Read-only endpoint (`syncRecordValues`). + +## Where layout state lives (internal data model) + +- **`block`** (the DB page, type `collection_view_page`) → `format.collection_pointer` → the collection id + spaceId. Use this to resolve a URL to a collection. +- **`collection`** (= public API "data source") → `schema` (property id → {name, type}) and `format`: + - `property_groups`: `[{id, title, propertyIds[]}]` — named groups, ordered, with ordered membership. + - `layout_pointer`: `{table: "layout", id, spaceId}` — present **only when the page layout was customized**; absent = default layout. + - `property_visibility`: `[{property, visibility: show|hide|hide_if_empty}]`. + - `collection_page_properties`: ordered `[{property, visible}]` (legacy page-property list; keeps **ghost entries for deleted properties**). + - `collection_page_sections`, `page_section_visibility` (comments/backlinks config), `subitem_property` (sub-items relation id), `automation_ids[]`. +- **`layout`** (own record type, parent = collection) → `modules.page_layout_schema`: ordered module list: + - `{type: "titleWithIcon", propertyIds[]}` — **the pinned chips, in display order**. + - `{type: "property", propertyId, config}` — a **standalone/dedicated property module** (e.g. `property_file` with style). + - `{type: "properties"}` — the property section; `relationsGroup`, `cover`, `discussions`, `editor`. +- **`automation`** (own record type, ids from `automation_ids`) → `trigger`, `action_ids`, `status`, `properties` — DB automations ARE readable here (the public API has none of this). + +## Recipe + +1. `new_tab('https://app.notion.com')` — must be app.notion.com; `notion.so` bounces logged-out visitors to the marketing site. If you land on a workspace page, the session is live. Auth wall → stop and ask the user. +2. Resolve the URL's page id (32-hex in the path, dashed form) to the collection: + +```python +browser-harness -c " +import json +r = js('''(async () => { + const bid = ''; + const sync=async(reqs)=>(await fetch('/api/v3/syncRecordValues',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({requests:reqs})})).json(); + let j=await sync([{pointer:{table:'block',id:bid},version:-1}]); // spaceId optional for block lookup + const blk=j.recordMap.block[bid].value.value; + return JSON.stringify(blk.format.collection_pointer || (blk.format.collection_pointers||[])[0]); +})()''') +print(r) +" +``` + +3. Fetch collection (+ layout when `layout_pointer` exists) and render the schematic: + +```python +browser-harness -c " +import json +r = js('''(async () => { + const SPACE='', COLL=''; + const sync=async(reqs)=>(await fetch('/api/v3/syncRecordValues',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({requests:reqs})})).json(); + let j=await sync([{pointer:{table:'collection',id:COLL,spaceId:SPACE},version:-1}]); + const coll=j.recordMap.collection[COLL].value.value; + const nameOf=id=>coll.schema[id]?.name||('<'+id+'>'); + const f=coll.format||{}; + let out=['DB: '+coll.name[0][0]]; + if(f.layout_pointer){ + j=await sync([{pointer:f.layout_pointer,version:-1}]); + const lay=j.recordMap.layout[f.layout_pointer.id].value.value; + out.push('PAGE LAYOUT (module order):'); + for(const m of lay.modules.page_layout_schema){ + if(m.type==='titleWithIcon') out.push(' [title] pinned: '+(m.propertyIds||[]).map(nameOf).join(' | ')); + else if(m.type==='property') out.push(' [module] standalone: '+nameOf(m.propertyId)+(m.config?' '+JSON.stringify(m.config):'')); + else out.push(' ['+m.type+']'); + } + } else out.push('PAGE LAYOUT: default (no layout record)'); + out.push('PROPERTY GROUPS:'); + for(const g of f.property_groups||[]) out.push(' '+g.title+' ('+g.propertyIds.length+'): '+g.propertyIds.map(nameOf).join(', ')); + if(f.subitem_property) out.push('SUB-ITEMS via: '+nameOf(f.subitem_property)); + const hidden=(f.property_visibility||[]).filter(p=>p.visibility!=='show'); + out.push('HIDDEN PROPS: '+(hidden.length?hidden.map(p=>nameOf(p.property)).join(', '):'none')); + out.push('AUTOMATIONS: '+((f.automation_ids||[]).length)); + return JSON.stringify(out); +})()''') +print(chr(10).join(json.loads(r))) +" +``` + +4. Automations detail (optional): `sync([{pointer:{table:'automation',id:,spaceId:SPACE},version:-1}])` → `trigger`, `action_ids`, `status`. + +## Traps + +- **Double envelope:** records are at `recordMap.[id].value.value` — the first `.value` wraps `{value, role}`. +- **`layout_pointer` absent ≠ error** — it means the DB uses the default layout (nothing was customized). +- **Ghost properties:** `collection_page_properties` and even layout `propertyIds` can reference deleted properties — `nameOf` misses resolve to ``; report, don't crash. +- **Escaping:** never put `\n` inside the `js('''…''')` string (bash+python eat backslashes → JS syntax error → `js()` returns `None`). Return `JSON.stringify(array)` and join in Python. +- **Ids:** URLs use bare 32-hex; v3 pointers need the dashed UUID form. +- **This is Notion's private, unversioned API** — shapes can change without notice. Field names above verified 2026-07-22. +- Do not write via v3 (`saveTransactions`) from this recipe — read-only. diff --git a/install.md b/install.md index 7e3b4370..0ab418d8 100644 --- a/install.md +++ b/install.md @@ -136,6 +136,8 @@ Chrome / Browser Use cloud -> CDP WS -> daemon.py -> /tmp/bu-.sock -> run. - Try attaching before asking the user to change anything. Decide what to escalate based on the harness's error message, not on whether Chrome is visibly running. - The remote-debugging checkbox is per-profile sticky in Chrome. If it has ever been ticked on a profile, just launching Chrome is enough — only navigate to `chrome://inspect/#remote-debugging` when `DevToolsActivePort` is genuinely missing. - The first connect may block on Chrome's `Allow` dialog, and Chrome may also stop first on the profile picker. +- The `Allow remote debugging?` dialog is a **once-per-Chrome-launch** consent gate, not a per-call or per-session prompt, and not a misconfiguration. Symptom: `/json/version` (HTTP) answers, but the CDP **WebSocket** handshake returns **HTTP 403** until the user clicks `Allow`. After one click the daemon stays attached and every later `browser-harness` call in that same Chrome launch connects silently. Seeing it "every time" almost always means Chrome was restarted in between (a fresh launch = a fresh consent). +- **Do NOT try to skip the dialog with `--remote-debugging-port=9222`.** Since Chrome 136 the flag is **silently ignored when Chrome runs on the default user-data-dir** — the port never binds, so it does nothing for the user's real profile. The flag only works with an explicit non-default `--user-data-dir`, i.e. a separate automation profile with no existing logins/extensions. For the user's normal profile, the `chrome://inspect` checkbox + the once-per-launch `Allow` click is the only path. (Verified 2026-06: flag-launched Chrome on the default profile leaves 9222 unbound; same flag with `--user-data-dir=/tmp/...` binds fine.) - `DevToolsActivePort` can exist before the port is actually listening. Treat connection refused as "still enabling" and keep polling briefly. - If the port is listening but `/json/version` returns `404`, treat that as expected on newer Chrome builds and retry `browser-harness`. - Chrome may open the profile picker before any real tab exists.