2"""CLI account + token storage (~/.aquin/config.json). One token per Aquin account."""
3from __future__
import annotations
9from datetime
import datetime, timezone
10from pathlib
import Path
13_CONFIG_PATH = Path.home() /
".aquin" /
"config.json"
14_BASE_URL_DEFAULT =
"https://api.aquin.app"
16_WHOAMI_CACHE_TTL_S = 15 * 60
25 return os.environ.get(
"AQUIN_BASE_URL", _BASE_URL_DEFAULT).rstrip(
"/")
29 """Print without crashing on Windows cp1252 consoles (no fancy unicode)."""
30 stream = file
or sys.stdout
33 .replace(
"\u2192",
"->")
34 .replace(
"\u2014",
"-")
35 .replace(
"\u2013",
"-")
36 .replace(
"\u2026",
"...")
39 print(text, file=stream)
40 except UnicodeEncodeError:
41 enc = getattr(stream,
"encoding",
None)
or "utf-8"
42 print(text.encode(enc, errors=
"replace").decode(enc, errors=
"replace"), file=stream)
46 """Strip whitespace / accidental 'Bearer ' prefix."""
47 key = (raw
or "").strip()
48 if key.lower().startswith(
"bearer "):
55 Return an error message if the token is obviously not an Aquin CLI token.
56 Returns None when the shape looks acceptable (still must pass whoami).
60 return "No token entered."
61 if not key.startswith(
"aq-"):
63 "Invalid CLI token: must start with aq- "
64 "(copy it from aquin.app Profile -> Account -> CLI token)."
66 if len(key) < _MIN_TOKEN_LEN:
67 return "Invalid CLI token: too short."
68 if any(ch.isspace()
for ch
in key):
69 return "Invalid CLI token: must not contain spaces."
74 if not _CONFIG_PATH.exists():
75 return {
"accounts": {}}
77 data = json.loads(_CONFIG_PATH.read_text(encoding=
"utf-8"))
78 if not isinstance(data, dict):
79 return {
"accounts": {}}
81 return {
"accounts": {}}
86 _CONFIG_PATH.parent.mkdir(parents=
True, exist_ok=
True)
87 _CONFIG_PATH.write_text(json.dumps(cfg, indent=2), encoding=
"utf-8")
91 accounts = cfg.get(
"accounts")
92 if isinstance(accounts, dict)
and accounts:
93 cfg.setdefault(
"active_account",
"")
95 key = str(cfg.get(
"api_key")
or "").strip()
96 accounts_dict: dict[str, Any] = {}
98 label = str(cfg.get(
"active_account")
or "saved").strip()
or "saved"
99 accounts_dict[label] = {
101 "name": cfg.get(
"account_name"),
102 "email": cfg.get(
"account_email")
if "@" in str(cfg.get(
"account_email")
or "")
else None,
104 cfg[
"active_account"] = label
105 cfg[
"accounts"] = accounts_dict
106 cfg.pop(
"api_key",
None)
110def _account_id(email: str |
None, name: str |
None, user_id: str |
None) -> str:
111 if email
and "@" in email:
112 return email.strip().lower()
116 return name.strip().lower().replace(
" ",
"-")
122 return str(cfg.get(
"active_account")
or "").strip()
125def list_accounts(cfg: dict[str, Any] |
None =
None) -> list[dict[str, str]]:
128 rows: list[dict[str, str]] = []
129 for aid, meta
in (cfg.get(
"accounts")
or {}).items():
130 if not isinstance(meta, dict):
132 label = str(meta.get(
"email")
or meta.get(
"name")
or aid)
136 "name": str(meta.get(
"name")
or ""),
137 "email": str(meta.get(
"email")
or ""),
138 "active":
"yes" if aid == active
else "",
143def resolve_api_key(*, allow_missing: bool =
False, explicit: str |
None =
None) -> str:
144 if explicit
and explicit.strip():
146 env = os.environ.get(
"AQUIN_API_KEY",
"").strip()
151 accounts = cfg.get(
"accounts")
or {}
152 if aid
and isinstance(accounts.get(aid), dict):
153 key = str(accounts[aid].get(
"api_key")
or "").strip()
157 first = next(iter(accounts.values()))
158 if isinstance(first, dict):
159 key = str(first.get(
"api_key")
or "").strip()
162 if not allow_missing:
163 safe_print(
"Not logged in. Run: aquin login", file=sys.stderr)
168def fetch_whoami(api_key: str, base_url: str |
None =
None) -> dict[str, Any]:
174 return {
"ok":
False,
"error":
"invalid_format",
"hint": fmt_err}
176 url = (base_url
or _base_url()).rstrip(
"/")
179 f
"{url}/api/sdk/whoami",
180 headers={
"Authorization": f
"Bearer {key}"},
184 except Exception
as exc:
189 "hint":
"Could not reach Aquin to verify the token.",
192 if resp.status_code == 401:
197 hint = body.get(
"hint")
or (
198 "Invalid CLI token. Copy a fresh aq- token from aquin.app "
199 "(Profile -> Account -> CLI token), then run aquin login --force."
203 "error": body.get(
"error")
or "Unauthorized",
204 "revoked_at": body.get(
"revoked_at"),
205 "hint": str(hint).replace(
"\u2192",
"->"),
208 if resp.status_code != 200:
211 "error": f
"HTTP {resp.status_code}",
212 "hint": f
"Token verification failed (HTTP {resp.status_code}).",
220 "error":
"bad_response",
221 "hint":
"Token verification returned an invalid response.",
224 if not isinstance(data, dict):
227 "error":
"bad_response",
228 "hint":
"Token verification returned an invalid response.",
231 user_id = data.get(
"user_id")
232 email = data.get(
"email")
233 name = data.get(
"name")
234 if not user_id
and not email
and not name:
237 "error":
"empty_identity",
238 "hint":
"Token verification succeeded but returned no account identity.",
246 "avatar_url": data.get(
"avatar_url"),
251 email = str(whoami.get(
"email")
or "").strip()
252 name = str(whoami.get(
"name")
or "").strip()
254 return f
"{name} ({email})"
255 return email
or name
or "?"
259 """Account label from saved login metadata - no network."""
262 accounts = cfg.get(
"accounts")
or {}
263 meta = accounts.get(aid)
if aid
else None
264 if not isinstance(meta, dict)
and accounts:
265 meta = next(iter(accounts.values()),
None)
266 if not isinstance(meta, dict):
268 name = str(meta.get(
"name")
or "").strip()
269 email = str(meta.get(
"email")
or "").strip()
271 return f
"{name} ({email})"
272 return email
or name
or str(aid
or "?")
277 raw = revoked_at.replace(
"Z",
"+00:00")
278 dt = datetime.fromisoformat(raw)
279 if dt.tzinfo
is None:
280 dt = dt.replace(tzinfo=timezone.utc)
281 delta = datetime.now(timezone.utc) - dt.astimezone(timezone.utc)
282 secs = int(max(0, delta.total_seconds()))
287 return f
"{mins} minute{'s' if mins != 1 else ''} ago"
290 return f
"{hours} hour{'s' if hours != 1 else ''} ago"
292 return f
"{days} day{'s' if days != 1 else ''} ago"
298 """Remember a successful whoami so we do not hit the network every command."""
301 accounts = dict(cfg.get(
"accounts")
or {})
302 meta = accounts.get(aid)
if aid
else None
303 if not isinstance(meta, dict):
308 meta[
"verified_at"] = time.time()
309 if whoami.get(
"email"):
310 meta[
"email"] = whoami.get(
"email")
311 if whoami.get(
"name"):
312 meta[
"name"] = whoami.get(
"name")
313 if whoami.get(
"user_id"):
314 meta[
"user_id"] = whoami.get(
"user_id")
315 if whoami.get(
"avatar_url"):
316 meta[
"avatar_url"] = whoami.get(
"avatar_url")
318 cfg[
"accounts"] = accounts
325 accounts = cfg.get(
"accounts")
or {}
326 meta = accounts.get(aid)
if aid
else None
327 if not isinstance(meta, dict):
332 verified_at = float(meta.get(
"verified_at")
or 0)
333 except (TypeError, ValueError):
335 return verified_at > 0
and (time.time() - verified_at) < ttl_s
340 Ensure a CLI token is present AND valid with Aquin cloud.
341 Exits the process on failure. Returns the verified api key.
347 safe_print(
"Run: aquin login --force", file=sys.stderr)
358 if whoami.get(
"revoked_at"):
360 safe_print(f
"Your CLI token was revoked {ago}.", file=sys.stderr)
361 safe_print(
"Regenerate at aquin.app (Profile -> Account -> CLI token), then:", file=sys.stderr)
362 safe_print(
" aquin login --force", file=sys.stderr)
365 if whoami.get(
"error") ==
"network":
370 whoami.get(
"hint")
or "Could not reach Aquin to verify your CLI token.",
373 safe_print(
"Check your network, or run: aquin login --force", file=sys.stderr)
381 """User-facing auth failure with revoked-at hint when available."""
382 safe_print(
"Cannot connect to Aquin: your CLI token was rejected.", file=sys.stderr)
385 if status.get(
"revoked_at"):
387 safe_print(f
"This token was revoked {ago}.", file=sys.stderr)
388 elif status.get(
"hint"):
389 safe_print(str(status[
"hint"]), file=sys.stderr)
391 safe_print(
"Run: aquin login --force (paste a fresh aq- token)", file=sys.stderr)
392 safe_print(
" aquin switch (use another saved account)", file=sys.stderr)
393 safe_print(
" aquin.app/profile (Profile -> Account -> CLI token)", file=sys.stderr)
397def save_login(api_key: str, *, whoami: dict[str, Any] |
None =
None) -> str:
401 raise ValueError(fmt_err)
403 info = whoami
if whoami
and whoami.get(
"ok")
else fetch_whoami(key)
404 if not info.get(
"ok"):
405 if info.get(
"revoked_at"):
407 raise ValueError(f
"Token was revoked {ago}. Regenerate at aquin.app and try again.")
408 hint = info.get(
"hint")
or "Invalid CLI token."
409 raise ValueError(str(hint).replace(
"\u2192",
"->"))
412 str(info.get(
"email")
or ""),
413 str(info.get(
"name")
or ""),
414 str(info.get(
"user_id")
or ""),
417 accounts = dict(cfg.get(
"accounts")
or {})
420 "email": info.get(
"email"),
421 "name": info.get(
"name"),
422 "user_id": info.get(
"user_id"),
423 "avatar_url": info.get(
"avatar_url"),
424 "saved_at": datetime.now(timezone.utc).isoformat(),
425 "verified_at": time.time(),
427 cfg[
"accounts"] = accounts
428 cfg[
"active_account"] = aid
435 accounts = cfg.get(
"accounts")
or {}
436 aid = account_id.strip().lower()
437 if aid
not in accounts:
440 if not isinstance(meta, dict):
442 email = str(meta.get(
"email")
or "").lower()
443 if email == aid
or k.lower() == aid:
448 f
"No CLI token saved for '{account_id}' on this machine.\n"
449 f
"Add that account's token: aquin switch --add"
452 cfg[
"active_account"] = aid
458 "api_key": str(meta.get(
"api_key")
or ""),
462def logout_local(*, account_id: str |
None =
None) ->
None:
464 accounts = dict(cfg.get(
"accounts")
or {})
466 aid = account_id.strip().lower()
470 if k.lower() == aid
or str((accounts[k]
or {}).get(
"email")
or "").lower() == aid
473 accounts.pop(k,
None)
475 cfg.pop(
"active_account",
None)
479 accounts.pop(aid,
None)
480 cfg.pop(
"active_account",
None)
481 cfg[
"accounts"] = accounts
482 if accounts
and not cfg.get(
"active_account"):
483 cfg[
"active_account"] = next(iter(accounts))
list[dict[str, str]] list_accounts(dict[str, Any]|None cfg=None)
None safe_print(str msg, *, file=None)
str require_valid_login(*, bool force_refresh=False)
None logout_local(*, str|None account_id=None)
bool _cached_whoami_fresh(str api_key, *, float ttl_s=_WHOAMI_CACHE_TTL_S)
dict[str, Any] load_config()
None print_token_rejected(str|None api_key=None)
str save_login(str api_key, *, dict[str, Any]|None whoami=None)
str _account_id(str|None email, str|None name, str|None user_id)
dict[str, Any] fetch_whoami(str api_key, str|None base_url=None)
dict[str, str] switch_account(str account_id)
str active_account_id(dict[str, Any]|None cfg=None)
None save_config(dict[str, Any] cfg)
str format_account_label(dict[str, Any] whoami)
dict[str, Any] _migrate_legacy(dict[str, Any] cfg)
str cached_account_label(dict[str, Any]|None cfg=None)
str resolve_api_key(*, bool allow_missing=False, str|None explicit=None)
str format_revoked_ago(str revoked_at)
str|None validate_cli_token_format(str raw)
None _cache_whoami_ok(str api_key, dict[str, Any] whoami)
str normalize_cli_token(str raw)