glossa-cli 0.1.1__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
glossa_cli/cli.py ADDED
@@ -0,0 +1,376 @@
1
+ """Glossa command-line interface.
2
+
3
+ Subcommands:
4
+ glossa analyze — CEFR-leveled word/phrase breakdown of arbitrary text
5
+ glossa add — add a word/phrase to your vocabulary
6
+ glossa set — change a word's state (saved / learning / known)
7
+ glossa list — list your vocabulary
8
+ glossa review — interactive FSRS review session
9
+
10
+ Auth & config come from the environment (see ``client.GlossaClient``):
11
+ GLOSSA_API_URL e.g. https://api.glossa.pro (or http://localhost:8000)
12
+ GLOSSA_API_TOKEN a glossa_* token; omit to use the OAuth browser flow.
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import sys
19
+ from collections.abc import Awaitable, Callable
20
+ from typing import Any
21
+
22
+ import httpx
23
+ import typer
24
+ from rich.console import Console
25
+ from rich.table import Table
26
+
27
+ from glossa_cli.auth import clear_credentials
28
+ from glossa_cli.client import GlossaApiError, GlossaClient
29
+
30
+ app = typer.Typer(
31
+ help="Glossa vocabulary CLI — analyze text and manage your vocabulary.",
32
+ no_args_is_help=True,
33
+ add_completion=False,
34
+ )
35
+ console = Console()
36
+ err_console = Console(stderr=True)
37
+
38
+ LANGUAGES = {"en", "fr", "pt-PT"}
39
+ STATUSES = {"saved", "learning", "known"}
40
+ RATINGS = {1: "Again", 2: "Hard", 3: "Good", 4: "Easy"}
41
+
42
+
43
+ # ── Shared helpers ─────────────────────────────────────────────────────────────
44
+
45
+
46
+ def _format_error(exc: Exception) -> str:
47
+ if isinstance(exc, GlossaApiError):
48
+ if exc.status_code == 401:
49
+ return (
50
+ "Glossa rejected your credentials. Generate a token in "
51
+ "Glossa → Settings → Tokens and export it as GLOSSA_API_TOKEN, "
52
+ "or omit it to use the browser sign-in flow."
53
+ )
54
+ if exc.status_code == 404:
55
+ return f"Not found: {exc.detail}"
56
+ return f"Glossa API error ({exc.status_code}): {exc.detail}"
57
+ if isinstance(exc, httpx.HTTPError):
58
+ return (
59
+ f"Could not reach Glossa at {os.environ.get('GLOSSA_API_URL', '')!r}: {exc}. "
60
+ "Is the backend running and GLOSSA_API_URL correct?"
61
+ )
62
+ return f"Glossa error: {exc}"
63
+
64
+
65
+ def _run(fn: Callable[[GlossaClient], Awaitable[Any]]) -> Any:
66
+ """Open an authenticated client, run *fn*, and surface errors as exit code 1."""
67
+
68
+ async def _wrap() -> Any:
69
+ async with GlossaClient() as client:
70
+ return await fn(client)
71
+
72
+ try:
73
+ return asyncio.run(_wrap())
74
+ except (GlossaApiError, RuntimeError, httpx.HTTPError) as exc:
75
+ err_console.print(f"[red]{_format_error(exc)}[/red]")
76
+ raise typer.Exit(1) from exc
77
+
78
+
79
+ def _validate_lang(language_code: str | None) -> None:
80
+ if language_code is not None and language_code not in LANGUAGES:
81
+ err_console.print(
82
+ f"[red]Unknown language '{language_code}'. Choose from: "
83
+ f"{', '.join(sorted(LANGUAGES))}.[/red]"
84
+ )
85
+ raise typer.Exit(2)
86
+
87
+
88
+ def _validate_status(status: str) -> None:
89
+ if status not in STATUSES:
90
+ err_console.print(
91
+ f"[red]Invalid status '{status}'. Choose from: "
92
+ f"{', '.join(sorted(STATUSES))}.[/red]"
93
+ )
94
+ raise typer.Exit(2)
95
+
96
+
97
+ # ── Commands ───────────────────────────────────────────────────────────────────
98
+
99
+
100
+ @app.command()
101
+ def login(
102
+ force: bool = typer.Option(
103
+ False, "--force", help="Clear cached credentials and sign in again."
104
+ ),
105
+ ) -> None:
106
+ """Sign in to Glossa. Run this first — opens a browser to authorise you."""
107
+ using_token = bool(os.environ.get("GLOSSA_API_TOKEN"))
108
+
109
+ if force and not using_token and clear_credentials():
110
+ console.print("[dim]Cleared saved credentials.[/dim]")
111
+
112
+ # Opening the client runs the OAuth device flow (printing an auth URL) when
113
+ # there's no usable token; the tiny authenticated call then confirms it worked.
114
+ _run(lambda c: c.reviews_due(limit=1))
115
+
116
+ if using_token:
117
+ console.print("[green]✓ Signed in with your GLOSSA_API_TOKEN.[/green]")
118
+ else:
119
+ console.print(
120
+ "[green]✓ Signed in.[/green] You're ready to use Glossa — try "
121
+ "[bold]glossa list[/bold]."
122
+ )
123
+
124
+
125
+ @app.command()
126
+ def analyze(
127
+ text: str | None = typer.Argument(
128
+ None, help="Text to analyze. Omit to read --file or stdin."
129
+ ),
130
+ file: str | None = typer.Option(None, "--file", "-f", help="Read text from this file."),
131
+ lang: str | None = typer.Option(
132
+ None, "--lang", "-l", help="Language: en, fr, pt-PT. Auto-detected if omitted."
133
+ ),
134
+ as_json: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
135
+ ) -> None:
136
+ """Analyze text and show its vocabulary breakdown (CEFR levels, phrases)."""
137
+ _validate_lang(lang)
138
+
139
+ if file is not None:
140
+ try:
141
+ with open(file, encoding="utf-8") as fh:
142
+ content = fh.read()
143
+ except OSError as exc:
144
+ err_console.print(f"[red]Cannot read {file}: {exc}[/red]")
145
+ raise typer.Exit(2) from exc
146
+ elif text is not None:
147
+ content = text
148
+ elif not sys.stdin.isatty():
149
+ content = sys.stdin.read()
150
+ else:
151
+ err_console.print("[red]No text provided. Pass TEXT, --file, or pipe via stdin.[/red]")
152
+ raise typer.Exit(2)
153
+
154
+ if not content.strip():
155
+ err_console.print("[red]Empty input.[/red]")
156
+ raise typer.Exit(2)
157
+
158
+ result = _run(lambda c: c.analyze_text(content, language_code=lang))
159
+
160
+ if as_json:
161
+ console.print_json(json.dumps(result))
162
+ return
163
+
164
+ words = result.get("words", [])
165
+ table = Table(title="Vocabulary breakdown")
166
+ table.add_column("Lemma", style="bold")
167
+ table.add_column("POS")
168
+ table.add_column("Count", justify="right")
169
+ table.add_column("CEFR")
170
+ table.add_column("Type")
171
+ for w in words:
172
+ kind = w.get("type", "word")
173
+ if kind == "phrase" and w.get("pattern_type"):
174
+ kind = f"phrase · {w['pattern_type']}"
175
+ table.add_row(
176
+ w.get("lemma", ""),
177
+ w.get("part_of_speech", "") or "",
178
+ str(w.get("occurrences", "")),
179
+ w.get("cefr_level") or "—",
180
+ kind,
181
+ )
182
+ console.print(table)
183
+ console.print(
184
+ f"[dim]{result.get('unique_lemmas', 0)} unique lemmas · "
185
+ f"{result.get('unique_phrases', 0)} phrases · "
186
+ f"{result.get('total_tokens', 0)} tokens[/dim]"
187
+ )
188
+
189
+
190
+ @app.command()
191
+ def add(
192
+ lemma: str = typer.Argument(..., help="Dictionary form, e.g. 'serendipity' or 'break the ice'."), # noqa: E501
193
+ lang: str = typer.Option("en", "--lang", "-l", help="Language: en, fr, pt-PT."),
194
+ pos: str | None = typer.Option(None, "--pos", help="NOUN, VERB, ADJ, or ADV."),
195
+ status: str = typer.Option("learning", "--status", "-s", help="saved, learning, or known."),
196
+ translation: str | None = typer.Option(None, "--translation", "-t", help="Your translation."),
197
+ ) -> None:
198
+ """Add a word or phrase to your vocabulary."""
199
+ _validate_lang(lang)
200
+ _validate_status(status)
201
+ result = _run(
202
+ lambda c: c.add_word(
203
+ lemma=lemma,
204
+ language_code=lang,
205
+ part_of_speech=pos,
206
+ status=status,
207
+ translation=translation,
208
+ )
209
+ )
210
+ _print_vocab_item(result, action="Saved")
211
+
212
+
213
+ @app.command(name="set")
214
+ def set_status(
215
+ lemma: str = typer.Argument(..., help="The word/phrase to update (or pass --id instead)."),
216
+ status: str = typer.Argument(..., help="New state: saved, learning, or known."),
217
+ lang: str = typer.Option("en", "--lang", "-l", help="Language: en, fr, pt-PT."),
218
+ pos: str | None = typer.Option(None, "--pos", help="NOUN, VERB, ADJ, or ADV."),
219
+ translation: str | None = typer.Option(None, "--translation", "-t", help="Your translation."),
220
+ entry_id: int | None = typer.Option(
221
+ None, "--id", help="Update a specific lexical_entry_id (from `glossa list`) precisely."
222
+ ),
223
+ ) -> None:
224
+ """Change a word's state. By default upserts by lemma; --id targets one sense."""
225
+ _validate_status(status)
226
+ if entry_id is not None:
227
+ result = _run(
228
+ lambda c: c.update_status(
229
+ lexical_entry_id=entry_id, status=status, user_translation=translation
230
+ )
231
+ )
232
+ else:
233
+ _validate_lang(lang)
234
+ result = _run(
235
+ lambda c: c.add_word(
236
+ lemma=lemma,
237
+ language_code=lang,
238
+ part_of_speech=pos,
239
+ status=status,
240
+ translation=translation,
241
+ )
242
+ )
243
+ _print_vocab_item(result, action="Updated")
244
+
245
+
246
+ @app.command(name="list")
247
+ def list_words(
248
+ status: str | None = typer.Option(
249
+ None, "--status", "-s", help="Filter: saved, learning, or known."
250
+ ),
251
+ lang: str | None = typer.Option(None, "--lang", "-l", help="Filter: en, fr, pt-PT."),
252
+ as_json: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
253
+ ) -> None:
254
+ """List your vocabulary."""
255
+ if status is not None:
256
+ _validate_status(status)
257
+ _validate_lang(lang)
258
+ items = _run(lambda c: c.list_vocabulary(status=status, language_code=lang))
259
+
260
+ if as_json:
261
+ console.print_json(json.dumps(items))
262
+ return
263
+
264
+ if not items:
265
+ console.print("[dim]No matching words.[/dim]")
266
+ return
267
+
268
+ table = Table(title="Your vocabulary")
269
+ table.add_column("ID", justify="right", style="dim")
270
+ table.add_column("Lemma", style="bold")
271
+ table.add_column("POS")
272
+ table.add_column("Lang")
273
+ table.add_column("Status")
274
+ table.add_column("CEFR")
275
+ table.add_column("Next review")
276
+ for it in items:
277
+ table.add_row(
278
+ str(it.get("lexical_entry_id", "")),
279
+ it.get("lemma") or "",
280
+ it.get("part_of_speech") or "",
281
+ it.get("language_code") or "",
282
+ it.get("status") or "",
283
+ it.get("cefr_level") or "—",
284
+ _fmt_dt(it.get("next_review")),
285
+ )
286
+ console.print(table)
287
+ console.print(f"[dim]{len(items)} word(s)[/dim]")
288
+
289
+
290
+ @app.command()
291
+ def review(
292
+ lang: str | None = typer.Option(None, "--lang", "-l", help="Filter: en, fr, pt-PT."),
293
+ limit: int = typer.Option(50, "--limit", help="Max cards to fetch (1–200)."),
294
+ ) -> None:
295
+ """Interactive FSRS review of words that are due."""
296
+ _validate_lang(lang)
297
+ cards = _run(lambda c: c.reviews_due(limit=limit))
298
+
299
+ # The /reviews/due endpoint has no language filter; apply it client-side.
300
+ if lang is not None:
301
+ cards = [c for c in cards if c.get("language_code") in (None, lang)]
302
+
303
+ if not cards:
304
+ console.print("[green]Nothing due for review — you're all caught up![/green]")
305
+ return
306
+
307
+ console.print(
308
+ f"[bold]{len(cards)}[/bold] card(s) due. Rate each: "
309
+ r"1 Again 2 Hard 3 Good 4 Easy (q to quit)" + "\n"
310
+ )
311
+
312
+ reviewed = 0
313
+ for idx, card in enumerate(cards, start=1):
314
+ console.print(
315
+ f"[dim]{idx}/{len(cards)}[/dim] [bold]{card.get('lemma', '')}[/bold] "
316
+ f"[dim]({card.get('part_of_speech', '')})[/dim]"
317
+ )
318
+ if card.get("context_sentence"):
319
+ console.print(f" [italic dim]{card['context_sentence']}[/italic dim]")
320
+
321
+ rating = _prompt_rating()
322
+ if rating is None:
323
+ break
324
+
325
+ result = _run(
326
+ lambda c, cid=card["id"], r=rating: c.submit_review(user_vocabulary_id=cid, rating=r)
327
+ )
328
+ reviewed += 1
329
+ console.print(
330
+ f" [green]✓ {RATINGS[rating]}[/green] — next review "
331
+ f"{_fmt_dt(result.get('next_review'))}\n"
332
+ )
333
+
334
+ console.print(f"[bold]Reviewed {reviewed} of {len(cards)} card(s).[/bold]")
335
+
336
+
337
+ # ── Output helpers ─────────────────────────────────────────────────────────────
338
+
339
+
340
+ def _prompt_rating() -> int | None:
341
+ """Prompt until a valid 1–4 rating or 'q' is entered. Returns None on quit."""
342
+ while True:
343
+ raw = typer.prompt(" Rating (1-4, q to quit)").strip().lower()
344
+ if raw in ("q", "quit"):
345
+ return None
346
+ if raw.isdigit() and int(raw) in RATINGS:
347
+ return int(raw)
348
+ err_console.print(" [red]Enter 1, 2, 3, 4, or q.[/red]")
349
+
350
+
351
+ def _print_vocab_item(item: dict, action: str) -> None:
352
+ bits = [f"[green]{action}[/green] [bold]{item.get('lemma', '')}[/bold]"]
353
+ if item.get("part_of_speech"):
354
+ bits.append(f"({item['part_of_speech']})")
355
+ bits.append(f"→ status [bold]{item.get('status', '')}[/bold]")
356
+ if item.get("cefr_level"):
357
+ bits.append(f"· CEFR {item['cefr_level']}")
358
+ console.print(" ".join(bits))
359
+ if item.get("next_review"):
360
+ console.print(f"[dim]Next review: {_fmt_dt(item['next_review'])}[/dim]")
361
+
362
+
363
+ def _fmt_dt(value: str | None) -> str:
364
+ if not value:
365
+ return "—"
366
+ # ISO timestamps render fine trimmed to the minute.
367
+ return value.replace("T", " ")[:16]
368
+
369
+
370
+ def main() -> None:
371
+ """Console-script entry point."""
372
+ app()
373
+
374
+
375
+ if __name__ == "__main__":
376
+ main()
glossa_cli/client.py ADDED
@@ -0,0 +1,176 @@
1
+ """Thin httpx client for the Glossa REST API.
2
+
3
+ Vendored from ``backend/glossa_mcp/client.py`` and extended with the vocabulary
4
+ and review endpoints the CLI needs. Authentication is resolved in this order:
5
+
6
+ 1. ``GLOSSA_API_TOKEN`` env var — a static ``glossa_*`` API token generated in
7
+ Glossa Settings → Tokens. Useful for scripting / CI.
8
+ 2. OAuth 2.0 device-code flow (RFC 8628) — the first time there are no stored
9
+ credentials, a URL is printed to stderr so the user can approve in a browser.
10
+ Subsequent runs load the saved access/refresh tokens from
11
+ ``~/.config/glossa/credentials.json`` and refresh automatically.
12
+
13
+ Set ``GLOSSA_API_URL`` (default ``https://api.glossa.pro``) to point at a
14
+ different instance (e.g. ``http://localhost:8000`` for local dev).
15
+ """
16
+
17
+ import os
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+ from glossa_cli.auth import AuthError, ensure_token
23
+
24
+ DEFAULT_TIMEOUT_SECONDS = 30
25
+ DEFAULT_BASE_URL = "https://api.glossa.pro"
26
+
27
+
28
+ class GlossaApiError(Exception):
29
+ """Raised when the API responds with an error status."""
30
+
31
+ def __init__(self, status_code: int, detail: str) -> None:
32
+ super().__init__(f"HTTP {status_code}: {detail}")
33
+ self.status_code = status_code
34
+ self.detail = detail
35
+
36
+
37
+ class GlossaClient:
38
+ """Async client that authenticates with a static API token **or** OAuth.
39
+
40
+ Call ``await client.ensure_auth()`` before the first request — or just
41
+ use the async context manager which calls it for you.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ base_url: str | None = None,
47
+ token: str | None = None,
48
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
49
+ ) -> None:
50
+ self.base_url = (base_url or os.environ.get("GLOSSA_API_URL", DEFAULT_BASE_URL)).rstrip("/")
51
+ self._static_token = token or os.environ.get("GLOSSA_API_TOKEN", "")
52
+ self._timeout = timeout
53
+ self._client: httpx.AsyncClient | None = None
54
+
55
+ async def ensure_auth(self) -> None:
56
+ """Resolve the bearer token (static env var or OAuth) and open the httpx client."""
57
+ if self._client is not None:
58
+ return # already initialised
59
+
60
+ if not self.base_url:
61
+ raise RuntimeError(
62
+ "GLOSSA_API_URL is not set. Export it (e.g. "
63
+ "https://api.glossa.pro, or http://localhost:8000 for local dev)."
64
+ )
65
+
66
+ if self._static_token:
67
+ bearer = self._static_token
68
+ else:
69
+ try:
70
+ bearer = await ensure_token(self.base_url)
71
+ except AuthError as exc:
72
+ raise RuntimeError(str(exc)) from exc
73
+
74
+ self._client = httpx.AsyncClient(
75
+ base_url=self.base_url,
76
+ timeout=self._timeout,
77
+ headers={"Authorization": f"Bearer {bearer}"},
78
+ )
79
+
80
+ async def aclose(self) -> None:
81
+ if self._client is not None:
82
+ await self._client.aclose()
83
+ self._client = None
84
+
85
+ async def __aenter__(self) -> "GlossaClient":
86
+ await self.ensure_auth()
87
+ return self
88
+
89
+ async def __aexit__(self, *exc: object) -> None:
90
+ await self.aclose()
91
+
92
+ async def _request(
93
+ self, method: str, path: str, json: dict | None = None
94
+ ) -> Any:
95
+ if self._client is None:
96
+ await self.ensure_auth()
97
+ resp = await self._client.request(method, path, json=json) # type: ignore[union-attr]
98
+ if resp.is_success:
99
+ if resp.status_code == 204 or not resp.content:
100
+ return None
101
+ return resp.json()
102
+ try:
103
+ payload = resp.json()
104
+ detail = payload.get("detail", resp.text) if isinstance(payload, dict) else resp.text
105
+ except ValueError:
106
+ detail = resp.text or resp.reason_phrase
107
+ raise GlossaApiError(resp.status_code, str(detail))
108
+
109
+ # ── Text analysis ─────────────────────────────────────────────────────────
110
+
111
+ async def analyze_text(
112
+ self, content: str, language_code: str | None = None
113
+ ) -> Any:
114
+ body: dict[str, Any] = {"content": content}
115
+ if language_code is not None:
116
+ body["language_code"] = language_code
117
+ return await self._request("POST", "/api/articles/analyze", body)
118
+
119
+ # ── Vocabulary ──────────────────────────────────────────────────────────────
120
+
121
+ async def add_word(
122
+ self,
123
+ lemma: str,
124
+ language_code: str,
125
+ part_of_speech: str | None = None,
126
+ status: str = "learning",
127
+ translation: str | None = None,
128
+ ) -> Any:
129
+ body: dict[str, Any] = {
130
+ "lemma": lemma,
131
+ "language_code": language_code,
132
+ "status": status,
133
+ }
134
+ if part_of_speech is not None:
135
+ body["part_of_speech"] = part_of_speech
136
+ if translation is not None:
137
+ body["user_translation"] = translation
138
+ return await self._request("POST", "/api/vocabulary/add", body)
139
+
140
+ async def list_vocabulary(
141
+ self, status: str | None = None, language_code: str | None = None
142
+ ) -> Any:
143
+ params: dict[str, str] = {}
144
+ if status is not None:
145
+ params["status"] = status
146
+ if language_code is not None:
147
+ params["language_code"] = language_code
148
+ query = ("?" + "&".join(f"{k}={v}" for k, v in params.items())) if params else ""
149
+ return await self._request("GET", f"/api/vocabulary{query}")
150
+
151
+ async def update_status(
152
+ self,
153
+ lexical_entry_id: int,
154
+ status: str,
155
+ user_translation: str | None = None,
156
+ ) -> Any:
157
+ body: dict[str, Any] = {
158
+ "lexical_entry_id": lexical_entry_id,
159
+ "status": status,
160
+ }
161
+ if user_translation is not None:
162
+ body["user_translation"] = user_translation
163
+ return await self._request("POST", "/api/vocabulary/update", body)
164
+
165
+ async def list_learning_words(self, language_code: str | None = None) -> Any:
166
+ params = f"?language_code={language_code}" if language_code else ""
167
+ return await self._request("GET", f"/api/extension/learning-words{params}")
168
+
169
+ # ── Reviews (FSRS) ────────────────────────────────────────────────────────
170
+
171
+ async def reviews_due(self, limit: int = 50) -> Any:
172
+ return await self._request("GET", f"/api/reviews/due?limit={limit}")
173
+
174
+ async def submit_review(self, user_vocabulary_id: int, rating: int) -> Any:
175
+ body = {"user_vocabulary_id": user_vocabulary_id, "rating": rating}
176
+ return await self._request("POST", "/api/reviews/submit", body)
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: glossa-cli
3
+ Version: 0.1.1
4
+ Summary: Command-line client for Glossa — analyze text and manage your vocabulary from the terminal
5
+ Project-URL: Homepage, https://glossa.pro
6
+ Project-URL: Repository, https://github.com/artemiy-rodionov/glossa
7
+ Project-URL: Issues, https://github.com/artemiy-rodionov/glossa/issues
8
+ Author-email: Artemiy Rodionov <artemiy.rodionov@gmail.com>
9
+ Keywords: cli,flashcards,fsrs,glossa,language-learning,vocabulary
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Education
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.28
23
+ Requires-Dist: rich>=13
24
+ Requires-Dist: typer>=0.12
25
+ Description-Content-Type: text/markdown
26
+
27
+ # glossa-cli
28
+
29
+ A standalone command-line client for [Glossa](https://glossa.pro) — analyze text
30
+ and manage your vocabulary from the terminal.
31
+
32
+ ## Install (one command)
33
+
34
+ ```sh
35
+ pipx install glossa-cli # recommended — isolated install
36
+ # or:
37
+ pip install glossa-cli
38
+ ```
39
+
40
+ Both put the `glossa` command on your PATH. No `uv` or source checkout required.
41
+
42
+ ## Sign in (OAuth — no token to copy)
43
+
44
+ Run `glossa login` first. It opens a browser to authorise you; after you
45
+ approve, your tokens are cached in `~/.config/glossa/credentials.json` and
46
+ refreshed automatically — you won't need to sign in again.
47
+
48
+ ```sh
49
+ glossa login # opens a browser; prints an auth URL to approve
50
+ glossa login --force # clear the cached sign-in and authorise again
51
+ ```
52
+
53
+ > **Self-hosted / local dev:** point the CLI at your instance with
54
+ > `export GLOSSA_API_URL=http://localhost:8000` (defaults to
55
+ > `https://api.glossa.pro`).
56
+ >
57
+ > **CI / scripting:** skip the browser flow by exporting a static token from
58
+ > Glossa → Settings → Tokens: `export GLOSSA_API_TOKEN=glossa_xxx`.
59
+
60
+ ## Usage
61
+
62
+ ```sh
63
+ # Analyze text (arg, --file, or stdin)
64
+ echo "The serendipitous discovery broke the ice." | glossa analyze --lang en
65
+ glossa analyze --file article.txt
66
+ glossa analyze "a short sentence" --json
67
+
68
+ # Add a word or phrase
69
+ glossa add serendipity --lang en --status learning
70
+ glossa add "break the ice" --lang en -t "сломать лёд"
71
+
72
+ # Change a word's state (upserts by lemma; --id targets one sense)
73
+ glossa set serendipity known --lang en
74
+ glossa set --id 1234 saved
75
+
76
+ # List your vocabulary
77
+ glossa list --status learning
78
+ glossa list --lang fr --json
79
+
80
+ # Interactive FSRS review session
81
+ glossa review --lang en
82
+ ```
@@ -0,0 +1,8 @@
1
+ glossa_cli/__init__.py,sha256=VFFQyFCA4dTGSTkMLNyFoZ-r5wIbwksWAww-zG2mIyo,304
2
+ glossa_cli/auth.py,sha256=ejzoZyEeGPD1if7EaQ1wkkYZgUfObsGxz6BHNNnnlrE,9347
3
+ glossa_cli/cli.py,sha256=FgvGaD_KaeYpoDY2S5BrrhVrKq-qDbJTUnxuFSVENvY,13341
4
+ glossa_cli/client.py,sha256=aJCu_2shn717ReIXDUbc1g-oHNDpaXAl1XHNaQ3MSpk,6892
5
+ glossa_cli-0.1.1.dist-info/METADATA,sha256=6IdfUXu3wkDxUXKL4AuI3jBs6KypevX0v6u4C9NiHFE,2789
6
+ glossa_cli-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
7
+ glossa_cli-0.1.1.dist-info/entry_points.txt,sha256=1GYBHUNF3-081eXSSeQM25FRKrqMw9R7DYsDFV2QcGY,47
8
+ glossa_cli-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ glossa = glossa_cli.cli:main