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.
- canopy/__init__.py +2 -0
- canopy/actions/__init__.py +32 -0
- canopy/actions/aliases.py +421 -0
- canopy/actions/augments.py +55 -0
- canopy/actions/bootstrap.py +249 -0
- canopy/actions/bot_resolutions.py +123 -0
- canopy/actions/bot_status.py +133 -0
- canopy/actions/commit.py +511 -0
- canopy/actions/conflicts.py +314 -0
- canopy/actions/doctor.py +1459 -0
- canopy/actions/draft_replies.py +185 -0
- canopy/actions/drift.py +241 -0
- canopy/actions/errors.py +115 -0
- canopy/actions/evacuate.py +192 -0
- canopy/actions/feature_state.py +607 -0
- canopy/actions/historian.py +612 -0
- canopy/actions/ide_workspace.py +49 -0
- canopy/actions/last_visit.py +83 -0
- canopy/actions/migrate_slots.py +313 -0
- canopy/actions/preflight_state.py +97 -0
- canopy/actions/push.py +199 -0
- canopy/actions/reads.py +304 -0
- canopy/actions/resume.py +582 -0
- canopy/actions/review_filter.py +135 -0
- canopy/actions/ship.py +399 -0
- canopy/actions/slot_details.py +208 -0
- canopy/actions/slot_load.py +383 -0
- canopy/actions/slots.py +221 -0
- canopy/actions/stash.py +230 -0
- canopy/actions/switch.py +775 -0
- canopy/actions/switch_preflight.py +192 -0
- canopy/actions/thread_actions.py +88 -0
- canopy/actions/thread_resolutions.py +101 -0
- canopy/actions/triage.py +286 -0
- canopy/agent/__init__.py +5 -0
- canopy/agent/runner.py +129 -0
- canopy/agent_setup/__init__.py +264 -0
- canopy/agent_setup/skills/augment-canopy/SKILL.md +116 -0
- canopy/agent_setup/skills/using-canopy/SKILL.md +191 -0
- canopy/cli/__init__.py +0 -0
- canopy/cli/main.py +4152 -0
- canopy/cli/render.py +98 -0
- canopy/cli/ui.py +150 -0
- canopy/features/__init__.py +2 -0
- canopy/features/coordinator.py +1256 -0
- canopy/git/__init__.py +0 -0
- canopy/git/hooks.py +173 -0
- canopy/git/multi.py +435 -0
- canopy/git/repo.py +859 -0
- canopy/git/templates/post-checkout.py +67 -0
- canopy/graph/__init__.py +0 -0
- canopy/integrations/__init__.py +0 -0
- canopy/integrations/github.py +983 -0
- canopy/integrations/linear.py +307 -0
- canopy/integrations/precommit.py +239 -0
- canopy/mcp/__init__.py +0 -0
- canopy/mcp/client.py +329 -0
- canopy/mcp/server.py +1797 -0
- canopy/providers/__init__.py +105 -0
- canopy/providers/github_issues.py +289 -0
- canopy/providers/linear.py +341 -0
- canopy/providers/types.py +149 -0
- canopy/workspace/__init__.py +4 -0
- canopy/workspace/config.py +378 -0
- canopy/workspace/context.py +224 -0
- canopy/workspace/discovery.py +197 -0
- canopy/workspace/workspace.py +173 -0
- canopy_cli-3.1.0.dist-info/METADATA +282 -0
- canopy_cli-3.1.0.dist-info/RECORD +71 -0
- canopy_cli-3.1.0.dist-info/WHEEL +4 -0
- canopy_cli-3.1.0.dist-info/entry_points.txt +3 -0
canopy/agent/runner.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Directory-safe shell exec for agents.
|
|
2
|
+
|
|
3
|
+
Eliminates the agent's ``cd <wrong-dir> && command`` mistake class by
|
|
4
|
+
making the agent pass ``(repo, command, feature?)`` semantically.
|
|
5
|
+
canopy resolves the working directory itself.
|
|
6
|
+
|
|
7
|
+
Trust boundary: the command string is shell-executed without
|
|
8
|
+
sanitization. The agent IS the trust boundary. The point of this tool
|
|
9
|
+
is path correctness, not command sandboxing.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from ..actions.errors import BlockerError, FailedError, FixAction
|
|
19
|
+
from ..workspace.workspace import Workspace
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_in_repo(
|
|
23
|
+
workspace: Workspace,
|
|
24
|
+
repo: str,
|
|
25
|
+
command: str,
|
|
26
|
+
feature: str | None = None,
|
|
27
|
+
timeout_seconds: int = 60,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""Run a shell command in a canopy-managed repo or worktree.
|
|
30
|
+
|
|
31
|
+
Resolution: if ``feature`` is set and a worktree exists for
|
|
32
|
+
``(feature, repo)``, the command runs in the worktree. Otherwise it
|
|
33
|
+
runs in the repo's main path.
|
|
34
|
+
|
|
35
|
+
Returns ``{exit_code, stdout, stderr, cwd, duration_ms}``.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
BlockerError: if ``repo`` is unknown to the workspace, or
|
|
39
|
+
``feature`` is set but doesn't exist.
|
|
40
|
+
FailedError: if the command times out.
|
|
41
|
+
"""
|
|
42
|
+
cwd = _resolve_cwd(workspace, repo, feature)
|
|
43
|
+
|
|
44
|
+
started = time.monotonic()
|
|
45
|
+
try:
|
|
46
|
+
proc = subprocess.run(
|
|
47
|
+
command,
|
|
48
|
+
cwd=cwd,
|
|
49
|
+
shell=True,
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=timeout_seconds,
|
|
53
|
+
)
|
|
54
|
+
except subprocess.TimeoutExpired as e:
|
|
55
|
+
elapsed_ms = (time.monotonic() - started) * 1000
|
|
56
|
+
raise FailedError(
|
|
57
|
+
code="timeout",
|
|
58
|
+
what=f"command exceeded {timeout_seconds}s timeout",
|
|
59
|
+
actual={"cwd": str(cwd), "command": command},
|
|
60
|
+
details={
|
|
61
|
+
"duration_ms": int(elapsed_ms),
|
|
62
|
+
"timeout_seconds": timeout_seconds,
|
|
63
|
+
"stdout": e.stdout.decode() if isinstance(e.stdout, bytes) else (e.stdout or ""),
|
|
64
|
+
"stderr": e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or ""),
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
elapsed_ms = (time.monotonic() - started) * 1000
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"exit_code": proc.returncode,
|
|
71
|
+
"stdout": proc.stdout,
|
|
72
|
+
"stderr": proc.stderr,
|
|
73
|
+
"cwd": str(cwd),
|
|
74
|
+
"duration_ms": int(elapsed_ms),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _resolve_cwd(workspace: Workspace, repo: str, feature: str | None) -> Path:
|
|
79
|
+
repo_names = {r.config.name for r in workspace.repos}
|
|
80
|
+
if repo not in repo_names:
|
|
81
|
+
raise BlockerError(
|
|
82
|
+
code="unknown_repo",
|
|
83
|
+
what=f"no repo named '{repo}' in workspace",
|
|
84
|
+
expected={"available_repos": sorted(repo_names)},
|
|
85
|
+
actual={"repo": repo},
|
|
86
|
+
fix_actions=[
|
|
87
|
+
FixAction(action="status", args={}, safe=True,
|
|
88
|
+
preview="canopy status lists configured repos"),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if feature is None:
|
|
93
|
+
# Fall back to the canonical-slot context if one is set.
|
|
94
|
+
# An explicit `feature` arg overrides this; passing None means
|
|
95
|
+
# "use whatever the user declared as their context (or main)".
|
|
96
|
+
from ..actions import slots as slots_mod
|
|
97
|
+
state = slots_mod.read_state(workspace)
|
|
98
|
+
if state and state.canonical and repo in state.canonical.per_repo_paths:
|
|
99
|
+
return Path(state.canonical.per_repo_paths[repo])
|
|
100
|
+
return workspace.get_repo(repo).abs_path
|
|
101
|
+
|
|
102
|
+
from ..features.coordinator import FeatureCoordinator
|
|
103
|
+
coordinator = FeatureCoordinator(workspace)
|
|
104
|
+
try:
|
|
105
|
+
resolved = coordinator._resolve_name(feature)
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
raise BlockerError(
|
|
108
|
+
code="ambiguous_feature",
|
|
109
|
+
what=str(e),
|
|
110
|
+
details={"feature": feature},
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
features = coordinator._load_features()
|
|
114
|
+
if resolved not in features:
|
|
115
|
+
raise BlockerError(
|
|
116
|
+
code="unknown_feature",
|
|
117
|
+
what=f"no feature lane named '{feature}'",
|
|
118
|
+
actual={"feature": feature},
|
|
119
|
+
details={"resolved": resolved},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
paths = coordinator.resolve_paths(resolved)
|
|
123
|
+
if repo in paths:
|
|
124
|
+
return Path(paths[repo])
|
|
125
|
+
|
|
126
|
+
# Repo isn't in the feature lane — fall back to the repo's main path
|
|
127
|
+
# rather than failing. Caller likely just wants the directory; if the
|
|
128
|
+
# repo isn't part of the feature, the worktree route doesn't apply.
|
|
129
|
+
return workspace.get_repo(repo).abs_path
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Agent setup — install bundled skills and wire canopy MCP into a workspace.
|
|
2
|
+
|
|
3
|
+
Skills live at ``skills/<name>/SKILL.md`` inside this package and are copied
|
|
4
|
+
into ``~/.claude/skills/<name>/SKILL.md`` so any Claude Code session knows
|
|
5
|
+
to use them. The MCP config (``.mcp.json`` at the workspace root) registers
|
|
6
|
+
canopy-mcp as an MCP server with ``CANOPY_ROOT`` pointing at the workspace.
|
|
7
|
+
|
|
8
|
+
Both pieces are independent — install skills, MCP, both, or neither.
|
|
9
|
+
``setup_agent`` returns a structured report describing what was done so
|
|
10
|
+
callers can render it.
|
|
11
|
+
|
|
12
|
+
The bundled skill set today: ``using-canopy`` (always installed by default)
|
|
13
|
+
and ``augment-canopy`` (opt-in via ``--skill augment-canopy``).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, asdict
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
_SKILLS_DIR = Path(__file__).parent / "skills"
|
|
22
|
+
|
|
23
|
+
DEFAULT_SKILL = "using-canopy"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _user_skills_dir() -> Path:
|
|
27
|
+
"""Resolved at call time so tests that monkeypatch ``HOME`` work."""
|
|
28
|
+
return Path.home() / ".claude" / "skills"
|
|
29
|
+
|
|
30
|
+
# Backward-compat alias — doctor.py imports this directly. Points at the
|
|
31
|
+
# bundled source for the default skill.
|
|
32
|
+
_SKILL_SOURCE = _SKILLS_DIR / DEFAULT_SKILL / "SKILL.md"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def available_skills() -> tuple[str, ...]:
|
|
36
|
+
"""Return the names of all bundled skills (directories under ``skills/``)."""
|
|
37
|
+
if not _SKILLS_DIR.exists():
|
|
38
|
+
return ()
|
|
39
|
+
return tuple(sorted(
|
|
40
|
+
d.name for d in _SKILLS_DIR.iterdir()
|
|
41
|
+
if d.is_dir() and (d / "SKILL.md").exists()
|
|
42
|
+
))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def skill_source(name: str = DEFAULT_SKILL) -> Path:
|
|
46
|
+
"""Path to the bundled SKILL.md for the named skill."""
|
|
47
|
+
return _SKILLS_DIR / name / "SKILL.md"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def skill_install_target(name: str = DEFAULT_SKILL) -> Path:
|
|
51
|
+
"""Default install location for the named skill."""
|
|
52
|
+
return _user_skills_dir() / name / "SKILL.md"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mcp_config_path(workspace_root: Path) -> Path:
|
|
56
|
+
"""Default location for the workspace's MCP config."""
|
|
57
|
+
return workspace_root / ".mcp.json"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class SkillResult:
|
|
62
|
+
action: str # "installed", "reinstalled", "skipped"
|
|
63
|
+
path: str
|
|
64
|
+
reason: str | None = None
|
|
65
|
+
name: str = DEFAULT_SKILL
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class McpResult:
|
|
70
|
+
action: str # "added", "updated", "skipped", "created"
|
|
71
|
+
path: str
|
|
72
|
+
reason: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def install_skill(name: str = DEFAULT_SKILL, *, reinstall: bool = False) -> SkillResult:
|
|
76
|
+
"""Install the named skill into ~/.claude/skills/<name>/SKILL.md.
|
|
77
|
+
|
|
78
|
+
If a skill file already exists and isn't ours, leaves it alone unless
|
|
79
|
+
``reinstall=True``. Detection: the source skill file's full body is
|
|
80
|
+
written verbatim, so we can byte-compare to know if it's ours.
|
|
81
|
+
|
|
82
|
+
Raises ``FileNotFoundError`` if the named skill isn't bundled.
|
|
83
|
+
"""
|
|
84
|
+
source = skill_source(name)
|
|
85
|
+
if not source.exists():
|
|
86
|
+
raise FileNotFoundError(
|
|
87
|
+
f"No bundled skill named '{name}'. Available: {', '.join(available_skills()) or '(none)'}",
|
|
88
|
+
)
|
|
89
|
+
target = skill_install_target(name)
|
|
90
|
+
source_text = source.read_text()
|
|
91
|
+
|
|
92
|
+
if target.exists():
|
|
93
|
+
existing = target.read_text()
|
|
94
|
+
if existing == source_text:
|
|
95
|
+
return SkillResult(
|
|
96
|
+
action="skipped", path=str(target), name=name,
|
|
97
|
+
reason="already up to date",
|
|
98
|
+
)
|
|
99
|
+
if not reinstall and f"name: {name}" not in existing:
|
|
100
|
+
return SkillResult(
|
|
101
|
+
action="skipped", path=str(target), name=name,
|
|
102
|
+
reason="foreign skill present; use --reinstall to overwrite",
|
|
103
|
+
)
|
|
104
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
target.write_text(source_text)
|
|
106
|
+
return SkillResult(action="reinstalled", path=str(target), name=name)
|
|
107
|
+
|
|
108
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
target.write_text(source_text)
|
|
110
|
+
return SkillResult(action="installed", path=str(target), name=name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def install_mcp(workspace_root: Path, *, reinstall: bool = False) -> McpResult:
|
|
114
|
+
"""Add (or update) a 'canopy' entry in the workspace's .mcp.json.
|
|
115
|
+
|
|
116
|
+
Merges with any existing ``mcpServers`` block. If a 'canopy' entry
|
|
117
|
+
already exists with the right shape, leaves it alone unless
|
|
118
|
+
``reinstall=True``.
|
|
119
|
+
"""
|
|
120
|
+
workspace_root = workspace_root.resolve()
|
|
121
|
+
target = mcp_config_path(workspace_root)
|
|
122
|
+
desired = {
|
|
123
|
+
"command": "canopy-mcp",
|
|
124
|
+
"args": [],
|
|
125
|
+
"env": {"CANOPY_ROOT": str(workspace_root)},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
config: dict
|
|
129
|
+
created = False
|
|
130
|
+
if target.exists():
|
|
131
|
+
try:
|
|
132
|
+
config = json.loads(target.read_text())
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
return McpResult(action="skipped", path=str(target),
|
|
135
|
+
reason="existing .mcp.json is not valid JSON; refusing to overwrite")
|
|
136
|
+
if not isinstance(config, dict):
|
|
137
|
+
return McpResult(action="skipped", path=str(target),
|
|
138
|
+
reason="existing .mcp.json root is not an object")
|
|
139
|
+
else:
|
|
140
|
+
config = {}
|
|
141
|
+
created = True
|
|
142
|
+
|
|
143
|
+
servers = config.setdefault("mcpServers", {})
|
|
144
|
+
if not isinstance(servers, dict):
|
|
145
|
+
return McpResult(action="skipped", path=str(target),
|
|
146
|
+
reason="existing mcpServers block is not an object")
|
|
147
|
+
|
|
148
|
+
if "canopy" in servers and not reinstall:
|
|
149
|
+
existing = servers["canopy"]
|
|
150
|
+
if (isinstance(existing, dict)
|
|
151
|
+
and existing.get("command") == "canopy-mcp"
|
|
152
|
+
and (existing.get("env") or {}).get("CANOPY_ROOT") == desired["env"]["CANOPY_ROOT"]):
|
|
153
|
+
return McpResult(action="skipped", path=str(target),
|
|
154
|
+
reason="canopy entry already present and current")
|
|
155
|
+
|
|
156
|
+
servers["canopy"] = desired
|
|
157
|
+
target.write_text(json.dumps(config, indent=2) + "\n")
|
|
158
|
+
return McpResult(
|
|
159
|
+
action=("created" if created else ("added" if "canopy" not in (servers or {}) else "updated")),
|
|
160
|
+
path=str(target),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check_status(workspace_root: Path) -> dict:
|
|
165
|
+
"""Report what's installed without changing anything.
|
|
166
|
+
|
|
167
|
+
Returns ``{skill, skills, mcp}``:
|
|
168
|
+
|
|
169
|
+
- ``skill`` — the default ``using-canopy`` entry (kept for
|
|
170
|
+
backward-compat with existing callers / dashboard).
|
|
171
|
+
- ``skills`` — every bundled skill's install state, including
|
|
172
|
+
opt-ins like ``augment-canopy`` once they're installed (M4
|
|
173
|
+
revealed that ``--check`` only reported the default; F-9).
|
|
174
|
+
- ``mcp`` — the workspace's ``.mcp.json`` canopy entry state.
|
|
175
|
+
"""
|
|
176
|
+
skill_state = check_skill_status(DEFAULT_SKILL)
|
|
177
|
+
skills_state = [check_skill_status(name) for name in available_skills()]
|
|
178
|
+
|
|
179
|
+
mcp_target = mcp_config_path(workspace_root)
|
|
180
|
+
mcp_state = {"path": str(mcp_target), "configured": False}
|
|
181
|
+
if mcp_target.exists():
|
|
182
|
+
try:
|
|
183
|
+
cfg = json.loads(mcp_target.read_text())
|
|
184
|
+
servers = (cfg.get("mcpServers") if isinstance(cfg, dict) else {}) or {}
|
|
185
|
+
entry = servers.get("canopy") if isinstance(servers, dict) else None
|
|
186
|
+
mcp_state["configured"] = bool(
|
|
187
|
+
isinstance(entry, dict) and entry.get("command") == "canopy-mcp"
|
|
188
|
+
)
|
|
189
|
+
mcp_state["env"] = (entry or {}).get("env", {}) if isinstance(entry, dict) else {}
|
|
190
|
+
except json.JSONDecodeError:
|
|
191
|
+
mcp_state["error"] = "invalid JSON"
|
|
192
|
+
|
|
193
|
+
return {"skill": skill_state, "skills": skills_state, "mcp": mcp_state}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_skill_status(name: str) -> dict:
|
|
197
|
+
"""Report install state for a single named skill."""
|
|
198
|
+
source = skill_source(name)
|
|
199
|
+
target = skill_install_target(name)
|
|
200
|
+
state = {
|
|
201
|
+
"name": name,
|
|
202
|
+
"path": str(target),
|
|
203
|
+
"installed": target.exists(),
|
|
204
|
+
"is_canopy_skill": False,
|
|
205
|
+
"up_to_date": False,
|
|
206
|
+
}
|
|
207
|
+
if target.exists() and source.exists():
|
|
208
|
+
existing = target.read_text()
|
|
209
|
+
state["is_canopy_skill"] = f"name: {name}" in existing
|
|
210
|
+
state["up_to_date"] = existing == source.read_text()
|
|
211
|
+
return state
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def setup_agent(
|
|
215
|
+
workspace_root: Path | None,
|
|
216
|
+
*,
|
|
217
|
+
skills: tuple[str, ...] = (DEFAULT_SKILL,),
|
|
218
|
+
do_mcp: bool = True,
|
|
219
|
+
reinstall: bool = False,
|
|
220
|
+
do_skill: bool | None = None,
|
|
221
|
+
) -> dict:
|
|
222
|
+
"""Install one or more skills + (optionally) wire MCP.
|
|
223
|
+
|
|
224
|
+
``skills`` is the tuple of bundled skill names to install. Defaults to
|
|
225
|
+
just ``using-canopy``. Pass ``()`` to skip all skill installs.
|
|
226
|
+
|
|
227
|
+
``do_skill`` is a backward-compat alias — when ``do_skill=False`` is
|
|
228
|
+
passed, no skills are installed regardless of the ``skills`` arg.
|
|
229
|
+
"""
|
|
230
|
+
if do_skill is False:
|
|
231
|
+
skills = ()
|
|
232
|
+
|
|
233
|
+
out: dict = {}
|
|
234
|
+
if skills:
|
|
235
|
+
results = []
|
|
236
|
+
for name in skills:
|
|
237
|
+
try:
|
|
238
|
+
results.append(asdict(install_skill(name, reinstall=reinstall)))
|
|
239
|
+
except FileNotFoundError as e:
|
|
240
|
+
results.append({
|
|
241
|
+
"action": "skipped",
|
|
242
|
+
"path": str(skill_install_target(name)),
|
|
243
|
+
"name": name,
|
|
244
|
+
"reason": str(e),
|
|
245
|
+
})
|
|
246
|
+
# Preserve legacy single-skill report at "skill" for callers that
|
|
247
|
+
# only know about the default skill.
|
|
248
|
+
default = next(
|
|
249
|
+
(r for r in results if r.get("name") == DEFAULT_SKILL),
|
|
250
|
+
results[0] if results else None,
|
|
251
|
+
)
|
|
252
|
+
if default is not None:
|
|
253
|
+
out["skill"] = default
|
|
254
|
+
out["skills"] = results
|
|
255
|
+
|
|
256
|
+
if do_mcp:
|
|
257
|
+
if workspace_root is None:
|
|
258
|
+
out["mcp"] = {
|
|
259
|
+
"action": "skipped", "path": "",
|
|
260
|
+
"reason": "no workspace_root (run from inside a canopy workspace)",
|
|
261
|
+
}
|
|
262
|
+
else:
|
|
263
|
+
out["mcp"] = asdict(install_mcp(workspace_root, reinstall=reinstall))
|
|
264
|
+
return out
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: augment-canopy
|
|
3
|
+
description: Use when the user wants to customize how canopy operations behave for this workspace — overriding the preflight command, listing which review-comment authors count as bots, choosing a custom test command, or otherwise tuning canopy.toml's [augments] block. Lets the agent edit canopy.toml directly and confirm the new behavior takes effect on the next operation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# augment-canopy
|
|
7
|
+
|
|
8
|
+
Per-workspace customization for canopy operations that vary by team or codebase. Lives in `canopy.toml` under an `[augments]` block (workspace defaults) plus optional per-repo overrides on `[[repos]]` entries.
|
|
9
|
+
|
|
10
|
+
## When to invoke
|
|
11
|
+
|
|
12
|
+
Listen for cues like:
|
|
13
|
+
|
|
14
|
+
- *"Use ruff for preflight here, not pre-commit."*
|
|
15
|
+
- *"Track CodeRabbit and Korbit comments; ignore Copilot."*
|
|
16
|
+
- *"This workspace runs `make check` before commits."*
|
|
17
|
+
- *"For the api repo specifically, run `uv run pytest tests/fast` as preflight."*
|
|
18
|
+
- *"Make `canopy preflight` run X."*
|
|
19
|
+
|
|
20
|
+
These are all augment edits — read canopy.toml, mutate the right block, atomic-write back, confirm with the user.
|
|
21
|
+
|
|
22
|
+
Do **not** invoke for:
|
|
23
|
+
|
|
24
|
+
- Adding/removing repos (use `canopy init` or edit `[[repos]]` blocks normally — that's structural config, not behavioral augments).
|
|
25
|
+
- Changing which issue tracker the workspace uses (that's `[issue_provider]`, a separate concern with its own provider abstraction).
|
|
26
|
+
- Per-feature overrides (not supported in v1 — augments are workspace + per-repo only).
|
|
27
|
+
|
|
28
|
+
## Schema
|
|
29
|
+
|
|
30
|
+
Workspace defaults under `[augments]`; per-repo overrides on each `[[repos]]` entry. **Per-repo wins on key collision.**
|
|
31
|
+
|
|
32
|
+
```toml
|
|
33
|
+
[augments]
|
|
34
|
+
preflight_cmd = "make check" # workspace default for all repos
|
|
35
|
+
test_cmd = "pytest" # consumed by future `canopy test`
|
|
36
|
+
review_bots = ["coderabbit", "korbit"] # case-insensitive author substring (M3 bot-tracking)
|
|
37
|
+
|
|
38
|
+
[[repos]]
|
|
39
|
+
name = "api"
|
|
40
|
+
path = "./api"
|
|
41
|
+
augments = { preflight_cmd = "uv run pytest tests/fast" } # api-only override
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Recognized keys (v1)
|
|
45
|
+
|
|
46
|
+
| Key | Type | Consumed by | Notes |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| `preflight_cmd` | string | `canopy preflight` (and `review_prep` path inside `coordinator.py`) | Runs via `sh -c` so pipes / `&&` chains work |
|
|
49
|
+
| `test_cmd` | string | future `canopy test` (not v1) | Schema-reserved; safe to set |
|
|
50
|
+
| `review_bots` | list[string] | M3 bot-comment tracking | Workspace-level only; per-repo overrides ignored for this key |
|
|
51
|
+
| `auto_resolve_threads_on_address` | bool | `canopy commit --address <id>` | When true, `canopy commit --address <id>` auto-resolves the corresponding GH review thread after push. `--no-resolve-thread` overrides. Default: false. |
|
|
52
|
+
|
|
53
|
+
Unknown keys are silently preserved by the parser — future augments don't require schema migration.
|
|
54
|
+
|
|
55
|
+
## How to mutate canopy.toml safely
|
|
56
|
+
|
|
57
|
+
The augment block is **not** reachable through `canopy config get/set` in v1 (that command is flat-only). Edit the TOML file directly. Use this recipe:
|
|
58
|
+
|
|
59
|
+
1. **Resolve the path.** The workspace root is the directory containing `canopy.toml`. If the user is in a feature worktree, walk up to find it. The MCP tool `mcp__canopy__workspace_status` returns `workspace_root` if you need it.
|
|
60
|
+
2. **Read + parse.** Use `tomllib` (Python ≥3.11) or `tomli`. Preserve unknown keys.
|
|
61
|
+
3. **Mutate the right block.**
|
|
62
|
+
- Workspace default: `data.setdefault("augments", {})[key] = value`
|
|
63
|
+
- Per-repo override: find the matching entry in `data["repos"]` by `name`, then `entry.setdefault("augments", {})[key] = value`
|
|
64
|
+
4. **Atomic write.** Write to a temp file in the same directory, then `os.replace(tmp, canopy_toml)`. This avoids partial writes if the process is interrupted.
|
|
65
|
+
5. **Confirm with the user.** Echo the change back: *"Set `augments.preflight_cmd = 'ruff check .'` in canopy.toml. The next `canopy preflight` run will use it."*
|
|
66
|
+
|
|
67
|
+
No canopy restart needed — every operation re-reads canopy.toml. Changes take effect on the next call.
|
|
68
|
+
|
|
69
|
+
## Worked example
|
|
70
|
+
|
|
71
|
+
User: *"Set up this workspace to run `ruff check . && pyright` before every commit, and to track CodeRabbit + Korbit as the bot reviewers I care about."*
|
|
72
|
+
|
|
73
|
+
Agent should:
|
|
74
|
+
|
|
75
|
+
1. Find `canopy.toml` (current dir or via `workspace_status`).
|
|
76
|
+
2. Read + parse it.
|
|
77
|
+
3. Add or update the `[augments]` block:
|
|
78
|
+
```python
|
|
79
|
+
data.setdefault("augments", {})
|
|
80
|
+
data["augments"]["preflight_cmd"] = "ruff check . && pyright"
|
|
81
|
+
data["augments"]["review_bots"] = ["coderabbit", "korbit"]
|
|
82
|
+
```
|
|
83
|
+
4. Atomic write back.
|
|
84
|
+
5. Reply:
|
|
85
|
+
> Updated canopy.toml:
|
|
86
|
+
> - `augments.preflight_cmd = "ruff check . && pyright"`
|
|
87
|
+
> - `augments.review_bots = ["coderabbit", "korbit"]`
|
|
88
|
+
>
|
|
89
|
+
> The next `canopy preflight` will run the new command. Bot-comment tracking uses the `review_bots` list.
|
|
90
|
+
|
|
91
|
+
## Per-repo override example
|
|
92
|
+
|
|
93
|
+
User: *"For the api repo only, preflight should be `uv run pytest tests/fast` — keep the workspace default for everything else."*
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
api_entry = next(r for r in data["repos"] if r["name"] == "api")
|
|
97
|
+
api_entry.setdefault("augments", {})
|
|
98
|
+
api_entry["augments"]["preflight_cmd"] = "uv run pytest tests/fast"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Atomic write, confirm:
|
|
102
|
+
|
|
103
|
+
> Set per-repo augment for `api`: `preflight_cmd = "uv run pytest tests/fast"`. Other repos still use the workspace default (`make check`).
|
|
104
|
+
|
|
105
|
+
## Edge cases
|
|
106
|
+
|
|
107
|
+
- **No `[augments]` block yet.** Create it. The parser handles a missing block as `{}`.
|
|
108
|
+
- **Concurrent edits.** Two agents writing simultaneously can clobber each other; the atomic-write minimizes the race window. If you read-then-write and the file mtime changed in between, re-read and retry once. On second failure, surface a `BlockerError`.
|
|
109
|
+
- **Symlinked canopy.toml.** Resolve via `Path.resolve()`; write to the resolved directory.
|
|
110
|
+
- **Invalid command in `preflight_cmd`.** Surfaces as a non-zero exit code at run time. Doesn't crash canopy. The user can fix by re-invoking this skill.
|
|
111
|
+
|
|
112
|
+
## Don't
|
|
113
|
+
|
|
114
|
+
- Don't add validation logic here — the parser is intentionally lenient. Validation (typo detection, unknown-key warnings) lives in `canopy doctor`.
|
|
115
|
+
- Don't introduce nested-key syntax via `canopy config augments.preflight_cmd` — that's a future refactor of `cmd_config`. v1 writes TOML directly.
|
|
116
|
+
- Don't alter `[issue_provider]` or `[[repos]]` structural fields from this skill — those are different concerns.
|