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/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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ama = ama_cli.cli:app