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,307 @@
1
+ """
2
+ Linear integration via MCP.
3
+
4
+ Fetches issue data from a Linear MCP server configured in .canopy/mcps.json.
5
+ Canopy never talks to Linear directly — it always goes through the MCP layer.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from ..mcp.client import (
14
+ get_mcp_config,
15
+ is_mcp_configured,
16
+ call_tool,
17
+ McpClientError,
18
+ )
19
+
20
+
21
+ class LinearNotConfiguredError(Exception):
22
+ """Linear MCP is not configured in .canopy/mcps.json."""
23
+
24
+
25
+ class LinearIssueNotFoundError(Exception):
26
+ """Could not find the requested Linear issue."""
27
+
28
+
29
+ class LinearCallError(Exception):
30
+ """Every attempt to call the Linear MCP server failed.
31
+
32
+ Carries the per-attempt log so callers (e.g. the MCP server tool wrapper)
33
+ can convert this into a structured ``BlockerError`` and the agent sees
34
+ why every attempt failed instead of an empty list.
35
+ """
36
+
37
+ def __init__(self, attempts: list[tuple[str, dict, str]]):
38
+ self.attempts = attempts
39
+ summary = "\n ".join(
40
+ f"- {tool}({args}): {err}" for tool, args, err in attempts
41
+ )
42
+ super().__init__(f"All Linear MCP attempts failed:\n {summary}")
43
+
44
+
45
+ _MCP_ERROR_PATTERN = re.compile(r"^(error\s*:|mcp error -?\d+\s*:)", re.IGNORECASE)
46
+
47
+
48
+ def _looks_like_mcp_error(text: str) -> bool:
49
+ """True when an MCP tool's text response is itself an error payload.
50
+
51
+ The Linear MCP returns validation errors as a normal text content block
52
+ rather than as a JSON-RPC error, so a naive parser sees them as success.
53
+ Detect the leading ``Error:``/``MCP error -32602:`` marker (and the
54
+ common ``Input validation error`` body) and treat as failure.
55
+ """
56
+ if not text:
57
+ return False
58
+ head = text.strip()[:200]
59
+ return bool(_MCP_ERROR_PATTERN.match(head)) or "Input validation error" in head
60
+
61
+
62
+ _OPEN_STATUS_TYPES = {"backlog", "unstarted", "started", "triage"}
63
+
64
+
65
+ def _get_linear_config(workspace_root: Path) -> dict:
66
+ """Get Linear MCP config, raising if not configured."""
67
+ config = get_mcp_config(workspace_root, "linear")
68
+ if config is None:
69
+ raise LinearNotConfiguredError(
70
+ "Linear MCP not configured.\n"
71
+ "Add a 'linear' entry to .canopy/mcps.json:\n"
72
+ " {\n"
73
+ ' "linear": {\n'
74
+ ' "command": "npx",\n'
75
+ ' "args": ["-y", "linear-mcp-server"],\n'
76
+ ' "env": {"LINEAR_API_KEY": "lin_api_..."}\n'
77
+ " }\n"
78
+ " }"
79
+ )
80
+ return config
81
+
82
+
83
+ def is_linear_configured(workspace_root: Path) -> bool:
84
+ """Check if Linear MCP is set up."""
85
+ return is_mcp_configured(workspace_root, "linear")
86
+
87
+
88
+ def _parse_issue_result(result: Any) -> dict | None:
89
+ """Extract issue data from an MCP tool call result.
90
+
91
+ MCP results come as a CallToolResult with .content blocks.
92
+ Linear MCP tools typically return a single text block with JSON.
93
+ """
94
+ if result is None:
95
+ return None
96
+
97
+ # result is a CallToolResult — iterate content blocks
98
+ for block in result.content:
99
+ if hasattr(block, "text") and block.text:
100
+ text = block.text.strip()
101
+ # Inline MCP error surfaced as text content (Linear MCP does this
102
+ # for validation failures). Treat as failure so the caller falls
103
+ # through to the next tool/args attempt instead of normalizing
104
+ # into an empty issue.
105
+ if _looks_like_mcp_error(text):
106
+ return None
107
+ # Try to parse as JSON
108
+ if text.startswith("{") or text.startswith("["):
109
+ import json
110
+ try:
111
+ return json.loads(text)
112
+ except json.JSONDecodeError:
113
+ pass
114
+ # If not JSON, return as raw text
115
+ return {"raw": text}
116
+ return None
117
+
118
+
119
+ def get_issue(workspace_root: Path, issue_id: str) -> dict:
120
+ """Fetch a Linear issue by identifier (e.g. ENG-123).
121
+
122
+ Returns a dict with at least: identifier, title, state, url.
123
+ The exact shape depends on the Linear MCP server's response.
124
+
125
+ Raises:
126
+ LinearNotConfiguredError: If Linear MCP isn't in mcps.json.
127
+ LinearIssueNotFoundError: If the issue doesn't exist.
128
+ McpClientError: If the MCP call fails.
129
+ """
130
+ config = _get_linear_config(workspace_root)
131
+
132
+ # Canonical Linear MCP (mcp.linear.app) is first; legacy servers follow.
133
+ tool_attempts = [
134
+ ("get_issue", {"id": issue_id}),
135
+ ("get_issue", {"issue_id": issue_id}),
136
+ ("linear_get_issue", {"issueId": issue_id}),
137
+ ("get_issue", {"issueId": issue_id}),
138
+ ("search_issues", {"query": issue_id}),
139
+ ("linear_search_issues", {"query": issue_id}),
140
+ ]
141
+
142
+ last_error = None
143
+ for tool_name, args in tool_attempts:
144
+ try:
145
+ result = call_tool(config, tool_name, args, timeout=15.0, server_name="linear")
146
+ parsed = _parse_issue_result(result)
147
+ if parsed:
148
+ return _normalize_issue(parsed, issue_id)
149
+ except McpClientError as e:
150
+ last_error = e
151
+ continue
152
+
153
+ raise LinearIssueNotFoundError(
154
+ f"Could not fetch issue '{issue_id}' from Linear MCP. "
155
+ f"Last error: {last_error}"
156
+ )
157
+
158
+
159
+ def _normalize_issue(data: dict, original_id: str) -> dict:
160
+ """Normalize issue data into a consistent shape.
161
+
162
+ Different MCP servers return different schemas. This normalizes
163
+ to: {identifier, title, state, url, description, raw}.
164
+ """
165
+ # If it's a search result (list), take the first match
166
+ if isinstance(data, list):
167
+ if not data:
168
+ raise LinearIssueNotFoundError(f"No results for '{original_id}'")
169
+ data = data[0]
170
+
171
+ # Handle nested results (some MCPs wrap in {issues: [...]})
172
+ if "issues" in data and isinstance(data["issues"], list):
173
+ if not data["issues"]:
174
+ raise LinearIssueNotFoundError(f"No results for '{original_id}'")
175
+ data = data["issues"][0]
176
+
177
+ return {
178
+ "identifier": data.get("identifier") or data.get("id") or original_id,
179
+ "title": data.get("title") or data.get("name") or "",
180
+ "state": (
181
+ data.get("state", {}).get("name")
182
+ if isinstance(data.get("state"), dict)
183
+ else data.get("state") or data.get("status") or ""
184
+ ),
185
+ "url": data.get("url") or "",
186
+ "description": data.get("description") or "",
187
+ "raw": data,
188
+ }
189
+
190
+
191
+ def list_my_issues_strict(workspace_root: Path, limit: int = 25) -> list[dict]:
192
+ """Fetch open Linear issues assigned to the current user; raise on failure.
193
+
194
+ Canonical Linear MCP (mcp.linear.app) attempts come first; legacy server
195
+ shapes follow as fallbacks. When the canonical attempt succeeds with a
196
+ full issue list, results are filtered agent-side to ``statusType in
197
+ {backlog, unstarted, started, triage}`` (Linear's ``state`` arg isn't
198
+ accepted on ``list_issues`` — filtering server-side is impossible).
199
+
200
+ Raises:
201
+ LinearNotConfiguredError: Linear MCP not in mcps.json.
202
+ LinearCallError: Every tool/args combination failed. Carries the
203
+ per-attempt log so the caller can build a structured error.
204
+ """
205
+ config = _get_linear_config(workspace_root)
206
+
207
+ tool_attempts = [
208
+ ("list_issues", {"assignee": "me"}),
209
+ ("list_my_issues", {}),
210
+ ("linear_list_my_issues", {}),
211
+ ("get_my_issues", {}),
212
+ ("list_issues", {"assignee": "me", "state": "open"}),
213
+ ("linear_list_issues", {"assignee": "me", "state": "open"}),
214
+ ("search_issues", {"query": "assignee:me state:open"}),
215
+ ]
216
+ attempts_log: list[tuple[str, dict, str]] = []
217
+
218
+ for tool_name, args in tool_attempts:
219
+ try:
220
+ result = call_tool(
221
+ config, tool_name, args, timeout=15.0, server_name="linear",
222
+ )
223
+ except McpClientError as e:
224
+ attempts_log.append((tool_name, args, str(e)))
225
+ continue
226
+ parsed = _parse_issue_result(result)
227
+ if parsed is None:
228
+ attempts_log.append(
229
+ (tool_name, args, "no usable response (parse failed or inline MCP error)"),
230
+ )
231
+ continue
232
+
233
+ items = parsed
234
+ if isinstance(items, dict):
235
+ for key in ("issues", "results", "data", "items"):
236
+ if isinstance(items.get(key), list):
237
+ items = items[key]
238
+ break
239
+ if not isinstance(items, list):
240
+ attempts_log.append(
241
+ (tool_name, args, f"unexpected response shape: {type(items).__name__}"),
242
+ )
243
+ continue
244
+
245
+ normalized = []
246
+ for entry in items[:limit]:
247
+ if not isinstance(entry, dict):
248
+ continue
249
+ status_type = entry.get("statusType")
250
+ if status_type and str(status_type).lower() not in _OPEN_STATUS_TYPES:
251
+ continue
252
+ issue = _normalize_issue(
253
+ entry, entry.get("identifier", entry.get("id", "")),
254
+ )
255
+ normalized.append({
256
+ "identifier": issue["identifier"],
257
+ "title": issue["title"],
258
+ "state": issue["state"],
259
+ "url": issue["url"],
260
+ })
261
+ if normalized:
262
+ return normalized
263
+ attempts_log.append((tool_name, args, "no open issues in response"))
264
+
265
+ raise LinearCallError(attempts_log)
266
+
267
+
268
+ def list_my_issues(workspace_root: Path, limit: int = 25) -> list[dict]:
269
+ """Soft wrapper around :func:`list_my_issues_strict`.
270
+
271
+ Returns ``[]`` whenever Linear isn't configured or every attempt failed,
272
+ preserving the existing "no autocomplete available" contract for the
273
+ VSCode extension's Create Feature quick pick.
274
+
275
+ Agent-facing surfaces should call :func:`list_my_issues_strict` and
276
+ convert :class:`LinearCallError` into a structured ``BlockerError`` so
277
+ the agent sees the real reason instead of an empty list.
278
+ """
279
+ if not is_linear_configured(workspace_root):
280
+ return []
281
+ try:
282
+ return list_my_issues_strict(workspace_root, limit=limit)
283
+ except (LinearNotConfiguredError, LinearCallError):
284
+ return []
285
+
286
+
287
+ def format_branch_name(issue_id: str, title: str = "", custom_name: str = "") -> str:
288
+ """Format a branch name from a Linear issue.
289
+
290
+ If custom_name is provided, use it as-is.
291
+ Otherwise, format as: <issue-id>-<slugified-title>
292
+
293
+ Examples:
294
+ format_branch_name("ENG-123", "Add payment flow") → "ENG-123-add-payment-flow"
295
+ format_branch_name("ENG-123", "", "payment-flow") → "payment-flow"
296
+ """
297
+ if custom_name:
298
+ return custom_name
299
+
300
+ if not title:
301
+ return issue_id.lower()
302
+
303
+ slug = re.sub(r"[^a-zA-Z0-9\s-]", "", title)
304
+ slug = re.sub(r"\s+", "-", slug.strip()).lower()
305
+ slug = slug[:50].rstrip("-") # Cap length
306
+
307
+ return f"{issue_id}-{slug}".lower()
@@ -0,0 +1,239 @@
1
+ """
2
+ Pre-commit hook detection and execution.
3
+
4
+ Detects whether a repo uses the pre-commit framework (.pre-commit-config.yaml),
5
+ bare git hooks (.git/hooks/pre-commit), or neither. Runs whichever is present
6
+ and returns structured results.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from pathlib import Path
12
+
13
+
14
+ class PrecommitError(Exception):
15
+ """A pre-commit hook failed."""
16
+ def __init__(self, message: str, output: str = "", returncode: int = 1):
17
+ super().__init__(message)
18
+ self.output = output
19
+ self.returncode = returncode
20
+
21
+
22
+ def detect_precommit(repo_path: Path) -> str:
23
+ """Detect which pre-commit system a repo uses.
24
+
25
+ Returns:
26
+ "framework" — .pre-commit-config.yaml exists (pre-commit framework)
27
+ "git_hook" — .git/hooks/pre-commit exists and is executable
28
+ "none" — no pre-commit hooks detected
29
+ """
30
+ # Check for pre-commit framework
31
+ if (repo_path / ".pre-commit-config.yaml").exists():
32
+ return "framework"
33
+
34
+ # Check for bare git hook
35
+ # Handle both normal repos (.git is directory) and worktrees (.git is file)
36
+ git_path = repo_path / ".git"
37
+ if git_path.is_dir():
38
+ hook = git_path / "hooks" / "pre-commit"
39
+ if hook.exists() and _is_executable(hook):
40
+ return "git_hook"
41
+ elif git_path.is_file():
42
+ # Worktree — .git is a file pointing to the main repo's .git dir
43
+ # Hooks live in the main repo's hooks dir, but git resolves this
44
+ # automatically when we run `git hook run`
45
+ result = subprocess.run(
46
+ ["git", "rev-parse", "--git-common-dir"],
47
+ capture_output=True, text=True, cwd=repo_path,
48
+ )
49
+ if result.returncode == 0:
50
+ common_dir = (repo_path / result.stdout.strip()).resolve()
51
+ hook = common_dir / "hooks" / "pre-commit"
52
+ if hook.exists() and _is_executable(hook):
53
+ return "git_hook"
54
+
55
+ return "none"
56
+
57
+
58
+ def _is_executable(path: Path) -> bool:
59
+ """Check if a file is executable."""
60
+ import os
61
+ return os.access(path, os.X_OK)
62
+
63
+
64
+ def run_precommit(repo_path: Path, augments: dict | None = None) -> dict:
65
+ """Run pre-commit hooks for a repo.
66
+
67
+ If ``augments`` contains ``preflight_cmd``, runs that command instead
68
+ of the auto-detected hook system. Otherwise detects the hook system
69
+ (pre-commit framework / git hook / none) and runs it.
70
+
71
+ Returns a result dict with:
72
+ type: "custom" | "framework" | "git_hook" | "none"
73
+ passed: bool
74
+ output: str (combined stdout + stderr)
75
+ applied_augment: bool (True iff preflight_cmd from augments ran)
76
+
77
+ Does not raise on hook failure — the caller decides what to do.
78
+ """
79
+ custom_cmd = (augments or {}).get("preflight_cmd")
80
+ if custom_cmd:
81
+ return _run_custom_preflight(repo_path, str(custom_cmd))
82
+
83
+ hook_type = detect_precommit(repo_path)
84
+
85
+ if hook_type == "none":
86
+ return {
87
+ "type": "none",
88
+ "passed": True,
89
+ "output": "No pre-commit hooks detected.",
90
+ "applied_augment": False,
91
+ }
92
+
93
+ if hook_type == "framework":
94
+ result = _run_framework(repo_path)
95
+ else:
96
+ result = _run_git_hook(repo_path)
97
+ result["applied_augment"] = False
98
+ return result
99
+
100
+
101
+ def _run_custom_preflight(repo_path: Path, command: str) -> dict:
102
+ """Run a user-configured preflight command via the shell.
103
+
104
+ Honors the augment's ``preflight_cmd`` literal — supports pipes /
105
+ chaining (e.g. ``ruff check . && pyright``) by passing through ``sh -c``.
106
+ """
107
+ result = subprocess.run(
108
+ ["sh", "-c", command],
109
+ capture_output=True,
110
+ text=True,
111
+ cwd=repo_path,
112
+ timeout=120,
113
+ )
114
+ output = result.stdout
115
+ if result.stderr:
116
+ output += "\n" + result.stderr
117
+ return {
118
+ "type": "custom",
119
+ "passed": result.returncode == 0,
120
+ "output": output.strip(),
121
+ "applied_augment": True,
122
+ "command": command,
123
+ }
124
+
125
+
126
+ def _run_framework(repo_path: Path) -> dict:
127
+ """Run `pre-commit run --all-files`."""
128
+ result = subprocess.run(
129
+ ["pre-commit", "run", "--all-files"],
130
+ capture_output=True,
131
+ text=True,
132
+ cwd=repo_path,
133
+ timeout=120,
134
+ )
135
+
136
+ output = result.stdout
137
+ if result.stderr:
138
+ output += "\n" + result.stderr
139
+
140
+ return {
141
+ "type": "framework",
142
+ "passed": result.returncode == 0,
143
+ "output": output.strip(),
144
+ }
145
+
146
+
147
+ def _run_git_hook(repo_path: Path) -> dict:
148
+ """Run git hook via `git hook run pre-commit`."""
149
+ # Try `git hook run` first (Git 2.36+), fall back to direct execution
150
+ result = subprocess.run(
151
+ ["git", "hook", "run", "pre-commit"],
152
+ capture_output=True,
153
+ text=True,
154
+ cwd=repo_path,
155
+ timeout=120,
156
+ )
157
+
158
+ # Check if `git hook run` isn't available (older Git versions)
159
+ stderr_lower = result.stderr.lower()
160
+ needs_fallback = (
161
+ result.returncode != 0
162
+ and ("not a git command" in stderr_lower
163
+ or "not found" in stderr_lower
164
+ or "unknown command" in stderr_lower)
165
+ )
166
+
167
+ if needs_fallback:
168
+ # Direct execution fallback
169
+ hook_path = _resolve_hook_path(repo_path)
170
+ if hook_path and hook_path.exists():
171
+ result = subprocess.run(
172
+ [str(hook_path)],
173
+ capture_output=True,
174
+ text=True,
175
+ cwd=repo_path,
176
+ timeout=120,
177
+ )
178
+ else:
179
+ return {
180
+ "type": "git_hook",
181
+ "passed": True,
182
+ "output": "Git hook not found at resolved path.",
183
+ }
184
+
185
+ output = result.stdout
186
+ if result.stderr:
187
+ output += "\n" + result.stderr
188
+
189
+ return {
190
+ "type": "git_hook",
191
+ "passed": result.returncode == 0,
192
+ "output": output.strip(),
193
+ }
194
+
195
+
196
+ def _resolve_hook_path(repo_path: Path) -> Path | None:
197
+ """Resolve the pre-commit hook path, handling worktrees."""
198
+ git_path = repo_path / ".git"
199
+ if git_path.is_dir():
200
+ return git_path / "hooks" / "pre-commit"
201
+
202
+ # Worktree
203
+ result = subprocess.run(
204
+ ["git", "rev-parse", "--git-common-dir"],
205
+ capture_output=True, text=True, cwd=repo_path,
206
+ )
207
+ if result.returncode == 0:
208
+ common_dir = (repo_path / result.stdout.strip()).resolve()
209
+ return common_dir / "hooks" / "pre-commit"
210
+
211
+ return None
212
+
213
+
214
+ def run_precommit_all(repo_paths: dict[str, Path]) -> dict[str, dict]:
215
+ """Run pre-commit hooks across multiple repos.
216
+
217
+ Args:
218
+ repo_paths: {repo_name: repo_path}
219
+
220
+ Returns:
221
+ {repo_name: {type, passed, output}}
222
+ """
223
+ results = {}
224
+ for name, path in repo_paths.items():
225
+ try:
226
+ results[name] = run_precommit(path)
227
+ except subprocess.TimeoutExpired:
228
+ results[name] = {
229
+ "type": detect_precommit(path),
230
+ "passed": False,
231
+ "output": "Pre-commit hook timed out (120s).",
232
+ }
233
+ except Exception as e:
234
+ results[name] = {
235
+ "type": "error",
236
+ "passed": False,
237
+ "output": f"Error running pre-commit: {e}",
238
+ }
239
+ return results
canopy/mcp/__init__.py ADDED
File without changes