claude-remote-cli 2.7.0 → 2.8.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.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-capable" content="yes" />
12
12
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
13
13
  <meta name="theme-color" content="#1a1a1a" />
14
- <script type="module" crossorigin src="/assets/index-C2nVSRxb.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-CUkDx_1l.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-Not5cXLa.css">
16
16
  </head>
17
17
  <body>
@@ -12,7 +12,7 @@ import { loadConfig, saveConfig, DEFAULTS, readMeta, writeMeta, deleteMeta, ensu
12
12
  import * as auth from './auth.js';
13
13
  import * as sessions from './sessions.js';
14
14
  import { setupWebSocket } from './ws.js';
15
- import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain } from './watcher.js';
15
+ import { WorktreeWatcher, WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from './watcher.js';
16
16
  import { isInstalled as serviceIsInstalled } from './service.js';
17
17
  import { extensionForMime, setClipboardImage } from './clipboard.js';
18
18
  const __filename = fileURLToPath(import.meta.url);
@@ -507,9 +507,22 @@ async function main() {
507
507
  res.status(400).json({ error: 'worktreePath and repoPath are required' });
508
508
  return;
509
509
  }
510
- if (!isValidWorktreePath(worktreePath)) {
511
- res.status(400).json({ error: 'Path is not inside a worktree directory' });
512
- return;
510
+ // Validate the path is a real git worktree (not the main worktree)
511
+ try {
512
+ const { stdout: wtListOut } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repoPath });
513
+ const allWorktrees = parseAllWorktrees(wtListOut, repoPath);
514
+ const isKnownWorktree = allWorktrees.some(wt => wt.path === path.resolve(worktreePath) && !wt.isMain);
515
+ if (!isKnownWorktree) {
516
+ res.status(400).json({ error: 'Path is not a recognized git worktree' });
517
+ return;
518
+ }
519
+ }
520
+ catch {
521
+ // If git worktree list fails, fall back to the directory-name check
522
+ if (!isValidWorktreePath(worktreePath)) {
523
+ res.status(400).json({ error: 'Path is not inside a worktree directory' });
524
+ return;
525
+ }
513
526
  }
514
527
  // Check no active session is using this worktree
515
528
  const activeSessions = sessions.list();
@@ -620,6 +633,62 @@ async function main() {
620
633
  }
621
634
  }
622
635
  if (branchName && branchExists) {
636
+ // Check if branch is already checked out in an existing worktree
637
+ const { stdout: wtListOut } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], { cwd: repoPath });
638
+ const allWorktrees = parseAllWorktrees(wtListOut, repoPath);
639
+ const existingWt = allWorktrees.find(wt => wt.branch === branchName);
640
+ if (existingWt) {
641
+ // Branch already checked out — redirect to the existing worktree
642
+ if (existingWt.isMain) {
643
+ // Main worktree → create a repo session
644
+ const existingRepoSession = sessions.findRepoSession(repoPath);
645
+ if (existingRepoSession) {
646
+ res.status(409).json({ error: 'A session already exists for this repo', sessionId: existingRepoSession.id });
647
+ return;
648
+ }
649
+ const repoSession = sessions.create({
650
+ type: 'repo',
651
+ repoName: name,
652
+ repoPath,
653
+ cwd: repoPath,
654
+ root,
655
+ displayName: name,
656
+ command: config.claudeCommand,
657
+ args: baseArgs,
658
+ });
659
+ res.status(201).json(repoSession);
660
+ return;
661
+ }
662
+ else {
663
+ // Another worktree → create a worktree session with --continue
664
+ cwd = existingWt.path;
665
+ sessionRepoPath = existingWt.path;
666
+ worktreeName = existingWt.path.split('/').pop() || '';
667
+ args = ['--continue', ...baseArgs];
668
+ const displayNameVal = branchName || worktreeName;
669
+ const session = sessions.create({
670
+ type: 'worktree',
671
+ repoName: name,
672
+ repoPath: sessionRepoPath,
673
+ cwd,
674
+ root,
675
+ worktreeName,
676
+ branchName: branchName || worktreeName,
677
+ displayName: displayNameVal,
678
+ command: config.claudeCommand,
679
+ args,
680
+ configPath: CONFIG_PATH,
681
+ });
682
+ writeMeta(CONFIG_PATH, {
683
+ worktreePath: sessionRepoPath,
684
+ displayName: displayNameVal,
685
+ lastActivity: new Date().toISOString(),
686
+ branchName: branchName || worktreeName,
687
+ });
688
+ res.status(201).json(session);
689
+ return;
690
+ }
691
+ }
623
692
  await execFileAsync('git', ['worktree', 'add', targetDir, resolvedBranch], { cwd: repoPath });
624
693
  }
625
694
  else if (branchName) {
@@ -8,6 +8,32 @@ export function isValidWorktreePath(worktreePath) {
8
8
  return resolved.includes(path.sep + dir + path.sep);
9
9
  });
10
10
  }
