talis-cli 0.1.0a1__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.
talis/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Talis command-line client."""
2
+
3
+ __version__ = "0.1.0a1"
talis/api.py ADDED
@@ -0,0 +1,470 @@
1
+ """
2
+ Thin httpx wrapper for the Talis control plane.
3
+
4
+ Handles:
5
+ - Bearer auth from the stored credentials
6
+ - ``X-API-Key`` header for the device-flow polling endpoints (no auth)
7
+ - ``X-Request-Id`` correlation header on every outbound request
8
+ - Sensible timeouts
9
+ - A small ``APIError`` that surfaces ``status_code``, parsed ``detail``,
10
+ and the server-echoed ``request_id`` so callers can paste a single
11
+ short string into a log search and find both sides of the failure.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import sys
18
+ import uuid
19
+ from contextlib import contextmanager
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+ from urllib.parse import urlparse
23
+
24
+ import httpx
25
+
26
+ from . import config
27
+
28
+ DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=30.0, pool=10.0)
29
+
30
+ REQUEST_ID_HEADER = "X-Request-Id"
31
+
32
+ # Loopback hosts don't traverse the network; HTTP there is fine and we
33
+ # shouldn't add noise to legitimate dev work.
34
+ _LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
35
+
36
+ # Process-once flag: one stderr line per CLI invocation, not per Client.
37
+ _http_warning_emitted = False
38
+
39
+
40
+ def _warn_non_https(endpoint: str) -> None:
41
+ """One-time warning if `endpoint` is HTTP over a non-loopback host.
42
+
43
+ The risk we're flagging: the X-API-Key header carries a Talis JWT and
44
+ sending it over plain HTTP leaks it to any network observer. Loopback
45
+ HTTP is exempt because the bytes never hit the network.
46
+ """
47
+ global _http_warning_emitted
48
+ if _http_warning_emitted:
49
+ return
50
+ try:
51
+ parsed = urlparse(endpoint)
52
+ except ValueError:
53
+ return
54
+ if parsed.scheme == "https":
55
+ return
56
+ host = (parsed.hostname or "").lower()
57
+ if host in _LOOPBACK_HOSTS:
58
+ return
59
+ sys.stderr.write(
60
+ f"warning: endpoint {endpoint} is not HTTPS — your token may be "
61
+ "sent in plaintext.\n"
62
+ )
63
+ _http_warning_emitted = True
64
+
65
+
66
+ @dataclass
67
+ class APIError(Exception):
68
+ status_code: int
69
+ detail: str
70
+ body: Any = None
71
+ request_id: str = ""
72
+
73
+ def __post_init__(self):
74
+ super().__init__(self.detail)
75
+
76
+ def __str__(self) -> str: # pragma: no cover - cosmetic
77
+ # Include request_id in the string form so a user pasting the error
78
+ # message into a bug report carries the correlation key without
79
+ # having to know the attribute exists.
80
+ if self.request_id:
81
+ return f"HTTP {self.status_code} (req {self.request_id}): {self.detail}"
82
+ return f"HTTP {self.status_code}: {self.detail}"
83
+
84
+
85
+ def _generate_request_id() -> str:
86
+ """Per-Client correlation token. Short, URL-safe, easily greppable.
87
+
88
+ The ``cli-`` prefix flags the source so a log diff against server-
89
+ generated IDs (which start with ``req-``) is one substring away.
90
+ """
91
+ return f"cli-{uuid.uuid4().hex[:12]}"
92
+
93
+
94
+ def _resolve_request_id(explicit: str | None) -> str:
95
+ """Pick the request_id for a Client lifetime.
96
+
97
+ Order: explicit constructor arg > ``TALIS_REQUEST_ID`` env var > new.
98
+ Env var lets a wrapping test runner pin one ID across multiple CLI
99
+ invocations so a higher-level scenario shows up as one logical thread
100
+ in the server logs.
101
+ """
102
+ if explicit:
103
+ return explicit
104
+ from_env = (os.environ.get("TALIS_REQUEST_ID") or "").strip()
105
+ if from_env:
106
+ return from_env
107
+ return _generate_request_id()
108
+
109
+
110
+ class Client:
111
+ """One-shot synchronous client. Construct, call, discard."""
112
+
113
+ def __init__(
114
+ self,
115
+ *,
116
+ endpoint: str | None = None,
117
+ token: str | None = None,
118
+ timeout: httpx.Timeout = DEFAULT_TIMEOUT,
119
+ request_id: str | None = None,
120
+ ):
121
+ self.endpoint = (endpoint or config.endpoint_from_env()).rstrip("/")
122
+ self.token = token
123
+ # One correlation ID per Client. Each command typically constructs
124
+ # one Client, so the ID covers all requests for that command. A
125
+ # test runner spanning multiple commands can pin one ID via the
126
+ # ``TALIS_REQUEST_ID`` env var.
127
+ self.request_id = _resolve_request_id(request_id)
128
+ self.last_server_request_id = "" # populated after each call
129
+ _warn_non_https(self.endpoint)
130
+ self._client = httpx.Client(base_url=self.endpoint, timeout=timeout)
131
+
132
+ def close(self) -> None:
133
+ self._client.close()
134
+
135
+ # Context manager keeps the dispatch site terse.
136
+ def __enter__(self) -> Client:
137
+ return self
138
+
139
+ def __exit__(self, *exc) -> None:
140
+ self.close()
141
+
142
+ # ---- request helpers -------------------------------------------------- #
143
+
144
+ def _headers(self, auth: bool) -> dict[str, str]:
145
+ h: dict[str, str] = {
146
+ "Accept": "application/json",
147
+ REQUEST_ID_HEADER: self.request_id,
148
+ }
149
+ if auth and self.token:
150
+ # X-API-Key carries the Talis JWT in the existing API contract.
151
+ # Authorization: Bearer is reserved for Privy tokens on the
152
+ # /auth/token + /auth/device/approve paths.
153
+ h["X-API-Key"] = self.token
154
+ return h
155
+
156
+ def _request(
157
+ self,
158
+ method: str,
159
+ path: str,
160
+ *,
161
+ params: dict[str, Any] | None = None,
162
+ json: Any = None,
163
+ auth: bool = True,
164
+ bearer: str | None = None,
165
+ ) -> Any:
166
+ headers = self._headers(auth)
167
+ if bearer:
168
+ headers["Authorization"] = f"Bearer {bearer}"
169
+ resp = self._client.request(method, path, params=params, json=json, headers=headers)
170
+ # Always record the server's request_id (echo of ours or, in rare
171
+ # cases like cache-served responses, something different) so callers
172
+ # have something to paste into a log search even on success.
173
+ self.last_server_request_id = resp.headers.get(REQUEST_ID_HEADER, "") or ""
174
+ if resp.status_code >= 400:
175
+ try:
176
+ body = resp.json()
177
+ detail = (
178
+ body.get("detail")
179
+ if isinstance(body, dict)
180
+ else str(body)
181
+ )
182
+ if not isinstance(detail, str):
183
+ detail = str(detail)
184
+ except ValueError:
185
+ body = None
186
+ detail = resp.text or f"HTTP {resp.status_code}"
187
+ raise APIError(
188
+ resp.status_code, detail, body,
189
+ request_id=self.last_server_request_id,
190
+ )
191
+ if not resp.content:
192
+ return None
193
+ try:
194
+ return resp.json()
195
+ except ValueError:
196
+ return resp.text
197
+
198
+ # ---- public endpoints ------------------------------------------------- #
199
+
200
+ # Device flow
201
+ def device_code(self, *, client_name: str, scope: str) -> dict:
202
+ return self._request(
203
+ "POST", "/auth/device/code",
204
+ json={"client_name": client_name, "scope": scope},
205
+ auth=False,
206
+ )
207
+
208
+ def device_token(self, *, device_code: str) -> dict:
209
+ return self._request(
210
+ "POST", "/auth/device/token",
211
+ json={"device_code": device_code},
212
+ auth=False,
213
+ )
214
+
215
+ def device_approve(self, *, user_code: str, scope: str | None = None) -> dict:
216
+ payload: dict[str, Any] = {"user_code": user_code}
217
+ if scope:
218
+ payload["scope"] = scope
219
+ return self._request(
220
+ "POST", "/auth/device/approve",
221
+ json=payload,
222
+ auth=True,
223
+ )
224
+
225
+ # OAuth loopback (PKCE) flow — the codex-style primary login.
226
+ def oauth_token(self, *, code: str, code_verifier: str, redirect_uri: str) -> dict:
227
+ """Exchange a loopback-captured authorization code for the Talis JWT.
228
+
229
+ PKCE is the proof of possession — no auth header. The server validates
230
+ SHA-256(code_verifier) == the stored code_challenge before minting.
231
+ """
232
+ return self._request(
233
+ "POST", "/auth/oauth/token",
234
+ json={
235
+ "grant_type": "authorization_code",
236
+ "code": code,
237
+ "code_verifier": code_verifier,
238
+ "redirect_uri": redirect_uri,
239
+ },
240
+ auth=False,
241
+ )
242
+
243
+ # Sessions
244
+ def list_sessions(self, *, tenant_id: str) -> dict:
245
+ return self._request("GET", "/auth/sessions", params={"tenant_id": tenant_id})
246
+
247
+ def revoke_session(self, *, tenant_id: str, jti_prefix: str) -> dict:
248
+ return self._request(
249
+ "DELETE", f"/auth/sessions/{jti_prefix}",
250
+ params={"tenant_id": tenant_id},
251
+ )
252
+
253
+ def revoke_other_sessions(self, *, tenant_id: str) -> dict:
254
+ return self._request("DELETE", "/auth/sessions", params={"tenant_id": tenant_id})
255
+
256
+ # Read surface
257
+ def get_portfolio(self, *, tenant_id: str, mode: str | None = None) -> dict:
258
+ # The route is GET /portfolio/{tenant_id} — tenant_id is bound via the
259
+ # path, not a query param. No need to send it twice.
260
+ params: dict[str, Any] = {}
261
+ if mode:
262
+ params["mode"] = mode
263
+ return self._request("GET", f"/portfolio/{tenant_id}", params=params or None)
264
+
265
+ def get_balance(self, *, tenant_id: str, exchange: str = "hyperliquid_perpetual") -> dict:
266
+ return self._request(
267
+ "GET", "/account/balance",
268
+ params={"tenant_id": tenant_id, "exchange": exchange},
269
+ )
270
+
271
+ def get_positions(
272
+ self,
273
+ *,
274
+ tenant_id: str,
275
+ exchange: str = "hyperliquid_perpetual",
276
+ mode: str | None = None,
277
+ ) -> dict:
278
+ params: dict[str, Any] = {"tenant_id": tenant_id, "exchange": exchange}
279
+ if mode:
280
+ params["mode"] = mode
281
+ return self._request("GET", "/positions", params=params)
282
+
283
+ def get_open_orders(
284
+ self,
285
+ *,
286
+ tenant_id: str,
287
+ exchange: str = "hyperliquid_perpetual",
288
+ ) -> dict:
289
+ """Open orders for this tenant. Used by ``talis wait order`` and
290
+ ``talis snapshot``. Server returns ``{"open": [...]}``."""
291
+ return self._request(
292
+ "GET", "/orders/open",
293
+ params={"tenant_id": tenant_id, "exchange": exchange},
294
+ )
295
+
296
+ # -----------------------------------------------------------------
297
+ # Admin: test-tenant lifecycle (PR 3). All three require admin key.
298
+ # The CLI surfaces them via ``talis test-tenant {create,delete,list}``.
299
+ # -----------------------------------------------------------------
300
+ def create_test_tenant(
301
+ self,
302
+ *,
303
+ label: str = "",
304
+ initial_balance_usd: float = 10000.0,
305
+ ttl_seconds: int = 3600,
306
+ exchange: str = "hyperliquid_perpetual",
307
+ ) -> dict:
308
+ return self._request(
309
+ "POST", "/admin/test-tenants",
310
+ json={
311
+ "label": label,
312
+ "initial_balance_usd": initial_balance_usd,
313
+ "ttl_seconds": ttl_seconds,
314
+ "exchange": exchange,
315
+ },
316
+ )
317
+
318
+ def list_test_tenants(self) -> dict:
319
+ return self._request("GET", "/admin/test-tenants")
320
+
321
+ def delete_test_tenant(
322
+ self, *, tenant_id: str, exchange: str | None = None,
323
+ ) -> dict:
324
+ """Server resolves the exchange from metadata stored at
325
+ provision time. Pass ``exchange`` only as an explicit override
326
+ for the rare case where metadata expired (7-day TTL) and the
327
+ operator knows the original exchange."""
328
+ params: dict[str, Any] = {}
329
+ if exchange:
330
+ params["exchange"] = exchange
331
+ return self._request(
332
+ "DELETE", f"/admin/test-tenants/{tenant_id}",
333
+ params=params or None,
334
+ )
335
+
336
+ # Strategies
337
+ def list_strategies(self, *, tenant_id: str) -> list[dict] | dict:
338
+ return self._request("GET", "/strategies", params={"tenant_id": tenant_id})
339
+
340
+ def get_strategy(self, *, tenant_id: str, strategy_id: str) -> dict:
341
+ return self._request(
342
+ "GET", f"/strategies/{strategy_id}",
343
+ params={"tenant_id": tenant_id},
344
+ )
345
+
346
+ def stop_strategy(self, *, tenant_id: str, strategy_id: str) -> dict:
347
+ return self._request(
348
+ "POST", f"/strategies/{strategy_id}/stop",
349
+ params={"tenant_id": tenant_id},
350
+ )
351
+
352
+ def delete_strategy(self, *, tenant_id: str, strategy_id: str) -> dict:
353
+ return self._request(
354
+ "DELETE", f"/strategies/{strategy_id}",
355
+ params={"tenant_id": tenant_id},
356
+ )
357
+
358
+ def create_strategy(self, *, body: dict) -> dict:
359
+ return self._request("POST", "/strategies", json=body)
360
+
361
+ # HIP-4 outcome markets
362
+ def list_outcomes(
363
+ self,
364
+ *,
365
+ limit: int = 50,
366
+ offset: int = 0,
367
+ include_settled: bool = False,
368
+ network: str | None = None,
369
+ ) -> dict:
370
+ """GET /outcomes — browse live HIP-4 outcome markets.
371
+
372
+ ``network='testnet'`` browses the testnet catalog (served from an
373
+ isolated cache); omit it (or 'mainnet') for production markets.
374
+ """
375
+ params: dict[str, Any] = {
376
+ "limit": limit,
377
+ "offset": offset,
378
+ "include_settled": str(include_settled).lower(),
379
+ }
380
+ if network is not None:
381
+ params["network"] = network
382
+ return self._request("GET", "/outcomes", params=params)
383
+
384
+ # Direct orders (the iOS manual-trade surface)
385
+ def place_order(
386
+ self,
387
+ *,
388
+ tenant_id: str,
389
+ coin: str,
390
+ side: str,
391
+ size: float | str,
392
+ size_mode: str = "usd",
393
+ order_type: str = "market",
394
+ price: float | str | None = None,
395
+ is_spot: bool = False,
396
+ reduce_only: bool = False,
397
+ leverage: int | None = None,
398
+ slippage: float | None = None,
399
+ network: str | None = None,
400
+ ) -> dict:
401
+ """POST /trading/orders — place a direct market/limit order.
402
+
403
+ This is the EXACT endpoint the iOS app uses for manual trades, including
404
+ HIP-4 outcome books (pass an outcome ``#<encoding>`` coin). Unlike
405
+ ``buy``/``sell`` (which create one-shot strategies via /strategies), this
406
+ exercises the manual order path end-to-end, so it's the faithful harness
407
+ for validating manual outcome trading.
408
+ """
409
+ body: dict[str, Any] = {
410
+ "coin": coin,
411
+ "side": side,
412
+ "size": str(size),
413
+ "size_mode": size_mode,
414
+ "order_type": order_type,
415
+ "is_spot": is_spot,
416
+ "reduce_only": reduce_only,
417
+ }
418
+ if price is not None:
419
+ body["price"] = str(price)
420
+ if leverage is not None:
421
+ body["leverage"] = leverage
422
+ if slippage is not None:
423
+ body["slippage"] = slippage
424
+ if network is not None:
425
+ body["network"] = network
426
+ return self._request(
427
+ "POST", "/trading/orders",
428
+ params={"tenant_id": tenant_id},
429
+ json=body,
430
+ )
431
+
432
+ # Positions
433
+ def close_position(
434
+ self,
435
+ *,
436
+ tenant_id: str,
437
+ symbol: str,
438
+ percentage: float = 100.0,
439
+ trading_pair: str | None = None,
440
+ exchange: str = "hyperliquid_perpetual",
441
+ mode: str | None = None,
442
+ ) -> dict:
443
+ """POST /positions/{SYMBOL}/close. Server resolves side + enforces
444
+ reduce_only based on the current position state.
445
+
446
+ ``mode="paper"`` short-circuits to the isolated paper executor and
447
+ will refuse to touch real positions even if the symbol exists in
448
+ real state. See engine/api/routes/positions.py:_paper_close_position.
449
+ """
450
+ body: dict = {"percentage": percentage}
451
+ if trading_pair:
452
+ body["trading_pair"] = trading_pair
453
+ params: dict[str, Any] = {"tenant_id": tenant_id, "exchange": exchange}
454
+ if mode:
455
+ params["mode"] = mode
456
+ return self._request(
457
+ "POST", f"/positions/{symbol.upper()}/close",
458
+ params=params,
459
+ json=body,
460
+ )
461
+
462
+
463
+ @contextmanager
464
+ def client_from_credentials(creds: config.Credentials):
465
+ """Convenience: ``with client_from_credentials(creds) as c: ...``."""
466
+ c = Client(endpoint=creds.endpoint, token=creds.token)
467
+ try:
468
+ yield c
469
+ finally:
470
+ c.close()
talis/cli.py ADDED
@@ -0,0 +1,70 @@
1
+ """
2
+ Talis CLI entry point.
3
+
4
+ `talis` resolves to ``main()`` via the project.scripts entry in pyproject.toml.
5
+
6
+ Command registration here is explicit: each top-level command function is
7
+ imported from its module and decorated with ``app.command(...)``. We avoid
8
+ reaching into ``Typer.registered_commands`` because that's a private surface
9
+ and would break on a Typer upgrade.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import typer
15
+
16
+ from . import __version__, output
17
+ from .commands import auth, trade
18
+ from .commands.auth import sessions_app
19
+ from .commands.outcome import outcome_app
20
+ from .commands.portfolio import portfolio_app
21
+ from .commands.snapshot import snapshot_app
22
+ from .commands.strategies import strategies_app
23
+ from .commands.test_tenant import test_tenant_app
24
+ from .commands.wait import wait_app
25
+
26
+ app = typer.Typer(
27
+ name="talis",
28
+ help="Command-line client for the Talis trading platform.",
29
+ no_args_is_help=True,
30
+ add_completion=False,
31
+ )
32
+
33
+ # --- top-level commands --------------------------------------------------- #
34
+ # Each function is defined as a plain function in its module (no module-level
35
+ # Typer decoration), then registered here. Keeps the call graph easy to follow
36
+ # from cli.py and avoids the "where is this command actually registered?"
37
+ # riddle that ad-hoc sub-typer mounting creates.
38
+ app.command("login")(auth.login)
39
+ app.command("approve")(auth.approve)
40
+ app.command("logout")(auth.logout)
41
+ app.command("whoami")(auth.whoami)
42
+ app.command("buy")(trade.buy)
43
+ app.command("sell")(trade.sell)
44
+ app.command("close")(trade.close)
45
+
46
+ # --- subgroups ------------------------------------------------------------ #
47
+ app.add_typer(sessions_app, name="sessions")
48
+ app.add_typer(strategies_app, name="strategies")
49
+ app.add_typer(portfolio_app, name="portfolio")
50
+ app.add_typer(wait_app, name="wait")
51
+ app.add_typer(snapshot_app, name="snapshot")
52
+ app.add_typer(test_tenant_app, name="test-tenant")
53
+ app.add_typer(outcome_app, name="outcome")
54
+
55
+
56
+ @app.command("version")
57
+ def version(json_: bool = typer.Option(False, "--json", help="JSON output.")) -> None:
58
+ """Print the CLI version."""
59
+ if output.should_render_json(json_):
60
+ output.emit_json({"version": __version__})
61
+ else:
62
+ typer.echo(__version__)
63
+
64
+
65
+ def main() -> None:
66
+ app()
67
+
68
+
69
+ if __name__ == "__main__": # pragma: no cover
70
+ main()
@@ -0,0 +1 @@
1
+ """Talis CLI subcommands."""
@@ -0,0 +1,152 @@
1
+ """Shared helpers used by every command module.
2
+
3
+ Kept in a leading-underscore module so it doesn't show up in the public typer
4
+ subcommand tree. Importers should pull directly: ``from . import _shared``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import json as _json
11
+ import os
12
+ from datetime import datetime, timezone
13
+
14
+ import typer
15
+
16
+ from .. import config, output
17
+
18
+
19
+ def require_creds() -> config.Credentials:
20
+ """Return the active credentials, or exit 1 if not signed in.
21
+
22
+ Precedence:
23
+ 1. ``JARVIS_TOKEN`` environment variable — for non-interactive runs
24
+ (CI, integration tests, Claude Code with a freshly minted admin
25
+ token). Validated locally for expiry; the JWT decode is unsigned,
26
+ which is safe — the server validates on receipt.
27
+ 2. The on-disk credentials file written by ``talis login``.
28
+
29
+ The env-var path produces an ephemeral ``Credentials`` (never saved).
30
+ A real ``talis login`` is still the right path for a long-running
31
+ workstation; this is for headless agents.
32
+ """
33
+ env_token = (os.environ.get("JARVIS_TOKEN") or "").strip()
34
+ if env_token:
35
+ creds = creds_from_jwt(env_token, source="JARVIS_TOKEN env var")
36
+ return creds
37
+
38
+ creds = config.load()
39
+ if not creds:
40
+ output.error("not signed in. Run `talis login`.")
41
+ raise typer.Exit(code=1)
42
+ return creds
43
+
44
+
45
+ def jti_from_jwt(token: str) -> str:
46
+ """Decode the jti claim from a JWT payload WITHOUT verifying the signature.
47
+
48
+ Safe: this only inspects a token the CLI itself holds, to extract a claim
49
+ we'll send back to the server for revocation. The server validates the
50
+ signature on receipt — a forged/corrupt JWT cannot revoke anyone else's
51
+ session even if its claims look plausible. Returns "" if the token doesn't
52
+ decode or the jti claim is absent.
53
+ """
54
+ try:
55
+ payload_b64 = token.split(".", 2)[1]
56
+ padding = "=" * (-len(payload_b64) % 4)
57
+ payload = _json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
58
+ jti = payload.get("jti", "")
59
+ return jti if isinstance(jti, str) else ""
60
+ except (ValueError, TypeError, IndexError):
61
+ return ""
62
+
63
+
64
+ def decode_jwt_unverified(token: str) -> dict:
65
+ """Decode JWT payload claims WITHOUT signature verification.
66
+
67
+ The result is used to populate a local ``Credentials`` from a token
68
+ obtained out-of-band (env var, ``talis login --token``). The server
69
+ validates the signature on every request — a forged token never
70
+ survives there. Locally we just need to read the claims to set
71
+ sensible expiry / scope / tenant on the credentials struct.
72
+
73
+ Raises ``ValueError`` on a malformed token. Callers should treat
74
+ that as "do not save this credential".
75
+ """
76
+ parts = token.split(".")
77
+ if len(parts) != 3:
78
+ raise ValueError("not a JWT (expected three dot-separated segments)")
79
+ payload_b64 = parts[1]
80
+ padding = "=" * (-len(payload_b64) % 4)
81
+ try:
82
+ return _json.loads(base64.urlsafe_b64decode(payload_b64 + padding))
83
+ except (ValueError, TypeError) as exc:
84
+ raise ValueError(f"could not decode JWT payload: {exc}") from exc
85
+
86
+
87
+ def creds_from_jwt(
88
+ token: str,
89
+ *,
90
+ endpoint: str | None = None,
91
+ client_name: str = "Talis CLI (token)",
92
+ source: str = "token",
93
+ ) -> config.Credentials:
94
+ """Build a ``Credentials`` from a raw JWT string.
95
+
96
+ Used by both ``talis login --token <jwt>`` (saved) and the
97
+ ``JARVIS_TOKEN`` env-var path (ephemeral). Raises ``typer.Exit(1)``
98
+ on malformed or already-expired tokens — silently accepting an
99
+ expired token would let the very next request fail with a confusing
100
+ 401.
101
+ """
102
+ try:
103
+ claims = decode_jwt_unverified(token)
104
+ except ValueError as exc:
105
+ output.error(f"invalid {source}: {exc}")
106
+ raise typer.Exit(code=1)
107
+
108
+ tenant_id = claims.get("sub") or claims.get("tenant_id")
109
+ if not isinstance(tenant_id, str) or not tenant_id:
110
+ output.error(f"invalid {source}: missing `sub` / `tenant_id` claim")
111
+ raise typer.Exit(code=1)
112
+
113
+ exp = claims.get("exp")
114
+ if isinstance(exp, (int, float)):
115
+ if exp <= datetime.now(timezone.utc).timestamp():
116
+ output.error(
117
+ f"{source} is already expired. Mint a new one and try again."
118
+ )
119
+ raise typer.Exit(code=1)
120
+ expires_at = datetime.fromtimestamp(int(exp), tz=timezone.utc).strftime(
121
+ "%Y-%m-%dT%H:%M:%SZ"
122
+ )
123
+ else:
124
+ # No exp claim is unusual for our tokens but not strictly invalid
125
+ # — let the server enforce. Empty string disables the local check
126
+ # in config._is_expired.
127
+ expires_at = ""
128
+
129
+ jti = claims.get("jti", "")
130
+ if not isinstance(jti, str):
131
+ jti = ""
132
+ scope = claims.get("scope", "trade")
133
+ if not isinstance(scope, str):
134
+ scope = "trade"
135
+ wallet_address = claims.get("wallet_address")
136
+ if not isinstance(wallet_address, str):
137
+ wallet_address = None
138
+ wallet_id = claims.get("wallet_id")
139
+ if not isinstance(wallet_id, str):
140
+ wallet_id = None
141
+
142
+ return config.Credentials(
143
+ endpoint=(endpoint or config.endpoint_from_env()),
144
+ tenant_id=tenant_id,
145
+ token=token,
146
+ expires_at=expires_at,
147
+ scope=scope,
148
+ jti_prefix=jti[:8],
149
+ wallet_address=wallet_address,
150
+ wallet_id=wallet_id,
151
+ client_name=client_name,
152
+ )