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/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"]
|