unbrowse 12.3.0 → 12.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/SKILL.md +2 -2
  2. package/dist/cli.js +84 -10
  3. package/package.json +1 -1
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.3.1";
30
30
 
31
31
  // ../sdk/src/client.ts
32
32
  function apiBase(url) {
@@ -992,7 +992,7 @@ function quoteSystemd(s) {
992
992
  return /[\s"\\]/.test(s) ? `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"` : s;
993
993
  }
994
994
  function ephemeralCli(cli) {
995
- return /[\\/](_npx|\.npm[\\/]_npx|npx-[^\\/]+|tmp|Temp)[\\/]/i.test(cli);
995
+ return /[\\/](_npx|\.npm[\\/]_npx|npx-[^\\/]+|bunx-[^\\/]+|tmp|Temp)[\\/]/i.test(cli);
996
996
  }
997
997
  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
998
  var init_cookie_daemon = __esm(() => {
@@ -1521,7 +1521,7 @@ import { createInterface as createInterface2 } from "node:readline/promises";
1521
1521
  import { createHash, randomBytes } from "node:crypto";
1522
1522
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1523
1523
  import { createServer } from "node:http";
1524
- import { homedir } from "node:os";
1524
+ import { homedir, hostname } from "node:os";
1525
1525
  import { dirname, join } from "node:path";
1526
1526
  function configPath() {
1527
1527
  const root = process.env.UNBROWSE_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "unbrowse");
@@ -1567,6 +1567,7 @@ function sharedSignIn(baseUrl, now = Date.now()) {
1567
1567
  return;
1568
1568
  }
1569
1569
  const api = new URL(apiResource(baseUrl));
1570
+ let best;
1570
1571
  for (const e of entries) {
1571
1572
  if (typeof e?.serverUrl !== "string" || typeof e.accessToken !== "string" || !e.accessToken)
1572
1573
  continue;
@@ -1580,9 +1581,11 @@ function sharedSignIn(baseUrl, now = Date.now()) {
1580
1581
  }
1581
1582
  if (url.origin !== api.origin || !(url.pathname === api.pathname || url.pathname.startsWith(`${api.pathname}/`)))
1582
1583
  continue;
1583
- return { token: e.accessToken, from: `Claude Code's MCP sign-in (${String(e.serverName ?? url.origin + url.pathname)})` };
1584
+ const expiresAt = typeof e.expiresAt === "number" ? e.expiresAt : 0;
1585
+ if (!best || expiresAt > best.expiresAt)
1586
+ best = { token: e.accessToken, from: `Claude Code's MCP sign-in (${String(e.serverName ?? url.origin + url.pathname)})`, expiresAt };
1584
1587
  }
1585
- return;
1588
+ return best && { token: best.token, from: best.from };
1586
1589
  }
1587
1590
  async function currentToken(baseUrl, fetchImpl = fetch) {
1588
1591
  if (process.env.UNBROWSE_API_KEY)
@@ -1606,6 +1609,16 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
1606
1609
  }
1607
1610
  return o.accessToken;
1608
1611
  }
