claude-team-mcp 0.4.0__py3-none-any.whl

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.
Files changed (42) hide show
  1. claude_team_mcp/__init__.py +24 -0
  2. claude_team_mcp/__main__.py +8 -0
  3. claude_team_mcp/cli_backends/__init__.py +44 -0
  4. claude_team_mcp/cli_backends/base.py +132 -0
  5. claude_team_mcp/cli_backends/claude.py +110 -0
  6. claude_team_mcp/cli_backends/codex.py +110 -0
  7. claude_team_mcp/colors.py +108 -0
  8. claude_team_mcp/formatting.py +120 -0
  9. claude_team_mcp/idle_detection.py +488 -0
  10. claude_team_mcp/iterm_utils.py +1119 -0
  11. claude_team_mcp/names.py +427 -0
  12. claude_team_mcp/profile.py +364 -0
  13. claude_team_mcp/registry.py +426 -0
  14. claude_team_mcp/schemas/__init__.py +5 -0
  15. claude_team_mcp/schemas/codex.py +267 -0
  16. claude_team_mcp/server.py +390 -0
  17. claude_team_mcp/session_state.py +1058 -0
  18. claude_team_mcp/subprocess_cache.py +119 -0
  19. claude_team_mcp/tools/__init__.py +52 -0
  20. claude_team_mcp/tools/adopt_worker.py +122 -0
  21. claude_team_mcp/tools/annotate_worker.py +57 -0
  22. claude_team_mcp/tools/bd_help.py +42 -0
  23. claude_team_mcp/tools/check_idle_workers.py +98 -0
  24. claude_team_mcp/tools/close_workers.py +194 -0
  25. claude_team_mcp/tools/discover_workers.py +129 -0
  26. claude_team_mcp/tools/examine_worker.py +56 -0
  27. claude_team_mcp/tools/list_workers.py +76 -0
  28. claude_team_mcp/tools/list_worktrees.py +106 -0
  29. claude_team_mcp/tools/message_workers.py +311 -0
  30. claude_team_mcp/tools/read_worker_logs.py +158 -0
  31. claude_team_mcp/tools/spawn_workers.py +634 -0
  32. claude_team_mcp/tools/wait_idle_workers.py +148 -0
  33. claude_team_mcp/utils/__init__.py +17 -0
  34. claude_team_mcp/utils/constants.py +87 -0
  35. claude_team_mcp/utils/errors.py +87 -0
  36. claude_team_mcp/utils/worktree_detection.py +79 -0
  37. claude_team_mcp/worker_prompt.py +350 -0
  38. claude_team_mcp/worktree.py +532 -0
  39. claude_team_mcp-0.4.0.dist-info/METADATA +414 -0
  40. claude_team_mcp-0.4.0.dist-info/RECORD +42 -0
  41. claude_team_mcp-0.4.0.dist-info/WHEEL +4 -0
  42. claude_team_mcp-0.4.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,532 @@
