canopy-cli 3.1.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 (71) hide show
  1. canopy/__init__.py +2 -0
  2. canopy/actions/__init__.py +32 -0
  3. canopy/actions/aliases.py +421 -0
  4. canopy/actions/augments.py +55 -0
  5. canopy/actions/bootstrap.py +249 -0
  6. canopy/actions/bot_resolutions.py +123 -0
  7. canopy/actions/bot_status.py +133 -0
  8. canopy/actions/commit.py +511 -0
  9. canopy/actions/conflicts.py +314 -0
  10. canopy/actions/doctor.py +1459 -0
  11. canopy/actions/draft_replies.py +185 -0
  12. canopy/actions/drift.py +241 -0
  13. canopy/actions/errors.py +115 -0
  14. canopy/actions/evacuate.py +192 -0
  15. canopy/actions/feature_state.py +607 -0
  16. canopy/actions/historian.py +612 -0
  17. canopy/actions/ide_workspace.py +49 -0
  18. canopy/actions/last_visit.py +83 -0
  19. canopy/actions/migrate_slots.py +313 -0
  20. canopy/actions/preflight_state.py +97 -0
  21. canopy/actions/push.py +199 -0
  22. canopy/actions/reads.py +304 -0
  23. canopy/actions/resume.py +582 -0
  24. canopy/actions/review_filter.py +135 -0
  25. canopy/actions/ship.py +399 -0
  26. canopy/actions/slot_details.py +208 -0
  27. canopy/actions/slot_load.py +383 -0
  28. canopy/actions/slots.py +221 -0
  29. canopy/actions/stash.py +230 -0
  30. canopy/actions/switch.py +775 -0
  31. canopy/actions/switch_preflight.py +192 -0
  32. canopy/actions/thread_actions.py +88 -0
  33. canopy/actions/thread_resolutions.py +101 -0
  34. canopy/actions/triage.py +286 -0
  35. canopy/agent/__init__.py +5 -0
  36. canopy/agent/runner.py +129 -0
  37. canopy/agent_setup/__init__.py +264 -0
  38. canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
  39. canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
  40. canopy/cli/__init__.py +0 -0
  41. canopy/cli/main.py +4152 -0
  42. canopy/cli/render.py +98 -0
  43. canopy/cli/ui.py +150 -0
  44. canopy/features/__init__.py +2 -0
  45. canopy/features/coordinator.py +1256 -0
  46. canopy/git/__init__.py +0 -0
  47. canopy/git/hooks.py +173 -0
  48. canopy/git/multi.py +435 -0
  49. canopy/git/repo.py +859 -0
  50. canopy/git/templates/post-checkout.py +67 -0
  51. canopy/graph/__init__.py +0 -0
  52. canopy/integrations/__init__.py +0 -0
  53. canopy/integrations/github.py +983 -0
  54. canopy/integrations/linear.py +307 -0
  55. canopy/integrations/precommit.py +239 -0
  56. canopy/mcp/__init__.py +0 -0
  57. canopy/mcp/client.py +329 -0
  58. canopy/mcp/server.py +1797 -0
  59. canopy/providers/__init__.py +105 -0
  60. canopy/providers/github_issues.py +289 -0
  61. canopy/providers/linear.py +341 -0
  62. canopy/providers/types.py +149 -0
  63. canopy/workspace/__init__.py +4 -0
  64. canopy/workspace/config.py +378 -0
  65. canopy/workspace/context.py +224 -0
  66. canopy/workspace/discovery.py +197 -0
  67. canopy/workspace/workspace.py +173 -0
  68. canopy_cli-3.1.0.dist-info/METADATA +282 -0
  69. canopy_cli-3.1.0.dist-info/RECORD +71 -0
  70. canopy_cli-3.1.0.dist-info/WHEEL +4 -0
  71. canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/cli/main.py ADDED
