unbrowse 12.2.1-preview.0 → 12.3.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/SKILL.md +9 -1
- package/dist/cli.js +477 -46
- package/package.json +1 -1
- package/references/tools.json +59 -0
package/SKILL.md
CHANGED
|
@@ -63,7 +63,7 @@ Hosts that need a stdio server or reject dotted tool names can run `npx unbrowse
|
|
|
63
63
|
- CLI: `unbrowse discover`, `run`, `inspect`, `resume`, `scrape <url>` (one page as markdown), `index <url>` (teach a site; `index status`), `registry`, `site`, `whoami`. `scrape` and `index` need CLI 12.2.0 or later. `unbrowse run` never drives a website in a browser (the only browser it opens is the first-use sign-in). There is no `unbrowse browse` and no `unbrowse install`. `unbrowse help` describes the installed version.
|
|
64
64
|
- SDK: REST integration and scripting. Consult the public SDK docs for its supported methods; MCP tools and REST methods are not interchangeable names.
|
|
65
65
|
|
|
66
|
-
Core MCP tools: `unbrowse_discover`, `unbrowse_run`, `unbrowse_inspect`, `unbrowse_resume`, `unbrowse_cancel`, `unbrowse_scrape`, `unbrowse_map`, `unbrowse_sites`, `unbrowse_usage`, `unbrowse_credits`, `unbrowse_forget`, `unbrowse_learn`, `unbrowse_index`, `unbrowse_index_status`, `unbrowse_credentials_list`, `unbrowse_credentials_request`, `unbrowse_credentials_status`, `unbrowse_connect` (connect an app's own MCP server, such as Linear, Notion or GitHub, so its tools run as the person: an app with sign-in returns a link the person opens), and the cloud browser `unbrowse_browse_open`, `unbrowse_browse_snapshot`, `unbrowse_browse_act`, `unbrowse_browse_finish`, `unbrowse_browse_close`.
|
|
66
|
+
Core MCP tools: `unbrowse_discover`, `unbrowse_run`, `unbrowse_inspect`, `unbrowse_resume`, `unbrowse_cancel`, `unbrowse_scrape`, `unbrowse_map`, `unbrowse_sites`, `unbrowse_usage`, `unbrowse_credits`, `unbrowse_forget`, `unbrowse_learn`, `unbrowse_index`, `unbrowse_index_status`, `unbrowse_credentials_list`, `unbrowse_credentials_request`, `unbrowse_credentials_status`, `unbrowse_connect` (connect an app's own MCP server, such as Linear, Notion or GitHub, so its tools run as the person: an app with sign-in returns a link the person opens), `unbrowse_import_mcps` (bring the MCP servers this agent already has into Unbrowse, below), and the cloud browser `unbrowse_browse_open`, `unbrowse_browse_snapshot`, `unbrowse_browse_act`, `unbrowse_browse_finish`, `unbrowse_browse_close`.
|
|
67
67
|
|
|
68
68
|
[references/tools.json](references/tools.json) contains the exported core MCP input schemas. The connected server's `tools/list` is authoritative: it also includes dynamic tools available to this user's workspace. Never invent a capability ID or input schema.
|
|
69
69
|
|
|
@@ -112,6 +112,14 @@ To cover a whole site ahead of need, `unbrowse_index {url, focus?, maxCapabiliti
|
|
|
112
112
|
|
|
113
113
|
An existing HAR pair can be sent through `unbrowse_learn`. Only submit recordings the user authorized; HARs can contain private data. Private and loopback destinations are refused by the hosted service.
|
|
114
114
|
|
|
115
|
+
## Bring the agent's MCP servers
|
|
116
|
+
|
|
117
|
+
When the person wants their existing MCP servers in Unbrowse (one sign-in, every agent gets them), import them instead of re-adding each by hand.
|
|
118
|
+
|
|
119
|
+
- From a terminal: `npx unbrowse mcps` lists the servers in this machine's agent configs (Claude Code, Claude Desktop, Cursor, Codex, VS Code, Windsurf, Gemini, Zed); `npx unbrowse mcps import` imports them (`--dry-run` shows the plan). Keys in those configs stay on the machine unless the person adds `--with-keys`.
|
|
120
|
+
- As an MCP tool: read your host's MCP config yourself and call `unbrowse_import_mcps {servers:[{name, url} | {name, command, args}]}`. Never pass keys, headers or env values; `dryRun: true` shows the plan.
|
|
121
|
+
- Remote servers connect by address. A local (stdio) server cannot run in Unbrowse's cloud: when the catalog has the same app hosted, that is connected instead; otherwise it is `local_only` and stays in the agent. `sign_in` and `key` rows carry the link the person opens. Show them the plan before importing.
|
|
122
|
+
|
|
115
123
|
## Website sign-in
|
|
116
124
|
|
|
117
125
|
Unbrowse account sign-in and a website's saved login are separate. Never ask for passwords in chat or type credentials via ordinary tool arguments.
|
package/dist/cli.js
CHANGED
|
@@ -25,6 +25,9 @@ var __esm = (fn, res, err) => () => {
|
|
|
25
25
|
return res;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
// ../sdk/src/version.ts
|
|
29
|
+
var SDK_VERSION = "12.3.0";
|
|
30
|
+
|
|
28
31
|
// ../sdk/src/client.ts
|
|
29
32
|
function apiBase(url) {
|
|
30
33
|
const u = url.replace(/\/+$/, "");
|
|
@@ -35,10 +38,12 @@ class Unbrowse {
|
|
|
35
38
|
baseUrl;
|
|
36
39
|
apiKey;
|
|
37
40
|
fetchImpl;
|
|
41
|
+
client;
|
|
38
42
|
constructor(opts = {}) {
|
|
39
43
|
this.baseUrl = apiBase(opts.baseUrl ?? env("UNBROWSE_BASE_URL") ?? DEFAULT_BASE_URL);
|
|
40
44
|
this.apiKey = opts.apiKey ?? env("UNBROWSE_API_KEY");
|
|
41
45
|
this.fetchImpl = opts.fetch ?? ((...a) => globalThis.fetch(...a));
|
|
46
|
+
this.client = (opts.client ?? `sdk/${SDK_VERSION}`).replace(/[^A-Za-z0-9._/-]/g, "").slice(0, 64);
|
|
42
47
|
}
|
|
43
48
|
async send(path, init) {
|
|
44
49
|
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
@@ -46,6 +51,7 @@ class Unbrowse {
|
|
|
46
51
|
headers: {
|
|
47
52
|
...this.apiKey ? { authorization: `Bearer ${this.apiKey}` } : {},
|
|
48
53
|
"content-type": "application/json",
|
|
54
|
+
...this.client ? { "x-unbrowse-client": this.client } : {},
|
|
49
55
|
...init?.headers ?? {}
|
|
50
56
|
}
|
|
51
57
|
});
|
|
@@ -158,7 +164,15 @@ class Unbrowse {
|
|
|
158
164
|
if (finished)
|
|
159
165
|
return;
|
|
160
166
|
try {
|
|
161
|
-
|
|
167
|
+
let next;
|
|
168
|
+
try {
|
|
169
|
+
next = await this.answerEgress(egressId, req.id, answer);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (!(err instanceof UnbrowseError && err.status === 413) || !("response" in answer))
|
|
172
|
+
throw err;
|
|
173
|
+
const bytes = Math.round((answer.response.body?.length ?? 0) * 0.75);
|
|
174
|
+
next = await this.answerEgress(egressId, req.id, { error: `the site's response (${bytes} bytes) is too large to relay from this machine; run it from Unbrowse instead (CLI: --from-unbrowse)` });
|
|
175
|
+
}
|
|
162
176
|
if (isEgressStep(next) || isEgressWaiting(next))
|
|
163
177
|
latest = next;
|
|
164
178
|
else
|
|
@@ -232,6 +246,13 @@ class Unbrowse {
|
|
|
232
246
|
connect: (a) => this.post("/accounts/connections", a),
|
|
233
247
|
register: (a) => this.post("/accounts/register", a)
|
|
234
248
|
};
|
|
249
|
+
connectors = {
|
|
250
|
+
list: () => this.req("/connectors"),
|
|
251
|
+
apps: (q = "", limit = 20) => this.req(`/connectors/apps?q=${encodeURIComponent(q)}&limit=${limit}`),
|
|
252
|
+
connect: (c) => this.post("/connectors/connect", c),
|
|
253
|
+
import: (servers, opts = {}) => this.post("/connectors/import", { servers, ...opts.dryRun ? { dryRun: true } : {} }),
|
|
254
|
+
disconnect: (app) => this.post("/connectors/disconnect", { app })
|
|
255
|
+
};
|
|
235
256
|
vault() {
|
|
236
257
|
return this.req("/vault");
|
|
237
258
|
}
|
|
@@ -296,7 +317,10 @@ function isEgressStep(v) {
|
|
|
296
317
|
return !!v && typeof v === "object" && v.status === "egress_required";
|
|
297
318
|
}
|
|
298
319
|
function isEgressWaiting(v) {
|
|
299
|
-
|
|
320
|
+
if (!v || typeof v !== "object")
|
|
321
|
+
return false;
|
|
322
|
+
const o = v;
|
|
323
|
+
return typeof o.egressId === "string" && o.runId === undefined && o.status !== "egress_required";
|
|
300
324
|
}
|
|
301
325
|
async function sendEgress(send, req) {
|
|
302
326
|
const body = req.body === undefined ? undefined : req.bodyEncoding === "base64" ? base64ToBytes(req.body) : req.body;
|
|
@@ -418,17 +442,208 @@ var init_src = __esm(() => {
|
|
|
418
442
|
init_mcp();
|
|
419
443
|
});
|
|
420
444
|
|
|
445
|
+
// src/agent-mcps.ts
|
|
446
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
447
|
+
import { homedir as homedir2 } from "node:os";
|
|
448
|
+
import { join as join2 } from "node:path";
|
|
449
|
+
function parseJsonc(text) {
|
|
450
|
+
let out = "";
|
|
451
|
+
for (let i = 0;i < text.length; i++) {
|
|
452
|
+
const c = text[i];
|
|
453
|
+
if (c === '"') {
|
|
454
|
+
let j = i + 1;
|
|
455
|
+
while (j < text.length && text[j] !== '"')
|
|
456
|
+
j += text[j] === "\\" ? 2 : 1;
|
|
457
|
+
out += text.slice(i, j + 1);
|
|
458
|
+
i = j;
|
|
459
|
+
} else if (c === "/" && text[i + 1] === "/") {
|
|
460
|
+
while (i < text.length && text[i] !== `
|
|
461
|
+
`)
|
|
462
|
+
i++;
|
|
463
|
+
out += `
|
|
464
|
+
`;
|
|
465
|
+
} else if (c === "/" && text[i + 1] === "*") {
|
|
466
|
+
i = text.indexOf("*/", i + 2);
|
|
467
|
+
if (i < 0)
|
|
468
|
+
break;
|
|
469
|
+
i++;
|
|
470
|
+
} else
|
|
471
|
+
out += c;
|
|
472
|
+
}
|
|
473
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
474
|
+
}
|
|
475
|
+
function parseCodexToml(text) {
|
|
476
|
+
const servers = {};
|
|
477
|
+
let current;
|
|
478
|
+
let sub;
|
|
479
|
+
const value = (v) => {
|
|
480
|
+
v = v.trim();
|
|
481
|
+
if (/^".*"$/.test(v) || /^'.*'$/.test(v))
|
|
482
|
+
return v.slice(1, -1).replace(/\\"/g, '"');
|
|
483
|
+
if (v.startsWith("["))
|
|
484
|
+
return [...v.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
|
|
485
|
+
if (v.startsWith("{"))
|
|
486
|
+
return Object.fromEntries([...v.matchAll(/([\w.-]+|"[^"]+")\s*=\s*("(?:[^"\\]|\\.)*"|'[^']*')/g)].map((m) => [m[1].replace(/"/g, ""), m[2].slice(1, -1)]));
|
|
487
|
+
if (v === "true" || v === "false")
|
|
488
|
+
return v === "true";
|
|
489
|
+
return v;
|
|
490
|
+
};
|
|
491
|
+
for (const line of text.split(/\r?\n/)) {
|
|
492
|
+
const t = line.replace(/\s+#.*$/, "").trim();
|
|
493
|
+
if (!t || t.startsWith("#"))
|
|
494
|
+
continue;
|
|
495
|
+
const table = /^\[mcp_servers\.("?)([^\]".]+)\1(?:\.([\w-]+))?\]$/.exec(t);
|
|
496
|
+
if (table) {
|
|
497
|
+
current = servers[table[2]] ??= {};
|
|
498
|
+
sub = table[3];
|
|
499
|
+
if (sub)
|
|
500
|
+
current[sub] ??= {};
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (t.startsWith("[")) {
|
|
504
|
+
current = undefined;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const kv = /^([\w.-]+|"[^"]+")\s*=\s*(.+)$/.exec(t);
|
|
508
|
+
if (!kv || !current)
|
|
509
|
+
continue;
|
|
510
|
+
const key = kv[1].replace(/"/g, "");
|
|
511
|
+
if (sub)
|
|
512
|
+
current[sub][key] = value(kv[2]);
|
|
513
|
+
else
|
|
514
|
+
current[key] = value(kv[2]);
|
|
515
|
+
}
|
|
516
|
+
return servers;
|
|
517
|
+
}
|
|
518
|
+
function places(home = process.env.HOME || process.env.USERPROFILE || homedir2(), cwd = process.cwd(), platform = process.platform, env = process.env) {
|
|
519
|
+
const appData = env.APPDATA ?? join2(home, "AppData", "Roaming");
|
|
520
|
+
const desktop = platform === "darwin" ? join2(home, "Library", "Application Support", "Claude") : platform === "win32" ? join2(appData, "Claude") : join2(env.XDG_CONFIG_HOME ?? join2(home, ".config"), "Claude");
|
|
521
|
+
const code = platform === "darwin" ? join2(home, "Library", "Application Support", "Code", "User") : platform === "win32" ? join2(appData, "Code", "User") : join2(env.XDG_CONFIG_HOME ?? join2(home, ".config"), "Code", "User");
|
|
522
|
+
const zed = platform === "darwin" || platform === "linux" ? join2(env.XDG_CONFIG_HOME ?? join2(home, ".config"), "zed", "settings.json") : join2(appData, "Zed", "settings.json");
|
|
523
|
+
const mcpServers = (t) => asObj(parseJsonc(t).mcpServers);
|
|
524
|
+
return [
|
|
525
|
+
{ host: "claude-code", file: join2(home, ".claude.json"), read: (t) => {
|
|
526
|
+
const j = parseJsonc(t);
|
|
527
|
+
const projects = asObj(j.projects);
|
|
528
|
+
return { ...asObj(projects[cwd]?.mcpServers), ...asObj(j.mcpServers) };
|
|
529
|
+
} },
|
|
530
|
+
{ host: "claude-code", file: join2(cwd, ".mcp.json"), read: mcpServers },
|
|
531
|
+
{ host: "claude-desktop", file: join2(desktop, "claude_desktop_config.json"), read: mcpServers },
|
|
532
|
+
{ host: "cursor", file: join2(home, ".cursor", "mcp.json"), read: mcpServers },
|
|
533
|
+
{ host: "cursor", file: join2(cwd, ".cursor", "mcp.json"), read: mcpServers },
|
|
534
|
+
{ host: "codex", file: join2(env.CODEX_HOME ?? join2(home, ".codex"), "config.toml"), read: parseCodexToml },
|
|
535
|
+
{ host: "vscode", file: join2(code, "mcp.json"), read: (t) => asObj(parseJsonc(t).servers) },
|
|
536
|
+
{ host: "vscode", file: join2(cwd, ".vscode", "mcp.json"), read: (t) => asObj(parseJsonc(t).servers) },
|
|
537
|
+
{ host: "windsurf", file: join2(home, ".codeium", "windsurf", "mcp_config.json"), read: mcpServers },
|
|
538
|
+
{ host: "gemini", file: join2(home, ".gemini", "settings.json"), read: mcpServers },
|
|
539
|
+
{ host: "zed", file: zed, read: (t) => asObj(parseJsonc(t).context_servers) }
|
|
540
|
+
];
|
|
541
|
+
}
|
|
542
|
+
function normalize(host, file, name, raw) {
|
|
543
|
+
const zedCommand = asObj(raw.command);
|
|
544
|
+
const command = str(raw.command) ?? str(zedCommand.path);
|
|
545
|
+
const args = (Array.isArray(raw.args) ? raw.args : Array.isArray(zedCommand.args) ? zedCommand.args : []).filter((a) => typeof a === "string").map((a) => secretish(a) && !/^https:\/\/[^\s?#=]+$/.test(a) ? HIDDEN : a);
|
|
546
|
+
const url = str(raw.url) ?? str(raw.serverUrl) ?? str(raw.httpUrl);
|
|
547
|
+
const type = String(raw.type ?? raw.transport ?? "").toLowerCase();
|
|
548
|
+
const transport = url ? type === "sse" || !type && /\/sse\/?$/.test(url) ? "sse" : "streamable-http" : undefined;
|
|
549
|
+
const headers = { ...asObj(raw.headers), ...asObj(raw.http_headers) };
|
|
550
|
+
const env = { ...asObj(raw.env), ...asObj(zedCommand.env) };
|
|
551
|
+
const self = !!url && isUnbrowse(url) || /unbrowse/.test(`${command ?? ""} ${args.join(" ")}`) && args.includes("mcp");
|
|
552
|
+
return { host, file, name, ...url ? { url, transport } : {}, ...command ? { command, args } : {}, headerNames: Object.keys(headers), envNames: Object.keys(env), self };
|
|
553
|
+
}
|
|
554
|
+
function scan(opts = {}) {
|
|
555
|
+
const servers = [];
|
|
556
|
+
const skipped = [];
|
|
557
|
+
const seenFiles = new Set;
|
|
558
|
+
for (const p of places(opts.home, opts.cwd, opts.platform, opts.env)) {
|
|
559
|
+
if (seenFiles.has(p.file) || !existsSync2(p.file))
|
|
560
|
+
continue;
|
|
561
|
+
seenFiles.add(p.file);
|
|
562
|
+
try {
|
|
563
|
+
for (const [name, raw] of Object.entries(p.read(readFileSync2(p.file, "utf8")))) {
|
|
564
|
+
if (raw && typeof raw === "object" && raw.disabled !== true && raw.enabled !== false)
|
|
565
|
+
servers.push(normalize(p.host, p.file, name, raw));
|
|
566
|
+
}
|
|
567
|
+
} catch (e) {
|
|
568
|
+
skipped.push({ file: p.file, why: e.message.slice(0, 120) });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return { servers, skipped };
|
|
572
|
+
}
|
|
573
|
+
function toImport(f) {
|
|
574
|
+
if (f.url)
|
|
575
|
+
return { name: f.name, url: f.url, ...f.transport ? { transport: f.transport } : {} };
|
|
576
|
+
const args = (f.args ?? []).filter((a) => a !== HIDDEN && (/^https:\/\/[^\s?#]+$/.test(a) || !a.startsWith("-") && !secretish(a) && /^[@\w][\w@./:-]*$/.test(a) && /mcp|server|remote|gateway/i.test(a)));
|
|
577
|
+
return { name: f.name, ...f.command ? { command: f.command.split(/[\\/]/).pop() } : {}, ...args.length ? { args } : {} };
|
|
578
|
+
}
|
|
579
|
+
function keyOf(f, raw) {
|
|
580
|
+
if (!f.url)
|
|
581
|
+
return;
|
|
582
|
+
const headers = { ...asObj(raw.headers), ...asObj(raw.http_headers) };
|
|
583
|
+
for (const [h, v] of Object.entries(headers)) {
|
|
584
|
+
if (typeof v !== "string" || !v.trim() || /\$\{|\$[A-Z_]/.test(v))
|
|
585
|
+
continue;
|
|
586
|
+
if (/^(authorization|x-api-key|api-key|x-auth-token)$/i.test(h))
|
|
587
|
+
return { header: h.toLowerCase(), key: v.trim() };
|
|
588
|
+
}
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
function rawEntry(f, opts = {}) {
|
|
592
|
+
const p = places(opts.home, opts.cwd, opts.platform, opts.env).find((x) => x.file === f.file && x.host === f.host);
|
|
593
|
+
try {
|
|
594
|
+
return p ? p.read(readFileSync2(f.file, "utf8"))[f.name] : undefined;
|
|
595
|
+
} catch {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function unique(servers) {
|
|
600
|
+
const seen = new Set;
|
|
601
|
+
return servers.filter((s) => {
|
|
602
|
+
const k = s.url ? `u:${s.url.replace(/\/+$/, "")}` : `c:${s.command} ${(s.args ?? []).join(" ")}`;
|
|
603
|
+
if (seen.has(k))
|
|
604
|
+
return false;
|
|
605
|
+
seen.add(k);
|
|
606
|
+
return true;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
var asObj = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : {}, str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined, secretish = (a) => /[=]/.test(a) || /(token|secret|key|password|bearer)/i.test(a) || /[A-Za-z0-9_\-]{32,}/.test(a) || /^(sk|ghp|gho|xox[abp]|ub_live|AKIA)[-_A-Za-z0-9]/.test(a), HIDDEN = "‹hidden›", isUnbrowse = (url) => /^https?:\/\/((www|v3|stage|api)\.)?unbrowse\.ai(\/|$)/i.test(url) || /^https?:\/\/[a-z0-9-]+-mcp\.unbrowse\.ai(\/|$)/i.test(url);
|
|
610
|
+
var init_agent_mcps = () => {};
|
|
611
|
+
|
|
421
612
|
// src/mcp-proxy.ts
|
|
422
613
|
import { createInterface } from "node:readline";
|
|
423
614
|
function safeToolName(name) {
|
|
424
615
|
return name.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
|
|
425
616
|
}
|
|
426
|
-
function
|
|
617
|
+
async function localMcps(args, upstream) {
|
|
618
|
+
const A = await Promise.resolve().then(() => (init_agent_mcps(), {}));
|
|
619
|
+
const { servers, skipped } = scan();
|
|
620
|
+
const names = Array.isArray(args.names) ? new Set(args.names.map(String)) : undefined;
|
|
621
|
+
const pick = servers.filter((s) => !names || names.has(s.name));
|
|
622
|
+
const listing = pick.map(({ file: _f, ...s }) => s);
|
|
623
|
+
if (args.import !== true) {
|
|
624
|
+
return { content: [{ type: "text", text: JSON.stringify({ servers: listing, skipped: skipped.map((x) => x.why) }, null, 2) }], structuredContent: { servers: listing }, isError: false };
|
|
625
|
+
}
|
|
626
|
+
const send = unique(pick.filter((s) => !s.self)).map(toImport);
|
|
627
|
+
if (!send.length)
|
|
628
|
+
return { content: [{ type: "text", text: "No MCP servers to import." }], isError: false };
|
|
629
|
+
return (await upstream()).rpc("tools/call", { name: "unbrowse_import_mcps", arguments: { servers: send, ...args.dryRun === true ? { dryRun: true } : {} } });
|
|
630
|
+
}
|
|
631
|
+
function localRun(rest, log = (l) => void process.stderr.write(`${l}
|
|
632
|
+
`)) {
|
|
633
|
+
return async (args) => {
|
|
634
|
+
const request = Object.fromEntries(RUN_KEYS.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
|
|
635
|
+
const deadline = Number(args.deadlineMs);
|
|
636
|
+
const view = await (await rest()).runOnClient(request, { onRequest: (r) => log(`→ ${r.method} ${r.url}`), timeoutMs: Math.max(120000, Number.isFinite(deadline) ? deadline : 0) });
|
|
637
|
+
const text = JSON.stringify(view);
|
|
638
|
+
return { content: [{ type: "text", text: text.length > RESULT_MAX ? `${text.slice(0, RESULT_MAX)}…` : text }], structuredContent: view, isError: false };
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
function createProxy(upstream, version, opts = {}) {
|
|
427
642
|
const names = new Map;
|
|
428
643
|
async function list() {
|
|
429
644
|
const tools = await (await upstream()).listTools();
|
|
430
645
|
names.clear();
|
|
431
|
-
const out = [];
|
|
646
|
+
const out = [LOCAL_MCPS];
|
|
432
647
|
for (const t of tools) {
|
|
433
648
|
const safe = safeToolName(t.name);
|
|
434
649
|
if (names.has(safe))
|
|
@@ -464,6 +679,10 @@ function createProxy(upstream, version) {
|
|
|
464
679
|
return reply({ tools: await list() });
|
|
465
680
|
case "tools/call": {
|
|
466
681
|
const name = String(msg.params?.name ?? "");
|
|
682
|
+
if (name === LOCAL_MCPS.name)
|
|
683
|
+
return reply(await localMcps(msg.params?.arguments ?? {}, upstream));
|
|
684
|
+
if (opts.runLocal && await original(name) === "unbrowse_run")
|
|
685
|
+
return reply(await opts.runLocal(msg.params?.arguments ?? {}));
|
|
467
686
|
return reply(await (await upstream()).rpc("tools/call", { ...msg.params, name: await original(name) }));
|
|
468
687
|
}
|
|
469
688
|
default:
|
|
@@ -478,7 +697,7 @@ function createProxy(upstream, version) {
|
|
|
478
697
|
};
|
|
479
698
|
}
|
|
480
699
|
async function serveStdio(opts) {
|
|
481
|
-
const handle = createProxy(async () => new UnbrowseMcp({ url: opts.url, apiKey: await opts.token() ?? "", endUser: opts.endUser, client: `cli-mcp/${opts.version}` }), opts.version);
|
|
700
|
+
const handle = createProxy(async () => new UnbrowseMcp({ url: opts.url, apiKey: await opts.token() ?? "", endUser: opts.endUser, client: `cli-mcp/${opts.version}` }), opts.version, opts.fromHere ? { runLocal: localRun(opts.fromHere) } : {});
|
|
482
701
|
const write = (m) => new Promise((resolve) => process.stdout.write(`${JSON.stringify(m)}
|
|
483
702
|
`, () => resolve()));
|
|
484
703
|
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
@@ -499,10 +718,24 @@ async function serveStdio(opts) {
|
|
|
499
718
|
}
|
|
500
719
|
await Promise.all(pending);
|
|
501
720
|
}
|
|
502
|
-
var PROTOCOLS;
|
|
721
|
+
var PROTOCOLS, LOCAL_MCPS, RUN_KEYS, RESULT_MAX = 40000;
|
|
503
722
|
var init_mcp_proxy = __esm(() => {
|
|
504
723
|
init_src();
|
|
505
724
|
PROTOCOLS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
|
|
725
|
+
LOCAL_MCPS = {
|
|
726
|
+
name: "unbrowse_local_mcps",
|
|
727
|
+
description: "The MCP servers configured in this machine's agents (Claude Code, Claude Desktop, Cursor, Codex, VS Code, Windsurf, Gemini, Zed), read by this local Unbrowse server: name, agent, remote address or local command, and the NAMES of any headers and env vars (never their values). import: true brings them into Unbrowse through unbrowse_import_mcps (remote ones by address, local ones as their hosted app; Unbrowse itself and duplicates skipped); dryRun: true shows that plan. Keys stay on this machine; for a server that needs one, the person opens the returned link, or runs `unbrowse mcps import --with-keys` in a terminal.",
|
|
728
|
+
inputSchema: {
|
|
729
|
+
type: "object",
|
|
730
|
+
properties: {
|
|
731
|
+
import: { type: "boolean", description: "Import them into Unbrowse" },
|
|
732
|
+
dryRun: { type: "boolean", description: "With import: show the plan without connecting anything" },
|
|
733
|
+
names: { type: "array", items: { type: "string" }, description: "Only these servers (by their config name)" }
|
|
734
|
+
},
|
|
735
|
+
additionalProperties: false
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
RUN_KEYS = ["capability", "task", "targetUrl", "input", "accountRefs", "interactionMode", "idempotencyKey", "allowedDestinations"];
|
|
506
739
|
});
|
|
507
740
|
|
|
508
741
|
// src/cookie-daemon.ts
|
|
@@ -532,9 +765,9 @@ __export(exports_cookie_daemon, {
|
|
|
532
765
|
writeJson: () => writeJson
|
|
533
766
|
});
|
|
534
767
|
import { createHash as createHash2 } from "node:crypto";
|
|
535
|
-
import { existsSync as
|
|
536
|
-
import { homedir as
|
|
537
|
-
import { dirname as dirname2, join as
|
|
768
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
769
|
+
import { homedir as homedir3, platform } from "node:os";
|
|
770
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
538
771
|
function siteOf(domain) {
|
|
539
772
|
const labels = domain.replace(/^\./, "").toLowerCase().split(".");
|
|
540
773
|
if (labels.length <= 2)
|
|
@@ -547,7 +780,7 @@ function configDir(cliConfigPath) {
|
|
|
547
780
|
}
|
|
548
781
|
function readJson(path, fallback) {
|
|
549
782
|
try {
|
|
550
|
-
return
|
|
783
|
+
return existsSync3(path) ? JSON.parse(readFileSync3(path, "utf8")) : fallback;
|
|
551
784
|
} catch {
|
|
552
785
|
return fallback;
|
|
553
786
|
}
|
|
@@ -691,10 +924,10 @@ async function watch(opts) {
|
|
|
691
924
|
}
|
|
692
925
|
function servicePlan(opts) {
|
|
693
926
|
const os = opts.os ?? platform();
|
|
694
|
-
const home = opts.home ??
|
|
927
|
+
const home = opts.home ?? homedir3();
|
|
695
928
|
const env = Object.entries(opts.env ?? {});
|
|
696
929
|
if (os === "linux") {
|
|
697
|
-
const path =
|
|
930
|
+
const path = join3(home, ".config", "systemd", "user", `${SERVICE}.service`);
|
|
698
931
|
const content = [
|
|
699
932
|
"[Unit]",
|
|
700
933
|
"Description=Unbrowse cookie sync: keeps your browser sign-ins for chosen sites in sync with Unbrowse",
|
|
@@ -722,8 +955,8 @@ function servicePlan(opts) {
|
|
|
722
955
|
};
|
|
723
956
|
}
|
|
724
957
|
if (os === "darwin") {
|
|
725
|
-
const path =
|
|
726
|
-
const logPath =
|
|
958
|
+
const path = join3(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
959
|
+
const logPath = join3(home, "Library", "Logs", `${SERVICE}.log`);
|
|
727
960
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
728
961
|
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
729
962
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -761,7 +994,7 @@ function quoteSystemd(s) {
|
|
|
761
994
|
function ephemeralCli(cli) {
|
|
762
995
|
return /[\\/](_npx|\.npm[\\/]_npx|npx-[^\\/]+|tmp|Temp)[\\/]/i.test(cli);
|
|
763
996
|
}
|
|
764
|
-
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) =>
|
|
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");
|
|
765
998
|
var init_cookie_daemon = __esm(() => {
|
|
766
999
|
TWO_LEVEL = /^(co|com|net|org|gov|edu|ac|or|ne|go)\.[a-z]{2}$/;
|
|
767
1000
|
REFRESH_MS = 24 * 60 * 60000;
|
|
@@ -985,18 +1218,18 @@ var HEADER = 100;
|
|
|
985
1218
|
// src/cookies.ts
|
|
986
1219
|
import { createDecipheriv, createHash as createHash3, pbkdf2Sync } from "node:crypto";
|
|
987
1220
|
import { execFileSync } from "node:child_process";
|
|
988
|
-
import { existsSync as
|
|
989
|
-
import { homedir as
|
|
990
|
-
import { join as
|
|
1221
|
+
import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync4 } from "node:fs";
|
|
1222
|
+
import { homedir as homedir4, platform as platform2 } from "node:os";
|
|
1223
|
+
import { join as join4 } from "node:path";
|
|
991
1224
|
function chromium(name, [mac, linux], keychain) {
|
|
992
1225
|
return {
|
|
993
1226
|
name,
|
|
994
1227
|
engine: "chromium",
|
|
995
1228
|
keychain: `${keychain} Safe Storage`,
|
|
996
1229
|
dirs: (h) => ({
|
|
997
|
-
darwin: [
|
|
998
|
-
linux: [
|
|
999
|
-
win32: [
|
|
1230
|
+
darwin: [join4(h, "Library/Application Support", mac)],
|
|
1231
|
+
linux: [join4(h, ".config", linux), join4(h, ".var/app", flatpakId(name), "config", linux)],
|
|
1232
|
+
win32: [join4(process.env.LOCALAPPDATA ?? join4(h, "AppData/Local"), mac.replace(/\//g, "\\"), "User Data")]
|
|
1000
1233
|
})
|
|
1001
1234
|
};
|
|
1002
1235
|
}
|
|
@@ -1005,9 +1238,9 @@ function firefox(name, [mac, linux, linuxHome]) {
|
|
|
1005
1238
|
name,
|
|
1006
1239
|
engine: "firefox",
|
|
1007
1240
|
dirs: (h) => ({
|
|
1008
|
-
darwin: [
|
|
1009
|
-
linux: [
|
|
1010
|
-
win32: [
|
|
1241
|
+
darwin: [join4(h, "Library/Application Support", mac)],
|
|
1242
|
+
linux: [join4(h, ".config", linux), join4(h, linuxHome ?? `.${name.toLowerCase()}`)],
|
|
1243
|
+
win32: [join4(process.env.APPDATA ?? join4(h, "AppData/Roaming"), mac)]
|
|
1011
1244
|
})
|
|
1012
1245
|
};
|
|
1013
1246
|
}
|
|
@@ -1019,7 +1252,7 @@ function findProfiles() {
|
|
|
1019
1252
|
const raw = [];
|
|
1020
1253
|
for (const def of BROWSERS) {
|
|
1021
1254
|
for (const base of def.dirs(home())[OS] ?? []) {
|
|
1022
|
-
if (!
|
|
1255
|
+
if (!existsSync4(base))
|
|
1023
1256
|
continue;
|
|
1024
1257
|
raw.push(...def.engine === "chromium" ? chromiumProfiles(def, base) : firefoxProfiles(def, base));
|
|
1025
1258
|
}
|
|
@@ -1040,7 +1273,7 @@ function chromiumProfiles(def, base) {
|
|
|
1040
1273
|
const out = [];
|
|
1041
1274
|
let names = [];
|
|
1042
1275
|
try {
|
|
1043
|
-
const local = JSON.parse(
|
|
1276
|
+
const local = JSON.parse(readFileSync4(join4(base, "Local State"), "utf8"));
|
|
1044
1277
|
names = Object.keys(local.profile?.info_cache ?? {});
|
|
1045
1278
|
} catch {
|
|
1046
1279
|
names = ["Default"];
|
|
@@ -1048,8 +1281,8 @@ function chromiumProfiles(def, base) {
|
|
|
1048
1281
|
if (!names.length)
|
|
1049
1282
|
names = ["Default"];
|
|
1050
1283
|
for (const dir of names) {
|
|
1051
|
-
const path =
|
|
1052
|
-
if (
|
|
1284
|
+
const path = join4(base, dir);
|
|
1285
|
+
if (existsSync4(join4(path, "Cookies")) || existsSync4(join4(path, "Network", "Cookies"))) {
|
|
1053
1286
|
out.push({ browser: def.name, profile: dir, label: `${def.name} · ${dir}`, path, engine: "chromium" });
|
|
1054
1287
|
}
|
|
1055
1288
|
}
|
|
@@ -1059,14 +1292,14 @@ function firefoxProfiles(def, base) {
|
|
|
1059
1292
|
const out = [];
|
|
1060
1293
|
let dirs = [];
|
|
1061
1294
|
try {
|
|
1062
|
-
const ini =
|
|
1295
|
+
const ini = readFileSync4(join4(base, "profiles.ini"), "utf8");
|
|
1063
1296
|
dirs = [...ini.matchAll(/^Path=(.+)$/gm)].map((m) => m[1].trim());
|
|
1064
1297
|
} catch {
|
|
1065
1298
|
dirs = safeReaddir(base).filter((d) => /\.default|\.dev-edition/.test(d));
|
|
1066
1299
|
}
|
|
1067
1300
|
for (const rel of dirs) {
|
|
1068
|
-
const path =
|
|
1069
|
-
if (
|
|
1301
|
+
const path = join4(base, rel);
|
|
1302
|
+
if (existsSync4(join4(path, "cookies.sqlite")))
|
|
1070
1303
|
out.push({ browser: def.name, profile: rel, label: `${def.name} · ${rel}`, path, engine: "firefox" });
|
|
1071
1304
|
}
|
|
1072
1305
|
return out;
|
|
@@ -1082,7 +1315,7 @@ function readCookies(profile, opts = {}) {
|
|
|
1082
1315
|
return profile.engine === "chromium" ? chromiumCookies(profile, opts) : firefoxCookies(profile, opts);
|
|
1083
1316
|
}
|
|
1084
1317
|
function loadDb(path) {
|
|
1085
|
-
return new Uint8Array(
|
|
1318
|
+
return new Uint8Array(readFileSync4(path));
|
|
1086
1319
|
}
|
|
1087
1320
|
function domainMatch(host, want) {
|
|
1088
1321
|
if (!want)
|
|
@@ -1092,7 +1325,7 @@ function domainMatch(host, want) {
|
|
|
1092
1325
|
return h === w || h.endsWith(`.${w}`);
|
|
1093
1326
|
}
|
|
1094
1327
|
function chromiumCookies(profile, opts) {
|
|
1095
|
-
const dbPath =
|
|
1328
|
+
const dbPath = existsSync4(join4(profile.path, "Network", "Cookies")) ? join4(profile.path, "Network", "Cookies") : join4(profile.path, "Cookies");
|
|
1096
1329
|
const rows = readTable(loadDb(dbPath), "cookies");
|
|
1097
1330
|
const decryptor = chromiumDecryptor(profile.browser, rows);
|
|
1098
1331
|
const out = [];
|
|
@@ -1118,7 +1351,7 @@ function chromiumCookies(profile, opts) {
|
|
|
1118
1351
|
return out;
|
|
1119
1352
|
}
|
|
1120
1353
|
function firefoxCookies(profile, opts) {
|
|
1121
|
-
const rows = readTable(loadDb(
|
|
1354
|
+
const rows = readTable(loadDb(join4(profile.path, "cookies.sqlite")), "moz_cookies");
|
|
1122
1355
|
const out = [];
|
|
1123
1356
|
for (const r of rows) {
|
|
1124
1357
|
const host = String(r.host ?? "");
|
|
@@ -1259,7 +1492,7 @@ function keychainName(browser) {
|
|
|
1259
1492
|
function windowsKey(_browser) {
|
|
1260
1493
|
return null;
|
|
1261
1494
|
}
|
|
1262
|
-
var home = () => process.env.UNBROWSE_COOKIES_HOME ??
|
|
1495
|
+
var home = () => process.env.UNBROWSE_COOKIES_HOME ?? homedir4(), OS, BROWSERS, TEXT;
|
|
1263
1496
|
var init_cookies = __esm(() => {
|
|
1264
1497
|
OS = platform2();
|
|
1265
1498
|
BROWSERS = [
|
|
@@ -1280,7 +1513,7 @@ var init_cookies = __esm(() => {
|
|
|
1280
1513
|
// src/cli.ts
|
|
1281
1514
|
import { spawn, spawnSync } from "node:child_process";
|
|
1282
1515
|
import { randomUUID } from "node:crypto";
|
|
1283
|
-
import { existsSync as
|
|
1516
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync5, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
1284
1517
|
import { dirname as dirname3 } from "node:path";
|
|
1285
1518
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1286
1519
|
|
|
@@ -1323,6 +1556,34 @@ function v1Key(baseUrl) {
|
|
|
1323
1556
|
return;
|
|
1324
1557
|
}
|
|
1325
1558
|
}
|
|
1559
|
+
function sharedSignIn(baseUrl, now = Date.now()) {
|
|
1560
|
+
if (process.env.UNBROWSE_SHARED_SIGNIN === "0")
|
|
1561
|
+
return;
|
|
1562
|
+
const dir = process.env.CLAUDE_CONFIG_DIR ?? join(process.env.HOME || homedir(), ".claude");
|
|
1563
|
+
let entries;
|
|
1564
|
+
try {
|
|
1565
|
+
entries = Object.values(JSON.parse(readFileSync(join(dir, ".credentials.json"), "utf8")).mcpOAuth ?? {});
|
|
1566
|
+
} catch {
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
const api = new URL(apiResource(baseUrl));
|
|
1570
|
+
for (const e of entries) {
|
|
1571
|
+
if (typeof e?.serverUrl !== "string" || typeof e.accessToken !== "string" || !e.accessToken)
|
|
1572
|
+
continue;
|
|
1573
|
+
if (typeof e.expiresAt === "number" && now > e.expiresAt - 60000)
|
|
1574
|
+
continue;
|
|
1575
|
+
let url;
|
|
1576
|
+
try {
|
|
1577
|
+
url = new URL(e.serverUrl);
|
|
1578
|
+
} catch {
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
if (url.origin !== api.origin || !(url.pathname === api.pathname || url.pathname.startsWith(`${api.pathname}/`)))
|
|
1582
|
+
continue;
|
|
1583
|
+
return { token: e.accessToken, from: `Claude Code's MCP sign-in (${String(e.serverName ?? url.origin + url.pathname)})` };
|
|
1584
|
+
}
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1326
1587
|
async function currentToken(baseUrl, fetchImpl = fetch) {
|
|
1327
1588
|
if (process.env.UNBROWSE_API_KEY)
|
|
1328
1589
|
return process.env.UNBROWSE_API_KEY;
|
|
@@ -1333,7 +1594,7 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
|
|
|
1333
1594
|
return stored.apiKey;
|
|
1334
1595
|
const o = stored.oauth;
|
|
1335
1596
|
if (!o)
|
|
1336
|
-
return v1Key(baseUrl);
|
|
1597
|
+
return v1Key(baseUrl) ?? sharedSignIn(baseUrl)?.token;
|
|
1337
1598
|
if (o.expiresAt && o.refreshToken && Date.now() > o.expiresAt - 60000) {
|
|
1338
1599
|
try {
|
|
1339
1600
|
const fresh = await tokenRequest(baseUrl, { grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId, resource: apiResource(baseUrl) }, fetchImpl);
|
|
@@ -1345,6 +1606,18 @@ async function currentToken(baseUrl, fetchImpl = fetch) {
|
|
|
1345
1606
|
}
|
|
1346
1607
|
return o.accessToken;
|
|
1347
1608
|
}
|
|
1609
|
+
function signInSource(baseUrl) {
|
|
1610
|
+
if (process.env.UNBROWSE_API_KEY)
|
|
1611
|
+
return "env";
|
|
1612
|
+
const stored = load();
|
|
1613
|
+
if (stored.baseUrl && stored.baseUrl !== baseUrl)
|
|
1614
|
+
return;
|
|
1615
|
+
if (stored.apiKey || stored.oauth)
|
|
1616
|
+
return "stored";
|
|
1617
|
+
if (v1Key(baseUrl))
|
|
1618
|
+
return "v1";
|
|
1619
|
+
return sharedSignIn(baseUrl) ? "shared" : undefined;
|
|
1620
|
+
}
|
|
1348
1621
|
function describe(baseUrl) {
|
|
1349
1622
|
if (process.env.UNBROWSE_API_KEY)
|
|
1350
1623
|
return "API key from UNBROWSE_API_KEY";
|
|
@@ -1358,6 +1631,9 @@ function describe(baseUrl) {
|
|
|
1358
1631
|
const v1 = !stored.baseUrl ? v1Key(baseUrl) : undefined;
|
|
1359
1632
|
if (v1)
|
|
1360
1633
|
return `v1 API key ${mask(v1)} from ~/.unbrowse/config.json (still works; \`unbrowse login\` replaces it)`;
|
|
1634
|
+
const shared = !stored.baseUrl ? sharedSignIn(baseUrl) : undefined;
|
|
1635
|
+
if (shared)
|
|
1636
|
+
return `${shared.from}, shared (\`unbrowse login\` gives the CLI its own)`;
|
|
1361
1637
|
return "not signed in";
|
|
1362
1638
|
}
|
|
1363
1639
|
function mask(key) {
|
|
@@ -1442,7 +1718,7 @@ ${url.toString()}`);
|
|
|
1442
1718
|
|
|
1443
1719
|
// src/cli.ts
|
|
1444
1720
|
init_src();
|
|
1445
|
-
var VERSION = "12.
|
|
1721
|
+
var VERSION = "12.3.0";
|
|
1446
1722
|
var DEFAULT_ORIGIN = "https://unbrowse.ai";
|
|
1447
1723
|
var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse API
|
|
1448
1724
|
|
|
@@ -1474,6 +1750,10 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
|
|
|
1474
1750
|
cookies daemon start|status|stop
|
|
1475
1751
|
The same as a background service (systemd / launchd) [--domain a,b | --all-sites] [--yes]
|
|
1476
1752
|
|
|
1753
|
+
mcps MCP servers in this machine's agents (Claude Code, Cursor, Codex, VS Code, …)
|
|
1754
|
+
mcps import [name…] Bring them into Unbrowse: remote ones by address, local ones as their hosted app
|
|
1755
|
+
[--dry-run] [--with-keys] keys in those configs stay here unless --with-keys (asks first; --yes in scripts)
|
|
1756
|
+
|
|
1477
1757
|
learn <a.har> <b.har>… Compile two HAR recordings into a capability [--title T] [--goal G]
|
|
1478
1758
|
learned [id] Your learned capabilities, or one with its harness and skill
|
|
1479
1759
|
logins Saved logins as masked hints
|
|
@@ -1487,11 +1767,15 @@ var HELP = `unbrowse ${VERSION} — call websites as APIs through the Unbrowse A
|
|
|
1487
1767
|
|
|
1488
1768
|
mcp Local stdio MCP server proxying the hosted one (tool names use _ not .)
|
|
1489
1769
|
[--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)
|
|
1771
|
+
mcp add [claude] [codex] Register that proxy with Claude Code / Codex: they use this CLI's sign-in (no second sign-in)
|
|
1772
|
+
[--name NAME] [--replace] [--dry-run]
|
|
1773
|
+
And back: no CLI sign-in, but Claude Code signed in to Unbrowse (plugin) → the CLI uses that
|
|
1490
1774
|
|
|
1491
1775
|
Options: --json (errors as JSON) · --base-url URL · --no-open
|
|
1492
1776
|
Env: UNBROWSE_API_KEY, UNBROWSE_BASE_URL (default ${DEFAULT_ORIGIN}), UNBROWSE_EGRESS=server, UNBROWSE_MCP_URL, UNBROWSE_END_USER
|
|
1493
1777
|
Exit: 0 ok · 1 error · 2 input required · 3 sign-in or login needed · 4 not verified`;
|
|
1494
|
-
var BOOLEAN = new Set(["json", "help", "version", "no-open", "no-wait", "unattended", "from-here", "from-unbrowse", "all", "all-sites", "yes", "full", "events"]);
|
|
1778
|
+
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"]);
|
|
1495
1779
|
var NEEDS_ACCOUNT = new Set(["whoami", "usage", "discover", "run", "scrape", "index", "inspect", "resume", "cancel", "learn", "learned", "logins", "call", "cookies"]);
|
|
1496
1780
|
var SCRAPE_FORMATS = ["markdown", "html", "text", "links", "raw"];
|
|
1497
1781
|
function parseArgs(argv) {
|
|
@@ -1553,14 +1837,15 @@ async function main(argv, io = stdio) {
|
|
|
1553
1837
|
const origin = String(args.flags["base-url"] ?? process.env.UNBROWSE_BASE_URL ?? load().baseUrl ?? DEFAULT_ORIGIN).replace(/\/+$/, "");
|
|
1554
1838
|
const flag = (k) => typeof args.flags[k] === "string" ? args.flags[k] : undefined;
|
|
1555
1839
|
const print = (v) => io.out(JSON.stringify(v, null, 2));
|
|
1556
|
-
const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin });
|
|
1557
|
-
if (cmd === "mcp") {
|
|
1840
|
+
const client = async () => new Unbrowse({ apiKey: await currentToken(origin) ?? "", baseUrl: origin, client: `cli/${VERSION}` });
|
|
1841
|
+
if (cmd === "mcp" && rest[0] !== "add") {
|
|
1558
1842
|
await Promise.resolve().then(() => init_mcp_proxy());
|
|
1559
1843
|
await serveStdio({
|
|
1560
1844
|
url: flag("url") ?? process.env.UNBROWSE_MCP_URL ?? `${origin}/api/mcp`,
|
|
1561
1845
|
token: async () => await currentToken(origin) ?? undefined,
|
|
1562
1846
|
version: VERSION,
|
|
1563
|
-
endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER
|
|
1847
|
+
endUser: flag("end-user") ?? process.env.UNBROWSE_END_USER,
|
|
1848
|
+
...args.flags["from-here"] || process.env.UNBROWSE_EGRESS === "client" ? { fromHere: client } : {}
|
|
1564
1849
|
});
|
|
1565
1850
|
return 0;
|
|
1566
1851
|
}
|
|
@@ -1636,7 +1921,7 @@ async function main(argv, io = stdio) {
|
|
|
1636
1921
|
if (!io.interactive || !io.ask || args.flags.json)
|
|
1637
1922
|
return;
|
|
1638
1923
|
const { D, dir } = await cookieSync();
|
|
1639
|
-
if (
|
|
1924
|
+
if (existsSync5(D.configFile(dir)))
|
|
1640
1925
|
return;
|
|
1641
1926
|
io.err("Optional: let runs act as you on sites you are signed into in your browser. Unbrowse keeps those sites' cookies in sync in the background (sealed in your vault; only the sites you name leave this machine).");
|
|
1642
1927
|
const answer = (await io.ask("Sites to keep in sync (e.g. github.com, linkedin.com), or Enter to skip: ")).trim();
|
|
@@ -1678,6 +1963,8 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
|
|
|
1678
1963
|
await offerCookieSync().catch((e) => io.err(`Cookie sync not set up: ${e.message}`));
|
|
1679
1964
|
return 0;
|
|
1680
1965
|
}
|
|
1966
|
+
case "mcp":
|
|
1967
|
+
return await mcpAdd(rest.slice(1), args, io, origin);
|
|
1681
1968
|
case "logout":
|
|
1682
1969
|
clear();
|
|
1683
1970
|
io.err("Signed out.");
|
|
@@ -1829,7 +2116,7 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
|
|
|
1829
2116
|
if (plan)
|
|
1830
2117
|
for (const cmd of plan.disable)
|
|
1831
2118
|
exec(cmd);
|
|
1832
|
-
if (plan &&
|
|
2119
|
+
if (plan && existsSync5(plan.path))
|
|
1833
2120
|
rmSync2(plan.path, { force: true });
|
|
1834
2121
|
rmSync2(D.configFile(dir), { force: true });
|
|
1835
2122
|
rmSync2(D.stateFile(dir), { force: true });
|
|
@@ -1838,7 +2125,7 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
|
|
|
1838
2125
|
}
|
|
1839
2126
|
if (action === "status") {
|
|
1840
2127
|
const state = D.readJson(D.stateFile(dir), { sites: {} });
|
|
1841
|
-
const installed = !!plan &&
|
|
2128
|
+
const installed = !!plan && existsSync5(plan.path);
|
|
1842
2129
|
const active = installed && plan ? exec(plan.status).status === 0 : false;
|
|
1843
2130
|
const out = {
|
|
1844
2131
|
installed,
|
|
@@ -1918,13 +2205,15 @@ Your browser opens ${origin}; approve it and \`unbrowse ${cmd}\` carries on. (Sc
|
|
|
1918
2205
|
}
|
|
1919
2206
|
case "learn": {
|
|
1920
2207
|
need(rest[0], "learn <a.har> <b.har>…");
|
|
1921
|
-
const har = rest.map((f) => JSON.parse(
|
|
2208
|
+
const har = rest.map((f) => JSON.parse(readFileSync5(f, "utf8")));
|
|
1922
2209
|
print(await ub.learn({ har: har.length === 1 ? har[0] : har, ...flag("title") ? { title: flag("title") } : {}, ...flag("goal") ? { goal: flag("goal") } : {} }));
|
|
1923
2210
|
return 0;
|
|
1924
2211
|
}
|
|
1925
2212
|
case "learned":
|
|
1926
2213
|
print(await ub.learned(rest[0]));
|
|
1927
2214
|
return 0;
|
|
2215
|
+
case "mcps":
|
|
2216
|
+
return await mcps(rest, args, io, origin, print, client, onboard);
|
|
1928
2217
|
case "logins":
|
|
1929
2218
|
if (rest[0] === "remove")
|
|
1930
2219
|
print(await ub.logins.remove({ origin: need(rest[1], "logins remove <origin>") }));
|
|
@@ -2018,6 +2307,148 @@ async function settle(ub, view, args, io, print) {
|
|
|
2018
2307
|
io.err(`${view.status}: ${view.error.code}${view.error.message ? ` — ${view.error.message}` : ""}`);
|
|
2019
2308
|
return 1;
|
|
2020
2309
|
}
|
|
2310
|
+
async function mcpAdd(rest, args, io, origin) {
|
|
2311
|
+
const HOSTS = ["claude", "codex"];
|
|
2312
|
+
const unknown = rest.filter((h) => !HOSTS.includes(h));
|
|
2313
|
+
if (unknown.length)
|
|
2314
|
+
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";
|
|
2316
|
+
const exec = io.exec ?? (() => ({ status: 1, stdout: "", stderr: "no exec" }));
|
|
2317
|
+
const dry = !!args.flags["dry-run"];
|
|
2318
|
+
const replace = !!args.flags.replace;
|
|
2319
|
+
const win = (io.service?.os ?? process.platform) === "win32";
|
|
2320
|
+
const shell = (cmd) => win ? ["cmd", "/c", ...cmd] : cmd;
|
|
2321
|
+
await Promise.resolve().then(() => init_cookie_daemon());
|
|
2322
|
+
let cli = io.service?.cli ?? process.argv[1] ?? "";
|
|
2323
|
+
try {
|
|
2324
|
+
cli = realpathSync(cli);
|
|
2325
|
+
} 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] : []];
|
|
2327
|
+
const env = ["UNBROWSE_CONFIG_DIR", "XDG_CONFIG_HOME", "CLAUDE_CONFIG_DIR", "UNBROWSE_SHARED_SIGNIN"].flatMap((k) => process.env[k] ? [`${k}=${process.env[k]}`] : []);
|
|
2328
|
+
const plans = {
|
|
2329
|
+
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] },
|
|
2330
|
+
codex: { get: ["codex", "mcp", "get", name], remove: ["codex", "mcp", "remove", name], add: ["codex", "mcp", "add", name, ...env.flatMap((e) => ["--env", e]), "--", ...launch] }
|
|
2331
|
+
};
|
|
2332
|
+
const wanted = rest.length ? rest : HOSTS.filter((h) => dry || exec(shell([h, "--version"])).status === 0);
|
|
2333
|
+
if (!wanted.length)
|
|
2334
|
+
return io.err("No Claude Code or Codex found on PATH. Any MCP host can run the proxy itself: " + launch.join(" ")), 1;
|
|
2335
|
+
let failed = 0;
|
|
2336
|
+
for (const h of wanted) {
|
|
2337
|
+
const plan = plans[h];
|
|
2338
|
+
if (dry) {
|
|
2339
|
+
io.out([...replace ? [plan.remove] : [], plan.add].map((c) => shell(c).join(" ")).join(`
|
|
2340
|
+
`));
|
|
2341
|
+
continue;
|
|
2342
|
+
}
|
|
2343
|
+
if (exec(shell(plan.get)).status === 0) {
|
|
2344
|
+
if (!replace) {
|
|
2345
|
+
failed++;
|
|
2346
|
+
io.err(`✗ ${h}: a server named "${name}" already exists; left as it is. Re-run with --replace, or pick --name.`);
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
2349
|
+
const r = exec(shell(plan.remove));
|
|
2350
|
+
if (r.status !== 0) {
|
|
2351
|
+
failed++;
|
|
2352
|
+
io.err(`✗ ${h}: could not remove the existing "${name}": ${(r.stderr || r.stdout).trim() || `exit ${r.status}`}`);
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
const r = exec(shell(plan.add));
|
|
2357
|
+
if (r.status === 0)
|
|
2358
|
+
io.err(`✓ ${h}: added "${name}" (stdio, on this CLI's sign-in)`);
|
|
2359
|
+
else
|
|
2360
|
+
failed++, io.err(`✗ ${h}: ${(r.stderr || r.stdout).trim() || `exit ${r.status}`}`);
|
|
2361
|
+
}
|
|
2362
|
+
if (!dry) {
|
|
2363
|
+
const source = signInSource(origin);
|
|
2364
|
+
if (source === "env")
|
|
2365
|
+
io.err("This CLI is signed in only through UNBROWSE_API_KEY, which the hosts do not get: run `unbrowse login` (or `unbrowse login --key`) so they have a sign-in.");
|
|
2366
|
+
else if (source === "shared")
|
|
2367
|
+
io.err("This CLI is using Claude Code's sign-in, which only Claude Code renews: run `unbrowse login` so the hosts keep working.");
|
|
2368
|
+
else if (!source)
|
|
2369
|
+
io.err("Not signed in yet: run `unbrowse login` once and the hosts use that sign-in.");
|
|
2370
|
+
}
|
|
2371
|
+
return failed ? 1 : 0;
|
|
2372
|
+
}
|
|
2373
|
+
async function mcps(rest, args, io, origin, print, client, onboard) {
|
|
2374
|
+
const A = await Promise.resolve().then(() => (init_agent_mcps(), {}));
|
|
2375
|
+
const { servers, skipped } = scan();
|
|
2376
|
+
const tilde = (f) => f.replace(process.env.HOME ?? "\x00", "~");
|
|
2377
|
+
if (rest[0] !== "import") {
|
|
2378
|
+
if (rest[0])
|
|
2379
|
+
throw new UsageError("usage: unbrowse mcps [import [name…] [--dry-run] [--with-keys]]");
|
|
2380
|
+
if (args.flags.json)
|
|
2381
|
+
return print({ servers: servers.map(({ file, ...s }) => ({ ...s, file: tilde(file) })), skipped }), 0;
|
|
2382
|
+
if (!servers.length)
|
|
2383
|
+
return io.err(`No MCP servers found in this machine's agent configs.${skipped.length ? ` Unreadable: ${skipped.map((x) => tilde(x.file)).join(", ")}` : ""}`), 0;
|
|
2384
|
+
let last = "";
|
|
2385
|
+
for (const s of servers) {
|
|
2386
|
+
const where = `${s.host} ${tilde(s.file)}`;
|
|
2387
|
+
if (where !== last)
|
|
2388
|
+
io.out(where), last = where;
|
|
2389
|
+
const what = s.url ? `remote ${s.url}` : `local ${[s.command, ...s.args ?? []].join(" ").slice(0, 90)}`;
|
|
2390
|
+
const env = s.envNames.length > 3 ? [...s.envNames.slice(0, 3).map((e) => `env ${e}`), `+${s.envNames.length - 3} env`] : s.envNames.map((e) => `env ${e}`);
|
|
2391
|
+
const secrets = [...s.headerNames.map((h) => `header ${h}`), ...env];
|
|
2392
|
+
io.out(` ${s.name.padEnd(22)} ${what}${secrets.length ? ` (${secrets.join(", ")})` : ""}${s.self ? " (Unbrowse itself)" : ""}`);
|
|
2393
|
+
}
|
|
2394
|
+
io.err(`
|
|
2395
|
+
${unique(servers.filter((s) => !s.self)).length} to import: unbrowse mcps import --dry-run`);
|
|
2396
|
+
return 0;
|
|
2397
|
+
}
|
|
2398
|
+
if (!await currentToken(origin) && !await onboard())
|
|
2399
|
+
return 3;
|
|
2400
|
+
const names = new Set(rest.slice(1));
|
|
2401
|
+
const pick = unique(servers.filter((s) => !s.self && (!names.size || names.has(s.name))));
|
|
2402
|
+
const missing = [...names].filter((n) => !servers.some((s) => s.name === n));
|
|
2403
|
+
if (missing.length)
|
|
2404
|
+
throw new UsageError(`no MCP server named ${missing.join(", ")} in this machine's agent configs (unbrowse mcps lists them)`);
|
|
2405
|
+
if (!pick.length)
|
|
2406
|
+
return io.err("Nothing to import."), 0;
|
|
2407
|
+
const dry = !!args.flags["dry-run"];
|
|
2408
|
+
const keyed = args.flags["with-keys"] ? pick.flatMap((f) => {
|
|
2409
|
+
const k = keyOf(f, rawEntry(f) ?? {});
|
|
2410
|
+
return k ? [{ f, k }] : [];
|
|
2411
|
+
}) : [];
|
|
2412
|
+
if (keyed.length && !dry && !args.flags.yes) {
|
|
2413
|
+
const list = keyed.map((x) => `${x.f.name} (${x.k.header})`).join(", ");
|
|
2414
|
+
if (!io.interactive || !io.ask)
|
|
2415
|
+
throw new UsageError(`Re-run with --yes to confirm: the keys for ${list} will be sent to ${origin} and sealed in your vault.`);
|
|
2416
|
+
const ok = (await io.ask(`Send the keys for ${list} from your agent configs to Unbrowse (sealed in your vault)? [y/N] `)).trim().toLowerCase();
|
|
2417
|
+
if (ok !== "y" && ok !== "yes")
|
|
2418
|
+
return io.err("Nothing sent."), 1;
|
|
2419
|
+
}
|
|
2420
|
+
const ub = await client();
|
|
2421
|
+
const rows = [];
|
|
2422
|
+
for (const { f, k } of keyed) {
|
|
2423
|
+
if (dry) {
|
|
2424
|
+
rows.push({ name: f.name, status: "planned", via: "url", note: `Would connect ${f.url} with the ${k.header} from its config.` });
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
try {
|
|
2428
|
+
const r = await ub.connectors.connect({ url: f.url, name: f.name, ...f.transport ? { transport: f.transport } : {}, header: k.header, key: k.key });
|
|
2429
|
+
rows.push({ name: f.name, status: r.status, app: r.app, tools: r.tools, url: r.url, via: "url", note: r.status === "connected" ? `Connected with its key: ${r.tools} tools.` : `Needs the person: ${r.url}` });
|
|
2430
|
+
} catch (e) {
|
|
2431
|
+
rows.push({ name: f.name, status: "failed", note: e.message.slice(0, 200) });
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
const rest2 = pick.filter((f) => !keyed.some((x) => x.f === f));
|
|
2435
|
+
if (rest2.length)
|
|
2436
|
+
rows.push(...(await ub.connectors.import(rest2.map(toImport), { dryRun: dry })).results);
|
|
2437
|
+
if (args.flags.json)
|
|
2438
|
+
print({ results: rows });
|
|
2439
|
+
else {
|
|
2440
|
+
const mark = { connected: "✓", planned: "·", sign_in: "→", key: "→", already: "=", skipped: "-", local_only: "-", failed: "✗" };
|
|
2441
|
+
for (const r of rows)
|
|
2442
|
+
io.out(`${mark[r.status] ?? "?"} ${r.name.padEnd(22)} ${r.status.padEnd(10)} ${r.note}${r.url ? `
|
|
2443
|
+
${r.url}` : ""}`);
|
|
2444
|
+
const hasKeyRows = rows.some((r) => r.status === "key") && !args.flags["with-keys"] && pick.some((f) => f.headerNames.length);
|
|
2445
|
+
if (hasKeyRows)
|
|
2446
|
+
io.err("A key server can take the key from your agent config instead: unbrowse mcps import --with-keys");
|
|
2447
|
+
if (dry)
|
|
2448
|
+
io.err("Nothing was connected. Run without --dry-run to import.");
|
|
2449
|
+
}
|
|
2450
|
+
return rows.some((r) => r.status === "failed") ? 1 : 0;
|
|
2451
|
+
}
|
|
2021
2452
|
function noCapabilityHint(view, args) {
|
|
2022
2453
|
const result = view.result ?? {};
|
|
2023
2454
|
const task = args._.slice(1).join(" ");
|
package/package.json
CHANGED
package/references/tools.json
CHANGED
|
@@ -549,6 +549,65 @@
|
|
|
549
549
|
"additionalProperties": false
|
|
550
550
|
}
|
|
551
551
|
},
|
|
552
|
+
{
|
|
553
|
+
"name": "unbrowse_import_mcps",
|
|
554
|
+
"description": "Import the MCP servers this agent already has into Unbrowse, so their tools run here with the person's own account and every agent signed in to Unbrowse gets them. Read your host's MCP config yourself (Claude Code: ~/.claude.json and .mcp.json; Claude Desktop: claude_desktop_config.json; Cursor: ~/.cursor/mcp.json; Codex: ~/.codex/config.toml; VS Code: .vscode/mcp.json; Windsurf: ~/.codeium/windsurf/mcp_config.json; Gemini: ~/.gemini/settings.json) and pass each server: a remote one as {name, url}, a local one as {name, command, args}. Never pass keys, headers or env values. A remote server is connected by its address (open ones at once; one with OAuth or a key returns the link the person opens). A local server cannot run in Unbrowse's cloud: when the catalog has the same app hosted, that is connected instead, otherwise it is reported local_only and stays in the agent. Servers already connected, duplicates and Unbrowse itself are skipped. dryRun: true shows the plan without connecting. CLI: `unbrowse mcps import` reads the configs itself.",
|
|
555
|
+
"inputSchema": {
|
|
556
|
+
"type": "object",
|
|
557
|
+
"properties": {
|
|
558
|
+
"servers": {
|
|
559
|
+
"type": "array",
|
|
560
|
+
"maxItems": 50,
|
|
561
|
+
"description": "The MCP servers in your agent's config",
|
|
562
|
+
"items": {
|
|
563
|
+
"type": "object",
|
|
564
|
+
"properties": {
|
|
565
|
+
"name": {
|
|
566
|
+
"type": "string",
|
|
567
|
+
"description": "The server's name in the config"
|
|
568
|
+
},
|
|
569
|
+
"url": {
|
|
570
|
+
"type": "string",
|
|
571
|
+
"description": "A remote server's https address"
|
|
572
|
+
},
|
|
573
|
+
"transport": {
|
|
574
|
+
"type": "string",
|
|
575
|
+
"enum": [
|
|
576
|
+
"streamable-http",
|
|
577
|
+
"http",
|
|
578
|
+
"sse"
|
|
579
|
+
],
|
|
580
|
+
"description": "A remote server's transport, when the config says"
|
|
581
|
+
},
|
|
582
|
+
"command": {
|
|
583
|
+
"type": "string",
|
|
584
|
+
"description": "A local server's command, e.g. npx"
|
|
585
|
+
},
|
|
586
|
+
"args": {
|
|
587
|
+
"type": "array",
|
|
588
|
+
"items": {
|
|
589
|
+
"type": "string"
|
|
590
|
+
},
|
|
591
|
+
"description": "A local server's arguments (package names; leave out tokens and paths to secrets)"
|
|
592
|
+
}
|
|
593
|
+
},
|
|
594
|
+
"required": [
|
|
595
|
+
"name"
|
|
596
|
+
],
|
|
597
|
+
"additionalProperties": false
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
"dryRun": {
|
|
601
|
+
"type": "boolean",
|
|
602
|
+
"description": "Show what would happen without connecting anything"
|
|
603
|
+
}
|
|
604
|
+
},
|
|
605
|
+
"required": [
|
|
606
|
+
"servers"
|
|
607
|
+
],
|
|
608
|
+
"additionalProperties": false
|
|
609
|
+
}
|
|
610
|
+
},
|
|
552
611
|
{
|
|
553
612
|
"name": "unbrowse_credits",
|
|
554
613
|
"description": "This workspace's credits: the free monthly allowance left (pays for Unbrowse's own verified calls only; resets monthly) and paid credits (bought by the user; never expire), plus recent credit history and the top-up link. 1 credit = one verified call = $0.001. Pass a pack (p10 = $10 → 10,000, p50 = $50 → 50,000, p200 = $200 → 200,000) to get a Stripe Checkout link for the user to open — you never pay or see card details.",
|