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,197 @@
1
+ """
2
+ Auto-detect Git repositories in a directory and generate canopy.toml.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ from collections import Counter
7
+ from pathlib import Path
8
+
9
+ from .config import RepoConfig
10
+ from ..git import repo as git
11
+
12
+
13
+ # Extension → language mapping (by frequency)
14
+ _LANG_MAP: dict[str, str] = {
15
+ ".py": "python",
16
+ ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript",
17
+ ".ts": "typescript", ".tsx": "typescript",
18
+ ".java": "java",
19
+ ".kt": "kotlin", ".kts": "kotlin",
20
+ ".go": "go",
21
+ ".rs": "rust",
22
+ ".rb": "ruby",
23
+ ".swift": "swift",
24
+ ".dart": "dart",
25
+ ".c": "c", ".h": "c",
26
+ ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp",
27
+ ".cs": "csharp",
28
+ ".scala": "scala",
29
+ ".php": "php",
30
+ ".lua": "lua",
31
+ ".ex": "elixir", ".exs": "elixir",
32
+ }
33
+
34
+ _SKIP_DIRS = {
35
+ ".git", "node_modules", "__pycache__", "vendor", "dist",
36
+ "build", ".venv", "venv", ".tox", "target",
37
+ }
38
+
39
+
40
+ def discover_repos(root: Path) -> list[RepoConfig]:
41
+ """Walk immediate children of root and find Git repositories.
42
+
43
+ For each repo found, detect primary language and default branch.
44
+ """
45
+ root = root.resolve()
46
+ repos = []
47
+
48
+ for child in sorted(root.iterdir()):
49
+ if not child.is_dir():
50
+ continue
51
+ if child.name.startswith("."):
52
+ continue
53
+
54
+ # Check if it's a git repo — .git can be a directory (normal)
55
+ # or a file (linked worktree)
56
+ git_dir = child / ".git"
57
+ if not git_dir.exists():
58
+ continue
59
+
60
+ lang = _detect_language(child)
61
+ default_branch = _detect_default_branch(child)
62
+ role = _guess_role(child.name, lang)
63
+
64
+ # Detect worktree status
65
+ is_linked_worktree = git_dir.is_file()
66
+ worktree_main = None
67
+ if is_linked_worktree:
68
+ main_path = git.worktree_main_path(child)
69
+ if main_path:
70
+ worktree_main = str(main_path)
71
+
72
+ repos.append(RepoConfig(
73
+ name=child.name,
74
+ path=f"./{child.name}",
75
+ role=role,
76
+ lang=lang,
77
+ default_branch=default_branch,
78
+ is_worktree=is_linked_worktree,
79
+ worktree_main=worktree_main,
80
+ ))
81
+
82
+ return repos
83
+
84
+
85
+ def generate_toml(root: Path, workspace_name: str | None = None) -> str:
86
+ """Generate a canopy.toml string for the given root directory."""
87
+ repos = discover_repos(root)
88
+ name = workspace_name or root.name
89
+
90
+ lines = [
91
+ "[workspace]",
92
+ f'name = "{name}"',
93
+ "",
94
+ ]
95
+
96
+ for repo in repos:
97
+ lines.append("[[repos]]")
98
+ lines.append(f'name = "{repo.name}"')
99
+ lines.append(f'path = "{repo.path}"')
100
+ if repo.role:
101
+ lines.append(f'role = "{repo.role}"')
102
+ if repo.lang:
103
+ lines.append(f'lang = "{repo.lang}"')
104
+ if repo.default_branch != "main":
105
+ lines.append(f'default_branch = "{repo.default_branch}"')
106
+ lines.append("")
107
+
108
+ return "\n".join(lines)
109
+
110
+
111
+ def _detect_language(repo_path: Path) -> str:
112
+ """Detect primary language by file extension frequency."""
113
+ counts: Counter[str] = Counter()
114
+
115
+ for item in repo_path.rglob("*"):
116
+ if any(skip in item.parts for skip in _SKIP_DIRS):
117
+ continue
118
+ if item.is_file() and item.suffix in _LANG_MAP:
119
+ counts[_LANG_MAP[item.suffix]] += 1
120
+
121
+ if not counts:
122
+ return ""
123
+ return counts.most_common(1)[0][0]
124
+
125
+
126
+ def _detect_default_branch(repo_path: Path) -> str:
127
+ """Detect the default branch from local refs.
128
+
129
+ Works for both normal repos (.git is a directory) and linked
130
+ worktrees (.git is a file pointing to the main repo).
131
+ """
132
+ git_path = repo_path / ".git"
133
+
134
+ # For linked worktrees, use the git command directly since
135
+ # the .git directory structure is different
136
+ if git_path.is_file():
137
+ return git.default_branch(repo_path)
138
+
139
+ # Normal repo: check remote HEAD
140
+ head_ref = git_path / "refs" / "remotes" / "origin" / "HEAD"
141
+ if head_ref.exists():
142
+ try:
143
+ content = head_ref.read_text().strip()
144
+ # "ref: refs/remotes/origin/main"
145
+ if content.startswith("ref:"):
146
+ return content.split("/")[-1]
147
+ except (OSError, IndexError):
148
+ pass
149
+
150
+ # Check local refs for common default branch names
151
+ for candidate in ("main", "master"):
152
+ ref_path = git_path / "refs" / "heads" / candidate
153
+ if ref_path.exists():
154
+ return candidate
155
+
156
+ # Fallback: parse HEAD for current branch
157
+ head_file = git_path / "HEAD"
158
+ if head_file.exists():
159
+ try:
160
+ content = head_file.read_text().strip()
161
+ if content.startswith("ref: refs/heads/"):
162
+ return content.replace("ref: refs/heads/", "")
163
+ except OSError:
164
+ pass
165
+
166
+ return "main"
167
+
168
+
169
+ def _guess_role(name: str, lang: str) -> str:
170
+ """Guess repo role from name and language."""
171
+ name_lower = name.lower()
172
+
173
+ backend_hints = {"api", "backend", "server", "service", "core"}
174
+ frontend_hints = {"ui", "frontend", "web", "client", "app"}
175
+ shared_hints = {"shared", "common", "types", "proto", "lib"}
176
+ infra_hints = {"infra", "infrastructure", "deploy", "ops", "terraform"}
177
+
178
+ for hint in backend_hints:
179
+ if hint in name_lower:
180
+ return "backend"
181
+ for hint in frontend_hints:
182
+ if hint in name_lower:
183
+ return "frontend"
184
+ for hint in shared_hints:
185
+ if hint in name_lower:
186
+ return "shared"
187
+ for hint in infra_hints:
188
+ if hint in name_lower:
189
+ return "infra"
190
+
191
+ # Language-based guess
192
+ if lang in ("python", "java", "go", "rust"):
193
+ return "backend"
194
+ if lang in ("javascript", "typescript"):
195
+ return "frontend"
196
+
197
+ return ""
@@ -0,0 +1,173 @@
1
+ """
2
+ Workspace — the central orchestration object.
3
+
4
+ Holds the workspace config and provides access to per-repo Git state.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from .config import WorkspaceConfig, RepoConfig
12
+ from ..git import repo as git
13
+
14
+
15
+ @dataclass
16
+ class RepoState:
17
+ """Live Git state for a single repository."""
18
+ config: RepoConfig
19
+ abs_path: Path
20
+ current_branch: str = ""
21
+ head_sha: str = ""
22
+ is_dirty: bool = False
23
+ dirty_count: int = 0
24
+ remote_url: str = ""
25
+
26
+ # Populated by enrich()
27
+ ahead_of_default: int = 0
28
+ behind_default: int = 0
29
+ changed_files: list[str] = field(default_factory=list)
30
+
31
+ def to_dict(self) -> dict:
32
+ d = {
33
+ "name": self.config.name,
34
+ "path": str(self.abs_path),
35
+ "role": self.config.role,
36
+ "lang": self.config.lang,
37
+ "current_branch": self.current_branch,
38
+ "head_sha": self.head_sha,
39
+ "is_dirty": self.is_dirty,
40
+ "dirty_count": self.dirty_count,
41
+ "remote_url": self.remote_url,
42
+ "default_branch": self.config.default_branch,
43
+ "ahead_of_default": self.ahead_of_default,
44
+ "behind_default": self.behind_default,
45
+ "changed_files": self.changed_files,
46
+ "changed_file_count": len(self.changed_files),
47
+ }
48
+ if self.config.is_worktree:
49
+ d["is_worktree"] = True
50
+ d["worktree_main"] = self.config.worktree_main
51
+ return d
52
+
53
+
54
+ class Workspace:
55
+ """Central workspace object.
56
+
57
+ Reads workspace config, discovers Git state for all repos,
58
+ and provides cross-repo queries.
59
+ """
60
+
61
+ def __init__(self, config: WorkspaceConfig):
62
+ self.config = config
63
+ self.repos: list[RepoState] = []
64
+ self._refresh_basic()
65
+
66
+ def _refresh_basic(self) -> None:
67
+ """Read basic Git state for all repos."""
68
+ self.repos = []
69
+ for rc in self.config.repos:
70
+ abs_path = (self.config.root / rc.path).resolve()
71
+ state = RepoState(
72
+ config=rc,
73
+ abs_path=abs_path,
74
+ )
75
+ if abs_path.exists():
76
+ try:
77
+ state.current_branch = git.current_branch(abs_path)
78
+ state.head_sha = git.short_sha(abs_path)
79
+ state.is_dirty = git.is_dirty(abs_path)
80
+ state.dirty_count = git.dirty_file_count(abs_path)
81
+ state.remote_url = git.remote_url(abs_path)
82
+ except git.GitError:
83
+ pass # repo exists but git commands failed
84
+ self.repos.append(state)
85
+
86
+ def refresh(self) -> None:
87
+ """Re-read Git state for all repos, including divergence."""
88
+ self._refresh_basic()
89
+ self.enrich()
90
+
91
+ def enrich(self) -> None:
92
+ """Enrich repo states with divergence and changed file data."""
93
+ for state in self.repos:
94
+ if not state.abs_path.exists():
95
+ continue
96
+ branch = state.current_branch
97
+ base = state.config.default_branch
98
+ if branch and branch != base and branch != "(detached)":
99
+ try:
100
+ ahead, behind = git.divergence(state.abs_path, branch, base)
101
+ state.ahead_of_default = ahead
102
+ state.behind_default = behind
103
+ state.changed_files = git.changed_files(
104
+ state.abs_path, branch, base
105
+ )
106
+ except git.GitError:
107
+ pass
108
+
109
+ def get_repo(self, name: str) -> RepoState:
110
+ """Get a repo state by name. Raises KeyError if not found."""
111
+ for state in self.repos:
112
+ if state.config.name == name:
113
+ return state
114
+ raise KeyError(f"No repo named '{name}' in workspace")
115
+
116
+ def active_features(self) -> list[str]:
117
+ """Find branch names that exist in 2+ repos.
118
+
119
+ These are candidate feature lanes — branches that were probably
120
+ created together across repos for coordinated work.
121
+ """
122
+ from collections import Counter
123
+ branch_counts: Counter[str] = Counter()
124
+ default_branches = {rc.default_branch for rc in self.config.repos}
125
+
126
+ for state in self.repos:
127
+ branch = state.current_branch
128
+ if branch and branch not in default_branches and branch != "(detached)":
129
+ branch_counts[branch] += 1
130
+
131
+ # Also check all local branches in each repo
132
+ for state in self.repos:
133
+ if not state.abs_path.exists():
134
+ continue
135
+ try:
136
+ for branch in git.branches(state.abs_path):
137
+ if branch not in default_branches:
138
+ branch_counts[branch] += 1
139
+ except git.GitError:
140
+ pass
141
+
142
+ # Branches in 2+ repos are candidate features
143
+ # Deduplicate: we may have counted a branch twice (once from current_branch,
144
+ # once from branches list) — use set-based counting
145
+ branch_repos: dict[str, set[str]] = {}
146
+ for state in self.repos:
147
+ if not state.abs_path.exists():
148
+ continue
149
+ try:
150
+ repo_branches = set(git.branches(state.abs_path))
151
+ except git.GitError:
152
+ repo_branches = set()
153
+ if state.current_branch:
154
+ repo_branches.add(state.current_branch)
155
+
156
+ for branch in repo_branches:
157
+ if branch not in default_branches and branch != "(detached)":
158
+ branch_repos.setdefault(branch, set()).add(state.config.name)
159
+
160
+ return sorted(
161
+ branch
162
+ for branch, repos in branch_repos.items()
163
+ if len(repos) >= 2
164
+ )
165
+
166
+ def to_dict(self) -> dict:
167
+ """Serialize workspace state to a dict (for --json output)."""
168
+ return {
169
+ "name": self.config.name,
170
+ "root": str(self.config.root),
171
+ "repos": [r.to_dict() for r in self.repos],
172
+ "active_features": self.active_features(),
173
+ }
@@ -0,0 +1,282 @@
1
+ Metadata-Version: 2.4
2
+ Name: canopy-cli
3
+ Version: 3.1.0
4
+ Summary: Workspace-first development orchestrator for AI coding agents
5
+ Project-URL: Homepage, https://github.com/ashmitb95/canopy
6
+ Project-URL: Source, https://github.com/ashmitb95/canopy
7
+ Project-URL: Issues, https://github.com/ashmitb95/canopy/issues
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Version Control :: Git
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: mcp>=1.0
19
+ Requires-Dist: rich>=13.0
20
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest-cov; extra == 'dev'
23
+ Requires-Dist: pytest>=7.0; extra == 'dev'
24
+ Provides-Extra: graph
25
+ Requires-Dist: networkx>=3.0; extra == 'graph'
26
+ Requires-Dist: tree-sitter>=0.21; extra == 'graph'
27
+ Description-Content-Type: text/markdown
28
+
29
+ <p align="center">
30
+ <img src="docs/canopy-banner.svg" alt="canopy — typed multi-repo work for AI coding agents" width="600">
31
+ </p>
32
+
33
+ <p align="center">
34
+ <em>The typed multi-repo MCP server your AI coding agent needs.</em>
35
+ </p>
36
+
37
+ <p align="center">
38
+ <img alt="Python 3.10+" src="https://img.shields.io/badge/python-3.10%2B-blue?style=flat-square&logo=python&logoColor=white">
39
+ <img alt="Tests" src="https://img.shields.io/badge/tests-857%20passing-brightgreen?style=flat-square">
40
+ <img alt="MCP Tools" src="https://img.shields.io/badge/MCP%20tools-67-purple?style=flat-square">
41
+ <a href="https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy"><img alt="VSCode Extension" src="https://img.shields.io/badge/VSCode-extension-blue?style=flat-square&logo=visualstudiocode"></a>
42
+ <img alt="License MIT" src="https://img.shields.io/badge/license-MIT-gray?style=flat-square">
43
+ </p>
44
+
45
+ ---
46
+
47
+ Canopy is built for workspaces with **multiple repos that share a feature lifecycle** — backend + frontend, api + mobile, a monolith plus its services. That setting breaks coding agents in specific, fixable ways: shell state doesn't survive between tool calls, paths get constructed wrong, drift accumulates silently between repos, and PR review work pulls the agent across repo boundaries faster than its context can keep up.
48
+
49
+ Canopy gives the agent a typed contract for that setting — `feature` / `repo` / alias inputs, structured outputs, recoverable errors — so it can drive multi-repo feature work end-to-end without ever shelling `cd /wrong/repo`.
50
+
51
+ ```python
52
+ # Without canopy: brittle paths, parsed stderr, no shared state across repos.
53
+ bash("cd /Users/.../web/api && git status")
54
+ bash("cd /Users/.../web/ui && git status")
55
+ bash("gh pr list --author @me --json number,title")
56
+ # ... then per-thread "is this still actionable?" logic in the agent's head
57
+
58
+ # With canopy: one typed call, structured multi-repo response, recoverable error.
59
+ mcp__canopy__feature_state(feature="auth-flow")
60
+ # → { "state": "needs_work",
61
+ # "next_actions": ["address_review_comments"],
62
+ # "summary": {
63
+ # "ci_aggregate": "passing",
64
+ # "actionable_human_count": 2,
65
+ # "repos": {
66
+ # "api": { "dirty_file_count": 3, "ahead": 2, "behind": 0, "pr": {...} },
67
+ # "ui": { "dirty_file_count": 0, "ahead": 0, "behind": 0, "pr": {...} }
68
+ # }
69
+ # }
70
+ # }
71
+ ```
72
+
73
+ The CLI is the surface humans use to drive the same primitives. Same JSON, two consumers.
74
+
75
+ ## Why multi-repo work breaks coding agents
76
+
77
+ Each `mcp__canopy__*` tool closes one failure mode that agents reliably hit when the workspace has more than one repo:
78
+
79
+ | Failure mode | Canopy's fix |
80
+ |---|---|
81
+ | **Shell state evaporates between tool calls.** `cd /repo-a && command` doesn't persist; the next call lands somewhere else. Multi-repo makes this worse because there's more than one "right" place to land. | Every canopy tool takes `feature` / `repo` / alias as parameters; path resolution lives inside canopy. The agent has no surface area to type the path. |
82
+ | **Cross-repo state is invisible.** `git status` in one repo doesn't tell you what's happening in the other. The agent has to query each repo separately and stitch the picture. | `mcp__canopy__feature_state(feature)` returns the full multi-repo picture in one call: per-repo dirty/ahead/behind, PRs, CI, computed state, prioritized next actions. |
83
+ | **Drift between tool calls.** The agent `git checkout`'d X in one repo, the next call assumes the OTHER repo is also on X; things go sideways. | Per-repo post-checkout hooks write `.canopy/state/heads.json` (fcntl-locked, atomic-renamed). `mcp__canopy__drift` reads cached state in <50ms. The agent sees misalignments that happened between calls, even when it didn't cause them. |
84
+ | **Session re-derivation.** Each new chat re-walks `gh pr list`, `git status` per repo, comment threads, CI status — burning context on bookkeeping the previous chat already did. | `mcp__canopy__feature_resume(alias)` is one call: alias → switch focus if needed → refresh GH+Linear → return structured brief of what changed since last visit. Cross-session state via `.canopy/state/visits.json` + per-feature memory at `.canopy/memory/<feature>.md`. |
85
+ | **PR review churn across repos.** A feature with two PRs (one per repo) accumulates threads on both; the agent re-classifies "is this still actionable?" every turn. | `mcp__canopy__github_get_pr_comments(alias)` returns threads pre-bucketed as `actionable` / `likely_resolved` via temporal filtering (comment timestamp vs commits-on-file-since). Resolved threads carry `by_canopy: true` attribution when canopy itself closed them. |
86
+ | **Closing GH threads needs raw GraphQL.** REST has no thread IDs; agents fumble with `gh api graphql` query strings. | `mcp__canopy__resolve_thread(thread_id)`, `mcp__canopy__reply_to_thread(thread_id, body, resolve_after=True)`, and `mcp__canopy__commit(address=<id>, resolve_thread=True)` handle the wire format and log resolutions locally for attribution. |
87
+ | **Juggling 2–3 features in parallel** loses in-progress work to forgotten stashes or breaks when one repo gets `git checkout`'d alone. | The slot model (Wave 3.0): each feature lives in `canonical` / `warm` / `cold`. `mcp__canopy__switch(feature)` rotates focus atomically across every repo in the feature's lane, evacuating the previous canonical into a warm slot with `stash → checkout → pop`. |
88
+ | **Errors come back as stderr text.** Agents have to parse English failure messages to decide recovery. | Structured `BlockerError(code, what, expected, actual, fix_actions)`, each fix carrying `safe: bool` so the agent knows what's auto-runnable vs needs human confirmation. |
89
+
90
+ ## Install
91
+
92
+ Requires Python 3.10+.
93
+
94
+ ```bash
95
+ pipx install git+https://github.com/ashmitb95/canopy.git
96
+ cd ~/your-multi-repo-workspace
97
+ canopy init
98
+ ```
99
+
100
+ If you don't have pipx: `brew install pipx && pipx ensurepath`.
101
+
102
+ `canopy init` does four things:
103
+ 1. Discovers your git repos and writes `canopy.toml`.
104
+ 2. Installs the drift-detection post-checkout git hook in every repo.
105
+ 3. **Wires the canopy MCP server into Claude Code** by writing a `.mcp.json` entry — this is what makes the agent surface live.
106
+ 4. Installs the `using-canopy` skill at `~/.claude/skills/using-canopy/SKILL.md` so the agent knows when to reach for canopy tools.
107
+
108
+ Skip the agent bits with `--no-agent` if you're just using the CLI.
109
+
110
+ <p align="center">
111
+ <img src="docs/cli-init.svg" alt="canopy init" width="720">
112
+ </p>
113
+
114
+ ## The 67-tool surface
115
+
116
+ Every CLI command has an `mcp__canopy__*` MCP equivalent returning the same JSON. The MCP server is the load-bearing surface for agents; the CLI is the side benefit for humans. Tools by topic:
117
+
118
+ ### Session-start + state
119
+
120
+ | Tool | What it does |
121
+ |---|---|
122
+ | `feature_resume(alias)` | The headline primitive. Resolves alias → switches canonical if needed → refreshes GitHub + Linear → returns the structured brief (`since_last_visit`, `current_state`, `intent_hints`). Call this first when a chat opens on a feature. |
123
+ | `feature_state(feature)` | 9-state machine (`drifted`, `needs_work`, `awaiting_bot_resolution`, `in_progress`, `ready_to_commit`, `ready_to_push`, `awaiting_review`, `approved`, `no_prs`) + `next_actions` array. Drives the agent's decision tree. |
124
+ | `triage` | Cross-feature priority view. Returns features ordered by review-state urgency. |
125
+ | `slots(rich=True)` | Dashboard data — canonical + every warm slot with per-repo branch, dirty, ahead/behind, PR, CI, bot threads, Linear, computed `feature_state`. |
126
+
127
+ ### Focus management (the slot model)
128
+
129
+ | Tool | What it does |
130
+ |---|---|
131
+ | `switch(feature)` | Promote a feature into the canonical slot. Previous canonical evacuates into a warm slot (active rotation, default) or goes cold with feature-tagged stash (`release_current=True`). Atomic across every repo in the feature's lane. |
132
+ | `slot_load(feature, slot_id?)` | Warm a cold feature into a slot **without** changing canonical. Use for pre-warming or inspecting a feature you're not ready to focus on. |
133
+ | `slot_clear(slot_id)` | Vacate a slot to cold (feature-tagged stash if dirty). The slot remains, just empty. |
134
+ | `slot_swap(slot_a, slot_b)` | Exchange the occupants of two warm slots. |
135
+ | `migrate_slots()` | One-shot migration from pre-3.0 layouts. |
136
+
137
+ ### PR review work
138
+
139
+ | Tool | What it does |
140
+ |---|---|
141
+ | `github_get_pr_comments(alias)` | Returns `actionable_threads` + `likely_resolved_threads` per repo. Temporal filter has already classified what's worth the agent's attention. |
142
+ | `resolve_thread(thread_id, feature?)` | Close a GH review thread via GraphQL + log to `.canopy/state/thread_resolutions.json` for attribution. |
143
+ | `reply_to_thread(thread_id, body, feature?, resolve_after=False)` | Post a reply; optionally close after. |
144
+ | `commit(message, feature?, address=<id>, resolve_thread=False)` | Commit across the feature's repos. With `address=<comment_id>`, auto-suffixes the message + logs to `bot_resolutions.json`. With `resolve_thread=True`, closes the corresponding GH thread. |
145
+ | `bot_comments_status(feature)` | Per-PR bot-comment rollup: total / resolved / unresolved. |
146
+ | `draft_replies(feature)` | File-history-based addressed-comment detector + reply templates. |
147
+
148
+ ### Operate across repos without `cd`
149
+
150
+ | Tool | What it does |
151
+ |---|---|
152
+ | `preflight(feature?)` | Run each repo's preflight hooks (or `[augments] preflight_cmd` override). Records result for the state machine. |
153
+ | `push(feature?)` | Push across every repo in the feature's lane. `set_upstream=True` on first push. |
154
+ | `run(repo, command)` | Path-safe shell exec. Canopy resolves the cwd to the right repo dir; the agent never types a path. |
155
+
156
+ ### Read + alias resolution
157
+
158
+ | Tool | What it does |
159
+ |---|---|
160
+ | `linear_get_issue(alias)`, `linear_my_issues` | Linear issue data via the issue-provider abstraction. |
161
+ | `github_get_pr(alias)`, `github_get_branch(alias)` | PR + branch data. |
162
+ | `issue_get(alias)` | Provider-agnostic issue read (Linear or GitHub Issues). |
163
+
164
+ Every read tool accepts the same alias forms:
165
+ - Feature name: `auth-flow`
166
+ - Linear issue ID: `TEAM-101` (resolves via lane's `linear_issue`)
167
+ - Specific PR: `<repo>#<n>` like `api#142`
168
+ - PR URL: `https://github.com/owner/repo/pull/142`
169
+ - Specific branch: `<repo>:<branch>`
170
+ - Slot id: `worktree-1`, `worktree-2`, ... (resolves to the slot's current occupant)
171
+
172
+ ### Recovery
173
+
174
+ | Tool | What it does |
175
+ |---|---|
176
+ | `doctor` | 21 diagnostic codes across 12 categories of state-file drift + install staleness. Each issue carries `severity`, `expected` / `actual`, and an `auto_fixable` flag. `doctor(fix=True)` runs the safe auto-fixes. First call when something feels off. |
177
+ | `version` | `{cli_version, mcp_version, schema_version}` handshake. Doctor reports `cli_stale` / `mcp_stale` when these drift. |
178
+
179
+ ### Cross-session memory
180
+
181
+ `feature_memory(feature)`, `historian_decide(feature, decisions)`, `historian_pause(feature, reason)`, `historian_defer_comment(feature, comment_id, reason)`, `historian_compact(feature, keep_sessions)` — persistent per-feature memory at `.canopy/memory/<feature>.md`. Auto-captured by `commit --address` and `github_get_pr_comments`. Read on `switch` to recover prior session context without re-deriving.
182
+
183
+ Full reference: [docs/mcp.md](docs/mcp.md).
184
+
185
+ ## The slot model
186
+
187
+ Every feature lives in one of three states:
188
+
189
+ - **canonical** — checked out in the main repo dirs. Exactly one canonical feature at a time across all repos. **This is the only place to run code.** Worktrees are passive branch storage; never `cd` into them to launch the app.
190
+ - **warm** — sits in a numbered slot at `.canopy/worktrees/worktree-N/<repo>/`. Slot identity (`worktree-1`, `worktree-2`, ...) is stable across feature swaps; feature occupancy is transient. Capped by `[workspace] slots = N` in canopy.toml (default 2).
191
+ - **cold** — branch only, no checkout. Cheap and unlimited.
192
+
193
+ `switch(Y)` is the single primitive that moves features between these states:
194
+
195
+ - **Active rotation (default):** previous canonical evacuates into a warm slot via `stash → checkout → pop`. Instant to switch back.
196
+ - **Wind-down (`release_current=True`):** previous goes cold with a feature-tagged stash.
197
+
198
+ When the cap fires, `switch` returns `BlockerError(code='worktree_cap_reached')` with explicit `fix_actions` (evict a specific slot, wind down the current focus, raise the cap). **No silent eviction.**
199
+
200
+ Full design: [docs/concepts.md §4](docs/concepts.md#4-the-slot-model).
201
+
202
+ ## Structured errors
203
+
204
+ Every error is a typed payload — agents don't parse stderr.
205
+
206
+ ```json
207
+ {
208
+ "status": "blocked",
209
+ "code": "drift_detected",
210
+ "what": "branches don't match feature lane 'auth-flow'",
211
+ "expected": {"branches": {"api": "auth-flow", "ui": "auth-flow"}},
212
+ "actual": {"branches": {"api": "auth-flow", "ui": "main"}},
213
+ "fix_actions": [
214
+ {"action": "switch", "args": {"feature": "auth-flow"}, "safe": true,
215
+ "preview": "promote auth-flow to canonical across all repos"}
216
+ ]
217
+ }
218
+ ```
219
+
220
+ The agent reads `fix_actions[0]`, checks `safe: true`, calls `mcp__canopy__switch(feature="auth-flow")`. The CLI renders the same payload as colored multi-line output via [`cli/render.py`](src/canopy/cli/render.py). Single source of truth, two surfaces.
221
+
222
+ ## Agent integration
223
+
224
+ `canopy init` installs the `using-canopy` skill at `~/.claude/skills/using-canopy/SKILL.md` (per-user) and writes `.mcp.json` so Claude Code spawns the canopy MCP server in this workspace. The skill teaches the agent when to reach for canopy:
225
+
226
+ - See a feature alias or issue ID as the first non-trivial token? Call `feature_resume(alias)` before doing anything else.
227
+ - About to `cd <repo> && command`? Use `mcp__canopy__run(repo, command)` or the feature-aware verb.
228
+ - About to `gh api graphql` for thread mutations? Use `resolve_thread` / `reply_to_thread` / `commit --address --resolve-thread`.
229
+ - See an unfamiliar error? Call `doctor` first.
230
+
231
+ Opt-in extra skills via `canopy setup-agent --skill <name>`:
232
+ - [`augment-canopy`](src/canopy/agent_setup/skills/augment-canopy/SKILL.md) — teaches the agent the `canopy.toml [augments]` schema so it can configure `preflight_cmd`, `review_bots`, `auto_resolve_threads_on_address`, etc. on the user's behalf.
233
+
234
+ GitHub access works via the `gh` CLI fallback when no `github` MCP server is configured. Linear works via OAuth (browser flow once, no API key).
235
+
236
+ ## For humans
237
+
238
+ The same primitives are available as a CLI. Daily commands:
239
+
240
+ ```bash
241
+ canopy resume <feature> # session start — print the brief
242
+ canopy switch <feature> # focus — promote to canonical
243
+ canopy status # workspace-wide rollup
244
+ canopy state <feature> # 9-state + next_actions
245
+ canopy triage # cross-feature priority
246
+ canopy preflight # run hooks across the feature's repos
247
+ canopy commit -m "..." # commit across repos at once
248
+ canopy push # push across repos at once
249
+ canopy slots --rich # dashboard data
250
+ canopy doctor # diagnose drift / staleness
251
+ ```
252
+
253
+ <p align="center">
254
+ <img src="docs/cli-state.svg" alt="canopy state" width="720">
255
+ </p>
256
+
257
+ The CLI and MCP server are thin wrappers over the same actions — `canopy state X` and `mcp__canopy__feature_state(feature='X')` return identical bytes. There's also a [VSCode extension](https://marketplace.visualstudio.com/items?itemName=SingularityInc.canopy) reading the same state the agent reads.
258
+
259
+ Full CLI reference: [docs/commands.md](docs/commands.md).
260
+
261
+ ## Docs
262
+
263
+ - [Concepts](docs/concepts.md) — the action framework, agent context contract, 9-state machine, slot model, resume brief
264
+ - [Agents](docs/agents.md) — skill install, integration recipes, the agent tool loop
265
+ - [Commands](docs/commands.md) — full CLI reference
266
+ - [MCP](docs/mcp.md) — server tool list, client transports (stdio + HTTP/OAuth), gh fallback
267
+ - [Workspace](docs/workspace.md) — `canopy.toml`, `features.json`, state files
268
+ - [Architecture](docs/architecture.md) — module boundaries, runtime pathways, state files
269
+ - [Providers](docs/architecture/providers.md) — issue-provider abstraction (Linear, GitHub Issues)
270
+
271
+ ## Develop
272
+
273
+ ```bash
274
+ git clone https://github.com/ashmitb95/canopy.git ~/projects/canopy
275
+ cd ~/projects/canopy
276
+ pip install -e ".[dev]"
277
+ pytest tests/ -v # 857 tests, ~225s, all use real temporary Git repos
278
+ ```
279
+
280
+ ## License
281
+
282
+ MIT