@@ -0,0 +1,4152 @@
1
+ """
2
+ Canopy CLI — workspace-first development orchestrator.
3
+
4
+ Commands:
5
+ init Auto-detect repos, generate canopy.toml
6
+ status Cross-repo workspace status
7
+ checkout <branch> Checkout branch across repos
8
+ log Interleaved log across repos
9
+ sync Pull + rebase across all repos
10
+ feature create <name> Create a feature lane across repos
11
+ feature list List active feature lanes
12
+ feature switch <name> Checkout feature branch in all repos
13
+ feature diff <name> Aggregate diff for a feature lane
14
+ feature status <name> Detailed feature lane status
15
+ branch list List branches across repos
16
+ branch delete <name> Delete a branch across repos
17
+ branch rename <old> <new> Rename a branch across repos
18
+ stash save Stash changes across repos
19
+ stash pop Pop stash across repos
20
+ stash list List stashes across repos
21
+ stash drop Drop stash across repos
22
+ worktree Show worktree info for repos
23
+ list List all feature lanes
24
+ switch <name> Switch to a feature lane
25
+ preflight Context-aware add + run hooks (from worktree dir)
26
+ review <feature> Fetch PR comments + run pre-commit + preflight
27
+ code <feature|.> Open VS Code for feature or workspace
28
+ cursor <feature|.> Open Cursor for feature or workspace
29
+ fork <feature|.> Open Fork.app for feature or workspace
30
+ context Show detected canopy context (debug)
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import subprocess
37
+ import sys
38
+ from pathlib import Path
39
+
40
+
41
+ def _print_json(data: dict | list) -> None:
42
+ """Print JSON to stdout."""
43
+ print(json.dumps(data, indent=2, default=str))
44
+
45
+
46
+ def _load_workspace():
47
+ """Load workspace from canopy.toml in current directory tree."""
48
+ from ..workspace.config import load_config, ConfigNotFoundError
49
+ from ..workspace.workspace import Workspace
50
+
51
+ try:
52
+ config = load_config()
53
+ except ConfigNotFoundError:
54
+ _print_no_workspace_error()
55
+ sys.exit(1)
56
+
57
+ return Workspace(config)
58
+
59
+
60
+ def _print_no_workspace_error() -> None:
61
+ """Render the canonical 'no canopy.toml' error.
62
+
63
+ Centralised so every workspace-scoped command prints the same helpful
64
+ message instead of a terse "No canopy.toml found." See test-findings
65
+ F-1: this is the first error a fresh user is likely to hit.
66
+ """
67
+ print("Error: no canopy.toml found here or in any parent directory.",
68
+ file=sys.stderr)
69
+ print(file=sys.stderr)
70
+ print(
71
+ "Canopy needs to be run from a workspace — a non-git directory that holds",
72
+ file=sys.stderr,
73
+ )
74
+ print(
75
+ "your repos as subdirectories along with canopy.toml. Either:",
76
+ file=sys.stderr,
77
+ )
78
+ print(file=sys.stderr)
79
+ print(" • cd into your existing workspace root, or", file=sys.stderr)
80
+ print(
81
+ " • run `canopy init` from a non-git directory containing the repos to bootstrap one.",
82
+ file=sys.stderr,
83
+ )
84
+
85
+
86
+ # ── Commands ──────────────────────────────────────────────────────────────
87
+
88
+ def cmd_init(args: argparse.Namespace) -> None:
89
+ """Auto-detect repos and generate canopy.toml."""
90
+ from ..workspace.discovery import discover_repos, generate_toml
91
+ from ..workspace.config import load_config, ConfigNotFoundError
92
+ from .ui import console, spinner, print_error, print_warning
93
+
94
+ root = Path(args.path).resolve() if args.path else Path.cwd().resolve()
95
+
96
+ # Check if canopy.toml already exists
97
+ toml_path = root / "canopy.toml"
98
+ if toml_path.exists() and not args.force:
99
+ print_error(f"canopy.toml already exists at [path]{toml_path}[/]")
100
+ console.print(f" [muted]Use [info]--force[/] to overwrite.[/]")
101
+ sys.exit(1)
102
+
103
+ is_reinit = toml_path.exists() and args.force
104
+ scan_msg = "Rescanning workspace..." if is_reinit else "Scanning for repos..."
105
+
106
+ with spinner(scan_msg):
107
+ repos = discover_repos(root)
108
+
109
+ if not repos:
110
+ print_error(f"No Git repositories found in [path]{root}[/]")
111
+ sys.exit(1)
112
+
113
+ toml_content = generate_toml(root, workspace_name=args.name)
114
+
115
+ if is_reinit:
116
+ print_warning("Overwriting existing canopy.toml")
117
+
118
+ if args.json:
119
+ all_dirs = [d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")]
120
+ skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
121
+ # Detect existing feature worktrees
122
+ worktrees_dir = root / ".canopy" / "worktrees"
123
+ active_worktrees = {}
124
+ if worktrees_dir.is_dir():
125
+ for feat_dir in worktrees_dir.iterdir():
126
+ if feat_dir.is_dir():
127
+ active_worktrees[feat_dir.name] = sorted(
128
+ d.name for d in feat_dir.iterdir() if d.is_dir()
129
+ )
130
+ _print_json({
131
+ "root": str(root),
132
+ "repos": [{
133
+ "name": r.name, "path": r.path, "role": r.role, "lang": r.lang,
134
+ "is_worktree": r.is_worktree, "worktree_main": r.worktree_main,
135
+ } for r in repos],
136
+ "skipped": skipped,
137
+ "active_worktrees": active_worktrees,
138
+ "toml": toml_content,
139
+ })
140
+ return
141
+
142
+ if args.dry_run:
143
+ print(toml_content)
144
+ return
145
+
146
+ from .ui import console, print_success, print_warning, separator, SYM_ARROW, SYM_CHECK
147
+
148
+ toml_path.write_text(toml_content)
149
+
150
+ # Install drift-tracking post-checkout hooks in each non-worktree repo.
151
+ # Worktrees inherit hooks from their main repo via commondir.
152
+ hook_results = _install_hooks_for_repos(root, repos)
153
+
154
+ # Count non-git dirs that were skipped
155
+ all_dirs = [d for d in root.iterdir() if d.is_dir() and not d.name.startswith(".")]
156
+ skipped = [d.name for d in all_dirs if not (d / ".git").exists()]
157
+
158
+ console.print()
159
+ print_success(f"Created [path]{toml_path}[/]")
160
+ console.print()
161
+ console.print(f" [header]Found {len(repos)} repos[/]")
162
+
163
+ for r in repos:
164
+ tags = []
165
+ if r.role:
166
+ tags.append(r.role)
167
+ if r.lang:
168
+ tags.append(r.lang)
169
+ if r.is_worktree:
170
+ tags.append(f"worktree {SYM_ARROW} {r.worktree_main}")
171
+ tag_str = f" [muted]{', '.join(tags)}[/]" if tags else ""
172
+ console.print(f" [repo]{r.name}[/]{tag_str}")
173
+
174
+ if skipped:
175
+ console.print(f" [muted]Skipped {len(skipped)} non-git dirs: {', '.join(skipped)}[/]")
176
+
177
+ if hook_results:
178
+ installed = [h for h in hook_results if h["action"] in ("installed", "reinstalled")]
179
+ chained = [h for h in hook_results if h["action"] == "chained_existing"]
180
+ if installed or chained:
181
+ console.print()
182
+ console.print(f" [header]Drift hooks ({len(installed) + len(chained)})[/]")
183
+ for h in installed + chained:
184
+ note = "" if h["action"] == "installed" else f" [muted]({h['action']})[/]"
185
+ console.print(f" [repo]{h['repo']}[/]{note}")
186
+
187
+ if not args.no_agent:
188
+ from ..agent_setup import setup_agent as _setup_agent
189
+ agent_result = _setup_agent(root, do_skill=True, do_mcp=True, reinstall=False)
190
+ skill = agent_result.get("skill", {})
191
+ mcp = agent_result.get("mcp", {})
192
+ console.print()
193
+ console.print(f" [header]Claude Code agent setup[/]")
194
+ if skill.get("action") in ("installed", "reinstalled"):
195
+ console.print(f" skill [success]{SYM_CHECK}[/] {skill['action']} [muted]{skill['path']}[/]")
196
+ else:
197
+ note = skill.get("reason") or skill.get("action", "")
198
+ console.print(f" skill [muted]· {note}[/]")
199
+ if mcp.get("action") in ("added", "updated", "created"):
200
+ console.print(f" mcp [success]{SYM_CHECK}[/] {mcp['action']} [muted]{mcp['path']}[/]")
201
+ else:
202
+ note = mcp.get("reason") or mcp.get("action", "")
203
+ console.print(f" mcp [muted]· {note}[/]")
204
+ console.print(f" [muted]Restart Claude Code to pick up the skill + MCP. Skip with --no-agent.[/]")
205
+
206
+ # Report existing feature worktrees under .canopy/
207
+ canopy_dir = root / ".canopy"
208
+ worktrees_dir = canopy_dir / "worktrees"
209
+ if worktrees_dir.is_dir():
210
+ features_with_wt = sorted(
211
+ d.name for d in worktrees_dir.iterdir() if d.is_dir()
212
+ )
213
+ if features_with_wt:
214
+ console.print()
215
+ console.print(f" [header]Active worktrees ({len(features_with_wt)})[/]")
216
+ for feat in features_with_wt:
217
+ feat_dir = worktrees_dir / feat
218
+ wt_repos = sorted(
219
+ d.name for d in feat_dir.iterdir() if d.is_dir()
220
+ )
221
+ console.print(f" [feature]{feat}[/] [muted]{SYM_ARROW}[/] {', '.join(wt_repos)}")
222
+ console.print()
223
+
224
+
225
+ def cmd_status(args: argparse.Namespace) -> None:
226
+ """Show cross-repo workspace status."""
227
+ from .ui import console, separator, spinner, SYM_BRANCH
228
+
229
+ workspace = _load_workspace()
230
+ with spinner("Reading workspace state…"):
231
+ workspace.refresh()
232
+
233
+ if args.json:
234
+ _print_json(workspace.to_dict())
235
+ return
236
+
237
+ console.print()
238
+ console.print(f" [header]{workspace.config.name}[/] [path]{workspace.config.root}[/]")
239
+ separator()
240
+
241
+ for state in workspace.repos:
242
+ role = f" [muted]{state.config.role}[/]" if state.config.role else ""
243
+ console.print(f"\n [repo]{state.config.name}[/]{role}")
244
+
245
+ # Branch line with status indicators
246
+ parts = []
247
+ if state.is_dirty:
248
+ parts.append(f"[dirty]{state.dirty_count} dirty[/]")
249
+ if state.ahead_of_default:
250
+ parts.append(f"[ahead]↑{state.ahead_of_default}[/]")
251
+ if state.behind_default:
252
+ parts.append(f"[behind]↓{state.behind_default}[/]")
253
+ status_str = f" {' '.join(parts)}" if parts else ""
254
+
255
+ console.print(f" {SYM_BRANCH} [branch]{state.current_branch}[/]{status_str}")
256
+ console.print(f" [muted]{state.head_sha}[/]")
257
+
258
+ features = workspace.active_features()
259
+ if features:
260
+ separator()
261
+ feat_str = " ".join(f"[feature]{f}[/]" for f in features)
262
+ console.print(f" Active features: {feat_str}")
263
+
264
+ console.print()
265
+
266
+
267
+ def cmd_feature_create(args: argparse.Namespace) -> None:
268
+ """Create a feature lane across repos."""
269
+ workspace = _load_workspace()
270
+ from ..features.coordinator import FeatureCoordinator
271
+
272
+ coordinator = FeatureCoordinator(workspace)
273
+ repos = args.repos.split(",") if args.repos else None
274
+ use_worktrees = getattr(args, "worktree", False)
275
+
276
+ try:
277
+ lane = coordinator.create(args.name, repos, use_worktrees=use_worktrees)
278
+ except (ValueError, RuntimeError) as e:
279
+ print(f"Error: {e}", file=sys.stderr)
280
+ sys.exit(1)
281
+
282
+ if args.json:
283
+ _print_json(lane.to_dict())
284
+ return
285
+
286
+ if use_worktrees:
287
+ print(f"Created feature lane with worktrees: {lane.name}")
288
+ # Show worktree paths
289
+ paths = coordinator.resolve_paths(lane.name)
290
+ for repo_name, path in paths.items():
291
+ print(f" {repo_name}: {path}")
292
+ print(f"\nOpen in VS Code: canopy code {lane.name}")
293
+ print(f"Open in Cursor: canopy cursor {lane.name}")
294
+ else:
295
+ print(f"Created feature lane: {lane.name}")
296
+ print(f" Repos: {', '.join(lane.repos)}")
297
+ print(f"\nSwitch to it with: canopy feature switch {lane.name}")
298
+ print(f"Or create with worktrees: canopy feature create --worktree {lane.name}")
299
+
300
+
301
+ def cmd_feature_list(args: argparse.Namespace) -> None:
302
+ """List active feature lanes."""
303
+ from .ui import console, separator, SYM_LINK
304
+
305
+ workspace = _load_workspace()
306
+ from ..features.coordinator import FeatureCoordinator
307
+
308
+ coordinator = FeatureCoordinator(workspace)
309
+ lanes = coordinator.list_active()
310
+
311
+ if args.json:
312
+ _print_json([lane.to_dict() for lane in lanes])
313
+ return
314
+
315
+ if not lanes:
316
+ console.print()
317
+ console.print(" [muted]No active feature lanes.[/]")
318
+ console.print(f" [muted]Create one with:[/] [info]canopy worktree <name>[/]")
319
+ console.print()
320
+ return
321
+
322
+ console.print()
323
+ console.print(f" [header]Feature Lanes ({len(lanes)})[/]")
324
+
325
+ for lane in lanes:
326
+ separator()
327
+ linear_str = ""
328
+ if lane.linear_issue:
329
+ title_bit = f" — {lane.linear_title}" if lane.linear_title else ""
330
+ linear_str = f" [linear]{SYM_LINK} {lane.linear_issue}{title_bit}[/]"
331
+ console.print(f" [feature]{lane.name}[/]{linear_str}")
332
+
333
+ for repo_name, state in lane.repo_states.items():
334
+ if "error" in state:
335
+ console.print(f" [repo]{repo_name}[/] [error]error — {state['error']}[/]")
336
+ continue
337
+ if not state.get("has_branch"):
338
+ console.print(f" [repo]{repo_name}[/] [muted]no branch[/]")
339
+ continue
340
+ parts = []
341
+ if state.get("ahead"):
342
+ parts.append(f"[ahead]↑{state['ahead']}[/]")
343
+ if state.get("behind"):
344
+ parts.append(f"[behind]↓{state['behind']}[/]")
345
+ if state.get("dirty"):
346
+ parts.append("[dirty]dirty[/]")
347
+ if state.get("changed_file_count"):
348
+ parts.append(f"[muted]{state['changed_file_count']} files[/]")
349
+ status = " ".join(parts) if parts else "[clean]up to date[/]"
350
+ console.print(f" [repo]{repo_name}[/] {status}")
351
+
352
+ console.print()
353
+
354
+
355
+ def cmd_feature_diff(args: argparse.Namespace) -> None:
356
+ """Show aggregate diff for a feature lane."""
357
+ workspace = _load_workspace()
358
+ from ..features.coordinator import FeatureCoordinator
359
+
360
+ coordinator = FeatureCoordinator(workspace)
361
+
362
+ try:
363
+ diff = coordinator.diff(args.name)
364
+ except ValueError as e:
365
+ print(f"Error: {e}", file=sys.stderr)
366
+ sys.exit(1)
367
+
368
+ if args.json:
369
+ _print_json(diff)
370
+ return
371
+
372
+ summary = diff["summary"]
373
+ print(f"\n Feature: {args.name}")
374
+ print(f" {summary['participating_repos']}/{summary['total_repos']} repos, "
375
+ f"{summary['total_files_changed']} files, "
376
+ f"+{summary['total_insertions']} -{summary['total_deletions']}")
377
+ print(f" {'─' * 60}")
378
+
379
+ for repo_name, data in diff["repos"].items():
380
+ if not data.get("has_branch"):
381
+ print(f"\n {repo_name}: (no branch)")
382
+ continue
383
+
384
+ ins = data.get("insertions", 0)
385
+ dele = data.get("deletions", 0)
386
+ files = data.get("changed_files", [])
387
+ print(f"\n {repo_name} ({len(files)} files, +{ins} -{dele})")
388
+ for f in files[:10]:
389
+ print(f" {f}")
390
+ if len(files) > 10:
391
+ print(f" ... and {len(files) - 10} more")
392
+
393
+ if diff.get("type_overlaps"):
394
+ print(f"\n {'─' * 60}")
395
+ print(f" Type Overlaps:")
396
+ for o in diff["type_overlaps"]:
397
+ repos = ", ".join(o["repos"])
398
+ print(f" '{o['file_pattern']}' modified in {repos}")
399
+ for f in o["files"]:
400
+ print(f" {f['repo']}: {f['path']}")
401
+
402
+ print()
403
+
404
+
405
+ def cmd_feature_changes(args: argparse.Namespace) -> None:
406
+ """Show per-file change status (M/A/D/?) for each repo in a feature."""
407
+ workspace = _load_workspace()
408
+ from ..features.coordinator import FeatureCoordinator
409
+
410
+ coordinator = FeatureCoordinator(workspace)
411
+
412
+ try:
413
+ result = coordinator.feature_changes(args.name)
414
+ except ValueError as e:
415
+ print(f"Error: {e}", file=sys.stderr)
416
+ sys.exit(1)
417
+
418
+ if args.json:
419
+ _print_json(result)
420
+ return
421
+
422
+ print(f"\n Feature: {result['feature']}")
423
+ print(f" {'─' * 60}")
424
+
425
+ for repo_name, data in result["repos"].items():
426
+ if data.get("error"):
427
+ print(f"\n {repo_name}: error — {data['error']}")
428
+ continue
429
+ if not data.get("has_branch"):
430
+ print(f"\n {repo_name}: (no branch)")
431
+ continue
432
+ changes = data.get("changes", [])
433
+ print(f"\n {repo_name} ({len(changes)} change{'s' if len(changes) != 1 else ''})")
434
+ for c in changes:
435
+ print(f" {c['status']} {c['path']}")
436
+
437
+ print()
438
+
439
+
440
+ def cmd_feature_status(args: argparse.Namespace) -> None:
441
+ """Show detailed feature lane status."""
442
+ from .ui import console, separator, print_success, SYM_CHECK, SYM_CROSS, SYM_LINK
443
+
444
+ workspace = _load_workspace()
445
+ from ..features.coordinator import FeatureCoordinator
446
+
447
+ coordinator = FeatureCoordinator(workspace)
448
+
449
+ try:
450
+ lane = coordinator.status(args.name)
451
+ except ValueError as e:
452
+ from .ui import print_error
453
+ print_error(str(e))
454
+ sys.exit(1)
455
+
456
+ if args.json:
457
+ _print_json(lane.to_dict())
458
+ return
459
+
460
+ console.print()
461
+ linear_str = ""
462
+ if lane.linear_issue:
463
+ title_bit = f" — {lane.linear_title}" if lane.linear_title else ""
464
+ linear_str = f" [linear]{SYM_LINK} {lane.linear_issue}{title_bit}[/]"
465
+ console.print(f" [feature]{lane.name}[/]{linear_str}")
466
+ console.print(f" [muted]status: {lane.status}[/]")
467
+ if lane.created_at:
468
+ console.print(f" [muted]created: {lane.created_at}[/]")
469
+ separator()
470
+
471
+ for repo_name, state in lane.repo_states.items():
472
+ if "error" in state:
473
+ console.print(f"\n [repo]{repo_name}[/] [error]error — {state['error']}[/]")
474
+ continue
475
+ if not state.get("has_branch"):
476
+ console.print(f"\n [repo]{repo_name}[/] [muted]no branch[/]")
477
+ continue
478
+
479
+ parts = []
480
+ if state.get("ahead"):
481
+ parts.append(f"[ahead]↑{state['ahead']} ahead[/]")
482
+ if state.get("behind"):
483
+ parts.append(f"[behind]↓{state['behind']} behind[/]")
484
+ if state.get("dirty"):
485
+ parts.append("[dirty]uncommitted changes[/]")
486
+ divergence = " ".join(parts) if parts else "[clean]up to date[/]"
487
+
488
+ console.print(f"\n [repo]{repo_name}[/] {divergence}")
489
+ files = state.get("changed_files", [])
490
+ if files:
491
+ console.print(f" [muted]files ({len(files)}):[/]")
492
+ for f in files[:8]:
493
+ console.print(f" [path]{f}[/]")
494
+ if len(files) > 8:
495
+ console.print(f" [muted]... and {len(files) - 8} more[/]")
496
+
497
+ # Merge readiness
498
+ readiness = coordinator.merge_readiness(lane.name)
499
+ separator()
500
+ if readiness["ready"]:
501
+ console.print(f" [success]{SYM_CHECK} Merge ready[/]")
502
+ else:
503
+ console.print(f" [error]{SYM_CROSS} Not merge ready[/]")
504
+ for issue in readiness["issues"]:
505
+ console.print(f" [muted]•[/] {issue}")
506
+
507
+ console.print()
508
+
509
+
510
+ def cmd_sync(args: argparse.Namespace) -> None:
511
+ """Pull + rebase across all repos."""
512
+ workspace = _load_workspace()
513
+ from ..git.multi import sync_all
514
+ from .ui import spinner
515
+
516
+ repo_count = len(workspace.repos)
517
+ with spinner(f"Syncing {repo_count} repo{'s' if repo_count != 1 else ''}…"):
518
+ results = sync_all(workspace, strategy=args.strategy)
519
+
520
+ if args.json:
521
+ _print_json({"results": results})
522
+ return
523
+
524
+ for repo, result in results.items():
525
+ icon = "ok" if result == "ok" else f"failed: {result}"
526
+ print(f" {repo}: {icon}")
527
+
528
+
529
+ def cmd_checkout(args: argparse.Namespace) -> None:
530
+ """Checkout a branch across repos."""
531
+ workspace = _load_workspace()
532
+ from ..git.multi import checkout_all
533
+
534
+ repos = args.repos.split(",") if args.repos else None
535
+ results = checkout_all(workspace, args.branch, repos)
536
+
537
+ if args.json:
538
+ _print_json({"branch": args.branch, "results": results})
539
+ return
540
+
541
+ for repo, result in results.items():
542
+ status = "ok" if result is True else f"failed: {result}"
543
+ print(f" {repo}: {status}")
544
+
545
+
546
+
547
+ def cmd_log(args: argparse.Namespace) -> None:
548
+ """Interleaved log across repos."""
549
+ workspace = _load_workspace()
550
+ from ..git.multi import log_all
551
+
552
+ entries = log_all(workspace, max_count=args.count, feature=args.feature)
553
+
554
+ if args.json:
555
+ _print_json(entries)
556
+ return
557
+
558
+ if not entries:
559
+ print(" No commits found.")
560
+ return
561
+
562
+ for entry in entries:
563
+ date_short = entry["date"][:10] if entry.get("date") else ""
564
+ print(f" {entry.get('short_sha', '')} [{entry.get('repo', '')}] "
565
+ f"{entry.get('subject', '')} ({entry.get('author', '')}, {date_short})")
566
+
567
+
568
+ def cmd_branch_list(args: argparse.Namespace) -> None:
569
+ """List branches across repos."""
570
+ workspace = _load_workspace()
571
+ from ..git.multi import branches_all
572
+
573
+ results = branches_all(workspace)
574
+
575
+ if args.json:
576
+ _print_json(results)
577
+ return
578
+
579
+ for repo_name, branches in results.items():
580
+ print(f"\n {repo_name}")
581
+ for b in branches:
582
+ marker = "* " if b["is_current"] else " "
583
+ print(f" {marker}{b['name']} {b['sha']} {b['subject']}")
584
+
585
+ print()
586
+
587
+
588
+ def cmd_branch_delete(args: argparse.Namespace) -> None:
589
+ """Delete a branch across repos."""
590
+ workspace = _load_workspace()
591
+ from ..git.multi import delete_branch_all
592
+
593
+ repos = args.repos.split(",") if args.repos else None
594
+ results = delete_branch_all(workspace, args.name, force=args.force, repos=repos)
595
+
596
+ if args.json:
597
+ _print_json({"branch": args.name, "results": results})
598
+ return
599
+
600
+ for repo, result in results.items():
601
+ print(f" {repo}: {result}")
602
+
603
+
604
+ def cmd_branch_rename(args: argparse.Namespace) -> None:
605
+ """Rename a branch across repos."""
606
+ workspace = _load_workspace()
607
+ from ..git.multi import rename_branch_all
608
+
609
+ repos = args.repos.split(",") if args.repos else None
610
+ results = rename_branch_all(workspace, args.old, args.new, repos)
611
+
612
+ if args.json:
613
+ _print_json({"old": args.old, "new": args.new, "results": results})
614
+ return
615
+
616
+ for repo, result in results.items():
617
+ print(f" {repo}: {result}")
618
+
619
+
620
+ def cmd_stash_save(args: argparse.Namespace) -> None:
621
+ """Stash uncommitted changes across repos."""
622
+ # Route to the feature-tagged path when --feature is passed.
623
+ if getattr(args, "feature", None):
624
+ cmd_stash_save_feature(args)
625
+ return
626
+
627
+ workspace = _load_workspace()
628
+ from ..git.multi import stash_save_all
629
+
630
+ repos = args.repos.split(",") if args.repos else None
631
+ results = stash_save_all(workspace, message=args.message or "", repos=repos)
632
+
633
+ if args.json:
634
+ _print_json({"results": results})
635
+ return
636
+
637
+ for repo, result in results.items():
638
+ print(f" {repo}: {result}")
639
+
640
+
641
+ def cmd_stash_pop(args: argparse.Namespace) -> None:
642
+ """Pop stash across repos."""
643
+ # Route to feature-tagged pop when --feature is passed.
644
+ if getattr(args, "feature", None):
645
+ cmd_stash_pop_feature(args)
646
+ return
647
+
648
+ workspace = _load_workspace()
649
+ from ..git.multi import stash_pop_all
650
+
651
+ repos = args.repos.split(",") if args.repos else None
652
+ results = stash_pop_all(workspace, index=args.index, repos=repos)
653
+
654
+ if args.json:
655
+ _print_json({"results": results})
656
+ return
657
+
658
+ for repo, result in results.items():
659
+ print(f" {repo}: {result}")
660
+
661
+
662
+ def cmd_stash_list(args: argparse.Namespace) -> None:
663
+ """List stashes across repos."""
664
+ # Route to grouped list whenever --feature is passed (or always if you
665
+ # want grouping by default; for now keep flat list as default).
666
+ if getattr(args, "feature", None):
667
+ cmd_stash_list_grouped(args)
668
+ return
669
+
670
+ workspace = _load_workspace()
671
+ from ..git.multi import stash_list_all
672
+
673
+ results = stash_list_all(workspace)
674
+
675
+ if args.json:
676
+ _print_json(results)
677
+ return
678
+
679
+ if not results:
680
+ print(" No stashes found.")
681
+ return
682
+
683
+ for repo_name, stashes in results.items():
684
+ print(f"\n {repo_name}")
685
+ for s in stashes:
686
+ print(f" {s['ref']}: {s['message']}")
687
+
688
+ print()
689
+
690
+
691
+ def cmd_stash_drop(args: argparse.Namespace) -> None:
692
+ """Drop stash across repos."""
693
+ workspace = _load_workspace()
694
+ from ..git.multi import stash_drop_all
695
+
696
+ repos = args.repos.split(",") if args.repos else None
697
+ results = stash_drop_all(workspace, index=args.index, repos=repos)
698
+
699
+ if args.json:
700
+ _print_json({"results": results})
701
+ return
702
+
703
+ for repo, result in results.items():
704
+ print(f" {repo}: {result}")
705
+
706
+
707
+ def cmd_worktree(args: argparse.Namespace) -> None:
708
+ """Dispatch: list worktrees or create a new one."""
709
+ if args.name:
710
+ cmd_worktree_create(args)
711
+ else:
712
+ cmd_worktree_list(args)
713
+
714
+
715
+ def cmd_worktree_create(args: argparse.Namespace) -> None:
716
+ """Create a feature with worktrees, optionally linked to a Linear issue."""
717
+ from .ui import console, spinner, print_success, print_warning, print_error, separator, SYM_ARROW, SYM_LINK
718
+
719
+ workspace = _load_workspace()
720
+ from ..features.coordinator import FeatureCoordinator
721
+
722
+ name = args.name
723
+ issue_id = args.issue
724
+ repos = args.repos
725
+
726
+ # ── Linear integration ──
727
+ linear_issue = ""
728
+ linear_title = ""
729
+ linear_url = ""
730
+
731
+ if issue_id:
732
+ from ..integrations.linear import (
733
+ is_linear_configured,
734
+ get_issue,
735
+ format_branch_name,
736
+ LinearNotConfiguredError,
737
+ LinearIssueNotFoundError,
738
+ )
739
+ from ..mcp.client import McpClientError
740
+
741
+ if is_linear_configured(workspace.config.root):
742
+ try:
743
+ with spinner(f"Fetching {issue_id} from Linear..."):
744
+ issue_data = get_issue(workspace.config.root, issue_id)
745
+ linear_issue = issue_data.get("identifier", issue_id)
746
+ linear_title = issue_data.get("title", "")
747
+ linear_url = issue_data.get("url", "")
748
+ if linear_title:
749
+ console.print(f" [linear]{SYM_LINK} {linear_issue}: {linear_title}[/]")
750
+ except (LinearNotConfiguredError, LinearIssueNotFoundError, McpClientError) as e:
751
+ print_warning(f"Could not fetch Linear issue: {e}")
752
+ console.print(f" [muted]Continuing without Linear link...[/]")
753
+ linear_issue = issue_id
754
+ else:
755
+ print_warning(f"Linear MCP not configured — storing '{issue_id}' without fetching.")
756
+ linear_issue = issue_id
757
+
758
+ # ── Create the feature with worktrees ──
759
+ coordinator = FeatureCoordinator(workspace)
760
+ try:
761
+ with spinner(f"Creating worktrees for {name}..."):
762
+ lane = coordinator.create(
763
+ name,
764
+ repos=repos,
765
+ use_worktrees=True,
766
+ linear_issue=linear_issue,
767
+ linear_title=linear_title,
768
+ linear_url=linear_url,
769
+ )
770
+ except (RuntimeError,) as e:
771
+ print_error(str(e))
772
+ sys.exit(1)
773
+ except ValueError as e:
774
+ # Check if this is a worktree limit error
775
+ from ..features.coordinator import WorktreeLimitError
776
+ if isinstance(e, WorktreeLimitError):
777
+ print_error(f"Worktree limit reached ({e.current}/{e.limit})")
778
+ if e.stale:
779
+ console.print()
780
+ console.print(f" [muted]Suggested cleanup:[/]")
781
+ for s in e.stale:
782
+ console.print(f" [feature]{s['name']}[/] [muted]{s['reason']}[/]")
783
+ console.print()
784
+ console.print(f" [muted]Run:[/] [info]canopy done <feature>[/]")
785
+ else:
786
+ console.print(f" [muted]Run:[/] [info]canopy done <feature>[/] to free a slot")
787
+ console.print(f" [muted]Or:[/] [info]canopy config slots {e.limit + 1}[/]")
788
+ else:
789
+ print_error(str(e))
790
+ sys.exit(1)
791
+
792
+ result = lane.to_dict()
793
+ result["worktree_paths"] = coordinator.resolve_paths(name)
794
+ from ..actions import slots as _slots_mod
795
+ _slot_id = _slots_mod.slot_for_feature(workspace, name)
796
+ if _slot_id is not None:
797
+ result["slot_id"] = _slot_id
798
+
799
+ if args.json:
800
+ _print_json(result)
801
+ return
802
+
803
+ console.print()
804
+ for repo_name, path in result["worktree_paths"].items():
805
+ print_success(f"[repo]{repo_name}[/] [muted]{SYM_ARROW}[/] [path]{path}[/]")
806
+
807
+ if linear_issue and not linear_title:
808
+ console.print(f"\n [linear]{SYM_LINK} {linear_issue}[/]")
809
+
810
+ console.print()
811
+ console.print(f" [muted]Open in IDE:[/]")
812
+ console.print(f" [info]canopy code {name}[/]")
813
+ console.print(f" [info]canopy cursor {name}[/]")
814
+ console.print(f" [info]canopy fork {name}[/]")
815
+ console.print()
816
+
817
+
818
+ def cmd_worktree_list(args: argparse.Namespace) -> None:
819
+ """Show live worktree status — always reflects current filesystem."""
820
+ from .ui import console, spinner, separator, SYM_BRANCH, SYM_LINK
821
+
822
+ workspace = _load_workspace()
823
+ from ..features.coordinator import FeatureCoordinator
824
+
825
+ coordinator = FeatureCoordinator(workspace)
826
+
827
+ with spinner("Scanning worktrees..."):
828
+ data = coordinator.worktrees_live()
829
+
830
+ if args.json:
831
+ _print_json(data)
832
+ return
833
+
834
+ features = data.get("features", {})
835
+ repos_wt = data.get("repos", {})
836
+
837
+ # Also load feature metadata for Linear links
838
+ features_json = coordinator._load_features()
839
+
840
+ if not features and all(
841
+ len(r.get("worktrees", [])) <= 1 for r in repos_wt.values()
842
+ ):
843
+ console.print()
844
+ console.print(" [muted]No active worktrees.[/]")
845
+ console.print(f" [muted]Create one with:[/] [info]canopy worktree <name>[/]")
846
+ console.print()
847
+ return
848
+
849
+ # ── Feature worktrees ──
850
+ if features:
851
+ console.print()
852
+ console.print(f" [header]Worktrees ({len(features)})[/]")
853
+ for feat_name, feat_data in features.items():
854
+ separator()
855
+ # Show Linear link if present
856
+ meta = features_json.get(feat_name, {})
857
+ linear_id = meta.get("linear_issue", "")
858
+ linear_title = meta.get("linear_title", "")
859
+ if linear_id:
860
+ title_str = f" — {linear_title}" if linear_title else ""
861
+ console.print(f" [feature]{feat_name}[/] [linear]{SYM_LINK} {linear_id}{title_str}[/]")
862
+ else:
863
+ console.print(f" [feature]{feat_name}[/]")
864
+
865
+ for repo_name, info in feat_data.get("repos", {}).items():
866
+ branch = info.get("branch", "?")
867
+ dirty = info.get("dirty", False)
868
+ dirty_count = info.get("dirty_count", 0)
869
+ ahead = info.get("ahead", 0)
870
+ behind = info.get("behind", 0)
871
+
872
+ parts = []
873
+ if dirty:
874
+ parts.append(f"[dirty]{dirty_count} dirty[/]")
875
+ if ahead:
876
+ parts.append(f"[ahead]↑{ahead}[/]")
877
+ if behind:
878
+ parts.append(f"[behind]↓{behind}[/]")
879
+ status_str = f" {' '.join(parts)}" if parts else ""
880
+
881
+ console.print(f" [repo]{repo_name}[/] {SYM_BRANCH} [branch]{branch}[/]{status_str}")
882
+ console.print(f" [path]{info.get('path', '?')}[/]")
883
+
884
+ # ── Per-repo git worktrees (only show if repo has >1 worktree) ──
885
+ multi_wt = {
886
+ name: info for name, info in repos_wt.items()
887
+ if len(info.get("worktrees", [])) > 1
888
+ }
889
+ if multi_wt:
890
+ console.print()
891
+ console.print(f" [subheader]Git worktrees per repo[/]")
892
+ for repo_name, info in multi_wt.items():
893
+ separator()
894
+ console.print(f" [repo]{repo_name}[/] [path]{info['main_path']}[/]")
895
+ for wt in info["worktrees"]:
896
+ branch = wt.get("branch", "(detached)")
897
+ console.print(f" [path]{wt['path']}[/] [branch]\\[{branch}][/]")
898
+
899
+ console.print()
900
+
901
+
902
+ def cmd_slots(args: argparse.Namespace) -> None:
903
+ """Show slot occupancy: canonical + warm slots + last_touched.
904
+
905
+ ``--json`` always returns the rich shape (single call powers the
906
+ dashboard + agent); pretty terminal output stays compact unless
907
+ ``--rich`` is passed.
908
+ """
909
+ from ..actions import slots as slots_mod
910
+ from .ui import console
911
+
912
+ workspace = _load_workspace()
913
+ state = slots_mod.read_state(workspace)
914
+ if args.json:
915
+ from ..actions.slot_details import rich_slots
916
+ _print_json(rich_slots(workspace))
917
+ return
918
+ if state is None:
919
+ console.print()
920
+ console.print(" [muted]No slot state yet — run `canopy switch <feature>`.[/]")
921
+ console.print()
922
+ return
923
+ console.print()
924
+ if state.canonical:
925
+ console.print(f" [header]Canonical:[/] [info]{state.canonical.feature}[/]"
926
+ f" [muted]({state.canonical.activated_at[:16]})[/]")
927
+ console.print(f" [header]Slots ({len(state.slots)}/{state.slot_count}):[/]")
928
+ for i in range(1, state.slot_count + 1):
929
+ sid = f"worktree-{i}"
930
+ entry = state.slots.get(sid)
931
+ if entry:
932
+ last = state.last_touched.get(entry.feature, "")
933
+ console.print(f" {sid}: [info]{entry.feature}[/]"
934
+ f" [muted]touched {last[:16]}[/]")
935
+ else:
936
+ console.print(f" {sid}: [muted]<empty>[/]")
937
+ console.print()
938
+
939
+
940
+ def cmd_slot_load(args: argparse.Namespace) -> None:
941
+ """Warm a cold feature into a slot without changing canonical."""
942
+ from ..actions.slot_load import slot_load
943
+ from .ui import console
944
+
945
+ workspace = _load_workspace()
946
+ result = slot_load(
947
+ workspace, args.feature,
948
+ slot_id=args.slot_id, replace=args.replace, bootstrap=args.bootstrap,
949
+ )
950
+ if args.json:
951
+ _print_json(result)
952
+ return
953
+ console.print(f"[ok]Loaded[/] [info]{result['feature']}[/] into [info]{result['slot_id']}[/]")
954
+ if result.get("evicted"):
955
+ console.print(f" Evicted: [muted]{result['evicted']['feature']}[/]")
956
+
957
+
958
+ def cmd_slot_clear(args: argparse.Namespace) -> None:
959
+ """Evict a slot's occupant to cold."""
960
+ from ..actions.slot_load import slot_clear
961
+ from .ui import console
962
+
963
+ workspace = _load_workspace()
964
+ result = slot_clear(workspace, args.slot_id)
965
+ if args.json:
966
+ _print_json(result)
967
+ return
968
+ console.print(f"[ok]Cleared[/] {result['slot_id']}: evicted [info]{result['feature']}[/]")
969
+
970
+
971
+ def cmd_slot_swap(args: argparse.Namespace) -> None:
972
+ """Exchange occupants of two slots."""
973
+ from ..actions.slot_load import slot_swap
974
+ from .ui import console
975
+
976
+ workspace = _load_workspace()
977
+ result = slot_swap(workspace, args.slot_a, args.slot_b)
978
+ if args.json:
979
+ _print_json(result)
980
+ return
981
+ console.print(f"[ok]Swapped:[/] {result['swapped'][0]} ({result['slot_a']} ↔ {result['slot_b']})")
982
+
983
+
984
+ def cmd_migrate_slots(args: argparse.Namespace) -> None:
985
+ """One-shot migration from pre-3.0 layout to 3.0 slot model."""
986
+ from ..actions.migrate_slots import migrate, AlreadyMigratedError, NotLegacyError
987
+ from .ui import console
988
+
989
+ # Don't use _load_workspace() — pre-3.0 canopy.toml fails load_config validation.
990
+ # Walk up from cwd looking for canopy.toml directly.
991
+ root = Path.cwd().resolve()
992
+ while root != root.parent:
993
+ if (root / "canopy.toml").exists():
994
+ break
995
+ root = root.parent
996
+ else:
997
+ print("Error: not inside a canopy workspace (no canopy.toml found)", file=sys.stderr)
998
+ sys.exit(1)
999
+
1000
+ try:
1001
+ result = migrate(root)
1002
+ except AlreadyMigratedError as e:
1003
+ print(f"Error: already migrated: {e}", file=sys.stderr)
1004
+ sys.exit(1)
1005
+ except NotLegacyError as e:
1006
+ console.print(f" [muted]Nothing to migrate: {e}[/]")
1007
+ return
1008
+
1009
+ if args.json:
1010
+ _print_json(result)
1011
+ return
1012
+
1013
+ console.print()
1014
+ console.print(f" [success]Migrated {len(result['moved'])} worktree dir(s) to slots[/]")
1015
+ for sid, feat in result["slots"].items():
1016
+ console.print(f" {sid}: [info]{feat}[/]")
1017
+ if result["canonical"]:
1018
+ console.print(f" [header]Canonical:[/] [info]{result['canonical']}[/]")
1019
+ console.print()
1020
+
1021
+
1022
+ def _open_ide(ide_cmd: str, args: argparse.Namespace) -> None:
1023
+ """Open an IDE with the right directories for a feature or workspace.
1024
+
1025
+ Supports two modes:
1026
+ - `canopy code <feature>` — open repos/worktrees for a feature lane
1027
+ - `canopy code .` — open all repos in the workspace
1028
+ """
1029
+ workspace = _load_workspace()
1030
+
1031
+ target = args.target
1032
+
1033
+ if target == ".":
1034
+ # Open all repos in workspace
1035
+ paths = [str(state.abs_path) for state in workspace.repos
1036
+ if state.abs_path.exists()]
1037
+ label = workspace.config.name
1038
+ else:
1039
+ # Open repos for a feature lane
1040
+ from ..features.coordinator import FeatureCoordinator
1041
+ coordinator = FeatureCoordinator(workspace)
1042
+ try:
1043
+ paths_dict = coordinator.resolve_paths(target)
1044
+ except ValueError as e:
1045
+ print(f"Error: {e}", file=sys.stderr)
1046
+ sys.exit(1)
1047
+
1048
+ if not paths_dict:
1049
+ print(f"No paths found for feature '{target}'", file=sys.stderr)
1050
+ sys.exit(1)
1051
+
1052
+ paths = list(paths_dict.values())
1053
+ label = target
1054
+
1055
+ if not paths:
1056
+ print("No directories to open.", file=sys.stderr)
1057
+ sys.exit(1)
1058
+
1059
+ if args.json:
1060
+ _print_json({"ide": ide_cmd, "target": target, "paths": paths})
1061
+ return
1062
+
1063
+ # If multiple paths, generate a .code-workspace file for multi-root
1064
+ if len(paths) > 1:
1065
+ workspace_file = _generate_workspace_file(
1066
+ workspace.config.root, label, paths
1067
+ )
1068
+ cmd = [ide_cmd, workspace_file]
1069
+ print(f" Opening {ide_cmd} with workspace: {workspace_file}")
1070
+ else:
1071
+ cmd = [ide_cmd, paths[0]]
1072
+ print(f" Opening {ide_cmd}: {paths[0]}")
1073
+
1074
+ try:
1075
+ subprocess.Popen(
1076
+ cmd,
1077
+ stdout=subprocess.DEVNULL,
1078
+ stderr=subprocess.DEVNULL,
1079
+ )
1080
+ except FileNotFoundError:
1081
+ print(f"Error: '{ide_cmd}' not found. Is it installed and on PATH?",
1082
+ file=sys.stderr)
1083
+ print(f" VS Code: install 'code' command from Command Palette",
1084
+ file=sys.stderr)
1085
+ print(f" Cursor: install 'cursor' command from Command Palette",
1086
+ file=sys.stderr)
1087
+ sys.exit(1)
1088
+
1089
+
1090
+ def _generate_workspace_file(
1091
+ root: Path,
1092
+ label: str,
1093
+ paths: list[str],
1094
+ ) -> str:
1095
+ """Generate a .code-workspace file for multi-root workspace.
1096
+
1097
+ Returns the path to the generated file.
1098
+ """
1099
+ canopy_dir = root / ".canopy"
1100
+ canopy_dir.mkdir(parents=True, exist_ok=True)
1101
+
1102
+ workspace_data = {
1103
+ "folders": [{"path": p} for p in paths],
1104
+ "settings": {
1105
+ "canopy.feature": label,
1106
+ },
1107
+ }
1108
+
1109
+ ws_file = canopy_dir / f"{label}.code-workspace"
1110
+ ws_file.write_text(json.dumps(workspace_data, indent=2))
1111
+ return str(ws_file)
1112
+
1113
+
1114
+ def cmd_code(args: argparse.Namespace) -> None:
1115
+ """Open VS Code with feature or workspace directories."""
1116
+ _open_ide("code", args)
1117
+
1118
+
1119
+ def cmd_cursor(args: argparse.Namespace) -> None:
1120
+ """Open Cursor with feature or workspace directories."""
1121
+ _open_ide("cursor", args)
1122
+
1123
+
1124
+ def cmd_fork(args: argparse.Namespace) -> None:
1125
+ """Open Fork.app with feature or workspace repos."""
1126
+ workspace = _load_workspace()
1127
+
1128
+ target = args.target
1129
+
1130
+ if target == ".":
1131
+ paths = [str(state.abs_path) for state in workspace.repos
1132
+ if state.abs_path.exists()]
1133
+ else:
1134
+ from ..features.coordinator import FeatureCoordinator
1135
+ coordinator = FeatureCoordinator(workspace)
1136
+ try:
1137
+ paths_dict = coordinator.resolve_paths(target)
1138
+ except ValueError as e:
1139
+ print(f"Error: {e}", file=sys.stderr)
1140
+ sys.exit(1)
1141
+ paths = list(paths_dict.values())
1142
+
1143
+ if not paths:
1144
+ print("No directories to open.", file=sys.stderr)
1145
+ sys.exit(1)
1146
+
1147
+ if args.json:
1148
+ _print_json({"ide": "fork", "target": target, "paths": paths})
1149
+ return
1150
+
1151
+ # Fork opens repos individually — each path becomes a tab
1152
+ import platform
1153
+ import shutil
1154
+
1155
+ use_fork_cli = shutil.which("fork") is not None
1156
+ is_macos = platform.system() == "Darwin"
1157
+
1158
+ if not use_fork_cli and not is_macos:
1159
+ print(
1160
+ "Error: 'fork' CLI not found.\n"
1161
+ " Install it from Fork → Preferences → Integration → Install CLI Tool.",
1162
+ file=sys.stderr,
1163
+ )
1164
+ sys.exit(1)
1165
+
1166
+ import time
1167
+
1168
+ for i, p in enumerate(paths):
1169
+ if use_fork_cli:
1170
+ subprocess.Popen(
1171
+ ["fork", p],
1172
+ stdout=subprocess.DEVNULL,
1173
+ stderr=subprocess.DEVNULL,
1174
+ )
1175
+ else:
1176
+ # macOS fallback: open -a Fork
1177
+ result = subprocess.run(
1178
+ ["open", "-a", "Fork", p],
1179
+ capture_output=True, text=True,
1180
+ )
1181
+ if result.returncode != 0:
1182
+ print(f"Error: could not open Fork. Is Fork.app installed?",
1183
+ file=sys.stderr)
1184
+ sys.exit(1)
1185
+ print(f" opened: {p}")
1186
+ # Small delay between opens so Fork can register each repo
1187
+ if i < len(paths) - 1:
1188
+ time.sleep(0.5)
1189
+
1190
+
1191
+ def _cmd_preflight_feature(args: argparse.Namespace) -> None:
1192
+ """Feature-scoped preflight via coordinator.review_prep (records result)."""
1193
+ from ..features.coordinator import FeatureCoordinator
1194
+ from .ui import console, separator, spinner, SYM_CHECK, SYM_DOT, SYM_CROSS
1195
+
1196
+ workspace = _load_workspace()
1197
+ coord = FeatureCoordinator(workspace)
1198
+ with spinner(f"Running preflight on {args.feature}…"):
1199
+ result = coord.review_prep(args.feature)
1200
+
1201
+ if args.json:
1202
+ _print_json(result)
1203
+ return
1204
+
1205
+ console.print()
1206
+ console.print(f" [feature]{result['feature']}[/] preflight")
1207
+ separator()
1208
+ for repo, info in result["repos"].items():
1209
+ pc = info.get("precommit") or {}
1210
+ glyph = "[success]✓[/]" if pc.get("passed") else "[error]✗[/]"
1211
+ kind = pc.get("type", "")
1212
+ dirty = info.get("dirty_count", 0)
1213
+ console.print(f" [repo]{repo}[/] {glyph} {dirty} files [muted]{kind}[/]")
1214
+ console.print()
1215
+ if result.get("all_passed"):
1216
+ console.print(" [success]Ready to commit.[/]")
1217
+ else:
1218
+ console.print(" [error]One or more repos failed checks.[/]")
1219
+ console.print()
1220
+
1221
+
1222
+ def cmd_preflight(args: argparse.Namespace) -> None:
1223
+ if getattr(args, "feature", None):
1224
+ _cmd_preflight_feature(args)
1225
+ else:
1226
+ _cmd_preflight_context(args)
1227
+
1228
+
1229
+ def _cmd_preflight_context(args: argparse.Namespace) -> None:
1230
+ """Context-aware pre-commit quality gate.
1231
+
1232
+ Detects which feature/repos you're in, stages all changes (git add -A),
1233
+ runs pre-commit hooks, and reports results. Does NOT commit — that's
1234
+ your job when you're satisfied.
1235
+
1236
+ When run from inside a feature worktree directory, checks all repo
1237
+ worktrees in that feature.
1238
+
1239
+ When run from inside a single repo worktree, checks just that repo.
1240
+ """
1241
+ from ..workspace.context import detect_context
1242
+ from ..workspace.config import load_config, ConfigNotFoundError, ConfigError
1243
+ from ..git import repo as git_repo
1244
+ from ..integrations.precommit import run_precommit
1245
+ from ..actions.augments import repo_augments
1246
+
1247
+ ctx = detect_context()
1248
+
1249
+ if ctx.context_type == "unknown":
1250
+ print("Error: can't detect canopy context from current directory.", file=sys.stderr)
1251
+ print("Run this from inside a feature worktree or a workspace repo.", file=sys.stderr)
1252
+ sys.exit(1)
1253
+
1254
+ if not ctx.repo_paths:
1255
+ print("Error: no repos found in current context.", file=sys.stderr)
1256
+ sys.exit(1)
1257
+
1258
+ workspace_config = None
1259
+ if ctx.workspace_root:
1260
+ try:
1261
+ workspace_config = load_config(ctx.workspace_root)
1262
+ except (ConfigNotFoundError, ConfigError):
1263
+ workspace_config = None
1264
+
1265
+ results: dict[str, dict] = {}
1266
+ all_passed = True
1267
+
1268
+ for repo_path, repo_name in zip(ctx.repo_paths, ctx.repo_names):
1269
+ # Check if there are any changes
1270
+ status = git_repo.status_porcelain(repo_path)
1271
+ if not status:
1272
+ results[repo_name] = {"status": "clean", "hooks": None}
1273
+ continue
1274
+
1275
+ # Stage everything so hooks can inspect staged changes
1276
+ try:
1277
+ git_repo._run(["add", "-A"], cwd=repo_path)
1278
+ except git_repo.GitError as e:
1279
+ results[repo_name] = {"status": "error", "error": str(e), "hooks": None}
1280
+ all_passed = False
1281
+ continue
1282
+
1283
+ # Run pre-commit hooks (honoring per-repo augments.preflight_cmd)
1284
+ augments = (
1285
+ repo_augments(workspace_config, repo_name) if workspace_config else None
1286
+ )
1287
+ hook_result = run_precommit(repo_path, augments=augments)
1288
+ passed = hook_result["passed"]
1289
+ if not passed:
1290
+ all_passed = False
1291
+
1292
+ dirty_count = len(status)
1293
+ results[repo_name] = {
1294
+ "status": "staged" if passed else "hooks_failed",
1295
+ "dirty_count": dirty_count,
1296
+ "hooks": hook_result,
1297
+ }
1298
+
1299
+ # Persist preflight result so feature_state can distinguish
1300
+ # IN_PROGRESS from READY_TO_COMMIT. Maps detect_context's directory
1301
+ # names back to canopy-registered repo names so feature_state
1302
+ # (which uses canonical names) can match.
1303
+ #
1304
+ # F-11: when run from the workspace root (not inside a worktree),
1305
+ # ``ctx.feature`` is None — but we may still have a canonical
1306
+ # feature in ``slots.json`` whose repos overlap. Fall back
1307
+ # to that so `canopy preflight` from the workspace root persists a
1308
+ # record for the canonical feature, which is what `canopy state`
1309
+ # then keys off to surface ready_to_commit.
1310
+ feature_for_record = ctx.feature
1311
+ if feature_for_record is None and ctx.workspace_root:
1312
+ try:
1313
+ from ..actions import slots as _slots_mod
1314
+ from ..workspace.config import load_config as _load_config
1315
+ from ..workspace.workspace import Workspace as _WS
1316
+ _ws = _WS(_load_config(ctx.workspace_root))
1317
+ _state = _slots_mod.read_state(_ws)
1318
+ if _state and _state.canonical and _state.canonical.feature:
1319
+ feature_for_record = _state.canonical.feature
1320
+ except Exception:
1321
+ pass
1322
+
1323
+ if feature_for_record and ctx.workspace_root:
1324
+ try:
1325
+ from ..actions.preflight_state import record_result
1326
+ from ..workspace.config import load_config
1327
+ from ..workspace.workspace import Workspace as _WS
1328
+ cfg = load_config(ctx.workspace_root)
1329
+ ws = _WS(cfg)
1330
+ path_to_canonical = {
1331
+ str(state.abs_path.resolve()): state.config.name
1332
+ for state in ws.repos
1333
+ }
1334
+ head_sha_per_repo: dict[str, str] = {}
1335
+ for path in ctx.repo_paths:
1336
+ resolved = str(Path(path).resolve())
1337
+ canonical = path_to_canonical.get(resolved)
1338
+ if not canonical:
1339
+ continue
1340
+ head_sha_per_repo[canonical] = git_repo.head_sha(path)
1341
+ if head_sha_per_repo:
1342
+ record_result(
1343
+ ctx.workspace_root, feature_for_record,
1344
+ passed=all_passed,
1345
+ head_sha_per_repo=head_sha_per_repo,
1346
+ summary=("preflight passed" if all_passed
1347
+ else "preflight failed"),
1348
+ )
1349
+ except Exception:
1350
+ pass
1351
+
1352
+ if args.json:
1353
+ _print_json({
1354
+ "feature": ctx.feature,
1355
+ "context_type": ctx.context_type,
1356
+ "all_passed": all_passed,
1357
+ "results": results,
1358
+ })
1359
+ return
1360
+
1361
+ from .ui import console, separator, SYM_CHECK, SYM_DOT, SYM_CROSS
1362
+
1363
+ console.print()
1364
+ if ctx.feature:
1365
+ console.print(f" [feature]{ctx.feature}[/] preflight")
1366
+ else:
1367
+ console.print(f" preflight")
1368
+ separator()
1369
+
1370
+ for repo, result in results.items():
1371
+ status = result["status"]
1372
+ if status == "clean":
1373
+ console.print(f" [repo]{repo}[/] [muted]{SYM_DOT} clean[/]")
1374
+ elif status == "error":
1375
+ console.print(f" [repo]{repo}[/] [error]{SYM_CROSS} {result['error']}[/]")
1376
+ elif status == "hooks_failed":
1377
+ dirty = result["dirty_count"]
1378
+ console.print(f" [repo]{repo}[/] [error]{SYM_CROSS} hooks failed[/] [muted]{dirty} staged[/]")
1379
+ # Show hook output indented
1380
+ hook_output = result["hooks"]["output"]
1381
+ if hook_output:
1382
+ for line in hook_output.splitlines()[:20]:
1383
+ console.print(f" [muted]{line}[/]")
1384
+ if len(hook_output.splitlines()) > 20:
1385
+ console.print(f" [muted]... ({len(hook_output.splitlines()) - 20} more lines)[/]")
1386
+ else:
1387
+ # staged, hooks passed
1388
+ dirty = result["dirty_count"]
1389
+ hook_type = result["hooks"]["type"] if result["hooks"] else "none"
1390
+ if hook_type == "none":
1391
+ console.print(f" [repo]{repo}[/] [success]{SYM_CHECK} {dirty} staged[/] [muted]no hooks[/]")
1392
+ else:
1393
+ console.print(f" [repo]{repo}[/] [success]{SYM_CHECK} {dirty} staged[/] [success]hooks passed[/]")
1394
+
1395
+ console.print()
1396
+ if all_passed:
1397
+ console.print(" [success]Ready to commit.[/]")
1398
+ else:
1399
+ console.print(" [error]Fix hook failures, then run preflight again.[/]")
1400
+ console.print()
1401
+ print()
1402
+
1403
+
1404
+ def cmd_review(args: argparse.Namespace) -> None:
1405
+ """Fetch PR review comments and run preflight.
1406
+
1407
+ Full workflow:
1408
+ 1. Check if PRs exist for the feature
1409
+ 2. Fetch unresolved review comments
1410
+ 3. Run pre-commit hooks + stage changes (preflight)
1411
+ """
1412
+ from .ui import console, spinner, separator, print_success, print_warning, print_error, SYM_CHECK, SYM_CROSS, SYM_LINK
1413
+ from ..integrations.github import GitHubNotConfiguredError, PullRequestNotFoundError
1414
+
1415
+ workspace = _load_workspace()
1416
+ from ..features.coordinator import FeatureCoordinator
1417
+
1418
+ coordinator = FeatureCoordinator(workspace)
1419
+ feature = args.name
1420
+
1421
+ # ── Step 1: Check PR status ──
1422
+ try:
1423
+ with spinner(f"Checking PRs for {feature}..."):
1424
+ status = coordinator.review_status(feature)
1425
+ except GitHubNotConfiguredError as e:
1426
+ print_error(str(e))
1427
+ sys.exit(1)
1428
+ except ValueError as e:
1429
+ print_error(str(e))
1430
+ sys.exit(1)
1431
+
1432
+ if not status["has_prs"]:
1433
+ print_error(f"No open PRs found for feature '{feature}'")
1434
+ console.print(f" [muted]Push your branch and create a PR first.[/]")
1435
+ if args.json:
1436
+ _print_json(status)
1437
+ sys.exit(1)
1438
+
1439
+ # ── Step 2: Fetch comments ──
1440
+ try:
1441
+ with spinner(f"Fetching review comments..."):
1442
+ comments_data = coordinator.review_comments(feature)
1443
+ except PullRequestNotFoundError as e:
1444
+ print_error(str(e))
1445
+ sys.exit(1)
1446
+
1447
+ # ── Step 3: Run pre-commit + stage ──
1448
+ prep_data = None
1449
+ if not args.comments_only:
1450
+ with spinner(f"Running pre-commit hooks..."):
1451
+ prep_data = coordinator.review_prep(
1452
+ feature, message=args.message or "",
1453
+ )
1454
+
1455
+ if args.json:
1456
+ result = {
1457
+ "review_status": status,
1458
+ "comments": comments_data,
1459
+ }
1460
+ if prep_data:
1461
+ result["prep"] = prep_data
1462
+ _print_json(result)
1463
+ return
1464
+
1465
+ # ── Display PR status ──
1466
+ console.print()
1467
+ console.print(f" [header]Review: {feature}[/]")
1468
+
1469
+ for repo_name, info in status["repos"].items():
1470
+ pr = info.get("pr")
1471
+ if pr:
1472
+ console.print(
1473
+ f" [repo]{repo_name}[/] "
1474
+ f"[linear]{SYM_LINK} #{pr['number']}[/] {pr['title']}"
1475
+ )
1476
+ console.print(f" [path]{pr.get('url', '')}[/]")
1477
+ elif "error" in info:
1478
+ console.print(f" [repo]{repo_name}[/] [error]{info['error']}[/]")
1479
+ else:
1480
+ console.print(f" [repo]{repo_name}[/] [muted]no PR[/]")
1481
+
1482
+ # ── Display comments ──
1483
+ separator()
1484
+ total = comments_data.get("total_comments", 0)
1485
+ if total == 0:
1486
+ print_success("No unresolved review comments")
1487
+ else:
1488
+ console.print(f" [warning]{total} unresolved comment{'s' if total != 1 else ''}[/]")
1489
+ console.print()
1490
+
1491
+ for repo_name, repo_data in comments_data.get("repos", {}).items():
1492
+ comments = repo_data.get("comments", [])
1493
+ if not comments:
1494
+ continue
1495
+
1496
+ console.print(f" [repo]{repo_name}[/] [muted]#{repo_data.get('pr_number', '?')}[/]")
1497
+
1498
+ # Group by file
1499
+ by_file: dict[str, list] = {}
1500
+ for c in comments:
1501
+ path = c.get("path") or "(general)"
1502
+ by_file.setdefault(path, []).append(c)
1503
+
1504
+ for filepath, file_comments in by_file.items():
1505
+ console.print(f" [path]{filepath}[/]")
1506
+ for c in file_comments:
1507
+ line = c.get("line")
1508
+ line_str = f"L{line}" if line else ""
1509
+ author = c.get("author", "")
1510
+ body = c.get("body", "").split("\n")[0][:120]
1511
+ console.print(
1512
+ f" [muted]{line_str}[/] "
1513
+ f"[info]{author}[/]: {body}"
1514
+ )
1515
+
1516
+ # ── Display prep results ──
1517
+ if prep_data:
1518
+ separator()
1519
+ if prep_data["all_passed"]:
1520
+ print_success("Pre-commit hooks passed")
1521
+ else:
1522
+ print_warning("Pre-commit hooks failed in some repos")
1523
+
1524
+ for repo_name, info in prep_data["repos"].items():
1525
+ pc = info.get("precommit", {})
1526
+ pc_type = pc.get("type", "none")
1527
+ passed = pc.get("passed", True)
1528
+ staged = info.get("staged", False)
1529
+ dirty = info.get("dirty_count", 0)
1530
+
1531
+ status_parts = []
1532
+ if pc_type != "none":
1533
+ icon = SYM_CHECK if passed else SYM_CROSS
1534
+ style = "success" if passed else "error"
1535
+ status_parts.append(f"[{style}]{icon} hooks[/]")
1536
+ if staged:
1537
+ status_parts.append(f"[ahead]{dirty} staged[/]")
1538
+ elif dirty == 0:
1539
+ status_parts.append("[muted]clean[/]")
1540
+
1541
+ console.print(
1542
+ f" [repo]{repo_name}[/] {' '.join(status_parts)}"
1543
+ )
1544
+
1545
+ if not passed and pc.get("output"):
1546
+ # Show first few lines of hook output
1547
+ for line in pc["output"].split("\n")[:5]:
1548
+ console.print(f" [muted]{line}[/]")
1549
+
1550
+ console.print()
1551
+
1552
+
1553
+ def cmd_list(args: argparse.Namespace) -> None:
1554
+ """List all feature lanes — quick overview of what exists."""
1555
+ from .ui import console, separator, SYM_BRANCH, SYM_LINK, SYM_ARROW
1556
+
1557
+ workspace = _load_workspace()
1558
+ from ..features.coordinator import FeatureCoordinator
1559
+
1560
+ coordinator = FeatureCoordinator(workspace)
1561
+ lanes = coordinator.list_active()
1562
+
1563
+ if args.json:
1564
+ _print_json([lane.to_dict() for lane in lanes])
1565
+ return
1566
+
1567
+ if not lanes:
1568
+ console.print()
1569
+ console.print(" [muted]No features.[/] Create one with [info]canopy worktree <name>[/]")
1570
+ console.print()
1571
+ return
1572
+
1573
+ console.print()
1574
+ for lane in lanes:
1575
+ # Feature name + Linear link on one line
1576
+ linear_str = ""
1577
+ if lane.linear_issue:
1578
+ title_bit = f" {lane.linear_title}" if lane.linear_title else ""
1579
+ linear_str = f" [linear]{SYM_LINK} {lane.linear_issue}{title_bit}[/]"
1580
+
1581
+ console.print(f" [feature]{lane.name}[/]{linear_str}")
1582
+
1583
+ # Per-repo: branch context line
1584
+ for repo_name, state in lane.repo_states.items():
1585
+ if "error" in state or not state.get("has_branch"):
1586
+ console.print(f" [muted]{repo_name}[/] [muted]no branch[/]")
1587
+ continue
1588
+
1589
+ parts = [f" [repo]{repo_name}[/]"]
1590
+ # Dirty count
1591
+ dirty_count = state.get("changed_file_count", 0)
1592
+ if state.get("dirty") and dirty_count:
1593
+ parts.append(f"[dirty]{dirty_count} dirty[/]")
1594
+ elif state.get("dirty"):
1595
+ parts.append("[dirty]*[/]")
1596
+ # Ahead/behind
1597
+ if state.get("ahead"):
1598
+ parts.append(f"[ahead]↑{state['ahead']}[/]")
1599
+ if state.get("behind"):
1600
+ parts.append(f"[behind]↓{state['behind']}[/]")
1601
+ # Worktree path
1602
+ wt_path = state.get("worktree_path")
1603
+ if wt_path:
1604
+ parts.append(f"[path]{wt_path}[/]")
1605
+
1606
+ console.print(" ".join(parts))
1607
+
1608
+ console.print()
1609
+
1610
+
1611
+ def cmd_done(args: argparse.Namespace) -> None:
1612
+ """Clean up a completed feature — remove worktrees, branches, archive."""
1613
+ from .ui import console, spinner, print_success, print_error, print_warning, separator, SYM_CHECK, SYM_CROSS, SYM_ARROW
1614
+
1615
+ workspace = _load_workspace()
1616
+ from ..features.coordinator import FeatureCoordinator
1617
+
1618
+ coordinator = FeatureCoordinator(workspace)
1619
+ name = args.name
1620
+
1621
+ # Resolve alias for display
1622
+ resolved = coordinator._resolve_name(name)
1623
+
1624
+ try:
1625
+ with spinner(f"Cleaning up {resolved}..."):
1626
+ result = coordinator.done(name, force=args.force)
1627
+ except ValueError as e:
1628
+ print_error(str(e))
1629
+ sys.exit(1)
1630
+
1631
+ if args.json:
1632
+ _print_json(result)
1633
+ return
1634
+
1635
+ feature = result["feature"]
1636
+ console.print()
1637
+
1638
+ # Show alias resolution
1639
+ if name != feature:
1640
+ console.print(f" [muted]{name} {SYM_ARROW}[/] [feature]{feature}[/]")
1641
+ console.print()
1642
+
1643
+ console.print(f" [header]Done: {feature}[/]")
1644
+
1645
+ wt = result.get("worktrees_removed", {})
1646
+ if wt:
1647
+ separator()
1648
+ console.print(f" [muted]Worktrees removed:[/]")
1649
+ for repo, path in wt.items():
1650
+ if "error" in str(path):
1651
+ console.print(f" [repo]{repo}[/] [error]{path}[/]")
1652
+ else:
1653
+ print_success(f"[repo]{repo}[/] [muted]{path}[/]")
1654
+
1655
+ br = result.get("branches_deleted", {})
1656
+ if br:
1657
+ separator()
1658
+ console.print(f" [muted]Branches deleted:[/]")
1659
+ for repo, status in br.items():
1660
+ if status == "ok":
1661
+ print_success(f"[repo]{repo}[/] [branch]{feature}[/] [muted]deleted[/]")
1662
+ elif status == "no branch":
1663
+ console.print(f" [repo]{repo}[/] [muted]no branch[/]")
1664
+ else:
1665
+ print_warning(f"[repo]{repo}[/] {status}")
1666
+
1667
+ if result.get("archived"):
1668
+ separator()
1669
+ print_success("Archived in features.json")
1670
+
1671
+ console.print()
1672
+
1673
+
1674
+ def cmd_config(args: argparse.Namespace) -> None:
1675
+ """Read or write workspace settings in canopy.toml."""
1676
+ from .ui import console, print_success, print_error
1677
+ from ..workspace.config import (
1678
+ get_config_value, set_config_value, get_all_config,
1679
+ ConfigNotFoundError, ConfigError, WORKSPACE_SETTINGS,
1680
+ )
1681
+
1682
+ # Find workspace root
1683
+ from ..workspace.config import _find_config
1684
+ try:
1685
+ toml_path = _find_config()
1686
+ root = toml_path.parent
1687
+ except ConfigNotFoundError as e:
1688
+ print_error(str(e))
1689
+ sys.exit(1)
1690
+
1691
+ key = args.key
1692
+ value = args.value
1693
+
1694
+ try:
1695
+ if key is None:
1696
+ # Show all settings
1697
+ settings = get_all_config(root)
1698
+ if args.json:
1699
+ _print_json(settings)
1700
+ return
1701
+ console.print()
1702
+ for k, v in settings.items():
1703
+ display = v if v is not None else "[muted]not set[/]"
1704
+ console.print(f" [info]{k}[/] = {display}")
1705
+ console.print()
1706
+
1707
+ elif value is None:
1708
+ # Get a single setting
1709
+ v = get_config_value(root, key)
1710
+ if args.json:
1711
+ _print_json({"key": key, "value": v})
1712
+ return
1713
+ if v is not None:
1714
+ console.print(f" {v}")
1715
+ else:
1716
+ console.print(f" [muted]not set[/]")
1717
+
1718
+ else:
1719
+ # Set a value
1720
+ coerced = set_config_value(root, key, value)
1721
+ if args.json:
1722
+ _print_json({"key": key, "value": coerced})
1723
+ return
1724
+ print_success(f"[info]{key}[/] = {coerced}")
1725
+
1726
+ except (ConfigNotFoundError, ConfigError) as e:
1727
+ print_error(str(e))
1728
+ sys.exit(1)
1729
+
1730
+
1731
+ def _install_hooks_for_repos(root: Path, repos) -> list[dict]:
1732
+ """Install canopy post-checkout hooks in each non-worktree repo.
1733
+
1734
+ Worktrees share their main repo's hooks dir, so they're skipped here.
1735
+ Returns a list of {repo, action, path} dicts (one per non-worktree repo).
1736
+ """
1737
+ from ..git.hooks import install_hook
1738
+
1739
+ results = []
1740
+ for r in repos:
1741
+ if r.is_worktree:
1742
+ continue
1743
+ repo_abs = (root / r.path).resolve() if not Path(r.path).is_absolute() else Path(r.path)
1744
+ try:
1745
+ res = install_hook(repo_abs, r.name, root)
1746
+ results.append({"repo": res.repo, "action": res.action, "path": res.path})
1747
+ except Exception as e:
1748
+ results.append({"repo": r.name, "action": "failed", "error": str(e)})
1749
+ return results
1750
+
1751
+
1752
+ def cmd_hooks(args: argparse.Namespace) -> None:
1753
+ """Manage drift-tracking post-checkout hooks across the workspace."""
1754
+ from ..git.hooks import install_hook, uninstall_hook, hook_status, read_heads_state
1755
+ from .ui import console, print_success, print_error, print_warning
1756
+
1757
+ workspace = _load_workspace()
1758
+ root = workspace.config.root
1759
+
1760
+ sub = getattr(args, "hooks_command", None) or "status"
1761
+ results: list[dict] = []
1762
+
1763
+ for state in workspace.repos:
1764
+ if state.config.is_worktree:
1765
+ continue
1766
+ repo_abs = state.abs_path
1767
+ try:
1768
+ if sub == "install":
1769
+ r = install_hook(repo_abs, state.config.name, root)
1770
+ results.append({"repo": r.repo, "action": r.action, "path": r.path})
1771
+ elif sub == "uninstall":
1772
+ r = uninstall_hook(repo_abs, state.config.name)
1773
+ results.append({
1774
+ "repo": r.repo, "action": r.action, "reason": r.reason,
1775
+ })
1776
+ elif sub == "status":
1777
+ s = hook_status(repo_abs)
1778
+ results.append({"repo": state.config.name, **s})
1779
+ else:
1780
+ print_error(f"Unknown hooks subcommand: {sub}")
1781
+ sys.exit(2)
1782
+ except Exception as e:
1783
+ results.append({"repo": state.config.name, "action": "failed", "error": str(e)})
1784
+
1785
+ if args.json:
1786
+ payload = {"command": sub, "repos": results}
1787
+ if sub == "status":
1788
+ payload["heads_state"] = read_heads_state(root)
1789
+ _print_json(payload)
1790
+ return
1791
+
1792
+ console.print()
1793
+ if sub == "status":
1794
+ heads = read_heads_state(root)
1795
+ for r in results:
1796
+ mark = "[green]✓[/]" if r.get("installed") else (
1797
+ "[yellow]foreign[/]" if r.get("foreign_hook") else "[red]✗[/]"
1798
+ )
1799
+ head = heads.get(r["repo"], {})
1800
+ head_note = f" [muted]→ {head['branch']} @ {head['sha'][:8]}[/]" if head else ""
1801
+ chained = " [muted](chained user hook present)[/]" if r.get("chained_present") else ""
1802
+ console.print(f" {mark} [repo]{r['repo']}[/]{head_note}{chained}")
1803
+ else:
1804
+ for r in results:
1805
+ action = r.get("action", "unknown")
1806
+ extra = ""
1807
+ if action == "failed":
1808
+ extra = f" [red]{r.get('error', '')}[/]"
1809
+ elif r.get("reason"):
1810
+ extra = f" [muted]({r['reason']})[/]"
1811
+ console.print(f" [repo]{r['repo']}[/] [muted]→[/] {action}{extra}")
1812
+ console.print()
1813
+
1814
+
1815
+ def cmd_run(args: argparse.Namespace) -> None:
1816
+ """Run a shell command in a canopy-managed repo, with directory resolution."""
1817
+ from ..agent.runner import run_in_repo
1818
+ from ..actions.errors import ActionError
1819
+ from .render import render_blocker
1820
+ from .ui import console
1821
+
1822
+ workspace = _load_workspace()
1823
+ try:
1824
+ result = run_in_repo(
1825
+ workspace,
1826
+ repo=args.repo,
1827
+ command=args.cmd,
1828
+ feature=getattr(args, "feature", None),
1829
+ timeout_seconds=getattr(args, "timeout", 60),
1830
+ )
1831
+ except ActionError as err:
1832
+ if args.json:
1833
+ _print_json(err.to_dict())
1834
+ else:
1835
+ render_blocker(err, action="run")
1836
+ sys.exit(1)
1837
+
1838
+ if args.json:
1839
+ _print_json(result)
1840
+ return
1841
+
1842
+ if result["stdout"]:
1843
+ sys.stdout.write(result["stdout"])
1844
+ if not result["stdout"].endswith("\n"):
1845
+ sys.stdout.write("\n")
1846
+ if result["stderr"]:
1847
+ sys.stderr.write(result["stderr"])
1848
+ if not result["stderr"].endswith("\n"):
1849
+ sys.stderr.write("\n")
1850
+ sys.exit(result["exit_code"])
1851
+
1852
+
1853
+ def _read_command(impl, args, action_label: str, *extra_kwargs_keys):
1854
+ """Run a read primitive with structured error rendering. Returns the result dict
1855
+ (or None on failure — caller should sys.exit after this)."""
1856
+ from ..actions.errors import ActionError
1857
+ from .render import render_blocker
1858
+
1859
+ workspace = _load_workspace()
1860
+ kwargs = {k: getattr(args, k) for k in extra_kwargs_keys if hasattr(args, k)}
1861
+ try:
1862
+ return impl(workspace, args.alias, **kwargs)
1863
+ except ActionError as err:
1864
+ if args.json:
1865
+ _print_json(err.to_dict())
1866
+ else:
1867
+ render_blocker(err, action=action_label)
1868
+ sys.exit(1)
1869
+
1870
+
1871
+ def cmd_issue(args: argparse.Namespace) -> None:
1872
+ """Fetch an issue from the workspace's configured provider.
1873
+
1874
+ Uses the M5 ``issue_get`` action so the CLI surface matches the
1875
+ ``mcp__canopy__issue_get`` MCP tool — canonical state mapping
1876
+ (``todo`` / ``in_progress`` / ``done`` / ``cancelled``) and the
1877
+ full ``Issue`` shape (id, identifier, title, description, state,
1878
+ url, assignee, labels, priority, raw).
1879
+ """
1880
+ from ..actions.reads import issue_get
1881
+ from .ui import console
1882
+
1883
+ result = _read_command(issue_get, args, "issue")
1884
+ if args.json:
1885
+ _print_json(result)
1886
+ return
1887
+ console.print()
1888
+ identifier = result.get("identifier") or result.get("id") or ""
1889
+ state = result.get("state") or ""
1890
+ console.print(f" [feature]{identifier}[/] [muted]{state}[/]")
1891
+ if result.get("title"):
1892
+ console.print(f" {result['title']}")
1893
+ if result.get("url"):
1894
+ console.print(f" [muted]{result['url']}[/]")
1895
+ if result.get("assignee"):
1896
+ console.print(f" [muted]assignee:[/] {result['assignee']}")
1897
+ labels = result.get("labels") or []
1898
+ if labels:
1899
+ console.print(f" [muted]labels:[/] {', '.join(labels)}")
1900
+ if result.get("description"):
1901
+ desc = result["description"].strip()
1902
+ if len(desc) > 400:
1903
+ desc = desc[:400] + "…"
1904
+ console.print()
1905
+ console.print(f" [muted]{desc}[/]")
1906
+ console.print()
1907
+
1908
+
1909
+ def cmd_issues(args: argparse.Namespace) -> None:
1910
+ """List the current user's open issues from the configured provider (F-5).
1911
+
1912
+ Mirrors ``mcp__canopy__issue_list_my_issues``. Empty list when the
1913
+ provider isn't configured (no autocomplete signal). Each entry is
1914
+ the canonical ``Issue.to_dict()`` shape.
1915
+ """
1916
+ from ..providers import (
1917
+ IssueProviderError, ProviderNotConfigured, get_issue_provider,
1918
+ )
1919
+ from ..actions.errors import BlockerError, FixAction
1920
+ from .render import render_blocker
1921
+ from .ui import console
1922
+
1923
+ workspace = _load_workspace()
1924
+ try:
1925
+ provider = get_issue_provider(workspace)
1926
+ issues = provider.list_my_issues(limit=args.limit)
1927
+ except ProviderNotConfigured:
1928
+ if args.json:
1929
+ _print_json([])
1930
+ else:
1931
+ console.print(" [muted]no issue provider configured[/]")
1932
+ return
1933
+ except IssueProviderError as e:
1934
+ err = BlockerError(
1935
+ code="issue_provider_failed",
1936
+ what="issue provider call failed",
1937
+ details={"error": str(e)},
1938
+ fix_actions=[
1939
+ FixAction(action="doctor", args={}, safe=True,
1940
+ preview="canopy doctor surfaces provider config drift"),
1941
+ ],
1942
+ )
1943
+ if args.json:
1944
+ _print_json(err.to_dict())
1945
+ else:
1946
+ render_blocker(err, action="issues")
1947
+ sys.exit(1)
1948
+
1949
+ items = [i.to_dict() for i in issues]
1950
+ if args.json:
1951
+ _print_json(items)
1952
+ return
1953
+ console.print()
1954
+ if not items:
1955
+ console.print(" [muted]no open issues[/]")
1956
+ console.print()
1957
+ return
1958
+ for it in items:
1959
+ identifier = it.get("identifier") or it.get("id") or ""
1960
+ state = it.get("state") or ""
1961
+ title = it.get("title") or ""
1962
+ console.print(f" [feature]{identifier}[/] [muted]{state}[/] {title}")
1963
+ console.print()
1964
+
1965
+
1966
+ def cmd_pr(args: argparse.Namespace) -> None:
1967
+ """Fetch PR data per repo for an alias."""
1968
+ from ..actions.reads import github_get_pr
1969
+ from .ui import console
1970
+
1971
+ result = _read_command(github_get_pr, args, "pr")
1972
+ if args.json:
1973
+ _print_json(result)
1974
+ return
1975
+ console.print()
1976
+ for repo, info in result["repos"].items():
1977
+ if not info.get("found"):
1978
+ console.print(f" [repo]{repo}[/] [muted]PR #{info['pr_number']} not found[/]")
1979
+ continue
1980
+ decision = info.get("review_decision") or "—"
1981
+ draft = " [muted](draft)[/]" if info.get("draft") else ""
1982
+ console.print(f" [repo]{repo}[/] PR #{info['pr_number']} [muted]{decision}[/]{draft}")
1983
+ if info.get("title"):
1984
+ console.print(f" {info['title']}")
1985
+ if info.get("url"):
1986
+ console.print(f" [muted]{info['url']}[/]")
1987
+ console.print()
1988
+
1989
+
1990
+ def cmd_branch(args: argparse.Namespace) -> None:
1991
+ """Fetch branch info (HEAD, divergence, upstream) per repo."""
1992
+ from ..actions.reads import github_get_branch
1993
+ from .ui import console
1994
+
1995
+ workspace = _load_workspace()
1996
+ from ..actions.errors import ActionError
1997
+ from .render import render_blocker
1998
+ try:
1999
+ result = github_get_branch(workspace, args.alias, repo=args.repo)
2000
+ except ActionError as err:
2001
+ if args.json:
2002
+ _print_json(err.to_dict())
2003
+ else:
2004
+ render_blocker(err, action="branch")
2005
+ sys.exit(1)
2006
+
2007
+ if args.json:
2008
+ _print_json(result)
2009
+ return
2010
+ console.print()
2011
+ for repo, info in result["repos"].items():
2012
+ if not info.get("exists_locally"):
2013
+ console.print(f" [repo]{repo}[/] [muted]{info['branch']} (not present locally)[/]")
2014
+ continue
2015
+ sha = info.get("head_sha", "")
2016
+ ahead = info.get("ahead", 0)
2017
+ behind = info.get("behind", 0)
2018
+ upstream = "↑" if info.get("has_upstream") else "no upstream"
2019
+ suffix = f" [muted]{upstream}"
2020
+ if info.get("has_upstream"):
2021
+ suffix += f" ↑{ahead} ↓{behind}"
2022
+ suffix += "[/]"
2023
+ console.print(f" [repo]{repo}[/] {info['branch']} [muted]@ {sha[:8]}[/]{suffix}")
2024
+ console.print()
2025
+
2026
+
2027
+ def cmd_comments(args: argparse.Namespace) -> None:
2028
+ """Fetch temporally classified PR review comments per repo."""
2029
+ from ..actions.reads import github_get_pr_comments
2030
+ from .ui import console
2031
+
2032
+ result = _read_command(github_get_pr_comments, args, "comments")
2033
+ if args.json:
2034
+ _print_json(result)
2035
+ return
2036
+ console.print()
2037
+ console.print(
2038
+ f" [header]actionable: {result['actionable_count']}[/] "
2039
+ f"[muted]likely resolved: {result['likely_resolved_count']} "
2040
+ f"resolved: {result['resolved_thread_count']}[/]"
2041
+ )
2042
+ for repo, info in result["repos"].items():
2043
+ console.print()
2044
+ console.print(f" [repo]{repo}[/] PR #{info['pr_number']} [muted]{info.get('pr_url','')}[/]")
2045
+ actionable = info.get("actionable_threads") or []
2046
+ if not actionable:
2047
+ console.print(" [muted]no actionable threads[/]")
2048
+ continue
2049
+ for t in actionable:
2050
+ line = f"{t.get('path','')}:{t.get('line','')}" if t.get("path") else "(general)"
2051
+ console.print(f" [warning]•[/] [muted]{line}[/] {t.get('author','')}")
2052
+ body = (t.get("body") or "").strip().split("\n")[0]
2053
+ if len(body) > 120:
2054
+ body = body[:120] + "…"
2055
+ console.print(f" {body}")
2056
+ console.print()
2057
+
2058
+
2059
+ def cmd_setup_agent(args: argparse.Namespace) -> None:
2060
+ """Install the using-canopy skill + register canopy MCP for the workspace."""
2061
+ from ..agent_setup import setup_agent, check_status
2062
+ from .ui import console, print_success, print_warning
2063
+
2064
+ if args.check:
2065
+ # Best-effort workspace detection, but still works without one.
2066
+ try:
2067
+ workspace = _load_workspace()
2068
+ workspace_root = workspace.config.root
2069
+ except Exception:
2070
+ workspace_root = Path.cwd()
2071
+ status = check_status(workspace_root)
2072
+ if args.json:
2073
+ _print_json(status)
2074
+ return
2075
+ skills_state = status.get("skills") or [status["skill"]]
2076
+ mcp = status["mcp"]
2077
+ console.print()
2078
+ for skill in skills_state:
2079
+ label_name = skill.get("name", "")
2080
+ if skill["installed"] and skill["is_canopy_skill"]:
2081
+ label = "[success]✓ up to date[/]" if skill["up_to_date"] else "[warning]● out of date[/]"
2082
+ console.print(f" skill[{label_name}] {label} [muted]{skill['path']}[/]")
2083
+ elif skill["installed"]:
2084
+ console.print(f" skill[{label_name}] [warning]foreign file present[/] [muted]{skill['path']}[/]")
2085
+ else:
2086
+ console.print(f" skill[{label_name}] [muted]not installed[/] [muted]{skill['path']}[/]")
2087
+ if mcp["configured"]:
2088
+ root = (mcp.get("env") or {}).get("CANOPY_ROOT", "")
2089
+ console.print(f" mcp [success]✓ configured[/] [muted]CANOPY_ROOT={root}[/]")
2090
+ else:
2091
+ console.print(f" mcp [error]✗ not configured[/] [muted]{mcp['path']}[/]")
2092
+ console.print()
2093
+ return
2094
+
2095
+ do_skill = not args.mcp_only
2096
+ do_mcp = not args.skill_only
2097
+
2098
+ from ..agent_setup import DEFAULT_SKILL
2099
+ extra = list(dict.fromkeys(args.skill or [])) # dedupe, preserve order
2100
+ skills: tuple[str, ...] = (
2101
+ tuple([DEFAULT_SKILL] + [s for s in extra if s != DEFAULT_SKILL])
2102
+ if do_skill else ()
2103
+ )
2104
+
2105
+ workspace_root: Path | None = None
2106
+ if do_mcp:
2107
+ try:
2108
+ workspace = _load_workspace()
2109
+ workspace_root = workspace.config.root
2110
+ except Exception:
2111
+ workspace_root = None
2112
+
2113
+ result = setup_agent(
2114
+ workspace_root, skills=skills, do_mcp=do_mcp, reinstall=args.reinstall,
2115
+ )
2116
+ if args.json:
2117
+ _print_json(result)
2118
+ return
2119
+
2120
+ console.print()
2121
+ for s in result.get("skills") or ([result["skill"]] if "skill" in result else []):
2122
+ glyph = {
2123
+ "installed": "[success]✓ installed[/]",
2124
+ "reinstalled": "[success]✓ reinstalled[/]",
2125
+ "skipped": "[muted]· skipped[/]",
2126
+ }.get(s["action"], s["action"])
2127
+ label = f"skill[{s.get('name', DEFAULT_SKILL)}]"
2128
+ console.print(f" {label:<22} {glyph} [muted]{s['path']}[/]")
2129
+ if s.get("reason"):
2130
+ console.print(f" [muted]{s['reason']}[/]")
2131
+ if "mcp" in result:
2132
+ m = result["mcp"]
2133
+ glyph = {
2134
+ "added": "[success]✓ added[/]",
2135
+ "updated": "[success]✓ updated[/]",
2136
+ "created": "[success]✓ created[/]",
2137
+ "skipped": "[muted]· skipped[/]",
2138
+ }.get(m["action"], m["action"])
2139
+ console.print(f" mcp {glyph} [muted]{m['path']}[/]")
2140
+ if m.get("reason"):
2141
+ console.print(f" [muted]{m['reason']}[/]")
2142
+ console.print()
2143
+ console.print(" [muted]Restart Claude Code (or open a new session) to pick up changes.[/]")
2144
+ console.print()
2145
+
2146
+
2147
+ def cmd_state(args: argparse.Namespace) -> None:
2148
+ """Show the feature state + suggested next actions."""
2149
+ from ..actions.errors import ActionError, BlockerError, FixAction
2150
+ from ..actions import slots as slots_mod
2151
+ from ..actions.feature_state import feature_state as state_impl
2152
+ from .render import render_blocker
2153
+ from .ui import console
2154
+
2155
+ workspace = _load_workspace()
2156
+ feature = args.feature
2157
+ if feature is None:
2158
+ slot_state = slots_mod.read_state(workspace)
2159
+ if slot_state is None or slot_state.canonical is None:
2160
+ err = BlockerError(
2161
+ code="no_active_feature",
2162
+ what="no feature passed and no active feature is set",
2163
+ fix_actions=[
2164
+ FixAction(action="switch", args={"feature": "<name>"},
2165
+ safe=True, preview="set active feature with: canopy switch <feature>"),
2166
+ FixAction(action="state", args={"feature": "<name>"},
2167
+ safe=True, preview="or pass a feature explicitly: canopy state <feature>"),
2168
+ ],
2169
+ )
2170
+ if args.json:
2171
+ _print_json(err.to_dict())
2172
+ else:
2173
+ render_blocker(err, action="state")
2174
+ sys.exit(1)
2175
+ feature = slot_state.canonical.feature
2176
+
2177
+ try:
2178
+ result = state_impl(workspace, feature)
2179
+ except ActionError as err:
2180
+ if args.json:
2181
+ _print_json(err.to_dict())
2182
+ else:
2183
+ render_blocker(err, action="state")
2184
+ sys.exit(1)
2185
+
2186
+ if args.json:
2187
+ _print_json(result)
2188
+ return
2189
+
2190
+ state_glyph = {
2191
+ "drifted": "[error]✗[/]",
2192
+ "in_progress": "[warning]●[/]",
2193
+ "ready_to_commit": "[info]●[/]",
2194
+ "ready_to_push": "[info]●[/]",
2195
+ "needs_work": "[error]●[/]",
2196
+ "approved": "[success]●[/]",
2197
+ "awaiting_review": "[muted]●[/]",
2198
+ "no_prs": "[muted]○[/]",
2199
+ }
2200
+ glyph = state_glyph.get(result["state"], "[muted]?[/]")
2201
+ console.print()
2202
+ console.print(f" {glyph} [feature]{result['feature']}[/] [muted]({result['state']})[/]")
2203
+
2204
+ summary = result.get("summary", {})
2205
+ alignment = summary.get("alignment")
2206
+ if alignment and not alignment.get("aligned"):
2207
+ for repo, exp in (alignment.get("expected") or {}).items():
2208
+ actual = (alignment.get("actual") or {}).get(repo) or "(missing)"
2209
+ mark = "[error]✗[/]" if actual != exp else "[success]✓[/]"
2210
+ console.print(f" {mark} [repo]{repo}[/] [muted]→ {actual} (expected {exp})[/]")
2211
+ else:
2212
+ repos = summary.get("repos") or {}
2213
+ for repo, info in repos.items():
2214
+ bits = []
2215
+ if info.get("is_dirty"):
2216
+ bits.append(f"dirty: {info.get('dirty_count', '?')} files")
2217
+ if info.get("ahead", 0) > 0:
2218
+ bits.append(f"↑{info['ahead']}")
2219
+ if info.get("behind", 0) > 0:
2220
+ bits.append(f"↓{info['behind']}")
2221
+ if info.get("actionable_count", 0) > 0:
2222
+ bits.append(f"actionable: {info['actionable_count']}")
2223
+ if info.get("review_decision"):
2224
+ bits.append(info["review_decision"])
2225
+ extra = " [muted](" + ", ".join(bits) + ")[/]" if bits else ""
2226
+ console.print(f" [repo]{repo}[/] [muted]→ {info.get('branch','')}[/]{extra}")
2227
+
2228
+ pf = summary.get("preflight") or {}
2229
+ if pf.get("ran"):
2230
+ status = "passed" if pf.get("passed") else "failed"
2231
+ fresh = "fresh" if pf.get("fresh") else "stale"
2232
+ console.print(f" [muted]preflight: {status} ({fresh}, ran {pf.get('ran_at','')})[/]")
2233
+
2234
+ for w in result.get("warnings", []):
2235
+ console.print(f" [warning]⚠ {w.get('what','')}[/] [muted]({w.get('code','')})[/]")
2236
+
2237
+ next_actions = result.get("next_actions") or []
2238
+ if next_actions:
2239
+ console.print()
2240
+ console.print(" [header]next:[/]")
2241
+ for i, a in enumerate(next_actions):
2242
+ tag = "[info]→[/]" if a.get("primary") else " "
2243
+ label = a.get("label") or a.get("action") or "?"
2244
+ preview = a.get("preview")
2245
+ line = f" {tag} [info]canopy {a['action']} {a.get('args', {}).get('feature','')}[/] [muted]{label}[/]"
2246
+ console.print(line)
2247
+ if preview:
2248
+ console.print(f" [muted]{preview}[/]")
2249
+ console.print()
2250
+
2251
+
2252
+ def cmd_triage(args: argparse.Namespace) -> None:
2253
+ """Show prioritized list of features needing attention."""
2254
+ from ..actions.errors import ActionError
2255
+ from ..actions.triage import triage as triage_impl
2256
+ from .render import render_blocker
2257
+ from .ui import console
2258
+
2259
+ workspace = _load_workspace()
2260
+ try:
2261
+ result = triage_impl(
2262
+ workspace,
2263
+ author=getattr(args, "author", "@me"),
2264
+ repos=_split_csv(getattr(args, "repos", None)),
2265
+ )
2266
+ except ActionError as err:
2267
+ if args.json:
2268
+ _print_json(err.to_dict())
2269
+ else:
2270
+ render_blocker(err, action="triage")
2271
+ sys.exit(1)
2272
+
2273
+ if args.json:
2274
+ _print_json(result)
2275
+ return
2276
+
2277
+ console.print()
2278
+ console.print(f" [header]triage[/] [muted]author={result['author']} ({len(result['features'])} features)[/]")
2279
+ if not result["features"]:
2280
+ console.print(" [muted]nothing needs attention[/]")
2281
+ console.print()
2282
+ return
2283
+ glyphs = {
2284
+ "changes_requested": "[error]●[/]",
2285
+ "review_required_with_bot_comments": "[warning]●[/]",
2286
+ "review_required": "[muted]●[/]",
2287
+ "approved": "[success]●[/]",
2288
+ "unknown": "[muted]?[/]",
2289
+ }
2290
+ for f in result["features"]:
2291
+ glyph = glyphs.get(f["priority"], "[muted]?[/]")
2292
+ label = f["feature"]
2293
+ linear = f.get("linear_issue") or ""
2294
+ title = f.get("linear_title") or ""
2295
+ suffix = f" [muted]{linear} {title}[/]".rstrip() if linear or title else ""
2296
+ console.print()
2297
+ console.print(f" {glyph} [feature]{label}[/] [muted]({f['priority']})[/]{suffix}")
2298
+ for repo, info in f["repos"].items():
2299
+ decision = info.get("review_decision") or "—"
2300
+ counts = []
2301
+ if info.get("actionable_count"):
2302
+ counts.append(f"actionable: {info['actionable_count']}")
2303
+ if info.get("likely_resolved_count"):
2304
+ counts.append(f"likely_resolved: {info['likely_resolved_count']}")
2305
+ count_str = " [muted](" + ", ".join(counts) + ")[/]" if counts else ""
2306
+ console.print(
2307
+ f" [repo]{repo}[/] PR #{info['pr_number']} "
2308
+ f"[muted]{decision}[/]{count_str}"
2309
+ )
2310
+ console.print()
2311
+
2312
+
2313
+ def cmd_switch(args: argparse.Namespace) -> None:
2314
+ """Promote a feature to the canonical slot (canonical-slot model, Wave 2.9)."""
2315
+ from ..actions.errors import ActionError
2316
+ from ..actions.switch import switch as switch_impl
2317
+ from .render import render_blocker
2318
+ from .ui import console, spinner
2319
+
2320
+ workspace = _load_workspace()
2321
+ to_slot = getattr(args, "to_slot", None)
2322
+ label = to_slot or args.feature or "feature"
2323
+ try:
2324
+ with spinner(f"Switching to {label}…"):
2325
+ result = switch_impl(
2326
+ workspace, args.feature,
2327
+ release_current=getattr(args, "release_current", False),
2328
+ no_evict=getattr(args, "no_evict", False),
2329
+ evict=getattr(args, "evict", None),
2330
+ evict_to=getattr(args, "evict_to", None),
2331
+ to_slot=to_slot,
2332
+ )
2333
+ except ActionError as err:
2334
+ if args.json:
2335
+ _print_json(err.to_dict())
2336
+ else:
2337
+ render_blocker(err, action="switch")
2338
+ sys.exit(1)
2339
+
2340
+ if args.json:
2341
+ _print_json(result)
2342
+ return
2343
+
2344
+ console.print()
2345
+ feature = result["feature"]
2346
+ mode = result["mode"]
2347
+ glyph = "[success]✓[/]"
2348
+ label = {"active_rotation": "active rotation",
2349
+ "wind_down": "wind down"}.get(mode, mode)
2350
+ console.print(f" {glyph} canonical: [feature]{feature}[/] [muted]({label})[/]")
2351
+ if result.get("previously_canonical"):
2352
+ prev = result["previously_canonical"]
2353
+ if mode == "wind_down":
2354
+ console.print(f" [muted]previous '{prev}' → cold (stashed if dirty)[/]")
2355
+ else:
2356
+ console.print(f" [muted]previous '{prev}' → warm worktree[/]")
2357
+ if result.get("eviction"):
2358
+ ev = result["eviction"]
2359
+ stashed_repos = [r for r in ev["repos"] if r["stashed"]]
2360
+ console.print(
2361
+ f" [warning]evicted '{ev['feature']}'[/] → cold "
2362
+ f"({len(stashed_repos)}/{len(ev['repos'])} repos auto-stashed)"
2363
+ )
2364
+ if result.get("branches_created"):
2365
+ for b in result["branches_created"]:
2366
+ console.print(f" [muted]created branch {b['repo']}/{b['branch']} from {b['base']}[/]")
2367
+ for repo, path in result["per_repo_paths"].items():
2368
+ console.print(f" [repo]{repo}[/] [muted]→ {path}[/]")
2369
+ if result.get("migration"):
2370
+ m = result["migration"]
2371
+ if m.get("ran"):
2372
+ detected = m.get("canonical_detected") or "(none)"
2373
+ console.print(f" [muted]migrated workspace to 2.9 schema; canonical detected: {detected}[/]")
2374
+ console.print()
2375
+ console.print(
2376
+ " [muted]now: 'canopy state' / 'canopy run <repo> <cmd>' default to this feature[/]"
2377
+ )
2378
+ console.print()
2379
+
2380
+
2381
+ def cmd_commit(args: argparse.Namespace) -> None:
2382
+ """Feature-scoped multi-repo commit (Wave 2.3)."""
2383
+ from ..actions.commit import commit as commit_impl
2384
+ from ..actions.errors import ActionError
2385
+ from .render import render_blocker
2386
+ from .ui import console, spinner
2387
+
2388
+ workspace = _load_workspace()
2389
+ spin_msg = "Committing (running hooks)…" if not args.no_hooks else "Committing…"
2390
+ try:
2391
+ with spinner(spin_msg):
2392
+ result = commit_impl(
2393
+ workspace,
2394
+ args.message or "",
2395
+ feature=args.feature,
2396
+ repos=_split_csv(args.repos),
2397
+ paths=args.paths or None,
2398
+ no_hooks=args.no_hooks,
2399
+ amend=args.amend,
2400
+ address=getattr(args, "address", None),
2401
+ resolve_thread=getattr(args, "resolve_thread", None),
2402
+ )
2403
+ except ActionError as err:
2404
+ if args.json:
2405
+ _print_json(err.to_dict())
2406
+ else:
2407
+ render_blocker(err, action="commit")
2408
+ sys.exit(1)
2409
+
2410
+ if args.json:
2411
+ _print_json(result)
2412
+ return
2413
+
2414
+ console.print()
2415
+ console.print(f" [feature]{result['feature']}[/]")
2416
+ for repo, r in result["results"].items():
2417
+ status = r["status"]
2418
+ if status == "ok":
2419
+ sha = r["sha"][:8]
2420
+ files = r["files_changed"]
2421
+ tag = " (amended)" if r.get("amended") else ""
2422
+ console.print(f" [success]✓[/] [repo]{repo}[/] {sha} ({files} files{tag})")
2423
+ elif status == "nothing":
2424
+ console.print(f" [muted]·[/] [repo]{repo}[/] no changes")
2425
+ elif status == "hooks_failed":
2426
+ console.print(f" [error]✗[/] [repo]{repo}[/] hook failed")
2427
+ for line in (r.get("hook_output") or "").splitlines()[:5]:
2428
+ console.print(f" [muted]{line}[/]")
2429
+ else: # failed
2430
+ console.print(f" [error]✗[/] [repo]{repo}[/] {r.get('reason', 'failed')}")
2431
+ addressed = result.get("addressed")
2432
+ if addressed:
2433
+ cid = addressed["comment_id"]
2434
+ if addressed.get("recorded"):
2435
+ sha = (addressed.get("sha") or "")[:8]
2436
+ console.print(
2437
+ f" [success]✓[/] addressed bot comment [muted]{cid}[/] "
2438
+ f"(recorded against [repo]{addressed['repo']}[/] {sha})",
2439
+ )
2440
+ else:
2441
+ console.print(
2442
+ f" [warning]·[/] bot comment [muted]{cid}[/] not recorded "
2443
+ f"({addressed.get('reason', 'no successful commit in owning repo')})",
2444
+ )
2445
+ tr = addressed.get("thread_resolved")
2446
+ if tr is not None:
2447
+ if tr.get("skipped"):
2448
+ console.print(
2449
+ f" [muted]·[/] thread resolve skipped "
2450
+ f"([muted]{tr['skipped']}[/])",
2451
+ )
2452
+ elif tr.get("is_resolved"):
2453
+ tid = tr.get("thread_id") or tr.get("logged", {}).get("thread_id", "")
2454
+ console.print(
2455
+ f" [success]✓[/] GH review thread resolved "
2456
+ f"([muted]{tid}[/])",
2457
+ )
2458
+ else:
2459
+ console.print(" [warning]·[/] GH thread resolve returned unexpected result")
2460
+ console.print()
2461
+
2462
+
2463
+ def cmd_bot_status(args: argparse.Namespace) -> None:
2464
+ """Per-feature bot-comment rollup (M3)."""
2465
+ from ..actions.bot_status import bot_comments_status
2466
+ from ..actions.errors import ActionError
2467
+ from .render import render_blocker
2468
+ from .ui import console
2469
+
2470
+ workspace = _load_workspace()
2471
+ try:
2472
+ result = bot_comments_status(workspace, feature=args.feature)
2473
+ except ActionError as err:
2474
+ if args.json:
2475
+ _print_json(err.to_dict())
2476
+ else:
2477
+ render_blocker(err, action="bot-status")
2478
+ sys.exit(1)
2479
+
2480
+ if args.json:
2481
+ _print_json(result)
2482
+ return
2483
+
2484
+ console.print()
2485
+ console.print(f" [feature]{result['feature']}[/]")
2486
+ if not result["any_bot_comments"]:
2487
+ console.print(" [muted]no bot comments tracked[/]")
2488
+ console.print()
2489
+ return
2490
+
2491
+ for repo, info in result["repos"].items():
2492
+ if info["total"] == 0:
2493
+ continue
2494
+ glyph = "[success]✓[/]" if info["unresolved"] == 0 else "[warning]●[/]"
2495
+ pr = f"PR #{info['pr_number']}" if info.get("pr_number") else "no PR"
2496
+ console.print(
2497
+ f" {glyph} [repo]{repo}[/] {pr} "
2498
+ f"resolved {info['resolved']}/{info['total']}",
2499
+ )
2500
+ threads = (
2501
+ [t for t in info["threads"] if not t["resolved"]]
2502
+ if args.unresolved_only
2503
+ else info["threads"]
2504
+ )
2505
+ for t in threads:
2506
+ mark = "[success]✓[/]" if t["resolved"] else "[warning]●[/]"
2507
+ label = f"{t.get('author', '')}".strip() or "(unknown)"
2508
+ preview = t.get("body_preview", "")
2509
+ console.print(f" {mark} [muted]{t.get('id', '')}[/] {label}: {preview}")
2510
+ overall = "[success]all resolved[/]" if result["all_resolved"] else "[warning]unresolved[/]"
2511
+ console.print(f" [muted]→ {overall}[/]")
2512
+ console.print()
2513
+
2514
+
2515
+ def _resolve_historian_feature(workspace, feature: str | None):
2516
+ """Resolve (workspace_root, feature_name) for a historian CLI call."""
2517
+ from ..actions import slots as slots_mod
2518
+ from ..actions.aliases import resolve_feature
2519
+ from ..actions.errors import BlockerError
2520
+ if feature:
2521
+ return workspace.config.root, resolve_feature(workspace, feature)
2522
+ state = slots_mod.read_state(workspace)
2523
+ if state is None or state.canonical is None:
2524
+ raise BlockerError(
2525
+ code="no_canonical_feature",
2526
+ what="no active feature; pass <feature> or run `canopy switch <name>` first",
2527
+ )
2528
+ return workspace.config.root, state.canonical.feature
2529
+
2530
+
2531
+ def cmd_historian(args: argparse.Namespace) -> None:
2532
+ """Read or compact a feature's historian memory file (M4)."""
2533
+ from ..actions import historian
2534
+ from ..actions.errors import ActionError
2535
+ from .render import render_blocker
2536
+ from .ui import console
2537
+
2538
+ workspace = _load_workspace()
2539
+ try:
2540
+ root, name = _resolve_historian_feature(workspace, args.feature)
2541
+ except ActionError as err:
2542
+ if args.json:
2543
+ _print_json(err.to_dict())
2544
+ else:
2545
+ render_blocker(err, action=f"historian {args.subcommand}")
2546
+ sys.exit(1)
2547
+
2548
+ if args.subcommand == "show":
2549
+ memory = historian.format_for_agent(root, name)
2550
+ if args.json:
2551
+ _print_json({"feature": name, "memory": memory})
2552
+ return
2553
+ if not memory:
2554
+ console.print()
2555
+ console.print(f" [muted]no memory recorded yet for [feature]{name}[/][/]")
2556
+ console.print()
2557
+ return
2558
+ console.print(memory)
2559
+ return
2560
+
2561
+ if args.subcommand == "compact":
2562
+ result = historian.compact(root, name, keep_sessions=args.keep_sessions)
2563
+ if args.json:
2564
+ _print_json({"feature": name, **result})
2565
+ return
2566
+ console.print()
2567
+ console.print(f" [feature]{name}[/] {result.get('action')}: "
2568
+ f"kept {result.get('kept', '?')} entries, "
2569
+ f"dropped {result.get('dropped', 0)}")
2570
+ console.print()
2571
+ return
2572
+
2573
+
2574
+ def cmd_push(args: argparse.Namespace) -> None:
2575
+ """Feature-scoped multi-repo push (Wave 2.3)."""
2576
+ from ..actions.errors import ActionError
2577
+ from ..actions.push import push as push_impl
2578
+ from .render import render_blocker
2579
+ from .ui import console, spinner
2580
+
2581
+ workspace = _load_workspace()
2582
+ spin_msg = "Dry-run push (no network)…" if args.dry_run else "Pushing…"
2583
+ try:
2584
+ with spinner(spin_msg):
2585
+ result = push_impl(
2586
+ workspace,
2587
+ feature=args.feature,
2588
+ repos=_split_csv(args.repos),
2589
+ set_upstream=args.set_upstream,
2590
+ force_with_lease=args.force_with_lease,
2591
+ dry_run=args.dry_run,
2592
+ )
2593
+ except ActionError as err:
2594
+ if args.json:
2595
+ _print_json(err.to_dict())
2596
+ else:
2597
+ render_blocker(err, action="push")
2598
+ sys.exit(1)
2599
+
2600
+ if args.json:
2601
+ _print_json(result)
2602
+ return
2603
+
2604
+ console.print()
2605
+ console.print(f" [feature]{result['feature']}[/]")
2606
+ for repo, r in result["results"].items():
2607
+ status = r["status"]
2608
+ if status == "ok":
2609
+ count = r.get("pushed_count", 0)
2610
+ ref = r.get("ref", "")
2611
+ extras = []
2612
+ if r.get("set_upstream"):
2613
+ extras.append("upstream set")
2614
+ if r.get("dry_run"):
2615
+ extras.append("dry-run")
2616
+ extra = f" ({', '.join(extras)})" if extras else ""
2617
+ console.print(f" [success]✓[/] [repo]{repo}[/] {ref} +{count}{extra}")
2618
+ elif status == "up_to_date":
2619
+ console.print(f" [muted]·[/] [repo]{repo}[/] up to date")
2620
+ elif status == "rejected":
2621
+ console.print(f" [error]✗[/] [repo]{repo}[/] rejected")
2622
+ console.print(f" [muted]{r.get('reason', '')}[/]")
2623
+ else: # failed
2624
+ console.print(f" [error]✗[/] [repo]{repo}[/] {r.get('reason', 'failed')}")
2625
+ console.print()
2626
+
2627
+
2628
+ def cmd_stash_save_feature(args: argparse.Namespace) -> None:
2629
+ """Feature-tagged stash save (extends `canopy stash save --feature`)."""
2630
+ from ..actions.errors import ActionError
2631
+ from ..actions.stash import save_for_feature
2632
+ from .render import render_blocker
2633
+ from .ui import console
2634
+
2635
+ workspace = _load_workspace()
2636
+ try:
2637
+ result = save_for_feature(
2638
+ workspace, args.feature, args.message or "",
2639
+ repos=_split_csv(getattr(args, "repos", None)),
2640
+ )
2641
+ except ActionError as err:
2642
+ if args.json:
2643
+ _print_json(err.to_dict())
2644
+ else:
2645
+ render_blocker(err, action="stash save")
2646
+ sys.exit(1)
2647
+ if args.json:
2648
+ _print_json(result)
2649
+ return
2650
+ console.print()
2651
+ console.print(f" [muted]message:[/] {result['message']}")
2652
+ for repo, status in result["repos"].items():
2653
+ glyph = "[success]✓[/]" if status == "stashed" else (
2654
+ "[muted]·[/]" if status == "clean" else "[error]✗[/]"
2655
+ )
2656
+ console.print(f" {glyph} [repo]{repo}[/] [muted]{status}[/]")
2657
+ console.print()
2658
+
2659
+
2660
+ def cmd_stash_list_grouped(args: argparse.Namespace) -> None:
2661
+ """Grouped stash list (extends `canopy stash list [--feature]`)."""
2662
+ from ..actions.errors import ActionError
2663
+ from ..actions.stash import list_grouped
2664
+ from .render import render_blocker
2665
+ from .ui import console
2666
+
2667
+ workspace = _load_workspace()
2668
+ try:
2669
+ result = list_grouped(workspace, feature=getattr(args, "feature", None))
2670
+ except ActionError as err:
2671
+ if args.json:
2672
+ _print_json(err.to_dict())
2673
+ else:
2674
+ render_blocker(err, action="stash list")
2675
+ sys.exit(1)
2676
+ if args.json:
2677
+ _print_json(result)
2678
+ return
2679
+ console.print()
2680
+ if not result["by_feature"] and not result["untagged"]:
2681
+ console.print(" [muted]no stashes[/]")
2682
+ console.print()
2683
+ return
2684
+ for feature, entries in result["by_feature"].items():
2685
+ console.print(f" [feature]{feature}[/] [muted]({len(entries)})[/]")
2686
+ for e in entries:
2687
+ console.print(f" [repo]{e['repo']}[/] {e['ref']} [muted]{e.get('ts','')}[/] {e.get('user_message','')}")
2688
+ if result["untagged"]:
2689
+ console.print(f" [header]untagged[/] [muted]({len(result['untagged'])})[/]")
2690
+ for e in result["untagged"]:
2691
+ console.print(f" [repo]{e['repo']}[/] {e['ref']} [muted]{e.get('message','')}[/]")
2692
+ console.print()
2693
+
2694
+
2695
+ def cmd_stash_pop_feature(args: argparse.Namespace) -> None:
2696
+ """Pop most recent feature-tagged stash per repo."""
2697
+ from ..actions.errors import ActionError
2698
+ from ..actions.stash import pop_feature
2699
+ from .render import render_blocker
2700
+ from .ui import console
2701
+
2702
+ workspace = _load_workspace()
2703
+ try:
2704
+ result = pop_feature(
2705
+ workspace, args.feature,
2706
+ repos=_split_csv(getattr(args, "repos", None)),
2707
+ )
2708
+ except ActionError as err:
2709
+ if args.json:
2710
+ _print_json(err.to_dict())
2711
+ else:
2712
+ render_blocker(err, action="stash pop")
2713
+ sys.exit(1)
2714
+ if args.json:
2715
+ _print_json(result)
2716
+ return
2717
+ console.print()
2718
+ for repo, info in result["repos"].items():
2719
+ if info["status"] == "popped":
2720
+ console.print(f" [success]✓[/] [repo]{repo}[/] [muted]{info.get('user_message') or info.get('message','')}[/]")
2721
+ elif info["status"] == "no_match":
2722
+ console.print(f" [muted]·[/] [repo]{repo}[/] [muted]no matching stash[/]")
2723
+ else:
2724
+ console.print(f" [error]✗[/] [repo]{repo}[/] [error]{info.get('error','')}[/]")
2725
+ console.print()
2726
+
2727
+
2728
+ def _split_csv(value: str | None) -> list[str] | None:
2729
+ if value is None or value == "":
2730
+ return None
2731
+ return [v.strip() for v in value.split(",") if v.strip()]
2732
+
2733
+
2734
+ def cmd_drift(args: argparse.Namespace) -> None:
2735
+ """Compare recorded HEAD state vs feature lane expectations across repos."""
2736
+ from ..actions.drift import detect_drift
2737
+ from .ui import console, print_warning, SYM_CHECK, SYM_CROSS
2738
+
2739
+ workspace = _load_workspace()
2740
+ feature = getattr(args, "feature", None)
2741
+ report = detect_drift(workspace, feature_name=feature)
2742
+
2743
+ if args.json:
2744
+ _print_json(report.to_dict())
2745
+ return
2746
+
2747
+ console.print()
2748
+ if report.note:
2749
+ console.print(f" [muted]{report.note}[/]")
2750
+ console.print()
2751
+ return
2752
+
2753
+ for fd in report.features:
2754
+ glyph = f"[success]{SYM_CHECK}[/]" if fd.aligned else f"[error]{SYM_CROSS}[/]"
2755
+ console.print(f" {glyph} [feature]{fd.feature}[/]")
2756
+ for r in fd.repos:
2757
+ if r.actual is None:
2758
+ line = f" [repo]{r.repo}[/] [muted]→ no recorded state (expected {r.expected})[/]"
2759
+ elif r.aligned:
2760
+ line = f" [repo]{r.repo}[/] [muted]→ {r.actual}[/]"
2761
+ else:
2762
+ line = f" [repo]{r.repo}[/] [warning]→ {r.actual}[/] [muted](expected {r.expected})[/]"
2763
+ console.print(line)
2764
+ if not fd.aligned:
2765
+ console.print(f" [muted]fix:[/] [info]canopy switch {fd.feature}[/]")
2766
+ console.print()
2767
+
2768
+
2769
+ def cmd_pr_checks(args: argparse.Namespace) -> None:
2770
+ """Fetch CI check runs for a PR alias (M10)."""
2771
+ from ..actions.aliases import resolve_pr_targets
2772
+ from ..integrations import github as gh
2773
+ from .ui import console
2774
+
2775
+ workspace = _load_workspace()
2776
+ targets = resolve_pr_targets(workspace, args.alias)
2777
+ results = []
2778
+ for t in targets:
2779
+ rollup, raw = gh.get_pr_checks(
2780
+ workspace.config.root, t.owner, t.repo_slug, t.pr_number,
2781
+ )
2782
+ results.append({
2783
+ "repo": t.repo,
2784
+ "pr_number": t.pr_number,
2785
+ "ci_status": rollup,
2786
+ "checks": raw,
2787
+ })
2788
+
2789
+ if args.json:
2790
+ _print_json({"alias": args.alias, "results": results})
2791
+ return
2792
+
2793
+ console.print()
2794
+ for r in results:
2795
+ ci = r["ci_status"]
2796
+ glyph = {
2797
+ "passing": "[success]✓[/]",
2798
+ "failing": "[error]✗[/]",
2799
+ "pending": "[warning]·[/]",
2800
+ "no_checks": "[muted]·[/]",
2801
+ }.get(ci.get("status"), "?")
2802
+ console.print(
2803
+ f" [repo]{r['repo']}[/] PR #{r['pr_number']} "
2804
+ f"{glyph} {ci.get('status', '')} "
2805
+ f"[muted]passed: {ci.get('passed', 0)}, failing: {ci.get('failing', 0)}, "
2806
+ f"pending: {ci.get('pending', 0)}[/]"
2807
+ )
2808
+ if ci.get("required_failing"):
2809
+ console.print(
2810
+ f" [error]failing:[/] {', '.join(ci['required_failing'])}"
2811
+ )
2812
+ if ci.get("details_url"):
2813
+ console.print(f" [muted]{ci['details_url']}[/]")
2814
+ console.print()
2815
+
2816
+
2817
+ def cmd_worktree_bootstrap(args: argparse.Namespace) -> None:
2818
+ """Bootstrap a feature's worktrees: env-files, deps, IDE workspace (M6)."""
2819
+ from ..actions.bootstrap import ALLOWED_STEPS, bootstrap_feature
2820
+ from ..actions.errors import ActionError
2821
+ from .render import render_blocker
2822
+ from .ui import console, spinner
2823
+
2824
+ workspace = _load_workspace()
2825
+ steps_arg = getattr(args, "step", None)
2826
+ steps = [steps_arg] if steps_arg else None
2827
+
2828
+ try:
2829
+ with spinner("Bootstrapping…"):
2830
+ result = bootstrap_feature(
2831
+ workspace, args.feature,
2832
+ force=getattr(args, "force", False),
2833
+ steps=steps,
2834
+ )
2835
+ except ActionError as err:
2836
+ if args.json:
2837
+ _print_json(err.to_dict())
2838
+ else:
2839
+ render_blocker(err, action="worktree-bootstrap")
2840
+ sys.exit(1)
2841
+
2842
+ if args.json:
2843
+ _print_json(result)
2844
+ return
2845
+
2846
+ console.print()
2847
+ console.print(f" [feature]{result['feature']}[/]")
2848
+ for repo, r in result["results"].items():
2849
+ console.print(f"\n [repo]{repo}[/]")
2850
+ env = r["env"]
2851
+ env_glyph = {"ok": "[success]✓[/]", "skipped": "[muted]·[/]",
2852
+ "missing_source": "[warning]·[/]"}.get(env["status"], "?")
2853
+ copied = env.get("files_copied", [])
2854
+ env_summary = f"{len(copied)} file(s)" if copied else env.get("reason", env["status"])
2855
+ console.print(f" env {env_glyph} {env_summary}")
2856
+ deps = r["deps"]
2857
+ deps_glyph = {"ok": "[success]✓[/]", "failed": "[error]✗[/]",
2858
+ "skipped": "[muted]·[/]"}.get(deps["status"], "?")
2859
+ deps_extra = ""
2860
+ if deps["status"] == "ok":
2861
+ deps_extra = f" [muted]({deps.get('duration_ms', 0)}ms)[/]"
2862
+ elif deps["status"] == "failed":
2863
+ deps_extra = f" [error]exit {deps.get('exit_code', '?')}[/]"
2864
+ elif deps.get("reason"):
2865
+ deps_extra = f" [muted]{deps['reason']}[/]"
2866
+ console.print(f" deps {deps_glyph}{deps_extra}")
2867
+ ide = result["ide"]
2868
+ ide_glyph = {"ok": "[success]✓[/]", "skipped": "[muted]·[/]",
2869
+ "no_ide_configured": "[muted]·[/]"}.get(ide["status"], "?")
2870
+ if ide.get("path"):
2871
+ console.print(f"\n ide {ide_glyph} [muted]{ide['path']}[/]")
2872
+ elif ide.get("reason"):
2873
+ console.print(f"\n ide {ide_glyph} [muted]{ide['reason']}[/]")
2874
+ else:
2875
+ console.print(f"\n ide {ide_glyph} [muted]{ide['status']}[/]")
2876
+ console.print()
2877
+
2878
+
2879
+ def cmd_ship(args: argparse.Namespace) -> None:
2880
+ """Open or update one PR per repo in the canonical feature (M8 / Wave 2.4)."""
2881
+ from ..actions.errors import ActionError
2882
+ from ..actions.ship import ship as ship_impl
2883
+ from .render import render_blocker
2884
+ from .ui import console, spinner
2885
+
2886
+ workspace = _load_workspace()
2887
+ spin_msg = "Dry-run ship…" if args.dry_run else "Shipping…"
2888
+ try:
2889
+ with spinner(spin_msg):
2890
+ result = ship_impl(
2891
+ workspace,
2892
+ feature=args.feature,
2893
+ repos=_split_csv(args.repos),
2894
+ draft=args.draft,
2895
+ reviewers=_split_csv(args.reviewers),
2896
+ dry_run=args.dry_run,
2897
+ base=args.base,
2898
+ )
2899
+ except ActionError as err:
2900
+ if args.json:
2901
+ _print_json(err.to_dict())
2902
+ else:
2903
+ render_blocker(err, action="ship")
2904
+ sys.exit(1)
2905
+
2906
+ if args.json:
2907
+ _print_json(result)
2908
+ return
2909
+
2910
+ console.print()
2911
+ console.print(f" [feature]{result['feature']}[/]")
2912
+ for repo, r in result["results"].items():
2913
+ status = r["status"]
2914
+ if status == "opened":
2915
+ console.print(
2916
+ f" [success]✓[/] [repo]{repo}[/] opened PR #{r['pr_number']} "
2917
+ f"[muted]{r.get('url', '')}[/]"
2918
+ + (" [muted](draft)[/]" if r.get("draft") else "")
2919
+ )
2920
+ elif status == "up_to_date":
2921
+ console.print(
2922
+ f" [muted]·[/] [repo]{repo}[/] PR #{r['pr_number']} up to date"
2923
+ )
2924
+ elif status == "diverged":
2925
+ console.print(
2926
+ f" [warning]⚠[/] [repo]{repo}[/] PR #{r['pr_number']} diverged "
2927
+ f"[muted]{r.get('warning', '')}[/]"
2928
+ )
2929
+ elif status == "closed":
2930
+ console.print(
2931
+ f" [warning]·[/] [repo]{repo}[/] PR #{r['pr_number']} closed/merged "
2932
+ f"[muted]{r.get('reason', '')}[/]"
2933
+ )
2934
+ elif status == "skipped":
2935
+ console.print(
2936
+ f" [muted]·[/] [repo]{repo}[/] skipped — {r.get('reason', '')}"
2937
+ )
2938
+ elif status.startswith("would_"):
2939
+ console.print(
2940
+ f" [muted]·[/] [repo]{repo}[/] {status} (dry-run)"
2941
+ )
2942
+ else:
2943
+ console.print(f" [error]✗[/] [repo]{repo}[/] {r.get('reason', status)}")
2944
+ if result.get("cross_repo_links_updated"):
2945
+ console.print()
2946
+ console.print(" [muted]cross-repo PR descriptions updated with sibling links[/]")
2947
+ console.print()
2948
+
2949
+
2950
+ def cmd_draft_replies(args: argparse.Namespace) -> None:
2951
+ """Auto-draft "Done in <sha>" replies for addressed PR comments (M9)."""
2952
+ from ..actions.draft_replies import draft_replies
2953
+ from .ui import console
2954
+
2955
+ workspace = _load_workspace()
2956
+ result = draft_replies(
2957
+ workspace, args.alias,
2958
+ include_likely_resolved=getattr(args, "include_likely_resolved", False),
2959
+ )
2960
+
2961
+ if args.json:
2962
+ _print_json(result)
2963
+ return
2964
+
2965
+ console.print()
2966
+ console.print(
2967
+ f" [header]drafts: {result['addressed_total']} addressed, "
2968
+ f"{result['unaddressed_total']} unaddressed[/]"
2969
+ )
2970
+ for repo, info in result["repos"].items():
2971
+ console.print()
2972
+ console.print(
2973
+ f" [repo]{repo}[/] PR #{info['pr_number']} [muted]{info.get('pr_url', '')}[/]"
2974
+ )
2975
+ for draft in info["addressed"]:
2976
+ conf = draft["confidence"]
2977
+ tag = {"high": "[success]✓[/]", "medium": "[warning]·[/]",
2978
+ "low": "[muted]·[/]"}.get(conf, "·")
2979
+ orig = draft["original_comment"]
2980
+ location = (
2981
+ f"{orig['path']}:{orig['line']}" if orig.get("path") else "(general)"
2982
+ )
2983
+ console.print(
2984
+ f" {tag} [muted]{location}[/] {orig.get('author', '')} "
2985
+ f"[muted]({conf})[/]"
2986
+ )
2987
+ console.print(f" → [info]{draft['draft_reply']}[/]")
2988
+ if not info["addressed"]:
2989
+ console.print(
2990
+ f" [muted]no draftable replies; "
2991
+ f"{len(info['unaddressed'])} unaddressed[/]"
2992
+ )
2993
+ console.print()
2994
+
2995
+
2996
+ def cmd_conflicts(args: argparse.Namespace) -> None:
2997
+ """Cross-feature file-overlap detection (M12)."""
2998
+ from ..actions.conflicts import find_conflicts
2999
+ from .ui import console
3000
+
3001
+ workspace = _load_workspace()
3002
+ result = find_conflicts(
3003
+ workspace,
3004
+ feature=getattr(args, "feature", None),
3005
+ other=getattr(args, "with_", None),
3006
+ include_cold=getattr(args, "include_cold", False),
3007
+ line_level=getattr(args, "lines", False),
3008
+ )
3009
+
3010
+ if args.json:
3011
+ _print_json(result)
3012
+ return
3013
+
3014
+ pairs = result["pairs"]
3015
+ console.print()
3016
+ console.print(f" [header]Conflicts ({len(pairs)} pair{'' if len(pairs) == 1 else 's'})[/]")
3017
+ console.print(f" {'─' * 60}")
3018
+ if not pairs:
3019
+ console.print(" [muted]no overlaps detected[/]")
3020
+ console.print()
3021
+ return
3022
+
3023
+ severity_glyph = {
3024
+ "high": "[error]⚠[/]",
3025
+ "medium": "[warning]·[/]",
3026
+ "low": "[muted]·[/]",
3027
+ }
3028
+ for pair in pairs:
3029
+ glyph = severity_glyph.get(pair["severity"], "·")
3030
+ console.print(
3031
+ f"\n {glyph} [feature]{pair['feature_a']}[/] ↔ [feature]{pair['feature_b']}[/] "
3032
+ f"[muted]{pair['severity']}[/]"
3033
+ )
3034
+ for repo, entry in pair["overlap"].items():
3035
+ files = entry["files"]
3036
+ file_list = ", ".join(files[:3])
3037
+ if len(files) > 3:
3038
+ file_list += f" (+{len(files) - 3} more)"
3039
+ line_bit = ""
3040
+ if "lines_both" in entry and entry["lines_both"] > 0:
3041
+ line_bit = f", {entry['lines_both']} lines overlapping"
3042
+ console.print(f" [repo]{repo}[/] {file_list}{line_bit}")
3043
+ console.print(f" [muted]suggestion: {pair['suggestion']}[/]")
3044
+ console.print()
3045
+
3046
+
3047
+ def cmd_doctor(args: argparse.Namespace) -> None:
3048
+ """Diagnose workspace + install integrity; optionally repair."""
3049
+ from ..actions.doctor import doctor
3050
+ from .ui import console
3051
+
3052
+ workspace = _load_workspace()
3053
+
3054
+ fix_categories = None
3055
+ if getattr(args, "fix_category", None):
3056
+ fix_categories = [args.fix_category]
3057
+ fix = bool(getattr(args, "fix", False) or fix_categories)
3058
+ feature = getattr(args, "feature", None)
3059
+ clean_vsix = bool(getattr(args, "clean_vsix", False))
3060
+
3061
+ report = doctor(
3062
+ workspace,
3063
+ fix=fix,
3064
+ fix_categories=fix_categories,
3065
+ feature=feature,
3066
+ clean_vsix=clean_vsix,
3067
+ )
3068
+
3069
+ if args.json:
3070
+ _print_json(report)
3071
+ return
3072
+
3073
+ issues = report["issues"]
3074
+ summary = report["summary"]
3075
+ fixed = report.get("fixed") or []
3076
+ skipped = report.get("skipped") or []
3077
+
3078
+ console.print()
3079
+ if not issues:
3080
+ console.print(" [success]✓[/] doctor: workspace + install look clean")
3081
+ console.print()
3082
+ return
3083
+
3084
+ # Group by severity, errors first.
3085
+ glyphs = {"error": ("[error]✗[/]", "errors"),
3086
+ "warn": ("[warning]![/]", "warnings"),
3087
+ "info": ("[muted]·[/]", "info")}
3088
+ for sev, (glyph, label) in glyphs.items():
3089
+ sev_issues = [i for i in issues if i["severity"] == sev]
3090
+ if not sev_issues:
3091
+ continue
3092
+ console.print(f" {glyph} [header]{label}:[/] {len(sev_issues)}")
3093
+ for issue in sev_issues:
3094
+ scope = ""
3095
+ if issue.get("repo") and issue.get("feature"):
3096
+ scope = f" [muted]({issue['feature']}/{issue['repo']})[/]"
3097
+ elif issue.get("repo"):
3098
+ scope = f" [muted]({issue['repo']})[/]"
3099
+ elif issue.get("feature"):
3100
+ scope = f" [muted]({issue['feature']})[/]"
3101
+ console.print(
3102
+ f" {glyph} {issue['what']}{scope} [muted]({issue['code']})[/]"
3103
+ )
3104
+ if getattr(args, "verbose", False):
3105
+ if issue.get("expected") is not None:
3106
+ console.print(f" [muted]expected:[/] {issue['expected']}")
3107
+ if issue.get("actual") is not None:
3108
+ console.print(f" [muted]actual: [/] {issue['actual']}")
3109
+ if issue.get("fix_action"):
3110
+ tag = "[muted](safe)[/]" if issue.get("auto_fixable") else "[warning](manual)[/]"
3111
+ console.print(f" [muted]fix:[/] {issue['fix_action']} {tag}")
3112
+ console.print()
3113
+
3114
+ if fix:
3115
+ console.print(f" [info]repaired:[/] {len(fixed)}; [muted]skipped:[/] {len(skipped)}")
3116
+ for f in fixed:
3117
+ ok = "[success]✓[/]" if f.get("success") else "[error]✗[/]"
3118
+ console.print(f" {ok} {f['action_taken']} [muted]({f['code']})[/]")
3119
+ if f.get("error"):
3120
+ console.print(f" [error]error:[/] {f['error']}")
3121
+ for s in skipped:
3122
+ console.print(
3123
+ f" [muted]· skipped {s['code']}: {s.get('skip_reason', '')}[/]"
3124
+ )
3125
+ console.print()
3126
+
3127
+ console.print(
3128
+ f" [muted]summary:[/] errors={summary['errors']} "
3129
+ f"warnings={summary['warnings']} info={summary['info']}"
3130
+ )
3131
+ console.print()
3132
+
3133
+
3134
+ def _resolve_thread_feature(workspace, feature: str | None) -> str:
3135
+ """Return a feature name for thread commands, falling back to canonical."""
3136
+ from ..actions import slots as slots_mod
3137
+ from ..actions.aliases import resolve_feature
3138
+ from ..actions.errors import BlockerError
3139
+ if feature:
3140
+ return resolve_feature(workspace, feature)
3141
+ state = slots_mod.read_state(workspace)
3142
+ if state is None or state.canonical is None:
3143
+ raise BlockerError(
3144
+ code="no_canonical_feature",
3145
+ what="no active feature; pass --feature or run `canopy switch <name>` first",
3146
+ )
3147
+ return state.canonical.feature
3148
+
3149
+
3150
+ def cmd_reply_thread(args: argparse.Namespace) -> None:
3151
+ """Post a reply to a GitHub PR review thread, optionally resolving it."""
3152
+ from ..actions.thread_actions import reply_to_thread
3153
+ from ..actions.errors import ActionError, BlockerError
3154
+ from .render import render_blocker
3155
+ from .ui import console
3156
+
3157
+ workspace = _load_workspace()
3158
+ try:
3159
+ feature = _resolve_thread_feature(workspace, getattr(args, "feature", None))
3160
+ except ActionError as err:
3161
+ if args.json:
3162
+ _print_json(err.to_dict())
3163
+ else:
3164
+ render_blocker(err, action="reply")
3165
+ sys.exit(1)
3166
+
3167
+ if args.body is not None:
3168
+ body = args.body
3169
+ elif args.body_file is not None:
3170
+ from pathlib import Path
3171
+ body = Path(args.body_file).read_text()
3172
+ else:
3173
+ if sys.stdin.isatty():
3174
+ err = BlockerError(
3175
+ code="missing_reply_body",
3176
+ what="reply body required: pass --body, --body-file, or pipe stdin",
3177
+ )
3178
+ if args.json:
3179
+ _print_json(err.to_dict())
3180
+ else:
3181
+ render_blocker(err, action="reply")
3182
+ sys.exit(1)
3183
+ body = sys.stdin.read()
3184
+
3185
+ if not body.strip():
3186
+ err = BlockerError(code="empty_reply_body", what="reply body is empty")
3187
+ if args.json:
3188
+ _print_json(err.to_dict())
3189
+ else:
3190
+ render_blocker(err, action="reply")
3191
+ sys.exit(1)
3192
+
3193
+ try:
3194
+ result = reply_to_thread(workspace, args.thread_id, body,
3195
+ feature=feature, resolve_after=args.resolve)
3196
+ except ActionError as err:
3197
+ if args.json:
3198
+ _print_json(err.to_dict())
3199
+ else:
3200
+ render_blocker(err, action="reply")
3201
+ sys.exit(1)
3202
+
3203
+ if args.json:
3204
+ _print_json(result)
3205
+ return
3206
+
3207
+ console.print()
3208
+ console.print(f" Posted reply: [info]{result['posted']['url']}[/]")
3209
+ if args.resolve and "resolved" in result:
3210
+ console.print(f" Resolved [info]{args.thread_id}[/]")
3211
+ console.print()
3212
+
3213
+
3214
+ def cmd_resolve_thread(args: argparse.Namespace) -> None:
3215
+ """Resolve a GitHub PR review thread and record the resolution locally."""
3216
+ from ..actions.thread_actions import resolve_thread
3217
+ from ..actions.errors import ActionError
3218
+ from .render import render_blocker
3219
+ from .ui import console
3220
+
3221
+ workspace = _load_workspace()
3222
+ try:
3223
+ feature = _resolve_thread_feature(workspace, getattr(args, "feature", None))
3224
+ except ActionError as err:
3225
+ if args.json:
3226
+ _print_json(err.to_dict())
3227
+ else:
3228
+ render_blocker(err, action="resolve")
3229
+ sys.exit(1)
3230
+
3231
+ try:
3232
+ result = resolve_thread(workspace, args.thread_id, feature=feature)
3233
+ except ActionError as err:
3234
+ if args.json:
3235
+ _print_json(err.to_dict())
3236
+ else:
3237
+ render_blocker(err, action="resolve")
3238
+ sys.exit(1)
3239
+
3240
+ if args.json:
3241
+ _print_json(result)
3242
+ return
3243
+
3244
+ console.print()
3245
+ console.print(f" Resolved [info]{args.thread_id}[/]")
3246
+ console.print()
3247
+
3248
+
3249
+ def _render_resume_human(brief: dict, console) -> None:
3250
+ """Paint the resume brief in a human-readable layout."""
3251
+ from .ui import SYM_ARROW, separator
3252
+
3253
+ feature = brief.get("feature", "")
3254
+ fstate = (brief.get("current_state") or {}).get("feature_state") or "unknown"
3255
+ last_visit = brief.get("last_visit")
3256
+ window_hours = brief.get("window_hours")
3257
+ first_visit = brief.get("first_visit", False)
3258
+ switch_performed = brief.get("switch_performed", False)
3259
+ switch_summary = brief.get("switch_summary") or {}
3260
+ since = brief.get("since_last_visit") or {}
3261
+ current = brief.get("current_state") or {}
3262
+ hints = brief.get("intent_hints") or []
3263
+
3264
+ console.print()
3265
+ console.print(
3266
+ f" [feature]{feature}[/] [muted]state: {fstate}[/]"
3267
+ )
3268
+
3269
+ # Last visit line
3270
+ if first_visit:
3271
+ console.print(f" [muted]first visit[/]")
3272
+ else:
3273
+ ago = f" [muted]({window_hours:.0f}h ago)[/]" if window_hours is not None else ""
3274
+ console.print(f" [muted]last visit: {last_visit}{ago}[/]")
3275
+
3276
+ if switch_performed:
3277
+ prev = switch_summary.get("previously_canonical") or ""
3278
+ mode = switch_summary.get("mode", "active_rotation")
3279
+ mode_label = {"active_rotation": "active_rotation", "wind_down": "wind_down"}.get(mode, mode)
3280
+ arrow_str = f"{prev} {SYM_ARROW} {feature}" if prev else feature
3281
+ console.print(f" [muted]switch: {arrow_str} [{mode_label}][/]")
3282
+
3283
+ # ── Since last visit ──────────────────────────────────────────────────
3284
+ separator()
3285
+ console.print(f" [header]Since last visit[/]")
3286
+
3287
+ commits = since.get("commits") or {}
3288
+ if commits:
3289
+ parts = " ".join(
3290
+ f"[repo]{r}[/] {len(cs)}" for r, cs in commits.items() if cs
3291
+ )
3292
+ if parts:
3293
+ console.print(f" Commits {parts}")
3294
+
3295
+ new_threads = since.get("threads_new") or []
3296
+ resolved_gh = since.get("threads_resolved_on_github") or []
3297
+ resolved_by_canopy = since.get("threads_resolved_by_canopy") or []
3298
+ if new_threads:
3299
+ repos_hit = {t.get("repo") for t in new_threads if t.get("repo")}
3300
+ suffix = f" [muted]({', '.join(sorted(repos_hit))})[/]" if repos_hit else ""
3301
+ console.print(f" New threads {len(new_threads)}{suffix}")
3302
+ if resolved_gh:
3303
+ by_canopy = sum(1 for t in resolved_gh if t.get("by_canopy"))
3304
+ canon_str = f" [muted](by canopy: {by_canopy})[/]" if by_canopy else ""
3305
+ console.print(f" Resolved threads {len(resolved_gh)}{canon_str}")
3306
+ elif resolved_by_canopy:
3307
+ console.print(f" Resolved by canopy {len(resolved_by_canopy)}")
3308
+
3309
+ drafts_pending = since.get("draft_replies_pending", 0)
3310
+ if drafts_pending:
3311
+ console.print(f" Draft replies {drafts_pending} pending")
3312
+
3313
+ historian_excerpt = since.get("historian_excerpt") or ""
3314
+ if historian_excerpt:
3315
+ first_line = historian_excerpt.splitlines()[0]
3316
+ console.print(f" Memory [muted]{first_line}[/]")
3317
+
3318
+ if not any([commits, new_threads, resolved_gh, resolved_by_canopy, drafts_pending, historian_excerpt]):
3319
+ console.print(f" [muted]nothing new[/]")
3320
+
3321
+ # ── Current ───────────────────────────────────────────────────────────
3322
+ separator()
3323
+ console.print(f" [header]Current[/]")
3324
+
3325
+ linear_issue = current.get("linear_issue")
3326
+ linear_url = current.get("linear_url")
3327
+ if linear_issue:
3328
+ url_str = f" [muted]{linear_url}[/]" if linear_url else ""
3329
+ console.print(f" Linear [linear]{linear_issue}[/]{url_str}")
3330
+
3331
+ ci = current.get("ci_summary_per_repo") or {}
3332
+ if ci:
3333
+ ci_parts = []
3334
+ for r, status in ci.items():
3335
+ if status in ("passing", "success"):
3336
+ ci_parts.append(f"[repo]{r}[/] [success]{status}[/]")
3337
+ elif status in ("failing", "failure"):
3338
+ ci_parts.append(f"[repo]{r}[/] [error]{status}[/]")
3339
+ else:
3340
+ ci_parts.append(f"[repo]{r}[/] [muted]{status}[/]")
3341
+ console.print(f" CI {' '.join(ci_parts)}")
3342
+
3343
+ open_threads = current.get("open_thread_count", 0)
3344
+ if open_threads:
3345
+ console.print(f" Open threads {open_threads}")
3346
+
3347
+ bot_unresolved = current.get("bot_unresolved_total", 0)
3348
+ if bot_unresolved:
3349
+ console.print(f" Bot unresolved {bot_unresolved}")
3350
+
3351
+ branch_pos = current.get("branch_position_per_repo") or {}
3352
+ if branch_pos:
3353
+ for repo_name, pos in branch_pos.items():
3354
+ ahead = pos.get("ahead", 0)
3355
+ behind = pos.get("behind", 0)
3356
+ default = pos.get("default_branch", "main")
3357
+ parts = []
3358
+ if ahead:
3359
+ parts.append(f"[ahead]+{ahead}[/]")
3360
+ if behind:
3361
+ parts.append(f"[behind]-{behind}[/]")
3362
+ div_str = " ".join(parts) if parts else "[muted]up to date[/]"
3363
+ console.print(
3364
+ f" Branch [repo]{repo_name}[/] {div_str} [muted]from {default}[/]"
3365
+ )
3366
+
3367
+ # ── Next actions ──────────────────────────────────────────────────────
3368
+ if hints:
3369
+ separator()
3370
+ console.print(f" [header]Next actions[/]")
3371
+ for i, hint in enumerate(hints, 1):
3372
+ kind = hint.get("kind", "")
3373
+ summary = hint.get("summary", "")
3374
+ tool = hint.get("suggested_tool", "")
3375
+ tool_args = hint.get("suggested_args") or {}
3376
+ args_str = ", ".join(f'{k}="{v}"' for k, v in tool_args.items() if v)
3377
+ call_str = f"{tool}({args_str})" if tool else ""
3378
+ console.print(f" {i}. [info]{kind}[/] [muted]{summary}[/]")
3379
+ if call_str:
3380
+ console.print(f" [muted]{SYM_ARROW} {call_str}[/]")
3381
+
3382
+ console.print()
3383
+
3384
+
3385
+ def cmd_resume(args: argparse.Namespace) -> None:
3386
+ """Show a fresh resume brief: what changed since last visit."""
3387
+ from ..actions.resume import feature_resume
3388
+ from ..actions.last_visit import reset_anchor
3389
+ from ..actions.errors import ActionError
3390
+ from .render import render_blocker
3391
+ from .ui import console
3392
+
3393
+ workspace = _load_workspace()
3394
+
3395
+ if args.reset_anchor:
3396
+ from ..actions.aliases import resolve_feature
3397
+ try:
3398
+ feature = resolve_feature(workspace, args.alias)
3399
+ except ActionError as err:
3400
+ if args.json:
3401
+ _print_json(err.to_dict())
3402
+ else:
3403
+ render_blocker(err, action="resume")
3404
+ sys.exit(1)
3405
+ cleared = reset_anchor(workspace, feature)
3406
+ if args.json:
3407
+ _print_json({"feature": feature, "cleared": cleared})
3408
+ return
3409
+ console.print(f" [success]Cleared last_visit for {feature}[/]")
3410
+ return
3411
+
3412
+ try:
3413
+ brief = feature_resume(workspace, args.alias)
3414
+ except ActionError as err:
3415
+ if args.json:
3416
+ _print_json(err.to_dict())
3417
+ else:
3418
+ render_blocker(err, action="resume")
3419
+ sys.exit(1)
3420
+
3421
+ if args.json:
3422
+ _print_json(brief)
3423
+ return
3424
+
3425
+ _render_resume_human(brief, console)
3426
+
3427
+
3428
+ def cmd_context(args: argparse.Namespace) -> None:
3429
+ """Show detected canopy context for current directory (debug)."""
3430
+ from ..workspace.context import detect_context
3431
+
3432
+ ctx = detect_context()
3433
+
3434
+ if args.json:
3435
+ _print_json(ctx.to_dict())
3436
+ return
3437
+
3438
+ print(f"\n Context type: {ctx.context_type}")
3439
+ print(f" Working dir: {ctx.cwd}")
3440
+ if ctx.workspace_root:
3441
+ print(f" Workspace: {ctx.workspace_root}")
3442
+ if ctx.feature:
3443
+ print(f" Feature: {ctx.feature}")
3444
+ if ctx.branch:
3445
+ print(f" Branch: {ctx.branch}")
3446
+ if ctx.repo_names:
3447
+ print(f" Repos: {', '.join(ctx.repo_names)}")
3448
+ for name, path in zip(ctx.repo_names, ctx.repo_paths):
3449
+ print(f" {name}: {path}")
3450
+ print()
3451
+
3452
+
3453
+ # ── Entry point ───────────────────────────────────────────────────────────
3454
+
3455
+ def main() -> None:
3456
+ from .. import __version__
3457
+
3458
+ parser = argparse.ArgumentParser(
3459
+ prog="canopy",
3460
+ description="Workspace-first development orchestrator.",
3461
+ )
3462
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
3463
+ parser.add_argument(
3464
+ "--version", action="version", version=f"canopy {__version__}",
3465
+ )
3466
+ subparsers = parser.add_subparsers(dest="command")
3467
+
3468
+ # init
3469
+ init_p = subparsers.add_parser("init", help="Initialize a workspace")
3470
+ init_p.add_argument("path", nargs="?", default=None, help="Workspace root path")
3471
+ init_p.add_argument("--name", default=None, help="Workspace name")
3472
+ init_p.add_argument("--force", action="store_true", help="Overwrite existing canopy.toml")
3473
+ init_p.add_argument("--dry-run", action="store_true", help="Print toml without writing")
3474
+ init_p.add_argument("--json", action="store_true", help="Output as JSON")
3475
+ init_p.add_argument("--no-agent", action="store_true",
3476
+ help="Skip Claude Code agent setup (skill + MCP config)")
3477
+
3478
+ # status
3479
+ status_p = subparsers.add_parser("status", help="Workspace status")
3480
+ status_p.add_argument("--json", action="store_true", help="Output as JSON")
3481
+
3482
+ # feature (with subcommands)
3483
+ feature_p = subparsers.add_parser("feature", help="Feature lane operations")
3484
+ feature_sub = feature_p.add_subparsers(dest="feature_command")
3485
+
3486
+ # feature create
3487
+ fc = feature_sub.add_parser("create", help="Create a feature lane")
3488
+ fc.add_argument("name", help="Feature/branch name")
3489
+ fc.add_argument("--repos", default=None, help="Comma-separated repo names (default: all)")
3490
+ fc.add_argument("--worktree", action="store_true",
3491
+ help="Create linked worktrees (each repo gets its own directory)")
3492
+ fc.add_argument("--json", action="store_true", help="Output as JSON")
3493
+
3494
+ # feature list
3495
+ fl = feature_sub.add_parser("list", help="List feature lanes")
3496
+ fl.add_argument("--json", action="store_true", help="Output as JSON")
3497
+
3498
+ # feature diff
3499
+ fd = feature_sub.add_parser("diff", help="Feature lane diff")
3500
+ fd.add_argument("name", help="Feature name")
3501
+ fd.add_argument("--json", action="store_true", help="Output as JSON")
3502
+
3503
+ # feature status
3504
+ fst = feature_sub.add_parser("status", help="Feature lane status")
3505
+ fst.add_argument("name", help="Feature name")
3506
+ fst.add_argument("--json", action="store_true", help="Output as JSON")
3507
+
3508
+ # feature changes
3509
+ fch = feature_sub.add_parser("changes", help="Per-file change status across repos")
3510
+ fch.add_argument("name", help="Feature name")
3511
+ fch.add_argument("--json", action="store_true", help="Output as JSON")
3512
+
3513
+ # sync
3514
+ sync_p = subparsers.add_parser("sync", help="Pull + rebase across repos")
3515
+ sync_p.add_argument("--strategy", choices=["rebase", "merge"], default="rebase")
3516
+ sync_p.add_argument("--json", action="store_true", help="Output as JSON")
3517
+
3518
+ # checkout
3519
+ co_p = subparsers.add_parser("checkout", help="Checkout branch across repos")
3520
+ co_p.add_argument("branch", help="Branch to checkout")
3521
+ co_p.add_argument("--repos", default=None, help="Comma-separated repo names")
3522
+ co_p.add_argument("--json", action="store_true", help="Output as JSON")
3523
+
3524
+ # log
3525
+ log_p = subparsers.add_parser("log", help="Interleaved log across repos")
3526
+ log_p.add_argument("-n", "--count", type=int, default=20, help="Max entries")
3527
+ log_p.add_argument("--feature", default=None, help="Show log for feature branch")
3528
+ log_p.add_argument("--json", action="store_true", help="Output as JSON")
3529
+
3530
+ # branch (with subcommands)
3531
+ branch_p = subparsers.add_parser("branch", help="Branch operations across repos")
3532
+ branch_sub = branch_p.add_subparsers(dest="branch_command")
3533
+
3534
+ bl = branch_sub.add_parser("list", help="List branches")
3535
+ bl.add_argument("--json", action="store_true", help="Output as JSON")
3536
+
3537
+ bd = branch_sub.add_parser("delete", help="Delete a branch")
3538
+ bd.add_argument("name", help="Branch to delete")
3539
+ bd.add_argument("--force", action="store_true", help="Force delete")
3540
+ bd.add_argument("--repos", default=None, help="Comma-separated repo names")
3541
+ bd.add_argument("--json", action="store_true", help="Output as JSON")
3542
+
3543
+ br = branch_sub.add_parser("rename", help="Rename a branch")
3544
+ br.add_argument("old", help="Current branch name")
3545
+ br.add_argument("new", help="New branch name")
3546
+ br.add_argument("--repos", default=None, help="Comma-separated repo names")
3547
+ br.add_argument("--json", action="store_true", help="Output as JSON")
3548
+
3549
+ binfo = branch_sub.add_parser(
3550
+ "info",
3551
+ help="Branch info per repo (alias = feature or <repo>:<branch>)",
3552
+ )
3553
+ binfo.add_argument("alias", help="Feature alias or <repo>:<branch>")
3554
+ binfo.add_argument("--repo", default=None,
3555
+ help="Filter feature-alias result to one repo")
3556
+ binfo.add_argument("--json", action="store_true", help="Output as JSON")
3557
+
3558
+ # stash (with subcommands)
3559
+ stash_p = subparsers.add_parser("stash", help="Stash operations across repos")
3560
+ stash_sub = stash_p.add_subparsers(dest="stash_command")
3561
+
3562
+ ss = stash_sub.add_parser("save", help="Stash changes")
3563
+ ss.add_argument("-m", "--message", default="", help="Stash message")
3564
+ ss.add_argument("--repos", default=None, help="Comma-separated repo names")
3565
+ ss.add_argument("--feature", default=None,
3566
+ help="Tag stash with this feature (canopy:<f>:...) and "
3567
+ "include untracked files")
3568
+ ss.add_argument("--json", action="store_true", help="Output as JSON")
3569
+
3570
+ sp = stash_sub.add_parser("pop", help="Pop stash")
3571
+ sp.add_argument("--index", type=int, default=0, help="Stash index")
3572
+ sp.add_argument("--repos", default=None, help="Comma-separated repo names")
3573
+ sp.add_argument("--feature", default=None,
3574
+ help="Pop the most recent stash tagged with this feature")
3575
+ sp.add_argument("--json", action="store_true", help="Output as JSON")
3576
+
3577
+ sl = stash_sub.add_parser("list", help="List stashes")
3578
+ sl.add_argument("--feature", default=None,
3579
+ help="Group/filter by feature tag")
3580
+ sl.add_argument("--json", action="store_true", help="Output as JSON")
3581
+
3582
+ sd = stash_sub.add_parser("drop", help="Drop stash")
3583
+ sd.add_argument("--index", type=int, default=0, help="Stash index")
3584
+ sd.add_argument("--repos", default=None, help="Comma-separated repo names")
3585
+ sd.add_argument("--json", action="store_true", help="Output as JSON")
3586
+
3587
+ # worktree
3588
+ wt_p = subparsers.add_parser(
3589
+ "worktree",
3590
+ help="Create or list worktrees (canopy worktree <name> [issue])",
3591
+ )
3592
+ wt_p.add_argument(
3593
+ "name", nargs="?", default=None,
3594
+ help="Feature name to create. Omit to list existing worktrees.",
3595
+ )
3596
+ wt_p.add_argument(
3597
+ "issue", nargs="?", default=None,
3598
+ help="Linear issue ID (e.g. ENG-123). Fetches via Linear MCP if configured.",
3599
+ )
3600
+ wt_p.add_argument(
3601
+ "--repos", nargs="+",
3602
+ help="Subset of repos (default: all)",
3603
+ )
3604
+ wt_p.add_argument("--json", action="store_true", help="Output as JSON")
3605
+
3606
+ # code (IDE launcher)
3607
+ code_p = subparsers.add_parser("code", help="Open VS Code for feature or workspace")
3608
+ code_p.add_argument("target", help="Feature name, or '.' for whole workspace")
3609
+ code_p.add_argument("--json", action="store_true", help="Output paths as JSON")
3610
+
3611
+ # cursor (IDE launcher)
3612
+ cursor_p = subparsers.add_parser("cursor", help="Open Cursor for feature or workspace")
3613
+ cursor_p.add_argument("target", help="Feature name, or '.' for whole workspace")
3614
+ cursor_p.add_argument("--json", action="store_true", help="Output paths as JSON")
3615
+
3616
+ # fork (IDE launcher)
3617
+ fork_p = subparsers.add_parser("fork", help="Open Fork.app for feature or workspace")
3618
+ fork_p.add_argument("target", help="Feature name, or '.' for whole workspace")
3619
+ fork_p.add_argument("--json", action="store_true", help="Output paths as JSON")
3620
+
3621
+ # preflight (context-aware add + hooks)
3622
+ preflight_p = subparsers.add_parser("preflight", help="Stage + run hooks (does not commit)")
3623
+ preflight_p.add_argument("feature", nargs="?", default=None,
3624
+ help="Feature alias — when set, runs against the lane's repos and records the result")
3625
+ preflight_p.add_argument("--json", action="store_true", help="Output as JSON")
3626
+
3627
+ # list (top-level shortcut)
3628
+ list_p = subparsers.add_parser("list", help="List all feature lanes")
3629
+ list_p.add_argument("--json", action="store_true", help="Output as JSON")
3630
+
3631
+ # review
3632
+ review_p = subparsers.add_parser(
3633
+ "review",
3634
+ help="Fetch PR review comments and prep for commit",
3635
+ )
3636
+ review_p.add_argument("name", help="Feature lane name")
3637
+ review_p.add_argument(
3638
+ "-m", "--message", default="",
3639
+ help="Placeholder commit message (staged but not committed)",
3640
+ )
3641
+ review_p.add_argument(
3642
+ "--comments-only", action="store_true",
3643
+ help="Only fetch comments — skip pre-commit and staging",
3644
+ )
3645
+ review_p.add_argument("--json", action="store_true", help="Output as JSON")
3646
+
3647
+ # done
3648
+ done_p = subparsers.add_parser(
3649
+ "done",
3650
+ help="Clean up a feature — remove worktrees, branches, archive",
3651
+ )
3652
+ done_p.add_argument("name", help="Feature lane name")
3653
+ done_p.add_argument("--force", action="store_true", help="Remove even with dirty worktrees")
3654
+ done_p.add_argument("--json", action="store_true", help="Output as JSON")
3655
+
3656
+ # config
3657
+ config_p = subparsers.add_parser(
3658
+ "config",
3659
+ help="Read or write workspace settings (canopy config [key] [value])",
3660
+ )
3661
+ config_p.add_argument("key", nargs="?", default=None, help="Setting name")
3662
+ config_p.add_argument("value", nargs="?", default=None, help="New value")
3663
+ config_p.add_argument("--json", action="store_true", help="Output as JSON")
3664
+
3665
+ # context (debug)
3666
+ ctx_p = subparsers.add_parser("context", help="Show detected canopy context (debug)")
3667
+ ctx_p.add_argument("--json", action="store_true", help="Output as JSON")
3668
+
3669
+ # commit (feature-scoped multi-repo commit — Wave 2.3)
3670
+ commit_p = subparsers.add_parser(
3671
+ "commit",
3672
+ help="Commit across every repo in the canonical (or named) feature",
3673
+ )
3674
+ commit_p.add_argument("-m", "--message", required=False, default="",
3675
+ help="Commit message (single message across all repos)")
3676
+ commit_p.add_argument("--feature", default=None,
3677
+ help="Feature alias; defaults to canonical feature")
3678
+ commit_p.add_argument("--repo", "--repos", dest="repos", default=None,
3679
+ help="Comma-separated subset of repos within the feature")
3680
+ commit_p.add_argument("--paths", nargs="*", default=None,
3681
+ help="Filter staging to these paths (relative to each repo root)")
3682
+ commit_p.add_argument("--no-hooks", action="store_true",
3683
+ help="Pass --no-verify to skip pre-commit / commit-msg hooks")
3684
+ commit_p.add_argument("--amend", action="store_true",
3685
+ help="Amend HEAD in each repo instead of creating new commits")
3686
+ commit_p.add_argument("--address", default=None, metavar="COMMENT-ID",
3687
+ help="Address a bot review comment (numeric id or GitHub URL); "
3688
+ "auto-suffixes the message with the comment title + URL "
3689
+ "and records the resolution in .canopy/state/bot_resolutions.json")
3690
+ _resolve_grp = commit_p.add_mutually_exclusive_group()
3691
+ _resolve_grp.add_argument("--resolve-thread", action="store_true", default=None,
3692
+ dest="resolve_thread",
3693
+ help="After --address commit succeeds, resolve the GH review "
3694
+ "thread (overrides augment auto_resolve_threads_on_address)")
3695
+ _resolve_grp.add_argument("--no-resolve-thread", action="store_false",
3696
+ dest="resolve_thread",
3697
+ help="Do NOT resolve the GH review thread even if the augment "
3698
+ "auto_resolve_threads_on_address is true")
3699
+ commit_p.add_argument("--json", action="store_true", help="Output as JSON")
3700
+
3701
+ # bot-status — per-feature bot-comment rollup (M3)
3702
+ bot_status_p = subparsers.add_parser(
3703
+ "bot-status",
3704
+ help="Show bot review comments for the canonical (or named) feature",
3705
+ )
3706
+ bot_status_p.add_argument("--feature", default=None,
3707
+ help="Feature alias; defaults to canonical feature")
3708
+ bot_status_p.add_argument("--unresolved-only", action="store_true",
3709
+ help="Only list unresolved threads")
3710
+ bot_status_p.add_argument("--json", action="store_true", help="Output as JSON")
3711
+
3712
+ # historian — cross-session feature memory (M4)
3713
+ historian_p = subparsers.add_parser(
3714
+ "historian",
3715
+ help="Read or compact a feature's persistent memory file (M4)",
3716
+ )
3717
+ historian_sub = historian_p.add_subparsers(dest="subcommand", required=True)
3718
+
3719
+ historian_show = historian_sub.add_parser(
3720
+ "show", help="Print the rendered memory file for the feature",
3721
+ )
3722
+ historian_show.add_argument("feature", nargs="?", default=None,
3723
+ help="Feature alias; defaults to canonical feature")
3724
+ historian_show.add_argument("--json", action="store_true", help="Output as JSON")
3725
+
3726
+ historian_compact = historian_sub.add_parser(
3727
+ "compact",
3728
+ help="Trim the Sessions section to the most recent N entries",
3729
+ )
3730
+ historian_compact.add_argument("feature", nargs="?", default=None,
3731
+ help="Feature alias; defaults to canonical feature")
3732
+ historian_compact.add_argument("--keep-sessions", type=int, default=5,
3733
+ dest="keep_sessions",
3734
+ help="Number of most-recent sessions to keep (default 5)")
3735
+ historian_compact.add_argument("--json", action="store_true", help="Output as JSON")
3736
+
3737
+ # push (feature-scoped multi-repo push — Wave 2.3)
3738
+ push_p = subparsers.add_parser(
3739
+ "push",
3740
+ help="Push the feature branch in every repo (canonical by default)",
3741
+ )
3742
+ push_p.add_argument("--feature", default=None,
3743
+ help="Feature alias; defaults to canonical feature")
3744
+ push_p.add_argument("--repo", "--repos", dest="repos", default=None,
3745
+ help="Comma-separated subset of repos within the feature")
3746
+ push_p.add_argument("--set-upstream", action="store_true",
3747
+ help="Pass --set-upstream for repos that lack an upstream")
3748
+ push_p.add_argument("--force-with-lease", action="store_true",
3749
+ help="Pass --force-with-lease to allow safe non-fast-forward pushes")
3750
+ push_p.add_argument("--dry-run", action="store_true",
3751
+ help="Enumerate what would happen without firing pushes")
3752
+ push_p.add_argument("--json", action="store_true", help="Output as JSON")
3753
+
3754
+ # switch (canonical-slot focus primitive — Wave 2.9)
3755
+ switch_p = subparsers.add_parser(
3756
+ "switch",
3757
+ help="Promote a feature to the canonical slot (canonical-slot model)",
3758
+ )
3759
+ switch_p.add_argument("feature", nargs="?", default=None,
3760
+ help="Feature alias (name or Linear ID)")
3761
+ switch_p.add_argument("--release-current", action="store_true",
3762
+ help="Wind-down mode: previous canonical goes cold (no warm worktree)")
3763
+ switch_p.add_argument("--no-evict", action="store_true",
3764
+ help="Refuse to auto-evict an LRU warm worktree if cap would fire")
3765
+ switch_p.add_argument("--evict", default=None,
3766
+ help="Explicit feature name to evict to cold (overrides LRU pick)")
3767
+ switch_p.add_argument("--evict-to", default=None,
3768
+ help="Pin which slot the previous canonical evacuates to")
3769
+ switch_p.add_argument("--to-slot", default=None,
3770
+ help="Promote the feature currently in this slot to canonical")
3771
+ switch_p.add_argument("--json", action="store_true", help="Output as JSON")
3772
+
3773
+ # slots
3774
+ slots_p = subparsers.add_parser(
3775
+ "slots",
3776
+ help="Show slot occupancy: canonical + warm slots + last_touched LRU",
3777
+ )
3778
+ slots_p.add_argument("--json", action="store_true", help="Output as JSON (always rich)")
3779
+ slots_p.add_argument("--rich", action="store_true",
3780
+ help="Include per-slot PR/CI/bots/linear (implied by --json)")
3781
+
3782
+ # slot (sub-command group for slot operations)
3783
+ slot_p = subparsers.add_parser("slot", help="Slot-targeted operations")
3784
+ slot_sub = slot_p.add_subparsers(dest="slot_cmd", required=True)
3785
+ slot_load_p = slot_sub.add_parser("load", help="Warm a cold feature into a slot")
3786
+ slot_load_p.add_argument("feature")
3787
+ slot_load_p.add_argument("slot_id", nargs="?", default=None,
3788
+ help="Target slot id (e.g. worktree-1); defaults to lowest free")
3789
+ slot_load_p.add_argument("--replace", action="store_true",
3790
+ help="Evict slot's current occupant first")
3791
+ slot_load_p.add_argument("--bootstrap", action="store_true",
3792
+ help="Run env/install bootstrap after load")
3793
+ slot_load_p.add_argument("--json", action="store_true")
3794
+
3795
+ slot_clear_p = slot_sub.add_parser("clear", help="Evict a slot's occupant to cold")
3796
+ slot_clear_p.add_argument("slot_id")
3797
+ slot_clear_p.add_argument("--json", action="store_true")
3798
+
3799
+ slot_swap_p = slot_sub.add_parser("swap", help="Exchange occupants of two slots")
3800
+ slot_swap_p.add_argument("slot_a")
3801
+ slot_swap_p.add_argument("slot_b")
3802
+ slot_swap_p.add_argument("--json", action="store_true")
3803
+
3804
+ # migrate-slots
3805
+ migrate_slots_p = subparsers.add_parser(
3806
+ "migrate-slots",
3807
+ help="One-shot migration from pre-3.0 feature-named worktrees to the 3.0 slot model",
3808
+ )
3809
+ migrate_slots_p.add_argument("--json", action="store_true", help="Output as JSON")
3810
+
3811
+ # setup-agent
3812
+ setup_p = subparsers.add_parser(
3813
+ "setup-agent",
3814
+ help="Install the using-canopy skill + add canopy MCP to .mcp.json",
3815
+ )
3816
+ setup_p.add_argument("--skill-only", action="store_true",
3817
+ help="Install only the skill(s) (skip MCP config)")
3818
+ setup_p.add_argument("--mcp-only", action="store_true",
3819
+ help="Install only the MCP config (skip skills)")
3820
+ setup_p.add_argument("--skill", action="append", default=None, metavar="NAME",
3821
+ help="Install an extra bundled skill by name (e.g. 'augment-canopy'). "
3822
+ "Repeatable. The default 'using-canopy' skill is always installed.")
3823
+ setup_p.add_argument("--reinstall", action="store_true",
3824
+ help="Overwrite existing files even if foreign or current")
3825
+ setup_p.add_argument("--check", action="store_true",
3826
+ help="Report status without changing anything")
3827
+ setup_p.add_argument("--json", action="store_true", help="Output as JSON")
3828
+
3829
+ # state
3830
+ state_p = subparsers.add_parser(
3831
+ "state",
3832
+ help="Feature state + suggested next actions (dashboard backend)",
3833
+ )
3834
+ state_p.add_argument("feature", nargs="?", default=None,
3835
+ help="Feature alias (name or Linear ID). Defaults to active feature if any.")
3836
+ state_p.add_argument("--json", action="store_true", help="Output as JSON")
3837
+
3838
+ # triage
3839
+ triage_p = subparsers.add_parser(
3840
+ "triage",
3841
+ help="Prioritized list of features needing attention",
3842
+ )
3843
+ triage_p.add_argument("--author", default="@me",
3844
+ help="Filter PRs to this author (default: @me)")
3845
+ triage_p.add_argument("--repos", default=None,
3846
+ help="Comma-separated subset of repos to scan")
3847
+ triage_p.add_argument("--json", action="store_true", help="Output as JSON")
3848
+
3849
+ # drift
3850
+ pr_checks_p = subparsers.add_parser(
3851
+ "pr-checks",
3852
+ help="CI check rollup for a PR alias (M10)",
3853
+ )
3854
+ pr_checks_p.add_argument("alias", help="Feature alias, <repo>#<n>, or PR URL")
3855
+ pr_checks_p.add_argument("--json", action="store_true", help="Output as JSON")
3856
+
3857
+ bootstrap_p = subparsers.add_parser(
3858
+ "worktree-bootstrap",
3859
+ help="Bootstrap a feature's worktrees (env files, deps, IDE workspace) — M6",
3860
+ )
3861
+ bootstrap_p.add_argument("feature", help="Feature alias to bootstrap")
3862
+ bootstrap_p.add_argument("--force", action="store_true",
3863
+ help="Overwrite existing destination env files")
3864
+ bootstrap_p.add_argument("--step", choices=["env", "deps", "ide"], default=None,
3865
+ help="Run only one step instead of all three")
3866
+ bootstrap_p.add_argument("--json", action="store_true", help="Output as JSON")
3867
+
3868
+ ship_p = subparsers.add_parser(
3869
+ "ship",
3870
+ help="Open or update one PR per repo in the canonical feature (M8 / Wave 2.4)",
3871
+ )
3872
+ ship_p.add_argument("--feature", default=None,
3873
+ help="Feature alias (defaults to canonical)")
3874
+ ship_p.add_argument("--repo", "--repos", dest="repos", default=None,
3875
+ help="Comma-separated repo names to scope the ship")
3876
+ ship_p.add_argument("--draft", action="store_true",
3877
+ help="Open PRs as drafts (initial open only)")
3878
+ ship_p.add_argument("--reviewers", default=None,
3879
+ help="Comma-separated GitHub handles to request review from")
3880
+ ship_p.add_argument("--base", default=None,
3881
+ help="Override base branch (default: each repo's default_branch)")
3882
+ ship_p.add_argument("--dry-run", action="store_true",
3883
+ help="Enumerate without firing pushes or opening PRs")
3884
+ ship_p.add_argument("--json", action="store_true", help="Output as JSON")
3885
+
3886
+ draft_replies_p = subparsers.add_parser(
3887
+ "draft-replies",
3888
+ help="Auto-draft replies for addressed PR review comments (M9)",
3889
+ )
3890
+ draft_replies_p.add_argument(
3891
+ "alias", help="Feature alias, <repo>#<n>, or PR URL",
3892
+ )
3893
+ draft_replies_p.add_argument(
3894
+ "--include-likely-resolved", action="store_true",
3895
+ help="Also draft for the temporal classifier's likely_resolved set (low confidence)",
3896
+ )
3897
+ draft_replies_p.add_argument("--json", action="store_true", help="Output as JSON")
3898
+
3899
+ conflicts_p = subparsers.add_parser(
3900
+ "conflicts",
3901
+ help="Cross-feature file-overlap detection (M12)",
3902
+ )
3903
+ conflicts_p.add_argument("--feature", default=None,
3904
+ help="Scope to pairs involving this feature")
3905
+ conflicts_p.add_argument("--with", dest="with_", default=None,
3906
+ help="Further scope to <feature> vs <other>")
3907
+ conflicts_p.add_argument("--include-cold", action="store_true",
3908
+ help="Also scan cold features (default: active only)")
3909
+ conflicts_p.add_argument("--lines", action="store_true",
3910
+ help="Compute line-range overlap (slower; downgrades to medium when files overlap but lines don't)")
3911
+ conflicts_p.add_argument("--json", action="store_true", help="Output as JSON")
3912
+
3913
+ drift_p = subparsers.add_parser(
3914
+ "drift",
3915
+ help="Show alignment between recorded HEADs and active feature lanes",
3916
+ )
3917
+ drift_p.add_argument("feature", nargs="?", default=None,
3918
+ help="Limit to a specific feature lane")
3919
+ drift_p.add_argument("--json", action="store_true", help="Output as JSON")
3920
+
3921
+ # issue — fetch one issue from the configured provider (M5+)
3922
+ issue_p = subparsers.add_parser(
3923
+ "issue",
3924
+ help="Fetch an issue from the workspace provider (Linear / GitHub Issues)",
3925
+ )
3926
+ issue_p.add_argument("alias",
3927
+ help="Provider-native id (SIN-412 / 5 / #5 / owner/repo#5 / URL) or feature alias")
3928
+ issue_p.add_argument("--json", action="store_true", help="Output as JSON")
3929
+
3930
+ # issues — list current user's open issues from the configured provider (F-5)
3931
+ issues_p = subparsers.add_parser(
3932
+ "issues",
3933
+ help="List the current user's open issues from the workspace provider",
3934
+ )
3935
+ issues_p.add_argument("--limit", type=int, default=25,
3936
+ help="Max issues to return (default 25)")
3937
+ issues_p.add_argument("--json", action="store_true", help="Output as JSON")
3938
+
3939
+ # pr (GitHub)
3940
+ pr_p = subparsers.add_parser(
3941
+ "pr",
3942
+ help="Fetch PR data per repo (alias = feature, <repo>#<n>, or PR URL)",
3943
+ )
3944
+ pr_p.add_argument("alias", help="Feature alias, <repo>#<n>, or PR URL")
3945
+ pr_p.add_argument("--json", action="store_true", help="Output as JSON")
3946
+
3947
+ # branch info (read tool — sits alongside branch list/delete/rename)
3948
+
3949
+ # comments (PR review comments)
3950
+ comments_p = subparsers.add_parser(
3951
+ "comments",
3952
+ help="Temporally classified PR review comments (alias = feature, <repo>#<n>, URL)",
3953
+ )
3954
+ comments_p.add_argument("alias", help="Feature alias, <repo>#<n>, or PR URL")
3955
+ comments_p.add_argument("--json", action="store_true", help="Output as JSON")
3956
+
3957
+ # run
3958
+ run_p = subparsers.add_parser(
3959
+ "run",
3960
+ help="Run a shell command in a canopy-managed repo (resolves cwd safely)",
3961
+ )
3962
+ run_p.add_argument("repo", help="Repo name (from canopy.toml)")
3963
+ # NB: positional named "cmd" not "command" — the top-level subparser
3964
+ # dispatch uses dest="command" and would clobber it.
3965
+ run_p.add_argument("cmd", help="Shell command to run")
3966
+ run_p.add_argument("--feature", default=None,
3967
+ help="Feature lane (selects worktree path if applicable)")
3968
+ run_p.add_argument("--timeout", type=int, default=60,
3969
+ help="Kill the process after N seconds (default 60)")
3970
+ run_p.add_argument("--json", action="store_true", help="Output as JSON")
3971
+
3972
+ # hooks
3973
+ hooks_p = subparsers.add_parser(
3974
+ "hooks",
3975
+ help="Manage drift-tracking post-checkout hooks (install/uninstall/status)",
3976
+ )
3977
+ hooks_sub = hooks_p.add_subparsers(dest="hooks_command")
3978
+ hooks_install_p = hooks_sub.add_parser("install", help="Install hooks in all managed repos")
3979
+ hooks_install_p.add_argument("--json", action="store_true", help="Output as JSON")
3980
+ hooks_uninstall_p = hooks_sub.add_parser("uninstall", help="Remove canopy hooks; restore chained user hooks")
3981
+ hooks_uninstall_p.add_argument("--json", action="store_true", help="Output as JSON")
3982
+ hooks_status_p = hooks_sub.add_parser("status", help="Show hook + heads state per repo")
3983
+ hooks_status_p.add_argument("--json", action="store_true", help="Output as JSON")
3984
+ hooks_p.add_argument("--json", action="store_true", help="Output as JSON")
3985
+
3986
+ # doctor
3987
+ doctor_p = subparsers.add_parser(
3988
+ "doctor",
3989
+ help="Diagnose workspace + install integrity; --fix to repair",
3990
+ )
3991
+ doctor_p.add_argument(
3992
+ "--fix", action="store_true",
3993
+ help="Repair every auto-fixable issue",
3994
+ )
3995
+ doctor_p.add_argument(
3996
+ "--fix-category",
3997
+ choices=sorted(["heads", "active_feature", "worktrees", "hooks",
3998
+ "preflight", "features", "branches",
3999
+ "cli", "mcp", "skill", "vsix"]),
4000
+ default=None,
4001
+ help="Repair only one category (implies --fix)",
4002
+ )
4003
+ doctor_p.add_argument(
4004
+ "--feature", default=None,
4005
+ help="Scope feature-bearing checks to one feature",
4006
+ )
4007
+ doctor_p.add_argument(
4008
+ "--clean-vsix", action="store_true",
4009
+ help="Remove duplicate vsix install dirs (gates the vsix repair)",
4010
+ )
4011
+ doctor_p.add_argument(
4012
+ "-v", "--verbose", action="store_true",
4013
+ help="Show expected/actual values per issue",
4014
+ )
4015
+ doctor_p.add_argument("--json", action="store_true", help="Output as JSON")
4016
+
4017
+ reply_p = subparsers.add_parser(
4018
+ "reply",
4019
+ help="Post a reply to a GH review thread",
4020
+ )
4021
+ reply_p.add_argument("thread_id", help="Thread node ID (starts with PRRT_)")
4022
+ _reply_body_g = reply_p.add_mutually_exclusive_group()
4023
+ _reply_body_g.add_argument("--body", default=None,
4024
+ help="Reply body text")
4025
+ _reply_body_g.add_argument("--body-file", default=None,
4026
+ help="Path to file containing the reply body")
4027
+ reply_p.add_argument("--resolve", action="store_true",
4028
+ help="Resolve the thread after posting")
4029
+ reply_p.add_argument("--feature", default=None,
4030
+ help="Feature alias; defaults to canonical feature")
4031
+ reply_p.add_argument("--json", action="store_true", help="Output as JSON")
4032
+
4033
+ resolve_p = subparsers.add_parser(
4034
+ "resolve",
4035
+ help="Resolve a GitHub PR review thread and record it locally",
4036
+ )
4037
+ resolve_p.add_argument("thread_id", help="Thread node ID (starts with PRRT_)")
4038
+ resolve_p.add_argument("--feature", default=None,
4039
+ help="Feature alias; defaults to canonical feature")
4040
+ resolve_p.add_argument("--json", action="store_true", help="Output as JSON")
4041
+
4042
+ # resume
4043
+ resume_p = subparsers.add_parser(
4044
+ "resume",
4045
+ help="Fresh brief: what changed since last visit",
4046
+ )
4047
+ resume_p.add_argument("alias", help="Feature alias (name, Linear ID, slot id, etc.)")
4048
+ resume_p.add_argument("--json", action="store_true", help="Output as JSON")
4049
+ resume_p.add_argument(
4050
+ "--reset-anchor", action="store_true",
4051
+ help="Clear last_visit so the next call is treated as a first visit",
4052
+ )
4053
+
4054
+ args = parser.parse_args()
4055
+
4056
+ if not args.command:
4057
+ parser.print_help()
4058
+ sys.exit(0)
4059
+
4060
+ commands = {
4061
+ "init": cmd_init,
4062
+ "status": cmd_status,
4063
+ "sync": cmd_sync,
4064
+ "checkout": cmd_checkout,
4065
+ "log": cmd_log,
4066
+ "worktree": cmd_worktree,
4067
+ "code": cmd_code,
4068
+ "cursor": cmd_cursor,
4069
+ "fork": cmd_fork,
4070
+ "preflight": cmd_preflight,
4071
+ "list": cmd_list,
4072
+ "review": cmd_review,
4073
+ "done": cmd_done,
4074
+ "config": cmd_config,
4075
+ "context": cmd_context,
4076
+ "hooks": cmd_hooks,
4077
+ "drift": cmd_drift,
4078
+ "run": cmd_run,
4079
+ "issue": cmd_issue,
4080
+ "issues": cmd_issues,
4081
+ "pr": cmd_pr,
4082
+ "comments": cmd_comments,
4083
+ "switch": cmd_switch,
4084
+ "slots": cmd_slots,
4085
+ "migrate-slots": cmd_migrate_slots,
4086
+ "commit": cmd_commit,
4087
+ "bot-status": cmd_bot_status,
4088
+ "historian": cmd_historian,
4089
+ "push": cmd_push,
4090
+ "triage": cmd_triage,
4091
+ "state": cmd_state,
4092
+ "setup-agent": cmd_setup_agent,
4093
+ "doctor": cmd_doctor,
4094
+ "conflicts": cmd_conflicts,
4095
+ "draft-replies": cmd_draft_replies,
4096
+ "ship": cmd_ship,
4097
+ "worktree-bootstrap": cmd_worktree_bootstrap,
4098
+ "pr-checks": cmd_pr_checks,
4099
+ "resolve": cmd_resolve_thread,
4100
+ "reply": cmd_reply_thread,
4101
+ "resume": cmd_resume,
4102
+ }
4103
+
4104
+ if args.command == "feature":
4105
+ if not args.feature_command:
4106
+ feature_p.print_help()
4107
+ sys.exit(0)
4108
+ feature_commands = {
4109
+ "create": cmd_feature_create,
4110
+ "list": cmd_feature_list,
4111
+ "diff": cmd_feature_diff,
4112
+ "status": cmd_feature_status,
4113
+ "changes": cmd_feature_changes,
4114
+ }
4115
+ feature_commands[args.feature_command](args)
4116
+ elif args.command == "branch":
4117
+ if not args.branch_command:
4118
+ branch_p.print_help()
4119
+ sys.exit(0)
4120
+ branch_commands = {
4121
+ "list": cmd_branch_list,
4122
+ "delete": cmd_branch_delete,
4123
+ "rename": cmd_branch_rename,
4124
+ "info": cmd_branch,
4125
+ }
4126
+ branch_commands[args.branch_command](args)
4127
+ elif args.command == "stash":
4128
+ if not args.stash_command:
4129
+ stash_p.print_help()
4130
+ sys.exit(0)
4131
+ stash_commands = {
4132
+ "save": cmd_stash_save,
4133
+ "pop": cmd_stash_pop,
4134
+ "list": cmd_stash_list,
4135
+ "drop": cmd_stash_drop,
4136
+ }
4137
+ stash_commands[args.stash_command](args)
4138
+ elif args.command == "slot":
4139
+ slot_commands = {
4140
+ "load": cmd_slot_load,
4141
+ "clear": cmd_slot_clear,
4142
+ "swap": cmd_slot_swap,
4143
+ }
4144
+ slot_commands[args.slot_cmd](args)
4145
+ elif args.command in commands:
4146
+ commands[args.command](args)
4147
+ else:
4148
+ parser.print_help()
4149
+
4150
+
4151
+ if __name__ == "__main__":
4152
+ main()