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/mcp/client.py ADDED
@@ -0,0 +1,329 @@
1
+ """
2
+ Canopy MCP Client — call tools on external MCP servers.
3
+
4
+ Two transports supported:
5
+
6
+ 1. **stdio** (subprocess) — for local npm/python MCP servers::
7
+
8
+ {
9
+ "github": {
10
+ "command": "npx",
11
+ "args": ["-y", "@modelcontextprotocol/server-github"],
12
+ "env": {"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_..."}
13
+ }
14
+ }
15
+
16
+ 2. **HTTP (streamable)** — for hosted MCP servers like Linear's. Set
17
+ ``"type": "http"`` (or just include ``"url"``); pass headers for
18
+ token-based auth, or ``"oauth": true`` for browser-based OAuth flow
19
+ with token caching::
20
+
21
+ {
22
+ "linear": {
23
+ "type": "http",
24
+ "url": "https://mcp.linear.app/mcp",
25
+ "oauth": true
26
+ }
27
+ }
28
+
29
+ Configs live in ``.canopy/mcps.json`` (canopy-specific) or ``.mcp.json``
30
+ (shared with Claude Code et al.); canopy merges with .canopy taking
31
+ precedence.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import json
37
+ import os
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+
42
+ class McpClientError(Exception):
43
+ """An MCP client operation failed."""
44
+
45
+
46
+ def _is_http_config(server_config: dict) -> bool:
47
+ """True if the config describes an HTTP-transport MCP server."""
48
+ return (
49
+ server_config.get("type") in {"http", "streamable-http", "sse"}
50
+ or bool(server_config.get("url"))
51
+ )
52
+
53
+
54
+ def _load_mcp_configs(workspace_root: Path) -> dict[str, dict]:
55
+ """Load MCP server configs.
56
+
57
+ Reads two sources, merging with .canopy/mcps.json taking precedence:
58
+
59
+ 1. ``.mcp.json`` at workspace root — the Claude Code / portable
60
+ convention. Entries live under a top-level ``mcpServers`` key.
61
+ 2. ``.canopy/mcps.json`` — canopy's own flat format. Overrides
62
+ anything in .mcp.json on key collision so users can customize
63
+ per-server configs without editing the shared file.
64
+ """
65
+ configs: dict[str, dict] = {}
66
+
67
+ shared_path = workspace_root / ".mcp.json"
68
+ if shared_path.exists():
69
+ try:
70
+ shared = json.loads(shared_path.read_text())
71
+ except (json.JSONDecodeError, OSError) as e:
72
+ raise McpClientError(f"Failed to read {shared_path}: {e}")
73
+ servers = shared.get("mcpServers") if isinstance(shared, dict) else None
74
+ if isinstance(servers, dict):
75
+ for name, cfg in servers.items():
76
+ if isinstance(cfg, dict):
77
+ configs[name] = cfg
78
+
79
+ canopy_path = workspace_root / ".canopy" / "mcps.json"
80
+ if canopy_path.exists():
81
+ try:
82
+ canopy_cfg = json.loads(canopy_path.read_text())
83
+ except (json.JSONDecodeError, OSError) as e:
84
+ raise McpClientError(f"Failed to read {canopy_path}: {e}")
85
+ if isinstance(canopy_cfg, dict):
86
+ for name, cfg in canopy_cfg.items():
87
+ if isinstance(cfg, dict):
88
+ configs[name] = cfg
89
+
90
+ return configs
91
+
92
+
93
+ def get_mcp_config(workspace_root: Path, server_name: str) -> dict | None:
94
+ """Get config for a named MCP server, or None if not configured."""
95
+ configs = _load_mcp_configs(workspace_root)
96
+ return configs.get(server_name)
97
+
98
+
99
+ def is_mcp_configured(workspace_root: Path, server_name: str) -> bool:
100
+ """Check if a named MCP server is configured."""
101
+ return get_mcp_config(workspace_root, server_name) is not None
102
+
103
+
104
+ async def _call_tool_async(
105
+ server_config: dict,
106
+ tool_name: str,
107
+ arguments: dict[str, Any] | None = None,
108
+ timeout: float = 30.0,
109
+ server_name: str | None = None,
110
+ ) -> Any:
111
+ """Connect to an MCP server, call a tool, and return the result.
112
+
113
+ Dispatches to stdio or HTTP transport based on the config shape.
114
+ """
115
+ if _is_http_config(server_config):
116
+ return await _http_call(
117
+ server_config, "call_tool", tool_name=tool_name,
118
+ arguments=arguments or {}, server_name=server_name,
119
+ )
120
+ return await _stdio_call(
121
+ server_config, "call_tool", tool_name=tool_name,
122
+ arguments=arguments or {},
123
+ )
124
+
125
+
126
+ async def _stdio_call(server_config: dict, op: str, **kwargs) -> Any:
127
+ """Spawn a stdio MCP server and run one operation."""
128
+ from mcp import ClientSession, StdioServerParameters
129
+ from mcp.client.stdio import stdio_client
130
+
131
+ env = dict(os.environ)
132
+ if server_config.get("env"):
133
+ env.update(server_config["env"])
134
+
135
+ server_params = StdioServerParameters(
136
+ command=server_config["command"],
137
+ args=server_config.get("args", []),
138
+ env=env,
139
+ )
140
+
141
+ async with stdio_client(server_params) as (read, write):
142
+ async with ClientSession(read, write) as session:
143
+ await session.initialize()
144
+ return await _run_op(session, op, **kwargs)
145
+
146
+
147
+ async def _http_call(
148
+ server_config: dict, op: str, *,
149
+ server_name: str | None = None, **kwargs,
150
+ ) -> Any:
151
+ """Connect to an HTTP MCP server (streamable HTTP) and run one operation.
152
+
153
+ Auth handling:
154
+ - ``headers`` (dict) on config — passed through (e.g. ``Authorization: Bearer ...``)
155
+ - ``oauth: true`` on config — uses OAuthClientProvider with token cache at
156
+ ``~/.canopy/mcp-tokens/<server_name>.json``. First call opens a browser
157
+ for the OAuth flow; cached token is reused after.
158
+ """
159
+ from mcp import ClientSession
160
+ from mcp.client.streamable_http import streamablehttp_client
161
+
162
+ url = server_config["url"]
163
+ headers = dict(server_config.get("headers") or {})
164
+ auth = None
165
+ if server_config.get("oauth"):
166
+ auth = _make_oauth_provider(server_name or "default", url)
167
+
168
+ async with streamablehttp_client(url, headers=headers or None, auth=auth) as (read, write, _):
169
+ async with ClientSession(read, write) as session:
170
+ await session.initialize()
171
+ return await _run_op(session, op, **kwargs)
172
+
173
+
174
+ async def _run_op(session, op: str, **kwargs) -> Any:
175
+ if op == "call_tool":
176
+ return await session.call_tool(kwargs["tool_name"], kwargs.get("arguments") or {})
177
+ if op == "list_tools":
178
+ return await session.list_tools()
179
+ raise McpClientError(f"unknown MCP op: {op}")
180
+
181
+
182
+ def _make_oauth_provider(server_name: str, server_url: str):
183
+ """Build an OAuthClientProvider with on-disk token caching.
184
+
185
+ Imported lazily so canopy doesn't require the auth dependency tree
186
+ when only stdio servers are used.
187
+ """
188
+ from mcp.client.auth import OAuthClientProvider, TokenStorage
189
+ from mcp.shared.auth import OAuthClientMetadata, OAuthClientInformationFull, OAuthToken
190
+
191
+ cache_dir = Path.home() / ".canopy" / "mcp-tokens"
192
+ cache_dir.mkdir(parents=True, exist_ok=True)
193
+ token_path = cache_dir / f"{server_name}.tokens.json"
194
+ client_info_path = cache_dir / f"{server_name}.client.json"
195
+
196
+ class FileTokenStorage(TokenStorage):
197
+ async def get_tokens(self) -> OAuthToken | None:
198
+ if not token_path.exists():
199
+ return None
200
+ data = json.loads(token_path.read_text())
201
+ return OAuthToken(**data)
202
+
203
+ async def set_tokens(self, tokens: OAuthToken) -> None:
204
+ token_path.write_text(tokens.model_dump_json(indent=2))
205
+
206
+ async def get_client_info(self) -> OAuthClientInformationFull | None:
207
+ if not client_info_path.exists():
208
+ return None
209
+ return OAuthClientInformationFull(**json.loads(client_info_path.read_text()))
210
+
211
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
212
+ client_info_path.write_text(client_info.model_dump_json(indent=2))
213
+
214
+ metadata = OAuthClientMetadata(
215
+ client_name="canopy",
216
+ redirect_uris=["http://localhost:33418/callback"],
217
+ grant_types=["authorization_code", "refresh_token"],
218
+ response_types=["code"],
219
+ token_endpoint_auth_method="none",
220
+ )
221
+
222
+ async def redirect_handler(authorization_url: str) -> None:
223
+ """Open the user's browser to the OAuth authorization URL."""
224
+ import webbrowser
225
+ print(f"\n → Opening browser for {server_name} OAuth: {authorization_url}\n")
226
+ webbrowser.open(authorization_url)
227
+
228
+ async def callback_handler() -> tuple[str, str | None]:
229
+ """Run a one-shot HTTP server on localhost:33418 to catch the OAuth redirect."""
230
+ import http.server
231
+ import socketserver
232
+ import urllib.parse
233
+
234
+ captured: dict[str, str] = {}
235
+
236
+ class CallbackHandler(http.server.BaseHTTPRequestHandler):
237
+ def do_GET(self):
238
+ parsed = urllib.parse.urlparse(self.path)
239
+ params = urllib.parse.parse_qs(parsed.query)
240
+ captured["code"] = (params.get("code") or [""])[0]
241
+ captured["state"] = (params.get("state") or [""])[0]
242
+ self.send_response(200)
243
+ self.send_header("Content-Type", "text/html")
244
+ self.end_headers()
245
+ self.wfile.write(
246
+ b"<html><body style='font-family:sans-serif;padding:2em'>"
247
+ b"<h2>Canopy: authorization received</h2>"
248
+ b"<p>You can close this tab.</p>"
249
+ b"</body></html>"
250
+ )
251
+
252
+ def log_message(self, format, *args):
253
+ pass # silence
254
+
255
+ loop = asyncio.get_event_loop()
256
+
257
+ def _serve():
258
+ with socketserver.TCPServer(("localhost", 33418), CallbackHandler) as httpd:
259
+ httpd.handle_request()
260
+
261
+ await loop.run_in_executor(None, _serve)
262
+ return captured.get("code", ""), captured.get("state") or None
263
+
264
+ return OAuthClientProvider(
265
+ server_url=server_url,
266
+ client_metadata=metadata,
267
+ storage=FileTokenStorage(),
268
+ redirect_handler=redirect_handler,
269
+ callback_handler=callback_handler,
270
+ )
271
+
272
+
273
+ def call_tool(
274
+ server_config: dict,
275
+ tool_name: str,
276
+ arguments: dict[str, Any] | None = None,
277
+ timeout: float = 30.0,
278
+ server_name: str | None = None,
279
+ ) -> Any:
280
+ """Sync wrapper — connect to MCP server, call tool, return result.
281
+
282
+ Dispatches to stdio or HTTP based on config shape (see module docstring).
283
+ ``server_name`` is used to scope OAuth token cache when the config is
284
+ HTTP+oauth; pass it from the caller (it's the key in mcps.json).
285
+ """
286
+ return _run_sync(
287
+ _call_tool_async(server_config, tool_name, arguments, timeout, server_name),
288
+ timeout=timeout,
289
+ what=f"call_tool({tool_name})",
290
+ )
291
+
292
+
293
+ def list_tools(
294
+ server_config: dict, timeout: float = 15.0, server_name: str | None = None,
295
+ ) -> list[dict]:
296
+ """List available tools on an MCP server (stdio or HTTP)."""
297
+ async def _list():
298
+ if _is_http_config(server_config):
299
+ tools_result = await _http_call(
300
+ server_config, "list_tools", server_name=server_name,
301
+ )
302
+ else:
303
+ tools_result = await _stdio_call(server_config, "list_tools")
304
+ return [
305
+ {"name": t.name, "description": t.description or ""}
306
+ for t in tools_result.tools
307
+ ]
308
+
309
+ return _run_sync(_list(), timeout=timeout, what="list_tools")
310
+
311
+
312
+ def _run_sync(coro, *, timeout: float, what: str) -> Any:
313
+ """Run an async coroutine, handling the case where we're already inside a loop."""
314
+ try:
315
+ loop = asyncio.get_running_loop()
316
+ except RuntimeError:
317
+ loop = None
318
+
319
+ try:
320
+ if loop and loop.is_running():
321
+ # Inside an existing loop (e.g., the canopy MCP server host).
322
+ # Run in a separate thread with a fresh loop.
323
+ import concurrent.futures
324
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
325
+ future = pool.submit(asyncio.run, coro)
326
+ return future.result(timeout=timeout)
327
+ return asyncio.run(coro)
328
+ except Exception as e:
329
+ raise McpClientError(f"MCP {what} failed: {e}") from e