anyagent-bridge 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,15 +7,20 @@ Control your local computer's terminal — and **any** CLI AI coding agent you'v
7
7
  ## Quick start
8
8
 
9
9
  ```bash
10
- # Fastest run it with one command (Node 18+):
10
+ # New here? A guided, first-timer setup (checks prerequisites, helps you go mobile):
11
+ npx anyagent-bridge setup
12
+
13
+ # Or run it directly (Node 18+):
11
14
  npx anyagent-bridge
12
15
 
13
16
  # Or self-host with Docker:
14
17
  docker compose up -d --build && docker compose logs bridge
15
18
  ```
16
19
 
17
- Open the printed URL, paste the access token from the banner, and you're in. Full
18
- install paths (npx · from source · Docker) and per-OS notes are in
20
+ Open the printed URL, paste the access token from the banner, and you're in. To open
21
+ it on a phone or another PC, click **"📱 Connect a device"** in the top bar and scan
22
+ the QR. New to all this? Start with **[docs/GETTING-STARTED.md](docs/GETTING-STARTED.md)**.
23
+ Full install paths (npx · from source · Docker) and per-OS notes are in
19
24
  **[docs/INSTALL.md](docs/INSTALL.md)**; read **[docs/SECURITY.md](docs/SECURITY.md)**
20
25
  before exposing the bridge beyond localhost, and
21
26
  **[docs/WALKTHROUGH.md](docs/WALKTHROUGH.md)** for a guided tour.
@@ -34,6 +39,7 @@ before exposing the bridge beyond localhost, and
34
39
  - Persistent sessions that survive reconnects, with automatic PTY respawn and backoff.
35
40
  - Heartbeat + dead-connection detection so stale viewers get cleaned up.
36
41
  - File management API: browse, read, write, rename, move, delete, upload, download — all behind a path whitelist.
42
+ - Add projects by **browsing folders** in the UI (the 📁 Projects button) — no typing full paths.
37
43
  - Crash guards (uncaught exceptions, signals) so the server stays up.
38
44
  - Constant-time token comparison and basic rate limiting.
39
45
  - Optional **login**: Google/GitHub OAuth, TOTP 2FA, and signed expiring sessions on top of the token (Stage 3).
