claude-remote-cli 1.1.2 → 1.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.
@@ -3,6 +3,8 @@ import http from 'node:http';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import readline from 'node:readline';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify } from 'node:util';
6
8
  import express from 'express';
7
9
  import cookieParser from 'cookie-parser';
8
10
  import { loadConfig, saveConfig, DEFAULTS } from './config.js';
@@ -10,11 +12,49 @@ import * as auth from './auth.js';
10
12
  import * as sessions from './sessions.js';
11
13
  import { setupWebSocket } from './ws.js';
12
14
  import { WorktreeWatcher } from './watcher.js';
15
+ import { isInstalled as serviceIsInstalled } from './service.js';
13
16
  const __filename = fileURLToPath(import.meta.url);
14
17
  const __dirname = path.dirname(__filename);
18
+ const execFileAsync = promisify(execFile);
15
19
  // When run via CLI bin, config lives in ~/.config/claude-remote-cli/
16
20
  // When run directly (development), fall back to local config.json
17
21
  const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
22
+ const VERSION_CACHE_TTL = 5 * 60 * 1000;
23
+ let versionCache = null;
24
+ function getCurrentVersion() {
25
+ const pkgPath = path.join(__dirname, '..', '..', 'package.json');
26
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
27
+ return pkg.version;
28
+ }
29
+ function semverLessThan(a, b) {
30
+ const parse = (v) => v.split('.').map(Number);
31
+ const [aMaj = 0, aMin = 0, aPat = 0] = parse(a);
32
+ const [bMaj = 0, bMin = 0, bPat = 0] = parse(b);
33
+ if (aMaj !== bMaj)
34
+ return aMaj < bMaj;
35
+ if (aMin !== bMin)
36
+ return aMin < bMin;
37
+ return aPat < bPat;
38
+ }
39
+ async function getLatestVersion() {
40
+ const now = Date.now();
41
+ if (versionCache && now - versionCache.fetchedAt < VERSION_CACHE_TTL) {
42
+ return versionCache.latest;
43
+ }
44
+ try {
45
+ const res = await fetch('https://registry.npmjs.org/claude-remote-cli/latest');
46
+ if (!res.ok)
47
+ return null;
48
+ const data = await res.json();
49
+ if (!data.version)
50
+ return null;
51
+ versionCache = { latest: data.version, fetchedAt: now };
52
+ return data.version;
53
+ }
54
+ catch (_) {
55
+ return null;
56
+ }
57
+ }
18
58
  function parseTTL(ttl) {
19
59
  if (typeof ttl !== 'string')
20
60
  return 24 * 60 * 60 * 1000;
@@ -221,6 +261,54 @@ async function main() {
221
261
  broadcastEvent('worktrees-changed');
222
262
  res.json(config.rootDirs);
223
263
  });
264
+ // DELETE /worktrees — remove a worktree, prune, and delete its branch
265
+ app.delete('/worktrees', requireAuth, async (req, res) => {
266
+ const { worktreePath, repoPath } = req.body;
267
+ if (!worktreePath || !repoPath) {
268
+ res.status(400).json({ error: 'worktreePath and repoPath are required' });
269
+ return;
270
+ }
271
+ // Validate the path is inside a .claude/worktrees/ directory
272
+ if (!worktreePath.includes(path.sep + '.claude' + path.sep + 'worktrees' + path.sep)) {
273
+ res.status(400).json({ error: 'Path is not inside a .claude/worktrees/ directory' });
274
+ return;
275
+ }
276
+ // Check no active session is using this worktree
277
+ const activeSessions = sessions.list();
278
+ const conflict = activeSessions.find(function (s) { return s.repoPath === worktreePath; });
279
+ if (conflict) {
280
+ res.status(409).json({ error: 'Close the active session first' });
281
+ return;
282
+ }
283
+ // Derive branch name from worktree directory name
284
+ const branchName = worktreePath.split('/').pop() || '';
285
+ try {
286
+ // Remove the worktree (will fail if uncommitted changes — no --force)
287
+ await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
288
+ }
289
+ catch (err) {
290
+ const message = err instanceof Error ? err.message : 'Failed to remove worktree';
291
+ res.status(500).json({ error: message });
292
+ return;
293
+ }
294
+ try {
295
+ // Prune stale worktree refs
296
+ await execFileAsync('git', ['worktree', 'prune'], { cwd: repoPath });
297
+ }
298
+ catch (_) {
299
+ // Non-fatal: prune failure doesn't block success
300
+ }
301
+ if (branchName) {
302
+ try {
303
+ // Delete the branch
304
+ await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoPath });
305
+ }
306
+ catch (_) {
307
+ // Non-fatal: branch may not exist or may be checked out elsewhere
308
+ }
309
+ }
310
+ res.json({ ok: true });
311
+ });
224
312
  // POST /sessions
