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/auth.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Browser loopback login for the public CLI.
|
|
2
|
+
|
|
3
|
+
OAuth 2.0 authorization code + PKCE over a loopback redirect (RFC 8252), the
|
|
4
|
+
same shape as `gh auth login`:
|
|
5
|
+
|
|
6
|
+
1. bind 127.0.0.1:0 -- the OS picks a free port
|
|
7
|
+
2. open the browser at {host}/api/v1/cli/auth?redirect_uri=...&state=...
|
|
8
|
+
&code_challenge=S256(verifier)
|
|
9
|
+
3. the human signs in (WorkOS) and is redirected back to the listener
|
|
10
|
+
4. check `state`, then POST {code, verifier} for an ama_ key
|
|
11
|
+
|
|
12
|
+
The key is never in a URL: the browser only carries a code, and the code is
|
|
13
|
+
useless without the verifier this process kept. URLs land in history, Referer
|
|
14
|
+
headers and proxy logs.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
import hashlib
|
|
21
|
+
import http.server
|
|
22
|
+
import secrets
|
|
23
|
+
import socket
|
|
24
|
+
import threading
|
|
25
|
+
import urllib.parse
|
|
26
|
+
import webbrowser
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
|
|
30
|
+
from ama_cli.credentials import Credentials
|
|
31
|
+
|
|
32
|
+
# Long enough for a real sign-in (including creating an account), short enough
|
|
33
|
+
# that a walked-away terminal does not hang forever.
|
|
34
|
+
LOGIN_TIMEOUT_SECONDS = 180
|
|
35
|
+
_VERIFIER_BYTES = 48 # -> 64 base64url chars, inside RFC 7636's 43..128
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LoginError(Exception):
|
|
39
|
+
"""Login could not complete."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class PkcePair:
|
|
44
|
+
"""A PKCE verifier and its S256 challenge."""
|
|
45
|
+
|
|
46
|
+
verifier: str
|
|
47
|
+
challenge: str
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def generate(cls) -> PkcePair:
|
|
51
|
+
verifier = secrets.token_urlsafe(_VERIFIER_BYTES)
|
|
52
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
53
|
+
challenge = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
|
54
|
+
return cls(verifier=verifier, challenge=challenge)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class _Callback:
|
|
59
|
+
"""What the browser handed back."""
|
|
60
|
+
|
|
61
|
+
code: str | None = None
|
|
62
|
+
state: str | None = None
|
|
63
|
+
error: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_SUCCESS_HTML = """<!doctype html>
|
|
67
|
+
<meta charset="utf-8"><title>Ama CLI</title>
|
|
68
|
+
<style>
|
|
69
|
+
body{font-family:system-ui,-apple-system,sans-serif;display:grid;place-items:center;
|
|
70
|
+
height:100vh;margin:0;background:#0b0b0f;color:#e8e8ee}
|
|
71
|
+
.c{text-align:center}.t{font-size:1.5rem;font-weight:600;margin-bottom:.5rem}
|
|
72
|
+
.s{opacity:.6}
|
|
73
|
+
</style>
|
|
74
|
+
<div class="c"><div class="t">You're signed in</div>
|
|
75
|
+
<div class="s">Return to your terminal — you can close this tab.</div></div>
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
_FAILURE_HTML = """<!doctype html>
|
|
79
|
+
<meta charset="utf-8"><title>Ama CLI</title>
|
|
80
|
+
<style>
|
|
81
|
+
body{font-family:system-ui,-apple-system,sans-serif;display:grid;place-items:center;
|
|
82
|
+
height:100vh;margin:0;background:#0b0b0f;color:#e8e8ee}
|
|
83
|
+
.c{text-align:center}.t{font-size:1.5rem;font-weight:600;margin-bottom:.5rem}
|
|
84
|
+
.s{opacity:.6}
|
|
85
|
+
</style>
|
|
86
|
+
<div class="c"><div class="t">Sign-in failed</div>
|
|
87
|
+
<div class="s">Return to your terminal for details.</div></div>
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LoopbackListener:
|
|
92
|
+
"""A one-shot HTTP server on 127.0.0.1 that catches the OAuth redirect.
|
|
93
|
+
|
|
94
|
+
Binds the loopback interface specifically, never 0.0.0.0 -- the latter would
|
|
95
|
+
expose the listener, and the authorization code, to the whole local network.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(self) -> None:
|
|
99
|
+
self._result = _Callback()
|
|
100
|
+
self._received = threading.Event()
|
|
101
|
+
self._server = self._build_server()
|
|
102
|
+
|
|
103
|
+
def _build_server(self) -> http.server.HTTPServer:
|
|
104
|
+
outer = self
|
|
105
|
+
|
|
106
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
107
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib naming
|
|
108
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
109
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
110
|
+
outer._result = _Callback(
|
|
111
|
+
code=_first(params, "code"),
|
|
112
|
+
state=_first(params, "state"),
|
|
113
|
+
error=_first(params, "error"),
|
|
114
|
+
)
|
|
115
|
+
ok = outer._result.code is not None and outer._result.error is None
|
|
116
|
+
body = (_SUCCESS_HTML if ok else _FAILURE_HTML).encode("utf-8")
|
|
117
|
+
self.send_response(200)
|
|
118
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
119
|
+
self.send_header("Content-Length", str(len(body)))
|
|
120
|
+
self.end_headers()
|
|
121
|
+
self.wfile.write(body)
|
|
122
|
+
outer._received.set()
|
|
123
|
+
|
|
124
|
+
def log_message(self, *args) -> None:
|
|
125
|
+
"""Silence the stdlib access log; it would corrupt CLI output."""
|
|
126
|
+
|
|
127
|
+
# Port 0: the OS picks a free one. Nothing is pre-registered, so there is
|
|
128
|
+
# no fixed port to collide with another CLI or a local dev server.
|
|
129
|
+
return http.server.HTTPServer(("127.0.0.1", 0), Handler)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def port(self) -> int:
|
|
133
|
+
return self._server.server_port
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def redirect_uri(self) -> str:
|
|
137
|
+
return f"http://127.0.0.1:{self.port}/cb"
|
|
138
|
+
|
|
139
|
+
def __enter__(self) -> LoopbackListener:
|
|
140
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
141
|
+
self._thread.start()
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *exc) -> None:
|
|
145
|
+
self._server.shutdown()
|
|
146
|
+
self._server.server_close()
|
|
147
|
+
|
|
148
|
+
def wait(self, timeout: float = LOGIN_TIMEOUT_SECONDS) -> _Callback:
|
|
149
|
+
"""Block until the browser calls back, or time out.
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
LoginError: on timeout, so the caller can fall back rather than hang.
|
|
153
|
+
"""
|
|
154
|
+
if not self._received.wait(timeout):
|
|
155
|
+
raise LoginError(
|
|
156
|
+
f"timed out after {int(timeout)}s waiting for the browser. "
|
|
157
|
+
"Use --no-browser to sign in by email instead."
|
|
158
|
+
)
|
|
159
|
+
return self._result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _first(params: dict[str, list[str]], key: str) -> str | None:
|
|
163
|
+
values = params.get(key)
|
|
164
|
+
return values[0] if values else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def authorize_url(
|
|
168
|
+
host: str, *, redirect_uri: str, state: str, challenge: str, client_name: str
|
|
169
|
+
) -> str:
|
|
170
|
+
"""The browser URL that starts the flow."""
|
|
171
|
+
query = urllib.parse.urlencode(
|
|
172
|
+
{
|
|
173
|
+
"redirect_uri": redirect_uri,
|
|
174
|
+
"state": state,
|
|
175
|
+
"code_challenge": challenge,
|
|
176
|
+
"code_challenge_method": "S256",
|
|
177
|
+
"client_name": client_name,
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
return f"{host.rstrip('/')}/api/v1/cli/auth?{query}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def default_client_name() -> str:
|
|
184
|
+
"""A key name the user will recognise in settings: the machine it lives on."""
|
|
185
|
+
try:
|
|
186
|
+
hostname = socket.gethostname().split(".")[0]
|
|
187
|
+
except OSError:
|
|
188
|
+
hostname = "unknown"
|
|
189
|
+
return f"ama-cli@{hostname}"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def login(
|
|
193
|
+
host: str,
|
|
194
|
+
*,
|
|
195
|
+
open_browser=webbrowser.open,
|
|
196
|
+
post_token=None,
|
|
197
|
+
timeout: float = LOGIN_TIMEOUT_SECONDS,
|
|
198
|
+
on_url=None,
|
|
199
|
+
) -> Credentials:
|
|
200
|
+
"""Run the loopback flow and return credentials.
|
|
201
|
+
|
|
202
|
+
`open_browser` and `post_token` are injected so tests can drive the whole
|
|
203
|
+
flow without a browser or a network.
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
LoginError: on timeout, state mismatch, or a rejected exchange.
|
|
207
|
+
"""
|
|
208
|
+
if post_token is None:
|
|
209
|
+
post_token = _post_token
|
|
210
|
+
|
|
211
|
+
pkce = PkcePair.generate()
|
|
212
|
+
state = secrets.token_urlsafe(16)
|
|
213
|
+
client_name = default_client_name()
|
|
214
|
+
|
|
215
|
+
with LoopbackListener() as listener:
|
|
216
|
+
url = authorize_url(
|
|
217
|
+
host,
|
|
218
|
+
redirect_uri=listener.redirect_uri,
|
|
219
|
+
state=state,
|
|
220
|
+
challenge=pkce.challenge,
|
|
221
|
+
client_name=client_name,
|
|
222
|
+
)
|
|
223
|
+
if on_url:
|
|
224
|
+
on_url(url)
|
|
225
|
+
open_browser(url)
|
|
226
|
+
|
|
227
|
+
callback = listener.wait(timeout)
|
|
228
|
+
|
|
229
|
+
if callback.error:
|
|
230
|
+
raise LoginError(f"authorization failed: {callback.error}")
|
|
231
|
+
if callback.code is None:
|
|
232
|
+
raise LoginError("no authorization code was returned")
|
|
233
|
+
# Reject a callback from a different session. Compared with compare_digest
|
|
234
|
+
# rather than == so the check is not a timing oracle.
|
|
235
|
+
if callback.state is None or not secrets.compare_digest(callback.state, state):
|
|
236
|
+
raise LoginError("state mismatch — ignoring a callback this login did not start")
|
|
237
|
+
|
|
238
|
+
payload = post_token(
|
|
239
|
+
f"{host.rstrip('/')}/api/v1/cli/auth/token",
|
|
240
|
+
{
|
|
241
|
+
"code": callback.code,
|
|
242
|
+
"code_verifier": pkce.verifier,
|
|
243
|
+
"redirect_uri": f"http://127.0.0.1:{_port_of(listener.redirect_uri)}/cb",
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return Credentials(
|
|
248
|
+
host=payload.get("host", host),
|
|
249
|
+
api_key=payload["api_key"],
|
|
250
|
+
mcp_url=payload["mcp_url"],
|
|
251
|
+
email=payload.get("email"),
|
|
252
|
+
tenant_id=payload.get("tenant_id"),
|
|
253
|
+
created_at=datetime.now(UTC).isoformat(),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _port_of(redirect_uri: str) -> int:
|
|
258
|
+
return urllib.parse.urlparse(redirect_uri).port or 0
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _post_token(url: str, body: dict) -> dict:
|
|
262
|
+
"""Exchange the code for a key.
|
|
263
|
+
|
|
264
|
+
urllib rather than httpx: this is the only HTTP the CLI makes, and one
|
|
265
|
+
stdlib call is cheaper than a dependency on the install path of a one-line
|
|
266
|
+
onboarding command.
|
|
267
|
+
"""
|
|
268
|
+
import json
|
|
269
|
+
import urllib.error
|
|
270
|
+
import urllib.request
|
|
271
|
+
|
|
272
|
+
data = json.dumps(body).encode()
|
|
273
|
+
req = urllib.request.Request(
|
|
274
|
+
url, data=data, headers={"Content-Type": "application/json"}, method="POST"
|
|
275
|
+
)
|
|
276
|
+
try:
|
|
277
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
278
|
+
return json.loads(resp.read())
|
|
279
|
+
except urllib.error.HTTPError as exc:
|
|
280
|
+
detail = _detail(exc)
|
|
281
|
+
raise LoginError(f"token exchange failed ({exc.code}): {detail}") from exc
|
|
282
|
+
except urllib.error.URLError as exc:
|
|
283
|
+
raise LoginError(f"could not reach {url}: {exc.reason}") from exc
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _detail(exc) -> str:
|
|
287
|
+
import json
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
return json.loads(exc.read()).get("detail", "unknown error")
|
|
291
|
+
except Exception:
|
|
292
|
+
return "unknown error"
|