@@ -28,6 +28,7 @@ function printHelp() {
28
28
  '',
29
29
  'Usage:',
30
30
  ' anyagent-bridge [options]',
31
+ ' anyagent-bridge setup Guided, first-timer setup (recommended to start).',
31
32
  '',
32
33
  'Options:',
33
34
  ' -p, --port <n> Port to listen on (default 3001).',
@@ -53,6 +54,11 @@ function fail(msg) {
53
54
  process.exit(1);
54
55
  }
55
56
 
57
+ // Subcommand: `anyagent-bridge setup` launches the guided first-timer wizard.
58
+ // Anything else is the normal flag-parse + boot below.
59
+ if (argv[0] === 'setup') {
60
+ require(path.join(__dirname, 'setup.js'));
61
+ } else {
56
62
  for (let i = 0; i < argv.length; i++) {
57
63
  let arg = argv[i];
58
64
  // Support the `--flag=value` form for long options. Splitting it out here also
@@ -125,3 +131,4 @@ for (let i = 0; i < argv.length; i++) {
125
131
  // Boot the server in-process. ROOT inside the server resolves relative to its own
126
132
  // __dirname, so config.json and .data live alongside the installed package.
127
133
  require(path.join(__dirname, '..', 'server', 'index.js'));
134
+ }
package/bin/setup.js ADDED
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * anyagent-bridge setup — a friendly, first-timer guided launcher.
4
+ *
5
+ * Invoked as `npx anyagent-bridge setup` (the launcher dispatches the `setup`
6
+ * subcommand here). It asks a few plain questions, checks prerequisites, helps
7
+ * you pick where you'll open the bridge (this computer / same Wi-Fi / phone over
8
+ * the internet), then boots the normal server with the matching settings — it
9
+ * only sets the same PORT / HOST / BRIDGE_* env vars the launcher already uses,
10
+ * so nothing here changes how the server runs. The scannable phone QR lives in
11
+ * the browser UI ("Connect a device" → Phone); this wizard points you to it.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+ const os = require('os');
19
+ const crypto = require('crypto');
20
+ const readline = require('readline');
21
+ const pkg = require('../package.json');
22
+
23
+ // ── tiny ANSI helpers (no dependency) ─────────────────────────────────────────
24
+ const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
25
+ const c = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
26
+ const bold = (s) => c('1', s);
27
+ const dim = (s) => c('2', s);
28
+ const cyan = (s) => c('36', s);
29
+ const green = (s) => c('32', s);
30
+ const yellow = (s) => c('33', s);
31
+ const out = (s = '') => process.stdout.write(s + '\n');
32
+ const rule = () => out(dim('─'.repeat(63)));
33
+
34
+ // ── prerequisite helpers ──────────────────────────────────────────────────────
35
+ // Cross-platform "is this command on PATH?" without spawning the command itself.
36
+ function onPath(bin) {
37
+ const exts = process.platform === 'win32'
38
+ ? (process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
39
+ : [''];
40
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
41
+ if (!dir) continue;
42
+ for (const ext of exts) {
43
+ const p = path.join(dir, bin + ext);
44
+ try { fs.accessSync(p, fs.constants.X_OK); return true; } catch (_) { /* keep looking */ }
45
+ }
46
+ }
47
+ return false;
48
+ }
49
+
50
+ function firstLanIPv4() {
51
+ const ifaces = os.networkInterfaces();
52
+ for (const name of Object.keys(ifaces)) {
53
+ for (const ni of ifaces[name] || []) {
54
+ if (ni.family === 'IPv4' && !ni.internal) return ni.address;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+
60
+ // ── interactive prompt (degrades gracefully without a TTY) ─────────────────────
61
+ let rl = null;
62
+ function ask(question, choices) {
63
+ return new Promise((resolve) => {
64
+ if (!process.stdin.isTTY) { resolve(choices ? choices[0].key : ''); return; }
65
+ if (!rl) rl = readline.createInterface({ input: process.stdin, output: process.stdout });
66
+ rl.question(question, (answer) => resolve(answer.trim()));
67
+ });
68
+ }
69
+
70
+ async function pick(title, choices) {
71
+ out();
72
+ out(bold(title));
73
+ for (const ch of choices) out(` ${cyan(ch.key)}) ${ch.label}${ch.hint ? dim(' — ' + ch.hint) : ''}`);
74
+ if (!process.stdin.isTTY) {
75
+ out(dim(' (no interactive terminal — defaulting to option ' + choices[0].key + ')'));
76
+ return choices[0];
77
+ }
78
+ for (;;) {
79
+ const a = (await ask(`${dim('choose')} [${choices[0].key}]: `)) || choices[0].key;
80
+ const found = choices.find((ch) => ch.key === a.toLowerCase());
81
+ if (found) return found;
82
+ out(yellow(` "${a}" isn't one of the choices — try again.`));
83
+ }
84
+ }
85
+
86
+ async function confirm(question, def = true) {
87
+ if (!process.stdin.isTTY) return def;
88
+ const a = (await ask(`${question} ${dim(def ? '[Y/n]' : '[y/N]')} `)).toLowerCase();
89
+ if (!a) return def;
90
+ return a.startsWith('y');
91
+ }
92
+
93
+ // ── main flow ─────────────────────────────────────────────────────────────────
94
+ (async function main() {
95
+ out();
96
+ rule();
97
+ out(' ' + bold('AnyAgent Bridge') + dim(` · setup · v${pkg.version}`));
98
+ out(' ' + dim('Run your terminal + AI agents from a browser, phone, or another PC.'));
99
+ rule();
100
+
101
+ // 1) prerequisites -----------------------------------------------------------
102
+ out();
103
+ out(bold('1. Checking what you have'));
104
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
105
+ out(` ${nodeMajor >= 18 ? green('✓') : yellow('!')} Node.js ${process.version}` +
106
+ (nodeMajor >= 18 ? '' : yellow(' (needs 18+; please upgrade)')));
107
+ const agents = [
108
+ { id: 'claude', label: 'Claude Code (claude)' },
109
+ { id: 'codex', label: 'Codex (codex)' },
110
+ ].map((a) => ({ ...a, found: onPath(a.id) }));
111
+ for (const a of agents) {
112
+ out(` ${a.found ? green('✓') : dim('·')} ${a.label}${a.found ? '' : dim(' — not found on PATH')}`);
113
+ }
114
+ if (!agents.some((a) => a.found)) {
115
+ out();
116
+ out(yellow(' No AI agent CLI was found. The bridge still runs (you get a plain shell),'));
117
+ out(yellow(' but to launch an agent install one first, e.g. Claude Code:'));
118
+ out(' ' + cyan('npm install -g @anthropic-ai/claude-code') + dim(' then run `claude` once to log in.'));
119
+ out(dim(' (Any CLI works — register it under "agents" in config.json.)'));
120
+ }
121
+
122
+ // 2) where will you open it? --------------------------------------------------
123
+ const where = await pick('2. Where do you want to open the bridge?', [
124
+ { key: '1', label: 'This computer', hint: "open it in this machine's browser" },
125
+ { key: '2', label: 'Another device on the same Wi-Fi', hint: 'a phone or PC on your network' },
126
+ { key: '3', label: 'Your phone / anywhere over the internet', hint: 'via a free tunnel' },
127
+ ]);
128
+
129
+ // settings we will hand to the server (same env the launcher uses)
130
+ const port = process.env.PORT || '3001';
131
+ const token = process.env.BRIDGE_AUTH_TOKEN || crypto.randomBytes(32).toString('hex');
132
+ let host = '127.0.0.1';
133
+ let tunnelProvider = null;
134
+ const nextSteps = [];
135
+
136
+ if (where.key === '1') {
137
+ host = '127.0.0.1';
138
+ nextSteps.push(`Open ${cyan(`http://127.0.0.1:${port}/?token=…`)} in your browser (the full link with the token prints below).`);
139
+ nextSteps.push(`Only this computer can reach it. Nothing is exposed to your network or the internet.`);
140
+ } else if (where.key === '2') {
141
+ host = '0.0.0.0';
142
+ const ip = firstLanIPv4();
143
+ nextSteps.push(ip
144
+ ? `On the other device's browser, go to ${cyan(`http://${ip}:${port}/`)} and paste the access token.`
145
+ : `Find this computer's local IP (e.g. ${dim('System Settings → Network')}), then visit ${cyan(`http://<that-ip>:${port}/`)} on the other device.`);
146
+ nextSteps.push(`${yellow('Heads up:')} on your Wi-Fi the access token is the only lock. Keep it private; only people on your network can even reach the page.`);
147
+ nextSteps.push(`Easiest on a phone: open the page on this computer, click ${bold('"Connect a device" → Phone')}, and scan the QR.`);
148
+ } else {
149
+ host = '127.0.0.1'; // the tunnel reaches in; we don't bind to the network directly
150
+ out();
151
+ out(yellow(' ⚠ Going over the internet means anyone with the link could reach the page.'));
152
+ out(yellow(' The access token still gates it, but before you rely on this you should'));
153
+ out(yellow(' turn on login / 2FA / OAuth — see docs/SECURITY.md.'));
154
+ const ok = await confirm(' Understood — set up an internet tunnel now?', true);
155
+ if (ok) {
156
+ const prov = await pick(' Which free tunnel?', [
157
+ { key: '1', label: 'Microsoft Dev Tunnels', hint: 'needs a one-time `devtunnel user login`' },
158
+ { key: '2', label: 'Cloudflare Quick Tunnel', hint: 'no account; testing-grade URL' },
159
+ ]);
160
+ tunnelProvider = prov.key === '2' ? 'cloudflare-quick' : 'devtunnel';
161
+ const cli = tunnelProvider === 'cloudflare-quick' ? 'cloudflared' : 'devtunnel';
162
+ if (!onPath(cli)) {
163
+ out(yellow(` The "${cli}" command isn't installed yet.`));
164
+ out(` Install it, then re-run setup. Docs: ${cyan('docs/INSTALL.md')} (Remote access).`);
165
+ out(dim(' Continuing anyway — the server will fall back to localhost-only if the tunnel cannot start.'));
166
+ }
167
+ nextSteps.push(`When the tunnel connects, its public ${bold('https://…')} URL prints in the banner below and shows in the UI.`);
168
+ nextSteps.push(`Open that URL on your phone, or use ${bold('"Connect a device" → Phone')} in the UI to scan a QR.`);
169
+ nextSteps.push(`${yellow('Before sharing it:')} open the UI, enable login/2FA, and read docs/SECURITY.md.`);
170
+ } else {
171
+ host = '127.0.0.1';
172
+ nextSteps.push(`No tunnel started — running on this computer only. Re-run ${cyan('anyagent-bridge setup')} when you're ready to go remote.`);
173
+ }
174
+ }
175
+
176
+ // 3) summary + launch ---------------------------------------------------------
177
+ out();
178
+ out(bold('3. Starting the bridge'));
179
+ out(` ${dim('mode ')} ${where.label}`);
180
+ out(` ${dim('listen ')} ${host}:${port}`);
181
+ out(` ${dim('tunnel ')} ${tunnelProvider || 'off'}`);
182
+ out();
183
+ out(bold(' Next:'));
184
+ for (const s of nextSteps) out(` ${green('→')} ${s}`);
185
+ out();
186
+ out(dim(' Full beginner guide: docs/GETTING-STARTED.md · Security: docs/SECURITY.md'));
187
+ rule();
188
+ out();
189
+
190
+ if (rl) rl.close();
191
+
192
+ // Hand the chosen settings to the real server via the same env vars the
193
+ // launcher uses, then boot it in-process. Its own banner (URL, token, and any
194
+ // tunnel URL) prints next.
195
+ process.env.PORT = port;
196
+ process.env.HOST = host;
197
+ process.env.BRIDGE_AUTH_TOKEN = token;
198
+ if (tunnelProvider) {
199
+ process.env.BRIDGE_TUNNEL_ENABLED = 'true';
200
+ process.env.BRIDGE_TUNNEL_PROVIDER = tunnelProvider;
201
+ }
202
+ require(path.join(__dirname, '..', 'server', 'index.js'));
203
+ })().catch((e) => {
204
+ process.stderr.write('setup failed: ' + (e && e.stack ? e.stack : e) + '\n');
205
+ process.exit(1);
206
+ });
package/client/index.html CHANGED
@@ -105,6 +105,75 @@
105
105
  select, button, input { font-size: 12px; padding: 7px 9px; }
106
106
  #status { margin-left: auto; }
107
107
  }
108
+
109
+ /* Connect-a-device onboarding */
110
+ #onboard {
111
+ position: fixed; inset: 0; z-index: 60;
112
+ background: rgba(13,17,23,.96);
113
+ display: none; align-items: center; justify-content: center; padding: 18px;
114
+ }
115
+ #onboard.open { display: flex; }
116
+ #onboard .card {
117
+ position: relative;
118
+ background: var(--bar); border: 1px solid var(--border); border-radius: 12px;
119
+ width: 100%; max-width: 460px; max-height: 90vh; overflow-y: auto;
120
+ padding: 20px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.5);
121
+ }
122
+ #onboard h2 { margin: 0 0 2px; font-size: 17px; }
123
+ #onboard h2 .dot { color: var(--accent); }
124
+ #onboard .sub { margin: 0 0 14px; color: var(--muted); font-size: 12.5px; line-height: 1.5; }
125
+ #onboard .ob-close {
126
+ position: absolute; top: 12px; right: 14px;
127
+ background: transparent; border: none; color: var(--muted);
128
+ font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px;
129
+ }
130
+ #onboard .ob-close:hover { color: var(--fg); }
131
+ #onboard .qrbox {
132
+ display: flex; align-items: center; justify-content: center;
133
+ background: #fff; border-radius: 10px; padding: 14px;
134
+ margin: 0 auto 8px; width: max-content;
135
+ }
136
+ #onboard .qrbox img, #onboard .qrbox canvas { display: block; }
137
+ #onboard .qrcap { color: var(--muted); font-size: 12px; text-align: center; margin: 0 0 6px; }
138
+ #onboard .blk { border-top: 1px solid var(--border); padding: 12px 0 4px; }
139
+ #onboard .blk:first-of-type { border-top: none; }
140
+ #onboard .blk h3 { margin: 0 0 6px; font-size: 13px; }
141
+ #onboard .blk p { margin: 0 0 8px; font-size: 12.5px; color: var(--fg); line-height: 1.55; }
142
+ #onboard code {
143
+ background: #0d1117; border: 1px solid var(--border); border-radius: 5px;
144
+ padding: 1px 6px; font-size: 12px; word-break: break-all;
145
+ }
146
+ #onboard .warn { color: var(--yellow); }
147
+ #onboard .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 6px; }
148
+ #onboard .muted { color: var(--muted); font-size: 12px; }
149
+
150
+ /* Projects — folder-browser picker */
151
+ #projModal { position: fixed; inset: 0; z-index: 60; background: rgba(13,17,23,.96); display: none; align-items: center; justify-content: center; padding: 18px; }
152
+ #projModal.open { display: flex; }
153
+ #projModal .card { position: relative; background: var(--bar); border: 1px solid var(--border); border-radius: 12px; width: 100%; max-width: 480px; max-height: 90vh; overflow-y: auto; padding: 20px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.5); }
154
+ #projModal h2 { margin: 0 0 2px; font-size: 17px; }
155
+ #projModal .sub { margin: 0 0 12px; color: var(--muted); font-size: 12.5px; line-height: 1.5; }
156
+ #projModal .pm-close { position: absolute; top: 12px; right: 14px; background: transparent; border: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px; }
157
+ #projModal .pm-close:hover { color: var(--fg); }
158
+ #projModal .pm-list { list-style: none; margin: 0 0 12px; padding: 0; }
159
+ #projModal .pm-list li { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border: 1px solid var(--border); border-radius: 7px; margin-bottom: 6px; }
160
+ #projModal .pm-nm { font-size: 13px; font-weight: 600; white-space: nowrap; }
161
+ #projModal .pm-pa { font-size: 11px; color: var(--muted); word-break: break-all; flex: 1; }
162
+ #projModal .pm-del { background: transparent; border: none; color: var(--red); cursor: pointer; font-size: 14px; padding: 0 4px; }
163
+ #projModal .pm-empty { color: var(--muted); font-size: 12.5px; margin: 0 0 12px; }
164
+ #projModal .pm-add { border-top: 1px solid var(--border); padding-top: 12px; }
165
+ #projModal .pm-add h3 { margin: 0 0 8px; font-size: 13px; }
166
+ #projModal .pm-crumb { font-size: 12px; color: var(--accent); word-break: break-all; margin: 0 0 6px; }
167
+ #projModal .pm-browser { border: 1px solid var(--border); border-radius: 7px; max-height: 190px; overflow-y: auto; margin-bottom: 8px; background: #0d1117; }
168
+ #projModal .pm-row { display: flex; align-items: center; gap: 8px; padding: 7px 10px; cursor: pointer; font-size: 13px; border-bottom: 1px solid rgba(48,54,61,.5); }
169
+ #projModal .pm-row:last-child { border-bottom: none; }
170
+ #projModal .pm-row:hover { background: #161b22; }
171
+ #projModal .pm-row.up { color: var(--muted); }
172
+ #projModal .pm-sel { font-size: 12px; color: var(--fg); margin: 0 0 8px; word-break: break-all; }
173
+ #projModal .pm-sel b { color: var(--green); }
174
+ #projModal .pm-fields { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
175
+ #projModal .pm-fields input { flex: 1; min-width: 150px; }
176
+ #projModal .pm-err { color: var(--red); font-size: 12px; min-height: 15px; margin: 6px 0 0; }
108
177
  </style>
