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 +3 -1
- package/SKILL.md +2 -2
- package/dist/cli.js +392 -42
- package/package.json +17 -1
- package/references/tools.json +2 -1
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
|
|
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.
|
|
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
|
|
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.
|
|
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", `${
|
|
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", `${
|
|
952
|
-
disable: [["systemctl", "--user", "disable", "--now", `${
|
|
953
|
-
status: ["systemctl", "--user", "is-active", `${
|
|
954
|
-
log: `journalctl --user -u ${
|
|
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", `${
|
|
959
|
-
const logPath = join3(home, "Library", "Logs", `${
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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>${
|
|
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}/${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
1845
|
-
token:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
"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",
|
package/references/tools.json
CHANGED
|
@@ -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"
|