claude-bridge-cli 2.0.12 → 2.0.18
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 +74 -74
- package/bin/cli.js +391 -391
- package/lib/bridge.js +1147 -1037
- package/lib/relay-client.js +152 -152
- package/package.json +34 -26
package/bin/cli.js
CHANGED
|
@@ -1,391 +1,391 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
|
|
4
|
-
const { parseArgs } = require("node:util");
|
|
5
|
-
const fs = require("node:fs");
|
|
6
|
-
const path = require("node:path");
|
|
7
|
-
const os = require("node:os");
|
|
8
|
-
const { startBridge } = require("../lib/bridge");
|
|
9
|
-
const { startRelay } = require("../lib/relay-client");
|
|
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, ephemeral) {
|
|
45
|
-
const base = relayHttpBase(relayWsUrl);
|
|
46
|
-
process.stdout.write(`[bridge] Starting pair flow for "${machine}" against ${base}\n`);
|
|
47
|
-
if (ephemeral) process.stdout.write(`[bridge] Ephemeral mode — bearer will NOT be saved to disk.\n`);
|
|
48
|
-
|
|
49
|
-
const startResp = await fetch(`${base}/pair-cli/start`, {
|
|
50
|
-
method: "POST",
|
|
51
|
-
headers: { "Content-Type": "application/json" },
|
|
52
|
-
body: JSON.stringify({ machine }),
|
|
53
|
-
});
|
|
54
|
-
if (!startResp.ok) {
|
|
55
|
-
const text = await startResp.text();
|
|
56
|
-
throw new Error(`pair start failed (${startResp.status}): ${text}`);
|
|
57
|
-
}
|
|
58
|
-
const { code, poll_token, pair_url, expires_in } = await startResp.json();
|
|
59
|
-
|
|
60
|
-
console.log("\n ┌─────────────────────────────────────────────────┐");
|
|
61
|
-
console.log(" │ Open this URL in a browser to pair the device │");
|
|
62
|
-
console.log(" ├─────────────────────────────────────────────────┤");
|
|
63
|
-
console.log(` │ ${pair_url.padEnd(47)} │`);
|
|
64
|
-
console.log(" │ │");
|
|
65
|
-
console.log(` │ Confirm this code matches: ${code.padEnd(17)}│`);
|
|
66
|
-
console.log(" └─────────────────────────────────────────────────┘");
|
|
67
|
-
console.log(` Code expires in ${expires_in}s. Waiting for confirmation…\n`);
|
|
68
|
-
|
|
69
|
-
const deadline = Date.now() + expires_in * 1000;
|
|
70
|
-
while (Date.now() < deadline) {
|
|
71
|
-
let resp;
|
|
72
|
-
try {
|
|
73
|
-
resp = await fetch(`${base}/pair-cli/claim`, {
|
|
74
|
-
method: "POST",
|
|
75
|
-
headers: { "Content-Type": "application/json" },
|
|
76
|
-
body: JSON.stringify({ poll_token }),
|
|
77
|
-
});
|
|
78
|
-
} catch (e) {
|
|
79
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
80
|
-
continue;
|
|
81
|
-
}
|
|
82
|
-
if (resp.status === 202) continue; // still waiting, re-poll
|
|
83
|
-
if (resp.status === 404 || resp.status === 410) {
|
|
84
|
-
throw new Error("pair expired or invalidated — re-run to start over");
|
|
85
|
-
}
|
|
86
|
-
if (!resp.ok) {
|
|
87
|
-
const text = await resp.text();
|
|
88
|
-
throw new Error(`pair claim failed (${resp.status}): ${text}`);
|
|
89
|
-
}
|
|
90
|
-
const { bearer, machine: confirmed } = await resp.json();
|
|
91
|
-
if (ephemeral) {
|
|
92
|
-
console.log(`[bridge] ✓ Paired as "${confirmed}" (in-memory only, nothing written to disk).`);
|
|
93
|
-
} else {
|
|
94
|
-
console.log(`[bridge] ✓ Paired as "${confirmed}". Bearer saved to ${authFilePath()}`);
|
|
95
|
-
const auth = readAuthFile();
|
|
96
|
-
auth.bearers = auth.bearers || {};
|
|
97
|
-
auth.bearers[base] = auth.bearers[base] || {};
|
|
98
|
-
auth.bearers[base][confirmed] = bearer;
|
|
99
|
-
writeAuthFile(auth);
|
|
100
|
-
}
|
|
101
|
-
return bearer;
|
|
102
|
-
}
|
|
103
|
-
throw new Error("pair window expired — re-run to start over");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function lookupSavedBearer(relayWsUrl, machine) {
|
|
107
|
-
const auth = readAuthFile();
|
|
108
|
-
return auth.bearers?.[relayHttpBase(relayWsUrl)]?.[machine] || null;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const HELP = `
|
|
112
|
-
claude-code-bridge — Bridge server for Claude Code CLI
|
|
113
|
-
|
|
114
|
-
Usage:
|
|
115
|
-
claude-code-bridge start [options] Start the bridge (+ optional relay)
|
|
116
|
-
claude-code-bridge install-service Register as a system service (auto-start on boot)
|
|
117
|
-
claude-code-bridge uninstall-service Remove the system service
|
|
118
|
-
claude-code-bridge --help Show this help
|
|
119
|
-
claude-code-bridge --version Print the installed version
|
|
120
|
-
|
|
121
|
-
Options:
|
|
122
|
-
--port <n> HTTP port for the local bridge (default: 8091)
|
|
123
|
-
--host <ip> Bind address (default: 127.0.0.1)
|
|
124
|
-
--token <secret> Bridge password (use the same in the extension). Auto-generated if omitted.
|
|
125
|
-
--cwd <path> Default working directory for Claude (default: current dir)
|
|
126
|
-
--claude-bin <path> Path to claude CLI binary (default: auto-detect)
|
|
127
|
-
--timeout <seconds> Max time for a single Claude call (default:
|
|
128
|
-
|
|
129
|
-
Relay options (connect to a remote relay server):
|
|
130
|
-
--relay-url <url> WebSocket URL of the relay server
|
|
131
|
-
--machine <name> Machine name for the relay
|
|
132
|
-
--token <bearer> Machine bearer (skips both auth.json and pair flow)
|
|
133
|
-
--save-auth Cache the bearer in ~/.claude-bridge/auth.json so the
|
|
134
|
-
next start reuses it. Required for unattended /
|
|
135
|
-
systemd-service installs. WITHOUT this flag the CLI
|
|
136
|
-
re-pairs (browser OTP) on every start — the default.
|
|
137
|
-
--cf-id <id> Cloudflare Access Client ID (optional)
|
|
138
|
-
--cf-secret <secret> Cloudflare Access Client Secret (optional)
|
|
139
|
-
|
|
140
|
-
Environment variables:
|
|
141
|
-
All options can be set via env vars with BRIDGE_ prefix:
|
|
142
|
-
BRIDGE_PORT, BRIDGE_HOST, BRIDGE_CWD, BRIDGE_TIMEOUT,
|
|
143
|
-
BRIDGE_RELAY_URL, BRIDGE_MACHINE_NAME, BRIDGE_MACHINE_TOKEN,
|
|
144
|
-
BRIDGE_SAVE_AUTH=1 (cache the bearer to ~/.claude-bridge/auth.json
|
|
145
|
-
for unattended restarts; default re-pairs every
|
|
146
|
-
start so nothing is persisted),
|
|
147
|
-
BRIDGE_CF_ID, BRIDGE_CF_SECRET
|
|
148
|
-
`;
|
|
149
|
-
|
|
150
|
-
function env(key, def) {
|
|
151
|
-
return process.env["BRIDGE_" + key] || def;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function main() {
|
|
155
|
-
const args = process.argv.slice(2);
|
|
156
|
-
if (args.includes("--version") || args.includes("-v") || args[0] === "version") {
|
|
157
|
-
console.log(require("../package.json").version);
|
|
158
|
-
process.exit(0);
|
|
159
|
-
}
|
|
160
|
-
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
161
|
-
console.log(HELP);
|
|
162
|
-
process.exit(0);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const command = args[0];
|
|
166
|
-
if (command === "start") {
|
|
167
|
-
const { values } = parseArgs({
|
|
168
|
-
args: args.slice(1),
|
|
169
|
-
options: {
|
|
170
|
-
port: { type: "string", default: env("PORT", "8091") },
|
|
171
|
-
host: { type: "string", default: env("HOST", "127.0.0.1") },
|
|
172
|
-
cwd: { type: "string", default: env("CWD", process.cwd()) },
|
|
173
|
-
"claude-bin": { type: "string", default: env("CLAUDE_BIN", "") },
|
|
174
|
-
timeout: { type: "string", default: env("TIMEOUT", "
|
|
175
|
-
"relay-url": { type: "string", default: env("RELAY_URL", "") },
|
|
176
|
-
machine: { type: "string", default: env("MACHINE_NAME", "") },
|
|
177
|
-
token: { type: "string", default: env("MACHINE_TOKEN", "") },
|
|
178
|
-
"save-auth":{ type: "boolean", default: env("SAVE_AUTH", "") === "1" },
|
|
179
|
-
ephemeral: { type: "boolean", default: false }, // deprecated, now default behavior — kept as a no-op for compatibility
|
|
180
|
-
"cf-id": { type: "string", default: env("CF_ID", "") },
|
|
181
|
-
"cf-secret": { type: "string", default: env("CF_SECRET", "") },
|
|
182
|
-
},
|
|
183
|
-
strict: false,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
const config = {
|
|
187
|
-
port: parseInt(values.port, 10),
|
|
188
|
-
host: values.host,
|
|
189
|
-
cwd: values.cwd,
|
|
190
|
-
claudeBin: values["claude-bin"],
|
|
191
|
-
timeout: parseInt(values.timeout, 10) * 1000,
|
|
192
|
-
relay: values["relay-url"] ? {
|
|
193
|
-
url: values["relay-url"],
|
|
194
|
-
machine: values.machine,
|
|
195
|
-
token: values.token,
|
|
196
|
-
// Default = ephemeral (pair on every start, never persist). Opt in
|
|
197
|
-
// to caching with --save-auth when running unattended / as a service.
|
|
198
|
-
// The legacy --ephemeral flag is a no-op (kept for back-compat).
|
|
199
|
-
saveAuth: !!values["save-auth"],
|
|
200
|
-
cfId: values["cf-id"],
|
|
201
|
-
cfSecret: values["cf-secret"],
|
|
202
|
-
} : null,
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
const crypto = require("node:crypto");
|
|
206
|
-
if (config.relay) {
|
|
207
|
-
config.bearerToken = crypto.randomBytes(32).toString("hex");
|
|
208
|
-
} else {
|
|
209
|
-
config.bearerToken = values.token || crypto.randomBytes(32).toString("hex");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
run(config);
|
|
213
|
-
} else if (command === "install-service") {
|
|
214
|
-
installService();
|
|
215
|
-
} else if (command === "uninstall-service") {
|
|
216
|
-
uninstallService();
|
|
217
|
-
} else {
|
|
218
|
-
console.error(`Unknown command: ${command}\nRun claude-code-bridge --help`);
|
|
219
|
-
process.exit(1);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
async function run(config) {
|
|
224
|
-
console.log(`[bridge] claude-bridge-cli v${require("../package.json").version}`);
|
|
225
|
-
console.log(`[bridge] Starting on http://${config.host}:${config.port}`);
|
|
226
|
-
console.log(`[bridge] Claude CWD: ${config.cwd}`);
|
|
227
|
-
|
|
228
|
-
// Find claude binary
|
|
229
|
-
if (!config.claudeBin) {
|
|
230
|
-
const { execSync } = require("node:child_process");
|
|
231
|
-
try {
|
|
232
|
-
const lines = execSync(
|
|
233
|
-
process.platform === "win32" ? "where claude" : "which claude",
|
|
234
|
-
{ encoding: "utf8" }
|
|
235
|
-
).trim().split(/\r?\n/);
|
|
236
|
-
if (process.platform === "win32") {
|
|
237
|
-
config.claudeBin = lines.find(l => l.endsWith(".cmd")) || lines[0];
|
|
238
|
-
} else {
|
|
239
|
-
config.claudeBin = lines[0];
|
|
240
|
-
}
|
|
241
|
-
} catch {
|
|
242
|
-
console.error("[bridge] ERROR: claude CLI not found. Install it: npm install -g @anthropic-ai/claude-code");
|
|
243
|
-
process.exit(1);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
console.log(`[bridge] Claude CLI: ${config.claudeBin}`);
|
|
247
|
-
|
|
248
|
-
// Start bridge HTTP server
|
|
249
|
-
const bridge = await startBridge(config);
|
|
250
|
-
console.log(`[bridge] Bridge ready on http://${config.host}:${config.port}`);
|
|
251
|
-
if (!config.relay) {
|
|
252
|
-
console.log(`[bridge] Bearer token: ${config.bearerToken}`);
|
|
253
|
-
console.log(`[bridge] Use this token when adding the endpoint in the extension.`);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Start relay client if configured
|
|
257
|
-
if (config.relay) {
|
|
258
|
-
if (!config.relay.machine) {
|
|
259
|
-
console.error("[bridge] ERROR: --machine required when using --relay-url");
|
|
260
|
-
process.exit(1);
|
|
261
|
-
}
|
|
262
|
-
// Resolve a bearer:
|
|
263
|
-
// --token → use it directly (never look at auth.json)
|
|
264
|
-
// --save-auth → reuse saved auth.json; pair only if no entry exists
|
|
265
|
-
// default (no flags) → pair fresh on every start, never read or write
|
|
266
|
-
// auth.json (most secure; requires user at the
|
|
267
|
-
// keyboard for every launch)
|
|
268
|
-
if (!config.relay.token) {
|
|
269
|
-
const saved = config.relay.saveAuth
|
|
270
|
-
? lookupSavedBearer(config.relay.url, config.relay.machine)
|
|
271
|
-
: null;
|
|
272
|
-
if (saved) {
|
|
273
|
-
config.relay.token = saved;
|
|
274
|
-
console.log(`[bridge] Using saved bearer from ${authFilePath()}`);
|
|
275
|
-
} else {
|
|
276
|
-
try {
|
|
277
|
-
// ephemeral == NOT saveAuth: don't write back when pair completes.
|
|
278
|
-
config.relay.token = await pairInteractive(
|
|
279
|
-
config.relay.url, config.relay.machine, /* ephemeral */ !config.relay.saveAuth
|
|
280
|
-
);
|
|
281
|
-
} catch (e) {
|
|
282
|
-
console.error(`[bridge] ERROR: ${e.message}`);
|
|
283
|
-
process.exit(1);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
console.log(`[bridge] Connecting to relay as "${config.relay.machine}"...`);
|
|
288
|
-
startRelay(config);
|
|
289
|
-
} else {
|
|
290
|
-
console.log("[bridge] No relay configured — running in local-only mode.");
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Graceful shutdown
|
|
294
|
-
const cleanup = () => {
|
|
295
|
-
console.log("\n[bridge] Shutting down...");
|
|
296
|
-
bridge.close();
|
|
297
|
-
process.exit(0);
|
|
298
|
-
};
|
|
299
|
-
process.on("SIGINT", cleanup);
|
|
300
|
-
process.on("SIGTERM", cleanup);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
function installService() {
|
|
304
|
-
const os = require("node:os");
|
|
305
|
-
const fs = require("node:fs");
|
|
306
|
-
const path = require("node:path");
|
|
307
|
-
|
|
308
|
-
// Save current args to a config file for the service
|
|
309
|
-
const configPath = path.join(os.homedir(), ".claude-code-bridge.env");
|
|
310
|
-
const args = process.argv.slice(2).filter(a => a !== "install-service");
|
|
311
|
-
|
|
312
|
-
if (process.platform === "win32") {
|
|
313
|
-
// Windows: create a scheduled task
|
|
314
|
-
const { execSync } = require("node:child_process");
|
|
315
|
-
const script = `claude-code-bridge start ${args.join(" ")}`;
|
|
316
|
-
const taskCmd = `schtasks /Create /TN "Claude Code Bridge" /TR "cmd /c ${script}" /SC ONLOGON /F /RL HIGHEST`;
|
|
317
|
-
try {
|
|
318
|
-
execSync(taskCmd, { stdio: "inherit" });
|
|
319
|
-
console.log("[bridge] Service installed (Windows Scheduled Task).");
|
|
320
|
-
console.log("[bridge] To remove: claude-code-bridge uninstall-service");
|
|
321
|
-
} catch (e) {
|
|
322
|
-
console.error("[bridge] Failed to create scheduled task:", e.message);
|
|
323
|
-
}
|
|
324
|
-
} else if (process.platform === "darwin") {
|
|
325
|
-
// macOS: launchd
|
|
326
|
-
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
327
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
328
|
-
<plist version="1.0"><dict>
|
|
329
|
-
<key>Label</key><string>com.claude-code-bridge</string>
|
|
330
|
-
<key>ProgramArguments</key><array><string>claude-code-bridge</string><string>start</string>${args.map(a => `<string>${a}</string>`).join("")}</array>
|
|
331
|
-
<key>RunAtLoad</key><true/>
|
|
332
|
-
<key>KeepAlive</key><true/>
|
|
333
|
-
</dict></plist>`;
|
|
334
|
-
const plistPath = path.join(os.homedir(), "Library/LaunchAgents/com.claude-code-bridge.plist");
|
|
335
|
-
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
336
|
-
fs.writeFileSync(plistPath, plist);
|
|
337
|
-
try { require("node:child_process").execSync(`launchctl load -w "${plistPath}"`); } catch {}
|
|
338
|
-
console.log("[bridge] Service installed (macOS LaunchAgent).");
|
|
339
|
-
} else {
|
|
340
|
-
// Linux: systemd user unit
|
|
341
|
-
const unit = `[Unit]
|
|
342
|
-
Description=Claude Code Bridge
|
|
343
|
-
After=network-online.target
|
|
344
|
-
|
|
345
|
-
[Service]
|
|
346
|
-
Type=simple
|
|
347
|
-
ExecStart=claude-code-bridge start ${args.join(" ")}
|
|
348
|
-
Restart=on-failure
|
|
349
|
-
RestartSec=5s
|
|
350
|
-
|
|
351
|
-
[Install]
|
|
352
|
-
WantedBy=default.target`;
|
|
353
|
-
const unitDir = path.join(os.homedir(), ".config/systemd/user");
|
|
354
|
-
fs.mkdirSync(unitDir, { recursive: true });
|
|
355
|
-
fs.writeFileSync(path.join(unitDir, "claude-code-bridge.service"), unit);
|
|
356
|
-
try {
|
|
357
|
-
const { execSync } = require("node:child_process");
|
|
358
|
-
execSync("systemctl --user daemon-reload");
|
|
359
|
-
execSync("systemctl --user enable claude-code-bridge.service");
|
|
360
|
-
console.log("[bridge] Service installed (systemd user unit).");
|
|
361
|
-
} catch {}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function uninstallService() {
|
|
366
|
-
const os = require("node:os");
|
|
367
|
-
const path = require("node:path");
|
|
368
|
-
const fs = require("node:fs");
|
|
369
|
-
|
|
370
|
-
if (process.platform === "win32") {
|
|
371
|
-
try {
|
|
372
|
-
require("node:child_process").execSync('schtasks /Delete /TN "Claude Code Bridge" /F', { stdio: "inherit" });
|
|
373
|
-
} catch {}
|
|
374
|
-
console.log("[bridge] Service removed (Windows).");
|
|
375
|
-
} else if (process.platform === "darwin") {
|
|
376
|
-
const p = path.join(os.homedir(), "Library/LaunchAgents/com.claude-code-bridge.plist");
|
|
377
|
-
try { require("node:child_process").execSync(`launchctl unload -w "${p}"`); } catch {}
|
|
378
|
-
try { fs.unlinkSync(p); } catch {}
|
|
379
|
-
console.log("[bridge] Service removed (macOS).");
|
|
380
|
-
} else {
|
|
381
|
-
try {
|
|
382
|
-
const { execSync } = require("node:child_process");
|
|
383
|
-
execSync("systemctl --user disable --now claude-code-bridge.service");
|
|
384
|
-
fs.unlinkSync(path.join(os.homedir(), ".config/systemd/user/claude-code-bridge.service"));
|
|
385
|
-
execSync("systemctl --user daemon-reload");
|
|
386
|
-
} catch {}
|
|
387
|
-
console.log("[bridge] Service removed (Linux).");
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
main();
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { parseArgs } = require("node:util");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const { startBridge } = require("../lib/bridge");
|
|
9
|
+
const { startRelay } = require("../lib/relay-client");
|
|
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, ephemeral) {
|
|
45
|
+
const base = relayHttpBase(relayWsUrl);
|
|
46
|
+
process.stdout.write(`[bridge] Starting pair flow for "${machine}" against ${base}\n`);
|
|
47
|
+
if (ephemeral) process.stdout.write(`[bridge] Ephemeral mode — bearer will NOT be saved to disk.\n`);
|
|
48
|
+
|
|
49
|
+
const startResp = await fetch(`${base}/pair-cli/start`, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json" },
|
|
52
|
+
body: JSON.stringify({ machine }),
|
|
53
|
+
});
|
|
54
|
+
if (!startResp.ok) {
|
|
55
|
+
const text = await startResp.text();
|
|
56
|
+
throw new Error(`pair start failed (${startResp.status}): ${text}`);
|
|
57
|
+
}
|
|
58
|
+
const { code, poll_token, pair_url, expires_in } = await startResp.json();
|
|
59
|
+
|
|
60
|
+
console.log("\n ┌─────────────────────────────────────────────────┐");
|
|
61
|
+
console.log(" │ Open this URL in a browser to pair the device │");
|
|
62
|
+
console.log(" ├─────────────────────────────────────────────────┤");
|
|
63
|
+
console.log(` │ ${pair_url.padEnd(47)} │`);
|
|
64
|
+
console.log(" │ │");
|
|
65
|
+
console.log(` │ Confirm this code matches: ${code.padEnd(17)}│`);
|
|
66
|
+
console.log(" └─────────────────────────────────────────────────┘");
|
|
67
|
+
console.log(` Code expires in ${expires_in}s. Waiting for confirmation…\n`);
|
|
68
|
+
|
|
69
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
70
|
+
while (Date.now() < deadline) {
|
|
71
|
+
let resp;
|
|
72
|
+
try {
|
|
73
|
+
resp = await fetch(`${base}/pair-cli/claim`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ poll_token }),
|
|
77
|
+
});
|
|
78
|
+
} catch (e) {
|
|
79
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (resp.status === 202) continue; // still waiting, re-poll
|
|
83
|
+
if (resp.status === 404 || resp.status === 410) {
|
|
84
|
+
throw new Error("pair expired or invalidated — re-run to start over");
|
|
85
|
+
}
|
|
86
|
+
if (!resp.ok) {
|
|
87
|
+
const text = await resp.text();
|
|
88
|
+
throw new Error(`pair claim failed (${resp.status}): ${text}`);
|
|
89
|
+
}
|
|
90
|
+
const { bearer, machine: confirmed } = await resp.json();
|
|
91
|
+
if (ephemeral) {
|
|
92
|
+
console.log(`[bridge] ✓ Paired as "${confirmed}" (in-memory only, nothing written to disk).`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(`[bridge] ✓ Paired as "${confirmed}". Bearer saved to ${authFilePath()}`);
|
|
95
|
+
const auth = readAuthFile();
|
|
96
|
+
auth.bearers = auth.bearers || {};
|
|
97
|
+
auth.bearers[base] = auth.bearers[base] || {};
|
|
98
|
+
auth.bearers[base][confirmed] = bearer;
|
|
99
|
+
writeAuthFile(auth);
|
|
100
|
+
}
|
|
101
|
+
return bearer;
|
|
102
|
+
}
|
|
103
|
+
throw new Error("pair window expired — re-run to start over");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lookupSavedBearer(relayWsUrl, machine) {
|
|
107
|
+
const auth = readAuthFile();
|
|
108
|
+
return auth.bearers?.[relayHttpBase(relayWsUrl)]?.[machine] || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const HELP = `
|
|
112
|
+
claude-code-bridge — Bridge server for Claude Code CLI
|
|
113
|
+
|
|
114
|
+
Usage:
|
|
115
|
+
claude-code-bridge start [options] Start the bridge (+ optional relay)
|
|
116
|
+
claude-code-bridge install-service Register as a system service (auto-start on boot)
|
|
117
|
+
claude-code-bridge uninstall-service Remove the system service
|
|
118
|
+
claude-code-bridge --help Show this help
|
|
119
|
+
claude-code-bridge --version Print the installed version
|
|
120
|
+
|
|
121
|
+
Options:
|
|
122
|
+
--port <n> HTTP port for the local bridge (default: 8091)
|
|
123
|
+
--host <ip> Bind address (default: 127.0.0.1)
|
|
124
|
+
--token <secret> Bridge password (use the same in the extension). Auto-generated if omitted.
|
|
125
|
+
--cwd <path> Default working directory for Claude (default: current dir)
|
|
126
|
+
--claude-bin <path> Path to claude CLI binary (default: auto-detect)
|
|
127
|
+
--timeout <seconds> Max time for a single Claude call (default: 10800)
|
|
128
|
+
|
|
129
|
+
Relay options (connect to a remote relay server):
|
|
130
|
+
--relay-url <url> WebSocket URL of the relay server
|
|
131
|
+
--machine <name> Machine name for the relay
|
|
132
|
+
--token <bearer> Machine bearer (skips both auth.json and pair flow)
|
|
133
|
+
--save-auth Cache the bearer in ~/.claude-bridge/auth.json so the
|
|
134
|
+
next start reuses it. Required for unattended /
|
|
135
|
+
systemd-service installs. WITHOUT this flag the CLI
|
|
136
|
+
re-pairs (browser OTP) on every start — the default.
|
|
137
|
+
--cf-id <id> Cloudflare Access Client ID (optional)
|
|
138
|
+
--cf-secret <secret> Cloudflare Access Client Secret (optional)
|
|
139
|
+
|
|
140
|
+
Environment variables:
|
|
141
|
+
All options can be set via env vars with BRIDGE_ prefix:
|
|
142
|
+
BRIDGE_PORT, BRIDGE_HOST, BRIDGE_CWD, BRIDGE_TIMEOUT,
|
|
143
|
+
BRIDGE_RELAY_URL, BRIDGE_MACHINE_NAME, BRIDGE_MACHINE_TOKEN,
|
|
144
|
+
BRIDGE_SAVE_AUTH=1 (cache the bearer to ~/.claude-bridge/auth.json
|
|
145
|
+
for unattended restarts; default re-pairs every
|
|
146
|
+
start so nothing is persisted),
|
|
147
|
+
BRIDGE_CF_ID, BRIDGE_CF_SECRET
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
function env(key, def) {
|
|
151
|
+
return process.env["BRIDGE_" + key] || def;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function main() {
|
|
155
|
+
const args = process.argv.slice(2);
|
|
156
|
+
if (args.includes("--version") || args.includes("-v") || args[0] === "version") {
|
|
157
|
+
console.log(require("../package.json").version);
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
161
|
+
console.log(HELP);
|
|
162
|
+
process.exit(0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const command = args[0];
|
|
166
|
+
if (command === "start") {
|
|
167
|
+
const { values } = parseArgs({
|
|
168
|
+
args: args.slice(1),
|
|
169
|
+
options: {
|
|
170
|
+
port: { type: "string", default: env("PORT", "8091") },
|
|
171
|
+
host: { type: "string", default: env("HOST", "127.0.0.1") },
|
|
172
|
+
cwd: { type: "string", default: env("CWD", process.cwd()) },
|
|
173
|
+
"claude-bin": { type: "string", default: env("CLAUDE_BIN", "") },
|
|
174
|
+
timeout: { type: "string", default: env("TIMEOUT", "10800") },
|
|
175
|
+
"relay-url": { type: "string", default: env("RELAY_URL", "") },
|
|
176
|
+
machine: { type: "string", default: env("MACHINE_NAME", "") },
|
|
177
|
+
token: { type: "string", default: env("MACHINE_TOKEN", "") },
|
|
178
|
+
"save-auth":{ type: "boolean", default: env("SAVE_AUTH", "") === "1" },
|
|
179
|
+
ephemeral: { type: "boolean", default: false }, // deprecated, now default behavior — kept as a no-op for compatibility
|
|
180
|
+
"cf-id": { type: "string", default: env("CF_ID", "") },
|
|
181
|
+
"cf-secret": { type: "string", default: env("CF_SECRET", "") },
|
|
182
|
+
},
|
|
183
|
+
strict: false,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const config = {
|
|
187
|
+
port: parseInt(values.port, 10),
|
|
188
|
+
host: values.host,
|
|
189
|
+
cwd: values.cwd,
|
|
190
|
+
claudeBin: values["claude-bin"],
|
|
191
|
+
timeout: parseInt(values.timeout, 10) * 1000,
|
|
192
|
+
relay: values["relay-url"] ? {
|
|
193
|
+
url: values["relay-url"],
|
|
194
|
+
machine: values.machine,
|
|
195
|
+
token: values.token,
|
|
196
|
+
// Default = ephemeral (pair on every start, never persist). Opt in
|
|
197
|
+
// to caching with --save-auth when running unattended / as a service.
|
|
198
|
+
// The legacy --ephemeral flag is a no-op (kept for back-compat).
|
|
199
|
+
saveAuth: !!values["save-auth"],
|
|
200
|
+
cfId: values["cf-id"],
|
|
201
|
+
cfSecret: values["cf-secret"],
|
|
202
|
+
} : null,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const crypto = require("node:crypto");
|
|
206
|
+
if (config.relay) {
|
|
207
|
+
config.bearerToken = crypto.randomBytes(32).toString("hex");
|
|
208
|
+
} else {
|
|
209
|
+
config.bearerToken = values.token || crypto.randomBytes(32).toString("hex");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
run(config);
|
|
213
|
+
} else if (command === "install-service") {
|
|
214
|
+
installService();
|
|
215
|
+
} else if (command === "uninstall-service") {
|
|
216
|
+
uninstallService();
|
|
217
|
+
} else {
|
|
218
|
+
console.error(`Unknown command: ${command}\nRun claude-code-bridge --help`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function run(config) {
|
|
224
|
+
console.log(`[bridge] claude-bridge-cli v${require("../package.json").version}`);
|
|
225
|
+
console.log(`[bridge] Starting on http://${config.host}:${config.port}`);
|
|
226
|
+
console.log(`[bridge] Claude CWD: ${config.cwd}`);
|
|
227
|
+
|
|
228
|
+
// Find claude binary
|
|
229
|
+
if (!config.claudeBin) {
|
|
230
|
+
const { execSync } = require("node:child_process");
|
|
231
|
+
try {
|
|
232
|
+
const lines = execSync(
|
|
233
|
+
process.platform === "win32" ? "where claude" : "which claude",
|
|
234
|
+
{ encoding: "utf8" }
|
|
235
|
+
).trim().split(/\r?\n/);
|
|
236
|
+
if (process.platform === "win32") {
|
|
237
|
+
config.claudeBin = lines.find(l => l.endsWith(".cmd")) || lines[0];
|
|
238
|
+
} else {
|
|
239
|
+
config.claudeBin = lines[0];
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
console.error("[bridge] ERROR: claude CLI not found. Install it: npm install -g @anthropic-ai/claude-code");
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
console.log(`[bridge] Claude CLI: ${config.claudeBin}`);
|
|
247
|
+
|
|
248
|
+
// Start bridge HTTP server
|
|
249
|
+
const bridge = await startBridge(config);
|
|
250
|
+
console.log(`[bridge] Bridge ready on http://${config.host}:${config.port}`);
|
|
251
|
+
if (!config.relay) {
|
|
252
|
+
console.log(`[bridge] Bearer token: ${config.bearerToken}`);
|
|
253
|
+
console.log(`[bridge] Use this token when adding the endpoint in the extension.`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Start relay client if configured
|
|
257
|
+
if (config.relay) {
|
|
258
|
+
if (!config.relay.machine) {
|
|
259
|
+
console.error("[bridge] ERROR: --machine required when using --relay-url");
|
|
260
|
+
process.exit(1);
|
|
261
|
+
}
|
|
262
|
+
// Resolve a bearer:
|
|
263
|
+
// --token → use it directly (never look at auth.json)
|
|
264
|
+
// --save-auth → reuse saved auth.json; pair only if no entry exists
|
|
265
|
+
// default (no flags) → pair fresh on every start, never read or write
|
|
266
|
+
// auth.json (most secure; requires user at the
|
|
267
|
+
// keyboard for every launch)
|
|
268
|
+
if (!config.relay.token) {
|
|
269
|
+
const saved = config.relay.saveAuth
|
|
270
|
+
? lookupSavedBearer(config.relay.url, config.relay.machine)
|
|
271
|
+
: null;
|
|
272
|
+
if (saved) {
|
|
273
|
+
config.relay.token = saved;
|
|
274
|
+
console.log(`[bridge] Using saved bearer from ${authFilePath()}`);
|
|
275
|
+
} else {
|
|
276
|
+
try {
|
|
277
|
+
// ephemeral == NOT saveAuth: don't write back when pair completes.
|
|
278
|
+
config.relay.token = await pairInteractive(
|
|
279
|
+
config.relay.url, config.relay.machine, /* ephemeral */ !config.relay.saveAuth
|
|
280
|
+
);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
console.error(`[bridge] ERROR: ${e.message}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
console.log(`[bridge] Connecting to relay as "${config.relay.machine}"...`);
|
|
288
|
+
startRelay(config);
|
|
289
|
+
} else {
|
|
290
|
+
console.log("[bridge] No relay configured — running in local-only mode.");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Graceful shutdown
|
|
294
|
+
const cleanup = () => {
|
|
295
|
+
console.log("\n[bridge] Shutting down...");
|
|
296
|
+
bridge.close();
|
|
297
|
+
process.exit(0);
|
|
298
|
+
};
|
|
299
|
+
process.on("SIGINT", cleanup);
|
|
300
|
+
process.on("SIGTERM", cleanup);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function installService() {
|
|
304
|
+
const os = require("node:os");
|
|
305
|
+
const fs = require("node:fs");
|
|
306
|
+
const path = require("node:path");
|
|
307
|
+
|
|
308
|
+
// Save current args to a config file for the service
|
|
309
|
+
const configPath = path.join(os.homedir(), ".claude-code-bridge.env");
|
|
310
|
+
const args = process.argv.slice(2).filter(a => a !== "install-service");
|
|
311
|
+
|
|
312
|
+
if (process.platform === "win32") {
|
|
313
|
+
// Windows: create a scheduled task
|
|
314
|
+
const { execSync } = require("node:child_process");
|
|
315
|
+
const script = `claude-code-bridge start ${args.join(" ")}`;
|
|
316
|
+
const taskCmd = `schtasks /Create /TN "Claude Code Bridge" /TR "cmd /c ${script}" /SC ONLOGON /F /RL HIGHEST`;
|
|
317
|
+
try {
|
|
318
|
+
execSync(taskCmd, { stdio: "inherit" });
|
|
319
|
+
console.log("[bridge] Service installed (Windows Scheduled Task).");
|
|
320
|
+
console.log("[bridge] To remove: claude-code-bridge uninstall-service");
|
|
321
|
+
} catch (e) {
|
|
322
|
+
console.error("[bridge] Failed to create scheduled task:", e.message);
|
|
323
|
+
}
|
|
324
|
+
} else if (process.platform === "darwin") {
|
|
325
|
+
// macOS: launchd
|
|
326
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
327
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
328
|
+
<plist version="1.0"><dict>
|
|
329
|
+
<key>Label</key><string>com.claude-code-bridge</string>
|
|
330
|
+
<key>ProgramArguments</key><array><string>claude-code-bridge</string><string>start</string>${args.map(a => `<string>${a}</string>`).join("")}</array>
|
|
331
|
+
<key>RunAtLoad</key><true/>
|
|
332
|
+
<key>KeepAlive</key><true/>
|
|
333
|
+
</dict></plist>`;
|
|
334
|
+
const plistPath = path.join(os.homedir(), "Library/LaunchAgents/com.claude-code-bridge.plist");
|
|
335
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
336
|
+
fs.writeFileSync(plistPath, plist);
|
|
337
|
+
try { require("node:child_process").execSync(`launchctl load -w "${plistPath}"`); } catch {}
|
|
338
|
+
console.log("[bridge] Service installed (macOS LaunchAgent).");
|
|
339
|
+
} else {
|
|
340
|
+
// Linux: systemd user unit
|
|
341
|
+
const unit = `[Unit]
|
|
342
|
+
Description=Claude Code Bridge
|
|
343
|
+
After=network-online.target
|
|
344
|
+
|
|
345
|
+
[Service]
|
|
346
|
+
Type=simple
|
|
347
|
+
ExecStart=claude-code-bridge start ${args.join(" ")}
|
|
348
|
+
Restart=on-failure
|
|
349
|
+
RestartSec=5s
|
|
350
|
+
|
|
351
|
+
[Install]
|
|
352
|
+
WantedBy=default.target`;
|
|
353
|
+
const unitDir = path.join(os.homedir(), ".config/systemd/user");
|
|
354
|
+
fs.mkdirSync(unitDir, { recursive: true });
|
|
355
|
+
fs.writeFileSync(path.join(unitDir, "claude-code-bridge.service"), unit);
|
|
356
|
+
try {
|
|
357
|
+
const { execSync } = require("node:child_process");
|
|
358
|
+
execSync("systemctl --user daemon-reload");
|
|
359
|
+
execSync("systemctl --user enable claude-code-bridge.service");
|
|
360
|
+
console.log("[bridge] Service installed (systemd user unit).");
|
|
361
|
+
} catch {}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function uninstallService() {
|
|
366
|
+
const os = require("node:os");
|
|
367
|
+
const path = require("node:path");
|
|
368
|
+
const fs = require("node:fs");
|
|
369
|
+
|
|
370
|
+
if (process.platform === "win32") {
|
|
371
|
+
try {
|
|
372
|
+
require("node:child_process").execSync('schtasks /Delete /TN "Claude Code Bridge" /F', { stdio: "inherit" });
|
|
373
|
+
} catch {}
|
|
374
|
+
console.log("[bridge] Service removed (Windows).");
|
|
375
|
+
} else if (process.platform === "darwin") {
|
|
376
|
+
const p = path.join(os.homedir(), "Library/LaunchAgents/com.claude-code-bridge.plist");
|
|
377
|
+
try { require("node:child_process").execSync(`launchctl unload -w "${p}"`); } catch {}
|
|
378
|
+
try { fs.unlinkSync(p); } catch {}
|
|
379
|
+
console.log("[bridge] Service removed (macOS).");
|
|
380
|
+
} else {
|
|
381
|
+
try {
|
|
382
|
+
const { execSync } = require("node:child_process");
|
|
383
|
+
execSync("systemctl --user disable --now claude-code-bridge.service");
|
|
384
|
+
fs.unlinkSync(path.join(os.homedir(), ".config/systemd/user/claude-code-bridge.service"));
|
|
385
|
+
execSync("systemctl --user daemon-reload");
|
|
386
|
+
} catch {}
|
|
387
|
+
console.log("[bridge] Service removed (Linux).");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
main();
|