109
178
  </head>
110
179
  <body>
@@ -113,7 +182,9 @@
113
182
  <span class="title">AnyAgent<span class="dot">·</span>Bridge</span>
114
183
  <select id="agentSel" title="Agent"></select>
115
184
  <button id="startBtn">Start</button>
185
+ <button id="connectBtn" title="Open on a phone or another device">📱 Connect a device</button>
116
186
  <select id="projectSel" title="Project" style="display:none"></select>
187
+ <button id="projBtn" title="Add or manage projects by browsing folders">📁 Projects</button>
117
188
  <span class="spacer"></span>
118
189
  <span id="status" class="disconnected"><span class="led"></span><span id="statusText">disconnected</span></span>
119
190
  </div>
@@ -140,6 +211,53 @@
140
211
  </div>
141
212
  </div>
142
213
 
214
+ <div id="onboard">
215
+ <div class="card">
216
+ <button class="ob-close" id="obClose" aria-label="Close">×</button>
217
+ <h2>Connect a device</h2>
218
+ <p class="sub">Open this bridge on your phone or another computer. Scan the code, or follow the steps below.</p>
219
+ <div class="qrbox" id="obQrBox"><div id="obQr"></div></div>
220
+ <p class="qrcap" id="obQrCap"></p>
221
+ <div class="blk" id="obPhone">
222
+ <h3>📱 On your phone</h3>
223
+ <p id="obPhoneMsg">Preparing…</p>
224
+ <div class="row" id="obTunnelRow"></div>
225
+ </div>
226
+ <div class="blk">
227
+ <h3>💻 This computer</h3>
228
+ <p>Open <code id="obLocalUrl">…</code> in this machine's browser.</p>
229
+ </div>
230
+ <div class="blk">
231
+ <h3>🖥 Another PC on the same Wi-Fi</h3>
232
+ <p id="obLanMsg">…</p>
233
+ </div>
234
+ <div class="blk">
235
+ <p class="warn">Beyond this computer, the access token is the only lock until you turn on login — set up login / 2FA / OAuth and read <code>docs/SECURITY.md</code> before exposing it.</p>
236
+ </div>
237
+ </div>
238
+ </div>
239
+
240
+ <div id="projModal">
241
+ <div class="card">
242
+ <button class="pm-close" id="pmClose" aria-label="Close">×</button>
243
+ <h2>Projects</h2>
244
+ <p class="sub">A project scopes an agent session to a folder. Add one by browsing — no typing the full path.</p>
245
+ <ul class="pm-list" id="pmList"></ul>
246
+ <p class="pm-empty" id="pmEmpty" style="display:none">No projects yet.</p>
247
+ <div class="pm-add">
248
+ <h3>Add a project</h3>
249
+ <p class="pm-crumb" id="pmCrumb">…</p>
250
+ <div class="pm-browser" id="pmBrowser"></div>
251
+ <p class="pm-sel">Selected folder: <b id="pmSel">…</b></p>
252
+ <div class="pm-fields">
253
+ <input id="pmName" type="text" placeholder="project name" autocomplete="off" autocapitalize="off" spellcheck="false" />
254
+ <button class="primary" id="pmAdd">Add project</button>
255
+ </div>
256
+ <p class="pm-err" id="pmErr"></p>
257
+ </div>
258
+ </div>
259
+ </div>
260
+
143
261
  <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
