unbrowse 12.3.0 → 12.4.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/README.md CHANGED
@@ -13,6 +13,7 @@ unbrowse resume <runId> origin=SIN # answer on the same run
13
13
  | Command | Does |
14
14
  |---|---|
15
15
  | `login [--key K]`, `logout`, `whoami`, `usage` | Account |
16
+ | `accounts`, `accounts use <name>`, `--account NAME` | Several Unbrowse accounts on one machine, each with its own vault ([docs](https://github.com/unbrowse-ai/unbrowse/blob/main/docs/cli.md#several-accounts)) |
16
17
  | `discover <query>` | Your capabilities, then the public registry |
17
18
  | `run <task…>`, `inspect`, `resume`, `cancel` | Runs |
18
19
  | `scrape <url>` | One page as markdown on stdout |
@@ -21,8 +22,9 @@ unbrowse resume <runId> origin=SIN # answer on the same run
21
22
  | `logins`, `logins remove <origin>` | Saved logins, masked. The CLI never takes a password |
22
23
  | `registry [query]`, `site <host>`, `openapi <host>` | Public compiled sites, a site's tools, its OpenAPI 3.1 document — no account |
23
24
  | `call <host> <tool> [JSON]` | Run one site tool (`--set k=v`, `--deadline MS`, `--select a,b`, `--idempotency-key K`, `--end-user ID`) |
25
+ | `tools [query]`, `tool <name> [--key value…]` | Every tool of the hosted MCP from the shell: the cloud browser (`tool browse_open --url …`), `map`, replay, memory, your sites' tools |
24
26
 
25
- The CLI is the REST client. The remote MCP is a separate client; see the [install guide](https://github.com/unbrowse-ai/unbrowse/blob/main/docs/install.md).
27
+ The CLI is the REST client, and through `tool` it can call everything the remote MCP has. To give an agent host the MCP itself, see the [install guide](https://github.com/unbrowse-ai/unbrowse/blob/main/docs/install.md).
26
28
 
27
29
  Exit codes: 0 accepted/ok (check status and verified), 1 error, 2 input required, 3 sign-in or saved login needed, 4 not verified.
28
30
  Env: `UNBROWSE_API_KEY`, `UNBROWSE_BASE_URL`. Node 18.17+, no dependencies.
package/SKILL.md CHANGED
@@ -40,7 +40,7 @@ From an agent's shell (no terminal for the person): run `npx unbrowse login --no
40
40
 
41
41
  Then the first call: `npx unbrowse run 'top stories on hacker news'`. The site requests go from the person's own IP by default; `--from-unbrowse` sends them from Unbrowse instead.
42
42
 
43
- `npx unbrowse mcp` (a stdio MCP for hosts that need one) uses the CLI sign-in. A remote MCP in a host config uses only that host's own OAuth: one sign-in does not cover the other, and the CLI token does not go into an MCP config.
43
+ `npx unbrowse mcp` (a stdio MCP for hosts that need one) uses the CLI sign-in; `unbrowse mcp add` registers it with Claude Code and Codex, so they run on that one sign-in. The other way: with no CLI sign-in, the CLI uses Claude Code's own Unbrowse MCP sign-in (the plugin's `https://unbrowse.ai/api/mcp`; a token for `/mcp` does not work on the REST API). No token is ever written into an MCP config.
44
44
 
45
45
  For automation and CI without a browser, set `UNBROWSE_API_KEY` from the caller's secret manager (create a key in the signed-in console at https://unbrowse.ai/app). Never put keys into committed config, prompts, command arguments or logs.
46
46
 
@@ -81,7 +81,7 @@ Do not infer business success from HTTP 200, tool transport success, a screensho
81
81
 
82
82
  When the person names a site:
83
83
 
84
- 1. If `unbrowse_discover` is not in this session's tool list, the MCP is not signed in. Stop. Name the configured MCP URL and say the CLI login does not cover it.
84
+ 1. If `unbrowse_discover` is not in this session's tool list, the MCP is not signed in. Stop. Name the configured MCP URL. A remote MCP needs the host's own OAuth; with the CLI signed in, `npx unbrowse mcp add` gives the host a server on that sign-in instead (it takes effect in a new session).
85
85
  2. Look the host up (`unbrowse_sites`, or CLI `unbrowse site <host>`). Tools already compiled: use one. Stop.
86
86
  3. No tools: `unbrowse_index {url, focus}` or `POST /api/v1/index`. Poll `unbrowse_index_status` or `GET /api/v1/index/{jobId}`.
87
87
  4. `status: failed` and `error.code: model_unavailable`: the indexing model is out of credit. Report the job id and the message. Do not browse instead, and do not start the same job again in a loop.
package/dist/cli.js CHANGED
@@ -26,7 +26,7 @@ var __esm = (fn, res, err) => () => {
26
26
  };
27
27
 
28
28
  // ../sdk/src/version.ts
29
- var SDK_VERSION = "12.3.0";
29
+ var SDK_VERSION = "12.4.0";
30
30
 
31
31
  // ../sdk/src/client.ts
32
32
  function apiBase(url) {
@@ -924,10 +924,12 @@ async function watch(opts) {
924
924
  }
925
925
  function servicePlan(opts) {
926
926
  const os = opts.os ?? platform();
927
+ const service = opts.account ? `${SERVICE}-${opts.account}` : SERVICE;
928
+ const label = opts.account ? `${LAUNCHD_LABEL}.${opts.account}` : LAUNCHD_LABEL;
927
929
  const home = opts.home ?? homedir3();
928
930
  const env = Object.entries(opts.env ?? {});
929
931
  if (os === "linux") {
930
- const path = join3(home, ".config", "systemd", "user", `${SERVICE}.service`);
932
+ const path = join3(home, ".config", "systemd", "user", `${service}.service`);
931
933
  const content = [
932
934
  "[Unit]",
933
935
  "Description=Unbrowse cookie sync: keeps your browser sign-ins for chosen sites in sync with Unbrowse",
@@ -948,21 +950,21 @@ function servicePlan(opts) {
948
950
  kind: "systemd",
949
951
  path,
950
952
  content,
951
- enable: [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", `${SERVICE}.service`]],
952
- disable: [["systemctl", "--user", "disable", "--now", `${SERVICE}.service`], ["systemctl", "--user", "daemon-reload"]],
953
- status: ["systemctl", "--user", "is-active", `${SERVICE}.service`],
954
- log: `journalctl --user -u ${SERVICE} -f`
953
+ enable: [["systemctl", "--user", "daemon-reload"], ["systemctl", "--user", "enable", "--now", `${service}.service`]],
954
+ disable: [["systemctl", "--user", "disable", "--now", `${service}.service`], ["systemctl", "--user", "daemon-reload"]],
955
+ status: ["systemctl", "--user", "is-active", `${service}.service`],
956
+ log: `journalctl --user -u ${service} -f`
955
957
  };
956
958
  }
957
959
  if (os === "darwin") {
958
- const path = join3(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
959
- const logPath = join3(home, "Library", "Logs", `${SERVICE}.log`);
960
+ const path = join3(home, "Library", "LaunchAgents", `${label}.plist`);
961
+ const logPath = join3(home, "Library", "Logs", `${service}.log`);
960
962
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
961
963
  const content = `<?xml version="1.0" encoding="UTF-8"?>
962
964
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
963
965
  <plist version="1.0">
964
966
  <dict>
965
- <key>Label</key><string>${LAUNCHD_LABEL}</string>
967
+ <key>Label</key><string>${label}</string>
966
968
  <key>ProgramArguments</key>
967
969
  <array><string>${esc(opts.node)}</string><string>${esc(opts.cli)}</string><string>cookies</string><string>watch</string></array>
968
970
  ${env.length ? ` <key>EnvironmentVariables</key>
@@ -982,7 +984,7 @@ ${env.length ? ` <key>EnvironmentVariables</key>
982
984
  content,
983
985
  enable: [["launchctl", "bootout", domain, path], ["launchctl", "bootstrap", domain, path]],
984
986
  disable: [["launchctl", "bootout", domain, path]],
985
- status: ["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`],
987
+ status: ["launchctl", "print", `${domain}/${label}`],
986
988
  log: `tail -f ${logPath}`
987
989
  };
988
990
  }
@@ -992,7 +994,7 @@ function quoteSystemd(s) {
992
994
  return /[\s"\\]/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"` : s;
993
995
  }
994
996
  function ephemeralCli(cli) {
995
- return /[\\/](_npx|\.npm[\\/]_npx|npx-[^\\/]+|tmp|Temp)[\\/]/i.test(cli);
997
+ return /[\\/](_npx|\.npm[\\/]_npx|npx-[^\\/]+|bunx-[^\\/]+|tmp|Temp)[\\/]/i.test(cli);
996
998
  }
997
999
  var ALL_SITES = "*", allSites = (cfg) => cfg.sites.includes(ALL_SITES), UPLOAD_BATCH = 500, TWO_LEVEL, DEFAULT_INTERVAL_MIN = 15, REFRESH_MS, SERVICE = "unbrowse-cookies", LAUNCHD_LABEL = "ai.unbrowse.cookies", configFile = (dir) => join3(dir, "cookie-sync.json"), stateFile = (dir) => join3(dir, "cookie-sync.state.json");
998
1000
  var init_cookie_daemon = __esm(() => {
@@ -1218,7 +1220,7 @@ var HEADER = 100;
1218
1220
  // src/cookies.ts
1219
1221
  import { createDecipheriv, createHash as createHash3, pbkdf2Sync } from "node:crypto";
1220
1222
  import { execFileSync } from "node:child_process";
1221
- import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
1223
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "node:fs";
1222
1224
  import { homedir as homedir4, platform as platform2 } from "node:os";
1223
1225
  import { join as join4 } from "node:path";
1224
1226
  function chromium(name, [mac, linux], keychain) {
@@ -1306,7 +1308,7 @@ function firefoxProfiles(def, base) {
1306
1308
  }
1307
1309
  function safeReaddir(dir) {
1308
1310
  try {
1309
- return readdirSync(dir);
1311
+ return readdirSync2(dir);
1310
1312
  } catch {
1311
1313
  return [];
1312
1314
  }
@@ -1510,6 +1512,85 @@ var init_cookies = __esm(() => {
1510
1512
  TEXT = /^[\x20-\x7e]*$/;
1511
1513
  });
1512
1514
 
1515
+ // src/mcp-tools.ts
1516
+ function resolveTool(tools, typed) {
1517
+ const names = new Map(tools.map((t) => [t.name, t]));
1518
+ const safe = safeToolName(typed);
1519
+ const underscored = safe.replace(/-/g, "_");
1520
+ for (const n of [typed, safe, underscored, `unbrowse_${safe}`, `unbrowse_${underscored}`])
1521
+ if (names.has(n))
1522
+ return names.get(n);
1523
+ return;
1524
+ }
1525
+ function shortName(name) {
1526
+ return name.replace(/^unbrowse_/, "");
1527
+ }
1528
+ function signature(t, max = 60) {
1529
+ const schema = t.inputSchema ?? {};
1530
+ const required = new Set(schema.required ?? []);
1531
+ const keys = Object.keys(schema.properties ?? {});
1532
+ const props = [...keys.filter((k) => required.has(k)), ...keys.filter((k) => !required.has(k)).map((k) => `${k}?`)];
1533
+ let shown = "";
1534
+ for (const [i, p] of props.entries()) {
1535
+ const next = shown ? `${shown}, ${p}` : p;
1536
+ if (`${shortName(t.name)}(${next})`.length > max && i > 0)
1537
+ return `${shortName(t.name)}(${shown}, …)`;
1538
+ shown = next;
1539
+ }
1540
+ return `${shortName(t.name)}(${shown})`;
1541
+ }
1542
+ function summary(description = "", max = 110) {
1543
+ const first = description.split(/(?<=[.!?])\s|\n/)[0].trim();
1544
+ return first.length > max ? `${first.slice(0, max - 1)}…` : first;
1545
+ }
1546
+ function filterTools(tools, query) {
1547
+ const split = (s) => s.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
1548
+ const words = split(query);
1549
+ const matches = (text) => {
1550
+ const tokens = split(text);
1551
+ return words.every((w) => tokens.some((k) => k.startsWith(w)));
1552
+ };
1553
+ const byName = tools.filter((t) => matches(shortName(t.name)));
1554
+ return byName.length ? byName : tools.filter((t) => matches(`${shortName(t.name)} ${t.description ?? ""}`));
1555
+ }
1556
+ function toolInput(t, json, flags, sets, coerce) {
1557
+ const props = new Set(Object.keys((t.inputSchema ?? {}).properties ?? {}));
1558
+ const key = (k) => props.has(k) ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1559
+ const base = json ? JSON.parse(json) : {};
1560
+ if (!base || typeof base !== "object" || Array.isArray(base))
1561
+ throw new SyntaxError("the tool's input must be a JSON object");
1562
+ const out = { ...base };
1563
+ for (const [k, v] of Object.entries(flags))
1564
+ if (!CLI_FLAGS.has(k))
1565
+ out[key(k)] = typeof v === "string" ? coerce(v) : v;
1566
+ for (const [k, v] of Object.entries(sets))
1567
+ out[key(k)] = v;
1568
+ return out;
1569
+ }
1570
+ function resultValue(r) {
1571
+ if (r.structuredContent !== undefined)
1572
+ return r.structuredContent;
1573
+ const parts = (r.content ?? []).map((c) => c.type === "text" ? c.text ?? "" : `[${c.type}${c.mimeType ? ` ${c.mimeType}` : ""}]`);
1574
+ const text = parts.join(`
1575
+ `);
1576
+ try {
1577
+ return JSON.parse(text);
1578
+ } catch {
1579
+ return text;
1580
+ }
1581
+ }
1582
+ function resultCode(r, value) {
1583
+ if (r.isError)
1584
+ return 1;
1585
+ const status = value?.status;
1586
+ return status === "input_required" ? 2 : 0;
1587
+ }
1588
+ var CLI_FLAGS;
1589
+ var init_mcp_tools = __esm(() => {
1590
+ init_mcp_proxy();
1591
+ CLI_FLAGS = new Set(["json", "base-url", "account", "no-open", "input", "from-here", "help", "version"]);
1592
+ });
1593
+
1513
1594
  // src/cli.ts
1514
1595
  import { spawn, spawnSync } from "node:child_process";
1515
1596
  import { randomUUID } from "node:crypto";
@@ -1519,13 +1600,70 @@ import { createInterface as createInterface2 } from "node:readline/promises";
1519
1600
 
1520
1601
  // src/auth.ts
1521
1602
  import { createHash, randomBytes } from "node:crypto";
1522
- import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1603
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1523
1604
  import { createServer } from "node:http";
1524
- import { homedir } from "node:os";
1605
+ import { homedir, hostname } from "node:os";
1525
1606
  import { dirname, join } from "node:path";
1607
+ function configRoot() {
1608
+ return process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
1609
+ }
1610
+ var selected;
1611
+ var ACCOUNT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
1612
+ var defaultFile = () => join(configRoot(), "account");
1613
+ function checkAccount(name) {
1614
+ if (name !== "default" && !ACCOUNT_NAME.test(name))
1615
+ throw new Error(`invalid account name "${name}": use letters, digits, - and _ (up to 40)`);
1616
+ return name;
1617
+ }
1618
+ function savedAccount() {
1619
+ try {
1620
+ const name = readFileSync(defaultFile(), "utf8").trim();
1621
+ return name && ACCOUNT_NAME.test(name) ? name : undefined;
1622
+ } catch {
1623
+ return;
1624
+ }
1625
+ }
1626
+ function selectAccount(name) {
1627
+ const pick = name ?? (process.env.UNBROWSE_ACCOUNT || undefined) ?? savedAccount();
1628
+ selected = !pick || checkAccount(pick) === "default" ? undefined : pick;
1629
+ return account();
1630
+ }
1631
+ function account() {
1632
+ return selected ?? "default";
1633
+ }
1634
+ function useAccount(name) {
1635
+ if (checkAccount(name) === "default")
1636
+ return void rmSync(defaultFile(), { force: true });
1637
+ mkdirSync(configRoot(), { recursive: true, mode: 448 });
1638
+ writeFileSync(defaultFile(), `${name}
1639
+ `, { mode: 384 });
1640
+ }
1641
+ function listAccounts() {
1642
+ const read = (path) => {
1643
+ try {
1644
+ return JSON.parse(readFileSync(path, "utf8"));
1645
+ } catch {
1646
+ return;
1647
+ }
1648
+ };
1649
+ const row = (name, s) => ({ name, ...s.baseUrl ? { baseUrl: s.baseUrl } : {}, signIn: s.apiKey ? "api key" : s.oauth ? "oauth" : "none" });
1650
+ const out = [];
1651
+ const main = read(join(configRoot(), "cli.json"));
1652
+ if (main)
1653
+ out.push(row("default", main));
1654
+ let names = [];
1655
+ try {
1656
+ names = readdirSync(join(configRoot(), "accounts")).filter((n) => ACCOUNT_NAME.test(n)).sort();
1657
+ } catch {}
1658
+ for (const n of names) {
1659
+ const s = read(join(configRoot(), "accounts", n, "cli.json"));
1660
+ if (s)
1661
+ out.push(row(n, s));
1662
+ }
1663
+ return out;
1664
+ }
1526
1665
  function configPath() {
1527
- const root = process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
1528
- return join(root, "cli.json");
1666
+ return selected ? join(configRoot(), "accounts", selected, "cli.json") : join(configRoot(), "cli.json");
1529
1667
  }
1530
1668
  function load() {
1531
1669
  const path = configPath();
@@ -1567,6 +1705,7 @@ function sharedSignIn(baseUrl, now = Date.now()) {
1567
1705
  return;
1568
1706
  }
1569
1707
  const api = new URL(apiResource(baseUrl));
1708
+ let best;
1570
1709
  for (const e of entries) {
1571
1710
  if (typeof e?.serverUrl !== "string" || typeof e.accessToken !== "string" || !e.accessToken)
1572
1711
  continue;
@@ -1580,9 +1719,11 @@ function sharedSignIn(baseUrl, now = Date.now()) {
1580
1719
  }
1581
1720
  if (url.origin !== api.origin || !(url.pathname === api.pathname || url.pathname.startsWith(`${api.pathname}/`)))
1582
1721
  continue;
1583
- return { token: e.accessToken, from: `Claude Code's MCP sign-in (${String(e.serverName ?? url.origin + url.pathname)})` };
1722
+ const expiresAt = typeof e.expiresAt === "number" ? e.expiresAt : 0;
1723
+ if (!best || expiresAt > best.expiresAt)
1724
+ best = { token: e.accessToken, from: `Claude Code's MCP sign-in (${String(e.serverName ?? url.origin + url.pathname)})`, expiresAt };
1584
1725
  }
1585
- return;
1726
+ return best && { token: best.token, from: best.from };
1586
1727
  }
1587
1728
  async function currentToken(baseUrl, fetchImpl = fetch) {
1588
1729
  if (process.env.UNBROWSE_API_KEY)
@@ -1594,7 +1735,7 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
1594
1735
  return stored.apiKey;
1595
1736
  const o = stored.oauth;
1596
1737
  if (!o)
1597
- return v1Key(baseUrl) ?? sharedSignIn(baseUrl)?.token;
1738
+ return selected ? undefined : v1Key(baseUrl) ?? sharedSignIn(baseUrl)?.token;
1598
1739
  if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
1599
1740
  try {
1600
1741
  const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
@@ -1606,6 +1747,16 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
1606
1747
  }
1607
1748
  return o.accessToken;
1608
1749
  }
1750
+ async function tokenFor(baseUrl, target, fetchImpl = fetch) {
1751
+ const token = await currentToken(baseUrl, fetchImpl);
1752
+ if (!token)
1753
+ return;
1754
+ let same = false;
1755
+ try {
1756
+ same = new URL(target, baseUrl).origin === new URL(baseUrl).origin;
1757
+ } catch {}
1758
+ return same || signInSource(baseUrl) !== "shared" ? token : undefined;
1759
+ }
1609
1760
  function signInSource(baseUrl) {
1610
1761
  if (process.env.UNBROWSE_API_KEY)
1611
1762
  return "env";
@@ -1614,6 +1765,8 @@ function signInSource(baseUrl) {
1614
1765
  return;
1615
1766
  if (stored.apiKey || stored.oauth)
1616
1767
  return "stored";
1768
+ if (selected)
1769
+ return;
1617
1770
  if (v1Key(baseUrl))
1618
1771
  return "v1";
1619
1772
  return sharedSignIn(baseUrl) ? "shared" : undefined;
@@ -1628,6 +1781,8 @@ function describe(baseUrl) {
1628
1781
  return `API key ${mask(stored.apiKey)} in ${configPath()}`;
1629
1782
  if (stored.oauth)
1630
1783
  return `OAuth sign-in in ${configPath()}`;
1784
+ if (selected)
1785
+ return `not signed in (account "${selected}": \`unbrowse login --account ${selected}\`)`;
1631
1786
  const v1 = !stored.baseUrl ? v1Key(baseUrl) : undefined;
1632
1787
  if (v1)
1633
1788
  return `v1 API key ${mask(v1)} from ~/.unbrowse/config.json (still works; \`unbrowse login\` replaces it)`;
@@ -1715,16 +1870,74 @@ ${url.toString()}`);
1715
1870
  server.close();
1716
1871
  }
1717
1872
  }
1873
+ async function deviceLogin(baseUrl, opts) {
1874
+ const fetchImpl = opts.fetch ?? fetch;
1875
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1876
+ const now = opts.now ?? Date.now;
1877
+ const name = opts.clientName ?? `Unbrowse CLI (${hostname()})`;
1878
+ const redirectUri = `${baseUrl.replace(/\/+$/, "")}/device/done`;
1879
+ const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
1880
+ method: "POST",
1881
+ headers: { "content-type": "application/json", accept: "application/json" },
1882
+ body: JSON.stringify({ client_name: name, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" })
1883
+ });
1884
+ const client = await reg.json().catch(() => ({}));
1885
+ if (!reg.ok || !client.client_id)
1886
+ throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
1887
+ const da = await fetchImpl(`${baseUrl}/oauth/device_authorization`, {
1888
+ method: "POST",
1889
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1890
+ body: new URLSearchParams({ client_id: client.client_id, resource: apiResource(baseUrl) }).toString()
1891
+ });
1892
+ const dev = await da.json().catch(() => ({}));
1893
+ if (!da.ok || !dev.device_code || !dev.user_code)
1894
+ throw new Error(`could not start device sign-in: ${dev.error_description ?? dev.error ?? `HTTP ${da.status}`}`);
1895
+ opts.log(`To sign in, open ${dev.verification_uri} on any device and enter the code:
1896
+
1897
+ ${dev.user_code}
1898
+
1899
+ (or open ${dev.verification_uri_complete} to skip typing it). Waiting for approval…`);
1900
+ const deadline = now() + (dev.expires_in ?? 600) * 1000;
1901
+ let interval = Math.max(1, dev.interval ?? 5);
1902
+ while (now() < deadline) {
1903
+ await sleep(interval * 1000);
1904
+ const res = await fetchImpl(`${baseUrl}/oauth/token`, {
1905
+ method: "POST",
1906
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1907
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: dev.device_code, client_id: client.client_id }).toString()
1908
+ });
1909
+ const body = await res.json().catch(() => ({}));
1910
+ if (res.ok && body.access_token) {
1911
+ save({ baseUrl, oauth: { clientId: client.client_id, accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? now() + body.expires_in * 1000 : undefined } });
1912
+ return;
1913
+ }
1914
+ if (body.error === "authorization_pending")
1915
+ continue;
1916
+ if (body.error === "slow_down") {
1917
+ interval += 5;
1918
+ continue;
1919
+ }
1920
+ if (body.error === "access_denied")
1921
+ throw new Error("the sign-in was denied");
1922
+ if (body.error === "expired_token")
1923
+ throw new Error("the code expired before it was approved; run `unbrowse login` again");
1924
+ throw new Error(body.error_description ?? body.error ?? `device sign-in failed (HTTP ${res.status})`);
1925
+ }
1926
+ throw new Error("timed out waiting for approval");
1927
+ }
1718
1928
 
1719
1929
  // src/cli.ts
1720
1930
  init_src();
1721
- var VERSION = "12.3.0";
1931
+ var VERSION = "12.4.0";
1722
1932
  var DEFAULT_ORIGIN = "https://unbrowse.ai";
1723
1933
  var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
1724
1934
 
1725
1935
  login [--key ub_live_…] Sign in in your browser (OAuth), or store an API key
1936
+ [--device] On a remote/headless machine, show a code to approve on your phone
1726
1937
  (first use of any command below signs you in the same way)
1727
1938
  logout Forget the stored sign-in
1939
+ accounts Sign-ins saved on this machine, one per Unbrowse account (each has its own vault)
1940
+ accounts use <name> Make one the default; any command takes --account NAME for just that run
1728
1941
  whoami Workspace and usage
1729
1942
  usage Verified calls this month and quota left
1730
1943
 
@@ -1746,6 +1959,8 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
1746
1959
  cookies list Browsers and profiles found on this machine (--json for agents)
1747
1960
  cookies sync Send your browser cookies so runs act as your signed-in self
1748
1961
  [--browser NAME] [--profile NAME] [--domain d] [--all]
1962
+ A page that says "sign in to view" (claude.ai, Notion, Google Docs…): sign in to it
1963
+ in your browser once, then: unbrowse cookies sync --domain <site>
1749
1964
  cookies watch --domain a,b Keep those sites' cookies in sync (re-upload on change) [--interval MIN] [--all-sites]
1750
1965
  cookies daemon start|status|stop
1751
1966
  The same as a background service (systemd / launchd) [--domain a,b | --all-sites] [--yes]
@@ -1765,18 +1980,26 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
1765
1980
  call <host> <tool> [JSON] Run one site tool with its inputs (or --set key=value…); prints the run
1766
1981
  [--deadline MS] [--select PATH,…] [--idempotency-key K] [--end-user ID]
1767
1982
 
1768
- mcp Local stdio MCP server proxying the hosted one (tool names use _ not .)
1983
+ tools [query | name] Every tool of the hosted MCP (cloud browser, map, replay, memory, your sites' tools…);
1984
+ one name prints its description and inputs (--json: every schema)
1985
+ tool <name> [JSON] Call one, the same as an agent over MCP: unbrowse tool browse_open --url https://…
1986
+ [--key value]… [--set key=value]… [--input JSON]
1987
+ the unbrowse_ prefix is optional; every --flag the CLI does not use is an input
1988
+ [--from-here] run sends its site requests from this machine, like \`unbrowse run\`
1989
+
1990
+ mcp Local stdio MCP server proxying the hosted one (tool names use _ not .)
1769
1991
  [--url URL] [--end-user ID] for agent hosts that need stdio or strict tool names (Grok Build)
1770
- [--from-here] unbrowse_run sends its site requests from this machine, like run (a Grok Bot's computer)
1992
+ [--from-here] unbrowse_run sends its site requests from this machine, like run (a Grok Bot's computer).
1993
+ It changes the IP only: it does not sign you in to a site (that is cookies sync)
1771
1994
  mcp add [claude] [codex] Register that proxy with Claude Code / Codex: they use this CLI's sign-in (no second sign-in)
1772
1995
  [--name NAME] [--replace] [--dry-run]
1773
1996
  And back: no CLI sign-in, but Claude Code signed in to Unbrowse (plugin) → the CLI uses that
1774
1997
 
1775
- Options: --json (errors as JSON) · --base-url URL · --no-open
1776
- Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN}), UNBROWSE_EGRESS=server, UNBROWSE_MCP_URL, UNBROWSE_END_USER
1998
+ Options: --json (errors as JSON) · --base-url URL · --no-open · --account NAME
1999
+ Env: UNBROWSE_API_KEY, UNBROWSE_ACCOUNT, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN}), UNBROWSE_EGRESS=server, UNBROWSE_MCP_URL, UNBROWSE_END_USER
1777
2000
  Exit: 0 ok · 1 error · 2 input required · 3 sign-in or login needed · 4 not verified`;
1778
2001
  var BOOLEAN = new Set(["json", "help", "version", "no-open", "no-wait", "unattended", "from-here", "from-unbrowse", "all", "all-sites", "yes", "full", "events", "dry-run", "with-keys", "replace"]);
1779
- var NEEDS_ACCOUNT = new Set(["whoami", "usage", "discover", "run", "scrape", "index", "inspect", "resume", "cancel", "learn", "learned", "logins", "call", "cookies"]);
2002
+ var NEEDS_ACCOUNT = new Set(["whoami", "usage", "discover", "run", "scrape", "index", "inspect", "resume", "cancel", "learn", "learned", "logins", "call", "cookies", "tools", "tool"]);
1780
2003
  var SCRAPE_FORMATS = ["markdown", "html", "text", "links", "raw"];
1781
2004
  function parseArgs(argv) {
1782
2005
  const out = { _: [], flags: {}, sets: {} };
@@ -1834,15 +2057,25 @@ async function main(argv, io = stdio) {
1834
2057
  return io.out(VERSION), 0;
1835
2058
  if (!cmd || cmd === "help" || args.flags.help)
1836
2059
  return io.out(HELP), 0;
2060
+ if (args.flags.account === true)
2061
+ return io.err("usage: --account NAME"), 1;
2062
+ try {
2063
+ selectAccount(typeof args.flags.account === "string" ? args.flags.account : undefined);
2064
+ } catch (e) {
2065
+ return io.err(e.message), 1;
2066
+ }
2067
+ const named = account() !== "default" ? account() : undefined;
2068
+ const loginCmd = `unbrowse login${named ? ` --account ${named}` : ""}`;
1837
2069
  const origin = String(args.flags["base-url"] ?? process.env.UNBROWSE_BASE_URL ?? load().baseUrl ?? DEFAULT_ORIGIN).replace(/\/+$/, "");
1838
2070
  const flag = (k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
1839
2071
  const print = (v) => io.out(JSON.stringify(v, null, 2));
1840
2072
  const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin, client: `cli/${VERSION}` });
1841
2073
  if (cmd === "mcp" && rest[0] !== "add") {
1842
2074
  await Promise.resolve().then(() => init_mcp_proxy());
2075
+ const url = flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`;
1843
2076
  await serveStdio({
1844
- url: flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`,
1845
- token: async () => await currentToken(origin) ?? undefined,
2077
+ url,
2078
+ token: () => tokenFor(origin, url),
1846
2079
  version: VERSION,
1847
2080
  endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER,
1848
2081
  ...args.flags["from-here"] || process.env.UNBROWSE_EGRESS === "client" ? { fromHere: client } : {}
@@ -1881,7 +2114,7 @@ async function main(argv, io = stdio) {
1881
2114
  io.err("This CLI runs from a temporary npx copy, which a background service cannot point at. Install it first: npm i -g unbrowse — then run this again.");
1882
2115
  return 1;
1883
2116
  }
1884
- const plan = D.servicePlan({ os: io.service?.os, home: io.service?.home, node, cli, env: { ...origin !== DEFAULT_ORIGIN ? { UNBROWSE_BASE_URL: origin } : {}, ...process.env.UNBROWSE_CONFIG_DIR ? { UNBROWSE_CONFIG_DIR: process.env.UNBROWSE_CONFIG_DIR } : {}, ...process.env.UNBROWSE_COOKIES_HOME ? { UNBROWSE_COOKIES_HOME: process.env.UNBROWSE_COOKIES_HOME } : {} } });
2117
+ const plan = D.servicePlan({ os: io.service?.os, home: io.service?.home, node, cli, account: named, env: { ...origin !== DEFAULT_ORIGIN ? { UNBROWSE_BASE_URL: origin } : {}, ...named ? { UNBROWSE_ACCOUNT: named } : {}, ...process.env.UNBROWSE_CONFIG_DIR ? { UNBROWSE_CONFIG_DIR: process.env.UNBROWSE_CONFIG_DIR } : {}, ...process.env.UNBROWSE_COOKIES_HOME ? { UNBROWSE_COOKIES_HOME: process.env.UNBROWSE_COOKIES_HOME } : {} } });
1885
2118
  if (!plan) {
1886
2119
  io.err("No background service on this OS yet. Keep it running with: unbrowse cookies watch (e.g. from Task Scheduler at logon).");
1887
2120
  return 1;
@@ -1932,7 +2165,7 @@ async function main(argv, io = stdio) {
1932
2165
  };
1933
2166
  const onboard = async () => {
1934
2167
  if (!io.interactive || args.flags.json) {
1935
- const message = `Not signed in to ${origin}. Run \`npx unbrowse login\` (opens your browser, nothing to paste), or set UNBROWSE_API_KEY (keys: ${origin}/app).`;
2168
+ const message = `Not signed in to ${origin}${named ? ` as account "${named}"` : ""}. Run \`npx ${loginCmd}\` (opens your browser, nothing to paste), or set UNBROWSE_API_KEY (keys: ${origin}/app).`;
1936
2169
  if (args.flags.json)
1937
2170
  print({ error: { status: 401, code: "not_signed_in", message, details: null } });
1938
2171
  else
@@ -1956,21 +2189,36 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
1956
2189
  const key = flag("key");
1957
2190
  if (key)
1958
2191
  save({ baseUrl: origin, apiKey: key });
2192
+ else if (args.flags.device || !io.interactive)
2193
+ await deviceLogin(origin, { log: io.err });
1959
2194
  else
1960
2195
  await oauthLogin(origin, { open: (u) => openLink(u, args, io), log: io.err });
1961
2196
  const me = await (await client()).me();
1962
- io.err(`Signed in to ${origin} (workspace ${me.workspaceId}).`);
2197
+ io.err(`Signed in to ${origin}${named ? ` as account "${named}"` : ""} (workspace ${me.workspaceId}).`);
1963
2198
  await offerCookieSync().catch((e) => io.err(`Cookie sync not set up: ${e.message}`));
1964
2199
  return 0;
1965
2200
  }
2201
+ case "accounts": {
2202
+ if (rest[0] === "use") {
2203
+ const name = checkAccount(need(rest[1], "accounts use <name>"));
2204
+ useAccount(name);
2205
+ const saved = listAccounts().some((a) => a.name === name);
2206
+ io.err(`Commands now use account "${name}"${saved ? "" : ` (not signed in yet: unbrowse login${name === "default" ? "" : ` --account ${name}`})`}.${process.env.UNBROWSE_ACCOUNT ? ` UNBROWSE_ACCOUNT=${process.env.UNBROWSE_ACCOUNT} still wins while it is set.` : ""}`);
2207
+ return 0;
2208
+ }
2209
+ if (rest[0])
2210
+ throw new UsageError("usage: unbrowse accounts [use <name>]");
2211
+ print({ current: account(), accounts: listAccounts() });
2212
+ return 0;
2213
+ }
1966
2214
  case "mcp":
1967
- return await mcpAdd(rest.slice(1), args, io, origin);
2215
+ return await mcpAdd(rest.slice(1), args, io, origin, named);
1968
2216
  case "logout":
1969
2217
  clear();
1970
- io.err("Signed out.");
2218
+ io.err(named ? `Signed out of account "${named}".` : "Signed out.");
1971
2219
  return 0;
1972
2220
  case "whoami":
1973
- print({ origin, auth: describe(origin), ...await ub.me() });
2221
+ print({ origin, account: account(), auth: describe(origin), ...await ub.me() });
1974
2222
  return 0;
1975
2223
  case "usage":
1976
2224
  print(await ub.usage());
@@ -1994,6 +2242,8 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
1994
2242
  };
1995
2243
  const fromHere = !args.flags["from-unbrowse"] && !args.flags["no-wait"] && process.env.UNBROWSE_EGRESS !== "server";
1996
2244
  const view = fromHere ? await ub.runOnClient(request, { onRequest: (r) => void io.err(`→ ${r.method} ${r.url}`) }) : await ub.run(request);
2245
+ if (fromHere && view.egress?.mode === "server")
2246
+ io.err("Ran from Unbrowse, not this machine: this site uses your saved sign-in, which never leaves your vault.");
1997
2247
  return settle(ub, view, args, io, print);
1998
2248
  }
1999
2249
  case "scrape": {
@@ -2108,7 +2358,7 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2108
2358
  if (sub === "daemon") {
2109
2359
  const action = rest[1] ?? "status";
2110
2360
  const { D, dir, saved, config } = await cookieSync();
2111
- const plan = D.servicePlan({ os: io.service?.os, home: io.service?.home, node: io.service?.node ?? process.execPath, cli: io.service?.cli ?? process.argv[1] ?? "" });
2361
+ const plan = D.servicePlan({ os: io.service?.os, home: io.service?.home, node: io.service?.node ?? process.execPath, cli: io.service?.cli ?? process.argv[1] ?? "", account: named });
2112
2362
  const exec = io.exec ?? (() => ({ status: 1, stdout: "", stderr: "no exec" }));
2113
2363
  if (action === "start" || action === "install")
2114
2364
  return await installCookieDaemon(config(), !!args.flags.yes);
@@ -2254,6 +2504,9 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2254
2504
  return io.err("Outcome unknown: a change may have happened. Inspect before retrying."), 4;
2255
2505
  return io.err(`${run.status}${run.error ? `: ${run.error.code} — ${run.error.message}` : ""}`), 1;
2256
2506
  }
2507
+ case "tools":
2508
+ case "tool":
2509
+ return await mcpTools(cmd, rest, args, io, origin, print, client);
2257
2510
  default:
2258
2511
  throw new UsageError(`unknown command "${cmd}". Run \`unbrowse help\`.`);
2259
2512
  }
@@ -2269,10 +2522,13 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2269
2522
  const cause = e.cause;
2270
2523
  e.message = `could not reach ${origin}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : ""}. Check the connection, or --base-url / UNBROWSE_BASE_URL.`;
2271
2524
  }
2525
+ const siteSignIn = e.status === 401 && siteLoginRequired(e.body);
2272
2526
  if (args.flags.json)
2273
2527
  print({ error: { status: e.status ?? null, message: e.message, details: e.body ?? null } });
2528
+ else if (siteSignIn)
2529
+ io.err(siteSignInHint(siteSignIn, e.message));
2274
2530
  else
2275
- io.err(e.status === 401 ? `Not signed in to ${origin}. Run \`unbrowse login\`, or set UNBROWSE_API_KEY.` : `error${e.status ? ` (HTTP ${e.status})` : ""}: ${e.message}`);
2531
+ io.err(e.status === 401 ? `Not signed in to ${origin}${named ? ` as account "${named}"` : ""}. Run \`${loginCmd}\`, or set UNBROWSE_API_KEY.` : `error${e.status ? ` (HTTP ${e.status})` : ""}: ${e.message}`);
2276
2532
  return e.status === 401 ? 3 : 1;
2277
2533
  }
2278
2534
  }
@@ -2307,12 +2563,12 @@ async function settle(ub, view, args, io, print) {
2307
2563
  io.err(`${view.status}: ${view.error.code}${view.error.message ? ` — ${view.error.message}` : ""}`);
2308
2564
  return 1;
2309
2565
  }
2310
- async function mcpAdd(rest, args, io, origin) {
2566
+ async function mcpAdd(rest, args, io, origin, account) {
2311
2567
  const HOSTS = ["claude", "codex"];
2312
2568
  const unknown = rest.filter((h) => !HOSTS.includes(h));
2313
2569
  if (unknown.length)
2314
2570
  throw new UsageError(`usage: unbrowse mcp add [claude] [codex] [--name NAME] [--replace] [--dry-run] (unknown: ${unknown.join(", ")})`);
2315
- const name = (typeof args.flags.name === "string" ? args.flags.name : "") || "unbrowse";
2571
+ const name = (typeof args.flags.name === "string" ? args.flags.name : "") || (account ? `unbrowse-${account}` : "unbrowse");
2316
2572
  const exec = io.exec ?? (() => ({ status: 1, stdout: "", stderr: "no exec" }));
2317
2573
  const dry = !!args.flags["dry-run"];
2318
2574
  const replace = !!args.flags.replace;
@@ -2323,7 +2579,7 @@ async function mcpAdd(rest, args, io, origin) {
2323
2579
  try {
2324
2580
  cli = realpathSync(cli);
2325
2581
  } catch {}
2326
- const launch = [...io.service?.cli || !ephemeralCli(cli) ? [io.service?.node ?? process.execPath, cli] : shell(["npx", "-y", "unbrowse"]), "mcp", ...origin !== DEFAULT_ORIGIN ? ["--base-url", origin] : []];
2582
+ const launch = [...io.service?.cli || !ephemeralCli(cli) ? [io.service?.node ?? process.execPath, cli] : shell(["npx", "-y", "unbrowse"]), "mcp", ...origin !== DEFAULT_ORIGIN ? ["--base-url", origin] : [], ...account ? ["--account", account] : []];
2327
2583
  const env = ["UNBROWSE_CONFIG_DIR", "XDG_CONFIG_HOME", "CLAUDE_CONFIG_DIR", "UNBROWSE_SHARED_SIGNIN"].flatMap((k) => process.env[k] ? [`${k}=${process.env[k]}`] : []);
2328
2584
  const plans = {
2329
2585
  claude: { get: ["claude", "mcp", "get", name], remove: ["claude", "mcp", "remove", name, "--scope", "user"], add: ["claude", "mcp", "add", "--scope", "user", name, ...env.flatMap((e) => ["-e", e]), "--", ...launch] },
@@ -2354,9 +2610,11 @@ async function mcpAdd(rest, args, io, origin) {
2354
2610
  }
2355
2611
  }
2356
2612
  const r = exec(shell(plan.add));
2357
- if (r.status === 0)
2613
+ if (r.status === 0) {
2358
2614
  io.err(`✓ ${h}: added "${name}" (stdio, on this CLI's sign-in)`);
2359
- else
2615
+ if (h === "claude" && exec(shell(["claude", "mcp", "get", "plugin:unbrowse:unbrowse"])).status === 0)
2616
+ io.err(` Claude Code also has the Unbrowse plugin's server (plugin:unbrowse:unbrowse), so its tools now show twice. Keep one: disable that server in /mcp, or \`claude mcp remove ${name} --scope user\`.`);
2617
+ } else
2360
2618
  failed++, io.err(`✗ ${h}: ${(r.stderr || r.stdout).trim() || `exit ${r.status}`}`);
2361
2619
  }
2362
2620
  if (!dry) {
@@ -2449,6 +2707,95 @@ ${unique(servers.filter((s) => !s.self)).length} to import: unbrowse mcps import
2449
2707
  }
2450
2708
  return rows.some((r) => r.status === "failed") ? 1 : 0;
2451
2709
  }
2710
+ async function mcpTools(cmd, rest, args, io, origin, print, client) {
2711
+ const T = await Promise.resolve().then(() => (init_mcp_tools(), {}));
2712
+ await Promise.resolve().then(() => init_mcp_proxy());
2713
+ await Promise.resolve().then(() => init_src());
2714
+ const url = process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`;
2715
+ const endUser = process.env.UNBROWSE_END_USER;
2716
+ const handle = createProxy(async () => new UnbrowseMcp({ url, apiKey: await tokenFor(origin, url) ?? "", endUser, client: `cli/${VERSION}` }), VERSION, args.flags["from-here"] ? { runLocal: localRun(client) } : {});
2717
+ let id = 0;
2718
+ const rpc = async (method, params) => {
2719
+ const res = await handle({ jsonrpc: "2.0", id: ++id, method, ...params ? { params } : {} });
2720
+ if (res.error)
2721
+ throw Object.assign(new Error(res.error.message), { status: res.error.data?.code === "unauthorized" || /unbrowse login/.test(res.error.message) ? 401 : undefined, code: res.error.data?.code });
2722
+ return res.result;
2723
+ };
2724
+ const { tools } = await rpc("tools/list");
2725
+ if (cmd === "tools") {
2726
+ const query = rest.join(" ");
2727
+ const one = rest.length === 1 ? resolveTool(tools, rest[0]) : undefined;
2728
+ if (one) {
2729
+ if (args.flags.json)
2730
+ return print(one), 0;
2731
+ io.out(`${one.name}
2732
+
2733
+ ${one.description ?? ""}
2734
+
2735
+ Inputs: ${JSON.stringify(one.inputSchema ?? {}, null, 2)}`);
2736
+ return io.err(`Call it: unbrowse tool ${shortName(one.name)} --key value…`), 0;
2737
+ }
2738
+ const shown = query ? filterTools(tools, query) : tools;
2739
+ if (args.flags.json)
2740
+ return print({ tools: shown }), 0;
2741
+ if (!shown.length)
2742
+ return io.err(`No tool matches "${query}". All of them: unbrowse tools`), 1;
2743
+ for (const t of shown)
2744
+ io.out(`${signature(t).padEnd(44)} ${summary(t.description)}`);
2745
+ io.err(`
2746
+ ${shown.length} tool(s). One in full: unbrowse tools <name> · call one: unbrowse tool <name> --key value…`);
2747
+ return 0;
2748
+ }
2749
+ const typed = need(rest[0], "tool <name> [JSON] [--key value]… (list them: unbrowse tools)");
2750
+ const tool = resolveTool(tools, typed);
2751
+ if (!tool) {
2752
+ const words = typed.toLowerCase().split(/[._\s-]+/).filter((w) => w && w !== "unbrowse");
2753
+ const near = tools.filter((t) => words.some((w) => shortName(t.name).toLowerCase().includes(w))).slice(0, 5).map((t) => shortName(t.name));
2754
+ throw new UsageError(`no MCP tool named "${typed}".${near.length ? ` Did you mean: ${near.join(", ")}?` : ""} List them: unbrowse tools`);
2755
+ }
2756
+ const input = toolInput(tool, rest[1] ? rest.slice(1).join(" ") : typeof args.flags.input === "string" ? args.flags.input : undefined, args.flags, mapValues(args.sets), coerce);
2757
+ const result = await rpc("tools/call", { name: tool.name, arguments: input });
2758
+ const value = resultValue(result);
2759
+ const code = resultCode(result, value);
2760
+ if (code === 1)
2761
+ io.err(typeof value === "string" ? value : JSON.stringify(value, null, 2));
2762
+ else if (typeof value === "string")
2763
+ io.out(value);
2764
+ else
2765
+ print(value);
2766
+ if (code === 2) {
2767
+ const runId = value.runId;
2768
+ io.err(`Input required: ${runId ? `unbrowse resume ${runId} key=value…` : "answer it with unbrowse tool resume"}`);
2769
+ }
2770
+ return code;
2771
+ }
2772
+ function siteLoginRequired(body) {
2773
+ const err = body?.error;
2774
+ if (err?.code !== "login_required")
2775
+ return;
2776
+ const d = err.details ?? {};
2777
+ let site = typeof d.site === "string" ? d.site : undefined;
2778
+ if (!site && typeof d.finalUrl === "string") {
2779
+ try {
2780
+ site = new URL(d.finalUrl).hostname.replace(/^www\./, "");
2781
+ } catch {}
2782
+ }
2783
+ const next = Array.isArray(d.next) ? d.next.filter((s) => typeof s === "string") : undefined;
2784
+ return { site, next };
2785
+ }
2786
+ function siteSignInHint(found, message) {
2787
+ const site = found.site ?? "<site>";
2788
+ const lines = [message || `${site} shows a sign-in page.`];
2789
+ if (found.next?.length)
2790
+ lines.push(...found.next.map((s) => ` - ${s}`));
2791
+ else {
2792
+ lines.push(` Use your own browser's sign-in: unbrowse cookies sync --domain ${site}`);
2793
+ lines.push(` Keep it fresh: unbrowse cookies daemon start --domain ${site}`);
2794
+ }
2795
+ lines.push(" (--from-here / --from-unbrowse change only where requests come from; neither signs you in.)");
2796
+ return lines.join(`
2797
+ `);
2798
+ }
2452
2799
  function noCapabilityHint(view, args) {
2453
2800
  const result = view.result ?? {};
2454
2801
  const task = args._.slice(1).join(" ");
@@ -2457,6 +2804,7 @@ function noCapabilityHint(view, args) {
2457
2804
  if (url) {
2458
2805
  lines.push(` Read the page now: unbrowse scrape ${url}`);
2459
2806
  lines.push(` Teach Unbrowse the site: unbrowse index ${url} (minutes; then run the task again)`);
2807
+ lines.push(` Do it in a cloud browser: unbrowse tool browse_open --url ${url} (recorded, so it teaches the site too)`);
2460
2808
  } else {
2461
2809
  lines.push(` Name the site: unbrowse run ${JSON.stringify(task || "<task>")} --url https://…`);
2462
2810
  lines.push(" Read any page now: unbrowse scrape https://…");
@@ -2503,5 +2851,7 @@ export {
2503
2851
  DEFAULT_ORIGIN,
2504
2852
  main,
2505
2853
  noCapabilityHint,
2506
- parseArgs
2854
+ parseArgs,
2855
+ siteLoginRequired,
2856
+ siteSignInHint
2507
2857
  };
package/package.json CHANGED
@@ -1,8 +1,24 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "12.3.0",
3
+ "version": "12.4.0",
4
4
  "mcpName": "io.github.unbrowse-ai/unbrowse",
5
5
  "description": "Call websites as APIs: a thin CLI over the Unbrowse API.",
6
+ "keywords": [
7
+ "unbrowse",
8
+ "cli",
9
+ "mcp",
10
+ "browser",
11
+ "web-scraping",
12
+ "scraper",
13
+ "api",
14
+ "agent",
15
+ "ai-agent",
16
+ "claude",
17
+ "llm",
18
+ "automation",
19
+ "headless-browser",
20
+ "website-to-api"
21
+ ],
6
22
  "license": "MIT",
7
23
  "type": "module",
8
24
  "homepage": "https://unbrowse.ai",
@@ -251,7 +251,8 @@
251
251
  "type": "string"
252
252
  },
253
253
  "value": {
254
- "type": "string"
254
+ "type": "string",
255
+ "description": "fill: the text to type. select: the option, by its value or label as the snapshot's `options` list it (any case; a unique part of a label also works)"
255
256
  },
256
257
  "name": {
257
258
  "type": "string"