paradigx-cli-core 0.1.0__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,8 @@
1
+ # Python build / test artifacts
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ build/
7
+ dist/
8
+ .venv/
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: paradigx-cli-core
3
+ Version: 0.1.0
4
+ Summary: Shared core for Paradigx product CLIs — OAuth device-flow, token cache, HTTP client
5
+ Project-URL: Homepage, https://github.com/jiangjin11/paradigx-workspace
6
+ Author: Paradigx Pte Ltd
7
+ License: MIT
8
+ Keywords: agent,cli,device-flow,oauth,paradigx
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.28
21
+ Requires-Dist: rich>=13.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-mock>=3.14; extra == 'dev'
24
+ Requires-Dist: pytest>=8.3; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # paradigx-cli-core
28
+
29
+ Shared core for [Paradigx](https://github.com/jiangjin11/paradigx-workspace)
30
+ product CLIs (`botu`, `tokenroute`, ...).
31
+
32
+ This is a **library, not a CLI** — it has no command entry point. Product
33
+ CLIs depend on it and keep only their command layer.
34
+
35
+ ## What it provides
36
+
37
+ - **OAuth device-flow** login against Logto (`auth`, `device_flow`)
38
+ - **Shared token cache** at `~/.paradigx/auth.json`, keyed by API URL, so
39
+ one login is reused across every Paradigx CLI (`config`)
40
+ - **Automatic JWT refresh** — cached tokens are transparently renewed via
41
+ the stored refresh token (`auth.valid_access_token`)
42
+ - **HTTP client** with per-product trailing-slash / redirect handling
43
+ (`client`)
44
+ - **`--json` output helpers** — rich tables for humans, JSON for agents
45
+ (`output`)
46
+
47
+ ## Usage (by a product CLI)
48
+
49
+ ```python
50
+ from paradigx_cli_core import do_login, valid_access_token, request
51
+
52
+ # login
53
+ do_login("https://botu.io/api/auth/discovery/", "https://botu.io",
54
+ on_code=lambda c: print("visit", c.verification_uri_complete))
55
+
56
+ # authenticated call (auto-refreshes the token)
57
+ token = valid_access_token("https://botu.io")
58
+ sites = request("GET", "https://botu.io", "/api/sites",
59
+ token=token, trailing_slash=True)
60
+ ```
61
+
62
+ See `docs/specs/cli-phase-b.md` in the workspace repo for the design.
63
+
64
+ ---
65
+
66
+ © 2026 Paradigx. All Rights Reserved.
@@ -0,0 +1,40 @@
1
+ # paradigx-cli-core
2
+
3
+ Shared core for [Paradigx](https://github.com/jiangjin11/paradigx-workspace)
4
+ product CLIs (`botu`, `tokenroute`, ...).
5
+
6
+ This is a **library, not a CLI** — it has no command entry point. Product
7
+ CLIs depend on it and keep only their command layer.
8
+
9
+ ## What it provides
10
+
11
+ - **OAuth device-flow** login against Logto (`auth`, `device_flow`)
12
+ - **Shared token cache** at `~/.paradigx/auth.json`, keyed by API URL, so
13
+ one login is reused across every Paradigx CLI (`config`)
14
+ - **Automatic JWT refresh** — cached tokens are transparently renewed via
15
+ the stored refresh token (`auth.valid_access_token`)
16
+ - **HTTP client** with per-product trailing-slash / redirect handling
17
+ (`client`)
18
+ - **`--json` output helpers** — rich tables for humans, JSON for agents
19
+ (`output`)
20
+
21
+ ## Usage (by a product CLI)
22
+
23
+ ```python
24
+ from paradigx_cli_core import do_login, valid_access_token, request
25
+
26
+ # login
27
+ do_login("https://botu.io/api/auth/discovery/", "https://botu.io",
28
+ on_code=lambda c: print("visit", c.verification_uri_complete))
29
+
30
+ # authenticated call (auto-refreshes the token)
31
+ token = valid_access_token("https://botu.io")
32
+ sites = request("GET", "https://botu.io", "/api/sites",
33
+ token=token, trailing_slash=True)
34
+ ```
35
+
36
+ See `docs/specs/cli-phase-b.md` in the workspace repo for the design.
37
+
38
+ ---
39
+
40
+ © 2026 Paradigx. All Rights Reserved.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "paradigx-cli-core"
3
+ version = "0.1.0"
4
+ description = "Shared core for Paradigx product CLIs — OAuth device-flow, token cache, HTTP client"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Paradigx Pte Ltd" }]
9
+ keywords = ["cli", "oauth", "device-flow", "agent", "paradigx"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+
23
+ dependencies = [
24
+ "httpx>=0.28",
25
+ "rich>=13.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.3",
31
+ "pytest-mock>=3.14",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/jiangjin11/paradigx-workspace"
36
+
37
+ [build-system]
38
+ requires = ["hatchling>=1.25"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/paradigx_cli_core"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,63 @@
1
+ """paradigx-cli-core — shared core for Paradigx product CLIs.
2
+
3
+ Provides OAuth device-flow login, a shared `~/.paradigx/auth.json` token
4
+ cache, automatic JWT refresh, an HTTP client, and `--json` output helpers.
5
+ Product CLIs (botu, tokenroute, ...) depend on this and keep only their
6
+ command layer. See docs/specs/cli-phase-b.md.
7
+ """
8
+ from .client import ApiError, exit_code_for, request, with_trailing_slash
9
+ from .config import (
10
+ Credentials,
11
+ clear_credentials,
12
+ default_config_dir,
13
+ import_legacy,
14
+ load_credentials,
15
+ save_credentials,
16
+ )
17
+ from .auth import NeedLogin, do_login, refresh, valid_access_token
18
+ from .device_flow import (
19
+ DeviceCodeResponse,
20
+ DiscoveryInfo,
21
+ fetch_discovery,
22
+ open_browser,
23
+ poll_for_token,
24
+ request_device_code,
25
+ )
26
+ from .output import emit, error, info, is_json_mode, set_json_mode, success
27
+
28
+ __version__ = "0.1.0"
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ # client
33
+ "ApiError",
34
+ "exit_code_for",
35
+ "request",
36
+ "with_trailing_slash",
37
+ # config
38
+ "Credentials",
39
+ "clear_credentials",
40
+ "default_config_dir",
41
+ "import_legacy",
42
+ "load_credentials",
43
+ "save_credentials",
44
+ # auth
45
+ "NeedLogin",
46
+ "do_login",
47
+ "refresh",
48
+ "valid_access_token",
49
+ # device_flow
50
+ "DeviceCodeResponse",
51
+ "DiscoveryInfo",
52
+ "fetch_discovery",
53
+ "open_browser",
54
+ "poll_for_token",
55
+ "request_device_code",
56
+ # output
57
+ "emit",
58
+ "error",
59
+ "info",
60
+ "is_json_mode",
61
+ "set_json_mode",
62
+ "success",
63
+ ]
@@ -0,0 +1,113 @@
1
+ """Login orchestration + automatic token refresh.
2
+
3
+ `do_login` runs the full device-flow and persists credentials.
4
+ `valid_access_token` is what command code calls before every API request:
5
+ it returns a non-expired access token, transparently refreshing via the
6
+ stored refresh_token when needed.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import replace
12
+
13
+ import httpx
14
+
15
+ from .config import Credentials, load_credentials, save_credentials
16
+ from .device_flow import fetch_discovery, poll_for_token, request_device_code
17
+
18
+ # Refresh when fewer than this many seconds of validity remain.
19
+ _REFRESH_SKEW_SECONDS = 60
20
+
21
+
22
+ class NeedLogin(RuntimeError):
23
+ """Raised when there's no usable credential and the user must log in."""
24
+
25
+ def __init__(self, message: str = "not logged in — run `login` first"):
26
+ super().__init__(message)
27
+
28
+
29
+ def _token_endpoint(creds: Credentials) -> str | None:
30
+ """Resolve the OIDC token endpoint for a credential.
31
+
32
+ New logins store `token_endpoint` directly. Pre-Phase-B / legacy-imported
33
+ entries only have `issuer`; derive from it (botu issuer ends with `/oidc`,
34
+ tokenroute issuer doesn't).
35
+ """
36
+ if creds.token_endpoint:
37
+ return creds.token_endpoint
38
+ iss = (creds.issuer or "").rstrip("/")
39
+ if not iss:
40
+ return None
41
+ return f"{iss}/token" if iss.endswith("/oidc") else f"{iss}/oidc/token"
42
+
43
+
44
+ def refresh(creds: Credentials) -> Credentials:
45
+ """Exchange the stored refresh_token for a fresh access token.
46
+
47
+ Raises NeedLogin if the credential can't be refreshed (no refresh_token,
48
+ endpoint unknown, or the IdP rejects it).
49
+ """
50
+ endpoint = _token_endpoint(creds)
51
+ if not creds.refresh_token or not creds.client_id or not endpoint:
52
+ raise NeedLogin("session expired — run `login` again")
53
+ data = {
54
+ "grant_type": "refresh_token",
55
+ "refresh_token": creds.refresh_token,
56
+ "client_id": creds.client_id,
57
+ }
58
+ if creds.resource:
59
+ data["resource"] = creds.resource
60
+ try:
61
+ with httpx.Client(timeout=15.0) as c:
62
+ r = c.post(endpoint, data=data)
63
+ except httpx.RequestError as e:
64
+ raise NeedLogin(f"could not reach auth server to refresh: {e}") from e
65
+ if not r.is_success:
66
+ raise NeedLogin("session expired — run `login` again")
67
+ tok = r.json()
68
+ return replace(
69
+ creds,
70
+ access_token=tok["access_token"],
71
+ refresh_token=tok.get("refresh_token") or creds.refresh_token,
72
+ expires_at=int(time.time()) + int(tok.get("expires_in", 3600)),
73
+ )
74
+
75
+
76
+ def valid_access_token(api_url: str) -> str:
77
+ """Return a non-expired access token for `api_url`.
78
+
79
+ Auto-refreshes (and persists) when the cached token is within the skew
80
+ window of expiry. Raises NeedLogin if there's nothing usable.
81
+ """
82
+ creds = load_credentials(api_url)
83
+ if creds is None or not creds.access_token:
84
+ raise NeedLogin()
85
+ if creds.expires_at and creds.expires_at - time.time() < _REFRESH_SKEW_SECONDS:
86
+ creds = refresh(creds)
87
+ save_credentials(creds)
88
+ return creds.access_token
89
+
90
+
91
+ def do_login(discovery_url: str, api_url: str, *, on_code=None) -> Credentials:
92
+ """Run the full OAuth device-flow and persist the result.
93
+
94
+ `on_code(DeviceCodeResponse)` is invoked once the user code is known —
95
+ the CLI uses it to print the verification URL and open a browser.
96
+ """
97
+ disc = fetch_discovery(discovery_url)
98
+ code = request_device_code(disc)
99
+ if on_code is not None:
100
+ on_code(code)
101
+ tok = poll_for_token(disc, code)
102
+ creds = Credentials(
103
+ access_token=tok["access_token"],
104
+ refresh_token=tok.get("refresh_token"),
105
+ expires_at=int(time.time()) + int(tok.get("expires_in", 3600)),
106
+ issuer=disc.issuer,
107
+ client_id=disc.client_id,
108
+ resource=disc.resource,
109
+ api_url=api_url,
110
+ token_endpoint=disc.token_endpoint,
111
+ )
112
+ save_credentials(creds)
113
+ return creds
@@ -0,0 +1,86 @@
1
+ """Thin HTTP client for Paradigx product APIs.
2
+
3
+ `request()` is product-agnostic: the caller passes the base URL, the bearer
4
+ token, and whether the API needs trailing slashes (botu-web is a Next.js
5
+ app with `trailingSlash: true`; the tokenroute FastAPI gateway is not).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+
14
+ class ApiError(RuntimeError):
15
+ def __init__(self, status: int, message: str, body: Any = None):
16
+ super().__init__(f"HTTP {status}: {message}")
17
+ self.status = status
18
+ self.message = message
19
+ self.body = body
20
+
21
+
22
+ def exit_code_for(err: ApiError) -> int:
23
+ """Map an ApiError onto a CLI exit code: 1 user / 2 network / 3 server."""
24
+ if err.status == 0:
25
+ return 2
26
+ return 1 if err.status < 500 else 3
27
+
28
+
29
+ def with_trailing_slash(path: str) -> str:
30
+ """Ensure a `/` before any query string — for `trailingSlash: true` APIs."""
31
+ base, sep, query = path.partition("?")
32
+ if not base.endswith("/"):
33
+ base += "/"
34
+ return f"{base}{sep}{query}"
35
+
36
+
37
+ def _raise(resp: httpx.Response) -> None:
38
+ if resp.is_success:
39
+ return
40
+ try:
41
+ body = resp.json()
42
+ except (ValueError, httpx.DecodingError):
43
+ body = resp.text
44
+ if isinstance(body, dict):
45
+ err = body.get("error")
46
+ if isinstance(err, dict): # {"error": {"message": ...}}
47
+ msg = err.get("message") or resp.reason_phrase
48
+ else: # {"error": "...", "detail"?: "..."}
49
+ msg = err or body.get("detail") or body.get("message") or resp.reason_phrase
50
+ if err and body.get("detail"):
51
+ msg = f"{err} ({body['detail']})"
52
+ else:
53
+ msg = resp.reason_phrase
54
+ raise ApiError(resp.status_code, str(msg), body)
55
+
56
+
57
+ def request(
58
+ method: str,
59
+ base_url: str,
60
+ path: str,
61
+ *,
62
+ token: str,
63
+ json_body: Any | None = None,
64
+ timeout: float = 30.0,
65
+ trailing_slash: bool = False,
66
+ follow_redirects: bool = True,
67
+ user_agent: str = "paradigx-cli-core",
68
+ ) -> Any:
69
+ """Call a product API endpoint with a bearer token. Returns parsed JSON."""
70
+ if trailing_slash:
71
+ path = with_trailing_slash(path)
72
+ headers = {
73
+ "User-Agent": user_agent,
74
+ "Accept": "application/json",
75
+ "Authorization": f"Bearer {token}",
76
+ }
77
+ url = f"{base_url.rstrip('/')}{path}"
78
+ try:
79
+ with httpx.Client(timeout=timeout, follow_redirects=follow_redirects) as client:
80
+ resp = client.request(method, url, json=json_body, headers=headers)
81
+ except httpx.RequestError as e:
82
+ raise ApiError(0, f"network error: {e}") from e
83
+ _raise(resp)
84
+ if resp.status_code == 204 or not resp.content:
85
+ return None
86
+ return resp.json()
@@ -0,0 +1,160 @@
1
+ """Shared credential storage for Paradigx product CLIs.
2
+
3
+ Tokens live in ``~/.paradigx/auth.json`` — shared across every Paradigx
4
+ CLI (botu, tokenroute, ...). They all authenticate against the same Logto,
5
+ so a single login is reused. Entries are keyed by Logto resource indicator
6
+ (so multiple products / envs coexist); retrieval picks the entry whose
7
+ ``api_url`` matches the current target.
8
+
9
+ auth.json shape::
10
+
11
+ { "version": 1,
12
+ "tokens": {
13
+ "<resource>": { access_token, refresh_token, expires_at, issuer,
14
+ client_id, resource, api_url, token_endpoint } } }
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import stat
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ _CRED_FIELDS = (
25
+ "access_token",
26
+ "refresh_token",
27
+ "expires_at",
28
+ "issuer",
29
+ "client_id",
30
+ "resource",
31
+ "api_url",
32
+ "token_endpoint",
33
+ )
34
+
35
+
36
+ def default_config_dir() -> Path:
37
+ return Path.home() / ".paradigx"
38
+
39
+
40
+ def _auth_path() -> Path:
41
+ return default_config_dir() / "auth.json"
42
+
43
+
44
+ @dataclass
45
+ class Credentials:
46
+ access_token: str
47
+ refresh_token: str | None = None
48
+ expires_at: int | None = None # unix seconds
49
+ issuer: str | None = None
50
+ client_id: str | None = None
51
+ resource: str | None = None
52
+ api_url: str | None = None
53
+ token_endpoint: str | None = None # OIDC token endpoint, for refresh
54
+
55
+
56
+ def _read_store() -> dict:
57
+ p = _auth_path()
58
+ if not p.exists():
59
+ return {"version": 1, "tokens": {}}
60
+ try:
61
+ data = json.loads(p.read_text(encoding="utf-8"))
62
+ except (json.JSONDecodeError, OSError):
63
+ return {"version": 1, "tokens": {}}
64
+ if not isinstance(data, dict):
65
+ return {"version": 1, "tokens": {}}
66
+ if not isinstance(data.get("tokens"), dict):
67
+ data["tokens"] = {}
68
+ data.setdefault("version", 1)
69
+ return data
70
+
71
+
72
+ def _write_store(store: dict) -> None:
73
+ d = default_config_dir()
74
+ d.mkdir(parents=True, exist_ok=True)
75
+ p = _auth_path()
76
+ p.write_text(json.dumps(store, indent=2), encoding="utf-8")
77
+ # Owner-only on POSIX; near no-op on Windows but doesn't error.
78
+ try:
79
+ os.chmod(p, stat.S_IRUSR | stat.S_IWUSR)
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ def save_credentials(creds: Credentials) -> Path:
85
+ store = _read_store()
86
+ key = creds.resource or creds.api_url or "default"
87
+ store["tokens"][key] = {f: getattr(creds, f) for f in _CRED_FIELDS}
88
+ _write_store(store)
89
+ return _auth_path()
90
+
91
+
92
+ def load_credentials(api_url: str) -> Credentials | None:
93
+ """Return the stored credential whose ``api_url`` matches."""
94
+ store = _read_store()
95
+ matches = [
96
+ v for v in store["tokens"].values() if isinstance(v, dict) and v.get("api_url") == api_url
97
+ ]
98
+ if not matches:
99
+ return None
100
+ best = max(matches, key=lambda v: v.get("expires_at") or 0)
101
+ return Credentials(**{f: best.get(f) for f in _CRED_FIELDS})
102
+
103
+
104
+ def clear_credentials(api_url: str) -> bool:
105
+ """Drop credential(s) for ``api_url``. True if anything was removed."""
106
+ store = _read_store()
107
+ kept = {
108
+ k: v
109
+ for k, v in store["tokens"].items()
110
+ if not (isinstance(v, dict) and v.get("api_url") == api_url)
111
+ }
112
+ if len(kept) == len(store["tokens"]):
113
+ return False
114
+ store["tokens"] = kept
115
+ _write_store(store)
116
+ return True
117
+
118
+
119
+ def import_legacy(
120
+ legacy_path: Path,
121
+ api_url: str,
122
+ *,
123
+ token_endpoint: str | None = None,
124
+ resource: str | None = None,
125
+ ) -> bool:
126
+ """Migrate a pre-Phase-B single-credential file into the shared store.
127
+
128
+ Used by tokenroute 0.2.0 to pull ``~/.tokenroute/credentials.json`` into
129
+ ``~/.paradigx/auth.json``. No-op if the legacy file is absent or this
130
+ api_url already has an entry. The legacy file is left in place (so a
131
+ downgrade to 0.1.0 still works).
132
+
133
+ `resource` is a fallback for legacy files that predate the field — it's
134
+ required for the OIDC token endpoint to mint JWT (not opaque) access
135
+ tokens on refresh, so the caller must supply its product's resource
136
+ indicator when the legacy file may lack one.
137
+ """
138
+ if not legacy_path.exists():
139
+ return False
140
+ if load_credentials(api_url) is not None:
141
+ return False
142
+ try:
143
+ data = json.loads(legacy_path.read_text(encoding="utf-8"))
144
+ except (json.JSONDecodeError, OSError):
145
+ return False
146
+ if not isinstance(data, dict) or not data.get("access_token"):
147
+ return False
148
+ save_credentials(
149
+ Credentials(
150
+ access_token=data.get("access_token", ""),
151
+ refresh_token=data.get("refresh_token"),
152
+ expires_at=data.get("expires_at"),
153
+ issuer=data.get("issuer"),
154
+ client_id=data.get("client_id"),
155
+ resource=data.get("resource") or resource,
156
+ api_url=api_url,
157
+ token_endpoint=token_endpoint,
158
+ )
159
+ )
160
+ return True
@@ -0,0 +1,129 @@
1
+ """OIDC device-flow client — talks directly to Logto.
2
+
3
+ The product CLI passes its discovery URL (a product endpoint that returns
4
+ ``{issuer, client_id, resource, scopes, device_authorization_endpoint,
5
+ token_endpoint}``). The CLI never hardcodes a Logto URL. This is the
6
+ OIDC-standard pattern: the client connects to the IdP, the resource server
7
+ only validates tokens.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ import webbrowser
13
+ from dataclasses import dataclass
14
+
15
+ import httpx
16
+
17
+ # Per RFC 8628 §3.5 we honour the server's `interval`; this is a sane floor.
18
+ _MIN_POLL_INTERVAL_SECONDS = 5
19
+
20
+
21
+ @dataclass
22
+ class DiscoveryInfo:
23
+ issuer: str
24
+ client_id: str
25
+ resource: str
26
+ scopes: list[str]
27
+ device_authorization_endpoint: str
28
+ token_endpoint: str
29
+
30
+
31
+ @dataclass
32
+ class DeviceCodeResponse:
33
+ device_code: str
34
+ user_code: str
35
+ verification_uri: str
36
+ verification_uri_complete: str
37
+ expires_in: int
38
+ interval: int
39
+
40
+
41
+ def fetch_discovery(discovery_url: str) -> DiscoveryInfo:
42
+ """GET the product's auth-discovery endpoint."""
43
+ with httpx.Client(timeout=10.0, follow_redirects=True) as c:
44
+ r = c.get(discovery_url)
45
+ r.raise_for_status()
46
+ data = r.json()
47
+ return DiscoveryInfo(
48
+ issuer=data["issuer"],
49
+ client_id=data["client_id"],
50
+ resource=data["resource"],
51
+ scopes=data["scopes"],
52
+ device_authorization_endpoint=data["device_authorization_endpoint"],
53
+ token_endpoint=data["token_endpoint"],
54
+ )
55
+
56
+
57
+ def request_device_code(disc: DiscoveryInfo) -> DeviceCodeResponse:
58
+ """POST {device_authorization_endpoint} → device_code + user_code."""
59
+ with httpx.Client(timeout=10.0) as c:
60
+ r = c.post(
61
+ disc.device_authorization_endpoint,
62
+ data={
63
+ "client_id": disc.client_id,
64
+ "scope": " ".join(disc.scopes),
65
+ "resource": disc.resource,
66
+ },
67
+ )
68
+ r.raise_for_status()
69
+ body = r.json()
70
+ return DeviceCodeResponse(
71
+ device_code=body["device_code"],
72
+ user_code=body["user_code"],
73
+ verification_uri=body["verification_uri"],
74
+ verification_uri_complete=body.get(
75
+ "verification_uri_complete", body["verification_uri"]
76
+ ),
77
+ expires_in=body["expires_in"],
78
+ interval=max(body.get("interval", 5), _MIN_POLL_INTERVAL_SECONDS),
79
+ )
80
+
81
+
82
+ def open_browser(url: str) -> bool:
83
+ """Best-effort. False on headless environments with no display."""
84
+ try:
85
+ return webbrowser.open(url)
86
+ except webbrowser.Error:
87
+ return False
88
+
89
+
90
+ def poll_for_token(disc: DiscoveryInfo, code: DeviceCodeResponse, *, on_pending=None) -> dict:
91
+ """Poll {token_endpoint} until success / expired / denied.
92
+
93
+ Returns the raw token response dict. Raises RuntimeError with a
94
+ human-readable message on failure.
95
+ """
96
+ deadline = time.time() + code.expires_in
97
+ interval = code.interval
98
+ with httpx.Client(timeout=10.0) as client:
99
+ while time.time() < deadline:
100
+ time.sleep(interval)
101
+ r = client.post(
102
+ disc.token_endpoint,
103
+ data={
104
+ "client_id": disc.client_id,
105
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
106
+ "device_code": code.device_code,
107
+ "resource": disc.resource,
108
+ },
109
+ )
110
+ if r.is_success:
111
+ return r.json()
112
+ err = (
113
+ r.json().get("error")
114
+ if r.headers.get("content-type", "").startswith("application/json")
115
+ else None
116
+ )
117
+ if err == "authorization_pending":
118
+ if on_pending is not None:
119
+ on_pending()
120
+ continue
121
+ if err == "slow_down":
122
+ interval += 5
123
+ continue
124
+ if err == "expired_token":
125
+ raise RuntimeError("device code expired — log in again")
126
+ if err == "access_denied":
127
+ raise RuntimeError("login denied by user")
128
+ raise RuntimeError(f"token exchange failed ({r.status_code}): {r.text}")
129
+ raise RuntimeError("device code expired before user completed login")
@@ -0,0 +1,76 @@
1
+ """Output helpers — toggle between human-friendly rich tables and --json.
2
+
3
+ JSON mode is process-global, set by the product CLI's Typer callback via
4
+ `set_json_mode()` and read everywhere via `is_json_mode()`.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ from typing import Any
12
+
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ _JSON_ENV = "_PARADIGX_CLI_JSON"
17
+ _console = Console()
18
+ _err_console = Console(stderr=True)
19
+
20
+
21
+ def set_json_mode(enabled: bool) -> None:
22
+ if enabled:
23
+ os.environ[_JSON_ENV] = "1"
24
+ else:
25
+ os.environ.pop(_JSON_ENV, None)
26
+
27
+
28
+ def is_json_mode() -> bool:
29
+ return os.environ.get(_JSON_ENV) == "1"
30
+
31
+
32
+ def emit(payload: Any, *, table_columns: list[tuple[str, str]] | None = None) -> None:
33
+ """Print `payload` as JSON (agent mode) or a rich table / kv list (human).
34
+
35
+ `table_columns` is a list of `(header, dict_key)` pairs used when payload
36
+ is a list. Single dicts render as a key/value list.
37
+ """
38
+ if is_json_mode():
39
+ print(json.dumps(payload, indent=2, default=str))
40
+ return
41
+
42
+ if isinstance(payload, list) and table_columns:
43
+ table = Table(show_header=True, header_style="bold")
44
+ for header, _ in table_columns:
45
+ table.add_column(header)
46
+ for row in payload:
47
+ table.add_row(*[str(row.get(k, "")) for _, k in table_columns])
48
+ _console.print(table)
49
+ return
50
+
51
+ if isinstance(payload, dict):
52
+ for k, v in payload.items():
53
+ _console.print(f"[bold]{k}[/bold]: {v}")
54
+ return
55
+
56
+ _console.print(payload)
57
+
58
+
59
+ def info(msg: str) -> None:
60
+ if not is_json_mode():
61
+ _console.print(msg)
62
+
63
+
64
+ def success(msg: str) -> None:
65
+ if not is_json_mode():
66
+ # ASCII-safe marker — Windows legacy GBK consoles can't encode U+2713.
67
+ _console.print(f"[green]OK[/green] {msg}")
68
+
69
+
70
+ def error(msg: str, *, code: int = 1) -> None:
71
+ """Print an error and exit. code: 1 user / 2 network / 3 server."""
72
+ if is_json_mode():
73
+ print(json.dumps({"error": msg}, indent=2))
74
+ else:
75
+ _err_console.print(f"[red]error[/red]: {msg}")
76
+ sys.exit(code)
File without changes
@@ -0,0 +1,265 @@
1
+ """Tests for paradigx-cli-core.
2
+
3
+ `Path.home` is redirected to tmp_path so the real ~/.paradigx/ is never
4
+ touched; httpx is monkeypatched so no real network calls happen.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import time
10
+ from pathlib import Path
11
+ from unittest.mock import MagicMock
12
+
13
+ import pytest
14
+
15
+ from paradigx_cli_core import auth as auth_mod
16
+ from paradigx_cli_core import client as client_mod
17
+ from paradigx_cli_core import config as config_mod
18
+ from paradigx_cli_core import output as output_mod
19
+ from paradigx_cli_core.config import Credentials
20
+
21
+
22
+ @pytest.fixture
23
+ def fake_home(tmp_path, monkeypatch):
24
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
25
+ return tmp_path
26
+
27
+
28
+ @pytest.fixture(autouse=True)
29
+ def _clean_json_mode(monkeypatch):
30
+ monkeypatch.delenv("_PARADIGX_CLI_JSON", raising=False)
31
+
32
+
33
+ def _mock_httpx(monkeypatch, *, status=200, json_body=None):
34
+ """Patch httpx.Client so .post / .request return a canned response."""
35
+ import httpx
36
+
37
+ resp = MagicMock(spec=httpx.Response)
38
+ resp.is_success = 200 <= status < 300
39
+ resp.status_code = status
40
+ resp.json.return_value = json_body or {}
41
+ resp.text = json.dumps(json_body) if json_body is not None else ""
42
+ resp.content = (resp.text or "x").encode()
43
+ resp.headers = {"content-type": "application/json"}
44
+ resp.reason_phrase = "OK" if resp.is_success else "Error"
45
+
46
+ client = MagicMock()
47
+ client.__enter__ = MagicMock(return_value=client)
48
+ client.__exit__ = MagicMock(return_value=False)
49
+ client.post = MagicMock(return_value=resp)
50
+ client.request = MagicMock(return_value=resp)
51
+ monkeypatch.setattr("httpx.Client", lambda **kw: client)
52
+ return client
53
+
54
+
55
+ # ─── config ──────────────────────────────────────────────────────────
56
+
57
+
58
+ def test_credentials_roundtrip(fake_home):
59
+ config_mod.save_credentials(
60
+ Credentials(access_token="abc", refresh_token="r", expires_at=123,
61
+ resource="res-1", api_url="https://botu.io")
62
+ )
63
+ loaded = config_mod.load_credentials("https://botu.io")
64
+ assert loaded.access_token == "abc"
65
+ assert loaded.refresh_token == "r"
66
+ assert loaded.resource == "res-1"
67
+
68
+
69
+ def test_load_none_when_empty(fake_home):
70
+ assert config_mod.load_credentials("https://botu.io") is None
71
+
72
+
73
+ def test_store_separates_by_api_url(fake_home):
74
+ config_mod.save_credentials(
75
+ Credentials(access_token="botu-tok", resource="r1", api_url="https://botu.io")
76
+ )
77
+ config_mod.save_credentials(
78
+ Credentials(access_token="tr-tok", resource="r2", api_url="https://api.tokenroute.io")
79
+ )
80
+ assert config_mod.load_credentials("https://botu.io").access_token == "botu-tok"
81
+ assert config_mod.load_credentials("https://api.tokenroute.io").access_token == "tr-tok"
82
+
83
+
84
+ def test_clear_credentials(fake_home):
85
+ config_mod.save_credentials(Credentials(access_token="x", resource="r", api_url="https://botu.io"))
86
+ assert config_mod.clear_credentials("https://botu.io") is True
87
+ assert config_mod.load_credentials("https://botu.io") is None
88
+ assert config_mod.clear_credentials("https://botu.io") is False
89
+
90
+
91
+ def test_import_legacy(fake_home, tmp_path):
92
+ legacy = tmp_path / "credentials.json"
93
+ legacy.write_text(json.dumps({
94
+ "access_token": "old-tok", "refresh_token": "old-ref",
95
+ "expires_at": 999, "issuer": "https://auth.paradigx.com", "client_id": "c1",
96
+ }), encoding="utf-8")
97
+ assert config_mod.import_legacy(
98
+ legacy, "https://api.tokenroute.io", token_endpoint="https://auth.paradigx.com/oidc/token"
99
+ ) is True
100
+ got = config_mod.load_credentials("https://api.tokenroute.io")
101
+ assert got.access_token == "old-tok"
102
+ assert got.token_endpoint == "https://auth.paradigx.com/oidc/token"
103
+ # idempotent — second call is a no-op
104
+ assert config_mod.import_legacy(legacy, "https://api.tokenroute.io") is False
105
+
106
+
107
+ def test_import_legacy_missing_file(fake_home, tmp_path):
108
+ assert config_mod.import_legacy(tmp_path / "nope.json", "https://botu.io") is False
109
+
110
+
111
+ def test_import_legacy_resource_fallback(fake_home, tmp_path):
112
+ # pre-0.2 files predate the `resource` field — the caller supplies it so
113
+ # refresh keeps minting JWT (not opaque) tokens.
114
+ legacy = tmp_path / "credentials.json"
115
+ legacy.write_text(
116
+ json.dumps({"access_token": "t", "refresh_token": "r", "issuer": "https://i"}),
117
+ encoding="utf-8",
118
+ )
119
+ config_mod.import_legacy(
120
+ legacy, "https://api.tokenroute.io", resource="https://api.tokenroute.io"
121
+ )
122
+ got = config_mod.load_credentials("https://api.tokenroute.io")
123
+ assert got.resource == "https://api.tokenroute.io"
124
+
125
+
126
+ # ─── auth: token endpoint resolution + refresh ───────────────────────
127
+
128
+
129
+ def test_token_endpoint_prefers_stored():
130
+ c = Credentials(access_token="x", token_endpoint="https://e/token", issuer="https://i")
131
+ assert auth_mod._token_endpoint(c) == "https://e/token"
132
+
133
+
134
+ def test_token_endpoint_derives_botu_style():
135
+ # botu issuer already ends with /oidc
136
+ c = Credentials(access_token="x", issuer="https://auth.paradigx.com/oidc")
137
+ assert auth_mod._token_endpoint(c) == "https://auth.paradigx.com/oidc/token"
138
+
139
+
140
+ def test_token_endpoint_derives_tokenroute_style():
141
+ # tokenroute issuer has no /oidc
142
+ c = Credentials(access_token="x", issuer="https://auth.paradigx.com")
143
+ assert auth_mod._token_endpoint(c) == "https://auth.paradigx.com/oidc/token"
144
+
145
+
146
+ def test_refresh_success(fake_home, monkeypatch):
147
+ _mock_httpx(monkeypatch, status=200, json_body={"access_token": "new-tok", "expires_in": 3600})
148
+ creds = Credentials(access_token="old", refresh_token="r", client_id="c",
149
+ token_endpoint="https://e/token", api_url="https://botu.io")
150
+ out = auth_mod.refresh(creds)
151
+ assert out.access_token == "new-tok"
152
+ assert out.refresh_token == "r" # kept when response omits a new one
153
+ assert out.expires_at > time.time()
154
+
155
+
156
+ def test_refresh_rejected_raises_needlogin(fake_home, monkeypatch):
157
+ _mock_httpx(monkeypatch, status=400, json_body={"error": "invalid_grant"})
158
+ creds = Credentials(access_token="old", refresh_token="r", client_id="c",
159
+ token_endpoint="https://e/token")
160
+ with pytest.raises(auth_mod.NeedLogin):
161
+ auth_mod.refresh(creds)
162
+
163
+
164
+ def test_refresh_no_refresh_token_raises():
165
+ with pytest.raises(auth_mod.NeedLogin):
166
+ auth_mod.refresh(Credentials(access_token="x", token_endpoint="https://e/token"))
167
+
168
+
169
+ # ─── auth: valid_access_token ────────────────────────────────────────
170
+
171
+
172
+ def test_valid_access_token_fresh(fake_home):
173
+ config_mod.save_credentials(Credentials(
174
+ access_token="fresh", expires_at=int(time.time()) + 3600, api_url="https://botu.io"))
175
+ assert auth_mod.valid_access_token("https://botu.io") == "fresh"
176
+
177
+
178
+ def test_valid_access_token_none_raises(fake_home):
179
+ with pytest.raises(auth_mod.NeedLogin):
180
+ auth_mod.valid_access_token("https://botu.io")
181
+
182
+
183
+ def test_valid_access_token_expired_refreshes(fake_home, monkeypatch):
184
+ config_mod.save_credentials(Credentials(
185
+ access_token="stale", refresh_token="r", client_id="c",
186
+ token_endpoint="https://e/token",
187
+ expires_at=int(time.time()) - 10, api_url="https://botu.io"))
188
+ _mock_httpx(monkeypatch, status=200, json_body={"access_token": "refreshed", "expires_in": 3600})
189
+ assert auth_mod.valid_access_token("https://botu.io") == "refreshed"
190
+ # persisted
191
+ assert config_mod.load_credentials("https://botu.io").access_token == "refreshed"
192
+
193
+
194
+ # ─── auth: do_login ──────────────────────────────────────────────────
195
+
196
+
197
+ def test_do_login(fake_home, monkeypatch):
198
+ from paradigx_cli_core.device_flow import DeviceCodeResponse, DiscoveryInfo
199
+
200
+ disc = DiscoveryInfo(
201
+ issuer="https://auth.paradigx.com/oidc", client_id="cli", resource="res",
202
+ scopes=["openid"], device_authorization_endpoint="https://e/device",
203
+ token_endpoint="https://auth.paradigx.com/oidc/token")
204
+ code = DeviceCodeResponse("dc", "USER-1", "https://v", "https://v?c=1", 600, 5)
205
+ monkeypatch.setattr(auth_mod, "fetch_discovery", lambda u: disc)
206
+ monkeypatch.setattr(auth_mod, "request_device_code", lambda d: code)
207
+ monkeypatch.setattr(auth_mod, "poll_for_token", lambda d, c: {
208
+ "access_token": "jwt", "refresh_token": "rt", "expires_in": 3600})
209
+
210
+ seen = []
211
+ creds = auth_mod.do_login("https://botu.io/api/auth/discovery/", "https://botu.io",
212
+ on_code=lambda c: seen.append(c.user_code))
213
+ assert seen == ["USER-1"]
214
+ assert creds.access_token == "jwt"
215
+ assert creds.token_endpoint == "https://auth.paradigx.com/oidc/token"
216
+ assert config_mod.load_credentials("https://botu.io").access_token == "jwt"
217
+
218
+
219
+ # ─── client ──────────────────────────────────────────────────────────
220
+
221
+
222
+ def test_with_trailing_slash():
223
+ assert client_mod.with_trailing_slash("/api/sites") == "/api/sites/"
224
+ assert client_mod.with_trailing_slash("/api/sites/") == "/api/sites/"
225
+ assert client_mod.with_trailing_slash("/api/usage?site=s") == "/api/usage/?site=s"
226
+
227
+
228
+ def test_exit_code_for():
229
+ assert client_mod.exit_code_for(client_mod.ApiError(0, "net")) == 2
230
+ assert client_mod.exit_code_for(client_mod.ApiError(404, "nf")) == 1
231
+ assert client_mod.exit_code_for(client_mod.ApiError(500, "boom")) == 3
232
+
233
+
234
+ def test_request_success(monkeypatch):
235
+ _mock_httpx(monkeypatch, status=200, json_body={"ok": True})
236
+ out = client_mod.request("GET", "https://botu.io", "/api/sites", token="t", trailing_slash=True)
237
+ assert out == {"ok": True}
238
+
239
+
240
+ def test_request_error_raises_apierror(monkeypatch):
241
+ _mock_httpx(monkeypatch, status=404, json_body={"error": "not_found", "detail": "no site"})
242
+ with pytest.raises(client_mod.ApiError) as ei:
243
+ client_mod.request("GET", "https://botu.io", "/api/sites/x", token="t")
244
+ assert ei.value.status == 404
245
+ assert "not_found" in ei.value.message and "no site" in ei.value.message
246
+
247
+
248
+ # ─── output ──────────────────────────────────────────────────────────
249
+
250
+
251
+ def test_json_mode_toggle():
252
+ assert output_mod.is_json_mode() is False
253
+ output_mod.set_json_mode(True)
254
+ assert output_mod.is_json_mode() is True
255
+ output_mod.set_json_mode(False)
256
+ assert output_mod.is_json_mode() is False
257
+
258
+
259
+ def test_emit_json(capsys):
260
+ output_mod.set_json_mode(True)
261
+ try:
262
+ output_mod.emit({"a": 1})
263
+ finally:
264
+ output_mod.set_json_mode(False)
265
+ assert json.loads(capsys.readouterr().out.strip()) == {"a": 1}