claude-remote-cli 1.9.0 → 1.9.2

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
@@ -2,30 +2,66 @@
2
2
 
3
3
  Remote web interface for interacting with Claude Code CLI sessions from any device.
4
4
 
5
- ## Quick Start
5
+ ## Getting Started
6
6
 
7
- ### Install from npm
7
+ ### 1. Install
8
8
 
9
9
  ```bash
10
10
  npm install -g claude-remote-cli
11
+ ```
12
+
13
+ Requires **Node.js 24+** and **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)** installed and available in your PATH.
14
+
15
+ ### 2. Start the server
16
+
17
+ ```bash
11
18
  claude-remote-cli
12
19
  ```
13
20
 
14
- ### Or run from source
21
+ On first launch you'll be prompted to set a PIN. This PIN protects access to your Claude sessions.
22
+
23
+ Open `http://localhost:3456` in your browser and enter your PIN.
24
+
25
+ ### 3. Add your project directories
26
+
27
+ Click **Settings** in the app to add root directories — these are parent folders that contain your git repos (scanned one level deep).
28
+
29
+ You can also edit `~/.config/claude-remote-cli/config.json` directly:
30
+
31
+ ```json
32
+ {
33
+ "rootDirs": ["/home/you/projects", "/home/you/work"]
34
+ }
35
+ ```
36
+
37
+ ### 4. Run as a background service (recommended)
38
+
39
+ To keep the server running after you close your terminal and auto-start on login:
15
40
 
16
41
  ```bash
17
- git clone https://github.com/donovan-yohan/claude-remote-cli.git
18
- cd claude-remote-cli
19
- npm install
20
- npm start
42
+ claude-remote-cli --bg
21
43
  ```
22
44
 
