unbrowse 12.3.1 → 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/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.1";
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
  }
@@ -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
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();
@@ -1597,7 +1735,7 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
1597
1735
  return stored.apiKey;
1598
1736
  const o = stored.oauth;
1599
1737
  if (!o)
1600
- return v1Key(baseUrl) ?? sharedSignIn(baseUrl)?.token;
1738
+ return selected ? undefined : v1Key(baseUrl) ?? sharedSignIn(baseUrl)?.token;
1601
1739
  if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
1602
1740
  try {
1603
1741
  const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
@@ -1627,6 +1765,8 @@ function signInSource(baseUrl) {
1627
1765
  return;
1628
1766
  if (stored.apiKey || stored.oauth)
1629
1767
  return "stored";
1768
+ if (selected)
1769
+ return;
1630
1770
  if (v1Key(baseUrl))
1631
1771
  return "v1";
1632
1772
  return sharedSignIn(baseUrl) ? "shared" : undefined;
@@ -1641,6 +1781,8 @@ function describe(baseUrl) {
1641
1781
  return `API key ${mask(stored.apiKey)} in ${configPath()}`;
1642
1782
  if (stored.oauth)
1643
1783
  return `OAuth sign-in in ${configPath()}`;
1784
+ if (selected)
1785
+ return `not signed in (account "${selected}": \`unbrowse login --account ${selected}\`)`;
1644
1786
  const v1 = !stored.baseUrl ? v1Key(baseUrl) : undefined;
1645
1787
  if (v1)
1646
1788
  return `v1 API key ${mask(v1)} from ~/.unbrowse/config.json (still works; \`unbrowse login\` replaces it)`;
@@ -1786,7 +1928,7 @@ async function deviceLogin(baseUrl, opts) {
1786
1928
 
1787
1929
  // src/cli.ts
1788
1930
  init_src();
1789
- var VERSION = "12.3.1";
1931
+ var VERSION = "12.4.0";
1790
1932
  var DEFAULT_ORIGIN = "https://unbrowse.ai";
1791
1933
  var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
1792
1934
 
@@ -1794,6 +1936,8 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
1794
1936
  [--device] On a remote/headless machine, show a code to approve on your phone
1795
1937
  (first use of any command below signs you in the same way)
1796
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
1797
1941
  whoami Workspace and usage
1798
1942
  usage Verified calls this month and quota left
1799
1943
 
@@ -1815,6 +1959,8 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
1815
1959
  cookies list Browsers and profiles found on this machine (--json for agents)
1816
1960
  cookies sync Send your browser cookies so runs act as your signed-in self
1817
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>
1818
1964
  cookies watch --domain a,b Keep those sites' cookies in sync (re-upload on change) [--interval MIN] [--all-sites]
1819
1965
  cookies daemon start|status|stop
1820
1966
  The same as a background service (systemd / launchd) [--domain a,b | --all-sites] [--yes]
@@ -1834,18 +1980,26 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
1834
1980
  call <host> <tool> [JSON] Run one site tool with its inputs (or --set key=value…); prints the run
1835
1981
  [--deadline MS] [--select PATH,…] [--idempotency-key K] [--end-user ID]
1836
1982
 
1837
- 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 .)
1838
1991
  [--url URL] [--end-user ID] for agent hosts that need stdio or strict tool names (Grok Build)
1839
- [--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)
1840
1994
  mcp add [claude] [codex] Register that proxy with Claude Code / Codex: they use this CLI's sign-in (no second sign-in)
1841
1995
  [--name NAME] [--replace] [--dry-run]
1842
1996
  And back: no CLI sign-in, but Claude Code signed in to Unbrowse (plugin) → the CLI uses that
1843
1997
 
1844
- Options: --json (errors as JSON) · --base-url URL · --no-open
1845
- 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
1846
2000
  Exit: 0 ok · 1 error · 2 input required · 3 sign-in or login needed · 4 not verified`;
1847
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"]);
1848
- 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"]);
1849
2003
  var SCRAPE_FORMATS = ["markdown", "html", "text", "links", "raw"];
1850
2004
  function parseArgs(argv) {
1851
2005
  const out = { _: [], flags: {}, sets: {} };
@@ -1903,6 +2057,15 @@ async function main(argv, io = stdio) {
1903
2057
  return io.out(VERSION), 0;
1904
2058
  if (!cmd || cmd === "help" || args.flags.help)
1905
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}` : ""}`;
1906
2069
  const origin = String(args.flags["base-url"] ?? process.env.UNBROWSE_BASE_URL ?? load().baseUrl ?? DEFAULT_ORIGIN).replace(/\/+$/, "");
1907
2070
  const flag = (k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
1908
2071
  const print = (v) => io.out(JSON.stringify(v, null, 2));
@@ -1951,7 +2114,7 @@ async function main(argv, io = stdio) {
1951
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.");
1952
2115
  return 1;
1953
2116
  }
1954
- 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 } : {} } });
1955
2118
  if (!plan) {
1956
2119
  io.err("No background service on this OS yet. Keep it running with: unbrowse cookies watch (e.g. from Task Scheduler at logon).");
1957
2120
  return 1;
@@ -2002,7 +2165,7 @@ async function main(argv, io = stdio) {
2002
2165
  };
2003
2166
  const onboard = async () => {
2004
2167
  if (!io.interactive || args.flags.json) {
2005
- 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).`;
2006
2169
  if (args.flags.json)
2007
2170
  print({ error: { status: 401, code: "not_signed_in", message, details: null } });
2008
2171
  else
@@ -2031,18 +2194,31 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2031
2194
  else
2032
2195
  await oauthLogin(origin, { open: (u) => openLink(u, args, io), log: io.err });
2033
2196
  const me = await (await client()).me();
2034
- io.err(`Signed in to ${origin} (workspace ${me.workspaceId}).`);
2197
+ io.err(`Signed in to ${origin}${named ? ` as account "${named}"` : ""} (workspace ${me.workspaceId}).`);
2035
2198
  await offerCookieSync().catch((e) => io.err(`Cookie sync not set up: ${e.message}`));
2036
2199
  return 0;
2037
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
+ }
2038
2214
  case "mcp":
2039
- return await mcpAdd(rest.slice(1), args, io, origin);
2215
+ return await mcpAdd(rest.slice(1), args, io, origin, named);
2040
2216
  case "logout":
2041
2217
  clear();
2042
- io.err("Signed out.");
2218
+ io.err(named ? `Signed out of account "${named}".` : "Signed out.");
2043
2219
  return 0;
2044
2220
  case "whoami":
2045
- print({ origin, auth: describe(origin), ...await ub.me() });
2221
+ print({ origin, account: account(), auth: describe(origin), ...await ub.me() });
2046
2222
  return 0;
2047
2223
  case "usage":
2048
2224
  print(await ub.usage());
@@ -2066,6 +2242,8 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2066
2242
  };
