glossa-cli 0.1.1__tar.gz

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.
@@ -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,56 @@
1
+ # glossa-cli
2
+
3
+ A standalone command-line client for [Glossa](https://glossa.pro) — analyze text
4
+ and manage your vocabulary from the terminal.
5
+
6
+ ## Install (one command)
7
+
8
+ ```sh
9
+ pipx install glossa-cli # recommended — isolated install
10
+ # or:
11
+ pip install glossa-cli
12
+ ```
13
+
14
+ Both put the `glossa` command on your PATH. No `uv` or source checkout required.
15
+
16
+ ## Sign in (OAuth — no token to copy)
17
+
18
+ Run `glossa login` first. It opens a browser to authorise you; after you
19
+ approve, your tokens are cached in `~/.config/glossa/credentials.json` and
20
+ refreshed automatically — you won't need to sign in again.
21
+
22
+ ```sh
23
+ glossa login # opens a browser; prints an auth URL to approve
24
+ glossa login --force # clear the cached sign-in and authorise again
25
+ ```
26
+
27
+ > **Self-hosted / local dev:** point the CLI at your instance with
28
+ > `export GLOSSA_API_URL=http://localhost:8000` (defaults to
29
+ > `https://api.glossa.pro`).
30
+ >
31
+ > **CI / scripting:** skip the browser flow by exporting a static token from
32
+ > Glossa → Settings → Tokens: `export GLOSSA_API_TOKEN=glossa_xxx`.
33
+
34
+ ## Usage
35
+
36
+ ```sh
37
+ # Analyze text (arg, --file, or stdin)
38
+ echo "The serendipitous discovery broke the ice." | glossa analyze --lang en
39
+ glossa analyze --file article.txt
40
+ glossa analyze "a short sentence" --json
41
+
42
+ # Add a word or phrase
43
+ glossa add serendipity --lang en --status learning
44
+ glossa add "break the ice" --lang en -t "сломать лёд"
45
+
46
+ # Change a word's state (upserts by lemma; --id targets one sense)
47
+ glossa set serendipity known --lang en
48
+ glossa set --id 1234 saved
49
+
50
+ # List your vocabulary
51
+ glossa list --status learning
52
+ glossa list --lang fr --json
53
+
54
+ # Interactive FSRS review session
55
+ glossa review --lang en
56
+ ```
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "glossa-cli"
3
+ version = "0.1.1"
4
+ description = "Command-line client for Glossa — analyze text and manage your vocabulary from the terminal"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [{ name = "Artemiy Rodionov", email = "artemiy.rodionov@gmail.com" }]
8
+ keywords = ["glossa", "vocabulary", "language-learning", "cli", "flashcards", "fsrs"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console",
12
+ "Intended Audience :: End Users/Desktop",
13
+ "Operating System :: OS Independent",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Education",
20
+ "Topic :: Utilities",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.28",
24
+ "typer>=0.12",
25
+ "rich>=13",
26
+ ]
27
+
28
+ [project.urls]
29
+ Homepage = "https://glossa.pro"
30
+ Repository = "https://github.com/artemiy-rodionov/glossa"
31
+ Issues = "https://github.com/artemiy-rodionov/glossa/issues"
32
+
33
+ [project.scripts]
34
+ # Once published to PyPI, users install with a single command — no uv or
35
+ # source checkout required:
36
+ # pipx install glossa-cli (recommended, isolated)
37
+ # pip install glossa-cli
38
+ # This puts the `glossa` command on PATH.
39
+ glossa = "glossa_cli.cli:main"
40
+
41
+ [build-system]
42
+ requires = ["hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/glossa_cli"]
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "pytest>=8.3",
51
+ "pytest-asyncio>=0.25",
52
+ "ruff>=0.8",
53
+ ]
54
+
55
+ [tool.pytest.ini_options]
56
+ asyncio_mode = "auto"
57
+ testpaths = ["tests"]
58
+
59
+ [tool.ruff]
60
+ line-length = 100
61
+ target-version = "py310"
62
+
63
+ [tool.ruff.lint]
64
+ select = ["E", "F", "I", "B", "UP", "SIM"]
@@ -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
+ """
@@ -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