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
@@ -0,0 +1,249 @@
1
+ """``canopy worktree bootstrap`` — env files, deps, IDE workspace (M6).
2
+
3
+ Three optional steps, gated per repo + per invocation:
4
+
5
+ 1. **Env file copy** — per-repo ``env_files`` lists files (relative to
6
+ repo root) to copy from the main checkout into the worktree.
7
+ 2. **Dependency install** — per-repo ``install_cmd`` runs once in the
8
+ worktree directory (e.g. ``uv sync`` / ``pnpm install``).
9
+ 3. **IDE workspace file** — workspace-level ``ide = "vscode"`` writes
10
+ ``.canopy/workspaces/<feature>.code-workspace`` listing every
11
+ worktree dir for the feature.
12
+
13
+ Each step is **off by default**. When the relevant config exists, the
14
+ caller must pass ``bootstrap=True`` (or set ``bootstrap_default = true``
15
+ in ``[workspace]``).
16
+
17
+ Failure of any step doesn't roll back the worktree — the worktree is
18
+ still valid. The caller can re-run ``canopy worktree bootstrap`` to
19
+ retry just the failed step.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import shutil
24
+ import subprocess
25
+ from pathlib import Path
26
+ from typing import Any, Iterable
27
+
28
+ from ..workspace.workspace import Workspace
29
+ from .aliases import resolve_feature
30
+ from .errors import BlockerError
31
+ from .ide_workspace import render_code_workspace
32
+
33
+ ALLOWED_STEPS = ("env", "deps", "ide")
34
+
35
+
36
+ def bootstrap_feature(
37
+ workspace: Workspace,
38
+ feature: str,
39
+ *,
40
+ force: bool = False,
41
+ steps: Iterable[str] | None = None,
42
+ ) -> dict[str, Any]:
43
+ """Run all three steps for every repo in a feature.
44
+
45
+ Args:
46
+ workspace: loaded workspace.
47
+ feature: feature alias.
48
+ force: overwrite existing destination env files.
49
+ steps: subset of {"env", "deps", "ide"} to run; default = all.
50
+
51
+ Returns ``{feature, results: {<repo>: {env, deps}}, ide}``.
52
+ Per-step result shape::
53
+
54
+ env → {status: "ok"|"skipped"|"missing_source", files_copied: [...]}
55
+ deps → {status: "ok"|"failed"|"skipped", exit_code, duration_ms,
56
+ stderr_tail?}
57
+ ide → {status: "ok"|"skipped"|"no_ide_configured", path?}
58
+ """
59
+ feature_name = resolve_feature(workspace, feature)
60
+ chosen_steps = _validate_steps(steps)
61
+ worktree_paths = _resolve_worktree_paths(workspace, feature_name)
62
+
63
+ if not worktree_paths:
64
+ raise BlockerError(
65
+ code="no_worktrees",
66
+ what=f"feature '{feature_name}' has no worktree paths recorded",
67
+ )
68
+
69
+ results: dict[str, dict[str, Any]] = {}
70
+ for repo_name, worktree_path in worktree_paths.items():
71
+ results[repo_name] = bootstrap_repo(
72
+ workspace, feature_name, repo_name, worktree_path,
73
+ force=force, steps=chosen_steps,
74
+ )
75
+
76
+ ide_result: dict[str, Any]
77
+ if "ide" in chosen_steps and workspace.config.ide and workspace.config.ide != "none":
78
+ ide_result = _write_ide_workspace(workspace, feature_name, worktree_paths)
79
+ else:
80
+ ide_result = {"status": "no_ide_configured"} if "ide" in chosen_steps else {"status": "skipped"}
81
+
82
+ return {
83
+ "feature": feature_name,
84
+ "results": results,
85
+ "ide": ide_result,
86
+ }
87
+
88
+
89
+ def bootstrap_repo(
90
+ workspace: Workspace,
91
+ feature_name: str,
92
+ repo_name: str,
93
+ worktree_path: Path,
94
+ *,
95
+ force: bool = False,
96
+ steps: Iterable[str] = ALLOWED_STEPS,
97
+ ) -> dict[str, Any]:
98
+ """Run env-copy + deps-install for a single repo's worktree."""
99
+ chosen = set(steps)
100
+ state = workspace.get_repo(repo_name)
101
+ main_path = state.abs_path
102
+ repo_config = state.config
103
+
104
+ env_result: dict[str, Any] = {"status": "skipped", "files_copied": []}
105
+ if "env" in chosen:
106
+ if repo_config.env_files:
107
+ env_result = _copy_env_files(
108
+ repo_config.env_files, main_path, worktree_path, force=force,
109
+ )
110
+ else:
111
+ env_result = {"status": "skipped", "files_copied": [],
112
+ "reason": "no env_files configured"}
113
+
114
+ deps_result: dict[str, Any] = {"status": "skipped"}
115
+ if "deps" in chosen:
116
+ if repo_config.install_cmd:
117
+ deps_result = _run_install(repo_config.install_cmd, worktree_path)
118
+ else:
119
+ deps_result = {"status": "skipped", "reason": "no install_cmd configured"}
120
+
121
+ return {"env": env_result, "deps": deps_result}
122
+
123
+
124
+ # ── step 1: env-file copy ──────────────────────────────────────────────
125
+
126
+ def _copy_env_files(
127
+ env_files: list[str],
128
+ src_dir: Path,
129
+ dst_dir: Path,
130
+ *,
131
+ force: bool,
132
+ ) -> dict[str, Any]:
133
+ """Copy each file relative to ``src_dir`` into ``dst_dir``.
134
+
135
+ Missing source files surface as ``missing_source`` per file but
136
+ don't block the others. Existing destinations are skipped unless
137
+ ``force=True``.
138
+ """
139
+ copied: list[str] = []
140
+ skipped: list[str] = []
141
+ missing: list[str] = []
142
+ for rel in env_files:
143
+ src = src_dir / rel
144
+ dst = dst_dir / rel
145
+ if not src.exists():
146
+ missing.append(rel)
147
+ continue
148
+ dst.parent.mkdir(parents=True, exist_ok=True)
149
+ if dst.exists() and not force:
150
+ skipped.append(rel)
151
+ continue
152
+ shutil.copy2(src, dst)
153
+ copied.append(rel)
154
+
155
+ if missing and not copied and not skipped:
156
+ status = "missing_source"
157
+ elif copied or skipped:
158
+ status = "ok"
159
+ else:
160
+ status = "skipped"
161
+
162
+ out: dict[str, Any] = {
163
+ "status": status,
164
+ "files_copied": copied,
165
+ }
166
+ if skipped:
167
+ out["files_skipped"] = skipped
168
+ if missing:
169
+ out["files_missing"] = missing
170
+ return out
171
+
172
+
173
+ # ── step 2: dep install ────────────────────────────────────────────────
174
+
175
+ def _run_install(install_cmd: str, worktree_path: Path) -> dict[str, Any]:
176
+ """Run ``install_cmd`` in ``worktree_path`` and capture exit + duration."""
177
+ import time
178
+ start = time.monotonic()
179
+ proc = subprocess.run(
180
+ install_cmd, shell=True, cwd=worktree_path,
181
+ capture_output=True, text=True,
182
+ )
183
+ duration_ms = int((time.monotonic() - start) * 1000)
184
+ out: dict[str, Any] = {
185
+ "status": "ok" if proc.returncode == 0 else "failed",
186
+ "exit_code": proc.returncode,
187
+ "duration_ms": duration_ms,
188
+ }
189
+ if proc.returncode != 0:
190
+ # Tail the last few lines of stderr — full output would balloon
191
+ # the JSON return for the dashboard. Caller can rerun manually
192
+ # for full output if needed.
193
+ tail = "\n".join(proc.stderr.splitlines()[-10:])
194
+ out["stderr_tail"] = tail
195
+ return out
196
+
197
+
198
+ # ── step 3: IDE workspace file ─────────────────────────────────────────
199
+
200
+ def _write_ide_workspace(
201
+ workspace: Workspace,
202
+ feature_name: str,
203
+ worktree_paths: dict[str, Path],
204
+ ) -> dict[str, Any]:
205
+ """Write ``.canopy/workspaces/<feature>.code-workspace`` atomically."""
206
+ if workspace.config.ide != "vscode":
207
+ return {"status": "skipped",
208
+ "reason": f"ide={workspace.config.ide!r} not supported (vscode only in v1)"}
209
+ ws_dir = workspace.config.root / ".canopy" / "workspaces"
210
+ ws_dir.mkdir(parents=True, exist_ok=True)
211
+ out_path = ws_dir / f"{feature_name}.code-workspace"
212
+ body = render_code_workspace(workspace, feature_name, worktree_paths)
213
+ tmp = out_path.with_suffix(out_path.suffix + ".tmp")
214
+ tmp.write_text(body)
215
+ tmp.replace(out_path)
216
+ return {"status": "ok", "path": str(out_path)}
217
+
218
+
219
+ # ── helpers ────────────────────────────────────────────────────────────
220
+
221
+ def _validate_steps(steps: Iterable[str] | None) -> set[str]:
222
+ if steps is None:
223
+ return set(ALLOWED_STEPS)
224
+ chosen = set(steps)
225
+ bad = chosen - set(ALLOWED_STEPS)
226
+ if bad:
227
+ raise BlockerError(
228
+ code="unknown_bootstrap_step",
229
+ what=f"unknown step(s): {sorted(bad)}",
230
+ expected={"allowed_steps": list(ALLOWED_STEPS)},
231
+ )
232
+ return chosen
233
+
234
+
235
+ def _resolve_worktree_paths(
236
+ workspace: Workspace, feature_name: str,
237
+ ) -> dict[str, Path]:
238
+ """Pull recorded worktree paths from features.json."""
239
+ import json
240
+ path = workspace.config.root / ".canopy" / "features.json"
241
+ if not path.exists():
242
+ return {}
243
+ try:
244
+ data = json.loads(path.read_text("utf-8"))
245
+ except (OSError, ValueError):
246
+ return {}
247
+ entry = data.get(feature_name) or {}
248
+ raw = entry.get("worktree_paths") or {}
249
+ return {repo: Path(p) for repo, p in raw.items() if p}
@@ -0,0 +1,123 @@
1
+ """Persistent log of bot review-comments addressed by canopy commits (M3).
2
+
3
+ State file: ``<workspace_root>/.canopy/state/bot_resolutions.json``
4
+
5
+ Schema (an append-only mapping; each key is a stringified GitHub comment ID)::
6
+
7
+ {
8
+ "123456": {
9
+ "feature": "sin-6-cache-stats",
10
+ "repo": "test-api",
11
+ "commit_sha": "abc123de",
12
+ "addressed_at": "2026-05-02T17:30:00Z",
13
+ "comment_title": "rename hit_rate to cache_hit_rate",
14
+ "comment_url": "https://github.com/owner/repo/pull/142#discussion_r123456"
15
+ }
16
+ }
17
+
18
+ Written by ``commit --address``; read by the ``bot_comments_status`` rollup
19
+ and by ``feature_state`` to subtract resolved bot comments from the
20
+ actionable count surfaced in the agent dashboard.
21
+
22
+ Writes are atomic (temp file + ``os.replace``) so concurrent record calls
23
+ across worktrees don't corrupt the file.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import tempfile
30
+ from datetime import datetime, timezone
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+
35
+ _STATE_DIR = ".canopy/state"
36
+ _STATE_FILE = "bot_resolutions.json"
37
+
38
+
39
+ def _state_path(workspace_root: Path) -> Path:
40
+ return workspace_root / _STATE_DIR / _STATE_FILE
41
+
42
+
43
+ def _now_iso() -> str:
44
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
45
+
46
+
47
+ def load_resolutions(workspace_root: Path) -> dict[str, dict[str, Any]]:
48
+ """Read the resolution log. Returns an empty dict when no file exists."""
49
+ path = _state_path(workspace_root)
50
+ if not path.exists():
51
+ return {}
52
+ try:
53
+ with open(path, "r") as f:
54
+ data = json.load(f)
55
+ except (json.JSONDecodeError, OSError):
56
+ return {}
57
+ return data if isinstance(data, dict) else {}
58
+
59
+
60
+ def is_resolved(workspace_root: Path, comment_id: str | int) -> bool:
61
+ """True iff this comment id is in the resolution log."""
62
+ return str(comment_id) in load_resolutions(workspace_root)
63
+
64
+
65
+ def resolutions_for_feature(
66
+ workspace_root: Path, feature: str,
67
+ ) -> dict[str, dict[str, Any]]:
68
+ """Filter the log to entries tagged with this feature name."""
69
+ return {
70
+ cid: entry
71
+ for cid, entry in load_resolutions(workspace_root).items()
72
+ if entry.get("feature") == feature
73
+ }
74
+
75
+
76
+ def record_resolution(
77
+ workspace_root: Path,
78
+ *,
79
+ comment_id: str | int,
80
+ feature: str,
81
+ repo: str,
82
+ commit_sha: str,
83
+ comment_title: str,
84
+ comment_url: str = "",
85
+ addressed_at: str | None = None,
86
+ ) -> dict[str, Any]:
87
+ """Append a resolution entry. Last-write-wins on duplicate comment_id.
88
+
89
+ Returns the entry written. Creates the state directory if missing.
90
+ """
91
+ path = _state_path(workspace_root)
92
+ path.parent.mkdir(parents=True, exist_ok=True)
93
+
94
+ existing = load_resolutions(workspace_root)
95
+ entry = {
96
+ "feature": feature,
97
+ "repo": repo,
98
+ "commit_sha": commit_sha,
99
+ "addressed_at": addressed_at or _now_iso(),
100
+ "comment_title": comment_title,
101
+ "comment_url": comment_url,
102
+ }
103
+ existing[str(comment_id)] = entry
104
+ _atomic_write(path, existing)
105
+ return entry
106
+
107
+
108
+ def _atomic_write(path: Path, data: dict) -> None:
109
+ """Write JSON atomically: tmp file in same dir, then os.replace."""
110
+ fd, tmp = tempfile.mkstemp(
111
+ prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent),
112
+ )
113
+ try:
114
+ with os.fdopen(fd, "w") as f:
115
+ json.dump(data, f, indent=2, sort_keys=True)
116
+ f.write("\n")
117
+ os.replace(tmp, path)
118
+ except Exception:
119
+ try:
120
+ os.unlink(tmp)
121
+ except FileNotFoundError:
122
+ pass
123
+ raise
@@ -0,0 +1,133 @@
1
+ """Per-feature bot-comment rollup (M3).
2
+
3
+ Composes the live actionable bot threads (from ``feature_state._per_repo_facts``)
4
+ with the persistent resolution log (``bot_resolutions.json``) into a single
5
+ ``{feature, repos: {<repo>: {pr_number, total, resolved, unresolved, threads}},
6
+ all_resolved}`` shape that the CLI / MCP / dashboard share.
7
+
8
+ A "bot comment" here means an actionable thread whose author was classified as
9
+ a bot per ``feature_state._is_bot_comment`` (GitHub-typed bot AND, when
10
+ ``review_bots`` is set in canopy.toml augments, substring-matching the
11
+ configured list).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ from . import slots as slots_mod
18
+ from .aliases import repos_for_feature, resolve_feature
19
+ from .bot_resolutions import resolutions_for_feature
20
+ from .errors import BlockerError, FixAction
21
+ from .feature_state import _per_repo_facts, resolve_repo_paths
22
+ from ..workspace.workspace import Workspace
23
+
24
+
25
+ def bot_comments_status(
26
+ workspace: Workspace,
27
+ feature: str | None = None,
28
+ ) -> dict[str, Any]:
29
+ """Build the rollup. Falls back to the canonical feature when ``feature`` is None."""
30
+ feature_name = _resolve_feature_name(workspace, feature)
31
+ repo_branches = repos_for_feature(workspace, feature_name)
32
+ if not repo_branches:
33
+ raise BlockerError(
34
+ code="empty_feature",
35
+ what=f"feature '{feature_name}' has no associated repos",
36
+ )
37
+
38
+ repo_paths, _has_wt = resolve_repo_paths(workspace, feature_name, repo_branches)
39
+ facts = _per_repo_facts(workspace, feature_name, repo_branches, repo_paths)
40
+ resolutions = resolutions_for_feature(workspace.config.root, feature_name)
41
+
42
+ repos_out: dict[str, dict[str, Any]] = {}
43
+ all_resolved = True
44
+ any_bot_comment_seen = False
45
+
46
+ for repo_name, repo_facts in facts.items():
47
+ pr = repo_facts.get("pr") or {}
48
+ bot_threads = repo_facts.get("actionable_bot_threads", [])
49
+ # Resolution entries scoped to this repo (so we report a sensible
50
+ # `resolved` count per PR even when other repos have their own).
51
+ repo_resolutions = {
52
+ cid: entry
53
+ for cid, entry in resolutions.items()
54
+ if entry.get("repo") == repo_name
55
+ }
56
+ unresolved_threads = [
57
+ _thread_summary(t, resolved=False, resolution=None) for t in bot_threads
58
+ ]
59
+ resolved_threads = [
60
+ _resolved_summary(cid, entry)
61
+ for cid, entry in sorted(repo_resolutions.items())
62
+ ]
63
+ threads = resolved_threads + unresolved_threads
64
+
65
+ total = len(threads)
66
+ if total > 0:
67
+ any_bot_comment_seen = True
68
+ if unresolved_threads:
69
+ all_resolved = False
70
+
71
+ repos_out[repo_name] = {
72
+ "pr_number": pr.get("number"),
73
+ "pr_url": pr.get("url", ""),
74
+ "total": total,
75
+ "resolved": len(resolved_threads),
76
+ "unresolved": len(unresolved_threads),
77
+ "threads": threads,
78
+ }
79
+
80
+ return {
81
+ "feature": feature_name,
82
+ "repos": repos_out,
83
+ "all_resolved": all_resolved if any_bot_comment_seen else True,
84
+ "any_bot_comments": any_bot_comment_seen,
85
+ }
86
+
87
+
88
+ def _resolve_feature_name(workspace: Workspace, feature: str | None) -> str:
89
+ if feature:
90
+ return resolve_feature(workspace, feature)
91
+ state = slots_mod.read_state(workspace)
92
+ if state is None or state.canonical is None:
93
+ raise BlockerError(
94
+ code="no_canonical_feature",
95
+ what="no active feature; pass --feature or run `canopy switch <name>` first",
96
+ fix_actions=[
97
+ FixAction(action="switch", args={}, safe=False,
98
+ preview="canopy switch <feature> sets the canonical slot"),
99
+ ],
100
+ )
101
+ return state.canonical.feature
102
+
103
+
104
+ def _thread_summary(
105
+ thread: dict, *, resolved: bool, resolution: dict | None,
106
+ ) -> dict[str, Any]:
107
+ out = {
108
+ "id": thread.get("id"),
109
+ "author": thread.get("author", ""),
110
+ "path": thread.get("path", ""),
111
+ "line": thread.get("line", 0),
112
+ "url": thread.get("url", ""),
113
+ "body_preview": (thread.get("body") or "").splitlines()[0][:120] if thread.get("body") else "",
114
+ "resolved": resolved,
115
+ }
116
+ if resolution:
117
+ out["resolved_by_commit"] = resolution.get("commit_sha", "")
118
+ out["addressed_at"] = resolution.get("addressed_at", "")
119
+ return out
120
+
121
+
122
+ def _resolved_summary(comment_id: str, entry: dict) -> dict[str, Any]:
123
+ return {
124
+ "id": comment_id,
125
+ "author": "",
126
+ "path": "",
127
+ "line": 0,
128
+ "url": entry.get("comment_url", ""),
129
+ "body_preview": entry.get("comment_title", ""),
130
+ "resolved": True,
131
+ "resolved_by_commit": entry.get("commit_sha", ""),
132
+ "addressed_at": entry.get("addressed_at", ""),
133
+ }