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/cli.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Glossa command-line interface.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
glossa login — sign in (browser OAuth)
|
|
5
|
+
glossa logout — sign out
|
|
6
|
+
glossa analyze — CEFR-leveled word/phrase breakdown of arbitrary text
|
|
7
|
+
glossa add — add a word/phrase to your vocabulary
|
|
8
|
+
glossa set — change a word's state (saved / learning / known)
|
|
9
|
+
glossa list — list your vocabulary
|
|
10
|
+
glossa review — interactive FSRS review session
|
|
11
|
+
glossa lang — show or set your default learning language
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Awaitable, Callable
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
import typer
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
|
|
26
|
+
from glossa_cli import config
|
|
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 (
|
|
55
|
+
isinstance(exc.detail, dict)
|
|
56
|
+
and exc.detail.get("error") == "multiple_languages"
|
|
57
|
+
):
|
|
58
|
+
enabled = exc.detail.get("enabled") or []
|
|
59
|
+
joined = ", ".join(enabled) if enabled else "(none)"
|
|
60
|
+
return (
|
|
61
|
+
"You have multiple learning languages enabled: "
|
|
62
|
+
f"{joined}.\nPick one for this command, or set a default:\n"
|
|
63
|
+
" --lang <code> # one-off override\n"
|
|
64
|
+
" export GLOSSA_LANG=<code> # session\n"
|
|
65
|
+
" glossa lang set <code> # persistent"
|
|
66
|
+
)
|
|
67
|
+
if exc.status_code == 404:
|
|
68
|
+
return f"Not found: {exc.detail}"
|
|
69
|
+
return f"Glossa API error ({exc.status_code}): {exc.detail}"
|
|
70
|
+
if isinstance(exc, httpx.HTTPError):
|
|
71
|
+
return (
|
|
72
|
+
f"Could not reach Glossa at {os.environ.get('GLOSSA_API_URL', '')!r}: {exc}. "
|
|
73
|
+
"Is the backend running and GLOSSA_API_URL correct?"
|
|
74
|
+
)
|
|
75
|
+
return f"Glossa error: {exc}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run(fn: Callable[[GlossaClient], Awaitable[Any]]) -> Any:
|
|
79
|
+
"""Open an authenticated client, run *fn*, and surface errors as exit code 1."""
|
|
80
|
+
|
|
81
|
+
async def _wrap() -> Any:
|
|
82
|
+
async with GlossaClient() as client:
|
|
83
|
+
return await fn(client)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
return asyncio.run(_wrap())
|
|
87
|
+
except (GlossaApiError, RuntimeError, httpx.HTTPError) as exc:
|
|
88
|
+
err_console.print(f"[red]{_format_error(exc)}[/red]")
|
|
89
|
+
raise typer.Exit(1) from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _validate_lang(language_code: str | None) -> None:
|
|
93
|
+
if language_code is not None and language_code not in LANGUAGES:
|
|
94
|
+
err_console.print(
|
|
95
|
+
f"[red]Unknown language '{language_code}'. Choose from: "
|
|
96
|
+
f"{', '.join(sorted(LANGUAGES))}.[/red]"
|
|
97
|
+
)
|
|
98
|
+
raise typer.Exit(2)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _validate_status(status: str) -> None:
|
|
102
|
+
if status not in STATUSES:
|
|
103
|
+
err_console.print(
|
|
104
|
+
f"[red]Invalid status '{status}'. Choose from: "
|
|
105
|
+
f"{', '.join(sorted(STATUSES))}.[/red]"
|
|
106
|
+
)
|
|
107
|
+
raise typer.Exit(2)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Commands ───────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command()
|
|
114
|
+
def login(
|
|
115
|
+
force: bool = typer.Option(
|
|
116
|
+
False, "--force", help="Clear cached credentials and sign in again."
|
|
117
|
+
),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Sign in to Glossa. Run this first — opens a browser to authorise you."""
|
|
120
|
+
using_token = bool(os.environ.get("GLOSSA_API_TOKEN"))
|
|
121
|
+
|
|
122
|
+
if force and not using_token and clear_credentials():
|
|
123
|
+
console.print("[dim]Cleared saved credentials.[/dim]")
|
|
124
|
+
|
|
125
|
+
# Opening the client runs the OAuth device flow (printing an auth URL) when
|
|
126
|
+
# there's no usable token; the tiny authenticated call then confirms it worked.
|
|
127
|
+
_run(lambda c: c.reviews_due(limit=1))
|
|
128
|
+
|
|
129
|
+
if using_token:
|
|
130
|
+
console.print("[green]✓ Signed in with your GLOSSA_API_TOKEN.[/green]")
|
|
131
|
+
else:
|
|
132
|
+
console.print(
|
|
133
|
+
"[green]✓ Signed in.[/green] You're ready to use Glossa — try "
|
|
134
|
+
"[bold]glossa list[/bold]."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.command()
|
|
139
|
+
def logout() -> None:
|
|
140
|
+
"""Sign out — remove cached OAuth credentials.
|
|
141
|
+
|
|
142
|
+
Idempotent: if nothing's cached, says so and exits 0. If a
|
|
143
|
+
``GLOSSA_API_TOKEN`` is set in your environment, the CLI still uses it
|
|
144
|
+
until you unset it — `logout` warns you about that.
|
|
145
|
+
"""
|
|
146
|
+
if os.environ.get("GLOSSA_API_TOKEN"):
|
|
147
|
+
err_console.print(
|
|
148
|
+
"[yellow]Note: GLOSSA_API_TOKEN is set in your environment — the "
|
|
149
|
+
"CLI will keep using it until you unset it.[/yellow]"
|
|
150
|
+
)
|
|
151
|
+
if clear_credentials():
|
|
152
|
+
console.print(
|
|
153
|
+
"[green]✓ Signed out.[/green] Cached credentials removed from "
|
|
154
|
+
"~/.config/glossa/credentials.json."
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
console.print("[dim]Already signed out — no cached credentials.[/dim]")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def analyze(
|
|
162
|
+
text: str | None = typer.Argument(
|
|
163
|
+
None, help="Text to analyze. Omit to read --file or stdin."
|
|
164
|
+
),
|
|
165
|
+
file: str | None = typer.Option(None, "--file", "-f", help="Read text from this file."),
|
|
166
|
+
as_json: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Analyze text and show its vocabulary breakdown (CEFR levels, phrases).
|
|
169
|
+
|
|
170
|
+
Language is auto-detected from the text.
|
|
171
|
+
"""
|
|
172
|
+
if file is not None:
|
|
173
|
+
try:
|
|
174
|
+
with open(file, encoding="utf-8") as fh:
|
|
175
|
+
content = fh.read()
|
|
176
|
+
except OSError as exc:
|
|
177
|
+
err_console.print(f"[red]Cannot read {file}: {exc}[/red]")
|
|
178
|
+
raise typer.Exit(2) from exc
|
|
179
|
+
elif text is not None:
|
|
180
|
+
content = text
|
|
181
|
+
elif not sys.stdin.isatty():
|
|
182
|
+
content = sys.stdin.read()
|
|
183
|
+
else:
|
|
184
|
+
err_console.print("[red]No text provided. Pass TEXT, --file, or pipe via stdin.[/red]")
|
|
185
|
+
raise typer.Exit(2)
|
|
186
|
+
|
|
187
|
+
if not content.strip():
|
|
188
|
+
err_console.print("[red]Empty input.[/red]")
|
|
189
|
+
raise typer.Exit(2)
|
|
190
|
+
|
|
191
|
+
result = _run(lambda c: c.analyze_text(content))
|
|
192
|
+
|
|
193
|
+
if as_json:
|
|
194
|
+
console.print_json(json.dumps(result))
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
words = result.get("words", [])
|
|
198
|
+
table = Table(title="Vocabulary breakdown")
|
|
199
|
+
table.add_column("Lemma", style="bold")
|
|
200
|
+
table.add_column("POS")
|
|
201
|
+
table.add_column("Count", justify="right")
|
|
202
|
+
table.add_column("CEFR")
|
|
203
|
+
table.add_column("Type")
|
|
204
|
+
for w in words:
|
|
205
|
+
kind = w.get("type", "word")
|
|
206
|
+
if kind == "phrase" and w.get("pattern_type"):
|
|
207
|
+
kind = f"phrase · {w['pattern_type']}"
|
|
208
|
+
table.add_row(
|
|
209
|
+
w.get("lemma", ""),
|
|
210
|
+
w.get("part_of_speech", "") or "",
|
|
211
|
+
str(w.get("occurrences", "")),
|
|
212
|
+
w.get("cefr_level") or "—",
|
|
213
|
+
kind,
|
|
214
|
+
)
|
|
215
|
+
console.print(table)
|
|
216
|
+
console.print(
|
|
217
|
+
f"[dim]{result.get('unique_lemmas', 0)} unique lemmas · "
|
|
218
|
+
f"{result.get('unique_phrases', 0)} phrases · "
|
|
219
|
+
f"{result.get('total_tokens', 0)} tokens[/dim]"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command()
|
|
224
|
+
def add(
|
|
225
|
+
lemma: str = typer.Argument(..., help="Dictionary form, e.g. 'serendipity' or 'break the ice'."), # noqa: E501
|
|
226
|
+
pos: str | None = typer.Option(None, "--pos", help="NOUN, VERB, ADJ, or ADV."),
|
|
227
|
+
status: str = typer.Option("learning", "--status", "-s", help="saved, learning, or known."),
|
|
228
|
+
translation: str | None = typer.Option(None, "--translation", "-t", help="Your translation."),
|
|
229
|
+
lang: str | None = typer.Option(
|
|
230
|
+
None, "--lang", "-l", hidden=True,
|
|
231
|
+
help="Override the language (en/fr/pt-PT). Usually inferred from your account.",
|
|
232
|
+
),
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Add a word or phrase to your vocabulary."""
|
|
235
|
+
_validate_status(status)
|
|
236
|
+
language_code = config.resolve_language(lang)
|
|
237
|
+
_validate_lang(language_code)
|
|
238
|
+
result = _run(
|
|
239
|
+
lambda c: c.add_word(
|
|
240
|
+
lemma=lemma,
|
|
241
|
+
language_code=language_code,
|
|
242
|
+
part_of_speech=pos,
|
|
243
|
+
status=status,
|
|
244
|
+
translation=translation,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
_print_vocab_item(result, action="Saved")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command(name="set")
|
|
251
|
+
def set_status(
|
|
252
|
+
lemma: str = typer.Argument(..., help="The word/phrase to update (or pass --id instead)."),
|
|
253
|
+
status: str = typer.Argument(..., help="New state: saved, learning, or known."),
|
|
254
|
+
pos: str | None = typer.Option(None, "--pos", help="NOUN, VERB, ADJ, or ADV."),
|
|
255
|
+
translation: str | None = typer.Option(None, "--translation", "-t", help="Your translation."),
|
|
256
|
+
entry_id: int | None = typer.Option(
|
|
257
|
+
None, "--id", help="Update a specific lexical_entry_id (from `glossa list`) precisely."
|
|
258
|
+
),
|
|
259
|
+
lang: str | None = typer.Option(
|
|
260
|
+
None, "--lang", "-l", hidden=True,
|
|
261
|
+
help="Override the language (en/fr/pt-PT). Usually inferred from your account.",
|
|
262
|
+
),
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Change a word's state. By default upserts by lemma; --id targets one sense."""
|
|
265
|
+
_validate_status(status)
|
|
266
|
+
if entry_id is not None:
|
|
267
|
+
result = _run(
|
|
268
|
+
lambda c: c.update_status(
|
|
269
|
+
lexical_entry_id=entry_id, status=status, user_translation=translation
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
language_code = config.resolve_language(lang)
|
|
274
|
+
_validate_lang(language_code)
|
|
275
|
+
result = _run(
|
|
276
|
+
lambda c: c.add_word(
|
|
277
|
+
lemma=lemma,
|
|
278
|
+
language_code=language_code,
|
|
279
|
+
part_of_speech=pos,
|
|
280
|
+
status=status,
|
|
281
|
+
translation=translation,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
_print_vocab_item(result, action="Updated")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@app.command(name="list")
|
|
288
|
+
def list_words(
|
|
289
|
+
status: str | None = typer.Option(
|
|
290
|
+
None, "--status", "-s", help="Filter: saved, learning, or known."
|
|
291
|
+
),
|
|
292
|
+
as_json: bool = typer.Option(False, "--json", help="Print the raw JSON response."),
|
|
293
|
+
) -> None:
|
|
294
|
+
"""List your vocabulary across all enabled languages."""
|
|
295
|
+
if status is not None:
|
|
296
|
+
_validate_status(status)
|
|
297
|
+
items = _run(lambda c: c.list_vocabulary(status=status))
|
|
298
|
+
|
|
299
|
+
if as_json:
|
|
300
|
+
console.print_json(json.dumps(items))
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if not items:
|
|
304
|
+
console.print("[dim]No matching words.[/dim]")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
table = Table(title="Your vocabulary")
|
|
308
|
+
table.add_column("ID", justify="right", style="dim")
|
|
309
|
+
table.add_column("Lemma", style="bold")
|
|
310
|
+
table.add_column("POS")
|
|
311
|
+
table.add_column("Lang")
|
|
312
|
+
table.add_column("Status")
|
|
313
|
+
table.add_column("CEFR")
|
|
314
|
+
table.add_column("Next review")
|
|
315
|
+
for it in items:
|
|
316
|
+
table.add_row(
|
|
317
|
+
str(it.get("lexical_entry_id", "")),
|
|
318
|
+
it.get("lemma") or "",
|
|
319
|
+
it.get("part_of_speech") or "",
|
|
320
|
+
it.get("language_code") or "",
|
|
321
|
+
it.get("status") or "",
|
|
322
|
+
it.get("cefr_level") or "—",
|
|
323
|
+
_fmt_dt(it.get("next_review")),
|
|
324
|
+
)
|
|
325
|
+
console.print(table)
|
|
326
|
+
console.print(f"[dim]{len(items)} word(s)[/dim]")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@app.command()
|
|
330
|
+
def review(
|
|
331
|
+
limit: int = typer.Option(50, "--limit", help="Max cards to fetch (1–200)."),
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Interactive FSRS review of words that are due."""
|
|
334
|
+
cards = _run(lambda c: c.reviews_due(limit=limit))
|
|
335
|
+
|
|
336
|
+
if not cards:
|
|
337
|
+
console.print("[green]Nothing due for review — you're all caught up![/green]")
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
console.print(
|
|
341
|
+
f"[bold]{len(cards)}[/bold] card(s) due. Rate each: "
|
|
342
|
+
r"1 Again 2 Hard 3 Good 4 Easy (q to quit)" + "\n"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
reviewed = 0
|
|
346
|
+
for idx, card in enumerate(cards, start=1):
|
|
347
|
+
console.print(
|
|
348
|
+
f"[dim]{idx}/{len(cards)}[/dim] [bold]{card.get('lemma', '')}[/bold] "
|
|
349
|
+
f"[dim]({card.get('part_of_speech', '')})[/dim]"
|
|
350
|
+
)
|
|
351
|
+
if card.get("context_sentence"):
|
|
352
|
+
console.print(f" [italic dim]{card['context_sentence']}[/italic dim]")
|
|
353
|
+
|
|
354
|
+
rating = _prompt_rating()
|
|
355
|
+
if rating is None:
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
result = _run(
|
|
359
|
+
lambda c, cid=card["id"], r=rating: c.submit_review(user_vocabulary_id=cid, rating=r)
|
|
360
|
+
)
|
|
361
|
+
reviewed += 1
|
|
362
|
+
console.print(
|
|
363
|
+
f" [green]✓ {RATINGS[rating]}[/green] — next review "
|
|
364
|
+
f"{_fmt_dt(result.get('next_review'))}\n"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
console.print(f"[bold]Reviewed {reviewed} of {len(cards)} card(s).[/bold]")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ── `glossa lang` sub-app ─────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
lang_app = typer.Typer(
|
|
373
|
+
help="Show or set your default learning language (for `add` and `set`).",
|
|
374
|
+
invoke_without_command=True,
|
|
375
|
+
no_args_is_help=False,
|
|
376
|
+
)
|
|
377
|
+
app.add_typer(lang_app, name="lang")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@lang_app.callback(invoke_without_command=True)
|
|
381
|
+
def _lang_default(ctx: typer.Context) -> None:
|
|
382
|
+
"""Show the effective default language and where it comes from.
|
|
383
|
+
|
|
384
|
+
Called when `glossa lang` is invoked without a subcommand. With a
|
|
385
|
+
subcommand (`set` / `unset`) Typer dispatches there instead.
|
|
386
|
+
"""
|
|
387
|
+
if ctx.invoked_subcommand is not None:
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
flag_value: str | None = None # no per-call flag in this context
|
|
391
|
+
env_value = os.environ.get("GLOSSA_LANG", "").strip() or None
|
|
392
|
+
file_value = config.get_default_language()
|
|
393
|
+
effective = config.resolve_language(flag_value)
|
|
394
|
+
|
|
395
|
+
if effective is None:
|
|
396
|
+
console.print(
|
|
397
|
+
"[dim]No default language set.[/dim]\n"
|
|
398
|
+
"Glossa will infer the language from your account when you run "
|
|
399
|
+
"[bold]glossa add[/bold] / [bold]glossa set[/bold]. If you have "
|
|
400
|
+
"multiple enabled, set a default:\n"
|
|
401
|
+
" [bold]glossa lang set <code>[/bold] # e.g. en, fr, pt-PT"
|
|
402
|
+
)
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
source = "GLOSSA_LANG env" if env_value else (
|
|
406
|
+
"~/.config/glossa/config.json" if file_value else "fallback"
|
|
407
|
+
)
|
|
408
|
+
console.print(f"Default language: [bold]{effective}[/bold] [dim]({source})[/dim]")
|
|
409
|
+
if env_value and file_value and env_value != file_value:
|
|
410
|
+
console.print(
|
|
411
|
+
f"[dim]Note: GLOSSA_LANG={env_value} overrides the persisted "
|
|
412
|
+
f"default ({file_value}).[/dim]"
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@lang_app.command("set")
|
|
417
|
+
def lang_set(
|
|
418
|
+
code: str = typer.Argument(..., help="Language code: en, fr, or pt-PT."),
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Persist a default learning language to ~/.config/glossa/config.json."""
|
|
421
|
+
_validate_lang(code)
|
|
422
|
+
config.set_default_language(code)
|
|
423
|
+
console.print(
|
|
424
|
+
f"[green]✓ Default language set to [bold]{code}[/bold].[/green] "
|
|
425
|
+
f"[dim](~/.config/glossa/config.json)[/dim]"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@lang_app.command("unset")
|
|
430
|
+
def lang_unset() -> None:
|
|
431
|
+
"""Remove the persisted default language."""
|
|
432
|
+
if config.unset_default_language():
|
|
433
|
+
console.print("[green]✓ Default language removed.[/green]")
|
|
434
|
+
else:
|
|
435
|
+
console.print("[dim]No default language was set.[/dim]")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ── Output helpers ─────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _prompt_rating() -> int | None:
|
|
442
|
+
"""Prompt until a valid 1–4 rating or 'q' is entered. Returns None on quit."""
|
|
443
|
+
while True:
|
|
444
|
+
raw = typer.prompt(" Rating (1-4, q to quit)").strip().lower()
|
|
445
|
+
if raw in ("q", "quit"):
|
|
446
|
+
return None
|
|
447
|
+
if raw.isdigit() and int(raw) in RATINGS:
|
|
448
|
+
return int(raw)
|
|
449
|
+
err_console.print(" [red]Enter 1, 2, 3, 4, or q.[/red]")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _print_vocab_item(item: dict, action: str) -> None:
|
|
453
|
+
bits = [f"[green]{action}[/green] [bold]{item.get('lemma', '')}[/bold]"]
|
|
454
|
+
if item.get("part_of_speech"):
|
|
455
|
+
bits.append(f"({item['part_of_speech']})")
|
|
456
|
+
bits.append(f"→ status [bold]{item.get('status', '')}[/bold]")
|
|
457
|
+
if item.get("cefr_level"):
|
|
458
|
+
bits.append(f"· CEFR {item['cefr_level']}")
|
|
459
|
+
console.print(" ".join(bits))
|
|
460
|
+
if item.get("next_review"):
|
|
461
|
+
console.print(f"[dim]Next review: {_fmt_dt(item['next_review'])}[/dim]")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _fmt_dt(value: str | None) -> str:
|
|
465
|
+
if not value:
|
|
466
|
+
return "—"
|
|
467
|
+
# ISO timestamps render fine trimmed to the minute.
|
|
468
|
+
return value.replace("T", " ")[:16]
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def main() -> None:
|
|
472
|
+
"""Console-script entry point."""
|
|
473
|
+
app()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
main()
|
glossa_cli/client.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
``detail`` preserves the parsed body of FastAPI's ``detail`` field — either
|
|
32
|
+
a string (most errors) or a dict (structured errors like
|
|
33
|
+
``{"error": "multiple_languages", "enabled": [...]}``). The CLI inspects
|
|
34
|
+
the dict shape to render actionable messages.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, status_code: int, detail: "str | dict") -> None:
|
|
38
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
39
|
+
self.status_code = status_code
|
|
40
|
+
self.detail = detail
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GlossaClient:
|
|
44
|
+
"""Async client that authenticates with a static API token **or** OAuth.
|
|
45
|
+
|
|
46
|
+
Call ``await client.ensure_auth()`` before the first request — or just
|
|
47
|
+
use the async context manager which calls it for you.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
base_url: str | None = None,
|
|
53
|
+
token: str | None = None,
|
|
54
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.base_url = (base_url or os.environ.get("GLOSSA_API_URL", DEFAULT_BASE_URL)).rstrip("/")
|
|
57
|
+
self._static_token = token or os.environ.get("GLOSSA_API_TOKEN", "")
|
|
58
|
+
self._timeout = timeout
|
|
59
|
+
self._client: httpx.AsyncClient | None = None
|
|
60
|
+
|
|
61
|
+
async def ensure_auth(self) -> None:
|
|
62
|
+
"""Resolve the bearer token (static env var or OAuth) and open the httpx client."""
|
|
63
|
+
if self._client is not None:
|
|
64
|
+
return # already initialised
|
|
65
|
+
|
|
66
|
+
if not self.base_url:
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
"GLOSSA_API_URL is not set. Export it (e.g. "
|
|
69
|
+
"https://api.glossa.pro, or http://localhost:8000 for local dev)."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if self._static_token:
|
|
73
|
+
bearer = self._static_token
|
|
74
|
+
else:
|
|
75
|
+
try:
|
|
76
|
+
bearer = await ensure_token(self.base_url)
|
|
77
|
+
except AuthError as exc:
|
|
78
|
+
raise RuntimeError(str(exc)) from exc
|
|
79
|
+
|
|
80
|
+
self._client = httpx.AsyncClient(
|
|
81
|
+
base_url=self.base_url,
|
|
82
|
+
timeout=self._timeout,
|
|
83
|
+
headers={"Authorization": f"Bearer {bearer}"},
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def aclose(self) -> None:
|
|
87
|
+
if self._client is not None:
|
|
88
|
+
await self._client.aclose()
|
|
89
|
+
self._client = None
|
|
90
|
+
|
|
91
|
+
async def __aenter__(self) -> "GlossaClient":
|
|
92
|
+
await self.ensure_auth()
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
96
|
+
await self.aclose()
|
|
97
|
+
|
|
98
|
+
async def _request(
|
|
99
|
+
self, method: str, path: str, json: dict | None = None
|
|
100
|
+
) -> Any:
|
|
101
|
+
if self._client is None:
|
|
102
|
+
await self.ensure_auth()
|
|
103
|
+
resp = await self._client.request(method, path, json=json) # type: ignore[union-attr]
|
|
104
|
+
if resp.is_success:
|
|
105
|
+
if resp.status_code == 204 or not resp.content:
|
|
106
|
+
return None
|
|
107
|
+
return resp.json()
|
|
108
|
+
try:
|
|
109
|
+
payload = resp.json()
|
|
110
|
+
detail = payload.get("detail", resp.text) if isinstance(payload, dict) else resp.text
|
|
111
|
+
except ValueError:
|
|
112
|
+
detail = resp.text or resp.reason_phrase
|
|
113
|
+
# Preserve structured detail (dict) as-is so the CLI can branch on it;
|
|
114
|
+
# fall back to a string for plain-text errors.
|
|
115
|
+
raise GlossaApiError(
|
|
116
|
+
resp.status_code, detail if isinstance(detail, dict) else str(detail)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# ── Text analysis ─────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
async def analyze_text(
|
|
122
|
+
self, content: str, language_code: str | None = None
|
|
123
|
+
) -> Any:
|
|
124
|
+
body: dict[str, Any] = {"content": content}
|
|
125
|
+
if language_code is not None:
|
|
126
|
+
body["language_code"] = language_code
|
|
127
|
+
return await self._request("POST", "/api/articles/analyze", body)
|
|
128
|
+
|
|
129
|
+
# ── Vocabulary ──────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
async def add_word(
|
|
132
|
+
self,
|
|
133
|
+
lemma: str,
|
|
134
|
+
language_code: str | None = None,
|
|
135
|
+
part_of_speech: str | None = None,
|
|
136
|
+
status: str = "learning",
|
|
137
|
+
translation: str | None = None,
|
|
138
|
+
) -> Any:
|
|
139
|
+
body: dict[str, Any] = {"lemma": lemma, "status": status}
|
|
140
|
+
if language_code is not None:
|
|
141
|
+
body["language_code"] = language_code
|
|
142
|
+
if part_of_speech is not None:
|
|
143
|
+
body["part_of_speech"] = part_of_speech
|
|
144
|
+
if translation is not None:
|
|
145
|
+
body["user_translation"] = translation
|
|
146
|
+
return await self._request("POST", "/api/vocabulary/add", body)
|
|
147
|
+
|
|
148
|
+
async def list_vocabulary(
|
|
149
|
+
self, status: str | None = None, language_code: str | None = None
|
|
150
|
+
) -> Any:
|
|
151
|
+
params: dict[str, str] = {}
|
|
152
|
+
if status is not None:
|
|
153
|
+
params["status"] = status
|
|
154
|
+
if language_code is not None:
|
|
155
|
+
params["language_code"] = language_code
|
|
156
|
+
query = ("?" + "&".join(f"{k}={v}" for k, v in params.items())) if params else ""
|
|
157
|
+
return await self._request("GET", f"/api/vocabulary{query}")
|
|
158
|
+
|
|
159
|
+
async def update_status(
|
|
160
|
+
self,
|
|
161
|
+
lexical_entry_id: int,
|
|
162
|
+
status: str,
|
|
163
|
+
user_translation: str | None = None,
|
|
164
|
+
) -> Any:
|
|
165
|
+
body: dict[str, Any] = {
|
|
166
|
+
"lexical_entry_id": lexical_entry_id,
|
|
167
|
+
"status": status,
|
|
168
|
+
}
|
|
169
|
+
if user_translation is not None:
|
|
170
|
+
body["user_translation"] = user_translation
|
|
171
|
+
return await self._request("POST", "/api/vocabulary/update", body)
|
|
172
|
+
|
|
173
|
+
async def list_learning_words(self, language_code: str | None = None) -> Any:
|
|
174
|
+
params = f"?language_code={language_code}" if language_code else ""
|
|
175
|
+
return await self._request("GET", f"/api/extension/learning-words{params}")
|
|
176
|
+
|
|
177
|
+
# ── Reviews (FSRS) ────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
async def reviews_due(self, limit: int = 50) -> Any:
|
|
180
|
+
return await self._request("GET", f"/api/reviews/due?limit={limit}")
|
|
181
|
+
|
|
182
|
+
async def submit_review(self, user_vocabulary_id: int, rating: int) -> Any:
|
|
183
|
+
body = {"user_vocabulary_id": user_vocabulary_id, "rating": rating}
|
|
184
|
+
return await self._request("POST", "/api/reviews/submit", body)
|