claude-remote-cli 0.1.1 → 0.3.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 +41 -2
- package/bin/claude-remote-cli.js +54 -5
- package/package.json +1 -1
- package/public/app.js +36 -4
- package/server/index.js +55 -53
- package/server/service.js +181 -0
- package/server/watcher.js +78 -0
- package/server/ws.js +31 -16
package/README.md
CHANGED
|
@@ -27,19 +27,56 @@ 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
|
```
|
|
33
|
-
claude-remote-cli [options]
|
|
37
|
+
Usage: claude-remote-cli [options]
|
|
38
|
+
claude-remote-cli <command>
|
|
39
|
+
|
|
40
|
+
Commands:
|
|
41
|
+
install Install as a background service (survives reboot)
|
|
42
|
+
uninstall Stop and remove the background service
|
|
43
|
+
status Show whether the service is running
|
|
34
44
|
|
|
35
45
|
Options:
|
|
46
|
+
--bg Shortcut: install and start as background service
|
|
36
47
|
--port <port> Override server port (default: 3456)
|
|
37
48
|
--host <host> Override bind address (default: 0.0.0.0)
|
|
38
|
-
--config <path> Path to config.json
|
|
49
|
+
--config <path> Path to config.json (default: ~/.config/claude-remote-cli/config.json)
|
|
39
50
|
--version, -v Show version
|
|
40
51
|
--help, -h Show this help
|
|
41
52
|
```
|
|
42
53
|
|
|
54
|
+
## Background Service
|
|
55
|
+
|
|
56
|
+
Run as a persistent service that starts on login and restarts on crash:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
claude-remote-cli --bg
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or with custom options:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
claude-remote-cli install --port 4000
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Manage the service:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
claude-remote-cli status # Check if running
|
|
72
|
+
claude-remote-cli uninstall # Stop and remove
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
- **macOS**: Uses launchd (`~/Library/LaunchAgents/`)
|
|
76
|
+
- **Linux**: Uses systemd user units (`~/.config/systemd/user/`)
|
|
77
|
+
- **Logs (macOS)**: `~/.config/claude-remote-cli/logs/`
|
|
78
|
+
- **Logs (Linux)**: `journalctl --user -u claude-remote-cli -f`
|
|
79
|
+
|
|
43
80
|
## Configuration
|
|
44
81
|
|
|
45
82
|
Config is stored at `~/.config/claude-remote-cli/config.json` (created on first run).
|
|
@@ -75,6 +112,7 @@ The PIN hash is stored in config under `pinHash`. To reset:
|
|
|
75
112
|
- **Scrollback buffer** — reconnect to a session and see prior output
|
|
76
113
|
- **Touch toolbar** — mobile-friendly buttons for special keys (arrows, Enter, Escape, Ctrl+C, Tab, y/n)
|
|
77
114
|
- **Responsive layout** — works on desktop and mobile with slide-out sidebar
|
|
115
|
+
- **Real-time updates** — worktree changes on disk are pushed to the browser instantly via WebSocket
|
|
78
116
|
|
|
79
117
|
## Architecture
|
|
80
118
|
|
|
@@ -86,6 +124,7 @@ claude-remote-cli/
|
|
|
86
124
|
│ ├── index.js # Express server, REST API routes
|
|
87
125
|
│ ├── sessions.js # PTY session manager (node-pty)
|
|
88
126
|
│ ├── ws.js # WebSocket relay (PTY ↔ browser)
|
|
127
|
+
│ ├── watcher.js # File watcher for .claude/worktrees/ changes
|
|
89
128
|
│ ├── auth.js # PIN hashing, verification, rate limiting
|
|
90
129
|
│ └── config.js # Config loading/saving
|
|
91
130
|
├── public/
|
package/bin/claude-remote-cli.js
CHANGED
|
@@ -9,8 +9,15 @@ const args = process.argv.slice(2);
|
|
|
9
9
|
|
|
10
10
|
if (args.includes('--help') || args.includes('-h')) {
|
|
11
11
|
console.log(`Usage: claude-remote-cli [options]
|
|
12
|
+
claude-remote-cli <command>
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
install Install as a background service (survives reboot)
|
|
16
|
+
uninstall Stop and remove the background service
|
|
17
|
+
status Show whether the service is running
|
|
12
18
|
|
|
13
19
|
Options:
|
|
20
|
+
--bg Shortcut: install and start as background service
|
|
14
21
|
--port <port> Override server port (default: 3456)
|
|
15
22
|
--host <host> Override bind address (default: 0.0.0.0)
|
|
16
23
|
--config <path> Path to config.json (default: ~/.config/claude-remote-cli/config.json)
|
|
@@ -31,12 +38,54 @@ function getArg(flag) {
|
|
|
31
38
|
return args[idx + 1];
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
function resolveConfigPath() {
|
|
42
|
+
const explicit = getArg('--config');
|
|
43
|
+
if (explicit) return explicit;
|
|
44
|
+
const { CONFIG_DIR } = require('../server/service');
|
|
45
|
+
return path.join(CONFIG_DIR, 'config.json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function runServiceCommand(fn) {
|
|
49
|
+
try {
|
|
50
|
+
fn();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.error(e.message);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const command = args[0];
|
|
59
|
+
if (command === 'install' || command === 'uninstall' || command === 'status' || args.includes('--bg')) {
|
|
60
|
+
const service = require('../server/service');
|
|
61
|
+
|
|
62
|
+
if (command === 'uninstall') {
|
|
63
|
+
runServiceCommand(function () { service.uninstall(); });
|
|
64
|
+
} else if (command === 'status') {
|
|
65
|
+
runServiceCommand(function () {
|
|
66
|
+
const st = service.status();
|
|
67
|
+
if (!st.installed) {
|
|
68
|
+
console.log('Service is not installed.');
|
|
69
|
+
} else if (st.running) {
|
|
70
|
+
console.log('Service is installed and running.');
|
|
71
|
+
} else {
|
|
72
|
+
console.log('Service is installed but not running.');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
runServiceCommand(function () {
|
|
77
|
+
const { DEFAULTS } = require('../server/config');
|
|
78
|
+
service.install({
|
|
79
|
+
configPath: resolveConfigPath(),
|
|
80
|
+
port: getArg('--port') || String(DEFAULTS.port),
|
|
81
|
+
host: getArg('--host') || DEFAULTS.host,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
38
86
|
|
|
39
|
-
const configPath =
|
|
87
|
+
const configPath = resolveConfigPath();
|
|
88
|
+
const configDir = path.dirname(configPath);
|
|
40
89
|
|
|
41
90
|
// Ensure config directory exists
|
|
42
91
|
if (!fs.existsSync(configDir)) {
|
package/package.json
CHANGED
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]
|
|
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
|
|
535
|
-
connectToSession(data.id
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
174
|
+
reposToScan = [{ path: repoParam, name: repoParam.split('/').filter(Boolean).pop(), root }];
|
|
161
175
|
} else {
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,181 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { DEFAULTS } = require('./config');
|
|
5
|
+
|
|
6
|
+
const SERVICE_LABEL = 'com.claude-remote-cli';
|
|
7
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || '~';
|
|
8
|
+
const CONFIG_DIR = path.join(HOME, '.config', 'claude-remote-cli');
|
|
9
|
+
|
|
10
|
+
function getPlatform() {
|
|
11
|
+
if (process.platform === 'darwin') return 'macos';
|
|
12
|
+
if (process.platform === 'linux') return 'linux';
|
|
13
|
+
throw new Error('Unsupported platform: ' + process.platform + '. Only macOS and Linux are supported.');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getServicePaths() {
|
|
17
|
+
const platform = getPlatform();
|
|
18
|
+
if (platform === 'macos') {
|
|
19
|
+
return {
|
|
20
|
+
servicePath: path.join(HOME, 'Library', 'LaunchAgents', SERVICE_LABEL + '.plist'),
|
|
21
|
+
logDir: path.join(CONFIG_DIR, 'logs'),
|
|
22
|
+
label: SERVICE_LABEL,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
servicePath: path.join(HOME, '.config', 'systemd', 'user', 'claude-remote-cli.service'),
|
|
27
|
+
logDir: null,
|
|
28
|
+
label: 'claude-remote-cli',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function generateServiceFile(platform, opts) {
|
|
33
|
+
const { nodePath, scriptPath, configPath, port, host, logDir } = opts;
|
|
34
|
+
|
|
35
|
+
if (platform === 'macos') {
|
|
36
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
37
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
38
|
+
<plist version="1.0">
|
|
39
|
+
<dict>
|
|
40
|
+
<key>Label</key>
|
|
41
|
+
<string>${SERVICE_LABEL}</string>
|
|
42
|
+
<key>ProgramArguments</key>
|
|
43
|
+
<array>
|
|
44
|
+
<string>${nodePath}</string>
|
|
45
|
+
<string>${scriptPath}</string>
|
|
46
|
+
<string>--config</string>
|
|
47
|
+
<string>${configPath}</string>
|
|
48
|
+
<string>--port</string>
|
|
49
|
+
<string>${port}</string>
|
|
50
|
+
<string>--host</string>
|
|
51
|
+
<string>${host}</string>
|
|
52
|
+
</array>
|
|
53
|
+
<key>RunAtLoad</key>
|
|
54
|
+
<true/>
|
|
55
|
+
<key>KeepAlive</key>
|
|
56
|
+
<true/>
|
|
57
|
+
<key>StandardOutPath</key>
|
|
58
|
+
<string>${path.join(logDir, 'stdout.log')}</string>
|
|
59
|
+
<key>StandardErrorPath</key>
|
|
60
|
+
<string>${path.join(logDir, 'stderr.log')}</string>
|
|
61
|
+
<key>EnvironmentVariables</key>
|
|
62
|
+
<dict>
|
|
63
|
+
<key>PATH</key>
|
|
64
|
+
<string>${process.env.PATH}</string>
|
|
65
|
+
</dict>
|
|
66
|
+
</dict>
|
|
67
|
+
</plist>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return `[Unit]
|
|
71
|
+
Description=Claude Remote CLI
|
|
72
|
+
After=network.target
|
|
73
|
+
|
|
74
|
+
[Service]
|
|
75
|
+
Type=simple
|
|
76
|
+
ExecStart=${nodePath} ${scriptPath} --config ${configPath} --port ${port} --host ${host}
|
|
77
|
+
Restart=on-failure
|
|
78
|
+
RestartSec=5
|
|
79
|
+
Environment=PATH=${process.env.PATH}
|
|
80
|
+
|
|
81
|
+
[Install]
|
|
82
|
+
WantedBy=default.target`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isInstalled() {
|
|
86
|
+
const { servicePath } = getServicePaths();
|
|
87
|
+
return fs.existsSync(servicePath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function install(opts) {
|
|
91
|
+
const platform = getPlatform();
|
|
92
|
+
const { servicePath, logDir } = getServicePaths();
|
|
93
|
+
|
|
94
|
+
if (isInstalled()) {
|
|
95
|
+
throw new Error('Service is already installed. Run `claude-remote-cli uninstall` first.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const nodePath = process.execPath;
|
|
99
|
+
const scriptPath = path.resolve(__dirname, '..', 'bin', 'claude-remote-cli.js');
|
|
100
|
+
const configPath = opts.configPath || path.join(CONFIG_DIR, 'config.json');
|
|
101
|
+
const port = opts.port || String(DEFAULTS.port);
|
|
102
|
+
const host = opts.host || DEFAULTS.host;
|
|
103
|
+
|
|
104
|
+
const content = generateServiceFile(platform, { nodePath, scriptPath, configPath, port, host, logDir });
|
|
105
|
+
|
|
106
|
+
fs.mkdirSync(path.dirname(servicePath), { recursive: true });
|
|
107
|
+
if (logDir) fs.mkdirSync(logDir, { recursive: true });
|
|
108
|
+
|
|
109
|
+
fs.writeFileSync(servicePath, content, 'utf8');
|
|
110
|
+
|
|
111
|
+
if (platform === 'macos') {
|
|
112
|
+
execSync('launchctl load -w ' + servicePath, { stdio: 'inherit' });
|
|
113
|
+
} else {
|
|
114
|
+
execSync('systemctl --user daemon-reload', { stdio: 'inherit' });
|
|
115
|
+
execSync('systemctl --user enable --now claude-remote-cli', { stdio: 'inherit' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log('Service installed and started.');
|
|
119
|
+
if (logDir) {
|
|
120
|
+
console.log('Logs: ' + logDir);
|
|
121
|
+
} else {
|
|
122
|
+
console.log('Logs: journalctl --user -u claude-remote-cli -f');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function uninstall() {
|
|
127
|
+
const platform = getPlatform();
|
|
128
|
+
const { servicePath } = getServicePaths();
|
|
129
|
+
|
|
130
|
+
if (!isInstalled()) {
|
|
131
|
+
throw new Error('Service is not installed.');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (platform === 'macos') {
|
|
135
|
+
try {
|
|
136
|
+
execSync('launchctl unload ' + servicePath, { stdio: 'inherit' });
|
|
137
|
+
} catch (_) {
|
|
138
|
+
// Ignore errors from already-unloaded services
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
try {
|
|
142
|
+
execSync('systemctl --user disable --now claude-remote-cli', { stdio: 'inherit' });
|
|
143
|
+
} catch (_) {
|
|
144
|
+
// Ignore errors from already-disabled services
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fs.unlinkSync(servicePath);
|
|
149
|
+
console.log('Service uninstalled.');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function status() {
|
|
153
|
+
const platform = getPlatform();
|
|
154
|
+
|
|
155
|
+
if (!isInstalled()) {
|
|
156
|
+
return { installed: false, running: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const running = checkRunning(platform);
|
|
160
|
+
return { installed: true, running };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function checkRunning(platform) {
|
|
164
|
+
if (platform === 'macos') {
|
|
165
|
+
try {
|
|
166
|
+
const out = execSync('launchctl list ' + SERVICE_LABEL, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
167
|
+
return !out.includes('"LastExitStatus" = -1');
|
|
168
|
+
} catch (_) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
execSync('systemctl --user is-active claude-remote-cli', { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
175
|
+
return true;
|
|
176
|
+
} catch (_) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { getPlatform, getServicePaths, generateServiceFile, isInstalled, install, uninstall, status, SERVICE_LABEL, CONFIG_DIR };
|
|
@@ -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
|
-
//
|
|
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
|
-
|
|
56
|
-
|
|
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 };
|