elan-cli 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.
elan_cli/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """The developer-facing command line.
2
+
3
+ THE ONLY BRANDED PACKAGE. Everything under `agentkit_*` is deliberately
4
+ brand-free so a derived project renames nothing; this one is the product, so it
5
+ is where the name lives.
6
+ """
elan_cli/adk.py ADDED
@@ -0,0 +1,197 @@
1
+ """`elan adk` — ADK's own tools, pointed at this repo.
2
+
3
+ WHY A WRAPPER AND NOT JUST `adk web`. ADK's loader looks in
4
+ `<agents_dir>/<name>/agent.py` for a module-level `app` or `root_agent`. A
5
+ target here exports `MANIFEST` and `build(ctx)` and neither of those names,
6
+ because the platform supplies the other half: `build_app` is what turns a
7
+ `build()` into an ADK `App`, and in a pod the engine does it. So `adk web`
8
+ against a tenant repo finds nothing and says so as "no agents", which reads as
9
+ the repo being empty.
10
+
11
+ THE SHIMS ARE GENERATED, NOT SCAFFOLDED INTO THE REPO. The obvious fix is to
12
+ append eight lines to every `agent.py`, and it works — but those lines run AT
13
+ IMPORT, and `elan validate` and the build's manifest export both import target
14
+ modules to read their MANIFEST. That makes every deploy build a whole App to
15
+ answer a question about a dataclass, and the day a `build()` needs something
16
+ only a laptop has, the BUILD breaks at manifest export naming nothing useful.
17
+ Generated modules keep the target files exporting exactly what the contract
18
+ says.
19
+
20
+ WHAT YOU GET IS THE REAL STACK, which is the point. The shim calls the same
21
+ `build_plugins` a pod does, so `adk web` runs the agent behind the autonomy
22
+ gate, the typing plugin and the observability plugin. The ledger is absent
23
+ because `LEDGER_SERVICE_URL` is unset, and it says so rather than pretending:
24
+ "running unbilled (local dev only)".
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import shutil
31
+ import subprocess
32
+ from pathlib import Path
33
+
34
+ from elan_cli.manifests import ManifestError, interpreter
35
+ from elan_cli.manifests import read as read_manifests
36
+ from elan_cli.project import Project, ProjectError, find
37
+
38
+ #: Under a dot directory so it never looks like part of the repo, and never
39
+ #: collides with `agents/` — which ADK would otherwise ALSO scan, finding the
40
+ #: real modules, failing to load them, and reporting both.
41
+ SHIM_ROOT = Path(".elan") / "adk"
42
+
43
+ #: Commands that take a directory of agents as their last positional.
44
+ DIRECTORY_COMMANDS = {"web", "api_server"}
45
+
46
+ #: Commands that take ONE agent, named by slug.
47
+ TARGET_COMMANDS = {"run"}
48
+
49
+ SHIM = '''\
50
+ """GENERATED by `elan adk` for {slug}. Rewritten on every run; do not edit.
51
+
52
+ The whole file exists to give ADK's loader the name it looks for. Everything
53
+ it does is what the runtime does at pod start, so what you drive here is the
54
+ agent as deployed rather than a stripped-down copy of it.
55
+ """
56
+
57
+ from pathlib import Path
58
+
59
+ from agentkit_runtime.builder import build_app
60
+ from agentkit_runtime.config import Settings
61
+ from agentkit_runtime.plugins import build_plugins
62
+ from agentkit_runtime.registry import resolve
63
+
64
+ # PASSED, NOT INHERITED. `Settings` would otherwise read TARGETS_ROOT from the
65
+ # environment, and `adk` is started from wherever the developer happens to be.
66
+ _settings = Settings(targets_root=Path(r"{root}"))
67
+ _target = resolve({slug!r}, _settings)
68
+ _plugins, _ = build_plugins(_target, _settings,
69
+ _target.version_stamp(_settings.build_sha))
70
+
71
+ # `app`, WHICH ADK CHECKS FOR BEFORE `root_agent`. The App carries the plugin
72
+ # stack; a bare root_agent would run the agent with none of it.
73
+ app = build_app(_target, _settings, plugins=_plugins)
74
+ '''
75
+
76
+ #: A directory that ignores itself, so this works in a repo whose .gitignore
77
+ #: predates `elan init` — which is every repo scaffolded before this existed.
78
+ SELF_IGNORE = "*\n"
79
+
80
+
81
+ def module_name(slug: str) -> str:
82
+ """The directory ADK will import this target as.
83
+
84
+ SLUGS CONTAIN HYPHENS AND MODULE NAMES CANNOT. ADK imports the DIRECTORY
85
+ NAME — `importlib.import_module(f"{name}.agent")` — so a shim directory
86
+ called `fish-facts` is a SyntaxError-shaped import failure rather than an
87
+ agent. Same substitution `elan new` already makes for the module it
88
+ scaffolds, so the two agree.
89
+ """
90
+ return slug.replace("-", "_")
91
+
92
+
93
+ def generate(project: Project, slugs: list[str]) -> Path:
94
+ """Write one shim per target. Returns the directory to point ADK at."""
95
+ root = project.root / SHIM_ROOT
96
+ # REMOVED FIRST, so a target that was deleted or renamed does not linger
97
+ # and get served alongside the ones that still exist.
98
+ if root.exists():
99
+ shutil.rmtree(root)
100
+ root.mkdir(parents=True)
101
+ (project.root / ".elan" / ".gitignore").write_text(SELF_IGNORE)
102
+
103
+ for slug in slugs:
104
+ package = root / module_name(slug)
105
+ package.mkdir()
106
+ # A REGULAR PACKAGE, not a namespace one. Without this the directory
107
+ # is still importable, and also shadowable by anything else on the
108
+ # path with the same name.
109
+ (package / "__init__.py").write_text("")
110
+ (package / "agent.py").write_text(
111
+ SHIM.format(slug=slug, root=project.targets_root))
112
+ return root
113
+
114
+
115
+ def argv_for(args: list[str], root: Path, slugs: list[str]) -> list[str]:
116
+ """What to pass ADK, given what the developer typed.
117
+
118
+ TWO RULES AND A PASSTHROUGH, deliberately not a general rewriter. Anything
119
+ cleverer — substituting every argument that happens to match a slug, say —
120
+ would eventually rewrite a filename or an option value, and a wrapper that
121
+ edits arguments unpredictably is worse than one that does nothing.
122
+ """
123
+ if not args:
124
+ return []
125
+ command = args[0]
126
+ if command in DIRECTORY_COMMANDS:
127
+ # APPENDED, so any option the developer passed still applies.
128
+ return [*args, str(root)]
129
+ if command in TARGET_COMMANDS and len(args) > 1:
130
+ # ONLY AN EXACT SLUG IS REPLACED. The slug they typed is the one the
131
+ # platform uses everywhere else; the directory is its module-safe
132
+ # spelling, which is an implementation detail they should not have to
133
+ # know.
134
+ return [command, *(str(root / module_name(a)) if a in slugs else a
135
+ for a in args[1:])]
136
+ return list(args)
137
+
138
+
139
+ def run(args: list[str], start: Path | None = None,
140
+ python: str | None = None) -> int:
141
+ if not args:
142
+ print("error: `elan adk` passes its arguments to ADK's own CLI.\n\n"
143
+ " elan adk web the dev UI, every target in this repo\n"
144
+ " elan adk run <slug> one target, in the terminal\n"
145
+ " elan adk --help everything else ADK offers")
146
+ return 2
147
+
148
+ try:
149
+ project = find(start)
150
+ except ProjectError as exc:
151
+ print(f"error: {exc}")
152
+ return 2
153
+
154
+ chosen, why = interpreter(project.targets_root, python)
155
+ # THE SAME INTERPRETER, so `adk` sees the repo's dependencies. A globally
156
+ # installed `adk` would import the tenant's modules with whatever happened
157
+ # to be on PATH, and fail on their first third-party import.
158
+ adk = Path(chosen).parent / "adk"
159
+ if not adk.exists():
160
+ print(f"error: no `adk` beside {chosen}\n({why})\n\n"
161
+ "ADK comes with the platform runtime, so this usually means the\n"
162
+ "environment was never created:\n\n"
163
+ " elan env")
164
+ return 2
165
+
166
+ try:
167
+ manifests = read_manifests(project.targets_root, python=python)
168
+ except ManifestError as exc:
169
+ print(f"error: {exc}")
170
+ return 2
171
+
172
+ slugs = sorted(m["slug"] for m in manifests)
173
+ if not slugs:
174
+ print("error: this repo declares no targets, so there is nothing to run.")
175
+ return 2
176
+
177
+ root = generate(project, slugs)
178
+ print(f"targets {', '.join(slugs)}")
179
+ print(f"shims {root}")
180
+
181
+ argv = [str(adk), *argv_for(args, root, slugs)]
182
+ print(f"running {' '.join(argv[1:])}")
183
+ print(flush=True)
184
+ return subprocess.run(argv, cwd=project.root, env=_environment()).returncode
185
+
186
+
187
+ def _environment() -> dict[str, str]:
188
+ """ADK's own environment, plus the one thing it must not inherit.
189
+
190
+ `TARGET_SLUG` NAMES WHICH AGENT A POD RUNS, and a developer who has it set
191
+ — from a `.env`, or from having run the runtime by hand — would get that
192
+ one target for every shim, because `Settings` reads it. The shim passes
193
+ its slug explicitly; removing this makes sure nothing overrides it.
194
+ """
195
+ env = dict(os.environ)
196
+ env.pop("TARGET_SLUG", None)
197
+ return env
elan_cli/auth.py ADDED
@@ -0,0 +1,391 @@
1
+ """`elan login` — the device flow, and where the refresh token lives.
2
+
3
+ A DEVICE FLOW BECAUSE A TERMINAL HAS NO REDIRECT URI. The CLI asks the issuer
4
+ for a code, prints a URL and a short user code, and polls until a human has
5
+ approved it in whatever browser they already have — possibly on another machine
6
+ entirely, which is the case that makes this better than a loopback redirect.
7
+
8
+ NOT A PERSONAL ACCESS TOKEN, which is what most tools reach for. A PAT is a
9
+ long-lived bearer secret that has to be pasted, ends up in a shell history and
10
+ a dotfile, and carries its owner's whole authority until somebody remembers to
11
+ revoke it. This yields a short-lived access token and a refresh token.
12
+
13
+ THE REFRESH TOKEN GOES TO THE OS KEYRING AND NEVER TO A FILE. That is the one
14
+ non-negotiable part: a token under `~/.config` is readable by every process
15
+ that runs as the developer, backed up to wherever their home directory is
16
+ backed up, and synced to whatever syncs it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import time
24
+ from dataclasses import dataclass
25
+
26
+ import httpx
27
+ from elan_agentkit_contracts.oauth import token_scope
28
+
29
+ from elan_cli.config import Config
30
+
31
+ #: What the keyring entry is called. Stable, because changing it orphans every
32
+ #: credential already stored rather than migrating it.
33
+ logger = logging.getLogger(__name__)
34
+
35
+ KEYRING_SERVICE = "elan-cli"
36
+
37
+ def scopes(config: Config) -> str:
38
+ """What to ask the issuer for, MOSTLY ANSWERED BY THE PLATFORM.
39
+
40
+ `offline_access` IS THE CLI'S OWN and is not in the served scope: it is what
41
+ makes the issuer return a refresh token at all, and without it every command
42
+ an hour later needs a browser again. A `client_credentials` caller must not
43
+ ask for it, so it is added here rather than published to everybody.
44
+
45
+ THE REST IS `token_scope` FROM `GET /v1/auth`, including the audience term
46
+ that says who the token is FOR — without which it is addressed to nobody in
47
+ particular and the service's audience check can never pass. Composed locally
48
+ only against a platform that predates the field.
49
+ """
50
+ base = config.token_scope or token_scope(config.audience)
51
+ return f"{base} offline_access"
52
+
53
+
54
+ class AuthError(Exception):
55
+ """Something about signing in, phrased for the person at the terminal."""
56
+
57
+
58
+ @dataclass(frozen=True, slots=True)
59
+ class Tokens:
60
+ access_token: str
61
+ refresh_token: str
62
+ #: Unix time. Compared with a margin rather than exactly — see `valid`.
63
+ expires_at: float
64
+
65
+ @property
66
+ def valid(self) -> bool:
67
+ """A MARGIN, NOT AN EXACT COMPARISON. A token that expires during the
68
+ request it was attached to fails as a 401 the CLI cannot distinguish
69
+ from a revoked session, so it is refreshed while it still works."""
70
+ return time.time() < self.expires_at - 60
71
+
72
+
73
+ def _keyring():
74
+ try:
75
+ import keyring
76
+ except ImportError as exc: # pragma: no cover - depends on the install
77
+ raise AuthError(
78
+ "the `keyring` package is not installed, and the CLI will not "
79
+ "write a credential to a file instead. Install it with "
80
+ "`pip install keyring`."
81
+ ) from exc
82
+ return keyring
83
+
84
+
85
+ def store(issuer: str, refresh_token: str) -> None:
86
+ """Keep the refresh token, and DROP ANY CACHED ACCESS TOKEN WITH IT.
87
+
88
+ A rotated refresh token means a new session; an access token minted under
89
+ the previous one is not obviously invalid and would be used until it
90
+ expired. Writing one without clearing the other is how a cache outlives
91
+ the thing it was derived from.
92
+ """
93
+ _keyring().set_password(KEYRING_SERVICE, issuer, refresh_token)
94
+ _forget_access(issuer)
95
+
96
+
97
+ def stored(issuer: str) -> str | None:
98
+ return _keyring().get_password(KEYRING_SERVICE, issuer)
99
+
100
+
101
+ # ── THE ACCESS TOKEN IS CACHED TOO, AND THAT IS A LATENCY FIX ──────────────
102
+ #
103
+ # `Api.token()` holds its tokens on the INSTANCE, so a fresh process always
104
+ # starts with none — and every `elan` command therefore paid a full OAuth
105
+ # refresh before doing any work: a keyring read, a TLS handshake to the
106
+ # issuer and a round trip, on `invoke`, on `agents`, on `status`. Measured at
107
+ # roughly a second, every time, against a command whose own work is one API
108
+ # call.
109
+ #
110
+ # `Tokens.valid` already existed and had nothing persisted to check against.
111
+ #
112
+ # SAME KEYRING, AND NO WEAKER FOR IT. The refresh token is already there and
113
+ # is the stronger credential — it MINTS access tokens, for as long as the
114
+ # session lives, while an access token expires on its own. Anything that can
115
+ # read one can read the other.
116
+ _ACCESS_SUFFIX = "#access"
117
+
118
+
119
+ def _access_key(issuer: str) -> str:
120
+ return f"{issuer}{_ACCESS_SUFFIX}"
121
+
122
+
123
+ def cache_access(issuer: str, tokens: Tokens) -> None:
124
+ """Remember an access token until it expires."""
125
+ try:
126
+ _keyring().set_password(
127
+ KEYRING_SERVICE, _access_key(issuer),
128
+ json.dumps({"access_token": tokens.access_token,
129
+ "expires_at": tokens.expires_at}))
130
+ except Exception: # noqa: BLE001
131
+ # A CACHE THAT CANNOT BE WRITTEN IS NOT AN ERROR, it is a slow
132
+ # command. Some keyring backends are read-only or locked, and
133
+ # refusing to run would trade a second of latency for an outage.
134
+ logger.debug("could not cache the access token", exc_info=True)
135
+
136
+
137
+ def cached_access(issuer: str) -> Tokens | None:
138
+ """The cached access token, if there is one and it is still good."""
139
+ try:
140
+ raw = _keyring().get_password(KEYRING_SERVICE, _access_key(issuer))
141
+ except Exception: # noqa: BLE001
142
+ return None
143
+ if not raw:
144
+ return None
145
+ try:
146
+ held = json.loads(raw)
147
+ found = Tokens(access_token=held["access_token"], refresh_token="",
148
+ expires_at=float(held["expires_at"]))
149
+ except (ValueError, KeyError, TypeError):
150
+ # A CACHE THAT CANNOT BE READ IS A CACHE MISS. The format may have
151
+ # changed under an older CLI; refreshing is always correct.
152
+ return None
153
+ return found if found.valid else None
154
+
155
+
156
+ def _forget_access(issuer: str) -> None:
157
+ try:
158
+ keyring = _keyring()
159
+ if keyring.get_password(KEYRING_SERVICE, _access_key(issuer)):
160
+ keyring.delete_password(KEYRING_SERVICE, _access_key(issuer))
161
+ except Exception: # noqa: BLE001
162
+ logger.debug("could not clear the cached access token", exc_info=True)
163
+
164
+
165
+ def forget(issuer: str) -> bool:
166
+ """`elan logout`. BOTH CREDENTIALS, or the session outlives the logout:
167
+ a cached access token keeps working until it expires, which is the one
168
+ thing somebody signing out is asking not to happen."""
169
+ keyring = _keyring()
170
+ _forget_access(issuer)
171
+ if keyring.get_password(KEYRING_SERVICE, issuer) is None:
172
+ return False
173
+ keyring.delete_password(KEYRING_SERVICE, issuer)
174
+ return True
175
+
176
+
177
+ def platform_auth(api: str, *, client: httpx.Client | None = None) -> dict:
178
+ """Ask the PLATFORM where to sign in.
179
+
180
+ ONE URL INSTEAD OF FOUR FLAGS. A developer cannot be expected to know a
181
+ generated OIDC project id, and every value this returns is public anyway:
182
+ the issuer and audience appear in every token minted, and a public client's
183
+ id is public by construction — a secret shipped in every copy of a CLI is
184
+ not a secret.
185
+ """
186
+ owned = client is None
187
+ client = client or httpx.Client(timeout=30.0)
188
+ try:
189
+ response = client.get(f"{api}/v1/auth")
190
+ except httpx.HTTPError as exc:
191
+ raise AuthError(f"cannot reach {api}: {exc}") from exc
192
+ finally:
193
+ if owned:
194
+ client.close()
195
+
196
+ if response.status_code == 503:
197
+ raise AuthError(
198
+ f"{api} is not finished being set up — it has no sign-in "
199
+ "configuration yet. That is a platform-side problem, not "
200
+ "something this CLI can work around."
201
+ )
202
+ if response.status_code == 404:
203
+ raise AuthError(
204
+ f"{api} does not look like an Elan platform: it has no /v1/auth. "
205
+ "Check the URL."
206
+ )
207
+ if response.status_code != 200:
208
+ raise AuthError(
209
+ f"{api} answered HTTP {response.status_code} when asked where to "
210
+ f"sign in: {response.text[:200]}")
211
+ payload = response.json()
212
+ missing = [f for f in ("issuer", "client_id") if not payload.get(f)]
213
+ if missing:
214
+ raise AuthError(
215
+ f"{api} answered without {', '.join(missing)}, so there is "
216
+ "nothing to sign in against.")
217
+ return payload
218
+
219
+
220
+ def discover(config: Config, *, client: httpx.Client | None = None) -> dict:
221
+ """The issuer's own endpoints, asked for rather than assumed.
222
+
223
+ Hardcoding `/oauth/v2/device_authorization` would work against this issuer
224
+ and silently fail against any other — and the discovery document exists so
225
+ a client never has to guess.
226
+ """
227
+ owned = client is None
228
+ client = client or httpx.Client(timeout=30.0)
229
+ try:
230
+ response = client.get(f"{config.issuer}/.well-known/openid-configuration")
231
+ if response.status_code != 200:
232
+ raise AuthError(
233
+ f"{config.issuer} did not serve a discovery document "
234
+ f"(HTTP {response.status_code}). Is the issuer right?"
235
+ )
236
+ return response.json()
237
+ except httpx.HTTPError as exc:
238
+ raise AuthError(f"cannot reach {config.issuer}: {exc}") from exc
239
+ finally:
240
+ if owned:
241
+ client.close()
242
+
243
+
244
+ def begin(config: Config, *, client: httpx.Client | None = None) -> dict:
245
+ """Ask for a device code. Returns the issuer's response verbatim."""
246
+ document = discover(config, client=client)
247
+ endpoint = document.get("device_authorization_endpoint")
248
+ if not endpoint:
249
+ raise AuthError(
250
+ f"{config.issuer} does not advertise a device authorization "
251
+ "endpoint, so `elan login` cannot work against it. The CLI needs "
252
+ "one because a terminal has no redirect URI."
253
+ )
254
+ owned = client is None
255
+ client = client or httpx.Client(timeout=30.0)
256
+ try:
257
+ response = client.post(endpoint, data={
258
+ "client_id": config.client_id,
259
+ "scope": scopes(config),
260
+ })
261
+ if response.status_code >= 400:
262
+ raise AuthError(
263
+ f"the issuer refused the device request "
264
+ f"(HTTP {response.status_code}): {response.text[:300]}\n"
265
+ " A 400 here usually means the CLI's client id is not "
266
+ "configured for the device_code grant."
267
+ )
268
+ payload = response.json()
269
+ payload["_token_endpoint"] = document["token_endpoint"]
270
+ return payload
271
+ except httpx.HTTPError as exc:
272
+ raise AuthError(f"cannot reach {endpoint}: {exc}") from exc
273
+ finally:
274
+ if owned:
275
+ client.close()
276
+
277
+
278
+ def poll(config: Config, device: dict, *, client: httpx.Client | None = None,
279
+ sleep=time.sleep, now=time.monotonic) -> Tokens:
280
+ """Wait for the human, honouring the issuer's OWN pacing.
281
+
282
+ `interval` AND `slow_down` ARE NOT OPTIONAL. Polling faster than the issuer
283
+ asked earns a `slow_down`, and ignoring that earns a refusal — so a login
284
+ that polled on a fixed timer would work until the issuer was busy and then
285
+ fail in a way nobody could reproduce.
286
+ """
287
+ owned = client is None
288
+ client = client or httpx.Client(timeout=30.0)
289
+ interval = float(device.get("interval", 5))
290
+ deadline = now() + float(device.get("expires_in", 300))
291
+ try:
292
+ while now() < deadline:
293
+ sleep(interval)
294
+ response = client.post(device["_token_endpoint"], data={
295
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
296
+ "device_code": device["device_code"],
297
+ "client_id": config.client_id,
298
+ })
299
+ payload = response.json() if response.content else {}
300
+ if response.status_code == 200:
301
+ return _tokens(payload)
302
+
303
+ error = payload.get("error", "")
304
+ if error == "authorization_pending":
305
+ continue
306
+ if error == "slow_down":
307
+ # The issuer's instruction, followed rather than noted.
308
+ interval += 5
309
+ continue
310
+ if error == "access_denied":
311
+ raise AuthError("the request was denied in the browser.")
312
+ if error == "expired_token":
313
+ break
314
+ raise AuthError(
315
+ f"the issuer refused the token request: "
316
+ f"{error or response.status_code} "
317
+ f"{payload.get('error_description', '')}".strip()
318
+ )
319
+ raise AuthError(
320
+ "the login expired before it was approved. Run `elan login` again."
321
+ )
322
+ finally:
323
+ if owned:
324
+ client.close()
325
+
326
+
327
+ def refresh(config: Config, refresh_token: str, *,
328
+ client: httpx.Client | None = None) -> Tokens:
329
+ """Trade a refresh token for a fresh access token.
330
+
331
+ A FAILURE HERE IS NOT AN ERROR TO RETRY. A refresh token is rejected
332
+ because the session was revoked, the token was rotated, or it expired — all
333
+ of which mean the same thing to the developer, which is that they have to
334
+ sign in again. So the message says that rather than reporting an OAuth
335
+ error code.
336
+ """
337
+ document = discover(config, client=client)
338
+ owned = client is None
339
+ client = client or httpx.Client(timeout=30.0)
340
+ try:
341
+ response = client.post(document["token_endpoint"], data={
342
+ "grant_type": "refresh_token",
343
+ "refresh_token": refresh_token,
344
+ "client_id": config.client_id,
345
+ "scope": scopes(config),
346
+ })
347
+ if response.status_code != 200:
348
+ raise AuthError("your session is no longer valid. Run `elan login`.")
349
+ return _tokens(response.json())
350
+ except httpx.HTTPError as exc:
351
+ raise AuthError(f"cannot reach the issuer: {exc}") from exc
352
+ finally:
353
+ if owned:
354
+ client.close()
355
+
356
+
357
+ def _tokens(payload: dict) -> Tokens:
358
+ access = payload.get("access_token", "")
359
+ if access.count(".") != 2:
360
+ # THE TRAP ADR-0049 RECORDED, caught on the client side too. Zitadel
361
+ # issues OPAQUE tokens unless the application asks for JWTs, and an
362
+ # opaque token fails at the verifier as MALFORMED — which reads like a
363
+ # CLI bug rather than an application setting. Saying it here names the
364
+ # actual cause.
365
+ raise AuthError(
366
+ "the issuer returned an OPAQUE access token, not a JWT. The "
367
+ "services verify locally against the published keys and cannot "
368
+ "verify an opaque string. The CLI's application needs "
369
+ "`accessTokenType: JWT`."
370
+ )
371
+ return Tokens(
372
+ access_token=access,
373
+ refresh_token=payload.get("refresh_token", ""),
374
+ expires_at=time.time() + float(payload.get("expires_in", 3600)),
375
+ )
376
+
377
+
378
+ def claims(access_token: str) -> dict:
379
+ """The token's payload, DECODED AND NOT VERIFIED.
380
+
381
+ Used only to show a developer who they are signed in as. The CLI has no
382
+ business verifying a token it did not have to trust — the SERVICE verifies,
383
+ against the issuer's published keys, and that is the check that matters. A
384
+ second verification here would imply this one was load-bearing.
385
+ """
386
+ import base64
387
+ import json
388
+
389
+ payload = access_token.split(".")[1]
390
+ payload += "=" * (-len(payload) % 4)
391
+ return json.loads(base64.urlsafe_b64decode(payload))