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/__init__.py +7 -0
- ama_cli/agents/__init__.py +25 -0
- ama_cli/agents/adapters/__init__.py +48 -0
- ama_cli/agents/adapters/cli_backed.py +271 -0
- ama_cli/agents/adapters/cursor.py +86 -0
- ama_cli/agents/base.py +206 -0
- ama_cli/agents/mcp.py +275 -0
- ama_cli/agents/registry.py +179 -0
- ama_cli/auth.py +292 -0
- ama_cli/cli.py +433 -0
- ama_cli/credentials.py +126 -0
- ama_cli/discovery.py +114 -0
- ama_cli/launch.py +82 -0
- ama_cli/onboard.py +421 -0
- ama_cli/prompts.py +53 -0
- ama_cli/verify.py +259 -0
- ama_cli-0.1.0.dist-info/METADATA +90 -0
- ama_cli-0.1.0.dist-info/RECORD +20 -0
- ama_cli-0.1.0.dist-info/WHEEL +4 -0
- ama_cli-0.1.0.dist-info/entry_points.txt +2 -0
ama_cli/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Coding-agent detection and MCP registration."""
|
|
2
|
+
|
|
3
|
+
from ama_cli.agents.base import (
|
|
4
|
+
AgentSpec,
|
|
5
|
+
Confidence,
|
|
6
|
+
Detection,
|
|
7
|
+
Env,
|
|
8
|
+
Platform,
|
|
9
|
+
Signal,
|
|
10
|
+
SignalKind,
|
|
11
|
+
)
|
|
12
|
+
from ama_cli.agents.registry import REGISTRY, detect_all, get_spec
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"REGISTRY",
|
|
16
|
+
"AgentSpec",
|
|
17
|
+
"Confidence",
|
|
18
|
+
"Detection",
|
|
19
|
+
"Env",
|
|
20
|
+
"Platform",
|
|
21
|
+
"Signal",
|
|
22
|
+
"SignalKind",
|
|
23
|
+
"detect_all",
|
|
24
|
+
"get_spec",
|
|
25
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The adapter registry.
|
|
2
|
+
|
|
3
|
+
v1 covers Claude Code, Cursor, Codex, Gemini CLI and VS Code. Windsurf, Zed,
|
|
4
|
+
Cline and Roo are detected but not yet registerable — adapters are cheap once
|
|
5
|
+
the seam exists, but each needs testing against a real install, and that is the
|
|
6
|
+
actual cost. `ama mcp add` says so rather than failing obscurely.
|
|
7
|
+
|
|
8
|
+
Zed is the only remaining target needing a comment-preserving (JSONC) writer;
|
|
9
|
+
every v1 agent is either CLI-backed or plain JSON.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ama_cli.agents.adapters.cli_backed import (
|
|
15
|
+
ClaudeCodeAdapter,
|
|
16
|
+
CodexAdapter,
|
|
17
|
+
GeminiAdapter,
|
|
18
|
+
VSCodeAdapter,
|
|
19
|
+
)
|
|
20
|
+
from ama_cli.agents.adapters.cursor import CursorAdapter
|
|
21
|
+
from ama_cli.agents.mcp import AgentAdapter
|
|
22
|
+
|
|
23
|
+
ADAPTERS: tuple[AgentAdapter, ...] = (
|
|
24
|
+
ClaudeCodeAdapter(),
|
|
25
|
+
CursorAdapter(),
|
|
26
|
+
CodexAdapter(),
|
|
27
|
+
GeminiAdapter(),
|
|
28
|
+
VSCodeAdapter(),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ADAPTERS_BY_ID: dict[str, AgentAdapter] = {a.spec.id: a for a in ADAPTERS}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_adapter(agent_id: str) -> AgentAdapter | None:
|
|
35
|
+
"""Look up an adapter by agent id, or None when the agent has no adapter yet."""
|
|
36
|
+
return ADAPTERS_BY_ID.get(agent_id)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ADAPTERS",
|
|
41
|
+
"ADAPTERS_BY_ID",
|
|
42
|
+
"ClaudeCodeAdapter",
|
|
43
|
+
"CodexAdapter",
|
|
44
|
+
"CursorAdapter",
|
|
45
|
+
"GeminiAdapter",
|
|
46
|
+
"VSCodeAdapter",
|
|
47
|
+
"get_adapter",
|
|
48
|
+
]
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Adapters for agents that ship their own non-interactive MCP CLI.
|
|
2
|
+
|
|
3
|
+
Preferred over writing config files: the vendor's CLI owns the dialect, so it
|
|
4
|
+
insulates us from schema and path churn. It matters most for VS Code, whose docs
|
|
5
|
+
describe the user `mcp.json` as living "in your user profile" but never state OS
|
|
6
|
+
paths — and those paths break under named profiles, Insiders and portable
|
|
7
|
+
installs anyway.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ama_cli.agents.base import Env
|
|
13
|
+
from ama_cli.agents.mcp import (
|
|
14
|
+
Plan,
|
|
15
|
+
RemoteServer,
|
|
16
|
+
RunCommand,
|
|
17
|
+
Scope,
|
|
18
|
+
unsupported_plan,
|
|
19
|
+
)
|
|
20
|
+
from ama_cli.agents.registry import CLAUDE_CODE, CODEX, GEMINI_CLI, VSCODE
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _replace_first(argv: tuple[str, ...]) -> RunCommand:
|
|
24
|
+
"""A pre-emptive remove, so a re-run replaces rather than erroring.
|
|
25
|
+
|
|
26
|
+
None of these CLIs have a --force, and `mcp add` errors when the server
|
|
27
|
+
already exists -- so re-running onboard would fail without this. Replace
|
|
28
|
+
rather than skip: someone re-onboarding after rotating their key must end up
|
|
29
|
+
with the new token, not silently keep the old one.
|
|
30
|
+
|
|
31
|
+
Tolerates failure because on a clean machine there is nothing to remove.
|
|
32
|
+
"""
|
|
33
|
+
return RunCommand(argv, tolerate_failure=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ClaudeCodeAdapter:
|
|
37
|
+
"""Claude Code, via `claude mcp add`.
|
|
38
|
+
|
|
39
|
+
Scopes are Claude's own: `local` (default -- this project only), `project`
|
|
40
|
+
(.mcp.json, VCS-shared), `user` (all projects). We map GLOBAL -> user.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
spec = CLAUDE_CODE
|
|
44
|
+
|
|
45
|
+
def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
|
|
46
|
+
if not env.which("claude"):
|
|
47
|
+
return unsupported_plan(self.spec.id, "claude is not on PATH")
|
|
48
|
+
|
|
49
|
+
if scope is Scope.PROJECT:
|
|
50
|
+
# Claude expands ${VAR} and ${VAR:-default} in url and headers, so
|
|
51
|
+
# .mcp.json can be committed without leaking the token.
|
|
52
|
+
auth = server.authorization(scope, env_ref=f"${{{server.token_env_var}}}")
|
|
53
|
+
else:
|
|
54
|
+
auth = server.authorization(scope)
|
|
55
|
+
|
|
56
|
+
# `-s user` is required for global: `claude mcp add` defaults to
|
|
57
|
+
# `-s local`, which would scope the server to this directory only.
|
|
58
|
+
claude_scope = "project" if scope is Scope.PROJECT else "user"
|
|
59
|
+
argv = (
|
|
60
|
+
"claude",
|
|
61
|
+
"mcp",
|
|
62
|
+
"add",
|
|
63
|
+
"--transport",
|
|
64
|
+
"http",
|
|
65
|
+
"-s",
|
|
66
|
+
claude_scope,
|
|
67
|
+
server.name,
|
|
68
|
+
server.url,
|
|
69
|
+
"--header",
|
|
70
|
+
f"Authorization: {auth}",
|
|
71
|
+
)
|
|
72
|
+
return Plan(
|
|
73
|
+
self.spec.id,
|
|
74
|
+
steps=(
|
|
75
|
+
_replace_first(("claude", "mcp", "remove", "-s", claude_scope, server.name)),
|
|
76
|
+
RunCommand(argv, secret=server.token),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
|
|
81
|
+
if not env.which("claude"):
|
|
82
|
+
return unsupported_plan(self.spec.id, "claude is not on PATH")
|
|
83
|
+
claude_scope = "project" if scope is Scope.PROJECT else "user"
|
|
84
|
+
return Plan(
|
|
85
|
+
self.spec.id,
|
|
86
|
+
steps=(RunCommand(("claude", "mcp", "remove", "-s", claude_scope, name)),),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def list_servers(self, scope: Scope, env: Env) -> list[str]:
|
|
90
|
+
return _list_via_cli(("claude", "mcp", "list"), env, "claude")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GeminiAdapter:
|
|
94
|
+
"""Gemini CLI, via `gemini mcp add`.
|
|
95
|
+
|
|
96
|
+
Gemini distinguishes transport by *key*: `httpUrl` is streamable HTTP while
|
|
97
|
+
`url` is SSE. Using `url` for an HTTP endpoint is the classic Gemini
|
|
98
|
+
misconfiguration -- `--transport http` is what keeps us on the right one.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
spec = GEMINI_CLI
|
|
102
|
+
|
|
103
|
+
def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
|
|
104
|
+
if not env.which("gemini"):
|
|
105
|
+
return unsupported_plan(self.spec.id, "gemini is not on PATH")
|
|
106
|
+
|
|
107
|
+
auth = (
|
|
108
|
+
server.authorization(scope, env_ref=f"${{{server.token_env_var}}}")
|
|
109
|
+
if scope is Scope.PROJECT
|
|
110
|
+
else server.authorization(scope)
|
|
111
|
+
)
|
|
112
|
+
argv = (
|
|
113
|
+
"gemini",
|
|
114
|
+
"mcp",
|
|
115
|
+
"add",
|
|
116
|
+
"--transport",
|
|
117
|
+
"http",
|
|
118
|
+
"-s",
|
|
119
|
+
"project" if scope is Scope.PROJECT else "user",
|
|
120
|
+
"--header",
|
|
121
|
+
f"Authorization: {auth}",
|
|
122
|
+
server.name,
|
|
123
|
+
server.url,
|
|
124
|
+
)
|
|
125
|
+
# Deliberately not passing --trust: it bypasses tool-call confirmation,
|
|
126
|
+
# which is not ours to switch off silently.
|
|
127
|
+
return Plan(
|
|
128
|
+
self.spec.id,
|
|
129
|
+
steps=(
|
|
130
|
+
_replace_first(("gemini", "mcp", "remove", server.name)),
|
|
131
|
+
RunCommand(argv, secret=server.token),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
|
|
136
|
+
if not env.which("gemini"):
|
|
137
|
+
return unsupported_plan(self.spec.id, "gemini is not on PATH")
|
|
138
|
+
return Plan(self.spec.id, steps=(RunCommand(("gemini", "mcp", "remove", name)),))
|
|
139
|
+
|
|
140
|
+
def list_servers(self, scope: Scope, env: Env) -> list[str]:
|
|
141
|
+
return _list_via_cli(("gemini", "mcp", "list"), env, "gemini")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CodexAdapter:
|
|
145
|
+
"""Codex CLI, via `codex mcp add`.
|
|
146
|
+
|
|
147
|
+
Two things make Codex the odd one out:
|
|
148
|
+
|
|
149
|
+
1. It is global-only -- no project config exists.
|
|
150
|
+
2. Bearer tokens are env-var-indirected *by design*: it stores the variable
|
|
151
|
+
name, never the token. So registration alone is not enough -- AMA_API_KEY
|
|
152
|
+
must also be exported in the user's shell, or the server silently fails
|
|
153
|
+
to authenticate.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
spec = CODEX
|
|
157
|
+
|
|
158
|
+
def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
|
|
159
|
+
if not env.which("codex"):
|
|
160
|
+
return unsupported_plan(self.spec.id, "codex is not on PATH")
|
|
161
|
+
if scope is Scope.PROJECT:
|
|
162
|
+
return unsupported_plan(
|
|
163
|
+
self.spec.id, "Codex has no project-level config; it is global-only"
|
|
164
|
+
)
|
|
165
|
+
argv = (
|
|
166
|
+
"codex",
|
|
167
|
+
"mcp",
|
|
168
|
+
"add",
|
|
169
|
+
server.name,
|
|
170
|
+
"--url",
|
|
171
|
+
server.url,
|
|
172
|
+
"--bearer-token-env-var",
|
|
173
|
+
server.token_env_var,
|
|
174
|
+
)
|
|
175
|
+
# No secret= : the token never reaches this command by design.
|
|
176
|
+
return Plan(
|
|
177
|
+
self.spec.id,
|
|
178
|
+
steps=(
|
|
179
|
+
_replace_first(("codex", "mcp", "remove", server.name)),
|
|
180
|
+
RunCommand(argv),
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
|
|
185
|
+
if not env.which("codex"):
|
|
186
|
+
return unsupported_plan(self.spec.id, "codex is not on PATH")
|
|
187
|
+
return Plan(self.spec.id, steps=(RunCommand(("codex", "mcp", "remove", name)),))
|
|
188
|
+
|
|
189
|
+
def list_servers(self, scope: Scope, env: Env) -> list[str]:
|
|
190
|
+
return _list_via_cli(("codex", "mcp", "list"), env, "codex")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class VSCodeAdapter:
|
|
194
|
+
"""VS Code / Copilot, via `code --add-mcp`.
|
|
195
|
+
|
|
196
|
+
VS Code's root key is `servers`, not `mcpServers` -- the single biggest
|
|
197
|
+
schema divergence in the ecosystem. We never write that file: `--add-mcp`
|
|
198
|
+
does, and it knows where the user's profile actually is.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
spec = VSCODE
|
|
202
|
+
|
|
203
|
+
def _binary(self, env: Env) -> str | None:
|
|
204
|
+
for candidate in ("code", "code-insiders"):
|
|
205
|
+
if env.which(candidate):
|
|
206
|
+
return candidate
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
|
|
210
|
+
binary = self._binary(env)
|
|
211
|
+
if not binary:
|
|
212
|
+
return unsupported_plan(self.spec.id, "code is not on PATH")
|
|
213
|
+
if scope is Scope.PROJECT:
|
|
214
|
+
return unsupported_plan(
|
|
215
|
+
self.spec.id,
|
|
216
|
+
"`code --add-mcp` writes user scope only; project scope needs .vscode/mcp.json",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
import json as _json
|
|
220
|
+
|
|
221
|
+
payload = _json.dumps(
|
|
222
|
+
{
|
|
223
|
+
"name": server.name,
|
|
224
|
+
"type": "http",
|
|
225
|
+
"url": server.url,
|
|
226
|
+
"headers": {"Authorization": server.authorization(scope)},
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
return Plan(
|
|
230
|
+
self.spec.id,
|
|
231
|
+
steps=(RunCommand((binary, "--add-mcp", payload), secret=server.token),),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
|
|
235
|
+
return unsupported_plan(
|
|
236
|
+
self.spec.id,
|
|
237
|
+
"VS Code has no MCP remove command; use the MCP: Open User Configuration command",
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def list_servers(self, scope: Scope, env: Env) -> list[str]:
|
|
241
|
+
# No documented list command; the config lives at a path VS Code's docs
|
|
242
|
+
# never state. Reporting nothing beats guessing wrong.
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _list_via_cli(argv: tuple[str, ...], env: Env, binary: str) -> list[str]:
|
|
247
|
+
"""Parse server names out of an agent's `mcp list` output.
|
|
248
|
+
|
|
249
|
+
Best-effort by construction: these are human-readable listings with no
|
|
250
|
+
documented machine format, so this reports what it can recognise and stays
|
|
251
|
+
quiet otherwise rather than inventing structure.
|
|
252
|
+
"""
|
|
253
|
+
import re
|
|
254
|
+
import subprocess
|
|
255
|
+
|
|
256
|
+
if not env.which(binary):
|
|
257
|
+
return []
|
|
258
|
+
try:
|
|
259
|
+
proc = subprocess.run(argv, capture_output=True, text=True, timeout=20)
|
|
260
|
+
except (OSError, subprocess.SubprocessError):
|
|
261
|
+
return []
|
|
262
|
+
if proc.returncode != 0:
|
|
263
|
+
return []
|
|
264
|
+
|
|
265
|
+
names: list[str] = []
|
|
266
|
+
for line in proc.stdout.splitlines():
|
|
267
|
+
# Matches the common "name: url" / "name url" listing shapes.
|
|
268
|
+
m = re.match(r"^\s*([A-Za-z0-9_-]+)\s*[::]\s*\S", line)
|
|
269
|
+
if m:
|
|
270
|
+
names.append(m.group(1))
|
|
271
|
+
return names
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Cursor adapter — writes mcp.json directly.
|
|
2
|
+
|
|
3
|
+
Cursor has no `mcp add` subcommand (`cursor-agent mcp` offers only list,
|
|
4
|
+
list-tools, login, enable, disable), so this is the one v1 agent whose config we
|
|
5
|
+
write ourselves. Both the editor and the CLI read the same file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ama_cli.agents.base import Env
|
|
13
|
+
from ama_cli.agents.mcp import (
|
|
14
|
+
FileWrite,
|
|
15
|
+
Plan,
|
|
16
|
+
RemoteServer,
|
|
17
|
+
Scope,
|
|
18
|
+
dump_json,
|
|
19
|
+
read_json,
|
|
20
|
+
)
|
|
21
|
+
from ama_cli.agents.registry import CURSOR
|
|
22
|
+
|
|
23
|
+
ROOT_KEY = "mcpServers"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CursorAdapter:
|
|
27
|
+
"""Cursor, via ~/.cursor/mcp.json (global) or .cursor/mcp.json (project).
|
|
28
|
+
|
|
29
|
+
Dialect note: Cursor identifies a remote server by the *presence of `url`*
|
|
30
|
+
and has no `type` field for it. Community snippets add
|
|
31
|
+
`"type": "streamable-http"`, but no official remote example includes one, so
|
|
32
|
+
we emit exactly what the docs show and nothing more. Guessing at an
|
|
33
|
+
undocumented discriminator is how you get a config that silently never
|
|
34
|
+
connects.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
spec = CURSOR
|
|
38
|
+
|
|
39
|
+
def config_path(self, scope: Scope, env: Env) -> Path:
|
|
40
|
+
if scope is Scope.PROJECT:
|
|
41
|
+
return env.cwd / ".cursor" / "mcp.json"
|
|
42
|
+
return env.home / ".cursor" / "mcp.json"
|
|
43
|
+
|
|
44
|
+
def _entry(self, server: RemoteServer, scope: Scope) -> dict:
|
|
45
|
+
# ${env:VAR} is Cursor's interpolation syntax, resolved in url and
|
|
46
|
+
# headers. Project scope must use it -- that file gets committed.
|
|
47
|
+
env_ref = f"${{env:{server.token_env_var}}}"
|
|
48
|
+
return {
|
|
49
|
+
"url": server.url,
|
|
50
|
+
"headers": {"Authorization": server.authorization(scope, env_ref=env_ref)},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def plan_add(self, server: RemoteServer, scope: Scope, env: Env) -> Plan:
|
|
54
|
+
path = self.config_path(scope, env)
|
|
55
|
+
data, raw = read_json(path)
|
|
56
|
+
|
|
57
|
+
servers = dict(data.get(ROOT_KEY) or {})
|
|
58
|
+
servers[server.name] = self._entry(server, scope)
|
|
59
|
+
merged = {**data, ROOT_KEY: servers}
|
|
60
|
+
|
|
61
|
+
return Plan(
|
|
62
|
+
self.spec.id,
|
|
63
|
+
steps=(FileWrite(path=path, before=raw, after=dump_json(merged), secret=server.token),),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def plan_remove(self, name: str, scope: Scope, env: Env) -> Plan:
|
|
67
|
+
path = self.config_path(scope, env)
|
|
68
|
+
data, raw = read_json(path)
|
|
69
|
+
|
|
70
|
+
servers = dict(data.get(ROOT_KEY) or {})
|
|
71
|
+
if name not in servers:
|
|
72
|
+
return Plan(self.spec.id) # already absent -- nothing to do
|
|
73
|
+
servers.pop(name)
|
|
74
|
+
merged = {**data, ROOT_KEY: servers}
|
|
75
|
+
|
|
76
|
+
return Plan(
|
|
77
|
+
self.spec.id,
|
|
78
|
+
steps=(FileWrite(path=path, before=raw, after=dump_json(merged)),),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def list_servers(self, scope: Scope, env: Env) -> list[str]:
|
|
82
|
+
try:
|
|
83
|
+
data, _ = read_json(self.config_path(scope, env))
|
|
84
|
+
except ValueError:
|
|
85
|
+
return []
|
|
86
|
+
return sorted((data.get(ROOT_KEY) or {}).keys())
|
ama_cli/agents/base.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Core types for coding-agent detection.
|
|
2
|
+
|
|
3
|
+
Detection answers one question: *which coding agents does this developer
|
|
4
|
+
actually use?* We then offer to register the Ama MCP server with each.
|
|
5
|
+
|
|
6
|
+
Everything here is a pure function of an injected `Env`. Nothing calls
|
|
7
|
+
`Path.home()`, `shutil.which`, or reads `sys.platform` at module scope, so
|
|
8
|
+
tests build a temp home and a fake PATH and need no monkeypatching.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum, IntEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Platform(str, Enum):
|
|
23
|
+
"""Host operating system, normalised."""
|
|
24
|
+
|
|
25
|
+
DARWIN = "darwin"
|
|
26
|
+
LINUX = "linux"
|
|
27
|
+
WINDOWS = "windows"
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def current(cls) -> Platform:
|
|
31
|
+
"""Map sys.platform onto the three cases we care about."""
|
|
32
|
+
if sys.platform == "darwin":
|
|
33
|
+
return cls.DARWIN
|
|
34
|
+
if sys.platform.startswith("win"):
|
|
35
|
+
return cls.WINDOWS
|
|
36
|
+
return cls.LINUX
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Env:
|
|
41
|
+
"""The host, injected rather than discovered.
|
|
42
|
+
|
|
43
|
+
`which` is a callable so tests can present a fake PATH without touching
|
|
44
|
+
the real one. `appdata` is only meaningful on Windows.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
home: Path
|
|
48
|
+
cwd: Path
|
|
49
|
+
platform: Platform
|
|
50
|
+
which: Callable[[str], str | None] = shutil.which
|
|
51
|
+
appdata: Path | None = None
|
|
52
|
+
xdg_config_home: Path | None = None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def current(cls) -> Env:
|
|
56
|
+
"""Build an Env from the real host.
|
|
57
|
+
|
|
58
|
+
The only place environment lookup happens. Everything downstream reads
|
|
59
|
+
the resolved Env, which is what keeps detectors testable.
|
|
60
|
+
"""
|
|
61
|
+
appdata = os.environ.get("APPDATA")
|
|
62
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
63
|
+
return cls(
|
|
64
|
+
home=Path.home(),
|
|
65
|
+
cwd=Path.cwd(),
|
|
66
|
+
platform=Platform.current(),
|
|
67
|
+
which=shutil.which,
|
|
68
|
+
appdata=Path(appdata) if appdata else None,
|
|
69
|
+
xdg_config_home=Path(xdg) if xdg else None,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def config_home(self) -> Path:
|
|
74
|
+
"""$XDG_CONFIG_HOME, defaulting to ~/.config per the XDG spec."""
|
|
75
|
+
return self.xdg_config_home or self.home / ".config"
|
|
76
|
+
|
|
77
|
+
def display(self, path: Path) -> str:
|
|
78
|
+
"""Render a path as ~/… when it sits under home, for UI output."""
|
|
79
|
+
try:
|
|
80
|
+
return f"~/{path.relative_to(self.home)}"
|
|
81
|
+
except ValueError:
|
|
82
|
+
return str(path)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Confidence(IntEnum):
|
|
86
|
+
"""How strongly a signal implies *this developer uses this agent here*.
|
|
87
|
+
|
|
88
|
+
Ordered, so `max()` over a detection's signals gives its confidence.
|
|
89
|
+
|
|
90
|
+
MACHINE beats BINARY because a config directory in home means the agent has
|
|
91
|
+
actually run on this machine, whereas a binary may be an unused leftover.
|
|
92
|
+
REPO is last and deliberately weak: `CLAUDE.md` means *some contributor*
|
|
93
|
+
once used Claude Code, which says nothing about the person running onboard.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
REPO = 1
|
|
97
|
+
BINARY = 2
|
|
98
|
+
MACHINE = 3
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SignalKind(str, Enum):
|
|
102
|
+
"""What kind of evidence a signal represents."""
|
|
103
|
+
|
|
104
|
+
MACHINE_DIR = "machine_dir"
|
|
105
|
+
BINARY = "binary"
|
|
106
|
+
REPO_PATH = "repo_path"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
_CONFIDENCE_BY_KIND = {
|
|
110
|
+
SignalKind.MACHINE_DIR: Confidence.MACHINE,
|
|
111
|
+
SignalKind.BINARY: Confidence.BINARY,
|
|
112
|
+
SignalKind.REPO_PATH: Confidence.REPO,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Signal:
|
|
118
|
+
"""One piece of evidence, with a human-readable reason for the UI.
|
|
119
|
+
|
|
120
|
+
`detail` is shown to the user verbatim — they should never have to wonder
|
|
121
|
+
why an agent appeared in the list.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
kind: SignalKind
|
|
125
|
+
detail: str
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def confidence(self) -> Confidence:
|
|
129
|
+
"""The confidence this kind of signal carries."""
|
|
130
|
+
return _CONFIDENCE_BY_KIND[self.kind]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Detection:
|
|
135
|
+
"""The result of running one agent's detector against an Env."""
|
|
136
|
+
|
|
137
|
+
agent_id: str
|
|
138
|
+
display_name: str
|
|
139
|
+
signals: tuple[Signal, ...] = ()
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def detected(self) -> bool:
|
|
143
|
+
"""True when any evidence was found."""
|
|
144
|
+
return bool(self.signals)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def confidence(self) -> Confidence | None:
|
|
148
|
+
"""The strongest signal's confidence, or None when undetected."""
|
|
149
|
+
return max((s.confidence for s in self.signals), default=None)
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def reason(self) -> str:
|
|
153
|
+
"""All signal details, comma-joined, for display."""
|
|
154
|
+
return ", ".join(s.detail for s in self.signals)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class AgentSpec:
|
|
159
|
+
"""A declarative detector for one agent platform.
|
|
160
|
+
|
|
161
|
+
Detection is nearly always "look for a config dir, a binary, some repo
|
|
162
|
+
files", so specs are data. The two callables exist only for genuine
|
|
163
|
+
platform variance (VS Code and Roo bury config under OS-specific
|
|
164
|
+
application-support paths); everything else stays a plain tuple.
|
|
165
|
+
|
|
166
|
+
Adding an agent should mean adding one entry to `registry.py` and a test.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
id: str
|
|
170
|
+
display_name: str
|
|
171
|
+
docs_url: str
|
|
172
|
+
binaries: tuple[str, ...] = ()
|
|
173
|
+
# Paths relative to home, e.g. ".claude" -> ~/.claude
|
|
174
|
+
machine_paths: tuple[str, ...] = ()
|
|
175
|
+
# Resolved against the Env for platform-specific locations.
|
|
176
|
+
machine_paths_fn: Callable[[Env], Sequence[Path]] | None = field(default=None, repr=False)
|
|
177
|
+
# Paths relative to cwd, e.g. "CLAUDE.md"
|
|
178
|
+
repo_paths: tuple[str, ...] = ()
|
|
179
|
+
|
|
180
|
+
def _machine_candidates(self, env: Env) -> list[Path]:
|
|
181
|
+
paths = [env.home / p for p in self.machine_paths]
|
|
182
|
+
if self.machine_paths_fn is not None:
|
|
183
|
+
paths.extend(self.machine_paths_fn(env))
|
|
184
|
+
return paths
|
|
185
|
+
|
|
186
|
+
def detect(self, env: Env) -> Detection:
|
|
187
|
+
"""Gather every signal for this agent. Never raises."""
|
|
188
|
+
signals: list[Signal] = []
|
|
189
|
+
|
|
190
|
+
for path in self._machine_candidates(env):
|
|
191
|
+
if path.exists():
|
|
192
|
+
signals.append(Signal(SignalKind.MACHINE_DIR, env.display(path)))
|
|
193
|
+
|
|
194
|
+
for binary in self.binaries:
|
|
195
|
+
if env.which(binary):
|
|
196
|
+
signals.append(Signal(SignalKind.BINARY, f"{binary} on PATH"))
|
|
197
|
+
|
|
198
|
+
for rel in self.repo_paths:
|
|
199
|
+
if (env.cwd / rel).exists():
|
|
200
|
+
signals.append(Signal(SignalKind.REPO_PATH, f"{rel} in repo"))
|
|
201
|
+
|
|
202
|
+
return Detection(
|
|
203
|
+
agent_id=self.id,
|
|
204
|
+
display_name=self.display_name,
|
|
205
|
+
signals=tuple(signals),
|
|
206
|
+
)
|