2067
2243
  const fromHere = !args.flags["from-unbrowse"] && !args.flags["no-wait"] && process.env.UNBROWSE_EGRESS !== "server";
2068
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.");
2069
2247
  return settle(ub, view, args, io, print);
2070
2248
  }
2071
2249
  case "scrape": {
@@ -2180,7 +2358,7 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2180
2358
  if (sub === "daemon") {
2181
2359
  const action = rest[1] ?? "status";
2182
2360
  const { D, dir, saved, config } = await cookieSync();
2183
- 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 });
2184
2362
  const exec = io.exec ?? (() => ({ status: 1, stdout: "", stderr: "no exec" }));
2185
2363
  if (action === "start" || action === "install")
2186
2364
  return await installCookieDaemon(config(), !!args.flags.yes);
@@ -2326,6 +2504,9 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2326
2504
  return io.err("Outcome unknown: a change may have happened. Inspect before retrying."), 4;
2327
2505
  return io.err(`${run.status}${run.error ? `: ${run.error.code} — ${run.error.message}` : ""}`), 1;
2328
2506
  }
2507
+ case "tools":
2508
+ case "tool":
2509
+ return await mcpTools(cmd, rest, args, io, origin, print, client);
2329
2510
  default:
2330
2511
  throw new UsageError(`unknown command "${cmd}". Run \`unbrowse help\`.`);
