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 +7 -0
- glossa_cli/auth.py +227 -0
- glossa_cli/cli.py +477 -0
- glossa_cli/client.py +184 -0
- glossa_cli/config.py +83 -0
- glossa_cli/mcp_server.py +271 -0
- glossa_cli-0.1.0.dist-info/METADATA +124 -0
- glossa_cli-0.1.0.dist-info/RECORD +10 -0
- glossa_cli-0.1.0.dist-info/WHEEL +6 -0
- glossa_cli-0.1.0.dist-info/entry_points.txt +3 -0
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
|