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/launch.py ADDED
@@ -0,0 +1,82 @@
1
+ """Launching the user's agent on a seeded prompt — the "Start now?" step.
2
+
3
+ A registered MCP server is not a win anyone can see. The win is a live notebook,
4
+ so the last thing onboarding does is offer to hand the user's own agent a prompt
5
+ that produces one, instead of returning them to a shell to guess what to type.
6
+
7
+ Two kinds of agent, and the difference is not cosmetic:
8
+
9
+ terminal agents take a prompt as argv, so this is an exec
10
+ editor agents have no prompt-on-launch equivalent, so the prompt is
11
+ printed for the user to paste
12
+
13
+ An agent is only offered when its binary is on PATH. Detection can fire on a
14
+ config directory alone (the agent ran here once, or was uninstalled), and
15
+ offering to start something we cannot exec would fail in front of the user at
16
+ the exact moment onboarding was meant to pay off.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass
23
+
24
+ from ama_cli.agents.base import Env
25
+
26
+ # How each terminal agent takes a prompt. Verified against each CLI's own
27
+ # documented non-interactive invocation; an agent missing here is one we have
28
+ # not confirmed, and guessing would produce exactly the broken hand-off this
29
+ # step exists to avoid.
30
+ _ARGV_BUILDERS: dict[str, Callable[[str, str], tuple[str, ...]]] = {
31
+ "claude-code": lambda binary, prompt: (binary, prompt),
32
+ "codex": lambda binary, prompt: (binary, prompt),
33
+ "gemini-cli": lambda binary, prompt: (binary, "-p", prompt),
34
+ }
35
+
36
+ _BINARIES: dict[str, str] = {
37
+ "claude-code": "claude",
38
+ "codex": "codex",
39
+ "gemini-cli": "gemini",
40
+ }
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Launch:
45
+ """A command that would start an agent on a prompt."""
46
+
47
+ agent_id: str
48
+ argv: tuple[str, ...]
49
+
50
+ @property
51
+ def display(self) -> str:
52
+ """The command, shortened for the terminal.
53
+
54
+ The prompt is long and multi-line; echoing it whole would bury the point.
55
+ No secret is involved -- the token is in the agent's config, never here.
56
+ """
57
+ head = self.argv[0]
58
+ return f'{head} "…"' if len(self.argv) > 1 else head
59
+
60
+
61
+ def can_launch(agent_id: str) -> bool:
62
+ """True when this agent takes a prompt on the command line."""
63
+ return agent_id in _ARGV_BUILDERS
64
+
65
+
66
+ def plan_launch(agent_id: str, prompt: str, env: Env) -> Launch | None:
67
+ """The command to start `agent_id` on `prompt`, or None when we cannot.
68
+
69
+ None covers both "editor agent, no prompt-on-launch" and "binary not on
70
+ PATH". Both mean the same thing to the caller: do not offer this.
71
+ """
72
+ builder = _ARGV_BUILDERS.get(agent_id)
73
+ if builder is None:
74
+ return None
75
+ binary = _BINARIES[agent_id]
76
+ resolved = env.which(binary)
77
+ if not resolved:
78
+ return None
79
+ return Launch(agent_id=agent_id, argv=builder(binary, prompt))
80
+
81
+
82
+ __all__ = ["Launch", "can_launch", "plan_launch"]
ama_cli/onboard.py ADDED
@@ -0,0 +1,421 @@
1
+ """`ama onboard` — the whole point of this package.
2
+
3
+ Composition, not new logic: phase 2's login, phase 3's detect-and-register, and
4
+ one confirm between them. Three interactions, no more:
5
+
6
+ 1. sign in (browser)
7
+ 2. confirm agents + scope, pre-checked
8
+ 3. start now? launch the agent on a prompt that builds a notebook
9
+
10
+ Every side effect is injected. The flow is the part most likely to break in ways
11
+ tests should catch -- the wrong default scope, a second key on re-run, success
12
+ declared over a dead key -- and none of that is testable if the function reaches
13
+ for the network, a browser, or subprocess itself.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from collections.abc import Callable, Mapping, Sequence
22
+ from dataclasses import dataclass
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+
26
+ from ama_cli import auth, credentials, discovery, launch, prompts, verify
27
+ from ama_cli.agents.adapters import get_adapter
28
+ from ama_cli.agents import Detection, Env, detect_all
29
+ from ama_cli.agents.mcp import (
30
+ DEFAULT_SERVER_NAME,
31
+ Plan,
32
+ RemoteServer,
33
+ Scope,
34
+ apply_plan,
35
+ )
36
+ from ama_cli.credentials import DEFAULT_HOST, HOST_ENV_VAR, Credentials
37
+
38
+ EXIT_OK = 0
39
+ EXIT_FAILED = 1
40
+
41
+ # Stands in for the key --dry-run deliberately does not mint. Shaped like the
42
+ # real thing so the rendered plan is honest, and obviously not a credential so
43
+ # nobody pastes it anywhere.
44
+ _PLACEHOLDER_KEY = "ama_<your-key>"
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Deps:
49
+ """Everything `run` does to the outside world.
50
+
51
+ Grouped rather than passed loose so the signature stays readable and tests
52
+ override one thing without restating the rest.
53
+ """
54
+
55
+ discover: Callable = discovery.discover
56
+ login: Callable = auth.login
57
+ verify: Callable = verify.verify
58
+ runner: Callable = subprocess.run
59
+ exec_agent: Callable = os.execvp
60
+ confirm: Callable | None = None
61
+ ask_token: Callable | None = None
62
+ now: Callable[[], datetime] = datetime.now
63
+ is_tty: Callable[[], bool] = lambda: sys.stdin.isatty()
64
+
65
+
66
+ def resolve_host(flag: str | None, environ: Mapping[str, str]) -> str:
67
+ """--host beats $AMA_HOST beats ama.dev.
68
+
69
+ Self-hosting is a first-class case, so the default is only ever a default.
70
+ """
71
+ return (flag or environ.get(HOST_ENV_VAR) or DEFAULT_HOST).rstrip("/")
72
+
73
+
74
+ def reusable_credentials(home: Path, host: str) -> Credentials | None:
75
+ """Stored credentials for this host, or None.
76
+
77
+ Idempotence lives here: re-running onboard must not mint a second key. A key
78
+ for a *different* host is not reusable -- that is a different account.
79
+
80
+ A revoked key still passes this and is caught by the verify step, which is
81
+ the right division: this function knows what we have, not whether it works.
82
+ """
83
+ stored = credentials.load(home)
84
+ if stored is None:
85
+ return None
86
+ return stored if stored.host.rstrip("/") == host.rstrip("/") else None
87
+
88
+
89
+ def registerable(env: Env, agent: str | None) -> list[Detection]:
90
+ """The agents to offer: detected, and with an adapter that can act.
91
+
92
+ Never pre-emptively touches an undetected agent's config. An explicit
93
+ --agent overrides detection, because a user naming an agent knows better
94
+ than our heuristics.
95
+ """
96
+ if agent:
97
+ wanted = [a.strip() for a in agent.split(",") if a.strip()]
98
+ by_id = {d.agent_id: d for d in detect_all(env)}
99
+ return [by_id[a] for a in wanted if a in by_id and get_adapter(a)]
100
+ return [d for d in detect_all(env) if d.detected and get_adapter(d.agent_id)]
101
+
102
+
103
+ def launchable(targets: Sequence[Detection], env: Env, prompt: str) -> launch.Launch | None:
104
+ """The first registered agent we can actually start, or None.
105
+
106
+ One offer, not a menu: the user has already made every choice this flow is
107
+ entitled to ask for.
108
+ """
109
+ for detection in targets:
110
+ planned = launch.plan_launch(detection.agent_id, prompt, env)
111
+ if planned is not None:
112
+ return planned
113
+ return None
114
+
115
+
116
+ def _join(names) -> str:
117
+ """ "a", "a and b", "a, b and c" — this text ends up in a yes/no question."""
118
+ items = list(names)
119
+ if len(items) <= 1:
120
+ return "".join(items)
121
+ return f"{', '.join(items[:-1])} and {items[-1]}"
122
+
123
+
124
+ def manual_instructions(creds: Credentials, name: str = DEFAULT_SERVER_NAME) -> str:
125
+ """What to print when we cannot register anything for the user.
126
+
127
+ Zero detections is a normal outcome -- a fresh machine, an agent we do not
128
+ know yet -- and it must never dead-end. The key is real and the URL is real,
129
+ so the user is two lines from working regardless of what we detected.
130
+
131
+ Contains the live token: this is printed only to the user's own terminal at
132
+ their request, and without it the output is useless to them.
133
+ """
134
+ return (
135
+ f" MCP endpoint : {creds.mcp_url}\n"
136
+ f" Header : Authorization: Bearer {creds.api_key}\n\n"
137
+ f" Claude Code : claude mcp add --transport http -s user {name} "
138
+ f'{creds.mcp_url} --header "Authorization: Bearer {creds.api_key}"'
139
+ )
140
+
141
+
142
+ def _sign_in(
143
+ *,
144
+ env: Env,
145
+ host: str,
146
+ mcp_url: str,
147
+ manifest: discovery.Manifest | None,
148
+ no_browser: bool,
149
+ console,
150
+ deps: Deps,
151
+ ) -> Credentials:
152
+ """Interaction 1. Reuse what we have, else get a key the right way for this host."""
153
+ existing = reusable_credentials(env.home, host)
154
+ if existing is not None:
155
+ console.print(f"[green]✓[/green] Already signed in as [bold]{existing.email or '?'}[/bold]")
156
+ # The stored URL can be stale (a host that moved, an older CLI). The
157
+ # manifest is authoritative when we have one.
158
+ refreshed = Credentials(
159
+ host=existing.host,
160
+ api_key=existing.api_key,
161
+ mcp_url=mcp_url,
162
+ email=existing.email,
163
+ tenant_id=existing.tenant_id,
164
+ created_at=existing.created_at,
165
+ )
166
+ credentials.save(refreshed, env.home)
167
+ return refreshed
168
+
169
+ if manifest is not None and manifest.is_notebook_mode:
170
+ # A local notebook has no tenant, no signup and no OAuth -- it
171
+ # string-compares AMA_LOCAL_TOKEN. There is nothing to log in to, so
172
+ # asking the browser to try would strand the user at a 404.
173
+ console.print("[dim]This host runs in notebook mode — it uses a preshared token.[/dim]")
174
+ ask = deps.ask_token
175
+ if ask is None:
176
+ raise RuntimeError("notebook-mode onboarding needs a token prompt")
177
+ token = ask()
178
+ creds = Credentials(host=host, api_key=token, mcp_url=mcp_url)
179
+ credentials.save(creds, env.home)
180
+ return creds
181
+
182
+ def show_url(url: str) -> None:
183
+ if no_browser:
184
+ console.print("\nOpen this URL to sign in:\n")
185
+ console.print(f" [cyan]{url}[/cyan]\n")
186
+ else:
187
+ console.print("Opening your browser to sign in…")
188
+
189
+ import webbrowser
190
+
191
+ creds = deps.login(
192
+ host,
193
+ open_browser=(lambda _url: False) if no_browser else webbrowser.open,
194
+ on_url=show_url,
195
+ )
196
+ credentials.save(creds, env.home)
197
+ console.print(f"[green]✓[/green] Signed in as [bold]{creds.email or '?'}[/bold]")
198
+ console.print(f" Key {creds.masked_key} created")
199
+ return creds
200
+
201
+
202
+ def _register(
203
+ *,
204
+ targets: Sequence[Detection],
205
+ server: RemoteServer,
206
+ scope: Scope,
207
+ env: Env,
208
+ dry_run: bool,
209
+ console,
210
+ render: Callable[[Plan], None],
211
+ deps: Deps,
212
+ ) -> int:
213
+ """Apply each adapter's plan. Returns the number of failures."""
214
+ failures = 0
215
+ for detection in targets:
216
+ adapter = get_adapter(detection.agent_id)
217
+ if adapter is None:
218
+ continue
219
+ try:
220
+ plan = adapter.plan_add(server, scope, env)
221
+ except ValueError as exc:
222
+ # An unparseable config. Refusing beats clobbering something the
223
+ # user has and we do not understand.
224
+ console.print(f" [red]✗ {detection.display_name}:[/red] {exc}")
225
+ failures += 1
226
+ continue
227
+
228
+ if plan.unsupported:
229
+ console.print(f" [yellow]•[/yellow] {detection.display_name}: {plan.unsupported}")
230
+ continue
231
+
232
+ if dry_run:
233
+ console.print(f" [bold]{detection.display_name}[/bold]")
234
+ render(plan)
235
+ continue
236
+
237
+ result = apply_plan(plan, now=deps.now(), runner=deps.runner)
238
+ if result.ok:
239
+ console.print(f" [green]✓[/green] {detection.display_name}")
240
+ for bak in result.backups:
241
+ console.print(f" [dim]backup: {bak}[/dim]")
242
+ else:
243
+ console.print(f" [red]✗[/red] {detection.display_name}: {result.detail}")
244
+ failures += 1
245
+ return failures
246
+
247
+
248
+ def _offer_start(
249
+ *,
250
+ targets: Sequence[Detection],
251
+ env: Env,
252
+ console,
253
+ deps: Deps,
254
+ ) -> None:
255
+ """Interaction 3. The payoff: a live notebook, built by their own agent.
256
+
257
+ Returns normally when declined or impossible. On acceptance this execs and
258
+ does not return.
259
+ """
260
+ prompt = prompts.first_notebook_prompt()
261
+ planned = launchable(targets, env, prompt)
262
+
263
+ if planned is None:
264
+ # Editor agents have no prompt-on-launch, so the best we can do is hand
265
+ # over the text. Still better than "you're all set" and nothing to do.
266
+ console.print("\n[bold]Try this in your agent:[/bold]\n")
267
+ console.print(f" [cyan]{prompts.first_notebook_prompt(short=True)}[/cyan]")
268
+ return
269
+
270
+ confirm = deps.confirm
271
+ if confirm is None or not confirm("\nStart now?"):
272
+ console.print("\n[bold]Try this in your agent:[/bold]\n")
273
+ console.print(f" [cyan]{prompts.first_notebook_prompt(short=True)}[/cyan]")
274
+ return
275
+
276
+ console.print(f"\n[dim]→ {planned.display}[/dim]\n")
277
+ # exec, not spawn: the agent takes over this terminal. Anything else leaves
278
+ # a dead parent process holding a pipe the agent needs.
279
+ deps.exec_agent(planned.argv[0], list(planned.argv))
280
+
281
+
282
+ def run(
283
+ *,
284
+ env: Env,
285
+ console,
286
+ host_flag: str | None = None,
287
+ scope: Scope = Scope.GLOBAL,
288
+ agent: str | None = None,
289
+ yes: bool = False,
290
+ no_browser: bool = False,
291
+ dry_run: bool = False,
292
+ no_start: bool = False,
293
+ server_name: str = DEFAULT_SERVER_NAME,
294
+ environ: Mapping[str, str] | None = None,
295
+ render: Callable[[Plan], None] | None = None,
296
+ deps: Deps | None = None,
297
+ ) -> int:
298
+ """The onboarding flow. Returns a process exit code."""
299
+ deps = deps or Deps()
300
+ environ = os.environ if environ is None else environ
301
+ render = render or (lambda _plan: None)
302
+ interactive = deps.is_tty()
303
+
304
+ host = resolve_host(host_flag, environ)
305
+ console.print(f"\n [bold]Ama[/bold] · [cyan]{host}[/cyan]\n")
306
+
307
+ manifest = deps.discover(host)
308
+ if manifest is None:
309
+ # Not fatal: the manifest is an optimisation and the path is
310
+ # conventional. If the host is genuinely unreachable, login says so
311
+ # next, with a better message than this could give.
312
+ console.print(
313
+ f"[dim]Could not read {host}{discovery.DISCOVERY_PATH}; assuming defaults.[/dim]"
314
+ )
315
+ mcp_url = manifest.mcp_url if manifest else discovery.default_mcp_url(host)
316
+
317
+ if dry_run:
318
+ # Signing in is not free of consequence: it mints a real key server-side
319
+ # and writes credentials.json. --dry-run promises to change nothing, and
320
+ # that has to include the half that happens on the server.
321
+ creds = reusable_credentials(env.home, host)
322
+ if creds is None:
323
+ console.print("[dim]Would sign in via the browser and create a key.[/dim]")
324
+ creds = Credentials(host=host, api_key=_PLACEHOLDER_KEY, mcp_url=mcp_url)
325
+ else:
326
+ console.print(
327
+ f"[green]✓[/green] Already signed in as [bold]{creds.email or '?'}[/bold]"
328
+ )
329
+ else:
330
+ try:
331
+ creds = _sign_in(
332
+ env=env,
333
+ host=host,
334
+ mcp_url=mcp_url,
335
+ manifest=manifest,
336
+ no_browser=no_browser,
337
+ console=console,
338
+ deps=deps,
339
+ )
340
+ except auth.LoginError as exc:
341
+ console.print(f"\n[red]Sign-in failed:[/red] {exc}")
342
+ return EXIT_FAILED
343
+
344
+ targets = registerable(env, agent)
345
+ if not targets:
346
+ # Never dead-end.
347
+ console.print("\n[yellow]No supported coding agents detected.[/yellow]")
348
+ console.print("Register Ama by hand:\n")
349
+ console.print(manual_instructions(creds, server_name))
350
+ return EXIT_OK
351
+
352
+ console.print("\n Detected on this machine:")
353
+ for detection in targets:
354
+ console.print(
355
+ f" [green]●[/green] {detection.display_name} [dim]{detection.reason}[/dim]"
356
+ )
357
+
358
+ names = _join(d.display_name for d in targets)
359
+ if not (yes or dry_run):
360
+ if not interactive:
361
+ # Refuse rather than guess: this writes to config files we do not own.
362
+ console.print(
363
+ "\n[red]Refusing to modify agent configs without confirmation.[/red]\n"
364
+ "Re-run with [bold]--yes[/bold] in a non-interactive shell."
365
+ )
366
+ return EXIT_FAILED
367
+ confirm = deps.confirm
368
+ if confirm is None or not confirm(f"\n Install {scope.value}ly for {names}?"):
369
+ console.print("\n[yellow]Nothing was changed.[/yellow]")
370
+ return EXIT_OK
371
+
372
+ server = RemoteServer(url=mcp_url, token=creds.api_key, name=server_name)
373
+ console.print("")
374
+ failures = _register(
375
+ targets=targets,
376
+ server=server,
377
+ scope=scope,
378
+ env=env,
379
+ dry_run=dry_run,
380
+ console=console,
381
+ render=render,
382
+ deps=deps,
383
+ )
384
+
385
+ if dry_run:
386
+ console.print("\n[dim]--dry-run: nothing was changed.[/dim]")
387
+ return EXIT_OK
388
+
389
+ if failures:
390
+ console.print(f"\n[red]{failures} agent(s) could not be configured.[/red]")
391
+ return EXIT_FAILED
392
+
393
+ # Writing a config proves nothing. Connect with the key we just wrote.
394
+ result = deps.verify(mcp_url, creds.api_key)
395
+ if result.ok:
396
+ console.print(f" [green]✓[/green] {result.summary}")
397
+ else:
398
+ console.print(f"\n[red]✗ Registered, but the connection failed:[/red] {result.summary}")
399
+ if result.unauthorized:
400
+ console.print("[dim]The key may have been revoked. Try `ama login` again.[/dim]")
401
+ return EXIT_FAILED
402
+
403
+ console.print("\n[dim]Undo anytime: ama mcp remove[/dim]")
404
+
405
+ if no_start or not interactive:
406
+ return EXIT_OK
407
+ _offer_start(targets=targets, env=env, console=console, deps=deps)
408
+ return EXIT_OK
409
+
410
+
411
+ __all__ = [
412
+ "Deps",
413
+ "EXIT_FAILED",
414
+ "EXIT_OK",
415
+ "launchable",
416
+ "manual_instructions",
417
+ "registerable",
418
+ "resolve_host",
419
+ "reusable_credentials",
420
+ "run",
421
+ ]
ama_cli/prompts.py ADDED
@@ -0,0 +1,53 @@
1
+ """The seeded prompt — the content half of "Start now?".
2
+
3
+ This is copy, and it will need iterating, so it lives in one place rather than
4
+ inline in the flow.
5
+
6
+ Every tool named here is real and reachable in the mode Ama actually deploys
7
+ (`AMA_MODE=notebook`), checked against the `@mcp.tool` registrations in
8
+ `src/ama/mcp/server.py`. That matters more than it looks: a prompt naming a tool
9
+ that does not exist turns the payoff moment into the agent apologising. In
10
+ particular there is **no** `notebook.add_cell` / `notebook.edit_cell` — cells are
11
+ driven through `notebook.execute_code`, so the prompt must not imply otherwise.
12
+
13
+ The shape follows the loop Ama already documents to agents in `AGENTS.md` and
14
+ `docs/quickstart-for-agents.mdx`: bootstrap, open a notebook, surface the
15
+ browser_url, then run something real.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ # The payoff is not a configured MCP server -- the user cannot see that. It is a
21
+ # live notebook in their browser, built by their own agent, in one step. So the
22
+ # prompt's real job is to get `browser_url` on screen fast.
23
+ _FULL = """Use the ama MCP server to get me started.
24
+
25
+ 1. Call workspace.bootstrap to set up my workspace and see what's there.
26
+ 2. Create or open a notebook, then show me the browser_url it returns — that's
27
+ where I watch you work, live.
28
+ 3. Use notebook.execute_code to build something real in the kernel: load a small
29
+ sample dataset, explore it, and plot it. Render the plot as text/ASCII with
30
+ plotext so I can see it in your output too.
31
+ 4. Then tell me, in two sentences, what else I can do here.
32
+
33
+ Go ahead and do all of it now — show me the notebook link as soon as you have it."""
34
+
35
+ # For agents with no prompt-on-launch, where the user pastes this by hand. Long
36
+ # multi-line text is miserable to paste into a chat box, so this says the same
37
+ # thing in one line.
38
+ _SHORT = (
39
+ "Use the ama MCP server: call workspace.bootstrap, create a notebook, show me its "
40
+ "browser_url, then use notebook.execute_code to load and plot a small sample dataset "
41
+ "(render it as ASCII with plotext)."
42
+ )
43
+
44
+
45
+ def first_notebook_prompt(short: bool = False) -> str:
46
+ """The prompt that takes a fresh user to a live notebook.
47
+
48
+ `short` returns the one-line form for pasting by hand.
49
+ """
50
+ return _SHORT if short else _FULL
51
+
52
+
53
+ __all__ = ["first_notebook_prompt"]