11
+ /**
12
+ * Parse `git worktree list --porcelain` output into ALL entries (including main worktree).
13
+ * Skips bare entries. Detached HEAD entries get empty branch string.
14
+ */
15
+ export function parseAllWorktrees(stdout, repoPath) {
16
+ const results = [];
17
+ const blocks = stdout.split('\n\n').filter(Boolean);
18
+ for (const block of blocks) {
19
+ const lines = block.split('\n');
20
+ let wtPath = '';
21
+ let branch = '';
22
+ let bare = false;
23
+ for (const line of lines) {
24
+ if (line.startsWith('worktree '))
25
+ wtPath = line.slice(9);
26
+ if (line.startsWith('branch refs/heads/'))
27
+ branch = line.slice(18);
28
+ if (line === 'bare')
29
+ bare = true;
30
+ }
31
+ if (!wtPath || bare)
32
+ continue;
33
+ results.push({ path: wtPath, branch, isMain: wtPath === repoPath });
34
+ }
35
+ return results;
36
+ }
11
37
  /**
12
38
  * Parse `git worktree list --porcelain` output into structured entries.
13
39
  * Skips the main worktree (matching repoPath) and bare/detached entries.
@@ -1,6 +1,6 @@
1
1
  import { describe, it } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain } from '../server/watcher.js';
3
+ import { WORKTREE_DIRS, isValidWorktreePath, parseWorktreeListPorcelain, parseAllWorktrees } from '../server/watcher.js';
4
4
  describe('worktree directories constant', () => {
5
5
  it('should include both .worktrees and .claude/worktrees', () => {
6
6
  assert.deepEqual(WORKTREE_DIRS, ['.worktrees', '.claude/worktrees']);
@@ -146,6 +146,91 @@ describe('parseWorktreeListPorcelain', () => {
146
146
  assert.equal(result[0].branch, 'dy/feat/deep/nesting/here');
147
147
  });
148
148
  });
149
+ describe('parseAllWorktrees', () => {
150
+ const repoPath = '/Users/me/code/my-repo';
151
+ it('should include the main worktree with isMain=true', () => {
152
+ const stdout = [
153
+ `worktree ${repoPath}`,
154
+ 'HEAD abc123',
155
+ 'branch refs/heads/main',
156
+ '',
157
+ ].join('\n');
158
+ const result = parseAllWorktrees(stdout, repoPath);
159
+ assert.equal(result.length, 1);
160
+ assert.equal(result[0].path, repoPath);
161
+ assert.equal(result[0].branch, 'main');
162
+ assert.equal(result[0].isMain, true);
163
+ });
164
+ it('should mark non-main worktrees with isMain=false', () => {
165
+ const stdout = [
166
+ `worktree ${repoPath}`,
167
+ 'HEAD abc123',
168
+ 'branch refs/heads/main',
169
+ '',
170
+ 'worktree /Users/me/code/my-repo/.worktrees/feat-branch',
171
+ 'HEAD def456',
172
+ 'branch refs/heads/feat/branch',
173
+ '',
174
+ ].join('\n');
175
+ const result = parseAllWorktrees(stdout, repoPath);
176
+ assert.equal(result.length, 2);
177
+ assert.equal(result[0].isMain, true);
178
+ assert.equal(result[1].isMain, false);
179
+ assert.equal(result[1].path, '/Users/me/code/my-repo/.worktrees/feat-branch');
180
+ assert.equal(result[1].branch, 'feat/branch');
181
+ });
182
+ it('should still skip bare entries', () => {
183
+ const stdout = [
184
+ `worktree ${repoPath}`,
185
+ 'HEAD abc123',
186
+ 'branch refs/heads/main',
187
+ '',
188
+ 'worktree /some/bare/repo',
189
+ 'HEAD def456',
190
+ 'bare',
191
+ '',
192
+ ].join('\n');
193
+ const result = parseAllWorktrees(stdout, repoPath);
194
+ assert.equal(result.length, 1);
195
+ assert.equal(result[0].isMain, true);
196
+ });
197
+ it('should include detached HEAD entries with empty branch', () => {
198
+ const stdout = [
199
+ `worktree ${repoPath}`,
200
+ 'HEAD abc123',
201
+ 'branch refs/heads/main',
202
+ '',
203
+ 'worktree /Users/me/code/my-repo/.worktrees/detached',
204
+ 'HEAD def456',
205
+ 'detached',
206
+ '',
207
+ ].join('\n');
208
+ const result = parseAllWorktrees(stdout, repoPath);
209
+ assert.equal(result.length, 2);
210
+ assert.equal(result[1].branch, '');
211
+ });
212
+ it('should handle empty output', () => {
213
+ const result = parseAllWorktrees('', repoPath);
214
+ assert.equal(result.length, 0);
215
+ });
216
+ it('should find worktree by branch name', () => {
217
+ const stdout = [
218
+ `worktree ${repoPath}`,
219
+ 'HEAD abc123',
220
+ 'branch refs/heads/dy/feat/worktree-isolation',
221
+ '',
222
+ 'worktree /Users/me/code/my-repo/.worktrees/feat-a',
223
+ 'HEAD def456',
224
+ 'branch refs/heads/feat/a',
225
+ '',
226
+ ].join('\n');
227
+ const result = parseAllWorktrees(stdout, repoPath);
228
+ const match = result.find(wt => wt.branch === 'dy/feat/worktree-isolation');
229
+ assert.ok(match);
230
+ assert.equal(match.path, repoPath);
231
+ assert.equal(match.isMain, true);
232
+ });
233
+ });
149
234
  describe('CLI worktree arg parsing', () => {
150
235
  it('should extract --yolo and leave other args intact', () => {
151
236
  const args = ['add', './.worktrees/my-feature', '-b', 'my-feature', '--yolo'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-remote-cli",
3
- "version": "2.7.0",
3
+ "version": "2.8.0",
4
4
  "description": "Remote web interface for Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",