paradigx-cli-core 0.1.0__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.
@@ -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)
@@ -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,9 @@
1
+ paradigx_cli_core/__init__.py,sha256=hICpsmMPWyeVyuksNVRAyeGOBIyj2rjJJOgGbBjvKuM,1508
2
+ paradigx_cli_core/auth.py,sha256=IyuPeinzfva_ktVQcB2gJHphV3gimnzojVUfJ6tPPYM,3974
3
+ paradigx_cli_core/client.py,sha256=Kpmr5dg2Fd9hTntkXy8kTVeEYhIWohaUtz20Q0Wr2Mw,2729
4
+ paradigx_cli_core/config.py,sha256=3qKf9V1N6YDNAOvgtE9PYJeEf1LgKSC91TUdYxCVimk,4894
5
+ paradigx_cli_core/device_flow.py,sha256=2t5lqKS7OuJnoXNoQFL5s94MP6Q1n2EoyAF8p3PT7XQ,4252
6
+ paradigx_cli_core/output.py,sha256=KraSThVAe-fxv7VZhLkdMdLswbditYuSc7pV9OOR-GU,2153
7
+ paradigx_cli_core-0.1.0.dist-info/METADATA,sha256=4CYZtJsNGs4FiigU5RC-Km21DC79HEqqWjapysID-K8,2412
8
+ paradigx_cli_core-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ paradigx_cli_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any