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/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
|
+
)
|