1612
+ async function tokenFor(baseUrl, target, fetchImpl = fetch) {
1613
+ const token = await currentToken(baseUrl, fetchImpl);
1614
+ if (!token)
1615
+ return;
1616
+ let same = false;
1617
+ try {
1618
+ same = new URL(target, baseUrl).origin === new URL(baseUrl).origin;
1619
+ } catch {}
1620
+ return same || signInSource(baseUrl) !== "shared" ? token : undefined;
1621
+ }
1609
1622
  function signInSource(baseUrl) {
1610
1623
  if (process.env.UNBROWSE_API_KEY)
1611
1624
  return "env";
@@ -1715,14 +1728,70 @@ ${url.toString()}`);
1715
1728
  server.close();
1716
1729
  }
1717
1730
  }
1731
+ async function deviceLogin(baseUrl, opts) {
1732
+ const fetchImpl = opts.fetch ?? fetch;
1733
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1734
+ const now = opts.now ?? Date.now;
1735
+ const name = opts.clientName ?? `Unbrowse CLI (${hostname()})`;
1736
+ const redirectUri = `${baseUrl.replace(/\/+$/, "")}/device/done`;
1737
+ const reg = await fetchImpl(`${baseUrl}/oauth/register`, {
1738
+ method: "POST",
1739
+ headers: { "content-type": "application/json", accept: "application/json" },
1740
+ body: JSON.stringify({ client_name: name, redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" })
1741
+ });
1742
+ const client = await reg.json().catch(() => ({}));
1743
+ if (!reg.ok || !client.client_id)
1744
+ throw new Error(`client registration failed: ${client.error_description ?? `HTTP ${reg.status}`}`);
1745
+ const da = await fetchImpl(`${baseUrl}/oauth/device_authorization`, {
1746
+ method: "POST",
1747
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1748
+ body: new URLSearchParams({ client_id: client.client_id, resource: apiResource(baseUrl) }).toString()
1749
+ });
1750
+ const dev = await da.json().catch(() => ({}));
1751
+ if (!da.ok || !dev.device_code || !dev.user_code)
1752
+ throw new Error(`could not start device sign-in: ${dev.error_description ?? dev.error ?? `HTTP ${da.status}`}`);
1753
+ opts.log(`To sign in, open ${dev.verification_uri} on any device and enter the code:
1754
+
1755
+ ${dev.user_code}
1756
+
1757
+ (or open ${dev.verification_uri_complete} to skip typing it). Waiting for approval…`);
1758
+ const deadline = now() + (dev.expires_in ?? 600) * 1000;
1759
+ let interval = Math.max(1, dev.interval ?? 5);
1760
+ while (now() < deadline) {
1761
+ await sleep(interval * 1000);
1762
+ const res = await fetchImpl(`${baseUrl}/oauth/token`, {
1763
+ method: "POST",
1764
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1765
+ body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: dev.device_code, client_id: client.client_id }).toString()
1766
+ });
1767
+ const body = await res.json().catch(() => ({}));
1768
+ if (res.ok && body.access_token) {
1769
+ 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 } });
1770
+ return;
1771
+ }
1772
+ if (body.error === "authorization_pending")
1773
+ continue;
1774
+ if (body.error === "slow_down") {
1775
+ interval += 5;
1776
+ continue;
1777
+ }
1778
+ if (body.error === "access_denied")
1779
+ throw new Error("the sign-in was denied");
1780
+ if (body.error === "expired_token")
1781
+ throw new Error("the code expired before it was approved; run `unbrowse login` again");
1782
+ throw new Error(body.error_description ?? body.error ?? `device sign-in failed (HTTP ${res.status})`);
1783
+ }
1784
+ throw new Error("timed out waiting for approval");
1785
+ }
1718
1786
 
1719
1787
  // src/cli.ts
1720
1788
  init_src();
1721
- var VERSION = "12.3.0";
1789
+ var VERSION = "12.3.1";
1722
1790
  var DEFAULT_ORIGIN = "https://unbrowse.ai";
1723
1791
  var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
1724
1792
 
1725
1793
  login [--key ub_live_…] Sign in in your browser (OAuth), or store an API key
1794
+ [--device] On a remote/headless machine, show a code to approve on your phone
1726
1795
  (first use of any command below signs you in the same way)
1727
1796
  logout Forget the stored sign-in
1728
1797
  whoami Workspace and usage
@@ -1840,9 +1909,10 @@ async function main(argv, io = stdio) {
1840
1909
  const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin, client: `cli/${VERSION}` });
1841
1910
  if (cmd === "mcp" && rest[0] !== "add") {
1842
1911
  await Promise.resolve().then(() => init_mcp_proxy());
1912
+ const url = flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`;
1843
1913
  await serveStdio({
1844
- url: flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`,
1845
- token: async () => await currentToken(origin) ?? undefined,
1914
+ url,
1915
+ token: () => tokenFor(origin, url),
1846
1916
  version: VERSION,
1847
1917
  endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER,
1848
1918
  ...args.flags["from-here"] || process.env.UNBROWSE_EGRESS === "client" ? { fromHere: client } : {}
@@ -1956,6 +2026,8 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
1956
2026
  const key = flag("key");
1957
2027
  if (key)
1958
2028
  save({ baseUrl: origin, apiKey: key });
2029
+ else if (args.flags.device || !io.interactive)
2030
+ await deviceLogin(origin, { log: io.err });
1959
2031
  else
1960
2032
  await oauthLogin(origin, { open: (u) => openLink(u, args, io), log: io.err });
1961
2033
  const me = await (await client()).me();
@@ -2354,9 +2426,11 @@ async function mcpAdd(rest, args, io, origin) {
2354
2426
  }
2355
2427
  }
2356
2428
  const r = exec(shell(plan.add));
2357
- if (r.status === 0)
2429
+ if (r.status === 0) {
2358
2430
  io.err(`✓ ${h}: added "${name}" (stdio, on this CLI's sign-in)`);
2359
- else
2431
+ if (h === "claude" && exec(shell(["claude", "mcp", "get", "plugin:unbrowse:unbrowse"])).status === 0)
2432
+ 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\`.`);
2433
+ } else
2360
2434
  failed++, io.err(`✗ ${h}: ${(r.stderr || r.stdout).trim() || `exit ${r.status}`}`);
2361
2435
  }
2362
2436
  if (!dry) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbrowse",
3
- "version": "12.3.0",
3
+ "version": "12.3.1",
4
4
  "mcpName": "io.github.unbrowse-ai/unbrowse",
5
5
  "description": "Call websites as APIs: a thin CLI over the Unbrowse API.",
6
6
  "license": "MIT",