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 ADDED
@@ -0,0 +1,3 @@
1
+ """aethis-cli — CLI for the Aethis developer API."""
2
+
3
+ __version__ = "0.1.0"
aethis_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Enable `python -m aethis_cli`."""
2
+ from aethis_cli.main import app
3
+
4
+ app()
aethis_cli/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Version info — updated per release."""
2
+ __version__ = "0.3.0"
3
+
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>&#10003; 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
+