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 +7 -0
- glossa_cli/auth.py +227 -0
- glossa_cli/cli.py +376 -0
- glossa_cli/client.py +176 -0
- glossa_cli-0.1.1.dist-info/METADATA +82 -0
- glossa_cli-0.1.1.dist-info/RECORD +8 -0
- glossa_cli-0.1.1.dist-info/WHEEL +4 -0
- glossa_cli-0.1.1.dist-info/entry_points.txt +2 -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
|
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,,
|