claude-remote-cli 0.1.1 → 0.2.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
@@ -27,6 +27,10 @@ On first launch you'll be prompted to set a PIN. Then open `http://localhost:345
27
27
  - **Node.js 20+**
28
28
  - **Claude Code CLI** installed and available in your PATH (or configure `claudeCommand` in config)
29
29
 
30
+ ## Platform Support
31
+
32
+ Tested on **macOS** and **Linux**. Windows is not currently tested — file watching and PTY spawning may behave differently.
33
+
30
34
  ## CLI Usage
31
35
 
32
36
  ```
@@ -75,6 +79,7 @@ The PIN hash is stored in config under `pinHash`. To reset:
75
79
  - **Scrollback buffer** — reconnect to a session and see prior output
76
80
  - **Touch toolbar** — mobile-friendly buttons for special keys (arrows, Enter, Escape, Ctrl+C, Tab, y/n)
77
81
  - **Responsive layout** — works on desktop and mobile with slide-out sidebar
82
+ - **Real-time updates** — worktree changes on disk are pushed to the browser instantly via WebSocket
78
83
 
79
84
  ## Architecture
80
85
 
@@ -86,6 +91,7 @@ claude-remote-cli/
86
91
  │ ├── index.js # Express server, REST API routes
87
92
  │ ├── sessions.js # PTY session manager (node-pty)
88
93
  │ ├── ws.js # WebSocket relay (PTY ↔ browser)
94
+ │ ├── watcher.js # File watcher for .claude/worktrees/ changes
89
95
  │ ├── auth.js # PIN hashing, verification, rate limiting
90
96
  │ └── config.js # Config loading/saving
91
97
  ├── public/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "main": "server/index.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -85,6 +85,7 @@
85
85
  initTerminal();
86
86
  loadRepos();
87
87
  refreshAll();
88
+ connectEventSocket();
88
89
  }
89
90
 
90
91
  // ── Terminal ────────────────────────────────────────────────────────────────
@@ -164,13 +165,43 @@
164
165
 
165
166
  // ── Sessions & Worktrees ────────────────────────────────────────────────────
166
167
 
168
+ var eventWs = null;
169
+
170
+ function connectEventSocket() {
171
+ if (eventWs) {
172
+ eventWs.close();
173
+ eventWs = null;
174
+ }
175
+
176
+ var url = wsProtocol + '//' + location.host + '/ws/events';
177
+ eventWs = new WebSocket(url);
178
+
179
+ eventWs.onmessage = function (event) {
180
+ try {
181
+ var msg = JSON.parse(event.data);
182
+ if (msg.type === 'worktrees-changed') {
183
+ loadRepos();
184
+ refreshAll();
185
+ }
186
+ } catch (_) {}
187
+ };
188
+
189
+ eventWs.onclose = function () {
190
+ setTimeout(function () {
191
+ connectEventSocket();
192
+ }, 3000);
193
+ };
194
+
195
+ eventWs.onerror = function () {};
196
+ }
197
+
167
198
  function refreshAll() {
168
199
  Promise.all([
169
200
  fetch('/sessions').then(function (res) { return res.json(); }),
170
201
  fetch('/worktrees').then(function (res) { return res.json(); }),
171
202
  ])
172
203
  .then(function (results) {
173
- cachedSessions = results[0].sessions || results[0] || [];
204
+ cachedSessions = results[0] || [];
174
205
  cachedWorktrees = results[1] || [];
175
206
  populateSidebarFilters();
176
207
  renderUnifiedList();
@@ -531,8 +562,8 @@
531
562
  .then(function (data) {
532
563
  if (dialog.open) dialog.close();
533
564
  refreshAll();
534
- if (data.id || data.sessionId) {
535
- connectToSession(data.id || data.sessionId);
565
+ if (data.id) {
566
+ connectToSession(data.id);
536
567
  }
537
568
  })
538
569
  .catch(function () {});
@@ -566,7 +597,7 @@
566
597
  dialogStart.addEventListener('click', function () {
567
598
  var path = customPath.value.trim() || dialogRepoSelect.value;
568
599
  if (!path) return;
569
- startSession(path, null);
600
+ startSession(path);
570
601
  });
571
602
 
572
603
  dialogCancel.addEventListener('click', function () {
@@ -668,6 +699,7 @@
668
699
  settingsClose.addEventListener('click', function () {
669
700
  settingsDialog.close();
670
701
  loadRepos();
702
+ refreshAll();
671
703
  });
672
704
 
673
705
  // ── Touch Toolbar ───────────────────────────────────────────────────────────
package/server/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
3
4
  const http = require('http');
4
5
  const path = require('path');
5
6
  const readline = require('readline');
@@ -11,6 +12,7 @@ const { loadConfig, saveConfig, DEFAULTS } = require('./config');
11
12
  const auth = require('./auth');
12
13
  const sessions = require('./sessions');
13
14
  const { setupWebSocket } = require('./ws');
15
+ const { WorktreeWatcher } = require('./watcher');
14
16
 
15
17
  // When run via CLI bin, config lives in ~/.config/claude-remote-cli/
16
18
  // When run directly (development), fall back to local config.json
@@ -40,6 +42,32 @@ function promptPin(question) {
40
42
  });
41
43
  }
42
44
 
45
+ function scanReposInRoot(rootDir) {
46
+ const repos = [];
47
+ let entries;
48
+ try {
49
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
50
+ } catch (_) {
51
+ return repos;
52
+ }
53
+ for (const entry of entries) {
54
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
55
+ const fullPath = path.join(rootDir, entry.name);
56
+ if (fs.existsSync(path.join(fullPath, '.git'))) {
57
+ repos.push({ name: entry.name, path: fullPath, root: rootDir });
58
+ }
59
+ }
60
+ return repos;
61
+ }
62
+
63
+ function scanAllRepos(rootDirs) {
64
+ const repos = [];
65
+ for (const rootDir of rootDirs) {
66
+ repos.push(...scanReposInRoot(rootDir));
67
+ }
68
+ return repos;
69
+ }
70
+
43
71
  async function main() {
44
72
  let config;
45
73
  try {
@@ -75,6 +103,12 @@ async function main() {
75
103
  next();
76
104
  }
77
105
 
106
+ const watcher = new WorktreeWatcher();
107
+ watcher.rebuild(config.rootDirs || []);
108
+
109
+ const server = http.createServer(app);
110
+ const { broadcastEvent } = setupWebSocket(server, authenticatedTokens, watcher);
111
+
78
112
  // POST /auth
79
113
  app.post('/auth', async (req, res) => {
80
114
  const ip = req.ip || req.connection.remoteAddress;
@@ -116,24 +150,7 @@ async function main() {
116
150
 
117
151
  // GET /repos — scan root dirs for repos
118
152
  app.get('/repos', requireAuth, (req, res) => {
119
- const fs = require('fs');
120
- const roots = config.rootDirs || [];
121
- const repos = [];
122
- for (const rootDir of roots) {
123
- try {
124
- const entries = fs.readdirSync(rootDir, { withFileTypes: true });
125
- for (const entry of entries) {
126
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
127
- const fullPath = path.join(rootDir, entry.name);
128
- const hasGit = fs.existsSync(path.join(fullPath, '.git'));
129
- if (hasGit) {
130
- repos.push({ name: entry.name, path: fullPath, root: rootDir });
131
- }
132
- }
133
- } catch (_) {
134
- // skip unreadable dirs
135
- }
136
- }
153
+ const repos = scanAllRepos(config.rootDirs || []);
137
154
  // Also include legacy manually-added repos
138
155
  if (config.repos) {
139
156
  for (const repo of config.repos) {
@@ -147,51 +164,35 @@ async function main() {
147
164
 
148
165
  // GET /worktrees?repo=<path> — list worktrees; omit repo to scan all repos in all rootDirs
149
166
  app.get('/worktrees', requireAuth, (req, res) => {
150
- const fs = require('fs');
151
167
  const repoParam = req.query.repo;
152
168
  const roots = config.rootDirs || [];
153
169
  const worktrees = [];
154
170
 
155
- // Build list of repos to scan
156
- const reposToScan = [];
171
+ let reposToScan;
157
172
  if (repoParam) {
158
- // Single repo mode (used by new session dialog)
159
173
  const root = roots.find(function (r) { return repoParam.startsWith(r); }) || '';
160
- reposToScan.push({ path: repoParam, name: repoParam.split('/').filter(Boolean).pop(), root });
174
+ reposToScan = [{ path: repoParam, name: repoParam.split('/').filter(Boolean).pop(), root }];
161
175
  } else {
162
- // Scan all repos in all roots
163
- for (const rootDir of roots) {
164
- try {
165
- const entries = fs.readdirSync(rootDir, { withFileTypes: true });
166
- for (const entry of entries) {
167
- if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
168
- const fullPath = path.join(rootDir, entry.name);
169
- if (fs.existsSync(path.join(fullPath, '.git'))) {
170
- reposToScan.push({ path: fullPath, name: entry.name, root: rootDir });
171
- }
172
- }
173
- } catch (_) {
174
- // skip unreadable dirs
175
- }
176
- }
176
+ reposToScan = scanAllRepos(roots);
177
177
  }
178
178
 
179
179
  for (const repo of reposToScan) {
180
180
  const worktreeDir = path.join(repo.path, '.claude', 'worktrees');
181
+ let entries;
181
182
  try {
182
- const entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
183
- for (const entry of entries) {
184
- if (!entry.isDirectory()) continue;
185
- worktrees.push({
186
- name: entry.name,
187
- path: path.join(worktreeDir, entry.name),
188
- repoName: repo.name,
189
- repoPath: repo.path,
190
- root: repo.root,
191
- });
192
- }
183
+ entries = fs.readdirSync(worktreeDir, { withFileTypes: true });
193
184
  } catch (_) {
194
- // no worktrees dir — that's fine
185
+ continue;
186
+ }
187
+ for (const entry of entries) {
188
+ if (!entry.isDirectory()) continue;
189
+ worktrees.push({
190
+ name: entry.name,
191
+ path: path.join(worktreeDir, entry.name),
192
+ repoName: repo.name,
193
+ repoPath: repo.path,
194
+ root: repo.root,
195
+ });
195
196
  }
196
197
  }
197
198
 
@@ -215,6 +216,8 @@ async function main() {
215
216
  }
216
217
  config.rootDirs.push(rootPath);
217
218
  saveConfig(CONFIG_PATH, config);
219
+ watcher.rebuild(config.rootDirs);
220
+ broadcastEvent('worktrees-changed');
218
221
  res.status(201).json(config.rootDirs);
219
222
  });
220
223
 
@@ -226,6 +229,8 @@ async function main() {
226
229
  }
227
230
  config.rootDirs = config.rootDirs.filter((r) => r !== rootPath);
228
231
  saveConfig(CONFIG_PATH, config);
232
+ watcher.rebuild(config.rootDirs);
233
+ broadcastEvent('worktrees-changed');
229
234
  res.json(config.rootDirs);
230
235
  });
231
236
 
@@ -295,9 +300,6 @@ async function main() {
295
300
  }
296
301
  });
297
302
 
298
- const server = http.createServer(app);
299
- setupWebSocket(server, authenticatedTokens);
300
-
301
303
  server.listen(config.port, config.host, () => {
302
304
  console.log(`claude-remote-cli listening on ${config.host}:${config.port}`);
303
305
  });
@@ -0,0 +1,78 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const EventEmitter = require('events');
6
+
7
+ class WorktreeWatcher extends EventEmitter {
8
+ constructor() {
9
+ super();
10
+ this._watchers = [];
11
+ this._debounceTimer = null;
12
+ }
13
+
14
+ rebuild(rootDirs) {
15
+ this._closeAll();
16
+
17
+ for (const rootDir of rootDirs) {
18
+ let entries;
19
+ try {
20
+ entries = fs.readdirSync(rootDir, { withFileTypes: true });
21
+ } catch (_) {
22
+ continue;
23
+ }
24
+ for (const entry of entries) {
25
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
26
+ const repoPath = path.join(rootDir, entry.name);
27
+ if (!fs.existsSync(path.join(repoPath, '.git'))) continue;
28
+ this._watchRepo(repoPath);
29
+ }
30
+ }
31
+ }
32
+
33
+ _watchRepo(repoPath) {
34
+ const worktreeDir = path.join(repoPath, '.claude', 'worktrees');
35
+ if (fs.existsSync(worktreeDir)) {
36
+ this._addWatch(worktreeDir);
37
+ } else {
38
+ const claudeDir = path.join(repoPath, '.claude');
39
+ if (fs.existsSync(claudeDir)) {
40
+ this._addWatch(claudeDir);
41
+ }
42
+ }
43
+ }
44
+
45
+ _addWatch(dirPath) {
46
+ try {
47
+ const watcher = fs.watch(dirPath, { persistent: false }, () => {
48
+ this._debouncedEmit();
49
+ });
50
+ watcher.on('error', () => {});
51
+ this._watchers.push(watcher);
52
+ } catch (_) {}
53
+ }
54
+
55
+ _debouncedEmit() {
56
+ if (this._debounceTimer) clearTimeout(this._debounceTimer);
57
+ this._debounceTimer = setTimeout(() => {
58
+ this.emit('worktrees-changed');
59
+ }, 500);
60
+ }
61
+
62
+ _closeAll() {
63
+ for (const w of this._watchers) {
64
+ try { w.close(); } catch (_) {}
65
+ }
66
+ this._watchers = [];
67
+ if (this._debounceTimer) {
68
+ clearTimeout(this._debounceTimer);
69
+ this._debounceTimer = null;
70
+ }
71
+ }
72
+
73
+ close() {
74
+ this._closeAll();
75
+ }
76
+ }
77
+
78
+ module.exports = { WorktreeWatcher };
package/server/ws.js CHANGED
@@ -16,11 +16,26 @@ function parseCookies(cookieHeader) {
16
16
  return cookies;
17
17
  }
18
18
 
19
- function setupWebSocket(server, authenticatedTokens) {
19
+ function setupWebSocket(server, authenticatedTokens, watcher) {
20
20
  const wss = new WebSocketServer({ noServer: true });
21
+ const eventClients = new Set();
22
+
23
+ function broadcastEvent(type) {
24
+ const msg = JSON.stringify({ type });
25
+ for (const client of eventClients) {
26
+ if (client.readyState === client.OPEN) {
27
+ client.send(msg);
28
+ }
29
+ }
30
+ }
31
+
32
+ if (watcher) {
33
+ watcher.on('worktrees-changed', function () {
34
+ broadcastEvent('worktrees-changed');
35
+ });
36
+ }
21
37
 
22
38
  server.on('upgrade', (request, socket, head) => {
23
- // Authenticate via cookie
24
39
  const cookies = parseCookies(request.headers.cookie);
25
40
  if (!authenticatedTokens.has(cookies.token)) {
26
41
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
@@ -28,7 +43,16 @@ function setupWebSocket(server, authenticatedTokens) {
28
43
  return;
29
44
  }
30
45
 
31
- // Extract sessionId from URL /ws/:sessionId
46
+ // Event channel: /ws/events
47
+ if (request.url === '/ws/events') {
48
+ wss.handleUpgrade(request, socket, head, (ws) => {
49
+ eventClients.add(ws);
50
+ ws.on('close', () => { eventClients.delete(ws); });
51
+ });
52
+ return;
53
+ }
54
+
55
+ // PTY channel: /ws/:sessionId
32
56
  const match = request.url && request.url.match(/^\/ws\/([a-f0-9]+)$/);
33
57
  if (!match) {
34
58
  socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
@@ -52,21 +76,16 @@ function setupWebSocket(server, authenticatedTokens) {
52
76
  wss.on('connection', (ws, request, session) => {
53
77
  const ptyProcess = session.pty;
54
78
 
55
- // Replay scrollback buffer so client sees all prior output
56
- if (session.scrollback && session.scrollback.length > 0) {
57
- for (const chunk of session.scrollback) {
58
- ws.send(chunk);
59
- }
79
+ for (const chunk of session.scrollback) {
80
+ ws.send(chunk);
60
81
  }
61
82
 
62
- // PTY output -> WebSocket
63
83
  const dataHandler = ptyProcess.onData((data) => {
64
84
  if (ws.readyState === ws.OPEN) {
65
85
  ws.send(data);
66
86
  }
67
87
  });
68
88
 
69
- // WebSocket input -> PTY
70
89
  ws.on('message', (msg) => {
71
90
  const str = msg.toString();
72
91
  try {
@@ -75,18 +94,14 @@ function setupWebSocket(server, authenticatedTokens) {
75
94
  sessions.resize(session.id, parsed.cols, parsed.rows);
76
95
  return;
77
96
  }
78
- } catch (_) {
79
- // Not JSON — fall through to write
80
- }
97
+ } catch (_) {}
81
98
  ptyProcess.write(str);
82
99
  });
83
100
 
84
- // Cleanup on WebSocket close
85
101
  ws.on('close', () => {
86
102
  dataHandler.dispose();
87
103
  });
88
104
 
89
- // Close WebSocket when PTY exits
90
105
  ptyProcess.onExit(() => {
91
106
  if (ws.readyState === ws.OPEN) {
92
107
  ws.close(1000);
@@ -94,7 +109,7 @@ function setupWebSocket(server, authenticatedTokens) {
94
109
  });
95
110
  });
96
111
 
97
- return wss;
112
+ return { wss, broadcastEvent };
98
113
  }
99
114
 
100
115
  module.exports = { setupWebSocket };