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/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.