2331
2512
  }
@@ -2341,10 +2522,13 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
2341
2522
  const cause = e.cause;
2342
2523
  e.message = `could not reach ${origin}${cause?.code ? ` (${cause.code})` : cause?.message ? ` (${cause.message})` : ""}. Check the connection, or --base-url / UNBROWSE_BASE_URL.`;
2343
2524
  }
2525
+ const siteSignIn = e.status === 401 && siteLoginRequired(e.body);
2344
2526
  if (args.flags.json)
2345
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));
2346
2530
  else
2347
- 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}`);
2348
2532
  return e.status === 401 ? 3 : 1;
2349
2533
  }
2350
2534
  }
@@ -2379,12 +2563,12 @@ async function settle(ub, view, args, io, print) {
2379
2563
  io.err(`${view.status}: ${view.error.code}${view.error.message ? ` — ${view.error.message}` : ""}`);
2380
2564
  return 1;
2381
2565
  }
2382
- async function mcpAdd(rest, args, io, origin) {
2566
+ async function mcpAdd(rest, args, io, origin, account) {
2383
2567
  const HOSTS = ["claude", "codex"];
2384
2568
  const unknown = rest.filter((h) => !HOSTS.includes(h));
2385
2569
  if (unknown.length)
2386
2570
  throw new UsageError(`usage: unbrowse mcp add [claude] [codex] [--name NAME] [--replace] [--dry-run] (unknown: ${unknown.join(", ")})`);
2387
- 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");
2388
2572
  const exec = io.exec ?? (() => ({ status: 1, stdout: "", stderr: "no exec" }));
2389
2573
  const dry = !!args.flags["dry-run"];
2390
2574
  const replace = !!args.flags.replace;
@@ -2395,7 +2579,7 @@ async function mcpAdd(rest, args, io, origin) {
2395
2579
  try {
2396
2580
  cli = realpathSync(cli);
2397
2581
  } catch {}
2398
- 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] : []];
2399
2583
  const env = ["UNBROWSE_CONFIG_DIR", "XDG_CONFIG_HOME", "CLAUDE_CONFIG_DIR", "UNBROWSE_SHARED_SIGNIN"].flatMap((k) => process.env[k] ? [`${k}=${process.env[k]}`] : []);
2400
2584
  const plans = {
2401
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] },
@@ -2523,6 +2707,95 @@ ${unique(servers.filter((s) => !s.self)).length} to import: unbrowse mcps import
2523
2707
  }
2524
2708
  return rows.some((r) => r.status === "failed") ? 1 : 0;
2525
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
+ }
2526
2799
  function noCapabilityHint(view, args) {
2527
2800
  const result = view.result ?? {};
2528
2801
  const task = args._.slice(1).join(" ");
@@ -2531,6 +2804,7 @@ function noCapabilityHint(view, args) {
2531
2804
  if (url) {
2532
2805
  lines.push(` Read the page now: unbrowse scrape ${url}`);
2533
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)`);
2534
2808
  } else {
2535
2809
  lines.push(` Name the site: unbrowse run ${JSON.stringify(task || "<task>")} --url https://…`);
2536
2810
  lines.push(" Read any page now: unbrowse scrape https://…");
@@ -2577,5 +2851,7 @@ export {
2577
2851
  DEFAULT_ORIGIN,
2578
2852
  main,
2579
2853
  noCapabilityHint,
2580
- parseArgs
2854
+ parseArgs,
2855
+ siteLoginRequired,
2856
+ siteSignInHint
2581
2857
  };
package/package.json CHANGED
@@ -1,8 +1,24 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "12.3.1",
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"