aethis-cli 0.3.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.
- aethis_cli/__init__.py +3 -0
- aethis_cli/__main__.py +4 -0
- aethis_cli/_version.py +3 -0
- aethis_cli/auth.py +203 -0
- aethis_cli/client.py +181 -0
- aethis_cli/commands/__init__.py +0 -0
- aethis_cli/commands/account_cmd.py +280 -0
- aethis_cli/commands/bundles_cmd.py +115 -0
- aethis_cli/commands/decide_cmd.py +149 -0
- aethis_cli/commands/explain_cmd.py +45 -0
- aethis_cli/commands/fields_cmd.py +47 -0
- aethis_cli/commands/generate_cmd.py +196 -0
- aethis_cli/commands/guidance_cmd.py +177 -0
- aethis_cli/commands/init_cmd.py +60 -0
- aethis_cli/commands/login_cmd.py +158 -0
- aethis_cli/commands/projects_cmd.py +136 -0
- aethis_cli/commands/publish_cmd.py +90 -0
- aethis_cli/commands/status_cmd.py +46 -0
- aethis_cli/commands/test_cmd.py +80 -0
- aethis_cli/commands/whoami_cmd.py +80 -0
- aethis_cli/config.py +151 -0
- aethis_cli/errors.py +20 -0
- aethis_cli/main.py +149 -0
- aethis_cli/output.py +28 -0
- aethis_cli/py.typed +0 -0
- aethis_cli-0.3.0.dist-info/METADATA +269 -0
- aethis_cli-0.3.0.dist-info/RECORD +31 -0
- aethis_cli-0.3.0.dist-info/WHEEL +5 -0
- aethis_cli-0.3.0.dist-info/entry_points.txt +2 -0
- aethis_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- aethis_cli-0.3.0.dist-info/top_level.txt +1 -0
aethis_cli/__init__.py
ADDED
aethis_cli/__main__.py
ADDED
aethis_cli/_version.py
ADDED
aethis_cli/auth.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Browser-based OAuth/PKCE authentication with Clerk for CLI key creation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import secrets
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from aethis_cli.errors import AuthenticationError
|
|
18
|
+
|
|
19
|
+
_CALLBACK_PORT = 9876
|
|
20
|
+
_SUCCESS_HTML = """\
|
|
21
|
+
<!DOCTYPE html>
|
|
22
|
+
<html><head><title>Aethis CLI</title></head>
|
|
23
|
+
<body style="font-family:system-ui;display:flex;justify-content:center;align-items:center;height:90vh">
|
|
24
|
+
<div style="text-align:center">
|
|
25
|
+
<h2>✓ Sign-in received</h2>
|
|
26
|
+
<p>Return to your terminal to complete setup.</p>
|
|
27
|
+
</div></body></html>"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def generate_pkce_pair() -> tuple[str, str]:
|
|
31
|
+
"""Generate (code_verifier, code_challenge) per RFC 7636."""
|
|
32
|
+
verifier = secrets.token_urlsafe(64)[:128]
|
|
33
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
34
|
+
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
|
35
|
+
return verifier, challenge
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _AuthHTTPServer(HTTPServer):
|
|
39
|
+
"""HTTPServer subclass with typed auth attributes."""
|
|
40
|
+
|
|
41
|
+
auth_code: Optional[str] = None
|
|
42
|
+
auth_state: Optional[str] = None
|
|
43
|
+
auth_error: Optional[str] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
47
|
+
"""Handler that captures the OAuth callback, ignoring other requests (e.g. favicon)."""
|
|
48
|
+
|
|
49
|
+
server: _AuthHTTPServer
|
|
50
|
+
|
|
51
|
+
def do_GET(self) -> None: # noqa: N802
|
|
52
|
+
parsed = urlparse(self.path)
|
|
53
|
+
if parsed.path != "/callback":
|
|
54
|
+
# Ignore favicon and other requests
|
|
55
|
+
self.send_response(404)
|
|
56
|
+
self.end_headers()
|
|
57
|
+
return
|
|
58
|
+
qs = parse_qs(parsed.query)
|
|
59
|
+
self.server.auth_code = qs.get("code", [None])[0]
|
|
60
|
+
self.server.auth_state = qs.get("state", [None])[0]
|
|
61
|
+
self.server.auth_error = qs.get("error", [None])[0]
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-Type", "text/html")
|
|
64
|
+
self.end_headers()
|
|
65
|
+
self.wfile.write(_SUCCESS_HTML.encode())
|
|
66
|
+
|
|
67
|
+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
|
|
68
|
+
pass # Suppress request logging
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class OAuthCallbackServer:
|
|
72
|
+
"""Ephemeral HTTP server on 127.0.0.1 to catch Clerk's redirect."""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self._server: Optional[_AuthHTTPServer] = None
|
|
76
|
+
self.port: int = 0
|
|
77
|
+
|
|
78
|
+
def _serve_until_auth(self) -> None:
|
|
79
|
+
"""Handle requests until we get the auth callback or server is closed."""
|
|
80
|
+
assert self._server is not None
|
|
81
|
+
while not (self._server.auth_code or self._server.auth_error):
|
|
82
|
+
self._server.handle_request()
|
|
83
|
+
|
|
84
|
+
def start(self) -> int:
|
|
85
|
+
"""Bind to port 9876 and start serving in a background thread.
|
|
86
|
+
|
|
87
|
+
Returns the port number. Uses a fixed port to match the redirect URI
|
|
88
|
+
registered with the OAuth provider.
|
|
89
|
+
"""
|
|
90
|
+
port = _CALLBACK_PORT
|
|
91
|
+
try:
|
|
92
|
+
self._server = _AuthHTTPServer(("127.0.0.1", port), _CallbackHandler)
|
|
93
|
+
self._server.timeout = 5.0 # Per-request timeout for the loop
|
|
94
|
+
self.port = port
|
|
95
|
+
thread = threading.Thread(target=self._serve_until_auth, daemon=True)
|
|
96
|
+
thread.start()
|
|
97
|
+
return port
|
|
98
|
+
except OSError:
|
|
99
|
+
raise AuthenticationError(
|
|
100
|
+
f"Port {port} is already in use. Close the process using it and try again."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def result(self, timeout: float) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
104
|
+
"""Wait for the callback and return (code, state, error)."""
|
|
105
|
+
if not self._server:
|
|
106
|
+
raise AuthenticationError("Server not started")
|
|
107
|
+
|
|
108
|
+
deadline = time.monotonic() + timeout
|
|
109
|
+
while time.monotonic() < deadline:
|
|
110
|
+
code = self._server.auth_code
|
|
111
|
+
state = self._server.auth_state
|
|
112
|
+
error = self._server.auth_error
|
|
113
|
+
if code or error:
|
|
114
|
+
return code, state, error
|
|
115
|
+
time.sleep(0.2)
|
|
116
|
+
return None, None, None
|
|
117
|
+
|
|
118
|
+
def shutdown(self) -> None:
|
|
119
|
+
if self._server:
|
|
120
|
+
self._server.server_close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def authenticate_with_clerk(
|
|
124
|
+
clerk_domain: str,
|
|
125
|
+
client_id: str,
|
|
126
|
+
timeout: int = 120,
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Run full OAuth/PKCE flow and return an access token.
|
|
129
|
+
|
|
130
|
+
Opens the user's browser to the Clerk sign-in page. After authentication,
|
|
131
|
+
Clerk redirects to a localhost callback. The authorization code is exchanged
|
|
132
|
+
for an access token.
|
|
133
|
+
|
|
134
|
+
Raises AuthenticationError on failure or timeout.
|
|
135
|
+
"""
|
|
136
|
+
verifier, challenge = generate_pkce_pair()
|
|
137
|
+
state = secrets.token_urlsafe(32)
|
|
138
|
+
|
|
139
|
+
server = OAuthCallbackServer()
|
|
140
|
+
port = server.start()
|
|
141
|
+
|
|
142
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
143
|
+
authorize_url = f"https://{clerk_domain}/oauth/authorize?" + urlencode({
|
|
144
|
+
"response_type": "code",
|
|
145
|
+
"client_id": client_id,
|
|
146
|
+
"redirect_uri": redirect_uri,
|
|
147
|
+
"scope": "profile email",
|
|
148
|
+
"code_challenge": challenge,
|
|
149
|
+
"code_challenge_method": "S256",
|
|
150
|
+
"state": state,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
opened = webbrowser.open(authorize_url)
|
|
155
|
+
if not opened:
|
|
156
|
+
raise OSError("webbrowser.open returned False")
|
|
157
|
+
except OSError:
|
|
158
|
+
# Headless / SSH fallback
|
|
159
|
+
from aethis_cli.output import console
|
|
160
|
+
|
|
161
|
+
console.print("\n[yellow]Could not open browser automatically.[/yellow]")
|
|
162
|
+
console.print("Open this URL in your browser:\n")
|
|
163
|
+
console.print(f" [bold]{authorize_url}[/bold]\n")
|
|
164
|
+
|
|
165
|
+
code, returned_state, error = server.result(timeout)
|
|
166
|
+
server.shutdown()
|
|
167
|
+
|
|
168
|
+
if error:
|
|
169
|
+
raise AuthenticationError(f"Clerk returned error: {error}")
|
|
170
|
+
if not code:
|
|
171
|
+
raise AuthenticationError(
|
|
172
|
+
f"Authentication timed out after {timeout}s. "
|
|
173
|
+
"Run the command again or use 'aethis login' to paste a key directly."
|
|
174
|
+
)
|
|
175
|
+
if returned_state != state:
|
|
176
|
+
raise AuthenticationError("State mismatch — possible CSRF attack. Aborting.")
|
|
177
|
+
|
|
178
|
+
# Exchange authorization code for access token
|
|
179
|
+
token_url = f"https://{clerk_domain}/oauth/token"
|
|
180
|
+
resp = httpx.post(
|
|
181
|
+
token_url,
|
|
182
|
+
data={
|
|
183
|
+
"grant_type": "authorization_code",
|
|
184
|
+
"code": code,
|
|
185
|
+
"redirect_uri": redirect_uri,
|
|
186
|
+
"client_id": client_id,
|
|
187
|
+
"code_verifier": verifier,
|
|
188
|
+
},
|
|
189
|
+
timeout=15.0,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if resp.status_code != 200:
|
|
193
|
+
raise AuthenticationError(
|
|
194
|
+
f"Token exchange failed (HTTP {resp.status_code}). "
|
|
195
|
+
"Check your Clerk OAuth configuration and try again."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
token_data = resp.json()
|
|
199
|
+
access_token = token_data.get("access_token")
|
|
200
|
+
if not access_token:
|
|
201
|
+
raise AuthenticationError("No access_token in token response")
|
|
202
|
+
|
|
203
|
+
return access_token
|
aethis_cli/client.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Thin HTTP client for the Aethis developer API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from aethis_cli.errors import AethisAPIError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AethisClient:
|
|
14
|
+
"""Synchronous client wrapping all Aethis API endpoints."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: str,
|
|
19
|
+
base_url: str = "https://api.aethis.ai",
|
|
20
|
+
anthropic_key: Optional[str] = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
headers: dict[str, str] = {"X-API-Key": api_key}
|
|
23
|
+
if anthropic_key:
|
|
24
|
+
headers["X-Anthropic-Key"] = anthropic_key
|
|
25
|
+
self._client = httpx.Client(
|
|
26
|
+
base_url=base_url,
|
|
27
|
+
headers=headers,
|
|
28
|
+
timeout=60.0,
|
|
29
|
+
verify=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
self._client.close()
|
|
34
|
+
|
|
35
|
+
def __enter__(self) -> "AethisClient":
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def __exit__(self, *args: Any) -> None:
|
|
39
|
+
self.close()
|
|
40
|
+
|
|
41
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
42
|
+
resp = self._client.request(method, path, **kwargs)
|
|
43
|
+
if resp.status_code >= 400:
|
|
44
|
+
try:
|
|
45
|
+
detail = resp.json().get("detail", resp.text)
|
|
46
|
+
except (ValueError, KeyError):
|
|
47
|
+
detail = resp.text or f"HTTP {resp.status_code}"
|
|
48
|
+
raise AethisAPIError(resp.status_code, detail)
|
|
49
|
+
if resp.status_code == 204:
|
|
50
|
+
return {}
|
|
51
|
+
return resp.json()
|
|
52
|
+
|
|
53
|
+
# -- Decision API --
|
|
54
|
+
|
|
55
|
+
def decide(self, bundle_id: str, field_values: dict, **opts: Any) -> dict:
|
|
56
|
+
return self._request("POST", "/api/v1/public/decide", json={
|
|
57
|
+
"bundle_id": bundle_id,
|
|
58
|
+
"field_values": field_values,
|
|
59
|
+
**opts,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
def whoami(self) -> dict:
|
|
63
|
+
"""Return metadata for the current API key."""
|
|
64
|
+
return self._request("GET", "/api/v1/public/me")
|
|
65
|
+
|
|
66
|
+
def get_schema(self, bundle_id: str) -> dict:
|
|
67
|
+
return self._request("GET", f"/api/v1/public/bundles/{bundle_id}/schema")
|
|
68
|
+
|
|
69
|
+
def explain(self, bundle_id: str) -> dict:
|
|
70
|
+
return self._request("GET", f"/api/v1/public/bundles/{bundle_id}/explain")
|
|
71
|
+
|
|
72
|
+
def get_source(self, bundle_id: str) -> dict:
|
|
73
|
+
return self._request("GET", f"/api/v1/public/bundles/{bundle_id}/source")
|
|
74
|
+
|
|
75
|
+
# -- Projects API --
|
|
76
|
+
|
|
77
|
+
def create_project(self, name: str, section_id: str, domain: str = "") -> dict:
|
|
78
|
+
return self._request("POST", "/api/v1/public/projects/", json={
|
|
79
|
+
"name": name,
|
|
80
|
+
"section_id": section_id,
|
|
81
|
+
"domain": domain,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
def list_projects(self, include_archived: bool = False) -> list[dict]:
|
|
85
|
+
params: dict[str, str] = {}
|
|
86
|
+
if include_archived:
|
|
87
|
+
params["include_archived"] = "true"
|
|
88
|
+
return self._request("GET", "/api/v1/public/projects/", params=params)
|
|
89
|
+
|
|
90
|
+
def get_project(self, project_id: str) -> dict:
|
|
91
|
+
return self._request("GET", f"/api/v1/public/projects/{project_id}")
|
|
92
|
+
|
|
93
|
+
def add_guidance(
|
|
94
|
+
self,
|
|
95
|
+
project_id: str,
|
|
96
|
+
guidance_text: str,
|
|
97
|
+
source: str = "human",
|
|
98
|
+
process_type: str = "rule_generation",
|
|
99
|
+
) -> dict:
|
|
100
|
+
return self._request("POST", f"/api/v1/public/projects/{project_id}/guidance", json={
|
|
101
|
+
"guidance_text": guidance_text,
|
|
102
|
+
"source": source,
|
|
103
|
+
"process_type": process_type,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
def list_guidance(self, project_id: str) -> list:
|
|
107
|
+
return self._request("GET", f"/api/v1/public/projects/{project_id}/guidance")
|
|
108
|
+
|
|
109
|
+
def export_guidance(self, project_id: str) -> dict:
|
|
110
|
+
return self._request("GET", f"/api/v1/public/projects/{project_id}/guidance/export")
|
|
111
|
+
|
|
112
|
+
def deactivate_guidance(self, project_id: str, hint_id: str) -> dict:
|
|
113
|
+
return self._request("DELETE", f"/api/v1/public/projects/{project_id}/guidance/{hint_id}")
|
|
114
|
+
|
|
115
|
+
def update_guidance(self, project_id: str, hint_id: str, guidance_text: str) -> dict:
|
|
116
|
+
return self._request("PATCH", f"/api/v1/public/projects/{project_id}/guidance/{hint_id}", json={
|
|
117
|
+
"guidance_text": guidance_text,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
def upload_sources(self, project_id: str, files: list[Path]) -> dict:
|
|
121
|
+
file_tuples = [
|
|
122
|
+
("files", (f.name, f.read_bytes(), "application/octet-stream"))
|
|
123
|
+
for f in files
|
|
124
|
+
]
|
|
125
|
+
return self._request("POST", f"/api/v1/public/projects/{project_id}/sources", files=file_tuples)
|
|
126
|
+
|
|
127
|
+
def add_tests(self, project_id: str, test_cases: list[dict]) -> dict:
|
|
128
|
+
return self._request("POST", f"/api/v1/public/projects/{project_id}/tests", json={
|
|
129
|
+
"test_cases": test_cases,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
def generate(self, project_id: str) -> dict:
|
|
133
|
+
return self._request("POST", f"/api/v1/public/projects/{project_id}/generate")
|
|
134
|
+
|
|
135
|
+
def get_status(self, project_id: str) -> dict:
|
|
136
|
+
return self._request("GET", f"/api/v1/public/projects/{project_id}/status")
|
|
137
|
+
|
|
138
|
+
def run_tests(self, project_id: str) -> dict:
|
|
139
|
+
return self._request("POST", f"/api/v1/public/projects/{project_id}/test-run")
|
|
140
|
+
|
|
141
|
+
def publish(self, project_id: str, *, slug: str | None = None) -> dict:
|
|
142
|
+
body: dict = {}
|
|
143
|
+
if slug is not None:
|
|
144
|
+
body["slug"] = slug
|
|
145
|
+
kwargs: dict = {}
|
|
146
|
+
if body:
|
|
147
|
+
kwargs["json"] = body
|
|
148
|
+
return self._request(
|
|
149
|
+
"POST",
|
|
150
|
+
f"/api/v1/public/projects/{project_id}/publish",
|
|
151
|
+
**kwargs,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def list_bundles(self, project_id: str, status: str | None = None) -> list[dict]:
|
|
155
|
+
params: dict[str, str] = {}
|
|
156
|
+
if status:
|
|
157
|
+
params["status"] = status
|
|
158
|
+
return self._request("GET", f"/api/v1/public/projects/{project_id}/bundles", params=params)
|
|
159
|
+
|
|
160
|
+
def archive_project(self, project_id: str) -> dict:
|
|
161
|
+
return self._request("POST", f"/api/v1/public/projects/{project_id}/archive")
|
|
162
|
+
|
|
163
|
+
def archive_bundle(self, bundle_id: str) -> dict:
|
|
164
|
+
return self._request("POST", f"/api/v1/public/bundles/{bundle_id}/archive")
|
|
165
|
+
|
|
166
|
+
# -- Domain guidance API --
|
|
167
|
+
|
|
168
|
+
def add_domain_guidance(
|
|
169
|
+
self,
|
|
170
|
+
domain: str,
|
|
171
|
+
guidance_text: str,
|
|
172
|
+
process_type: str = "rule_generation",
|
|
173
|
+
notes: Optional[str] = None,
|
|
174
|
+
) -> dict:
|
|
175
|
+
body: dict[str, Any] = {"guidance_text": guidance_text, "process_type": process_type}
|
|
176
|
+
if notes:
|
|
177
|
+
body["notes"] = notes
|
|
178
|
+
return self._request("POST", f"/api/v1/public/domains/{domain}/guidance", json=body)
|
|
179
|
+
|
|
180
|
+
def list_domain_guidance(self, domain: str) -> list:
|
|
181
|
+
return self._request("GET", f"/api/v1/public/domains/{domain}/guidance")
|
|
File without changes
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""aethis account — manage API keys via browser-based Clerk sign-in."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from aethis_cli.auth import authenticate_with_clerk
|
|
12
|
+
from aethis_cli.commands.login_cmd import _save_to_keyring, _save_to_file
|
|
13
|
+
from aethis_cli.config import DEFAULT_BASE_URL
|
|
14
|
+
from aethis_cli.errors import AuthenticationError
|
|
15
|
+
from aethis_cli.output import console, info, success
|
|
16
|
+
|
|
17
|
+
CLERK_DOMAIN = os.environ.get("AETHIS_CLERK_DOMAIN", "clerk.aethis.legal")
|
|
18
|
+
CLERK_CLIENT_ID = os.environ.get("AETHIS_CLERK_CLIENT_ID", "cwH009p1vPtyy1EG")
|
|
19
|
+
|
|
20
|
+
VALID_SCOPES = {
|
|
21
|
+
"decide",
|
|
22
|
+
"bundles:read",
|
|
23
|
+
"bundles:explain",
|
|
24
|
+
"bundles:write",
|
|
25
|
+
"keys:manage",
|
|
26
|
+
"projects:read",
|
|
27
|
+
"projects:write",
|
|
28
|
+
"rulebooks:read",
|
|
29
|
+
"rulebooks:write",
|
|
30
|
+
}
|
|
31
|
+
VALID_TIERS = {"free", "starter", "pro"}
|
|
32
|
+
DEFAULT_SCOPES = ["decide", "projects:read", "projects:write", "bundles:read", "bundles:explain", "bundles:write"]
|
|
33
|
+
|
|
34
|
+
account_app = typer.Typer(
|
|
35
|
+
name="account",
|
|
36
|
+
help="Manage your Aethis account and API keys (browser sign-in).",
|
|
37
|
+
no_args_is_help=True,
|
|
38
|
+
pretty_exceptions_enable=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format_api_error(resp: httpx.Response) -> str:
|
|
43
|
+
try:
|
|
44
|
+
data = resp.json()
|
|
45
|
+
except Exception:
|
|
46
|
+
return resp.text
|
|
47
|
+
|
|
48
|
+
detail = data.get("detail") if isinstance(data, dict) else data
|
|
49
|
+
if isinstance(detail, dict):
|
|
50
|
+
reason = detail.get("reason_code", "unknown")
|
|
51
|
+
action = detail.get("action", "unknown")
|
|
52
|
+
missing = detail.get("missing_permissions", [])
|
|
53
|
+
missing_str = ", ".join(missing) if isinstance(missing, list) else str(missing)
|
|
54
|
+
msg = detail.get("message") or detail.get("error") or "Request denied"
|
|
55
|
+
return f"{msg} (reason={reason}, action={action}, missing={missing_str})"
|
|
56
|
+
if isinstance(detail, str):
|
|
57
|
+
return detail
|
|
58
|
+
return str(detail)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fetch_permissions(base_url: str) -> tuple[list[dict], set[str]]:
|
|
62
|
+
try:
|
|
63
|
+
resp = httpx.get(f"{base_url}/api/v1/public/permissions", timeout=10.0)
|
|
64
|
+
except httpx.HTTPError:
|
|
65
|
+
return [], set(VALID_SCOPES)
|
|
66
|
+
|
|
67
|
+
if resp.status_code != 200:
|
|
68
|
+
return [], set(VALID_SCOPES)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
items = resp.json()
|
|
72
|
+
except Exception:
|
|
73
|
+
return [], set(VALID_SCOPES)
|
|
74
|
+
|
|
75
|
+
if not isinstance(items, list):
|
|
76
|
+
return [], set(VALID_SCOPES)
|
|
77
|
+
|
|
78
|
+
permissions: set[str] = set()
|
|
79
|
+
parsed_items: list[dict] = []
|
|
80
|
+
for item in items:
|
|
81
|
+
if not isinstance(item, dict):
|
|
82
|
+
continue
|
|
83
|
+
req = item.get("required_permissions", [])
|
|
84
|
+
if isinstance(req, list):
|
|
85
|
+
for p in req:
|
|
86
|
+
if isinstance(p, str) and p:
|
|
87
|
+
permissions.add(p)
|
|
88
|
+
parsed_items.append(item)
|
|
89
|
+
|
|
90
|
+
if not permissions:
|
|
91
|
+
permissions = set(VALID_SCOPES)
|
|
92
|
+
return parsed_items, permissions
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_clerk_config() -> tuple[str, str]:
|
|
96
|
+
"""Return (domain, client_id), raising if not configured."""
|
|
97
|
+
domain = CLERK_DOMAIN
|
|
98
|
+
client_id = CLERK_CLIENT_ID
|
|
99
|
+
if not client_id:
|
|
100
|
+
console.print(
|
|
101
|
+
"[red]Clerk OAuth client_id not configured.[/red]\n"
|
|
102
|
+
"Set AETHIS_CLERK_CLIENT_ID environment variable.\n"
|
|
103
|
+
"Or use 'aethis login' to paste an existing API key."
|
|
104
|
+
)
|
|
105
|
+
raise typer.Exit(code=1)
|
|
106
|
+
return domain, client_id
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _clerk_auth(timeout: int) -> str:
|
|
110
|
+
"""Run Clerk OAuth flow, return access token."""
|
|
111
|
+
domain, client_id = _get_clerk_config()
|
|
112
|
+
info("Opening browser for sign-in...")
|
|
113
|
+
console.print(f"Waiting for authentication ({timeout}s timeout)...\n")
|
|
114
|
+
try:
|
|
115
|
+
return authenticate_with_clerk(domain, client_id, timeout)
|
|
116
|
+
except AuthenticationError as e:
|
|
117
|
+
console.print(f"[red]{e}[/red]")
|
|
118
|
+
raise typer.Exit(code=1) from None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@account_app.command()
|
|
122
|
+
def generate(
|
|
123
|
+
name: str = typer.Option("cli-generated", "--name", "-n", help="Key name"),
|
|
124
|
+
scopes: Optional[List[str]] = typer.Option(None, "--scope", "-s", help="Key scopes (repeatable)"),
|
|
125
|
+
tier: str = typer.Option("free", "--tier", "-t", help="Rate limit tier: free|starter|pro"),
|
|
126
|
+
no_save: bool = typer.Option(False, "--no-save", help="Print key but don't save"),
|
|
127
|
+
timeout: int = typer.Option(120, "--timeout", help="Browser auth timeout in seconds"),
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Create a new API key by signing in through your browser."""
|
|
130
|
+
base_url = os.environ.get("AETHIS_BASE_URL", DEFAULT_BASE_URL)
|
|
131
|
+
if scopes is None:
|
|
132
|
+
scopes = list(DEFAULT_SCOPES)
|
|
133
|
+
|
|
134
|
+
_, available_permissions = _fetch_permissions(base_url)
|
|
135
|
+
|
|
136
|
+
# Validate inputs
|
|
137
|
+
invalid_scopes = set(scopes) - available_permissions
|
|
138
|
+
if invalid_scopes:
|
|
139
|
+
console.print(f"[red]Invalid scope(s): {', '.join(invalid_scopes)}[/red]")
|
|
140
|
+
console.print(f"Valid scopes: {', '.join(sorted(available_permissions))}")
|
|
141
|
+
raise typer.Exit(code=1)
|
|
142
|
+
|
|
143
|
+
if tier not in VALID_TIERS:
|
|
144
|
+
console.print(f"[red]Invalid tier: {tier}[/red]. Must be one of: {', '.join(sorted(VALID_TIERS))}")
|
|
145
|
+
raise typer.Exit(code=1)
|
|
146
|
+
|
|
147
|
+
access_token = _clerk_auth(timeout)
|
|
148
|
+
success("Authenticated successfully.")
|
|
149
|
+
|
|
150
|
+
# Create API key via the key management endpoint
|
|
151
|
+
info("Creating API key...")
|
|
152
|
+
try:
|
|
153
|
+
resp = httpx.post(
|
|
154
|
+
f"{base_url}/api/v1/keys/",
|
|
155
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
156
|
+
json={"name": name, "scopes": scopes, "rate_limit_tier": tier},
|
|
157
|
+
timeout=15.0,
|
|
158
|
+
)
|
|
159
|
+
except httpx.HTTPError as e:
|
|
160
|
+
console.print(f"[red]Could not reach API at {base_url}: {e}[/red]")
|
|
161
|
+
raise typer.Exit(code=1) from None
|
|
162
|
+
|
|
163
|
+
if resp.status_code != 201:
|
|
164
|
+
console.print(f"[red]Key creation failed (HTTP {resp.status_code}): {_format_api_error(resp)}[/red]")
|
|
165
|
+
raise typer.Exit(code=1)
|
|
166
|
+
|
|
167
|
+
data = resp.json()
|
|
168
|
+
full_key = data.get("full_key")
|
|
169
|
+
if not full_key:
|
|
170
|
+
console.print("[red]Unexpected API response: missing 'full_key'.[/red]")
|
|
171
|
+
raise typer.Exit(code=1)
|
|
172
|
+
|
|
173
|
+
console.print()
|
|
174
|
+
success("API key created:")
|
|
175
|
+
console.print(f" Key ID: {data.get('key_id', 'unknown')}")
|
|
176
|
+
console.print(f" Name: {data.get('name', name)}")
|
|
177
|
+
console.print(f" Scopes: {', '.join(data.get('scopes', scopes))}")
|
|
178
|
+
console.print(f" Tier: {data.get('rate_limit_tier', tier)}")
|
|
179
|
+
console.print()
|
|
180
|
+
console.print("[bold yellow]Full key (shown once only):[/bold yellow]")
|
|
181
|
+
console.print(f" {full_key}")
|
|
182
|
+
console.print()
|
|
183
|
+
|
|
184
|
+
if no_save:
|
|
185
|
+
info("--no-save specified. Key not saved to credential store.")
|
|
186
|
+
else:
|
|
187
|
+
if _save_to_keyring(full_key):
|
|
188
|
+
success("API key saved to system keychain.")
|
|
189
|
+
else:
|
|
190
|
+
_save_to_file(full_key)
|
|
191
|
+
success("API key saved to credentials file.")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@account_app.command()
|
|
195
|
+
def keys(
|
|
196
|
+
timeout: int = typer.Option(120, "--timeout", help="Browser auth timeout in seconds"),
|
|
197
|
+
) -> None:
|
|
198
|
+
"""List your API keys (requires browser sign-in)."""
|
|
199
|
+
base_url = os.environ.get("AETHIS_BASE_URL", DEFAULT_BASE_URL)
|
|
200
|
+
access_token = _clerk_auth(timeout)
|
|
201
|
+
success("Authenticated successfully.")
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
resp = httpx.get(
|
|
205
|
+
f"{base_url}/api/v1/keys/",
|
|
206
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
207
|
+
timeout=15.0,
|
|
208
|
+
)
|
|
209
|
+
except httpx.HTTPError as e:
|
|
210
|
+
console.print(f"[red]Could not reach API at {base_url}: {e}[/red]")
|
|
211
|
+
raise typer.Exit(code=1) from None
|
|
212
|
+
|
|
213
|
+
if resp.status_code != 200:
|
|
214
|
+
console.print(f"[red]Failed to list keys (HTTP {resp.status_code}): {_format_api_error(resp)}[/red]")
|
|
215
|
+
raise typer.Exit(code=1)
|
|
216
|
+
|
|
217
|
+
data = resp.json()
|
|
218
|
+
if not data:
|
|
219
|
+
info("No API keys found.")
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
from rich.table import Table
|
|
223
|
+
|
|
224
|
+
table = Table(title="API Keys")
|
|
225
|
+
table.add_column("Key ID", style="bold")
|
|
226
|
+
table.add_column("Name")
|
|
227
|
+
table.add_column("Scopes")
|
|
228
|
+
table.add_column("Tier")
|
|
229
|
+
table.add_column("Created")
|
|
230
|
+
table.add_column("Revoked")
|
|
231
|
+
|
|
232
|
+
for key in data:
|
|
233
|
+
table.add_row(
|
|
234
|
+
key.get("key_id", ""),
|
|
235
|
+
key.get("name", ""),
|
|
236
|
+
", ".join(key.get("scopes", [])),
|
|
237
|
+
key.get("rate_limit_tier", ""),
|
|
238
|
+
key.get("created_at", "")[:10] if key.get("created_at") else "",
|
|
239
|
+
"yes" if key.get("revoked") else "",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
console.print(table)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@account_app.command()
|
|
246
|
+
def revoke(
|
|
247
|
+
key_id: str = typer.Argument(..., help="Key ID to revoke (ak_...)"),
|
|
248
|
+
timeout: int = typer.Option(120, "--timeout", help="Browser auth timeout in seconds"),
|
|
249
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Revoke an API key (requires browser sign-in)."""
|
|
252
|
+
base_url = os.environ.get("AETHIS_BASE_URL", DEFAULT_BASE_URL)
|
|
253
|
+
if not yes:
|
|
254
|
+
confirmed = typer.confirm(f"Revoke key {key_id}? This cannot be undone")
|
|
255
|
+
if not confirmed:
|
|
256
|
+
raise typer.Abort()
|
|
257
|
+
|
|
258
|
+
access_token = _clerk_auth(timeout)
|
|
259
|
+
success("Authenticated successfully.")
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
resp = httpx.delete(
|
|
263
|
+
f"{base_url}/api/v1/keys/{key_id}",
|
|
264
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
265
|
+
timeout=15.0,
|
|
266
|
+
)
|
|
267
|
+
except httpx.HTTPError as e:
|
|
268
|
+
console.print(f"[red]Could not reach API at {base_url}: {e}[/red]")
|
|
269
|
+
raise typer.Exit(code=1) from None
|
|
270
|
+
|
|
271
|
+
if resp.status_code == 204:
|
|
272
|
+
success(f"Key {key_id} revoked.")
|
|
273
|
+
elif resp.status_code == 404:
|
|
274
|
+
console.print(f"[red]Key {key_id} not found.[/red]")
|
|
275
|
+
raise typer.Exit(code=1)
|
|
276
|
+
else:
|
|
277
|
+
console.print(f"[red]Revoke failed (HTTP {resp.status_code}): {_format_api_error(resp)}[/red]")
|
|
278
|
+
raise typer.Exit(code=1)
|
|
279
|
+
|
|
280
|
+
|