23
- On first launch you'll be prompted to set a PIN. Then open `http://localhost:3456` in your browser.
45
+ This installs a persistent service (launchd on macOS, systemd on Linux) that restarts on crash. See [Background Service](#background-service) for more options.
46
+
47
+ ### 5. Access from your phone with Tailscale
48
+
49
+ claude-remote-cli binds to `0.0.0.0` by default, but you should **not** expose it to the public internet. Use [Tailscale](https://tailscale.com/) to create a private encrypted network between your devices.
50
+
51
+ 1. **Install Tailscale** on your computer (the one running claude-remote-cli) and on your phone/tablet
52
+ - macOS: `brew install tailscale` or download from [tailscale.com/download](https://tailscale.com/download)
53
+ - Linux: follow the [install guide](https://tailscale.com/download/linux)
54
+ - iOS/Android: install the Tailscale app from your app store
55
+
56
+ 2. **Sign in** to the same Tailscale account on both devices
24
57
 
25
- ## Prerequisites
58
+ 3. **Find your computer's Tailscale IP** — run `tailscale ip` on your computer, or check the Tailscale admin console. It will look like `100.x.y.z`.
26
59
 
27
- - **Node.js 24+**
28
- - **Claude Code CLI** installed and available in your PATH (or configure `claudeCommand` in config)
60
+ 4. **Open the app** on your phone at `http://100.x.y.z:3456`
61
+
62
+ That's it. Your traffic is encrypted end-to-end via WireGuard, no ports are exposed to the internet, and only devices on your Tailscale network can reach the server.
63
+
64
+ > **Alternatives:** You can also use [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) (`cloudflared tunnel --url http://localhost:3456`) or [ngrok](https://ngrok.com/) (`ngrok http 3456`), but these expose your server to the public internet and rely on the PIN as your only layer of defense. Tailscale keeps everything private.
29
65
 
30
66
  ## Platform Support
31
67
 
@@ -150,14 +186,6 @@ claude-remote-cli/
150
186
  └── package.json
151
187
  ```
152
188
 
153
- ## Remote Access
154
-
155
- To access from your phone or another device, expose the server via a tunnel or VPN:
156
-
157
- - **Tailscale** (recommended): Install on both devices, access via Tailscale IP
158
- - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:3456`
159
- - **ngrok**: `ngrok http 3456`
160
-
161
189
  ## License
162
190
 
163
191
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
package/public/app.js CHANGED
@@ -162,65 +162,59 @@
162
162
  term.onScroll(updateScrollbar);
163
163
  term.onWriteParsed(updateScrollbar);
164
164
 
165
- term.onData(function (data) {
166
- if (ws && ws.readyState === WebSocket.OPEN) {
167
- ws.send(data);
165
+ var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || '');
166
+ term.attachCustomKeyEventHandler(function (e) {
167
+ if (isMobileDevice) {
168
+ return false;
168
169
  }
169
- });
170
170
 
171
- // On Windows/Linux, Ctrl+V is the paste shortcut but xterm.js intercepts it
172
- // internally without firing a native paste event, so our image paste handler
173
- // on terminalContainer never runs. Intercept Ctrl+V here to check for images.
174
- // On macOS, Ctrl+V sends a raw \x16 to the terminal (used by vim etc.), so
175
- // we only intercept on non-Mac platforms.
176
- var isMac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || '');
177
- if (!isMac) {
178
- term.attachCustomKeyEventHandler(function (e) {
179
- if (e.type === 'keydown' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey &&
180
- (e.key === 'v' || e.key === 'V')) {
181
- if (navigator.clipboard && navigator.clipboard.read) {
182
- navigator.clipboard.read().then(function (clipboardItems) {
183
- var imageBlob = null;
184
- var imageType = null;
185
-
186
- for (var i = 0; i < clipboardItems.length; i++) {
187
- var types = clipboardItems[i].types;
188
- for (var j = 0; j < types.length; j++) {
189
- if (types[j].indexOf('image/') === 0) {
190
- imageType = types[j];
191
- imageBlob = clipboardItems[i];
192
- break;
193
- }
171
+ if (!isMac && e.type === 'keydown' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey &&
172
+ (e.key === 'v' || e.key === 'V')) {
173
+ if (navigator.clipboard && navigator.clipboard.read) {
174
+ navigator.clipboard.read().then(function (clipboardItems) {
175
+ var imageBlob = null;
176
+ var imageType = null;
177
+
178
+ for (var i = 0; i < clipboardItems.length; i++) {
179
+ var types = clipboardItems[i].types;
180
+ for (var j = 0; j < types.length; j++) {
181
+ if (types[j].indexOf('image/') === 0) {
182
+ imageType = types[j];
183
+ imageBlob = clipboardItems[i];
184
+ break;
194
185
  }
195
- if (imageBlob) break;
196
186
  }
187
+ if (imageBlob) break;
188
+ }
197
189
 
198
- if (imageBlob) {
199
- imageBlob.getType(imageType).then(function (blob) {
200
- uploadImage(blob, imageType);
201
- });
202
- } else {
203
- navigator.clipboard.readText().then(function (text) {
204
- if (text) term.paste(text);
205
- });
206
- }
207
- }).catch(function () {
208
- // Clipboard read failed (permission denied, etc.) — fall back to text.
209
- // If readText also fails, paste is lost for this keypress; this only
210
- // happens when clipboard permission is fully denied, which is rare
211
- // for user-gesture-triggered reads on HTTPS origins.
212
- if (navigator.clipboard.readText) {
213
- navigator.clipboard.readText().then(function (text) {
214
- if (text) term.paste(text);
215
- }).catch(function () {});
216
- }
217
- });
218
- return false; // Prevent xterm from handling Ctrl+V
219
- }
190
+ if (imageBlob) {
191
+ imageBlob.getType(imageType).then(function (blob) {
192
+ uploadImage(blob, imageType);
193
+ });
194
+ } else {
195
+ navigator.clipboard.readText().then(function (text) {
196
+ if (text) term.paste(text);
197
+ });
198
+ }
199
+ }).catch(function () {
200
+ if (navigator.clipboard.readText) {
201
+ navigator.clipboard.readText().then(function (text) {
202
+ if (text) term.paste(text);
203
+ }).catch(function () {});
204
+ }
205
+ });
206
+ return false;
220
207
  }
221
- return true; // Let xterm handle all other keys
222
- });
223
- }
208
+ }
209
+
210
+ return true;
211
+ });
212
+
213
+ term.onData(function (data) {
214
+ if (ws && ws.readyState === WebSocket.OPEN) {
215
+ ws.send(data);
216
+ }
217
+ });
224
218
 
225
219
  var resizeObserver = new ResizeObserver(function () {
226
220
  fitAddon.fit();
@@ -255,7 +249,7 @@
255
249
  }
256
250
 
257
251
  terminalScrollbarThumb.style.display = 'block';
258
- var thumbHeight = Math.max(20, (term.rows / totalLines) * trackHeight);
252
+ var thumbHeight = Math.max(isMobileDevice ? 44 : 20, (term.rows / totalLines) * trackHeight);
259
253
  var thumbTop = (viewportTop / (totalLines - term.rows)) * (trackHeight - thumbHeight);
260
254
 
261
255
  terminalScrollbarThumb.style.height = thumbHeight + 'px';
@@ -268,7 +262,7 @@
268
262
  var totalLines = buf.baseY + term.rows;
269
263
  if (totalLines <= term.rows) return;
270
264
 
271
- var thumbHeight = Math.max(20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
265
+ var thumbHeight = Math.max(isMobileDevice ? 44 : 20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
272
266
  var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
273
267
  var relativeY = clientY - rect.top - thumbHeight / 2;
274
268
  var ratio = Math.max(0, Math.min(1, relativeY / trackUsable));
@@ -293,7 +287,7 @@
293
287
  var totalLines = buf.baseY + term.rows;
294
288
  if (totalLines <= term.rows) return;
295
289
 
296
- var thumbHeight = Math.max(20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
290
+ var thumbHeight = Math.max(isMobileDevice ? 44 : 20, (term.rows / totalLines) * terminalScrollbar.clientHeight);
297
291
  var trackUsable = terminalScrollbar.clientHeight - thumbHeight;
298
292
  var newTop = Math.max(0, Math.min(trackUsable, scrollbarDragStartTop + deltaY));
299
293
  var ratio = newTop / trackUsable;
@@ -331,7 +325,13 @@
331
325
  delete attentionSessions[sessionId];
332
326
  noSessionMsg.hidden = true;
333
327
  term.clear();
334
- term.focus();
328
+ if (isMobileDevice) {
329
+ mobileInput.value = '';
330
+ mobileInput.dispatchEvent(new Event('sessionchange'));
331
+ mobileInput.focus();
332
+ } else {
333
+ term.focus();
334
+ }
335
335
  closeSidebar();
336
336
  updateSessionTitle();
337
337
  highlightActiveSession();
@@ -1108,12 +1108,22 @@
1108
1108
  var text = btn.dataset.text;
1109
1109
  var key = btn.dataset.key;
1110
1110
 
1111
+ // Flush composed text before sending Enter so pending input isn't lost
1112
+ if (key === '\r' && mobileInput.flushComposedText) {
1113
+ mobileInput.flushComposedText();
1114
+ }
1115
+
1111
1116
  if (text !== undefined) {
1112
1117
  ws.send(text);
1113
1118
  } else if (key !== undefined) {
1114
1119
  ws.send(key);
1115
1120
  }
1116
1121
 
1122
+ // Clear input after Enter to reset state
1123
+ if (key === '\r' && mobileInput.clearInput) {
1124
+ mobileInput.clearInput();
1125
+ }
1126
+
1117
1127
  // Re-focus the mobile input to keep keyboard open
1118
1128
  if (isMobileDevice) {
1119
1129
  mobileInput.focus();
@@ -1277,6 +1287,7 @@
1277
1287
  if (!isMobileDevice) return;
1278
1288
 
1279
1289
  var lastInputValue = '';
1290
+ var isComposing = false;
1280
1291
 
1281
1292
  function focusMobileInput() {
1282
1293
  if (document.activeElement !== mobileInput) {
@@ -1336,6 +1347,50 @@
1336
1347
  }
1337
1348
  }
1338
1349
 
1350
+ mobileInput.addEventListener('compositionstart', function () {
1351
+ isComposing = true;
1352
+ });
1353
+
1354
+ mobileInput.addEventListener('compositionend', function () {
1355
+ isComposing = false;
1356
+ if (ws && ws.readyState === WebSocket.OPEN) {
1357
+ var currentValue = mobileInput.value;
1358
+ sendInputDiff(currentValue);
1359
+ lastInputValue = currentValue;
1360
+ }
1361
+ });
1362
+
1363
+ mobileInput.addEventListener('blur', function () {
1364
+ if (isComposing) {
1365
+ isComposing = false;
1366
+ lastInputValue = mobileInput.value;
1367
+ }
1368
+ });
1369
+
1370
+ mobileInput.addEventListener('sessionchange', function () {
1371
+ isComposing = false;
1372
+ lastInputValue = '';
1373
+ });
1374
+
1375
+ // Flush any pending composed text to the terminal.
1376
+ // Safe to call even if compositionend already ran: sendInputDiff
1377
+ // is a no-op when lastInputValue === mobileInput.value.
1378
+ function flushComposedText() {
1379
+ isComposing = false;
1380
+ if (ws && ws.readyState === WebSocket.OPEN) {
1381
+ var currentValue = mobileInput.value;
1382
+ sendInputDiff(currentValue);
1383
+ lastInputValue = currentValue;
1384
+ }
1385
+ }
1386
+ function clearInput() {
1387
+ mobileInput.value = '';
1388
+ lastInputValue = '';
1389
+ }
1390
+ // Expose for toolbar handler
1391
+ mobileInput.flushComposedText = flushComposedText;
1392
+ mobileInput.clearInput = clearInput;
1393
+
1339
1394
  // Handle text input with autocorrect
1340
1395
  var clearTimer = null;
1341
1396
  mobileInput.addEventListener('input', function () {
@@ -1348,6 +1403,8 @@
1348
1403
 
1349
1404
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
1350
1405
 
1406
+ if (isComposing) return;
1407
+
1351
1408
  var currentValue = mobileInput.value;
1352
1409
  sendInputDiff(currentValue);
1353
1410
  lastInputValue = currentValue;
@@ -1361,6 +1418,7 @@
1361
1418
 
1362
1419
  switch (e.key) {
1363
1420
  case 'Enter':
1421
+ flushComposedText();
1364
1422
  ws.send('\r');
1365
1423
  mobileInput.value = '';
1366
1424
  lastInputValue = '';
@@ -1379,24 +1437,14 @@
1379
1437
  lastInputValue = '';
1380
1438
  break;
1381
1439
  case 'Tab':
1382
- e.preventDefault();
1383
1440
  ws.send('\t');
1384
1441
  break;
1385
1442
  case 'ArrowUp':
1386
- e.preventDefault();
1387
1443
  ws.send('\x1b[A');
1388
1444
  break;
1389
1445
  case 'ArrowDown':
1390
- e.preventDefault();
1391
1446
  ws.send('\x1b[B');
1392
1447
  break;
1393
- case 'ArrowLeft':
1394
- // Let input handle cursor movement for autocorrect
1395
- handled = false;
1396
- break;
1397
- case 'ArrowRight':
1398
- handled = false;
1399
- break;
1400
1448
  default:
1401
1449
  handled = false;
1402
1450
  }
Binary file
Binary file
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="64" fill="#1a1a1a"/>
3
+ <text x="256" y="200" text-anchor="middle" font-family="monospace" font-size="72" font-weight="bold" fill="#d97757">&gt;_</text>
4
+ <text x="256" y="320" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="56" font-weight="600" fill="#ececec">Claude</text>
5
+ <text x="256" y="390" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="40" fill="#9b9b9b">Remote CLI</text>
6
+ </svg>
package/public/index.html CHANGED
@@ -5,6 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
6
6
  <title>Claude Remote CLI</title>
7
7
  <link rel="manifest" href="/manifest.json" />
8
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
9
+ <link rel="apple-touch-icon" href="/icon-192.png" />
8
10
  <meta name="mobile-web-app-capable" content="yes" />
9
11
  <meta name="apple-mobile-web-app-capable" content="yes" />
10
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -64,7 +66,7 @@
64
66
  <button id="menu-btn" class="icon-btn" aria-label="Open sessions menu">&#9776;</button>
65
67
  <span id="session-title" class="mobile-title">No session</span>
66
68
  </div>
67
- <input type="text" id="mobile-input" autocomplete="on" autocorrect="on" autocapitalize="sentences" spellcheck="true" aria-label="Terminal input" />
69
+ <input type="text" id="mobile-input" dir="ltr" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" aria-label="Terminal input" />
68
70
  <div id="terminal-container"></div>
69
71
  <div id="terminal-scrollbar"><div id="terminal-scrollbar-thumb"></div></div>
70
72
  <div id="no-session-msg">No active session. Create or select a session to begin.</div>
@@ -182,5 +184,6 @@
182
184
  <script src="/vendor/xterm.js"></script>
183
185
  <script src="/vendor/addon-fit.js"></script>
184
186
  <script src="/app.js"></script>
187
+ <script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');</script>
185
188
  </body>
186
189
  </html>
@@ -4,5 +4,22 @@
4
4
  "display": "standalone",
5
5
  "start_url": "/",
6
6
  "background_color": "#1a1a1a",
7
- "theme_color": "#1a1a1a"
7
+ "theme_color": "#1a1a1a",
8
+ "icons": [
9
+ {
10
+ "src": "/icon-192.png",
11
+ "sizes": "192x192",
12
+ "type": "image/png"
13
+ },
14
+ {
15
+ "src": "/icon-512.png",
16
+ "sizes": "512x512",
17
+ "type": "image/png"
18
+ },
19
+ {
20
+ "src": "/icon.svg",
21
+ "sizes": "any",
22
+ "type": "image/svg+xml"
23
+ }
24
+ ]
8
25
  }
package/public/style.css CHANGED
@@ -470,13 +470,20 @@ html, body {
470
470
 
471
471
  #mobile-input {
472
472
  position: absolute;
473
- top: -9999px;
474
- left: -9999px;
473
+ left: 0;
474
+ top: 0;
475
475
  width: 1px;
476
476
  height: 1px;
477
477
  opacity: 0;
478
478
  font-size: 16px; /* prevents iOS zoom on focus */
479
479
  z-index: -1;
480
+ border: 0;
481
+ padding: 0;
482
+ margin: 0;
483
+ outline: none;
484
+ color: transparent;
485
+ caret-color: transparent;
486
+ background: transparent;
480
487
  }
481
488
 
482
489
  /* ===== Terminal Scrollbar ===== */
@@ -1082,6 +1089,16 @@ dialog#delete-worktree-dialog h2 {
1082
1089
 
1083
1090
  #terminal-scrollbar {
1084
1091
  display: block;
1092
+ width: 24px;
1093
+ right: 0;
1094
+ }
1095
+
1096
+ #terminal-scrollbar-thumb {
1097
+ width: 24px;
1098
+ min-height: 44px;
1099
+ border-radius: 12px;
1100
+ background: var(--text-muted);
1101
+ opacity: 0.5;
1085
1102
  }
1086
1103
  }
1087
1104
 
package/public/sw.js ADDED
@@ -0,0 +1,5 @@
1
+ // Minimal service worker for PWA install prompt support.
2
+ // Does not cache — all requests pass through to the network.
3
+ self.addEventListener('fetch', function (event) {
4
+ event.respondWith(fetch(event.request));
5
+ });