1
+ """
2
+ Git worktree utilities for worker session isolation.
3
+
4
+ Provides functions to create, remove, and list git worktrees, enabling
5
+ each worker session to operate in its own isolated working directory
6
+ while sharing the same repository history.
7
+
8
+ Two worktree strategies are supported:
9
+
10
+ 1. External worktrees (legacy):
11
+ ~/.claude-team/worktrees/{repo-path-hash}/{worker-name}-{timestamp}/
12
+ - Created outside the target repo to avoid polluting it
13
+ - No .gitignore modifications needed
14
+
15
+ 2. Local worktrees (preferred):
16
+ {repo}/.worktrees/{bead-annotation}/ or {name-uuid-annotation}/
17
+ - Kept within the repo for easier discovery and cleanup
18
+ - Automatically adds .worktrees to .gitignore
19
+ """
20
+
21
+ import hashlib
22
+ import re
23
+ import subprocess
24
+ import time
25
+ import uuid
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+
30
+ # Base directory for all worktrees (outside any repo)
31
+ WORKTREE_BASE_DIR = Path.home() / ".claude-team" / "worktrees"
32
+
33
+ # Local worktree directory name within repos
34
+ LOCAL_WORKTREE_DIR = ".worktrees"
35
+
36
+
37
+ def slugify(text: str) -> str:
38
+ """
39
+ Convert text to a URL/filesystem-friendly slug.
40
+
41
+ Converts to lowercase, replaces spaces and special chars with dashes,
42
+ and removes consecutive dashes.
43
+
44
+ Args:
45
+ text: The text to slugify
46
+
47
+ Returns:
48
+ A lowercase, dash-separated string safe for filenames/URLs
49
+
50
+ Example:
51
+ slugify("Add local worktrees support") # "add-local-worktrees-support"
52
+ slugify("Fix Bug #123") # "fix-bug-123"
53
+ """
54
+ # Convert to lowercase
55
+ text = text.lower()
56
+ # Replace spaces and underscores with dashes
57
+ text = re.sub(r"[\s_]+", "-", text)
58
+ # Remove any characters that aren't alphanumeric or dashes
59
+ text = re.sub(r"[^a-z0-9-]", "", text)
60
+ # Collapse multiple dashes
61
+ text = re.sub(r"-+", "-", text)
62
+ # Strip leading/trailing dashes
63
+ text = text.strip("-")
64
+ return text
65
+
66
+
67
+ def ensure_gitignore_entry(repo_path: Path, entry: str) -> bool:
68
+ """
69
+ Ensure an entry exists in the repository's .gitignore file.
70
+
71
+ Creates the .gitignore file if it doesn't exist. Adds the entry
72
+ on a new line if not already present.
73
+
74
+ Args:
75
+ repo_path: Path to the repository root
76
+ entry: The gitignore entry to add (e.g., ".worktrees")
77
+
78
+ Returns:
79
+ True if the entry was added, False if it already existed
80
+
81
+ Example:
82
+ ensure_gitignore_entry(Path("/path/to/repo"), ".worktrees")
83
+ """
84
+ gitignore_path = Path(repo_path) / ".gitignore"
85
+
86
+ # Check if entry already exists
87
+ if gitignore_path.exists():
88
+ content = gitignore_path.read_text()
89
+ lines = content.splitlines()
90
+
91
+ # Check for exact match (with or without trailing slash)
92
+ entry_variants = {entry, entry + "/", entry.rstrip("/")}
93
+ for line in lines:
94
+ stripped = line.strip()
95
+ if stripped in entry_variants:
96
+ return False
97
+
98
+ # Entry not found, append it
99
+ # Ensure there's a newline before our entry if file doesn't end with one
100
+ if content and not content.endswith("\n"):
101
+ content += "\n"
102
+ content += f"{entry}\n"
103
+ gitignore_path.write_text(content)
104
+ return True
105
+ else:
106
+ # Create new .gitignore with the entry
107
+ gitignore_path.write_text(f"{entry}\n")
108
+ return True
109
+
110
+
111
+ class WorktreeError(Exception):
112
+ """Raised when a git worktree operation fails."""
113
+
114
+ pass
115
+
116
+
117
+ def get_repo_hash(repo_path: Path) -> str:
118
+ """
119
+ Generate a short hash from a repository path.
120
+
121
+ Used to create unique subdirectories for each repo's worktrees.
122
+
123
+ Args:
124
+ repo_path: Absolute path to the repository
125
+
126
+ Returns:
127
+ 8-character hex hash of the repo path
128
+ """
129
+ return hashlib.sha256(str(repo_path).encode()).hexdigest()[:8]
130
+
131
+
132
+ def get_worktree_base_for_repo(repo_path: Path) -> Path:
133
+ """
134
+ Get the base directory for a repo's worktrees.
135
+
136
+ Args:
137
+ repo_path: Path to the main repository
138
+
139
+ Returns:
140
+ Path to ~/.claude-team/worktrees/{repo-hash}/
141
+ """
142
+ repo_path = Path(repo_path).resolve()
143
+ repo_hash = get_repo_hash(repo_path)
144
+ return WORKTREE_BASE_DIR / repo_hash
145
+
146
+
147
+ def create_worktree(
148
+ repo_path: Path,
149
+ worktree_name: str,
150
+ branch: Optional[str] = None,
151
+ timestamp: Optional[int] = None,
152
+ ) -> Path:
153
+ """
154
+ Create a git worktree for a worker.
155
+
156
+ Creates a new worktree at:
157
+ ~/.claude-team/worktrees/{repo-hash}/{worktree_name}-{timestamp}/
158
+
159
+ If a branch is specified and doesn't exist, it will be created from HEAD.
160
+ If no branch is specified, creates a detached HEAD worktree.
161
+
162
+ Args:
163
+ repo_path: Path to the main repository
164
+ worktree_name: Name for the worktree (worker name, e.g., "John-abc123")
165
+ branch: Branch to checkout (creates new branch from HEAD if doesn't exist)
166
+ timestamp: Unix timestamp for directory name (defaults to current time)
167
+
168
+ Returns:
169
+ Path to the created worktree
170
+
171
+ Raises:
172
+ WorktreeError: If the git worktree command fails
173
+
174
+ Example:
175
+ path = create_worktree(
176
+ repo_path=Path("/path/to/repo"),
177
+ worktree_name="John-abc123",
178
+ branch="John-abc123"
179
+ )
180
+ # Returns: Path("~/.claude-team/worktrees/a1b2c3d4/John-abc123-1703001234")
181
+ """
182
+ repo_path = Path(repo_path).resolve()
183
+
184
+ # Generate worktree path outside the repo
185
+ if timestamp is None:
186
+ timestamp = int(time.time())
187
+ worktree_dir_name = f"{worktree_name}-{timestamp}"
188
+ base_dir = get_worktree_base_for_repo(repo_path)
189
+ worktree_path = base_dir / worktree_dir_name
190
+
191
+ # Ensure base directory exists
192
+ base_dir.mkdir(parents=True, exist_ok=True)
193
+
194
+ # Check if worktree already exists
195
+ if worktree_path.exists():
196
+ raise WorktreeError(f"Worktree already exists at {worktree_path}")
197
+
198
+ # Build the git worktree add command
199
+ cmd = ["git", "-C", str(repo_path), "worktree", "add"]
200
+
201
+ if branch:
202
+ # Check if branch exists
203
+ branch_check = subprocess.run(
204
+ ["git", "-C", str(repo_path), "rev-parse", "--verify", f"refs/heads/{branch}"],
205
+ capture_output=True,
206
+ text=True,
207
+ )
208
+
209
+ if branch_check.returncode == 0:
210
+ # Branch exists, check it out
211
+ cmd.extend([str(worktree_path), branch])
212
+ else:
213
+ # Branch doesn't exist, create it with -b
214
+ cmd.extend(["-b", branch, str(worktree_path)])
215
+ else:
216
+ # No branch specified, create detached HEAD
217
+ cmd.extend(["--detach", str(worktree_path)])
218
+
219
+ result = subprocess.run(cmd, capture_output=True, text=True)
220
+
221
+ if result.returncode != 0:
222
+ raise WorktreeError(f"Failed to create worktree: {result.stderr.strip()}")
223
+
224
+ return worktree_path
225
+
226
+
227
+ def create_local_worktree(
228
+ repo_path: Path,
229
+ worker_name: str,
230
+ bead_id: Optional[str] = None,
231
+ annotation: Optional[str] = None,
232
+ ) -> Path:
233
+ """
234
+ Create a git worktree in the repo's .worktrees/ directory.
235
+
236
+ Creates a new worktree at:
237
+ {repo}/.worktrees/{bead_id}-{annotation}/ (if bead_id provided)
238
+ {repo}/.worktrees/{worker_name}-{uuid}-{annotation}/ (otherwise)
239
+
240
+ The branch name matches the worktree directory name for consistency.
241
+ Automatically adds .worktrees to .gitignore if not present.
242
+
243
+ Args:
244
+ repo_path: Path to the main repository
245
+ worker_name: Name of the worker (used in fallback naming)
246
+ bead_id: Optional bead issue ID (e.g., "cic-abc123")
247
+ annotation: Optional annotation for the worktree
248
+
249
+ Returns:
250
+ Path to the created worktree
251
+
252
+ Raises:
253
+ WorktreeError: If the git worktree command fails
254
+
255
+ Example:
256
+ # With bead ID
257
+ path = create_local_worktree(
258
+ repo_path=Path("/path/to/repo"),
259
+ worker_name="Groucho",
260
+ bead_id="cic-abc",
261
+ annotation="Add local worktrees"
262
+ )
263
+ # Returns: Path("/path/to/repo/.worktrees/cic-abc-add-local-worktrees")
264
+
265
+ # Without bead ID
266
+ path = create_local_worktree(
267
+ repo_path=Path("/path/to/repo"),
268
+ worker_name="Groucho",
269
+ annotation="Fix bug"
270
+ )
271
+ # Returns: Path("/path/to/repo/.worktrees/groucho-a1b2c3d4-fix-bug")
272
+ """
273
+ repo_path = Path(repo_path).resolve()
274
+
275
+ # Build the worktree directory name
276
+ if bead_id:
277
+ # Bead-based naming: {bead_id}-{annotation}
278
+ if annotation:
279
+ dir_name = f"{bead_id}-{slugify(annotation)}"
280
+ else:
281
+ dir_name = bead_id
282
+ else:
283
+ # Fallback naming: {worker_name}-{uuid}-{annotation}
284
+ short_uuid = uuid.uuid4().hex[:8]
285
+ name_slug = slugify(worker_name)
286
+ if annotation:
287
+ dir_name = f"{name_slug}-{short_uuid}-{slugify(annotation)}"
288
+ else:
289
+ dir_name = f"{name_slug}-{short_uuid}"
290
+
291
+ # Worktree path inside the repo
292
+ worktrees_dir = repo_path / LOCAL_WORKTREE_DIR
293
+ worktree_path = worktrees_dir / dir_name
294
+
295
+ # Ensure .worktrees is in .gitignore
296
+ ensure_gitignore_entry(repo_path, LOCAL_WORKTREE_DIR)
297
+
298
+ # Ensure .worktrees directory exists
299
+ worktrees_dir.mkdir(parents=True, exist_ok=True)
300
+
301
+ # Check if worktree already exists
302
+ if worktree_path.exists():
303
+ raise WorktreeError(f"Worktree already exists at {worktree_path}")
304
+
305
+ # Branch name matches directory name for clarity
306
+ branch_name = dir_name
307
+
308
+ # Build the git worktree add command
309
+ cmd = ["git", "-C", str(repo_path), "worktree", "add"]
310
+
311
+ # Check if branch exists
312
+ branch_check = subprocess.run(
313
+ ["git", "-C", str(repo_path), "rev-parse", "--verify", f"refs/heads/{branch_name}"],
314
+ capture_output=True,
315
+ text=True,
316
+ )
317
+
318
+ if branch_check.returncode == 0:
319
+ # Branch exists, check it out
320
+ cmd.extend([str(worktree_path), branch_name])
321
+ else:
322
+ # Branch doesn't exist, create it with -b
323
+ cmd.extend(["-b", branch_name, str(worktree_path)])
324
+
325
+ result = subprocess.run(cmd, capture_output=True, text=True)
326
+
327
+ if result.returncode != 0:
328
+ raise WorktreeError(f"Failed to create local worktree: {result.stderr.strip()}")
329
+
330
+ return worktree_path
331
+
332
+
333
+ def remove_worktree(
334
+ repo_path: Path,
335
+ worktree_path: Path,
336
+ force: bool = True,
337
+ ) -> bool:
338
+ """
339
+ Remove a worktree directory (does NOT delete the branch).
340
+
341
+ The branch is intentionally kept alive so that commits can be
342
+ cherry-picked before manual cleanup.
343
+
344
+ Args:
345
+ repo_path: Path to the main repository
346
+ worktree_path: Full path to the worktree to remove
347
+ force: If True, force removal even with uncommitted changes
348
+
349
+ Returns:
350
+ True if worktree was successfully removed
351
+
352
+ Raises:
353
+ WorktreeError: If the git worktree remove command fails
354
+
355
+ Example:
356
+ success = remove_worktree(
357
+ repo_path=Path("/path/to/repo"),
358
+ worktree_path=Path("~/.claude-team/worktrees/a1b2c3d4/John-abc123-1703001234")
359
+ )
360
+ """
361
+ repo_path = Path(repo_path).resolve()
362
+ worktree_path = Path(worktree_path).resolve()
363
+
364
+ cmd = ["git", "-C", str(repo_path), "worktree", "remove"]
365
+
366
+ if force:
367
+ cmd.append("--force")
368
+
369
+ cmd.append(str(worktree_path))
370
+
371
+ result = subprocess.run(cmd, capture_output=True, text=True)
372
+
373
+ if result.returncode != 0:
374
+ # Check if worktree doesn't exist (not an error)
375
+ if "is not a working tree" in result.stderr or "No such file" in result.stderr:
376
+ return True
377
+ raise WorktreeError(f"Failed to remove worktree: {result.stderr.strip()}")
378
+
379
+ # Also run prune to clean up stale worktree references
380
+ subprocess.run(
381
+ ["git", "-C", str(repo_path), "worktree", "prune"],
382
+ capture_output=True,
383
+ text=True,
384
+ )
385
+
386
+ return True
387
+
388
+
389
+ def list_git_worktrees(repo_path: Path) -> list[dict]:
390
+ """
391
+ List all worktrees registered with git for a repository.
392
+
393
+ Parses the porcelain output of git worktree list to provide
394
+ structured information about each worktree.
395
+
396
+ Args:
397
+ repo_path: Path to the repository
398
+
399
+ Returns:
400
+ List of dicts, each containing:
401
+ - path: Path to the worktree
402
+ - branch: Branch name (or None if detached HEAD)
403
+ - commit: Current HEAD commit hash
404
+ - bare: True if this is the bare repository entry
405
+ - detached: True if HEAD is detached
406
+
407
+ Raises:
408
+ WorktreeError: If the git worktree list command fails
409
+
410
+ Example:
411
+ worktrees = list_git_worktrees(Path("/path/to/repo"))
412
+ for wt in worktrees:
413
+ print(f"{wt['path']}: {wt['branch'] or 'detached'}")
414
+ """
415
+ repo_path = Path(repo_path).resolve()
416
+
417
+ result = subprocess.run(
418
+ ["git", "-C", str(repo_path), "worktree", "list", "--porcelain"],
419
+ capture_output=True,
420
+ text=True,
421
+ )
422
+
423
+ if result.returncode != 0:
424
+ raise WorktreeError(f"Failed to list worktrees: {result.stderr.strip()}")
425
+
426
+ worktrees = []
427
+ current_worktree: dict = {}
428
+
429
+ for line in result.stdout.strip().split("\n"):
430
+ if not line:
431
+ # Empty line separates worktree entries
432
+ if current_worktree:
433
+ worktrees.append(current_worktree)
434
+ current_worktree = {}
435
+ continue
436
+
437
+ if line.startswith("worktree "):
438
+ current_worktree["path"] = Path(line[9:])
439
+ current_worktree["branch"] = None
440
+ current_worktree["commit"] = None
441
+ current_worktree["bare"] = False
442
+ current_worktree["detached"] = False
443
+ elif line.startswith("HEAD "):
444
+ current_worktree["commit"] = line[5:]
445
+ elif line.startswith("branch "):
446
+ # Branch is in format "refs/heads/branch-name"
447
+ branch_ref = line[7:]
448
+ if branch_ref.startswith("refs/heads/"):
449
+ current_worktree["branch"] = branch_ref[11:]
450
+ else:
451
+ current_worktree["branch"] = branch_ref
452
+ elif line == "bare":
453
+ current_worktree["bare"] = True
454
+ elif line == "detached":
455
+ current_worktree["detached"] = True
456
+
457
+ # Don't forget the last entry
458
+ if current_worktree:
459
+ worktrees.append(current_worktree)
460
+
461
+ return worktrees
462
+
463
+
464
+ def list_local_worktrees(repo_path: Path) -> list[dict]:
465
+ """
466
+ List all local worktrees in a repository's .worktrees/ directory.
467
+
468
+ Finds worktrees in {repo}/.worktrees/ and cross-references them
469
+ with git's worktree list to determine registration status.
470
+
471
+ Args:
472
+ repo_path: Path to the repository
473
+
474
+ Returns:
475
+ List of dicts, each containing:
476
+ - path: Path to the worktree directory
477
+ - name: Worktree directory name (e.g., "cic-abc-fix-bug")
478
+ - branch: Branch name (if found in git worktree list)
479
+ - commit: Current HEAD commit hash (if found)
480
+ - registered: True if git knows about this worktree
481
+ - exists: True if the directory exists on disk
482
+
483
+ Example:
484
+ worktrees = list_local_worktrees(Path("/path/to/repo"))
485
+ for wt in worktrees:
486
+ status = "active" if wt["registered"] else "orphaned"
487
+ print(f"{wt['name']}: {status}")
488
+ """
489
+ repo_path = Path(repo_path).resolve()
490
+ local_worktrees_dir = repo_path / LOCAL_WORKTREE_DIR
491
+
492
+ # Get git's view of worktrees
493
+ try:
494
+ git_worktrees = list_git_worktrees(repo_path)
495
+ except WorktreeError:
496
+ git_worktrees = []
497
+
498
+ git_worktree_paths = {str(wt["path"]) for wt in git_worktrees}
499
+
500
+ worktrees = []
501
+
502
+ # Check if .worktrees directory exists
503
+ if not local_worktrees_dir.exists():
504
+ return worktrees
505
+
506
+ # Scan the directory for worktree folders
507
+ for item in local_worktrees_dir.iterdir():
508
+ if not item.is_dir():
509
+ continue
510
+
511
+ wt_path_str = str(item.resolve())
512
+ registered = wt_path_str in git_worktree_paths
513
+
514
+ # Find matching git worktree info if registered
515
+ git_info = None
516
+ for gwt in git_worktrees:
517
+ if str(gwt["path"]) == wt_path_str:
518
+ git_info = gwt
519
+ break
520
+
521
+ worktrees.append({
522
+ "path": item,
523
+ "name": item.name,
524
+ "branch": git_info["branch"] if git_info else None,
525
+ "commit": git_info["commit"] if git_info else None,
526
+ "registered": registered,
527
+ "exists": True,
528
+ })
529
+
530
+ return worktrees
531
+
532
+