ama-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,99 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ !packages/design-system/src/lib/
15
+ !apps/web/src/lib/
16
+ lib64/
17
+ parts/
18
+ sdist/
19
+ var/
20
+ wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+
25
+ # Virtual environments
26
+ .venv/
27
+ venv/
28
+ ENV/
29
+
30
+ # Node.js
31
+ node_modules/
32
+ .turbo/
33
+ .next/
34
+
35
+ # IDE
36
+ .idea/
37
+ .vscode/
38
+ *.swp
39
+ *.swo
40
+
41
+ # Environment
42
+ .env
43
+ .ama_state
44
+
45
+ # Data
46
+ data/
47
+ !data/podcast_demo/
48
+ !apps/web/src/routes/**/data/
49
+ /prompts/
50
+
51
+ # Testing
52
+ .pytest_cache/
53
+ .coverage
54
+ htmlcov/
55
+ ag_news_200.jsonl
56
+
57
+ # Node.js
58
+ node_modules/
59
+ # pnpm-lock.yaml is intentionally COMMITTED. It is the single source of truth for
60
+ # JS dependency resolution and apps/web/Dockerfile installs with --frozen-lockfile.
61
+ # Ignoring it is what previously left bun.lock as the only committed lockfile,
62
+ # so reproducible installs used bun's resolution while the docs mandated pnpm.
63
+
64
+ # mkcert certificates are safe to commit - they only work when the local CA is trusted
65
+ # infra/caddy/certs/ - intentionally NOT ignored for zero-config local dev
66
+
67
+ # Terraform
68
+ *.tfstate
69
+ *.tfstate.*
70
+ *.tfvars
71
+ !terraform.tfvars.example
72
+ .terraform/
73
+
74
+ # Atlantis secrets (Helm values with GitHub tokens)
75
+ infra/k8s/atlantis/secrets.yaml
76
+
77
+ # Agent harness state.
78
+ # Worktrees are multi-GB full repo clones (workmux scratch); leaving them
79
+ # unignored puts them in every agent's glob/grep scope and times out searches.
80
+ .claude/worktrees/
81
+ .claude/settings.local.json
82
+
83
+ # Local MCP client config for Claude Code in this repo (personal endpoint/keys).
84
+ # Root-anchored so the tracked plugins/ama-agent/.mcp.json is unaffected.
85
+ /.mcp.json
86
+
87
+ # Compiled email templates are committed (read at runtime by ama.modules.email.templates;
88
+ # the prod image copies packages/ without running a pnpm build)
89
+ !packages/email-templates/dist/
90
+ !packages/email-templates/dist/**
91
+
92
+ # TypeScript incremental build artifacts
93
+ *.tsbuildinfo
94
+ .design-sync/sb-reference/
95
+ .design-sync/learnings/
96
+ .design-sync/.cache/
97
+ .design-sync/node_modules
98
+ .ds-sync/
99
+ ds-bundle/
ama_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: ama-cli
3
+ Version: 0.1.0
4
+ Summary: Connect your coding agent to Ama.
5
+ Classifier: Development Status :: 3 - Alpha
6
+ Classifier: Environment :: Console
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Python: >=3.11
12
+ Requires-Dist: rich>=13.0
13
+ Requires-Dist: typer>=0.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # ama
17
+
18
+ Connect your coding agent to [Ama](https://ama.dev).
19
+
20
+ ```bash
21
+ curl -fsSL https://ama.dev/install | sh
22
+ ```
23
+
24
+ That installs the CLI and runs `ama onboard`. Three questions and you are done:
25
+
26
+ 1. **Sign in** — in your browser.
27
+ 2. **Confirm** — which agents, and where.
28
+ 3. **Start now?** — opens your agent on a live notebook.
29
+
30
+ Already have the CLI?
31
+
32
+ ```bash
33
+ ama onboard
34
+ ```
35
+
36
+ Re-running either line upgrades.
37
+
38
+ ## Commands
39
+
40
+ | Command | Does |
41
+ |---|---|
42
+ | `ama onboard` | Sign in, detect agents, register MCP. Start here. |
43
+ | `ama detect` | Show which coding agents were found, and why. |
44
+ | `ama login` / `logout` / `whoami` | Manage credentials. |
45
+ | `ama mcp add` / `list` / `remove` | Manage MCP registration directly. |
46
+
47
+ ## Supported agents
48
+
49
+ Registered automatically:
50
+
51
+ | Agent | |
52
+ |---|---|
53
+ | Claude Code | `claude mcp add -s user` |
54
+ | Cursor | `~/.cursor/mcp.json` |
55
+ | Codex CLI | `codex mcp add` — also export `AMA_API_KEY`; Codex stores the variable name, not the token |
56
+ | Gemini CLI | `gemini mcp add` |
57
+ | VS Code (Copilot) | `code --add-mcp` |
58
+
59
+ **Detected but not yet registered automatically:** Windsurf, Zed, Cline, Roo
60
+ Code. `ama onboard` tells you rather than pretending, and prints what to paste.
61
+
62
+ `ama detect` explains what it found and where, so you can check before anything
63
+ is written. `ama onboard --dry-run` prints the exact diff for every file it
64
+ would touch — and signs in to nothing, so no key is created.
65
+
66
+ ## Self-hosted
67
+
68
+ Point at your own deployment; the CLI reads `/.well-known/mcp.json` for the
69
+ endpoint and auth method rather than assuming anything.
70
+
71
+ ```bash
72
+ ama onboard --host https://ama.internal.example.com
73
+ ```
74
+
75
+ ## Install without the script
76
+
77
+ ```bash
78
+ uv tool install ama-cli # binary is `ama`
79
+ ```
80
+
81
+ ## Notes
82
+
83
+ This is the public client. It speaks HTTP to a hosted or self-hosted Ama and
84
+ carries no server dependencies — `typer` and `rich`, nothing else.
85
+
86
+ Your API key is stored in `~/.ama/credentials.json`, mode `0600`. The CLI never
87
+ writes a literal token into a project-scoped config file, since those get
88
+ committed; project scope uses environment-variable indirection instead.
89
+
90
+ Licensed under Apache-2.0.
@@ -0,0 +1,75 @@
1
+ # ama
2
+
3
+ Connect your coding agent to [Ama](https://ama.dev).
4
+
5
+ ```bash
6
+ curl -fsSL https://ama.dev/install | sh
7
+ ```
8
+
9
+ That installs the CLI and runs `ama onboard`. Three questions and you are done:
10
+
11
+ 1. **Sign in** — in your browser.
12
+ 2. **Confirm** — which agents, and where.
13
+ 3. **Start now?** — opens your agent on a live notebook.
14
+
15
+ Already have the CLI?
16
+
17
+ ```bash
18
+ ama onboard
19
+ ```
20
+
21
+ Re-running either line upgrades.
22
+
23
+ ## Commands
24
+
25
+ | Command | Does |
26
+ |---|---|
27
+ | `ama onboard` | Sign in, detect agents, register MCP. Start here. |
28
+ | `ama detect` | Show which coding agents were found, and why. |
29
+ | `ama login` / `logout` / `whoami` | Manage credentials. |
30
+ | `ama mcp add` / `list` / `remove` | Manage MCP registration directly. |
31
+
32
+ ## Supported agents
33
+
34
+ Registered automatically:
35
+
36
+ | Agent | |
37
+ |---|---|
38
+ | Claude Code | `claude mcp add -s user` |
39
+ | Cursor | `~/.cursor/mcp.json` |
40
+ | Codex CLI | `codex mcp add` — also export `AMA_API_KEY`; Codex stores the variable name, not the token |
41
+ | Gemini CLI | `gemini mcp add` |
42
+ | VS Code (Copilot) | `code --add-mcp` |
43
+
44
+ **Detected but not yet registered automatically:** Windsurf, Zed, Cline, Roo
45
+ Code. `ama onboard` tells you rather than pretending, and prints what to paste.
46
+
47
+ `ama detect` explains what it found and where, so you can check before anything
48
+ is written. `ama onboard --dry-run` prints the exact diff for every file it
49
+ would touch — and signs in to nothing, so no key is created.
50
+
51
+ ## Self-hosted
52
+
53
+ Point at your own deployment; the CLI reads `/.well-known/mcp.json` for the
54
+ endpoint and auth method rather than assuming anything.
55
+
56
+ ```bash
57
+ ama onboard --host https://ama.internal.example.com
58
+ ```
59
+
60
+ ## Install without the script
61
+
62
+ ```bash
63
+ uv tool install ama-cli # binary is `ama`
64
+ ```
65
+
66
+ ## Notes
67
+
68
+ This is the public client. It speaks HTTP to a hosted or self-hosted Ama and
69
+ carries no server dependencies — `typer` and `rich`, nothing else.
70
+
71
+ Your API key is stored in `~/.ama/credentials.json`, mode `0600`. The CLI never
72
+ writes a literal token into a project-scoped config file, since those get
73
+ committed; project scope uses environment-variable indirection instead.
74
+
75
+ Licensed under Apache-2.0.
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "ama-cli"
3
+ version = "0.1.0"
4
+ description = "Connect your coding agent to Ama."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ # Deliberately thin. This package is on the install path of a one-line
8
+ # onboarding command, so every dependency is weight a new user waits for.
9
+ # It must never depend on ama-core (enforced by .importlinter) -- that is the
10
+ # server, and it is unpublishable anyway (path + git sources).
11
+ dependencies = [
12
+ "typer>=0.9",
13
+ "rich>=13.0",
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ ]
23
+
24
+ [project.scripts]
25
+ ama = "ama_cli.cli:app"
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/ama_cli"]
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "pytest>=8.0",
37
+ "ruff>=0.8",
38
+ "ty>=0.0.1a8",
39
+ ]
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py311"
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -0,0 +1,7 @@
1
+ """Ama CLI — connect your coding agent to Ama.
2
+
3
+ The public, thin CLI. It speaks HTTP to a hosted or self-hosted Ama and never
4
+ imports server code (see the `public-cli-isolation` contract in .importlinter).
5
+ """
6
+
7
+ __version__ = "0.1.0"
@@ -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