ama-cli 0.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.
ama_cli/agents/mcp.py ADDED
@@ -0,0 +1,275 @@
1
+ """MCP registration: what to write, and where, for each agent.
2
+
3
+ There is no cross-agent standard. An MCP RFC to create one was closed as a
4
+ duplicate in Feb 2026, so every client invented its own dialect: the root key is
5
+ `mcpServers` / `servers` / `context_servers` / `mcp_servers`, and the transport
6
+ discriminator is `type: "http"` / `"streamable-http"` / `"streamableHttp"` / an
7
+ `httpUrl` key / a `serverUrl` key / nothing at all. That variation is the reason
8
+ for one adapter per agent rather than one writer with branches.
9
+
10
+ Adapters do not act. They return a `Plan` describing what they *would* do, which
11
+ `--dry-run` prints and `apply()` executes. Tests assert on plans and never touch
12
+ a real config file.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import subprocess
19
+ from dataclasses import dataclass, replace
20
+ from datetime import datetime
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from typing import Protocol
24
+
25
+ from ama_cli.agents.base import AgentSpec, Env
26
+
27
+ DEFAULT_SERVER_NAME = "ama"
28
+ DEFAULT_TOKEN_ENV_VAR = "AMA_API_KEY"
29
+
30
+
31
+ class Scope(str, Enum):
32
+ """Where a server registration lives.
33
+
34
+ GLOBAL is the default everywhere. PROJECT is worse twice over: the file is
35
+ committed (so it must never hold a literal token) and Claude Code
36
+ additionally requires interactive approval before a project-scoped server
37
+ will connect at all.
38
+ """
39
+
40
+ GLOBAL = "global"
41
+ PROJECT = "project"
42
+
43
+
44
+ class UnsupportedScope(Exception):
45
+ """Raised when an agent cannot honour the requested scope safely."""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class RemoteServer:
50
+ """The Ama MCP server, in canonical form. Each adapter lowers it to its own dialect."""
51
+
52
+ url: str
53
+ token: str
54
+ name: str = DEFAULT_SERVER_NAME
55
+ token_env_var: str = DEFAULT_TOKEN_ENV_VAR
56
+
57
+ def authorization(self, scope: Scope, env_ref: str | None = None) -> str:
58
+ """The Authorization header value for a scope.
59
+
60
+ Global configs are user-owned and uncommitted, so a literal token is
61
+ both safe and more reliable than indirection (it needs no shell profile
62
+ edit and works in GUI editors that never inherit a shell env).
63
+
64
+ Project configs get committed. A literal token there is a leaked
65
+ credential, so callers must pass the agent's env-reference syntax.
66
+ """
67
+ if scope is Scope.GLOBAL:
68
+ return f"Bearer {self.token}"
69
+ if env_ref is None:
70
+ raise UnsupportedScope(
71
+ "project scope requires env-var indirection; a literal token would be committed"
72
+ )
73
+ return f"Bearer {env_ref}"
74
+
75
+
76
+ def mask(text: str, secret: str | None) -> str:
77
+ """Replace a secret with a placeholder, for anything shown to a human."""
78
+ if not secret:
79
+ return text
80
+ return text.replace(secret, "ama_****")
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class FileWrite:
85
+ """A pending write to a config file we do not own.
86
+
87
+ `after` holds the real token (it is what lands on disk at global scope);
88
+ `display_after` is what may be printed. --dry-run renders these diffs, so
89
+ the two must never be confused: a token echoed to the terminal ends up in
90
+ scrollback and in whatever bug report the user pastes it into.
91
+ """
92
+
93
+ path: Path
94
+ before: str | None # None when the file does not exist yet
95
+ after: str
96
+ secret: str | None = None
97
+
98
+ @property
99
+ def creates(self) -> bool:
100
+ """True when this write creates the file rather than modifying it."""
101
+ return self.before is None
102
+
103
+ @property
104
+ def is_noop(self) -> bool:
105
+ """True when the file already says exactly this."""
106
+ return self.before == self.after
107
+
108
+ @property
109
+ def display_after(self) -> str:
110
+ """`after` with the secret masked. Safe to print."""
111
+ return mask(self.after, self.secret)
112
+
113
+ @property
114
+ def display_before(self) -> str | None:
115
+ """`before` with the secret masked. Safe to print.
116
+
117
+ Masked too: an existing config already holds a real key, and it is
118
+ rendered on the `-` side of the diff.
119
+ """
120
+ return None if self.before is None else mask(self.before, self.secret)
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class RunCommand:
125
+ """A pending shell-out to an agent's own MCP CLI.
126
+
127
+ Preferred over writing files wherever a CLI exists: it insulates us from
128
+ path churn and from VS Code's user-profile location, which its docs never
129
+ actually specify.
130
+ """
131
+
132
+ argv: tuple[str, ...]
133
+ secret: str | None = None
134
+ # A pre-emptive `mcp remove` fails on a clean machine. That is expected,
135
+ # not an error -- see the adapters' replace-on-rerun comment.
136
+ tolerate_failure: bool = False
137
+
138
+ @property
139
+ def display(self) -> str:
140
+ """The command with the token masked, safe to print or log."""
141
+ return " ".join(mask(a, self.secret) for a in self.argv)
142
+
143
+
144
+ Step = FileWrite | RunCommand
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class Plan:
149
+ """What an adapter would do. Print it, or apply it."""
150
+
151
+ agent_id: str
152
+ steps: tuple[Step, ...] = ()
153
+ unsupported: str | None = None
154
+
155
+ @property
156
+ def is_noop(self) -> bool:
157
+ """True when nothing would change."""
158
+ return (
159
+ all(isinstance(s, FileWrite) and s.is_noop for s in self.steps) if self.steps else True
160
+ )
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class Result:
165
+ """The outcome of applying a plan."""
166
+
167
+ agent_id: str
168
+ ok: bool
169
+ detail: str
170
+ backups: tuple[Path, ...] = ()
171
+
172
+
173
+ class AgentAdapter(Protocol):
174
+ """One agent's MCP dialect."""
175
+
176
+ spec: AgentSpec
177
+
178
+ def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
179
+ """Describe registering `server`. Must not touch the filesystem."""
180
+ ...
181
+
182
+ def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
183
+ """Describe removing a server by name. Must not touch the filesystem."""
184
+ ...
185
+
186
+ def list_servers(self, scope: Scope, env: Env) -> list[str]:
187
+ """Names of MCP servers currently registered with this agent."""
188
+ ...
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # JSON config helpers, shared by the file-writing adapters
193
+ # ---------------------------------------------------------------------------
194
+
195
+
196
+ def read_json(path: Path) -> tuple[dict, str | None]:
197
+ """Read a JSON config, tolerating absent and empty files.
198
+
199
+ Returns (data, raw). `raw` is None when the file does not exist, which is
200
+ what tells FileWrite whether it is creating or modifying.
201
+
202
+ A malformed file is a hard error: silently replacing a config we cannot
203
+ parse would destroy whatever the user actually had in it.
204
+ """
205
+ if not path.exists():
206
+ return {}, None
207
+ raw = path.read_text()
208
+ if not raw.strip():
209
+ return {}, raw
210
+ try:
211
+ data = json.loads(raw)
212
+ except json.JSONDecodeError as exc:
213
+ raise ValueError(f"{path} is not valid JSON ({exc}); refusing to overwrite it") from exc
214
+ if not isinstance(data, dict):
215
+ raise ValueError(f"{path} is not a JSON object; refusing to overwrite it")
216
+ return data, raw
217
+
218
+
219
+ def dump_json(data: dict) -> str:
220
+ """Serialise a config the way these files are conventionally formatted."""
221
+ return json.dumps(data, indent=2) + "\n"
222
+
223
+
224
+ def backup_path(path: Path, stamp: str) -> Path:
225
+ """Where a pre-write backup of `path` goes."""
226
+ return path.with_name(f"{path.name}.ama-backup-{stamp}")
227
+
228
+
229
+ def apply_plan(plan: Plan, *, now: datetime, runner=subprocess.run) -> Result:
230
+ """Execute a plan, backing up every file before touching it.
231
+
232
+ `now` and `runner` are injected so tests can assert on real behaviour with a
233
+ fixed timestamp and no subprocesses.
234
+ """
235
+ if plan.unsupported:
236
+ return Result(plan.agent_id, ok=False, detail=plan.unsupported)
237
+
238
+ backups: list[Path] = []
239
+ stamp = now.strftime("%Y%m%dT%H%M%S")
240
+
241
+ for step in plan.steps:
242
+ if isinstance(step, FileWrite):
243
+ if step.is_noop:
244
+ continue
245
+ if step.before is not None:
246
+ # Back up before mutating a file we do not own. Cheap insurance
247
+ # against a dialect bug costing someone their config.
248
+ bak = backup_path(step.path, stamp)
249
+ bak.write_text(step.before)
250
+ backups.append(bak)
251
+ step.path.parent.mkdir(parents=True, exist_ok=True)
252
+ step.path.write_text(step.after)
253
+ else:
254
+ proc = runner(step.argv, capture_output=True, text=True)
255
+ if proc.returncode != 0 and not step.tolerate_failure:
256
+ err = (proc.stderr or proc.stdout or "").strip().splitlines()
257
+ detail = err[-1] if err else f"exit {proc.returncode}"
258
+ return Result(
259
+ plan.agent_id,
260
+ ok=False,
261
+ detail=f"`{step.display}` failed: {detail}",
262
+ backups=tuple(backups),
263
+ )
264
+
265
+ return Result(plan.agent_id, ok=True, detail="registered", backups=tuple(backups))
266
+
267
+
268
+ def noop_plan(agent_id: str) -> Plan:
269
+ """A plan that does nothing."""
270
+ return Plan(agent_id=agent_id)
271
+
272
+
273
+ def unsupported_plan(agent_id: str, reason: str) -> Plan:
274
+ """A plan that cannot be carried out, with a reason worth showing the user."""
275
+ return replace(noop_plan(agent_id), unsupported=reason)
@@ -0,0 +1,179 @@
1
+ """The agent detector registry.
2
+
3
+ Adding support for a new agent should mean adding one `AgentSpec` here plus a
4
+ test in `tests/agents/`. Keep the list alphabetical by display name.
5
+
6
+ Paths are sourced from each vendor's official docs; see `docs_url` on each spec.
7
+ They drift, so treat a failing detection test as a docs-changed signal rather
8
+ than a flaky test.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Sequence
14
+ from pathlib import Path
15
+
16
+ from ama_cli.agents.base import AgentSpec, Detection, Env, Platform
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Platform-specific config roots
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def _vscode_user_dirs(env: Env) -> list[Path]:
24
+ """VS Code's per-user config root, including the Insiders channel.
25
+
26
+ The MCP docs describe the user `mcp.json` as living "in your user profile"
27
+ but never state OS paths, and these break under named profiles and portable
28
+ installs. Good enough to *detect* VS Code; registration should prefer
29
+ `code --add-mcp` over writing these paths.
30
+ """
31
+ if env.platform is Platform.DARWIN:
32
+ roots = [env.home / "Library" / "Application Support"]
33
+ elif env.platform is Platform.WINDOWS:
34
+ roots = [env.appdata or env.home / "AppData" / "Roaming"]
35
+ else:
36
+ roots = [env.config_home]
37
+ return [root / channel / "User" for root in roots for channel in ("Code", "Code - Insiders")]
38
+
39
+
40
+ def _globalstorage_dirs(env: Env, extension_id: str) -> list[Path]:
41
+ """A VS Code extension's globalStorage dir, where Cline and Roo keep settings."""
42
+ return [user / "globalStorage" / extension_id for user in _vscode_user_dirs(env)]
43
+
44
+
45
+ def _zed_dirs(env: Env) -> Sequence[Path]:
46
+ """Zed's config dir.
47
+
48
+ Zed uses ~/.config/zed on macOS as well as Linux — *not* ~/Library — which
49
+ is the mistake worth having a helper for.
50
+ """
51
+ if env.platform is Platform.WINDOWS:
52
+ return [(env.appdata or env.home / "AppData" / "Roaming") / "Zed"]
53
+ return [env.config_home / "zed"]
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # The registry
58
+ #
59
+ # Note on repo signals: AGENTS.md is deliberately absent from every spec. It is
60
+ # read by Claude Code, Cursor, Copilot, Gemini CLI, Windsurf and Aider, so it
61
+ # identifies no agent and would produce a false positive for all of them.
62
+ # ---------------------------------------------------------------------------
63
+
64
+ CLAUDE_CODE = AgentSpec(
65
+ id="claude-code",
66
+ display_name="Claude Code",
67
+ docs_url="https://code.claude.com/docs/en/mcp",
68
+ binaries=("claude",),
69
+ machine_paths=(".claude.json", ".claude"),
70
+ repo_paths=("CLAUDE.md", ".claude"),
71
+ )
72
+
73
+ CLINE = AgentSpec(
74
+ id="cline",
75
+ display_name="Cline",
76
+ docs_url="https://docs.cline.bot/mcp/configuring-mcp-servers",
77
+ binaries=("cline",),
78
+ machine_paths=(".cline",),
79
+ machine_paths_fn=lambda env: _globalstorage_dirs(env, "saoudrizwan.claude-dev"),
80
+ repo_paths=(".clinerules",),
81
+ )
82
+
83
+ CODEX = AgentSpec(
84
+ id="codex",
85
+ display_name="Codex CLI",
86
+ docs_url="https://learn.chatgpt.com/docs/extend/mcp?surface=cli",
87
+ binaries=("codex",),
88
+ machine_paths=(".codex",),
89
+ # No repo signal: Codex reads AGENTS.md, which identifies no agent.
90
+ )
91
+
92
+ CURSOR = AgentSpec(
93
+ id="cursor",
94
+ display_name="Cursor",
95
+ docs_url="https://cursor.com/docs/mcp",
96
+ binaries=("cursor", "cursor-agent"),
97
+ machine_paths=(".cursor",),
98
+ repo_paths=(".cursor", ".cursorrules"),
99
+ )
100
+
101
+ GEMINI_CLI = AgentSpec(
102
+ id="gemini-cli",
103
+ display_name="Gemini CLI",
104
+ docs_url="https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md",
105
+ binaries=("gemini",),
106
+ machine_paths=(".gemini",),
107
+ repo_paths=(".gemini", "GEMINI.md"),
108
+ )
109
+
110
+ ROO_CODE = AgentSpec(
111
+ id="roo-code",
112
+ display_name="Roo Code",
113
+ docs_url="https://docs.roocode.com/features/mcp/using-mcp-in-roo",
114
+ # No binary: Roo is a VS Code extension only.
115
+ machine_paths_fn=lambda env: _globalstorage_dirs(env, "rooveterinaryinc.roo-cline"),
116
+ repo_paths=(".roo", ".roomodes", ".roorules"),
117
+ )
118
+
119
+ VSCODE = AgentSpec(
120
+ id="vscode",
121
+ display_name="VS Code (Copilot)",
122
+ docs_url="https://code.visualstudio.com/docs/agents/reference/mcp-configuration",
123
+ binaries=("code", "code-insiders"),
124
+ machine_paths_fn=_vscode_user_dirs,
125
+ repo_paths=(".vscode", ".github/copilot-instructions.md"),
126
+ )
127
+
128
+ WINDSURF = AgentSpec(
129
+ id="windsurf",
130
+ display_name="Windsurf",
131
+ # Docs moved to a Devin domain under Cognition ownership; highest churn risk
132
+ # of the set.
133
+ docs_url="https://docs.devin.ai/desktop/cascade/mcp",
134
+ binaries=("windsurf",),
135
+ machine_paths=(".codeium/windsurf",),
136
+ repo_paths=(".windsurf", ".windsurfrules"),
137
+ )
138
+
139
+ ZED = AgentSpec(
140
+ id="zed",
141
+ display_name="Zed",
142
+ docs_url="https://zed.dev/docs/ai/mcp",
143
+ binaries=("zed",),
144
+ machine_paths_fn=_zed_dirs,
145
+ repo_paths=(".zed",),
146
+ )
147
+
148
+ REGISTRY: tuple[AgentSpec, ...] = (
149
+ CLAUDE_CODE,
150
+ CLINE,
151
+ CODEX,
152
+ CURSOR,
153
+ GEMINI_CLI,
154
+ ROO_CODE,
155
+ VSCODE,
156
+ WINDSURF,
157
+ ZED,
158
+ )
159
+
160
+
161
+ def get_spec(agent_id: str) -> AgentSpec | None:
162
+ """Look up a spec by id, or None when unknown."""
163
+ return next((spec for spec in REGISTRY if spec.id == agent_id), None)
164
+
165
+
166
+ def detect_all(env: Env, specs: Sequence[AgentSpec] = REGISTRY) -> list[Detection]:
167
+ """Detect every registered agent, strongest evidence first.
168
+
169
+ Returns a Detection for *every* spec, including undetected ones, so callers
170
+ can show "not detected" rows. Filter on `.detected` for the positives.
171
+
172
+ Ordering is by confidence descending, then display name, so the list is
173
+ stable across runs — a UI that reshuffles between invocations is a bug.
174
+ """
175
+ detections = [spec.detect(env) for spec in specs]
176
+ return sorted(
177
+ detections,
178
+ key=lambda d: (-(d.confidence or 0), d.display_name),
179
+ )