144
262
  <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
145
263
  <script>
@@ -483,6 +601,205 @@
483
601
  } catch (_) {}
484
602
  }
485
603
 
604
+ // ---------- Connect-a-device onboarding ----------
605
+ const connectBtn = $("connectBtn"), onboard = $("onboard"), obClose = $("obClose");
606
+ let qrLibPromise = null, tunnelPolling = false;
607
+ function loadQrLib() {
608
+ if (window.QRCode) return Promise.resolve();
609
+ if (qrLibPromise) return qrLibPromise;
610
+ qrLibPromise = new Promise((resolve, reject) => {
611
+ const s = document.createElement("script");
612
+ s.src = "https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js";
613
+ s.onload = () => resolve();
614
+ s.onerror = () => reject(new Error("QR library failed to load"));
615
+ document.head.appendChild(s);
616
+ });
617
+ return qrLibPromise;
618
+ }
619
+ // The QR carries the access token so the device logs in by scanning. With a
620
+ // cookie-only (OAuth) session there is no JS token, so it links to the page
621
+ // and the device logs in there.
622
+ function loginQuery() { return token ? ("/?token=" + encodeURIComponent(token)) : "/"; }
623
+ async function showQr(url) {
624
+ const box = $("obQrBox"), holder = $("obQr");
625
+ try {
626
+ await loadQrLib();
627
+ holder.innerHTML = "";
628
+ new window.QRCode(holder, { text: url, width: 196, height: 196, correctLevel: window.QRCode.CorrectLevel.M });
629
+ box.style.display = "flex";
630
+ } catch (e) {
631
+ box.style.display = "none";
632
+ }
633
+ }
634
+ async function openOnboard() {
635
+ onboard.classList.add("open");
636
+ const port = location.port || (location.protocol === "https:" ? "443" : "80");
637
+ $("obLocalUrl").textContent = location.origin + "/";
638
+ $("obLanMsg").innerHTML =
639
+ 'Restart the bridge so it listens on your network — run <code>anyagent-bridge setup</code> and pick ' +
640
+ '"same Wi-Fi", or add <code>--host 0.0.0.0</code> — then on the other device open ' +
641
+ '<code>http://&lt;this-computer-ip&gt;:' + port + '/</code> and paste the token.';
642
+ const phoneMsg = $("obPhoneMsg"), tunnelRow = $("obTunnelRow"), qrCap = $("obQrCap");
643
+ tunnelRow.innerHTML = "";
644
+ let sys = null;
645
+ try { sys = await api("/api/system/status"); } catch (_) {}
646
+ const tunnelUrl = sys && sys.tunnel && sys.tunnel.url;
647
+ const isLocalHost = location.hostname === "127.0.0.1" || location.hostname === "localhost";
648
+ if (tunnelUrl) {
649
+ phoneMsg.textContent = "A live internet tunnel is running. Scan to open it from anywhere:";
650
+ qrCap.textContent = "Point your phone's camera at this code.";
651
+ showQr(tunnelUrl.replace(/\/+$/, "") + loginQuery());
652
+ } else if (!isLocalHost) {
653
+ phoneMsg.textContent = "Your phone must be on the same Wi-Fi as this computer. Scan to open:";
654
+ qrCap.textContent = "Point your phone's camera at this code.";
655
+ showQr(location.origin + loginQuery());
656
+ } else {
657
+ qrCap.textContent = "";
658
+ $("obQrBox").style.display = "none";
659
+ phoneMsg.innerHTML = "This bridge is only listening on this computer, so a phone can't reach it yet. Start a free internet tunnel:";
660
+ const btn = document.createElement("button");
661
+ btn.textContent = "Start internet tunnel";
662
+ btn.className = "primary";
663
+ btn.addEventListener("click", () => startTunnel(btn));
664
+ tunnelRow.appendChild(btn);
665
+ const note = document.createElement("span");
666
+ note.className = "muted";
667
+ note.textContent = "Turn on login/2FA first if anyone else could reach it.";
668
+ tunnelRow.appendChild(note);
669
+ }
670
+ }
671
+ async function startTunnel(btn) {
672
+ if (tunnelPolling) return; // ignore a second click while one poll is in flight
673
+ tunnelPolling = true;
674
+ btn.disabled = true; btn.textContent = "Starting…";
675
+ try {
676
+ await fetch("/api/tunnel/start", { method: "POST", credentials: "include", headers: token ? { Authorization: "Bearer " + token } : {} });
677
+ } catch (_) {}
678
+ for (let i = 0; i < 20; i++) {
679
+ await new Promise((r) => setTimeout(r, 1500));
680
+ if (!onboard.classList.contains("open")) { tunnelPolling = false; return; } // panel closed — stop polling
681
+ let sys; try { sys = await api("/api/system/status"); } catch (_) { continue; }
682
+ const url = sys && sys.tunnel && sys.tunnel.url;
683
+ if (url) {
684
+ tunnelPolling = false;
685
+ btn.textContent = "Tunnel ready ✓";
686
+ $("obPhoneMsg").textContent = "Scan to open over the internet:";
687
+ $("obQrCap").textContent = "Point your phone's camera at this code.";
688
+ showQr(url.replace(/\/+$/, "") + loginQuery());
689
+ return;
690
+ }
691
+ }
692
+ tunnelPolling = false;
693
+ btn.disabled = false; btn.textContent = "Start internet tunnel";
694
+ $("obPhoneMsg").innerHTML = "The tunnel didn't come up — the tunnel CLI may not be installed. See <code>docs/INSTALL.md</code> (Remote access).";
695
+ }
696
+ function closeOnboard() { onboard.classList.remove("open"); }
697
+ connectBtn.addEventListener("click", openOnboard);
698
+ obClose.addEventListener("click", closeOnboard);
699
+ onboard.addEventListener("click", (e) => { if (e.target === onboard) closeOnboard(); });
700
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && onboard.classList.contains("open")) closeOnboard(); });
701
+
702
+ // ---------- Projects: folder-browser picker (reuses GET /api/browse) ----------
703
+ const projBtn = $("projBtn"), projModal = $("projModal"), pmClose = $("pmClose");
704
+ let pmCurrent = null;
705
+ function pmBasename(p) { return p.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop() || p; }
706
+ async function pmBrowse(p) {
707
+ const browser = $("pmBrowser"), err = $("pmErr");
708
+ err.textContent = "";
709
+ // Raw fetch (not api()) so the server's own message survives a 403 — e.g. a
710
+ // folder outside the configured allowedPaths returns "Access denied", which is
711
+ // far more useful than a generic "unauthorized".
712
+ let res, data;
713
+ try {
714
+ res = await fetch("/api/browse" + (p ? "?path=" + encodeURIComponent(p) : ""),
715
+ { credentials: "include", headers: token ? { Authorization: "Bearer " + token } : {} });
716
+ data = await res.json().catch(() => ({}));
717
+ } catch (e) { err.textContent = "Could not reach the server."; return; }
718
+ if (res.status === 401) { err.textContent = "Session expired — reload to log in again."; return; }
719
+ if (!res.ok || data.error) { err.textContent = data.error || ("Could not open that folder (HTTP " + res.status + ")."); return; }
720
+ pmCurrent = data.current;
721
+ $("pmCrumb").textContent = data.current;
722
+ $("pmSel").textContent = data.current;
723
+ // keep the name field in sync with the folder until the user types their own
724
+ if ($("pmName").dataset.auto !== "0") {
725
+ $("pmName").value = pmBasename(data.current);
726
+ $("pmName").dataset.auto = "1";
727
+ }
728
+ browser.innerHTML = "";
729
+ if (data.parent) {
730
+ const up = document.createElement("div");
731
+ up.className = "pm-row up"; up.textContent = "⬆ ..";
732
+ up.addEventListener("click", () => pmBrowse(data.parent));
733
+ browser.appendChild(up);
734
+ }
735
+ if (!data.folders.length) {
736
+ const none = document.createElement("div");
737
+ none.className = "pm-row"; none.style.cursor = "default"; none.textContent = "(no subfolders here)";
738
+ browser.appendChild(none);
739
+ }
740
+ for (const f of data.folders) {
741
+ const row = document.createElement("div");
742
+ row.className = "pm-row"; row.textContent = "📁 " + f.name;
743
+ row.addEventListener("click", () => pmBrowse(f.path));
744
+ browser.appendChild(row);
745
+ }
746
+ }
747
+ async function pmLoadList() {
748
+ const ul = $("pmList");
749
+ ul.innerHTML = "";
750
+ let list = [];
751
+ try { const d = await api("/api/projects"); list = d.projects || []; } catch (_) {}
752
+ $("pmEmpty").style.display = list.length ? "none" : "";
753
+ for (const p of list) {
754
+ const li = document.createElement("li");
755
+ const nm = document.createElement("span"); nm.className = "pm-nm"; nm.textContent = p.name;
756
+ const pa = document.createElement("span"); pa.className = "pm-pa"; pa.textContent = p.path;
757
+ const del = document.createElement("button"); del.className = "pm-del"; del.textContent = "✕"; del.title = "Remove";
758
+ del.addEventListener("click", () => pmDelete(p.name));
759
+ li.appendChild(nm); li.appendChild(pa); li.appendChild(del);
760
+ ul.appendChild(li);
761
+ }
762
+ }
763
+ async function pmAddProject() {
764
+ const err = $("pmErr"), name = $("pmName").value.trim();
765
+ if (!pmCurrent) { err.textContent = "Pick a folder first."; return; }
766
+ if (!name) { err.textContent = "Give the project a name."; return; }
767
+ err.textContent = "";
768
+ let res, data;
769
+ try {
770
+ res = await fetch("/api/projects", { method: "POST", credentials: "include",
771
+ headers: Object.assign({ "Content-Type": "application/json" }, token ? { Authorization: "Bearer " + token } : {}),
772
+ body: JSON.stringify({ name: name, path: pmCurrent }) });
773
+ data = await res.json().catch(() => ({}));
774
+ } catch (e) { err.textContent = "Network error."; return; }
775
+ if (!res.ok || data.error) { err.textContent = data.error || ("HTTP " + res.status); return; }
776
+ $("pmName").value = ""; $("pmName").dataset.auto = "1";
777
+ await pmLoadList();
778
+ try { await loadProjects(); } catch (_) {} // refresh the toolbar dropdown
779
+ }
780
+ async function pmDelete(name) {
781
+ try {
782
+ await fetch("/api/projects/" + encodeURIComponent(name), { method: "DELETE", credentials: "include",
783
+ headers: token ? { Authorization: "Bearer " + token } : {} });
784
+ } catch (_) {}
785
+ await pmLoadList();
786
+ try { await loadProjects(); } catch (_) {}
787
+ }
788
+ function openProjects() {
789
+ projModal.classList.add("open");
790
+ $("pmErr").textContent = "";
791
+ $("pmName").value = ""; $("pmName").dataset.auto = "1";
792
+ pmLoadList();
793
+ pmBrowse(null); // start at the server's home / first allowed base
794
+ }
795
+ function closeProjects() { projModal.classList.remove("open"); }
796
+ $("pmName").addEventListener("input", () => { $("pmName").dataset.auto = "0"; });
797
+ projBtn.addEventListener("click", openProjects);
798
+ pmClose.addEventListener("click", closeProjects);
799
+ projModal.addEventListener("click", (e) => { if (e.target === projModal) closeProjects(); });
800
+ $("pmAdd").addEventListener("click", pmAddProject);
801
+ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && projModal.classList.contains("open")) closeProjects(); });
802
+
486
803
  // ---------- Bootstrap ----------
