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/cli.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Typer app for the public Ama CLI.
|
|
2
|
+
|
|
3
|
+
Thin by design: this module wires commands to the terminal and nothing else.
|
|
4
|
+
Detection lives in `ama_cli.agents`, registration in `ama_cli.agents.adapters`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import os
|
|
11
|
+
import webbrowser
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
|
|
18
|
+
from ama_cli import auth, credentials
|
|
19
|
+
from ama_cli import onboard as onboard_flow
|
|
20
|
+
from ama_cli.agents import Confidence, Detection, Env, detect_all
|
|
21
|
+
from ama_cli.credentials import DEFAULT_HOST, HOST_ENV_VAR
|
|
22
|
+
from ama_cli.agents.adapters import ADAPTERS_BY_ID, get_adapter
|
|
23
|
+
from ama_cli.agents.mcp import (
|
|
24
|
+
DEFAULT_SERVER_NAME,
|
|
25
|
+
Plan,
|
|
26
|
+
RemoteServer,
|
|
27
|
+
RunCommand,
|
|
28
|
+
Scope,
|
|
29
|
+
apply_plan,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="ama",
|
|
34
|
+
help="Connect your coding agent to Ama.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
add_completion=False,
|
|
37
|
+
)
|
|
38
|
+
mcp_app = typer.Typer(help="Manage MCP registration with your coding agents.", no_args_is_help=True)
|
|
39
|
+
app.add_typer(mcp_app, name="mcp")
|
|
40
|
+
console = Console()
|
|
41
|
+
|
|
42
|
+
_CONFIDENCE_LABEL = {
|
|
43
|
+
Confidence.MACHINE: "machine",
|
|
44
|
+
Confidence.BINARY: "binary",
|
|
45
|
+
Confidence.REPO: "repo",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _version() -> str:
|
|
50
|
+
"""This package's installed version.
|
|
51
|
+
|
|
52
|
+
Read from installed metadata rather than duplicated as a constant, so it
|
|
53
|
+
cannot drift from what pyproject.toml declares and PyPI publishes.
|
|
54
|
+
"""
|
|
55
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
return version("ama-cli")
|
|
59
|
+
except PackageNotFoundError: # running from a source tree, not installed
|
|
60
|
+
return "unknown"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _version_callback(value: bool) -> None:
|
|
64
|
+
if value:
|
|
65
|
+
console.print(f"ama {_version()}")
|
|
66
|
+
raise typer.Exit()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.callback()
|
|
70
|
+
def main(
|
|
71
|
+
version: bool = typer.Option(
|
|
72
|
+
None,
|
|
73
|
+
"--version",
|
|
74
|
+
"-V",
|
|
75
|
+
callback=_version_callback,
|
|
76
|
+
is_eager=True,
|
|
77
|
+
help="Show the version and exit.",
|
|
78
|
+
),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Ama — connect your coding agent to Ama.
|
|
81
|
+
|
|
82
|
+
Present so Typer keeps subcommand dispatch. With a single registered command
|
|
83
|
+
and no callback, Typer collapses the app into that command.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _detected(env: Env) -> list[Detection]:
|
|
88
|
+
return [d for d in detect_all(env) if d.detected]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_agents(env: Env, agent: str | None) -> list[Detection]:
|
|
92
|
+
"""Pick the agents to act on: an explicit --agent list, or whatever was detected."""
|
|
93
|
+
if agent:
|
|
94
|
+
wanted = [a.strip() for a in agent.split(",") if a.strip()]
|
|
95
|
+
unknown = [a for a in wanted if a not in ADAPTERS_BY_ID]
|
|
96
|
+
if unknown:
|
|
97
|
+
console.print(f"[red]Unknown agent(s):[/red] {', '.join(unknown)}")
|
|
98
|
+
console.print(f"Known: {', '.join(sorted(ADAPTERS_BY_ID))}")
|
|
99
|
+
raise typer.Exit(2)
|
|
100
|
+
by_id = {d.agent_id: d for d in detect_all(env)}
|
|
101
|
+
return [by_id[a] for a in wanted]
|
|
102
|
+
return [d for d in _detected(env) if get_adapter(d.agent_id)]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _render_plan(plan: Plan) -> None:
|
|
106
|
+
"""Print what a plan would do, secrets masked."""
|
|
107
|
+
for step in plan.steps:
|
|
108
|
+
if isinstance(step, RunCommand):
|
|
109
|
+
console.print(f" [cyan]$[/cyan] {step.display}")
|
|
110
|
+
continue
|
|
111
|
+
if step.is_noop:
|
|
112
|
+
console.print(f" [dim]{step.path} — already configured[/dim]")
|
|
113
|
+
continue
|
|
114
|
+
verb = "create" if step.creates else "update"
|
|
115
|
+
console.print(f" [cyan]{verb}[/cyan] {step.path}")
|
|
116
|
+
diff = difflib.unified_diff(
|
|
117
|
+
(step.display_before or "").splitlines(),
|
|
118
|
+
step.display_after.splitlines(),
|
|
119
|
+
fromfile="before",
|
|
120
|
+
tofile="after",
|
|
121
|
+
lineterm="",
|
|
122
|
+
)
|
|
123
|
+
for line in diff:
|
|
124
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
125
|
+
console.print(f" [green]{line}[/green]")
|
|
126
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
127
|
+
console.print(f" [red]{line}[/red]")
|
|
128
|
+
elif line.startswith("@@"):
|
|
129
|
+
console.print(f" [dim]{line}[/dim]")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def onboard(
|
|
134
|
+
host: str = typer.Option(
|
|
135
|
+
None, "--host", help=f"Ama host. Default: ${HOST_ENV_VAR} or {DEFAULT_HOST}."
|
|
136
|
+
),
|
|
137
|
+
scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Where to register."),
|
|
138
|
+
agent: str = typer.Option(
|
|
139
|
+
None, "--agent", help="Comma-separated agent ids. Default: detected."
|
|
140
|
+
),
|
|
141
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation. For CI."),
|
|
142
|
+
no_browser: bool = typer.Option(
|
|
143
|
+
False, "--no-browser", help="Print the sign-in URL instead of opening it."
|
|
144
|
+
),
|
|
145
|
+
dry_run: bool = typer.Option(
|
|
146
|
+
False, "--dry-run", help="Show what would change, change nothing."
|
|
147
|
+
),
|
|
148
|
+
no_start: bool = typer.Option(False, "--no-start", help="Skip the offer to launch your agent."),
|
|
149
|
+
name: str = typer.Option(DEFAULT_SERVER_NAME, "--name", help="Server name to register as."),
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Sign in, connect your coding agent, and start.
|
|
152
|
+
|
|
153
|
+
The one command this package exists for: `curl -fsSL https://ama.dev/install | sh`
|
|
154
|
+
ends here.
|
|
155
|
+
"""
|
|
156
|
+
code = onboard_flow.run(
|
|
157
|
+
env=Env.current(),
|
|
158
|
+
console=console,
|
|
159
|
+
host_flag=host,
|
|
160
|
+
scope=scope,
|
|
161
|
+
agent=agent,
|
|
162
|
+
yes=yes,
|
|
163
|
+
no_browser=no_browser,
|
|
164
|
+
dry_run=dry_run,
|
|
165
|
+
no_start=no_start,
|
|
166
|
+
server_name=name,
|
|
167
|
+
render=_render_plan,
|
|
168
|
+
deps=onboard_flow.Deps(
|
|
169
|
+
confirm=typer.confirm,
|
|
170
|
+
# Notebook mode's token is a password, not a key we minted: it must
|
|
171
|
+
# not land in scrollback or in the user's shell history.
|
|
172
|
+
ask_token=lambda: typer.prompt("Local token", hide_input=True),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
raise typer.Exit(code)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.command()
|
|
179
|
+
def login(
|
|
180
|
+
host: str = typer.Option(
|
|
181
|
+
None, "--host", help=f"Ama host. Default: ${HOST_ENV_VAR} or {DEFAULT_HOST}."
|
|
182
|
+
),
|
|
183
|
+
no_browser: bool = typer.Option(
|
|
184
|
+
False, "--no-browser", help="Print the URL instead of opening it."
|
|
185
|
+
),
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Sign in and store an API key for this machine."""
|
|
188
|
+
env = Env.current()
|
|
189
|
+
target = host or os.environ.get(HOST_ENV_VAR) or DEFAULT_HOST
|
|
190
|
+
|
|
191
|
+
def show_url(url: str) -> None:
|
|
192
|
+
if no_browser:
|
|
193
|
+
console.print("Open this URL to sign in:\n")
|
|
194
|
+
console.print(f" [cyan]{url}[/cyan]\n")
|
|
195
|
+
else:
|
|
196
|
+
console.print(f"Opening [cyan]{target}[/cyan] to sign in…")
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
creds = auth.login(
|
|
200
|
+
target,
|
|
201
|
+
# --no-browser still runs the loopback listener: the user may be on
|
|
202
|
+
# a machine with a browser but no default handler. Only the auto-open
|
|
203
|
+
# is suppressed.
|
|
204
|
+
open_browser=(lambda _url: False) if no_browser else webbrowser.open,
|
|
205
|
+
on_url=show_url,
|
|
206
|
+
)
|
|
207
|
+
except auth.LoginError as exc:
|
|
208
|
+
console.print(f"[red]Login failed:[/red] {exc}")
|
|
209
|
+
raise typer.Exit(1) from exc
|
|
210
|
+
|
|
211
|
+
path = credentials.save(creds, env.home)
|
|
212
|
+
console.print(f"\n[green]✓[/green] Signed in as [bold]{creds.email or 'unknown'}[/bold]")
|
|
213
|
+
console.print(f" Key {creds.masked_key} stored in {path}")
|
|
214
|
+
console.print("\nNext: [bold]ama mcp add[/bold] to connect your coding agent.")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@app.command()
|
|
218
|
+
def logout() -> None:
|
|
219
|
+
"""Delete the stored API key from this machine."""
|
|
220
|
+
env = Env.current()
|
|
221
|
+
if credentials.delete(env.home):
|
|
222
|
+
console.print("[green]✓[/green] Signed out.")
|
|
223
|
+
console.print(
|
|
224
|
+
"[dim]The key still exists server-side — revoke it in settings if it was shared.[/dim]"
|
|
225
|
+
)
|
|
226
|
+
else:
|
|
227
|
+
console.print("[yellow]Not signed in.[/yellow]")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.command()
|
|
231
|
+
def whoami() -> None:
|
|
232
|
+
"""Show who this machine is signed in as."""
|
|
233
|
+
env = Env.current()
|
|
234
|
+
creds = credentials.load(env.home)
|
|
235
|
+
if creds is None:
|
|
236
|
+
console.print("[yellow]Not signed in.[/yellow] Run [bold]ama login[/bold].")
|
|
237
|
+
raise typer.Exit(1)
|
|
238
|
+
|
|
239
|
+
table = Table(show_header=False, box=None)
|
|
240
|
+
table.add_row("Email", creds.email or "[dim]unknown[/dim]")
|
|
241
|
+
table.add_row("Host", creds.host)
|
|
242
|
+
table.add_row("Key", creds.masked_key)
|
|
243
|
+
table.add_row("MCP", creds.mcp_url)
|
|
244
|
+
console.print(table)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command()
|
|
248
|
+
def detect(
|
|
249
|
+
all_agents: bool = typer.Option(False, "--all", help="Include agents that were not detected."),
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Show which coding agents were detected on this machine."""
|
|
252
|
+
env = Env.current()
|
|
253
|
+
detections = detect_all(env)
|
|
254
|
+
shown = detections if all_agents else [d for d in detections if d.detected]
|
|
255
|
+
|
|
256
|
+
if not shown:
|
|
257
|
+
console.print("[yellow]No coding agents detected.[/yellow]")
|
|
258
|
+
console.print("Run with [bold]--all[/bold] to see everything that was checked.")
|
|
259
|
+
raise typer.Exit(0)
|
|
260
|
+
|
|
261
|
+
table = Table(title="Detected coding agents", title_justify="left")
|
|
262
|
+
table.add_column("", width=1)
|
|
263
|
+
table.add_column("Agent")
|
|
264
|
+
table.add_column("Confidence")
|
|
265
|
+
table.add_column("Evidence", overflow="fold")
|
|
266
|
+
|
|
267
|
+
for d in shown:
|
|
268
|
+
marker = "[green]●[/green]" if d.detected else "[dim]○[/dim]"
|
|
269
|
+
confidence = _CONFIDENCE_LABEL.get(d.confidence, "") if d.confidence else ""
|
|
270
|
+
reason = d.reason or "[dim]not detected[/dim]"
|
|
271
|
+
if d.detected and not get_adapter(d.agent_id):
|
|
272
|
+
confidence += " [dim](no adapter yet)[/dim]"
|
|
273
|
+
table.add_row(marker, d.display_name, confidence, reason)
|
|
274
|
+
|
|
275
|
+
console.print(table)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@mcp_app.command("add")
|
|
279
|
+
def mcp_add(
|
|
280
|
+
agent: str = typer.Option(
|
|
281
|
+
None, "--agent", help="Comma-separated agent ids. Default: detected."
|
|
282
|
+
),
|
|
283
|
+
scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Where to register."),
|
|
284
|
+
token: str = typer.Option(None, "--token", help="API key. Default: stored or $AMA_API_KEY."),
|
|
285
|
+
url: str = typer.Option(None, "--url", help="MCP URL. Default: stored or $AMA_MCP_URL."),
|
|
286
|
+
name: str = typer.Option(DEFAULT_SERVER_NAME, "--name", help="Server name to register as."),
|
|
287
|
+
dry_run: bool = typer.Option(
|
|
288
|
+
False, "--dry-run", help="Show what would change, change nothing."
|
|
289
|
+
),
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Register the Ama MCP server with your coding agents."""
|
|
292
|
+
env = Env.current()
|
|
293
|
+
creds = credentials.resolve(env.home, token=token, url=url)
|
|
294
|
+
if creds is None:
|
|
295
|
+
console.print(
|
|
296
|
+
"[red]No credentials.[/red] Pass --token, set $AMA_API_KEY, or run `ama login`."
|
|
297
|
+
)
|
|
298
|
+
raise typer.Exit(2)
|
|
299
|
+
|
|
300
|
+
targets = _resolve_agents(env, agent)
|
|
301
|
+
if not targets:
|
|
302
|
+
console.print("[yellow]No supported coding agents detected.[/yellow]")
|
|
303
|
+
console.print(f"Register manually: {creds.mcp_url}")
|
|
304
|
+
raise typer.Exit(0)
|
|
305
|
+
|
|
306
|
+
server = RemoteServer(url=creds.mcp_url, token=creds.api_key, name=name)
|
|
307
|
+
failures = 0
|
|
308
|
+
|
|
309
|
+
for detection in targets:
|
|
310
|
+
adapter = get_adapter(detection.agent_id)
|
|
311
|
+
if adapter is None:
|
|
312
|
+
console.print(f"[dim]{detection.display_name}: no adapter yet, skipping[/dim]")
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
plan = adapter.plan_add(server, scope, env)
|
|
317
|
+
except ValueError as exc: # unreadable config -- refuse rather than clobber
|
|
318
|
+
console.print(f"[red]{detection.display_name}:[/red] {exc}")
|
|
319
|
+
failures += 1
|
|
320
|
+
continue
|
|
321
|
+
|
|
322
|
+
console.print(f"\n[bold]{detection.display_name}[/bold]")
|
|
323
|
+
if plan.unsupported:
|
|
324
|
+
console.print(f" [yellow]skipped:[/yellow] {plan.unsupported}")
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
_render_plan(plan)
|
|
328
|
+
if dry_run:
|
|
329
|
+
continue
|
|
330
|
+
|
|
331
|
+
result = apply_plan(plan, now=datetime.now())
|
|
332
|
+
if result.ok:
|
|
333
|
+
console.print(" [green]✓ registered[/green]")
|
|
334
|
+
for bak in result.backups:
|
|
335
|
+
console.print(f" [dim]backup: {bak}[/dim]")
|
|
336
|
+
else:
|
|
337
|
+
console.print(f" [red]✗ {result.detail}[/red]")
|
|
338
|
+
failures += 1
|
|
339
|
+
|
|
340
|
+
if dry_run:
|
|
341
|
+
console.print("\n[dim]--dry-run: nothing was changed.[/dim]")
|
|
342
|
+
elif not failures:
|
|
343
|
+
console.print("\n[dim]Undo anytime: ama mcp remove[/dim]")
|
|
344
|
+
|
|
345
|
+
if failures:
|
|
346
|
+
raise typer.Exit(1)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@mcp_app.command("list")
|
|
350
|
+
def mcp_list(
|
|
351
|
+
scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Which scope to inspect."),
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Show which agents have MCP servers registered, and which."""
|
|
354
|
+
env = Env.current()
|
|
355
|
+
rows: list[tuple[str, str]] = []
|
|
356
|
+
|
|
357
|
+
for detection in _detected(env):
|
|
358
|
+
adapter = get_adapter(detection.agent_id)
|
|
359
|
+
if adapter is None:
|
|
360
|
+
rows.append((detection.display_name, "[dim]no adapter yet[/dim]"))
|
|
361
|
+
continue
|
|
362
|
+
servers = adapter.list_servers(scope, env)
|
|
363
|
+
rows.append((detection.display_name, ", ".join(servers) if servers else "[dim]none[/dim]"))
|
|
364
|
+
|
|
365
|
+
if not rows:
|
|
366
|
+
console.print("[yellow]No coding agents detected.[/yellow]")
|
|
367
|
+
raise typer.Exit(0)
|
|
368
|
+
|
|
369
|
+
table = Table(title=f"MCP servers ({scope.value} scope)", title_justify="left")
|
|
370
|
+
table.add_column("Agent")
|
|
371
|
+
table.add_column("Servers", overflow="fold")
|
|
372
|
+
for agent_name, servers in rows:
|
|
373
|
+
table.add_row(agent_name, servers)
|
|
374
|
+
console.print(table)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
@mcp_app.command("remove")
|
|
378
|
+
def mcp_remove(
|
|
379
|
+
agent: str = typer.Option(
|
|
380
|
+
None, "--agent", help="Comma-separated agent ids. Default: detected."
|
|
381
|
+
),
|
|
382
|
+
scope: Scope = typer.Option(Scope.GLOBAL, "--scope", help="Where to remove from."),
|
|
383
|
+
name: str = typer.Option(DEFAULT_SERVER_NAME, "--name", help="Server name to remove."),
|
|
384
|
+
dry_run: bool = typer.Option(
|
|
385
|
+
False, "--dry-run", help="Show what would change, change nothing."
|
|
386
|
+
),
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Remove the Ama MCP server from your coding agents."""
|
|
389
|
+
env = Env.current()
|
|
390
|
+
targets = _resolve_agents(env, agent)
|
|
391
|
+
if not targets:
|
|
392
|
+
console.print("[yellow]No supported coding agents detected.[/yellow]")
|
|
393
|
+
raise typer.Exit(0)
|
|
394
|
+
|
|
395
|
+
failures = 0
|
|
396
|
+
for detection in targets:
|
|
397
|
+
adapter = get_adapter(detection.agent_id)
|
|
398
|
+
if adapter is None:
|
|
399
|
+
continue
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
plan = adapter.plan_remove(name, scope, env)
|
|
403
|
+
except ValueError as exc:
|
|
404
|
+
console.print(f"[red]{detection.display_name}:[/red] {exc}")
|
|
405
|
+
failures += 1
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
console.print(f"\n[bold]{detection.display_name}[/bold]")
|
|
409
|
+
if plan.unsupported:
|
|
410
|
+
console.print(f" [yellow]skipped:[/yellow] {plan.unsupported}")
|
|
411
|
+
continue
|
|
412
|
+
if not plan.steps:
|
|
413
|
+
console.print(" [dim]not registered[/dim]")
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
_render_plan(plan)
|
|
417
|
+
if dry_run:
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
result = apply_plan(plan, now=datetime.now())
|
|
421
|
+
console.print(
|
|
422
|
+
" [green]✓ removed[/green]" if result.ok else f" [red]✗ {result.detail}[/red]"
|
|
423
|
+
)
|
|
424
|
+
failures += 0 if result.ok else 1
|
|
425
|
+
|
|
426
|
+
if dry_run:
|
|
427
|
+
console.print("\n[dim]--dry-run: nothing was changed.[/dim]")
|
|
428
|
+
if failures:
|
|
429
|
+
raise typer.Exit(1)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
if __name__ == "__main__":
|
|
433
|
+
app()
|
ama_cli/credentials.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Local credential storage for the public CLI.
|
|
2
|
+
|
|
3
|
+
`~/.ama/credentials.json`, mode 0600. Deliberately separate from the internal
|
|
4
|
+
CLI's `~/.ama/session.json` (a WorkOS sealed session, with no API-key field):
|
|
5
|
+
the two CLIs must not share state, which is the coupling the package split
|
|
6
|
+
exists to prevent.
|
|
7
|
+
|
|
8
|
+
Phase 2 (`ama login`) writes this. Until then `ama mcp add` takes --token/--url
|
|
9
|
+
or reads AMA_API_KEY / AMA_MCP_URL from the environment.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import stat
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from dataclasses import asdict, dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
CREDENTIALS_ENV_VAR = "AMA_API_KEY"
|
|
22
|
+
MCP_URL_ENV_VAR = "AMA_MCP_URL"
|
|
23
|
+
HOST_ENV_VAR = "AMA_HOST"
|
|
24
|
+
DEFAULT_HOST = "https://ama.dev"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def credentials_path(home: Path) -> Path:
|
|
28
|
+
"""Where credentials live."""
|
|
29
|
+
return home / ".ama" / "credentials.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Credentials:
|
|
34
|
+
"""A stored API key and the endpoint it belongs to."""
|
|
35
|
+
|
|
36
|
+
host: str
|
|
37
|
+
api_key: str
|
|
38
|
+
mcp_url: str
|
|
39
|
+
email: str | None = None
|
|
40
|
+
tenant_id: str | None = None
|
|
41
|
+
created_at: str | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def masked_key(self) -> str:
|
|
45
|
+
"""The key, safe to display: prefix only.
|
|
46
|
+
|
|
47
|
+
Note the server's own key_prefix is the first 8 chars of the whole
|
|
48
|
+
string -- i.e. `ama_` plus four -- so there is little to show and
|
|
49
|
+
showing more would defeat the point.
|
|
50
|
+
"""
|
|
51
|
+
return f"{self.api_key[:8]}…" if len(self.api_key) > 8 else "…"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load(home: Path) -> Credentials | None:
|
|
55
|
+
"""Read stored credentials, or None when absent or unreadable."""
|
|
56
|
+
path = credentials_path(home)
|
|
57
|
+
if not path.exists():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(path.read_text())
|
|
61
|
+
except (json.JSONDecodeError, OSError):
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
return Credentials(
|
|
65
|
+
host=data["host"],
|
|
66
|
+
api_key=data["api_key"],
|
|
67
|
+
mcp_url=data["mcp_url"],
|
|
68
|
+
email=data.get("email"),
|
|
69
|
+
tenant_id=data.get("tenant_id"),
|
|
70
|
+
created_at=data.get("created_at"),
|
|
71
|
+
)
|
|
72
|
+
except KeyError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save(creds: Credentials, home: Path) -> Path:
|
|
77
|
+
"""Write credentials with 0600 permissions.
|
|
78
|
+
|
|
79
|
+
The file is chmod'd before the secret is written, so it is never briefly
|
|
80
|
+
world-readable on a fresh create.
|
|
81
|
+
"""
|
|
82
|
+
path = credentials_path(home)
|
|
83
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
path.touch(mode=0o600, exist_ok=True)
|
|
85
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
86
|
+
path.write_text(json.dumps(asdict(creds), indent=2) + "\n")
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def delete(home: Path) -> bool:
|
|
91
|
+
"""Remove stored credentials. True when something was deleted."""
|
|
92
|
+
path = credentials_path(home)
|
|
93
|
+
if path.exists():
|
|
94
|
+
path.unlink()
|
|
95
|
+
return True
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve(
|
|
100
|
+
home: Path,
|
|
101
|
+
*,
|
|
102
|
+
token: str | None = None,
|
|
103
|
+
url: str | None = None,
|
|
104
|
+
environ: Mapping[str, str] | None = None,
|
|
105
|
+
) -> Credentials | None:
|
|
106
|
+
"""Resolve credentials from flags, then env, then the stored file.
|
|
107
|
+
|
|
108
|
+
Explicit flags win so a user can always override; the environment comes next
|
|
109
|
+
for CI; the stored file is the everyday path.
|
|
110
|
+
"""
|
|
111
|
+
env_map: Mapping[str, str] = os.environ if environ is None else environ
|
|
112
|
+
token = token or env_map.get(CREDENTIALS_ENV_VAR)
|
|
113
|
+
url = url or env_map.get(MCP_URL_ENV_VAR)
|
|
114
|
+
|
|
115
|
+
stored = load(home)
|
|
116
|
+
if token:
|
|
117
|
+
return Credentials(
|
|
118
|
+
host=stored.host if stored else DEFAULT_HOST,
|
|
119
|
+
api_key=token,
|
|
120
|
+
mcp_url=url or (stored.mcp_url if stored else f"{DEFAULT_HOST}/mcp/sse"),
|
|
121
|
+
)
|
|
122
|
+
if stored and url:
|
|
123
|
+
return Credentials(
|
|
124
|
+
host=stored.host, api_key=stored.api_key, mcp_url=url, email=stored.email
|
|
125
|
+
)
|
|
126
|
+
return stored
|
ama_cli/discovery.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Host discovery via `GET /.well-known/mcp.json`.
|
|
2
|
+
|
|
3
|
+
Asking the host what it is beats hardcoding `ama.dev`: self-hosted deployments
|
|
4
|
+
work for free, and the MCP path stays the server's business rather than a
|
|
5
|
+
constant duplicated in the CLI.
|
|
6
|
+
|
|
7
|
+
The manifest also tells us *which kind* of host this is, which changes the flow:
|
|
8
|
+
|
|
9
|
+
full mode auth "oauth_or_bearer" + a signup key -> browser login works
|
|
10
|
+
notebook mode auth "bearer", no signup -> no tenant, no OAuth;
|
|
11
|
+
the operator sets a
|
|
12
|
+
preshared token
|
|
13
|
+
|
|
14
|
+
So `ama onboard` against a local notebook cannot log in via the browser, and
|
|
15
|
+
must ask for the token instead. See `src/ama/api/app.py`'s mcp_discovery_manifest
|
|
16
|
+
for the two shapes.
|
|
17
|
+
|
|
18
|
+
urllib rather than httpx: this package sits on the install path of a one-line
|
|
19
|
+
onboarding command, so it stays at typer + rich.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
DISCOVERY_PATH = "/.well-known/mcp.json"
|
|
30
|
+
_TIMEOUT_SECONDS = 15
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Manifest:
|
|
35
|
+
"""What the host says about itself."""
|
|
36
|
+
|
|
37
|
+
mcp_url: str
|
|
38
|
+
auth_type: str
|
|
39
|
+
token_prefix: str | None = None
|
|
40
|
+
signup_url: str | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_notebook_mode(self) -> bool:
|
|
44
|
+
"""True when this is a single-user local deployment.
|
|
45
|
+
|
|
46
|
+
Notebook mode has no tenant to create and no OAuth flow -- it
|
|
47
|
+
string-compares a preshared token -- so the browser login the public CLI
|
|
48
|
+
is built around does not apply. The manifest is deliberately explicit
|
|
49
|
+
about this rather than making us probe for a 404.
|
|
50
|
+
"""
|
|
51
|
+
return self.auth_type == "bearer"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_mcp_url(host: str) -> str:
|
|
55
|
+
"""The MCP endpoint to assume when the manifest cannot be read."""
|
|
56
|
+
return f"{host.rstrip('/')}/mcp/sse"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_manifest(data: dict, host: str) -> Manifest:
|
|
60
|
+
"""Build a Manifest from the raw JSON, tolerating missing keys.
|
|
61
|
+
|
|
62
|
+
Every field is optional here on purpose: this parses a document served by a
|
|
63
|
+
host we may not control, possibly an older one. A missing field falls back to
|
|
64
|
+
convention rather than raising -- a manifest we half-understand is still
|
|
65
|
+
better than none.
|
|
66
|
+
"""
|
|
67
|
+
endpoint = data.get("endpoint")
|
|
68
|
+
url = endpoint.get("url") if isinstance(endpoint, dict) else None
|
|
69
|
+
|
|
70
|
+
auth = data.get("authentication")
|
|
71
|
+
auth = auth if isinstance(auth, dict) else {}
|
|
72
|
+
bearer = auth.get("bearer")
|
|
73
|
+
bearer = bearer if isinstance(bearer, dict) else {}
|
|
74
|
+
|
|
75
|
+
signup = data.get("signup")
|
|
76
|
+
signup_url = signup.get("url") if isinstance(signup, dict) else None
|
|
77
|
+
|
|
78
|
+
return Manifest(
|
|
79
|
+
mcp_url=url or default_mcp_url(host),
|
|
80
|
+
auth_type=auth.get("type") or "oauth_or_bearer",
|
|
81
|
+
token_prefix=bearer.get("token_prefix"),
|
|
82
|
+
signup_url=signup_url,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def discover(host: str, *, fetch=None) -> Manifest | None:
|
|
87
|
+
"""Read the host's manifest, or None when it cannot be read.
|
|
88
|
+
|
|
89
|
+
Returns None rather than raising: discovery is an optimisation, not a
|
|
90
|
+
requirement. A host that is genuinely unreachable will fail again at login
|
|
91
|
+
with a clearer message than anything this could produce, and a self-hosted
|
|
92
|
+
box serving no manifest should still be onboardable by convention.
|
|
93
|
+
|
|
94
|
+
`fetch` is injected so tests need no network.
|
|
95
|
+
"""
|
|
96
|
+
fetcher = _fetch if fetch is None else fetch
|
|
97
|
+
url = f"{host.rstrip('/')}{DISCOVERY_PATH}"
|
|
98
|
+
try:
|
|
99
|
+
data = fetcher(url)
|
|
100
|
+
except (OSError, urllib.error.URLError, json.JSONDecodeError, ValueError):
|
|
101
|
+
return None
|
|
102
|
+
if not isinstance(data, dict):
|
|
103
|
+
return None
|
|
104
|
+
return parse_manifest(data, host)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _fetch(url: str) -> dict:
|
|
108
|
+
"""GET a JSON document."""
|
|
109
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
|
110
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS) as resp:
|
|
111
|
+
return json.loads(resp.read())
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["DISCOVERY_PATH", "Manifest", "default_mcp_url", "discover", "parse_manifest"]
|