225
313
  app.post('/sessions', requireAuth, (req, res) => {
226
314
  const { repoPath, repoName, worktreePath, claudeArgs } = req.body;
@@ -289,6 +377,28 @@ async function main() {
289
377
  res.status(404).json({ error: 'Session not found' });
290
378
  }
291
379
  });
380
+ // GET /version — check current vs latest
381
+ app.get('/version', requireAuth, async (_req, res) => {
382
+ const current = getCurrentVersion();
383
+ const latest = await getLatestVersion();
384
+ const updateAvailable = latest !== null && semverLessThan(current, latest);
385
+ res.json({ current, latest, updateAvailable });
386
+ });
387
+ // POST /update — install latest version from npm
388
+ app.post('/update', requireAuth, async (_req, res) => {
389
+ try {
390
+ await execFileAsync('npm', ['install', '-g', 'claude-remote-cli@latest']);
391
+ const restarting = serviceIsInstalled();
392
+ res.json({ ok: true, restarting });
393
+ if (restarting) {
394
+ setTimeout(() => process.exit(0), 1000);
395
+ }
396
+ }
397
+ catch (err) {
398
+ const message = err instanceof Error ? err.message : 'Update failed';
399
+ res.status(500).json({ ok: false, error: message });
400
+ }
401
+ });
292
402
  server.listen(config.port, config.host, () => {
293
403
  console.log(`claude-remote-cli listening on ${config.host}:${config.port}`);
294
404
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
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
@@ -34,6 +34,41 @@
34
34
  var sidebarRepoFilter = document.getElementById('sidebar-repo-filter');
35
35
  var dialogRootSelect = document.getElementById('dialog-root-select');
36
36
  var dialogRepoSelect = document.getElementById('dialog-repo-select');
37
+ var contextMenu = document.getElementById('context-menu');
38
+ var ctxDeleteWorktree = document.getElementById('ctx-delete-worktree');
39
+ var deleteWtDialog = document.getElementById('delete-worktree-dialog');
40
+ var deleteWtName = document.getElementById('delete-wt-name');
41
+ var deleteWtCancel = document.getElementById('delete-wt-cancel');
42
+ var deleteWtConfirm = document.getElementById('delete-wt-confirm');
43
+ var updateToast = document.getElementById('update-toast');
44
+ var updateToastText = document.getElementById('update-toast-text');
45
+ var updateToastBtn = document.getElementById('update-toast-btn');
46
+ var updateToastDismiss = document.getElementById('update-toast-dismiss');
47
+
48
+ // Context menu state
49
+ var contextMenuTarget = null; // stores { worktreePath, repoPath, name }
50
+ var longPressTimer = null;
51
+ var longPressFired = false;
52
+
53
+ function showContextMenu(x, y, wt) {
54
+ contextMenuTarget = { worktreePath: wt.path, repoPath: wt.repoPath, name: wt.name };
55
+ contextMenu.style.left = Math.min(x, window.innerWidth - 180) + 'px';
56
+ contextMenu.style.top = Math.min(y, window.innerHeight - 60) + 'px';
57
+ contextMenu.hidden = false;
58
+ }
59
+
60
+ function hideContextMenu() {
61
+ contextMenu.hidden = true;
62
+ contextMenuTarget = null;
63
+ }
64
+
65
+ document.addEventListener('click', function () {
66
+ hideContextMenu();
67
+ });
68
+
69
+ document.addEventListener('keydown', function (e) {
70
+ if (e.key === 'Escape') hideContextMenu();
71
+ });
37
72
 
38
73
  // Session / worktree / repo state
39
74
  var cachedSessions = [];
@@ -86,6 +121,7 @@
86
121
  loadRepos();
87
122
  refreshAll();
88
123
  connectEventSocket();
124
+ checkForUpdates();
89
125
  }
90
126
 
91
127
  // ── Terminal ────────────────────────────────────────────────────────────────
@@ -409,10 +445,44 @@
409
445
 
410
446
  li.appendChild(infoDiv);
411
447
 
448
+ // Click to resume (but not if context menu just opened or long-press fired)
412
449
  li.addEventListener('click', function () {
450
+ if (longPressFired || !contextMenu.hidden) return;
413
451
  startSession(wt.repoPath, wt.path);
414
452
  });
415
453
 
454
+ // Right-click context menu (desktop)
455
+ li.addEventListener('contextmenu', function (e) {
456
+ e.preventDefault();
457
+ e.stopPropagation();
458
+ showContextMenu(e.clientX, e.clientY, wt);
459
+ });
460
+
461
+ // Long-press context menu (mobile)
462
+ li.addEventListener('touchstart', function (e) {
463
+ longPressFired = false;
464
+ longPressTimer = setTimeout(function () {
465
+ longPressTimer = null;
466
+ longPressFired = true;
467
+ var touch = e.touches[0];
468
+ showContextMenu(touch.clientX, touch.clientY, wt);
469
+ }, 500);
470
+ }, { passive: true });
471
+
472
+ li.addEventListener('touchend', function () {
473
+ if (longPressTimer) {
474
+ clearTimeout(longPressTimer);
475
+ longPressTimer = null;
476
+ }
477
+ });
478
+
479
+ li.addEventListener('touchmove', function () {
480
+ if (longPressTimer) {
481
+ clearTimeout(longPressTimer);
482
+ longPressTimer = null;
483
+ }
484
+ });
485
+
416
486
  return li;
417
487
  }
