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/verify.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Prove the registration actually works, by being a client.
|
|
2
|
+
|
|
3
|
+
Writing a config file proves nothing. The user finds out whether onboarding
|
|
4
|
+
worked when their agent tries a tool call and fails -- by which point they have
|
|
5
|
+
left the terminal and have no idea which of the moving parts is broken. So we
|
|
6
|
+
connect ourselves, with the key we just minted, against the URL we just wrote,
|
|
7
|
+
and count the tools.
|
|
8
|
+
|
|
9
|
+
This speaks MCP streamable-http (`transport="streamable-http"` in
|
|
10
|
+
`src/ama/api/app.py`, mounted at `/mcp` with path `/sse` -- the path is a
|
|
11
|
+
historical name, not the transport). Three JSON-RPC messages:
|
|
12
|
+
|
|
13
|
+
initialize -> capabilities, and a session id in a header
|
|
14
|
+
notifications/initialized -> the handshake's required ack
|
|
15
|
+
tools/list -> the thing we actually want
|
|
16
|
+
|
|
17
|
+
Deliberately hand-rolled over urllib rather than pulling in the `mcp` SDK: the
|
|
18
|
+
SDK is a heavy async dependency on the install path of a one-line onboarding
|
|
19
|
+
command, and this needs exactly three messages of a protocol that is stable.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
# The revision we advertise. Servers negotiate down and answer with their own,
|
|
31
|
+
# so a newer server is not a failure -- we never assert on the response version.
|
|
32
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
33
|
+
_TIMEOUT_SECONDS = 30
|
|
34
|
+
_SESSION_HEADER = "mcp-session-id"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class HttpResponse:
|
|
39
|
+
"""A raw HTTP response, injectable so tests need no server."""
|
|
40
|
+
|
|
41
|
+
status: int
|
|
42
|
+
body: str
|
|
43
|
+
headers: Mapping[str, str] = None # type: ignore[assignment]
|
|
44
|
+
|
|
45
|
+
def header(self, name: str) -> str | None:
|
|
46
|
+
"""Case-insensitive header lookup; HTTP header case is not guaranteed."""
|
|
47
|
+
if not self.headers:
|
|
48
|
+
return None
|
|
49
|
+
lowered = {k.lower(): v for k, v in self.headers.items()}
|
|
50
|
+
return lowered.get(name.lower())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class VerifyResult:
|
|
55
|
+
"""The outcome of a live connection attempt."""
|
|
56
|
+
|
|
57
|
+
ok: bool
|
|
58
|
+
tool_count: int = 0
|
|
59
|
+
detail: str = ""
|
|
60
|
+
unauthorized: bool = False
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def summary(self) -> str:
|
|
64
|
+
"""One line for the terminal."""
|
|
65
|
+
if self.ok:
|
|
66
|
+
return f"Connected — {self.tool_count} tools available"
|
|
67
|
+
return self.detail or "could not connect"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Rpc:
|
|
71
|
+
"""A JSON-RPC id sequence. Ids must be unique within a session."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
self._next = 0
|
|
75
|
+
|
|
76
|
+
def take(self) -> int:
|
|
77
|
+
self._next += 1
|
|
78
|
+
return self._next
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _headers(api_key: str, session_id: str | None) -> dict[str, str]:
|
|
82
|
+
"""Headers for every request in the handshake.
|
|
83
|
+
|
|
84
|
+
Accept must list *both* json and event-stream: the streamable-http spec lets
|
|
85
|
+
the server pick either per response, and FastMCP rejects a request that does
|
|
86
|
+
not accept both with a 406 -- which would look like a broken key.
|
|
87
|
+
"""
|
|
88
|
+
headers = {
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
"Accept": "application/json, text/event-stream",
|
|
91
|
+
"Authorization": f"Bearer {api_key}",
|
|
92
|
+
}
|
|
93
|
+
if session_id:
|
|
94
|
+
headers[_SESSION_HEADER] = session_id
|
|
95
|
+
return headers
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_rpc_body(body: str, content_type: str | None) -> dict | None:
|
|
99
|
+
"""Pull a JSON-RPC envelope out of a response body.
|
|
100
|
+
|
|
101
|
+
The same logical response arrives as either bare JSON or an SSE frame
|
|
102
|
+
depending on how the server felt, so both are handled. Returns None when the
|
|
103
|
+
body holds no recognisable envelope.
|
|
104
|
+
"""
|
|
105
|
+
if content_type and "text/event-stream" in content_type.lower():
|
|
106
|
+
for line in body.splitlines():
|
|
107
|
+
if not line.startswith("data:"):
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
payload = json.loads(line[len("data:") :].strip())
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
continue
|
|
113
|
+
if isinstance(payload, dict) and ("result" in payload or "error" in payload):
|
|
114
|
+
return payload
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
payload = json.loads(body)
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
return None
|
|
120
|
+
return payload if isinstance(payload, dict) else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _rpc_error(payload: dict) -> str | None:
|
|
124
|
+
"""The human-readable half of a JSON-RPC error, if the payload is one."""
|
|
125
|
+
err = payload.get("error")
|
|
126
|
+
if not isinstance(err, dict):
|
|
127
|
+
return None
|
|
128
|
+
message = err.get("message") or "unknown error"
|
|
129
|
+
return f"{message} (code {err.get('code')})" if "code" in err else str(message)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def verify(mcp_url: str, api_key: str, *, transport=None) -> VerifyResult:
|
|
133
|
+
"""Connect to `mcp_url` with `api_key` and list tools.
|
|
134
|
+
|
|
135
|
+
Never raises: a verification failure is information to show the user, not an
|
|
136
|
+
exception to handle. `transport` is injected so tests drive the whole
|
|
137
|
+
handshake without a network.
|
|
138
|
+
"""
|
|
139
|
+
post = _post if transport is None else transport
|
|
140
|
+
rpc = _Rpc()
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
init = post(
|
|
144
|
+
mcp_url,
|
|
145
|
+
json.dumps(
|
|
146
|
+
{
|
|
147
|
+
"jsonrpc": "2.0",
|
|
148
|
+
"id": rpc.take(),
|
|
149
|
+
"method": "initialize",
|
|
150
|
+
"params": {
|
|
151
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
152
|
+
"capabilities": {},
|
|
153
|
+
"clientInfo": {"name": "ama-cli", "version": "0.1.0"},
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
).encode(),
|
|
157
|
+
_headers(api_key, None),
|
|
158
|
+
)
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
return VerifyResult(ok=False, detail=f"could not reach {mcp_url}: {exc}")
|
|
161
|
+
|
|
162
|
+
if init.status in (401, 403):
|
|
163
|
+
# The single most valuable thing this function catches: a key that was
|
|
164
|
+
# minted but does not authenticate here.
|
|
165
|
+
return VerifyResult(
|
|
166
|
+
ok=False,
|
|
167
|
+
unauthorized=True,
|
|
168
|
+
detail="the key was rejected by the MCP endpoint (401)",
|
|
169
|
+
)
|
|
170
|
+
if init.status >= 400:
|
|
171
|
+
return VerifyResult(ok=False, detail=f"initialize failed (HTTP {init.status})")
|
|
172
|
+
|
|
173
|
+
payload = parse_rpc_body(init.body, init.header("content-type"))
|
|
174
|
+
if payload is None:
|
|
175
|
+
return VerifyResult(ok=False, detail="initialize returned no JSON-RPC response")
|
|
176
|
+
if (err := _rpc_error(payload)) is not None:
|
|
177
|
+
return VerifyResult(ok=False, detail=f"initialize failed: {err}")
|
|
178
|
+
|
|
179
|
+
# Absent in stateless mode (dev), present in stateful (staging/prod). Echoing
|
|
180
|
+
# it when given is what keeps the next two calls in the same session.
|
|
181
|
+
session_id = init.header(_SESSION_HEADER)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
# Required by the spec before normal operation. It is a notification, so
|
|
185
|
+
# there is no response to read.
|
|
186
|
+
#
|
|
187
|
+
# FastMCP -- what Ama runs -- is measurably fine without it (verified by
|
|
188
|
+
# deleting this call and watching tests/install/test_verify_against_real_fastmcp.py
|
|
189
|
+
# still pass). We send it anyway: the spec asks for it, and `--host`
|
|
190
|
+
# means the far end is not guaranteed to be FastMCP.
|
|
191
|
+
post(
|
|
192
|
+
mcp_url,
|
|
193
|
+
json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}).encode(),
|
|
194
|
+
_headers(api_key, session_id),
|
|
195
|
+
)
|
|
196
|
+
listed = post(
|
|
197
|
+
mcp_url,
|
|
198
|
+
json.dumps(
|
|
199
|
+
{"jsonrpc": "2.0", "id": rpc.take(), "method": "tools/list", "params": {}}
|
|
200
|
+
).encode(),
|
|
201
|
+
_headers(api_key, session_id),
|
|
202
|
+
)
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
return VerifyResult(ok=False, detail=f"tools/list failed: {exc}")
|
|
205
|
+
|
|
206
|
+
if listed.status in (401, 403):
|
|
207
|
+
return VerifyResult(
|
|
208
|
+
ok=False, unauthorized=True, detail="the key was rejected by the MCP endpoint (401)"
|
|
209
|
+
)
|
|
210
|
+
if listed.status >= 400:
|
|
211
|
+
return VerifyResult(ok=False, detail=f"tools/list failed (HTTP {listed.status})")
|
|
212
|
+
|
|
213
|
+
payload = parse_rpc_body(listed.body, listed.header("content-type"))
|
|
214
|
+
if payload is None:
|
|
215
|
+
return VerifyResult(ok=False, detail="tools/list returned no JSON-RPC response")
|
|
216
|
+
if (err := _rpc_error(payload)) is not None:
|
|
217
|
+
return VerifyResult(ok=False, detail=f"tools/list failed: {err}")
|
|
218
|
+
|
|
219
|
+
result = payload.get("result")
|
|
220
|
+
tools = result.get("tools") if isinstance(result, dict) else None
|
|
221
|
+
if not isinstance(tools, list):
|
|
222
|
+
return VerifyResult(ok=False, detail="tools/list returned no tools")
|
|
223
|
+
if not tools:
|
|
224
|
+
# Authenticated, but the agent would have nothing to call. That is a
|
|
225
|
+
# broken setup even though nothing errored, so it must not read as
|
|
226
|
+
# success -- this is precisely the "wrote the file, declared victory"
|
|
227
|
+
# failure the step exists to catch.
|
|
228
|
+
return VerifyResult(
|
|
229
|
+
ok=False, tool_count=0, detail="connected, but the server exposed no tools"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return VerifyResult(ok=True, tool_count=len(tools), detail="connected")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _post(url: str, body: bytes, headers: dict[str, str]) -> HttpResponse:
|
|
236
|
+
"""POST and capture the response, treating HTTP errors as data.
|
|
237
|
+
|
|
238
|
+
An HTTPError is a normal outcome here (401 is the case we most want to
|
|
239
|
+
report), so it is unwrapped into a response rather than propagated.
|
|
240
|
+
"""
|
|
241
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
242
|
+
try:
|
|
243
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS) as resp:
|
|
244
|
+
return HttpResponse(
|
|
245
|
+
status=resp.status,
|
|
246
|
+
body=resp.read().decode("utf-8", "replace"),
|
|
247
|
+
headers=dict(resp.headers),
|
|
248
|
+
)
|
|
249
|
+
except urllib.error.HTTPError as exc:
|
|
250
|
+
return HttpResponse(
|
|
251
|
+
status=exc.code,
|
|
252
|
+
body=exc.read().decode("utf-8", "replace"),
|
|
253
|
+
headers=dict(exc.headers or {}),
|
|
254
|
+
)
|
|
255
|
+
except urllib.error.URLError as exc:
|
|
256
|
+
raise OSError(str(exc.reason)) from exc
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
__all__ = ["HttpResponse", "PROTOCOL_VERSION", "VerifyResult", "parse_rpc_body", "verify"]
|
|
@@ -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,20 @@
|
|
|
1
|
+
ama_cli/__init__.py,sha256=tL4ADf3DjskgaheudFxWWAQdyN5yF_Y2isrV5O_6OD4,235
|
|
2
|
+
ama_cli/auth.py,sha256=ICr3AZB6JzKfOAhwNHXaICIDTlA6g-_O0mRgpdT6tCg,9570
|
|
3
|
+
ama_cli/cli.py,sha256=UXlzQn_Q_sJO0zmY9A0R-aheAPnlTD4gg1B5MEHYYAM,14924
|
|
4
|
+
ama_cli/credentials.py,sha256=laF5xNmU_hS0Z-yKuo6--qgYs2S-000oBKI9KU_8dJI,3793
|
|
5
|
+
ama_cli/discovery.py,sha256=36OtjaGphKYQIZSoPa-z1qAYqh2_0hBvKwvFZim2x4g,4095
|
|
6
|
+
ama_cli/launch.py,sha256=PjDat9pjIKYffBVC8NJFF5_IuKhtjZ_5tS2OYSL2Jt0,2882
|
|
7
|
+
ama_cli/onboard.py,sha256=tH0C0jXj71AsFmeMnjqFy15Uiu6k0g10xeKOBFth4oA,14931
|
|
8
|
+
ama_cli/prompts.py,sha256=rx3R5Y06BI8cw7vFQD0eFvgv6t2IItPEyBH4yI40LoU,2355
|
|
9
|
+
ama_cli/verify.py,sha256=1zBRr933xX5VA8uEg8lm9z8lHqiSFsGxIjZPbzHH5HY,9834
|
|
10
|
+
ama_cli/agents/__init__.py,sha256=J_IA41xrdlGQbgkMvKLC6Nbb3hRGaqiaYwOjMBUv3wI,428
|
|
11
|
+
ama_cli/agents/base.py,sha256=4A3QSgSbiHMJnBBdrDeTz3iM_BzN8vmXQJBeyYBnnk0,6401
|
|
12
|
+
ama_cli/agents/mcp.py,sha256=8v25hgWJmExgrtXBw_DPd6iLG9hngao-hXSD0Hqo_1Y,9305
|
|
13
|
+
ama_cli/agents/registry.py,sha256=06zWqhsnvg61hR_rtcVs-OAam7Ty7tKf0Vj8KB5BLMk,5950
|
|
14
|
+
ama_cli/agents/adapters/__init__.py,sha256=fTxHUPtCyzNJ_m_L49szSjIVjaeboQgD52XYodxxkmk,1320
|
|
15
|
+
ama_cli/agents/adapters/cli_backed.py,sha256=aCRYdovv2cul0xIfUSgoK_hFTeDnNw-_pVnMLADBTKk,9522
|
|
16
|
+
ama_cli/agents/adapters/cursor.py,sha256=ys0KataR4uJlAP18WGXDlCTiwl8EigQoYn9SIPQSexE,2911
|
|
17
|
+
ama_cli-0.1.0.dist-info/METADATA,sha256=9tIgxbELoge4I3gftOQaD6jNSeHEMIIwtA0uXH9eRSM,2640
|
|
18
|
+
ama_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
19
|
+
ama_cli-0.1.0.dist-info/entry_points.txt,sha256=IkXhsfKO4Xod18wjcgMDUPlQFUp6uMiwzxilAoLFvv0,40
|
|
20
|
+
ama_cli-0.1.0.dist-info/RECORD,,
|