claude-remote-cli 1.1.1 → 1.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.
@@ -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';
@@ -12,9 +14,10 @@ import { setupWebSocket } from './ws.js';
12
14
  import { WorktreeWatcher } from './watcher.js';
13
15
  const __filename = fileURLToPath(import.meta.url);
14
16
  const __dirname = path.dirname(__filename);
17
+ const execFileAsync = promisify(execFile);
15
18
  // When run via CLI bin, config lives in ~/.config/claude-remote-cli/
16
19
  // When run directly (development), fall back to local config.json
17
- const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', 'config.json');
20
+ const CONFIG_PATH = process.env.CLAUDE_REMOTE_CONFIG || path.join(__dirname, '..', '..', 'config.json');
18
21
  function parseTTL(ttl) {
19
22
  if (typeof ttl !== 'string')
20
23
  return 24 * 60 * 60 * 1000;
@@ -89,7 +92,7 @@ async function main() {
89
92
  const app = express();
90
93
  app.use(express.json());
91
94
  app.use(cookieParser());
92
- app.use(express.static(path.join(__dirname, '..', 'public')));
95
+ app.use(express.static(path.join(__dirname, '..', '..', 'public')));
93
96
  const requireAuth = (req, res, next) => {
94
97
  const token = req.cookies && req.cookies.token;
95
98
  if (!token || !authenticatedTokens.has(token)) {
@@ -221,6 +224,54 @@ async function main() {
221
224
  broadcastEvent('worktrees-changed');
222
225
  res.json(config.rootDirs);
223
226
  });
227
+ // DELETE /worktrees — remove a worktree, prune, and delete its branch
228
+ app.delete('/worktrees', requireAuth, async (req, res) => {
229
+ const { worktreePath, repoPath } = req.body;
230
+ if (!worktreePath || !repoPath) {
231
+ res.status(400).json({ error: 'worktreePath and repoPath are required' });
232
+ return;
233
+ }
234
+ // Validate the path is inside a .claude/worktrees/ directory
235
+ if (!worktreePath.includes(path.sep + '.claude' + path.sep + 'worktrees' + path.sep)) {
236
+ res.status(400).json({ error: 'Path is not inside a .claude/worktrees/ directory' });
237
+ return;
238
+ }
239
+ // Check no active session is using this worktree
240
+ const activeSessions = sessions.list();
241
+ const conflict = activeSessions.find(function (s) { return s.repoPath === worktreePath; });
242
+ if (conflict) {
243
+ res.status(409).json({ error: 'Close the active session first' });
244
+ return;
245
+ }
246
+ // Derive branch name from worktree directory name
247
+ const branchName = worktreePath.split('/').pop() || '';
248
+ try {
249
+ // Remove the worktree (will fail if uncommitted changes — no --force)
250
+ await execFileAsync('git', ['worktree', 'remove', worktreePath], { cwd: repoPath });
251
+ }
252
+ catch (err) {
253
+ const message = err instanceof Error ? err.message : 'Failed to remove worktree';
254
+ res.status(500).json({ error: message });
255
+ return;
256
+ }
257
+ try {
258
+ // Prune stale worktree refs
259
+ await execFileAsync('git', ['worktree', 'prune'], { cwd: repoPath });
260
+ }
261
+ catch (_) {
262
+ // Non-fatal: prune failure doesn't block success
263
+ }
264
+ if (branchName) {
265
+ try {
266
+ // Delete the branch
267
+ await execFileAsync('git', ['branch', '-D', branchName], { cwd: repoPath });
268
+ }
269
+ catch (_) {
270
+ // Non-fatal: branch may not exist or may be checked out elsewhere
271
+ }
272
+ }
273
+ res.json({ ok: true });
274
+ });
224
275
  // POST /sessions
225
276
  app.post('/sessions', requireAuth, (req, res) => {
226
277
  const { repoPath, repoName, worktreePath, claudeArgs } = req.body;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "1.1.1",
3
+ "version": "1.2.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,37 @@
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
+
44
+ // Context menu state
45
+ var contextMenuTarget = null; // stores { worktreePath, repoPath, name }
46
+ var longPressTimer = null;
47
+ var longPressFired = false;
48
+
49
+ function showContextMenu(x, y, wt) {
50
+ contextMenuTarget = { worktreePath: wt.path, repoPath: wt.repoPath, name: wt.name };
51
+ contextMenu.style.left = Math.min(x, window.innerWidth - 180) + 'px';
52
+ contextMenu.style.top = Math.min(y, window.innerHeight - 60) + 'px';
53
+ contextMenu.hidden = false;
54
+ }
55
+
56
+ function hideContextMenu() {
57
+ contextMenu.hidden = true;
58
+ contextMenuTarget = null;
59
+ }
60
+
61
+ document.addEventListener('click', function () {
62
+ hideContextMenu();
63
+ });
64
+
65
+ document.addEventListener('keydown', function (e) {
66
+ if (e.key === 'Escape') hideContextMenu();
67
+ });
37
68
 
38
69
  // Session / worktree / repo state
39
70
  var cachedSessions = [];
@@ -409,10 +440,44 @@
409
440
 
410
441
  li.appendChild(infoDiv);
411
442
 
443
+ // Click to resume (but not if context menu just opened or long-press fired)
412
444
  li.addEventListener('click', function () {
445
+ if (longPressFired || !contextMenu.hidden) return;
413
446
  startSession(wt.repoPath, wt.path);
414
447
  });
415
448
 
449
+ // Right-click context menu (desktop)
450
+ li.addEventListener('contextmenu', function (e) {
451
+ e.preventDefault();
452
+ e.stopPropagation();
453
+ showContextMenu(e.clientX, e.clientY, wt);
454
+ });
455
+
456
+ // Long-press context menu (mobile)
457
+ li.addEventListener('touchstart', function (e) {
458
+ longPressFired = false;
459
+ longPressTimer = setTimeout(function () {
460
+ longPressTimer = null;
461
+ longPressFired = true;
462
+ var touch = e.touches[0];
463
+ showContextMenu(touch.clientX, touch.clientY, wt);
464
+ }, 500);
465
+ }, { passive: true });
466
+
467
+ li.addEventListener('touchend', function () {
468
+ if (longPressTimer) {
469
+ clearTimeout(longPressTimer);
470
+ longPressTimer = null;
471
+ }
472
+ });
473
+
474
+ li.addEventListener('touchmove', function () {
475
+ if (longPressTimer) {
476
+ clearTimeout(longPressTimer);
477
+ longPressTimer = null;
478
+ }
479
+ });
480
+
416
481
  return li;
417
482
  }
418
483
 
@@ -480,6 +545,48 @@
480
545
  .catch(function () {});
481
546
  }
482
547
 
548
+ // ── Delete Worktree ────────────────────────────────────────────────────────
549
+
550
+ ctxDeleteWorktree.addEventListener('click', function (e) {
551
+ e.stopPropagation();
552
+ hideContextMenu();
553
+ if (!contextMenuTarget) return;
554
+ deleteWtName.textContent = contextMenuTarget.name;
555
+ deleteWtDialog.showModal();
556
+ });
557
+
558
+ deleteWtCancel.addEventListener('click', function () {
559
+ deleteWtDialog.close();
560
+ contextMenuTarget = null;
561
+ });
562
+
563
+ deleteWtConfirm.addEventListener('click', function () {
564
+ if (!contextMenuTarget) return;
565
+ var target = contextMenuTarget;
566
+ deleteWtDialog.close();
567
+ contextMenuTarget = null;
568
+
569
+ fetch('/worktrees', {
570
+ method: 'DELETE',
571
+ headers: { 'Content-Type': 'application/json' },
572
+ body: JSON.stringify({
573
+ worktreePath: target.worktreePath,
574
+ repoPath: target.repoPath,
575
+ }),
576
+ })
577
+ .then(function (res) {
578
+ if (!res.ok) {
579
+ return res.json().then(function (data) {
580
+ alert(data.error || 'Failed to delete worktree');
581
+ });
582
+ }
583
+ // UI will auto-update via worktrees-changed WebSocket event
584
+ })
585
+ .catch(function () {
586
+ alert('Failed to delete worktree');
587
+ });
588
+ });
589
+
483
590
  function highlightActiveSession() {
484
591
  var items = sessionList.querySelectorAll('li');
485
592
  items.forEach(function (li) {
package/public/index.html CHANGED
@@ -122,6 +122,22 @@
122
122
  </div>
123
123
  </dialog>
124
124
 
125
+ <!-- Context Menu -->
126
+ <div id="context-menu" class="context-menu" hidden>
127
+ <button id="ctx-delete-worktree" class="context-menu-item">Delete worktree</button>
128
+ </div>
129
+
130
+ <!-- Delete Worktree Confirmation Dialog -->
131
+ <dialog id="delete-worktree-dialog">
132
+ <h2>Delete worktree?</h2>
133
+ <p class="delete-wt-warning">This will remove the worktree directory and delete its branch. This cannot be undone.</p>
134
+ <p class="delete-wt-name" id="delete-wt-name"></p>
135
+ <div class="dialog-actions">
136
+ <button id="delete-wt-cancel">Cancel</button>
137
+ <button id="delete-wt-confirm" class="btn-danger">Delete</button>
138
+ </div>
139
+ </dialog>
140
+
125
141
  <script src="/vendor/xterm.js"></script>
126
142
  <script src="/vendor/addon-fit.js"></script>
127
143
  <script src="/app.js"></script>
package/public/style.css CHANGED
@@ -737,6 +737,87 @@ 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
+
740
821
  /* ===== Mobile Responsive ===== */
741
822
  @media (max-width: 600px) {
742
823
  #mobile-header {