418
488
 
@@ -480,6 +550,48 @@
480
550
  .catch(function () {});
481
551
  }
482
552
 
553
+ // ── Delete Worktree ────────────────────────────────────────────────────────
554
+
555
+ ctxDeleteWorktree.addEventListener('click', function (e) {
556
+ e.stopPropagation();
557
+ hideContextMenu();
558
+ if (!contextMenuTarget) return;
559
+ deleteWtName.textContent = contextMenuTarget.name;
560
+ deleteWtDialog.showModal();
561
+ });
562
+
563
+ deleteWtCancel.addEventListener('click', function () {
564
+ deleteWtDialog.close();
565
+ contextMenuTarget = null;
566
+ });
567
+
568
+ deleteWtConfirm.addEventListener('click', function () {
569
+ if (!contextMenuTarget) return;
570
+ var target = contextMenuTarget;
571
+ deleteWtDialog.close();
572
+ contextMenuTarget = null;
573
+
574
+ fetch('/worktrees', {
575
+ method: 'DELETE',
576
+ headers: { 'Content-Type': 'application/json' },
577
+ body: JSON.stringify({
578
+ worktreePath: target.worktreePath,
579
+ repoPath: target.repoPath,
580
+ }),
581
+ })
582
+ .then(function (res) {
583
+ if (!res.ok) {
584
+ return res.json().then(function (data) {
585
+ alert(data.error || 'Failed to delete worktree');
586
+ });
587
+ }
588
+ // UI will auto-update via worktrees-changed WebSocket event
589
+ })
590
+ .catch(function () {
591
+ alert('Failed to delete worktree');
592
+ });
593
+ });
594
+
483
595
  function highlightActiveSession() {
484
596
  var items = sessionList.querySelectorAll('li');
485
597
  items.forEach(function (li) {
@@ -744,6 +856,73 @@
744
856
  vv.addEventListener('scroll', onViewportResize);
745
857
  })();
746
858
 
859
+ // ── Update Toast ─────────────────────────────────────────────────────────────
860
+
861
+ function checkForUpdates() {
862
+ fetch('/version')
863
+ .then(function (res) {
864
+ if (!res.ok) return;
865
+ return res.json();
866
+ })
867
+ .then(function (data) {
868
+ if (data && data.updateAvailable) {
869
+ showUpdateToast(data.current, data.latest);
870
+ }
871
+ })
872
+ .catch(function () {
873
+ // Silently ignore version check errors
874
+ });
875
+ }
876
+
877
+ function showUpdateToast(current, latest) {
878
+ updateToastText.textContent = 'Update available: v' + current + ' \u2192 v' + latest;
879
+ updateToast.hidden = false;
880
+ updateToastBtn.disabled = false;
881
+ updateToastBtn.textContent = 'Update Now';
882
+
883
+ updateToastBtn.onclick = function () {
884
+ triggerUpdate(latest);
885
+ };
886
+ }
887
+
888
+ function triggerUpdate(latest) {
889
+ updateToastBtn.disabled = true;
890
+ updateToastBtn.textContent = 'Updating\u2026';
891
+
892
+ fetch('/update', { method: 'POST' })
893
+ .then(function (res) {
894
+ return res.json().then(function (data) {
895
+ return { ok: res.ok, data: data };
896
+ });
897
+ })
898
+ .then(function (result) {
899
+ if (result.ok && result.data.restarting) {
900
+ updateToastText.textContent = 'Updated! Restarting server\u2026';
901
+ updateToastBtn.hidden = true;
902
+ updateToastDismiss.hidden = true;
903
+ setTimeout(function () {
904
+ location.reload();
905
+ }, 5000);
906
+ } else if (result.ok) {
907
+ updateToastText.textContent = 'Updated! Please restart the server manually.';
908
+ updateToastBtn.hidden = true;
909
+ } else {
910
+ updateToastText.textContent = 'Update failed: ' + (result.data.error || 'Unknown error');
911
+ updateToastBtn.disabled = false;
912
+ updateToastBtn.textContent = 'Retry';
913
+ }
914
+ })
915
+ .catch(function () {
916
+ updateToastText.textContent = 'Update failed. Please try again.';
917
+ updateToastBtn.disabled = false;
918
+ updateToastBtn.textContent = 'Retry';
919
+ });
920
+ }
921
+
922
+ updateToastDismiss.addEventListener('click', function () {
923
+ updateToast.hidden = true;
924
+ });
925
+
747
926
  // ── Auto-auth Check ─────────────────────────────────────────────────────────
748
927
 
749
928
  fetch('/sessions')
package/public/index.html CHANGED
@@ -78,6 +78,17 @@
78
78
  </div>
79
79
  </div>
80
80
 
81
+ <!-- Update Toast -->
82
+ <div id="update-toast" hidden>
83
+ <div id="update-toast-content">
84
+ <span id="update-toast-text"></span>
85
+ <div id="update-toast-actions">
86
+ <button id="update-toast-btn" class="btn-accent">Update Now</button>
87
+ <button id="update-toast-dismiss" aria-label="Dismiss">&times;</button>
88
+ </div>
89
+ </div>
90
+ </div>
91
+
81
92
  </div>
82
93
 
83
94
  <!-- New Session Dialog -->
@@ -122,6 +133,22 @@
122
133
  </div>
123
134
  </dialog>
124
135
 
136
+ <!-- Context Menu -->
137
+ <div id="context-menu" class="context-menu" hidden>
138
+ <button id="ctx-delete-worktree" class="context-menu-item">Delete worktree</button>
139
+ </div>
140
+
141
+ <!-- Delete Worktree Confirmation Dialog -->
142
+ <dialog id="delete-worktree-dialog">
143
+ <h2>Delete worktree?</h2>
144
+ <p class="delete-wt-warning">This will remove the worktree directory and delete its branch. This cannot be undone.</p>
145
+ <p class="delete-wt-name" id="delete-wt-name"></p>
146
+ <div class="dialog-actions">
147
+ <button id="delete-wt-cancel">Cancel</button>
148
+ <button id="delete-wt-confirm" class="btn-danger">Delete</button>
149
+ </div>
150
+ </dialog>
151
+
125
152
  <script src="/vendor/xterm.js"></script>
126
153
  <script src="/vendor/addon-fit.js"></script>
127
154
  <script src="/app.js"></script>
package/public/style.css CHANGED
@@ -737,6 +737,165 @@ dialog#settings-dialog h2 {
737
737
  opacity: 0.85;
738
738
  }