487
804
  (async function boot() {
488
805
  const params = new URLSearchParams(location.search);
@@ -0,0 +1,108 @@
1
+ # Getting started (for first-timers)
2
+
3
+ New to the terminal or to AI coding agents? This is the gentlest path to running
4
+ anyagent-bridge and opening it on your computer, another PC, or your phone. If you
5
+ just want the short version, the [README](../README.md) Quick start has it; for the
6
+ full reference see [INSTALL.md](INSTALL.md), and **before you let anything but your
7
+ own computer reach the bridge, read [SECURITY.md](SECURITY.md).**
8
+
9
+ ## What this is, in one line
10
+
11
+ anyagent-bridge puts your computer's terminal — and any AI coding agent you've
12
+ installed (like Claude Code) — onto a web page, so you can drive it from a browser
13
+ on the same computer, another PC, or your phone.
14
+
15
+ ## What you need first
16
+
17
+ 1. **Node.js 18 or newer.** Check by opening a terminal and running `node -v`. No
18
+ Node? Install it from <https://nodejs.org> (the "LTS" button).
19
+ 2. **An AI agent CLI, optional but recommended.** e.g. Claude Code:
20
+ `npm install -g @anthropic-ai/claude-code`, then run `claude` once to log in.
21
+ Without one you still get a normal shell in the browser; you just can't launch
22
+ an agent until one is installed.
23
+
24
+ ## Step 1 — Run the guided setup
25
+
26
+ In a terminal, run:
27
+
28
+ ```bash
29
+ npx anyagent-bridge setup
30
+ ```
31
+
32
+ The first time, npm asks to download the package — say yes. Then a friendly wizard:
33
+
34
+ - checks your Node version and which agents (`claude`, `codex`) it can find,
35
+ - asks **where you want to open the bridge**, and
36
+ - starts the server with the right settings and prints your access link.
37
+
38
+ That's it — you do **not** need to edit any config files to get going.
39
+
40
+ ## Step 2 — Pick where you'll open it
41
+
42
+ The wizard asks this; here's what each choice means.
43
+
44
+ ### 💻 On this same computer (simplest)
45
+
46
+ Choose "This computer". When it starts, it prints a line like
47
+ `URL: http://127.0.0.1:3001/?token=…`. Open that whole link (including the
48
+ `?token=…` part) in your browser. You're in. Nothing is exposed to your network
49
+ or the internet.
50
+
51
+ ### 🖥 Another PC or your phone on the same Wi-Fi
52
+
53
+ Choose "same Wi-Fi". The bridge starts listening on your network and the wizard
54
+ prints your computer's address, like `http://192.168.0.8:3001/`. On the other
55
+ device's browser, open that address and paste the access token (the long string
56
+ the wizard printed). Both devices must be on the same Wi-Fi.
57
+
58
+ > On your network, **the token is the only lock.** Keep it private.
59
+
60
+ ### 📱 Your phone from anywhere (over the internet)
61
+
62
+ Choose "phone / anywhere". The wizard helps you turn on a free **tunnel** (a safe
63
+ public web address that points back to your computer). When it connects, a public
64
+ `https://…` address appears. Open it on your phone, or use the QR (next step).
65
+
66
+ > Going over the internet means the page is reachable by anyone with the link. The
67
+ > token still gates it, but **turn on login / 2FA before you rely on it** — see
68
+ > [SECURITY.md](SECURITY.md).
69
+
70
+ ## Step 3 — Open it on your phone by scanning a QR
71
+
72
+ Once the bridge is open in a browser on your computer, click
73
+ **"📱 Connect a device"** in the top bar. A panel pops up with a **QR code** —
74
+ point your phone's camera at it and it opens the bridge on your phone, already
75
+ logged in. The same panel shows the steps for "this computer", "same Wi-Fi", and
76
+ starting an internet tunnel, so you never have to type the long token on a phone.
77
+
78
+ ## Step 4 — Launch an AI agent
79
+
80
+ In the web terminal you have a real shell. To start an agent, pick it from the
81
+ dropdown in the top bar (e.g. **Claude Code**) and click **Start** — it runs inside
82
+ the session, streamed live to your browser. Detaching the browser keeps it alive;
83
+ reconnect and you're back with full scrollback.
84
+
85
+ To run the agent inside a specific **project folder**, click **📁 Projects** in the
86
+ top bar and *browse* to the folder — no typing the full path. It's saved and shows
87
+ up in the toolbar's project dropdown; pick one before launching.
88
+
89
+ ## If something goes wrong
90
+
91
+ - **`node -v` says command not found** — install Node.js (above), then reopen the
92
+ terminal.
93
+ - **The agent dropdown is empty** — the agent CLI isn't installed or isn't on your
94
+ PATH. Verify it runs in the same terminal (e.g. type `claude`).
95
+ - **"Port already in use"** — something else uses port 3001. Run
96
+ `npx anyagent-bridge --port 8080` (any free number) or re-run `setup`.
97
+ - **The phone QR / tunnel didn't work** — the tunnel CLI may not be installed; see
98
+ [INSTALL.md](INSTALL.md) → Remote access. On the same Wi-Fi you don't need a
99
+ tunnel at all — use the "same Wi-Fi" address instead.
100
+ - **Lost the token** — it's printed in the terminal banner; scroll up, or stop and
101
+ re-run. You can also pin your own with `--token`.
102
+
103
+ ## Where to go next
104
+
105
+ - [INSTALL.md](INSTALL.md) — every install path (npx, source, Docker), per-OS notes.
106
+ - [SECURITY.md](SECURITY.md) — what to turn on before exposing the bridge. **Read
107
+ this before going remote.**
108
+ - [WALKTHROUGH.md](WALKTHROUGH.md) — a screenshot tour of the whole thing.
package/docs/INSTALL.md CHANGED
@@ -32,7 +32,9 @@ npx anyagent-bridge
32
32
  ```
33
33
 
34
34
  This downloads and runs the bridge in one step, then prints an access URL with a
35
- token. Open it in your browser. Useful flags:
35
+ token. Open it in your browser. New to this? Run `npx anyagent-bridge setup` for a
36
+ guided, first-timer flow (prerequisite checks + help opening it on a phone or
37
+ another PC); see [GETTING-STARTED.md](GETTING-STARTED.md). Useful flags:
36
38
 
37
39
  ```bash
38
40
  npx anyagent-bridge --port 8080 # listen on a different port
@@ -7,6 +7,7 @@ A guided tour of using anyagent-bridge end to end. The screenshots below live in
7
7
  ## 1. Start the bridge
8
8
 
9
9
  ```bash
10
+ npx anyagent-bridge setup # guided first-timer setup (recommended), or:
10
11
  npx anyagent-bridge # or: npm start / docker compose up -d --build
11
12
  ```
12
13
 
@@ -45,6 +46,10 @@ as you would in your own terminal — streamed live to the browser. Type prompts
45
46
  send keys, and watch output in real time. Detaching the browser keeps the session
46
47
  alive; reconnecting reattaches with full scrollback.
47
48
 
49
+ To scope a session to a folder, click **📁 Projects** in the top bar and *browse* to
50
+ it — no typing the path. Saved projects appear in the toolbar dropdown; pick one
51
+ before you launch the agent.
52
+
48
53
  ![Claude Code running inside a bridge session](screenshots/03-agent-running.png)
49
54
 
50
55
  ## 4. Browse and edit files
@@ -61,6 +66,13 @@ at startup (`--tunnel devtunnel`) or at runtime via the tunnel controls
61
66
  ready. Before exposing anything, read [SECURITY.md](SECURITY.md) and turn on
62
67
  login / 2FA / OAuth.
63
68
 
69
+ The easiest way onto a phone: click **"📱 Connect a device"** in the top bar. It
70
+ shows a scannable QR for your current address (or a live tunnel), the localhost
71
+ link, step-by-step same-Wi-Fi instructions, and a one-click "Start internet
72
+ tunnel" — so you never type the long token on a phone.
73
+
74
+ ![The "Connect a device" panel with a scannable QR](screenshots/05-connect-device.png)
75
+
64
76
  ![The bridge UI at a phone-sized viewport](screenshots/04-mobile.png)
65
77
 
66
78
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyagent-bridge",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Control your local terminal and any CLI AI coding agent from a browser, anywhere.",
5
5
  "license": "MIT",
6
6
  "author": "elon-choo",