glossa-cli 0.1.0__3-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.
glossa_cli/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Glossa CLI — a standalone terminal client for the Glossa REST API.
2
+
3
+ Talks to the API over HTTP using the same auth as the MCP server
4
+ (``GLOSSA_API_TOKEN`` static token or OAuth device-code flow). Install with::
5
+
6
+ uv tool install glossa-cli # or: pipx install glossa-cli / uvx glossa-cli
7
+ """
glossa_cli/auth.py ADDED
@@ -0,0 +1,227 @@
1
+ """OAuth 2.0 Device-Code flow + credential storage for the Glossa CLI.
2
+
3
+ Vendored from ``backend/glossa_mcp/auth.py`` so the CLI ships as a standalone,
4
+ lightweight package (httpx + stdlib only) without depending on the heavy
5
+ ``glossa-backend`` distribution.
6
+
7
+ Design:
8
+ - Credentials are stored in ``~/.config/glossa/credentials.json`` (shared with
9
+ the MCP client — a token approved once works for both).
10
+ - When a valid access token exists it's used directly.
11
+ - When the access token has expired the refresh token is used to get a new pair.
12
+ - When there are no credentials at all, the device-code flow is initiated:
13
+ 1. POST /api/oauth/device_authorization → get device_code + user_code
14
+ 2. Print verification_uri_complete to stderr so the user can approve it.
15
+ 3. Poll /api/oauth/token every ``interval`` seconds until approved, denied
16
+ or the device code expires.
17
+ 4. Save the resulting access + refresh tokens to disk.
18
+
19
+ Only ``ensure_token(base_url)`` is the public entry-point — it returns a valid
20
+ Bearer token string or raises ``AuthError`` (fatal, surfaced to the user).
21
+ """
22
+
23
+ import asyncio
24
+ import json
25
+ import sys
26
+ import time
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+
30
+ import httpx
31
+
32
+ # ── Configuration ─────────────────────────────────────────────────────────────
33
+
34
+ CREDENTIALS_PATH = Path.home() / ".config" / "glossa" / "credentials.json"
35
+ POLL_MAX_SECONDS = 600 # 10 min — matches backend DEVICE_CODE_TTL
36
+ DEFAULT_POLL_INTERVAL = 5 # seconds between /token poll attempts
37
+
38
+
39
+ # ── Data ─────────────────────────────────────────────────────────────────────
40
+
41
+
42
+ @dataclass
43
+ class Credentials:
44
+ access_token: str
45
+ refresh_token: str
46
+ expires_at: float # Unix timestamp when access_token expires
47
+ base_url: str
48
+
49
+ def access_expired(self) -> bool:
50
+ # Give a 30-second buffer so we refresh before the token actually expires.
51
+ return time.time() >= (self.expires_at - 30)
52
+
53
+
54
+ # ── Storage ──────────────────────────────────────────────────────────────────
55
+
56
+
57
+ def _load() -> Credentials | None:
58
+ if not CREDENTIALS_PATH.exists():
59
+ return None
60
+ try:
61
+ data = json.loads(CREDENTIALS_PATH.read_text())
62
+ return Credentials(**data)
63
+ except Exception:
64
+ return None
65
+
66
+
67
+ def _save(creds: Credentials) -> None:
68
+ CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
69
+ CREDENTIALS_PATH.write_text(json.dumps(asdict(creds), indent=2))
70
+
71
+
72
+ def _clear() -> None:
73
+ if CREDENTIALS_PATH.exists():
74
+ CREDENTIALS_PATH.unlink()
75
+
76
+
77
+ def clear_credentials() -> bool:
78
+ """Remove any cached OAuth credentials. Returns True if a file was removed."""
79
+ existed = CREDENTIALS_PATH.exists()
80
+ _clear()
81
+ return existed
82
+
83
+
84
+ # ── Errors ───────────────────────────────────────────────────────────────────
85
+
86
+
87
+ class AuthError(Exception):
88
+ """Fatal auth failure — message is user-visible."""
89
+
90
+
91
+ # ── OAuth HTTP helpers ────────────────────────────────────────────────────────
92
+
93
+
94
+ async def _post(client: httpx.AsyncClient, path: str, body: dict) -> dict:
95
+ resp = await client.post(path, json=body)
96
+ try:
97
+ payload = resp.json()
98
+ except Exception:
99
+ payload = {}
100
+ if resp.is_success:
101
+ return payload
102
+ # Surface OAuth error_description if present
103
+ detail = payload.get("detail") or {}
104
+ if isinstance(detail, dict):
105
+ msg = detail.get("error_description") or detail.get("error") or str(payload)
106
+ elif isinstance(detail, str):
107
+ msg = detail
108
+ else:
109
+ msg = resp.text or resp.reason_phrase
110
+ raise httpx.HTTPStatusError(msg, request=resp.request, response=resp)
111
+
112
+
113
+ async def _refresh(client: httpx.AsyncClient, refresh_token: str, base_url: str) -> Credentials:
114
+ data = await _post(client, "/api/oauth/token", {
115
+ "grant_type": "refresh_token",
116
+ "refresh_token": refresh_token,
117
+ })
118
+ return Credentials(
119
+ access_token=data["access_token"],
120
+ refresh_token=data["refresh_token"],
121
+ expires_at=time.time() + data.get("expires_in", 3600),
122
+ base_url=base_url,
123
+ )
124
+
125
+
126
+ # ── Device-code flow ─────────────────────────────────────────────────────────
127
+
128
+
129
+ async def _device_flow(base_url: str) -> Credentials:
130
+ """Run the full RFC 8628 device-code flow interactively (via stderr)."""
131
+ async with httpx.AsyncClient(base_url=base_url, timeout=30) as client:
132
+ # 1 — Request codes
133
+ init = await _post(client, "/api/oauth/device_authorization", {})
134
+ device_code = init["device_code"]
135
+ user_code = init["user_code"]
136
+ verification_uri_complete = init["verification_uri_complete"]
137
+ interval = int(init.get("interval", DEFAULT_POLL_INTERVAL))
138
+
139
+ # 2 — Prompt user (stderr keeps stdout clean for piping / JSON output)
140
+ print(
141
+ f"\n{'─' * 60}\n"
142
+ f" Glossa CLI — authorisation required\n\n"
143
+ f" 1. Open this URL in your browser:\n"
144
+ f" {verification_uri_complete}\n\n"
145
+ f" 2. Code to confirm: {user_code}\n"
146
+ f"{'─' * 60}\n",
147
+ file=sys.stderr,
148
+ flush=True,
149
+ )
150
+
151
+ # 3 — Poll
152
+ deadline = time.time() + POLL_MAX_SECONDS
153
+ while time.time() < deadline:
154
+ await asyncio.sleep(interval)
155
+ try:
156
+ data = await _post(client, "/api/oauth/token", {
157
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
158
+ "device_code": device_code,
159
+ })
160
+ # Success
161
+ print(" ✓ Authorised — Glossa CLI is ready.\n", file=sys.stderr, flush=True)
162
+ return Credentials(
163
+ access_token=data["access_token"],
164
+ refresh_token=data["refresh_token"],
165
+ expires_at=time.time() + data.get("expires_in", 3600),
166
+ base_url=base_url,
167
+ )
168
+ except httpx.HTTPStatusError as exc:
169
+ # Parse the body that _post embeds in the exception message
170
+ try:
171
+ body = exc.response.json()
172
+ detail = body.get("detail", {})
173
+ error_code = detail.get("error") if isinstance(detail, dict) else None
174
+ except Exception:
175
+ error_code = None
176
+
177
+ if error_code == "authorization_pending":
178
+ continue # keep polling
179
+ if error_code == "slow_down":
180
+ interval += 5
181
+ continue
182
+ if error_code in ("access_denied", "expired_token", "invalid_grant"):
183
+ # Upgrade path: surface the upgrade URL if present
184
+ upgrade_url = (
185
+ (detail or {}).get("upgrade_url") if isinstance(detail, dict) else None
186
+ )
187
+ if upgrade_url:
188
+ raise AuthError(
189
+ f"Glossa access requires a Pro subscription.\n"
190
+ f"Upgrade at: {upgrade_url}"
191
+ ) from exc
192
+ raise AuthError(f"Authorisation denied ({error_code}).") from exc
193
+ # Unknown error — surface verbatim
194
+ raise AuthError(f"Token error: {exc}") from exc
195
+
196
+ raise AuthError("Authorisation timed out — please run the command again.")
197
+
198
+
199
+ # ── Public entry-point ────────────────────────────────────────────────────────
200
+
201
+
202
+ async def ensure_token(base_url: str) -> str:
203
+ """Return a valid Bearer token for *base_url*, refreshing or re-authorising as needed."""
204
+ creds = _load()
205
+
206
+ # Mismatched base_url (e.g. switched from prod to dev) — start fresh
207
+ if creds is not None and creds.base_url.rstrip("/") != base_url.rstrip("/"):
208
+ _clear()
209
+ creds = None
210
+
211
+ if creds is not None and not creds.access_expired():
212
+ return creds.access_token
213
+
214
+ if creds is not None and creds.refresh_token:
215
+ try:
216
+ async with httpx.AsyncClient(base_url=base_url, timeout=30) as client:
217
+ creds = await _refresh(client, creds.refresh_token, base_url)
218
+ _save(creds)
219
+ return creds.access_token
220
+ except Exception:
221
+ # Refresh failed (revoked / expired / downgraded) — full re-auth
222
+ _clear()
223
+
224
+ # No usable credentials — run device-code flow
225
+ creds = await _device_flow(base_url)
226
+ _save(creds)
227
+ return creds.access_token