739
739
 
740
+ /* ===== Context Menu ===== */
741
+ .context-menu {
742
+ position: fixed;
743
+ z-index: 200;
744
+ background: var(--surface);
745
+ border: 1px solid var(--border);
746
+ border-radius: 8px;
747
+ padding: 4px;
748
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
749
+ min-width: 160px;
750
+ }
751
+
752
+ .context-menu-item {
753
+ display: block;
754
+ width: 100%;
755
+ padding: 10px 14px;
756
+ background: none;
757
+ border: none;
758
+ border-radius: 6px;
759
+ color: var(--text);
760
+ font-size: 0.85rem;
761
+ text-align: left;
762
+ cursor: pointer;
763
+ touch-action: manipulation;
764
+ }
765
+
766
+ .context-menu-item:hover,
767
+ .context-menu-item:active {
768
+ background: var(--bg);
769
+ color: var(--accent);
770
+ }
771
+
772
+ /* ===== Delete Worktree Dialog ===== */
773
+ dialog#delete-worktree-dialog {
774
+ background: var(--surface);
775
+ border: 1px solid var(--border);
776
+ border-radius: 12px;
777
+ color: var(--text);
778
+ padding: 1.5rem;
779
+ width: 90%;
780
+ max-width: 400px;
781
+ margin: auto;
782
+ }
783
+
784
+ dialog#delete-worktree-dialog::backdrop {
785
+ background: rgba(0, 0, 0, 0.7);
786
+ }
787
+
788
+ dialog#delete-worktree-dialog h2 {
789
+ font-size: 1.1rem;
790
+ margin-bottom: 0.75rem;
791
+ }
792
+
793
+ .delete-wt-warning {
794
+ font-size: 0.85rem;
795
+ color: var(--text-muted);
796
+ margin-bottom: 0.75rem;
797
+ line-height: 1.4;
798
+ }
799
+
800
+ .delete-wt-name {
801
+ font-size: 0.85rem;
802
+ font-family: monospace;
803
+ color: var(--accent);
804
+ padding: 8px 10px;
805
+ background: var(--bg);
806
+ border-radius: 6px;
807
+ margin-bottom: 1rem;
808
+ word-break: break-all;
809
+ }
810
+
811
+ .btn-danger {
812
+ background: #c0392b !important;
813
+ border-color: #c0392b !important;
814
+ color: #fff !important;
815
+ }
816
+
817
+ .btn-danger:active {
818
+ opacity: 0.85;
819
+ }
820
+
821
+ /* ===== Update Toast ===== */
822
+
823
+ #update-toast {
824
+ position: fixed;
825
+ bottom: 0;
826
+ left: 0;
827
+ right: 0;
828
+ z-index: 150;
829
+ display: flex;
830
+ justify-content: center;
831
+ padding: 12px 12px calc(12px + env(safe-area-inset-bottom));
832
+ pointer-events: none;
833
+ animation: toast-slide-up 0.25s ease-out;
834
+ }
835
+
836
+ @keyframes toast-slide-up {
837
+ from {
838
+ transform: translateY(100%);
839
+ opacity: 0;
840
+ }
841
+ to {
842
+ transform: translateY(0);
843
+ opacity: 1;
844
+ }
845
+ }
846
+
847
+ #update-toast-content {
848
+ display: flex;
849
+ flex-direction: row;
850
+ align-items: center;
851
+ gap: 12px;
852
+ background: var(--surface);
853
+ border: 1px solid var(--border);
854
+ border-radius: 10px;
855
+ padding: 12px 16px;
856
+ max-width: 500px;
857
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
858
+ pointer-events: auto;
859
+ }
860
+
861
+ #update-toast-text {
862
+ flex: 1;
863
+ font-size: 0.85rem;
864
+ color: var(--text);
865
+ }
866
+
867
+ #update-toast-actions {
868
+ display: flex;
869
+ gap: 8px;
870
+ flex-shrink: 0;
871
+ }
872
+
873
+ #update-toast-btn {
874
+ padding: 8px 14px;
875
+ border-radius: 6px;
876
+ font-size: 0.8rem;
877
+ border: none;
878
+ white-space: nowrap;
879
+ }
880
+
881
+ #update-toast-btn:disabled {
882
+ opacity: 0.6;
883
+ cursor: not-allowed;
884
+ }
885
+
886
+ #update-toast-dismiss {
887
+ background: none;
888
+ border: none;
889
+ color: var(--text-muted);
890
+ font-size: 1.2rem;
891
+ padding: 4px 6px;
892
+ cursor: pointer;
893
+ }
894
+
895
+ #update-toast-dismiss:hover {
896
+ color: var(--text);
897
+ }
898
+
740
899
  /* ===== Mobile Responsive ===== */
741
900
  @media (max-width: 600px) {
742
901
  #mobile-header {