claude-bridge-cli 1.3.0 → 1.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.
Files changed (3) hide show
  1. package/bin/cli.js +119 -3
  2. package/lib/bridge.js +14 -13
  3. package/package.json +1 -1
package/bin/cli.js CHANGED
@@ -2,9 +2,108 @@
2
2
  "use strict";
3
3
 
4
4
  const { parseArgs } = require("node:util");
5
+ const fs = require("node:fs");
6
+ const path = require("node:path");
7
+ const os = require("node:os");
5
8
  const { startBridge } = require("../lib/bridge");
6
9
  const { startRelay } = require("../lib/relay-client");
7
10
 
11
+ // ── Pair flow (browser-OTP based auth) ────────────────────────────────────
12
+ // When --token isn't supplied, the CLI:
13
+ // 1. POSTs /pair-cli/start to the relay's HTTP base (public endpoint)
14
+ // 2. Prints the short code + pair URL — user opens it, OTPs into CF Access,
15
+ // confirms in the browser
16
+ // 3. Long-polls /pair-cli/claim until the bearer arrives
17
+ // 4. Saves the bearer to ~/.claude-bridge/auth.json so subsequent starts
18
+ // pick it up automatically
19
+ // Replaces the old workflow of running `claude-relay-ctl provision <m>` on
20
+ // the host and copy-pasting a long bearer onto the CLI command line.
21
+
22
+ function authFilePath() {
23
+ return path.join(os.homedir(), ".claude-bridge", "auth.json");
24
+ }
25
+
26
+ function readAuthFile() {
27
+ try { return JSON.parse(fs.readFileSync(authFilePath(), "utf8")); }
28
+ catch { return { bearers: {} }; }
29
+ }
30
+
31
+ function writeAuthFile(data) {
32
+ const fp = authFilePath();
33
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
34
+ fs.writeFileSync(fp, JSON.stringify(data, null, 2), { mode: 0o600 });
35
+ }
36
+
37
+ function relayHttpBase(wsUrl) {
38
+ // wss://host/agent/ws → https://host
39
+ const u = new URL(wsUrl);
40
+ const proto = u.protocol === "wss:" ? "https:" : "http:";
41
+ return `${proto}//${u.host}`;
42
+ }
43
+
44
+ async function pairInteractive(relayWsUrl, machine) {
45
+ const base = relayHttpBase(relayWsUrl);
46
+ process.stdout.write(`[bridge] Starting pair flow for "${machine}" against ${base}\n`);
47
+
48
+ const startResp = await fetch(`${base}/pair-cli/start`, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ machine }),
52
+ });
53
+ if (!startResp.ok) {
54
+ const text = await startResp.text();
55
+ throw new Error(`pair start failed (${startResp.status}): ${text}`);
56
+ }
57
+ const { code, poll_token, pair_url, expires_in } = await startResp.json();
58
+
59
+ console.log("\n ┌─────────────────────────────────────────────────┐");
60
+ console.log(" │ Open this URL in a browser to pair the device │");
61
+ console.log(" ├─────────────────────────────────────────────────┤");
62
+ console.log(` │ ${pair_url.padEnd(47)} │`);
63
+ console.log(" │ │");
64
+ console.log(` │ Confirm this code matches: ${code.padEnd(17)}│`);
65
+ console.log(" └─────────────────────────────────────────────────┘");
66
+ console.log(` Code expires in ${expires_in}s. Waiting for confirmation…\n`);
67
+
68
+ const deadline = Date.now() + expires_in * 1000;
69
+ while (Date.now() < deadline) {
70
+ let resp;
71
+ try {
72
+ resp = await fetch(`${base}/pair-cli/claim`, {
73
+ method: "POST",
74
+ headers: { "Content-Type": "application/json" },
75
+ body: JSON.stringify({ poll_token }),
76
+ });
77
+ } catch (e) {
78
+ await new Promise(r => setTimeout(r, 2000));
79
+ continue;
80
+ }
81
+ if (resp.status === 202) continue; // still waiting, re-poll
82
+ if (resp.status === 404 || resp.status === 410) {
83
+ throw new Error("pair expired or invalidated — re-run to start over");
84
+ }
85
+ if (!resp.ok) {
86
+ const text = await resp.text();
87
+ throw new Error(`pair claim failed (${resp.status}): ${text}`);
88
+ }
89
+ const { bearer, machine: confirmed } = await resp.json();
90
+ console.log(`[bridge] ✓ Paired as "${confirmed}". Bearer saved to ${authFilePath()}`);
91
+
92
+ const auth = readAuthFile();
93
+ auth.bearers = auth.bearers || {};
94
+ auth.bearers[base] = auth.bearers[base] || {};
95
+ auth.bearers[base][confirmed] = bearer;
96
+ writeAuthFile(auth);
97
+ return bearer;
98
+ }
99
+ throw new Error("pair window expired — re-run to start over");
100
+ }
101
+
102
+ function lookupSavedBearer(relayWsUrl, machine) {
103
+ const auth = readAuthFile();
104
+ return auth.bearers?.[relayHttpBase(relayWsUrl)]?.[machine] || null;
105
+ }
106
+
8
107
  const HELP = `
9
108
  claude-code-bridge — Bridge server for Claude Code CLI
10
109
 
@@ -25,7 +124,9 @@ Options:
25
124
  Relay options (connect to a remote relay server):
26
125
  --relay-url <url> WebSocket URL of the relay server
27
126
  --machine <name> Machine name for the relay
28
- --token <bearer> Machine bearer token (when using relay, --token is the relay auth)
127
+ --token <bearer> Machine bearer (optional if omitted, the CLI starts
128
+ a browser-OTP pair flow and caches the bearer at
129
+ ~/.claude-bridge/auth.json for next time)
29
130
  --cf-id <id> Cloudflare Access Client ID (optional)
30
131
  --cf-secret <secret> Cloudflare Access Client Secret (optional)
31
132
 
@@ -133,10 +234,25 @@ async function run(config) {
133
234
 
134
235
  // Start relay client if configured
135
236
  if (config.relay) {
136
- if (!config.relay.machine || !config.relay.token) {
137
- console.error("[bridge] ERROR: --machine and --token required when using --relay-url");
237
+ if (!config.relay.machine) {
238
+ console.error("[bridge] ERROR: --machine required when using --relay-url");
138
239
  process.exit(1);
139
240
  }
241
+ // Resolve a bearer: --token > saved auth.json > interactive pair flow.
242
+ if (!config.relay.token) {
243
+ const saved = lookupSavedBearer(config.relay.url, config.relay.machine);
244
+ if (saved) {
245
+ config.relay.token = saved;
246
+ console.log(`[bridge] Using saved bearer from ${authFilePath()}`);
247
+ } else {
248
+ try {
249
+ config.relay.token = await pairInteractive(config.relay.url, config.relay.machine);
250
+ } catch (e) {
251
+ console.error(`[bridge] ERROR: ${e.message}`);
252
+ process.exit(1);
253
+ }
254
+ }
255
+ }
140
256
  console.log(`[bridge] Connecting to relay as "${config.relay.machine}"...`);
141
257
  startRelay(config);
142
258
  } else {
package/lib/bridge.js CHANGED
@@ -304,13 +304,7 @@ function extractUserText(content) {
304
304
  function shouldSkipUserText(text) {
305
305
  if (!text) return true;
306
306
  const t = text.replace(/^\s+/, "");
307
- // Skip internal command-stub patterns
308
307
  if (INTERNAL_USER_PATTERNS.some(p => t.startsWith(p))) return true;
309
- // Skip @file references — the bridge writes prompts to temp files,
310
- // claude logs the literal @path string in the JSONL even though it
311
- // reads file contents internally.
312
- if (/^@[A-Z]:[\\\/]/.test(t) && /\.txt\s*$/.test(t)) return true;
313
- if (/^@\/.*\.txt\s*$/.test(t)) return true;
314
308
  return false;
315
309
  }
316
310
 
@@ -424,10 +418,16 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
424
418
  const isWin = process.platform === "win32";
425
419
  const isCmdShim = isWin && config.claudeBin.endsWith(".cmd");
426
420
 
427
- // Write prompt to temp file avoids Windows 8K arg limit and stdin
428
- // piping issues through cmd.exe. Works on all platforms.
429
- const tmpFile = path.join(os.tmpdir(), `claude-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
430
- fs.writeFileSync(tmpFile, finalPrompt, "utf8");
421
+ // Only fall back to @file syntax for prompts that would exceed Windows
422
+ // cmd.exe's ~8K arg limit. Direct -p means the JSONL stores the actual
423
+ // prompt text (so session history shows the real user messages, not
424
+ // temp-file paths). Threshold is conservative: 6000 chars.
425
+ const useTempFile = finalPrompt.length > 6000;
426
+ let tmpFile = null;
427
+ if (useTempFile) {
428
+ tmpFile = path.join(os.tmpdir(), `claude-prompt-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
429
+ fs.writeFileSync(tmpFile, finalPrompt, "utf8");
430
+ }
431
431
 
432
432
  const settings = JSON.stringify({
433
433
  permissions: {
@@ -437,7 +437,8 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
437
437
  deny: ["AskUserQuestion"],
438
438
  }
439
439
  });
440
- const args = ["-p", `@${tmpFile}`, "--output-format", "json",
440
+ const promptArg = useTempFile ? `@${tmpFile}` : finalPrompt;
441
+ const args = ["-p", promptArg, "--output-format", "json",
441
442
  "--settings", settings, "--permission-mode", "acceptEdits"];
442
443
  let resumeCwd = null;
443
444
  if (session_id) {
@@ -479,7 +480,7 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
479
480
  if (session_id) running.set(session_id, proc);
480
481
 
481
482
  proc.on("close", (code) => {
482
- try { fs.unlinkSync(tmpFile); } catch {}
483
+ if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch {} }
483
484
  if (session_id) running.delete(session_id);
484
485
  try {
485
486
  const result = JSON.parse(stdout);
@@ -506,7 +507,7 @@ function askClaude(config, { prompt, session_id, images, cwd, plan_mode, allow_t
506
507
  });
507
508
 
508
509
  proc.on("error", (err) => {
509
- try { fs.unlinkSync(tmpFile); } catch {}
510
+ if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch {} }
510
511
  if (session_id) running.delete(session_id);
511
512
  resolve({ error: err.message });
512
513
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-bridge-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Use Claude Code from your browser. Runs a local server that connects your browser tools to the Claude CLI.",
5
5
  "main": "lib/bridge.js",
6
6
  "bin": {