coelevate-cli 0.2.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.
coelevate_cli/cli.py ADDED
@@ -0,0 +1,988 @@
1
+ """``coelevate``: a command-line client for the Context Repository public API.
2
+
3
+ Every action calls one of the routes the public API exposes to a customer key. The CLI
4
+ proposes a change to the context's assets (``stage``) and never lands one. A kit is not an
5
+ asset: ``kit`` writes it directly, with no changeset and no review (it needs the
6
+ ``context:kits`` scope), and so does ``gap`` (it needs ``context:gaps``).
7
+
8
+ Exit codes (stable):
9
+
10
+ * ``0`` - success.
11
+ * ``1`` - the request ran and failed: the service or token endpoint refused it, or it
12
+ could not be reached.
13
+ * ``2`` - the command could not run: bad usage, required configuration is missing, or the
14
+ key resolves to a different project than the one pinned.
15
+
16
+ Secrets come from the environment only, never a flag (a ``./.env`` file in the working
17
+ folder counts as the environment, see ``_load_dotenv``). The CLI never prompts.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ from contextlib import ExitStack
28
+ from pathlib import Path
29
+ from typing import Any
30
+ from urllib.parse import urlsplit
31
+
32
+ import httpx
33
+
34
+ from coelevate_cli import __version__
35
+ from coelevate_cli.auth import (
36
+ ASSETS_SCOPE,
37
+ GAPS_SCOPE,
38
+ KITS_SCOPE,
39
+ READ_SCOPE,
40
+ TOKEN_PATH,
41
+ ClientCredentialsTokenSource,
42
+ OAuthTokenError,
43
+ )
44
+
45
+ CLIENT_ID_ENV = "COELEVATE_CLIENT_ID"
46
+ CLIENT_SECRET_ENV = "COELEVATE_CLIENT_SECRET"
47
+ TOKEN_URL_ENV = "COELEVATE_TOKEN_URL"
48
+ API_URL_ENV = "COELEVATE_API_URL"
49
+ HTTP_TIMEOUT_ENV = "COELEVATE_HTTP_TIMEOUT"
50
+ RETRY_BACKOFF_ENV = "COELEVATE_RETRY_BACKOFF"
51
+ PROJECT_ID_ENV = "COELEVATE_PROJECT_ID"
52
+ WORKSPACE_URL_ENV = "COELEVATE_WORKSPACE_URL"
53
+ ENVIRONMENT_ENV = "COELEVATE_ENVIRONMENT"
54
+
55
+ #: The file read from the working folder before the environment is consulted.
56
+ DOTENV_NAME = ".env"
57
+ #: Only variables with this prefix are taken from it: the file may be a shared project
58
+ #: ``.env`` holding unrelated secrets, and the CLI has no business with those.
59
+ DOTENV_PREFIX = "COELEVATE_"
60
+
61
+ #: Seconds each phase of a request may take (connect, read, write) before it times out.
62
+ DEFAULT_HTTP_TIMEOUT = 30.0
63
+ #: Seconds to wait before the one retry of a read.
64
+ DEFAULT_RETRY_BACKOFF = 1.0
65
+
66
+ #: Where the public Context API lives on the API origin.
67
+ CONTEXT_PREFIX = "/context"
68
+
69
+ #: The server's cap on ops per staging request.
70
+ MAX_OPS_PER_REQUEST = 100
71
+
72
+ #: Every route the CLI calls, relative to ``<base url>/context``. The project is not in the
73
+ #: path: the key's token names it. A test holds this set against the published API description.
74
+ ROUTES: dict[str, tuple[str, str]] = {
75
+ "kits": ("GET", "/v1/kits"),
76
+ "read_kit": ("GET", "/v1/kits/{kit_id}"),
77
+ "read_asset": ("GET", "/v1/assets/{asset_id}"),
78
+ "search": ("POST", "/v1/search"),
79
+ "asset_types": ("GET", "/v1/asset-types"),
80
+ "changeset": ("GET", "/v1/changeset"),
81
+ "stage": ("POST", "/v1/changeset/ops"),
82
+ "unstage": ("DELETE", "/v1/changeset/ops/{op_id}"),
83
+ "releases": ("GET", "/v1/releases"),
84
+ "assets": ("GET", "/v1/assets"),
85
+ "kit_create": ("POST", "/v1/kits"),
86
+ "kit_patch": ("PATCH", "/v1/kits/{kit_id}"),
87
+ "kit_add_members": ("POST", "/v1/kits/{kit_id}/members"),
88
+ "kit_remove_member": ("DELETE", "/v1/kits/{kit_id}/members/{asset_id}"),
89
+ "kit_revert": ("POST", "/v1/kits/{kit_id}/revert"),
90
+ "kit_retire": ("DELETE", "/v1/kits/{kit_id}"),
91
+ "kit_journal": ("GET", "/v1/kits/{kit_id}/journal"),
92
+ "gaps": ("GET", "/v1/gaps"),
93
+ "gap_read": ("GET", "/v1/gaps/{gap_id}"),
94
+ "gap_journal": ("GET", "/v1/gaps/{gap_id}/journal"),
95
+ "gap_create": ("POST", "/v1/gaps"),
96
+ "gap_patch": ("PATCH", "/v1/gaps/{gap_id}"),
97
+ "gap_retire": ("POST", "/v1/gaps/{gap_id}/retire"),
98
+ "gap_revert": ("POST", "/v1/gaps/{gap_id}/revert"),
99
+ }
100
+
101
+ #: The routes that only read, so sending one twice changes nothing. ``search`` is a POST
102
+ #: that mutates nothing; ``stage`` and ``unstage`` change the changeset and are not here.
103
+ RETRYABLE_ROUTES = frozenset(
104
+ {
105
+ "kits",
106
+ "read_kit",
107
+ "read_asset",
108
+ "search",
109
+ "asset_types",
110
+ "changeset",
111
+ "releases",
112
+ "assets",
113
+ "kit_journal",
114
+ "gaps",
115
+ "gap_read",
116
+ "gap_journal",
117
+ }
118
+ )
119
+
120
+
121
+ class CliError(RuntimeError):
122
+ """A clean, user-facing failure: printed to stderr, never a traceback. ``code`` is
123
+ the exit code."""
124
+
125
+ def __init__(self, message: str, *, code: int = 1) -> None:
126
+ super().__init__(message)
127
+ self.code = code
128
+
129
+
130
+ class _Context:
131
+ """What a handler needs: an authenticated client and the API origin."""
132
+
133
+ def __init__(
134
+ self,
135
+ http_client: httpx.Client,
136
+ base_url: str,
137
+ retry_backoff: float = DEFAULT_RETRY_BACKOFF,
138
+ expected_project: str | None = None,
139
+ ) -> None:
140
+ self.http = http_client
141
+ self.base_url = base_url
142
+ self.retry_backoff = retry_backoff
143
+ self.expected_project = expected_project
144
+
145
+ @property
146
+ def project_id(self) -> str | None:
147
+ """The project the key resolves to; known once a token has been exchanged."""
148
+ return self.http.auth.project_id
149
+
150
+ def token(self) -> str:
151
+ """The bearer token. The first exchange also enforces the pin: a key that resolves
152
+ to another project than the one expected refuses every action, before any request
153
+ carries the token anywhere."""
154
+ token = self.http.auth.token()
155
+ expected, actual = self.expected_project, self.project_id
156
+ if expected is not None and actual != expected:
157
+ if actual is None:
158
+ raise CliError(
159
+ f"cannot verify the project: {expected!r} is expected but the token response names no project",
160
+ code=2,
161
+ )
162
+ raise CliError(
163
+ f"project mismatch: this key resolves to project {actual!r} but {expected!r} is expected "
164
+ f"(--expect-project or ${PROJECT_ID_ENV}). Nothing was sent.",
165
+ code=2,
166
+ )
167
+ return token
168
+
169
+ def _send(self, method: str, url: str, *, retry: bool, **kwargs: Any) -> httpx.Response:
170
+ """Send once; for a read, send again after a short pause if the first attempt
171
+ timed out or could not connect (a service that scales to zero answers late while
172
+ it starts, and the second attempt reaches it warm). Only that, only once: an
173
+ answer of any status is final, and a write is never sent twice by the client."""
174
+ try:
175
+ return self.http.request(method, url, **kwargs)
176
+ except (httpx.TimeoutException, httpx.ConnectError):
177
+ if not retry:
178
+ raise
179
+ time.sleep(self.retry_backoff)
180
+ return self.http.request(method, url, **kwargs)
181
+
182
+ def call(
183
+ self,
184
+ route: str,
185
+ *,
186
+ params: dict[str, Any] | None = None,
187
+ json_body: dict[str, Any] | None = None,
188
+ headers: dict[str, str] | None = None,
189
+ **path_args: str,
190
+ ) -> Any:
191
+ method, template = ROUTES[route]
192
+ path = f"{CONTEXT_PREFIX}{template.format(**path_args)}"
193
+ # The token exchange happens here, once and outside any retry; the request then
194
+ # carries the token itself, so its own auth flow never exchanges (or repeats) one.
195
+ bearer = {"Authorization": f"Bearer {self.token()}"}
196
+ try:
197
+ response = self._send(
198
+ method,
199
+ f"{self.base_url}{path}",
200
+ retry=route in RETRYABLE_ROUTES,
201
+ params=params,
202
+ json=json_body,
203
+ headers={**(headers or {}), **bearer},
204
+ auth=None,
205
+ )
206
+ except httpx.HTTPError as exc:
207
+ raise CliError(f"{method} {path} failed to connect: {exc}") from exc
208
+ if response.status_code >= 400:
209
+ try:
210
+ detail = response.json()
211
+ except ValueError:
212
+ detail = response.text[:400]
213
+ message = f"{method} {path} refused (HTTP {response.status_code}): {detail}"
214
+ if response.status_code == 409 and "idempotency_key_reused" in str(detail):
215
+ message += (
216
+ "\nhint: this key already belongs to a different request. To retry safely, "
217
+ "reuse the first attempt's values: pass --expected-revision and "
218
+ "--expected-release as the first attempt's stage output reported them, "
219
+ "together with the same --idempotency-key."
220
+ )
221
+ raise CliError(message)
222
+ if not response.content:
223
+ return {}
224
+ try:
225
+ return response.json()
226
+ except ValueError as exc:
227
+ raise CliError(f"{method} {path} answered with a body that is not JSON") from exc
228
+
229
+
230
+ def _load_json(raw: str, what: str) -> Any:
231
+ try:
232
+ return json.loads(raw)
233
+ except json.JSONDecodeError as exc:
234
+ raise CliError(f"{what} is not valid JSON: {exc}", code=2) from exc
235
+
236
+
237
+ def _load_json_file(path: str, what: str) -> Any:
238
+ try:
239
+ with open(path, encoding="utf-8") as handle:
240
+ text = handle.read()
241
+ except OSError as exc:
242
+ raise CliError(f"cannot read {what} {path!r}: {exc.strerror or exc}", code=2) from exc
243
+ except ValueError as exc: # UnicodeDecodeError is one
244
+ raise CliError(f"cannot read {what} {path!r}: not valid UTF-8 text ({exc})", code=2) from exc
245
+ return _load_json(text, f"{what} {path!r}")
246
+
247
+
248
+ # --- actions -----------------------------------------------------------------
249
+
250
+
251
+ def _cmd_read(args: argparse.Namespace, ctx: _Context) -> Any:
252
+ params: dict[str, Any] = {}
253
+ if args.version is not None:
254
+ params["version"] = args.version
255
+ if args.kit_id:
256
+ return ctx.call("read_kit", params=params or None, kit_id=args.kit_id)
257
+ if args.links:
258
+ params["expand"] = "links"
259
+ return ctx.call("read_asset", params=params, asset_id=args.asset_id)
260
+
261
+
262
+ def _cmd_search(args: argparse.Namespace, ctx: _Context) -> Any:
263
+ body: dict[str, Any] = {
264
+ "query": args.query,
265
+ "max_results": args.max_results,
266
+ }
267
+ if args.asset_type:
268
+ body["asset_type"] = args.asset_type
269
+ return ctx.call("search", json_body=body)
270
+
271
+
272
+ def _cmd_kits(args: argparse.Namespace, ctx: _Context) -> Any:
273
+ result = ctx.call("kits")
274
+ if not isinstance(result, dict) or not isinstance(result.get("kits"), list):
275
+ raise CliError("unexpected response from the service: no 'kits' list")
276
+ if not all(isinstance(kit, dict) for kit in result["kits"]):
277
+ raise CliError("unexpected response from the service: every entry of 'kits' must be an object")
278
+ return result
279
+
280
+
281
+ def _format_kits(result: Any) -> str:
282
+ """One line per kit (id, title, version) for a human; ``--json`` keeps the raw body."""
283
+ kits = result["kits"] # _cmd_kits has already checked this is a list
284
+ if not kits:
285
+ return "no kits"
286
+ return "\n".join(f"{kit.get('id')}\t{kit.get('title')}\tv{kit.get('version')}" for kit in kits)
287
+
288
+
289
+ def _cmd_assets(args: argparse.Namespace, ctx: _Context) -> Any:
290
+ params: dict[str, Any] = {"limit": args.limit, "offset": args.offset}
291
+ if args.asset_type:
292
+ params["asset_type"] = args.asset_type # httpx repeats the key per value
293
+ if args.label:
294
+ params["label"] = args.label
295
+ result = ctx.call("assets", params=params)
296
+ if not isinstance(result, dict) or not isinstance(result.get("assets"), list):
297
+ raise CliError("unexpected response from the service: no 'assets' list")
298
+ return result
299
+
300
+
301
+ def _format_assets(result: Any) -> str:
302
+ """One line per asset (id, type, label) for a human; ``--json`` keeps the raw body."""
303
+ assets = [a for a in result["assets"] if isinstance(a, dict)]
304
+ if not assets:
305
+ return "no assets"
306
+ return "\n".join(f"{a.get('asset_id')}\t{a.get('asset_type')}\t{a.get('label')}" for a in assets)
307
+
308
+
309
+ def _environment_label(base_url: str) -> str:
310
+ """staging, production or local, from the API host, unless ``COELEVATE_ENVIRONMENT`` says."""
311
+ override = os.environ.get(ENVIRONMENT_ENV, "").strip()
312
+ if override:
313
+ return override
314
+ host = urlsplit(base_url).hostname or ""
315
+ if host in ("localhost", "127.0.0.1", "::1") or host.endswith(".localhost"):
316
+ return "local"
317
+ return "staging" if "staging" in host.split(".") else "production"
318
+
319
+
320
+ #: The scopes ``whoami`` and ``doctor`` ask the server about, one token each. ``context:review``
321
+ #: is deliberately absent: it is the human reviewer's scope, and a diagnostic never requests it.
322
+ PROBED_SCOPES = (READ_SCOPE, ASSETS_SCOPE, KITS_SCOPE, GAPS_SCOPE)
323
+
324
+
325
+ def _probe_permits(source: ClientCredentialsTokenSource) -> dict[str, bool | None]:
326
+ """For each probed scope, whether the server grants it (``True``), refuses it (``False``)
327
+ or could not say (``None``)."""
328
+ return {scope: source.grants_scope(scope) for scope in PROBED_SCOPES}
329
+
330
+
331
+ def _cmd_whoami(args: argparse.Namespace, ctx: _Context) -> Any:
332
+ """Which project, where, and with what rights this key resolves to. Never the secret."""
333
+ ctx.token()
334
+ if ctx.project_id is None:
335
+ raise CliError("the token response names no project, so this key's project cannot be determined")
336
+ source = ctx.http.auth
337
+ permits = _probe_permits(source)
338
+ result = {
339
+ "project_id": ctx.project_id,
340
+ "api_host": urlsplit(ctx.base_url).netloc,
341
+ "environment": _environment_label(ctx.base_url),
342
+ "scopes": (source.granted_scope or "").split(),
343
+ "permits": [scope for scope, granted in permits.items() if granted],
344
+ "client_id": source.client_id,
345
+ }
346
+ unknown = [scope for scope, granted in permits.items() if granted is None]
347
+ if unknown:
348
+ result["permits_unknown"] = unknown
349
+ return result
350
+
351
+
352
+ _WHOAMI_LABELS = {"scopes": "default token scopes", "permits_unknown": "permits (could not check)"}
353
+
354
+
355
+ def _format_whoami(result: Any) -> str:
356
+ return "\n".join(
357
+ f"{_WHOAMI_LABELS.get(key, key)}: {' '.join(value) if isinstance(value, list) else value}" for key, value in result.items()
358
+ )
359
+
360
+
361
+ def _cmd_asset_types(args: argparse.Namespace, ctx: _Context) -> Any:
362
+ return ctx.call("asset_types")
363
+
364
+
365
+ def _cmd_changeset(args: argparse.Namespace, ctx: _Context) -> Any:
366
+ return ctx.call("changeset")
367
+
368
+
369
+ def _stage_ops(args: argparse.Namespace) -> list[Any]:
370
+ if args.ops_file:
371
+ ops = _load_json_file(args.ops_file, "--ops-file")
372
+ if not isinstance(ops, list) or not ops:
373
+ raise CliError("--ops-file must hold a non-empty JSON array of ops", code=2)
374
+ if len(ops) > MAX_OPS_PER_REQUEST:
375
+ raise CliError(
376
+ f"--ops-file holds {len(ops)} ops; one request takes at most {MAX_OPS_PER_REQUEST} - split the file",
377
+ code=2,
378
+ )
379
+ return ops
380
+ if args.op_file:
381
+ return [_load_json_file(args.op_file, "--op-file")]
382
+ return [_load_json(args.op, "--op")]
383
+
384
+
385
+ def _cmd_stage(args: argparse.Namespace, ctx: _Context) -> Any:
386
+ if args.remove_op:
387
+ return ctx.call("unstage", op_id=args.remove_op)
388
+ ops = _stage_ops(args)
389
+ provenance = None
390
+ if args.provenance is not None:
391
+ provenance = _load_json(args.provenance, "--provenance")
392
+ if not isinstance(provenance, dict):
393
+ raise CliError("--provenance must be a JSON object", code=2)
394
+
395
+ expected_revision, expected_release = args.expected_revision, args.expected_release
396
+ if expected_revision is None or expected_release is None:
397
+ # Every batch declares exactly what it read, so read the changeset immediately
398
+ # before staging rather than trusting a value from an earlier call.
399
+ changeset = ctx.call("changeset")
400
+ try:
401
+ if expected_revision is None:
402
+ expected_revision = changeset["revision"]
403
+ if expected_release is None:
404
+ expected_release = changeset["base_release"]
405
+ except (KeyError, TypeError) as exc:
406
+ raise CliError("the changeset read did not return a revision and base_release") from exc
407
+
408
+ body: dict[str, Any] = {
409
+ "ops": ops,
410
+ "expected_revision": expected_revision,
411
+ "expected_release": expected_release,
412
+ }
413
+ if provenance is not None:
414
+ body["provenance"] = provenance
415
+ headers = {"Idempotency-Key": args.idempotency_key} if args.idempotency_key else None
416
+ result = ctx.call("stage", json_body=body, headers=headers)
417
+ if isinstance(result, dict):
418
+ # What this request declared, so a retry can pin the same values.
419
+ result["expected_revision"] = expected_revision
420
+ result["expected_release"] = expected_release
421
+ return result
422
+
423
+
424
+ def _cmd_tag(args: argparse.Namespace, ctx: _Context) -> Any:
425
+ return ctx.call("releases", params={"tag": args.name})
426
+
427
+
428
+ def _cmd_release(args: argparse.Namespace, ctx: _Context) -> Any:
429
+ params: dict[str, Any] = {"limit": args.limit, "offset": args.offset}
430
+ if args.number is not None:
431
+ params["release"] = args.number
432
+ if args.tag is not None:
433
+ params["tag"] = args.tag
434
+ return ctx.call("releases", params=params)
435
+
436
+
437
+ #: The kit verbs whose request model is a JSON body; ``remove-member`` and ``retire`` carry
438
+ #: only the version they read, and ``journal`` only reads.
439
+ _KIT_BODY_VERBS = {"create": "kit_create", "patch": "kit_patch", "add-members": "kit_add_members", "revert": "kit_revert"}
440
+
441
+
442
+ def _cmd_kit(args: argparse.Namespace, ctx: _Context) -> Any:
443
+ """Write a kit directly (a kit is not an asset: no changeset, no release). A write names
444
+ the kit version it read (``base_version`` in the body, or ``--base-version``); a kit that
445
+ moved since is refused with 409 ``kit_moved``, never overwritten."""
446
+ verb = args.verb
447
+ if verb != "create" and not args.kit_id:
448
+ raise CliError(f"kit {verb} needs --kit-id", code=2)
449
+ if verb in _KIT_BODY_VERBS:
450
+ if args.body_file:
451
+ body = _load_json_file(args.body_file, "--body-file")
452
+ elif args.body:
453
+ body = _load_json(args.body, "--body")
454
+ else:
455
+ raise CliError(f"kit {verb} needs --body or --body-file", code=2)
456
+ if not isinstance(body, dict):
457
+ raise CliError("the kit request body must be a JSON object", code=2)
458
+ return ctx.call(_KIT_BODY_VERBS[verb], json_body=body, kit_id=args.kit_id)
459
+ if verb == "journal":
460
+ return ctx.call("kit_journal", kit_id=args.kit_id)
461
+ if args.base_version is None:
462
+ raise CliError(f"kit {verb} needs --base-version", code=2)
463
+ params = {"base_version": args.base_version}
464
+ if verb == "remove-member":
465
+ if not args.asset_id:
466
+ raise CliError("kit remove-member needs --asset-id", code=2)
467
+ return ctx.call("kit_remove_member", params=params, kit_id=args.kit_id, asset_id=args.asset_id)
468
+ return ctx.call("kit_retire", params=params, kit_id=args.kit_id)
469
+
470
+
471
+ #: The gap verbs whose request model is a JSON body. ``list``, ``get`` and ``journal`` only read.
472
+ _GAP_BODY_VERBS = {"create": "gap_create", "patch": "gap_patch", "retire": "gap_retire", "revert": "gap_revert"}
473
+
474
+
475
+ def _cmd_gap(args: argparse.Namespace, ctx: _Context) -> Any:
476
+ """Read or write a gap directly (a gap is not an asset: no changeset, no release, and it
477
+ names no kit). A write other than ``create`` names the gap version it read
478
+ (``base_version`` in the body); a gap that moved since is refused with 409 ``gap_moved``,
479
+ never overwritten. Closing (``retire``) takes an optional ``covered_by`` list of asset ids."""
480
+ verb = args.verb
481
+ if verb == "list":
482
+ params: dict[str, Any] = {"state": args.state}
483
+ if args.kind:
484
+ params["kind"] = args.kind
485
+ if args.opened_release is not None:
486
+ params["opened_release"] = args.opened_release
487
+ if args.closed_release is not None:
488
+ params["closed_release"] = args.closed_release
489
+ return ctx.call("gaps", params=params)
490
+ if verb != "create" and not args.gap_id:
491
+ raise CliError(f"gap {verb} needs --gap-id", code=2)
492
+ if verb == "get":
493
+ return ctx.call("gap_read", params={"version": args.version} if args.version is not None else None, gap_id=args.gap_id)
494
+ if verb == "journal":
495
+ return ctx.call("gap_journal", gap_id=args.gap_id)
496
+ if args.body_file:
497
+ body = _load_json_file(args.body_file, "--body-file")
498
+ elif args.body:
499
+ body = _load_json(args.body, "--body")
500
+ else:
501
+ raise CliError(f"gap {verb} needs --body or --body-file", code=2)
502
+ if not isinstance(body, dict):
503
+ raise CliError("the gap request body must be a JSON object", code=2)
504
+ if verb == "create" and "kit_ids" in body:
505
+ raise CliError(
506
+ "a gap names no kit, so a create body takes no kit_ids: put the gap into a kit with `kit add-members` (needs context:kits)",
507
+ code=2,
508
+ )
509
+ return ctx.call(_GAP_BODY_VERBS[verb], json_body=body, gap_id=args.gap_id)
510
+
511
+
512
+ def _check_retry_is_pinned(args: argparse.Namespace) -> None:
513
+ """A retry is only the same request when what it declares is pinned, so a key demands
514
+ both expected values. Checked before anything goes over the wire."""
515
+ if args.action != "stage" or not args.idempotency_key or args.remove_op:
516
+ return
517
+ missing = [
518
+ flag
519
+ for flag, value in (
520
+ ("--expected-revision", args.expected_revision),
521
+ ("--expected-release", args.expected_release),
522
+ )
523
+ if value is None
524
+ ]
525
+ if missing:
526
+ raise CliError(
527
+ f"--idempotency-key requires {' and '.join(missing)}: a retry is only the same "
528
+ "request when the values it declares are pinned. Read them with "
529
+ "`coelevate changeset --json`, then pass --expected-revision and "
530
+ "--expected-release together with the same --idempotency-key (and the same "
531
+ "values on every retry).",
532
+ code=2,
533
+ )
534
+
535
+
536
+ def _stage_scope(args: argparse.Namespace) -> str:
537
+ """Staging narrows the token to what it needs: write, plus read when it must read the
538
+ changeset first (a write-only token is refused on that read)."""
539
+ if args.remove_op:
540
+ return ASSETS_SCOPE
541
+ if args.expected_revision is not None and args.expected_release is not None:
542
+ return ASSETS_SCOPE
543
+ return f"{READ_SCOPE} {ASSETS_SCOPE}"
544
+
545
+
546
+ _READ = lambda args: READ_SCOPE # noqa: E731
547
+
548
+
549
+ def _kit_scope(args: argparse.Namespace) -> str:
550
+ """The journal is a read; every kit write needs only the kits scope (the published API
551
+ description says so), so the token asks for no more than the verb uses."""
552
+ return READ_SCOPE if args.verb == "journal" else KITS_SCOPE
553
+
554
+
555
+ def _gap_scope(args: argparse.Namespace) -> str:
556
+ """Listing, reading and the journal are reads; every gap write needs only the gaps scope."""
557
+ return READ_SCOPE if args.verb in ("list", "get", "journal") else GAPS_SCOPE
558
+
559
+
560
+ _HANDLERS = {
561
+ "read": (_cmd_read, _READ),
562
+ "search": (_cmd_search, _READ),
563
+ "stage": (_cmd_stage, _stage_scope),
564
+ "tag": (_cmd_tag, _READ),
565
+ "release": (_cmd_release, _READ),
566
+ "kits": (_cmd_kits, _READ),
567
+ "kit": (_cmd_kit, _kit_scope),
568
+ "gap": (_cmd_gap, _gap_scope),
569
+ "asset-types": (_cmd_asset_types, _READ),
570
+ "changeset": (_cmd_changeset, _READ),
571
+ "assets": (_cmd_assets, _READ),
572
+ "whoami": (_cmd_whoami, _READ),
573
+ }
574
+
575
+ #: Actions whose JSON gains a top-level ``project_id`` (only ever added, never replacing a key).
576
+ _PROJECT_ID_ACTIONS = frozenset({"kits", "kit", "gap", "read", "changeset", "stage", "release", "assets"})
577
+
578
+
579
+ # --- configuration -----------------------------------------------------------
580
+
581
+
582
+ def _load_dotenv(path: Path) -> list[str]:
583
+ """Take ``COELEVATE_*`` variables from a ``.env`` file into the process environment and
584
+ return their names. A variable already in the real environment wins. Minimal on purpose
585
+ (``KEY=value``, optional ``export``, optional quotes, ``#`` comments): the CLI has no
586
+ dependency for this and reads no other key. Values are never printed."""
587
+ if not path.is_file():
588
+ return []
589
+ try:
590
+ text = path.read_text(encoding="utf-8")
591
+ except (OSError, ValueError) as exc:
592
+ raise CliError(f"cannot read {str(path)!r}: {getattr(exc, 'strerror', None) or exc}", code=2) from exc
593
+ values: dict[str, str] = {}
594
+ for line in text.splitlines():
595
+ line = line.strip()
596
+ if not line or line.startswith("#"):
597
+ continue
598
+ if line.startswith("export "):
599
+ line = line[len("export ") :].lstrip()
600
+ key, sep, value = line.partition("=")
601
+ key, value = key.strip(), value.strip()
602
+ if not sep or not key.startswith(DOTENV_PREFIX):
603
+ continue
604
+ values[key] = _dotenv_value(value)
605
+ # A file in an untrusted checkout must not aim the real credentials at its own endpoint:
606
+ # its endpoint keys count only when the file brings the credentials too and the real
607
+ # environment brings neither.
608
+ brings_credentials = CLIENT_ID_ENV in values and CLIENT_SECRET_ENV in values
609
+ real_credentials = CLIENT_ID_ENV in os.environ or CLIENT_SECRET_ENV in os.environ
610
+ if not (brings_credentials and not real_credentials):
611
+ ignored = [key for key in (TOKEN_URL_ENV, API_URL_ENV) if key in values and key not in os.environ]
612
+ for key in ignored:
613
+ del values[key]
614
+ if ignored:
615
+ print(
616
+ f"notice: ignored {', '.join(ignored)} from ./{DOTENV_NAME}: an endpoint from the file is used "
617
+ "only when the file also sets the client id and secret and the environment sets neither",
618
+ file=sys.stderr,
619
+ )
620
+ loaded = []
621
+ for key, value in values.items():
622
+ if key not in os.environ:
623
+ os.environ[key] = value
624
+ loaded.append(key)
625
+ return loaded
626
+
627
+
628
+ def _dotenv_value(raw: str) -> str:
629
+ """The value of one ``KEY=value`` line: a quoted value (single or double quotes) up to its
630
+ closing quote, then optionally whitespace and a ``#`` comment; an unquoted value up to
631
+ `` #``."""
632
+ if raw[:1] in ("'", '"'):
633
+ close = raw.find(raw[0], 1)
634
+ if close != -1:
635
+ rest = raw[close + 1 :].strip()
636
+ if not rest or rest.startswith("#"):
637
+ return raw[1:close]
638
+ return raw.split(" #", 1)[0].rstrip()
639
+
640
+
641
+ def _workspace_base(base_url: str) -> str | None:
642
+ """The Workspace origin: ``COELEVATE_WORKSPACE_URL``, else the API origin without its
643
+ ``api.`` label (``api.staging.coelevate.ai`` -> ``staging.coelevate.ai``). ``None`` when
644
+ the API host does not follow that shape, rather than a guess that points nowhere."""
645
+ override = os.environ.get(WORKSPACE_URL_ENV, "").strip().rstrip("/")
646
+ if override:
647
+ return override
648
+ parts = urlsplit(base_url)
649
+ host = parts.hostname or ""
650
+ if not host.startswith("api.") or not parts.scheme:
651
+ return None
652
+ port = f":{parts.port}" if parts.port else ""
653
+ return f"{parts.scheme}://{host[len('api.') :]}{port}"
654
+
655
+
656
+ def _add_workspace_url(result: Any, ctx: _Context) -> None:
657
+ """Add where a human reviews the open changeset, unless the server already said."""
658
+ if not isinstance(result, dict) or "workspace_url" in result:
659
+ return
660
+ detail = result.get("changeset")
661
+ changeset_id = result.get("changeset_id") or (detail.get("changeset_id") if isinstance(detail, dict) else None)
662
+ base = _workspace_base(ctx.base_url)
663
+ if changeset_id and base and ctx.project_id:
664
+ result["workspace_url"] = f"{base}/projects/{ctx.project_id}/context/changes/{changeset_id}"
665
+
666
+
667
+ def _positive_seconds_from_env(name: str, default: float) -> float:
668
+ raw = os.environ.get(name, "").strip()
669
+ if not raw:
670
+ return default
671
+ try:
672
+ value = float(raw)
673
+ except ValueError:
674
+ value = 0.0
675
+ if not value > 0 or value == float("inf"):
676
+ raise CliError(f"{name} must be a positive number of seconds, got {raw!r}", code=2)
677
+ return value
678
+
679
+
680
+ def _resolve_config(args: argparse.Namespace) -> dict[str, str]:
681
+ """Everything the CLI needs, read once. Names every missing variable at once, before
682
+ anything goes over the wire."""
683
+ secret = os.environ.get(CLIENT_SECRET_ENV, "").strip()
684
+ client_id = os.environ.get(CLIENT_ID_ENV, "").strip()
685
+ base_url = (args.api_url or os.environ.get(API_URL_ENV, "")).strip().rstrip("/")
686
+ token_url = (args.token_url or os.environ.get(TOKEN_URL_ENV, "")).strip()
687
+ if not token_url and base_url:
688
+ token_url = f"{base_url}{TOKEN_PATH}"
689
+ missing = []
690
+ if not client_id:
691
+ missing.append(CLIENT_ID_ENV)
692
+ if not secret:
693
+ missing.append(CLIENT_SECRET_ENV)
694
+ if not base_url:
695
+ missing.append(f"{API_URL_ENV} (or --api-url)")
696
+ if missing:
697
+ raise CliError(
698
+ f"missing configuration: {', '.join(missing)}. Credentials are read from the environment or from "
699
+ f"./{DOTENV_NAME}, never from a flag; create an API key in the project's API keys panel in the "
700
+ f"Workspace and put its values in ./{DOTENV_NAME}.",
701
+ code=2,
702
+ )
703
+ return {
704
+ "client_id": client_id,
705
+ "client_secret": secret,
706
+ "token_url": token_url,
707
+ "base_url": base_url,
708
+ }
709
+
710
+
711
+ # --- parser ------------------------------------------------------------------
712
+
713
+
714
+ class _Parser(argparse.ArgumentParser):
715
+ """Usage errors follow the ``--json`` contract when ``--json`` was asked for."""
716
+
717
+ json_errors = False
718
+
719
+ def error(self, message: str): # type: ignore[override]
720
+ if not _Parser.json_errors:
721
+ super().error(message) # prints usage + message to stderr, exits 2
722
+ print(json.dumps({"error": f"{self.prog}: {message}", "exit_code": 2}), file=sys.stderr)
723
+ raise SystemExit(2)
724
+
725
+
726
+ def build_parser() -> argparse.ArgumentParser:
727
+ common = argparse.ArgumentParser(add_help=False, allow_abbrev=False)
728
+ common.add_argument("--api-url", help=f"the API origin (default: ${API_URL_ENV})")
729
+ common.add_argument(
730
+ "--token-url",
731
+ help=f"the token endpoint (default: ${TOKEN_URL_ENV}, else <API URL>{TOKEN_PATH})",
732
+ )
733
+ common.add_argument(
734
+ "--json",
735
+ action="store_true",
736
+ help="print one compact JSON document on stdout; errors become a JSON object on stderr",
737
+ )
738
+
739
+ common.add_argument(
740
+ "--expect-project",
741
+ help=f"refuse to act unless the key resolves to this project (default: ${PROJECT_ID_ENV})",
742
+ )
743
+
744
+ parser = _Parser(
745
+ prog="coelevate",
746
+ allow_abbrev=False,
747
+ description=(
748
+ "Command-line client for the Coelevate Context Repository public API: read, "
749
+ "search, stage changes to assets and inspect releases. Asset changes are "
750
+ "stage-only - proposed, never landed - while a kit is written directly with "
751
+ "`kit`. Credentials come from the environment: "
752
+ f"{CLIENT_ID_ENV}, {CLIENT_SECRET_ENV}; the API origin from {API_URL_ENV} "
753
+ "(or --api-url)."
754
+ ),
755
+ )
756
+ parser.add_argument("--version", action="version", version=f"coelevate {__version__}")
757
+ sub = parser.add_subparsers(dest="action", required=True, metavar="ACTION")
758
+
759
+ def add(name: str, help_text: str) -> argparse.ArgumentParser:
760
+ return sub.add_parser(name, parents=[common], help=help_text, allow_abbrev=False)
761
+
762
+ read_p = add("read", "read one kit or one asset by id")
763
+ target = read_p.add_mutually_exclusive_group(required=True)
764
+ target.add_argument("--kit-id", help="read this kit")
765
+ target.add_argument("--asset-id", help="read this asset (any asset type)")
766
+ read_p.add_argument("--version", type=int, default=None, help="an archived version, else live")
767
+ read_p.add_argument("--links", action="store_true", help="expand resolved in/out edges (assets only)")
768
+
769
+ search_p = add("search", "lexical text search over your key's context")
770
+ search_p.add_argument("--query", required=True)
771
+ search_p.add_argument("--asset-type", action="append", help="repeatable; omit for every type")
772
+ search_p.add_argument("--max-results", type=int, default=20)
773
+
774
+ add("whoami", "show which project, API, default-token scopes and permitted scopes this key resolves to (never the secret)")
775
+ add("doctor", "check configuration, token, project pin, scopes and one read; exit 0/1/2")
776
+ assets_p = add("assets", "list assets from the index (id, type, label)")
777
+ assets_p.add_argument("--asset-type", action="append", help="repeatable; omit for every type")
778
+ assets_p.add_argument("--label", default=None, help="only assets with this label")
779
+ assets_p.add_argument("--limit", type=int, default=50)
780
+ assets_p.add_argument("--offset", type=int, default=0)
781
+
782
+ add("kits", "list the kits (id, title, version)")
783
+ add("asset-types", "list the asset types the context accepts")
784
+ add("changeset", "show the open changeset: revision, base release and staged ops")
785
+
786
+ stage_p = add("stage", "stage ops (or un-stage one) in the open changeset")
787
+ ops = stage_p.add_mutually_exclusive_group(required=True)
788
+ ops.add_argument("--op", help="one op as a JSON string")
789
+ ops.add_argument("--op-file", help="one op as a JSON file")
790
+ ops.add_argument("--ops-file", help=f"a JSON array of up to {MAX_OPS_PER_REQUEST} ops, staged in one request")
791
+ ops.add_argument("--remove-op", help="un-stage this op id instead of staging")
792
+ stage_p.add_argument("--provenance", default=None, help="a JSON object recording where the ops came from")
793
+ stage_p.add_argument("--idempotency-key", default=None, help="make a retry of this request safe")
794
+ stage_p.add_argument("--expected-revision", type=int, default=None, help="default: read from the changeset just before staging")
795
+ stage_p.add_argument("--expected-release", type=int, default=None, help="default: read from the changeset just before staging")
796
+
797
+ tag_p = add("tag", "look up the release currently holding a tag (read-only)")
798
+ tag_p.add_argument("--name", required=True, help="the tag to look up")
799
+
800
+ release_p = add("release", "list releases, or read one by number or tag")
801
+ release_p.add_argument("--number", type=int, default=None, help="read this one release")
802
+ release_p.add_argument("--tag", default=None, help="the release currently holding this tag")
803
+ release_p.add_argument("--limit", type=int, default=50)
804
+ release_p.add_argument("--offset", type=int, default=0)
805
+
806
+ kit_p = add(
807
+ "kit",
808
+ "write a kit directly (create, patch, add or remove members, revert, retire) or read its journal",
809
+ )
810
+ kit_p.add_argument("verb", choices=("create", "patch", "add-members", "remove-member", "revert", "retire", "journal"))
811
+ kit_p.add_argument("--kit-id", default=None, help="the kit (every verb but create)")
812
+ kit_p.add_argument("--asset-id", default=None, help="the member to take out (remove-member)")
813
+ kit_p.add_argument("--base-version", type=int, default=None, help="the kit version you read (remove-member, retire)")
814
+ kit_p.add_argument("--body", default=None, help="create, patch, add-members, revert: the request as a JSON string")
815
+ kit_p.add_argument("--body-file", default=None, help="create, patch, add-members, revert: the request as a JSON file")
816
+
817
+ gap_p = add(
818
+ "gap",
819
+ "list, read, record, edit, close (retire) or revert a gap directly, or read its journal",
820
+ )
821
+ gap_p.add_argument("verb", choices=("list", "get", "journal", "create", "patch", "retire", "revert"))
822
+ gap_p.add_argument("--gap-id", default=None, help="the gap (every verb but list and create)")
823
+ gap_p.add_argument("--state", choices=("open", "retired", "all"), default="open", help="list: which gaps (default open)")
824
+ gap_p.add_argument("--kind", default=None, help="list: only gaps of this kind")
825
+ gap_p.add_argument("--opened-release", type=int, default=None, help="list: only gaps opened in this release")
826
+ gap_p.add_argument("--closed-release", type=int, default=None, help="list: only gaps closed in this release")
827
+ gap_p.add_argument("--version", type=int, default=None, help="get: an archived version, else live")
828
+ gap_p.add_argument("--body", default=None, help="create, patch, retire, revert: the request as a JSON string")
829
+ gap_p.add_argument("--body-file", default=None, help="create, patch, retire, revert: the request as a JSON file")
830
+
831
+ return parser
832
+
833
+
834
+ def _fail(exc: Exception, code: int, as_json: bool) -> int:
835
+ if as_json:
836
+ print(json.dumps({"error": str(exc), "exit_code": code}), file=sys.stderr)
837
+ else:
838
+ print(f"error: {exc}", file=sys.stderr)
839
+ return code
840
+
841
+
842
+ def _expected_project(args: argparse.Namespace) -> str | None:
843
+ """The project the caller pinned: the flag, else ``COELEVATE_PROJECT_ID``, else none."""
844
+ return (args.expect_project or os.environ.get(PROJECT_ID_ENV, "")).strip() or None
845
+
846
+
847
+ def _open_ctx(stack: ExitStack, args: argparse.Namespace, config: dict[str, str], scope: str, timeout: float, backoff: float) -> _Context:
848
+ http_client = stack.enter_context(httpx.Client(timeout=httpx.Timeout(timeout)))
849
+ http_client.auth = ClientCredentialsTokenSource(
850
+ token_url=config["token_url"],
851
+ client_id=config["client_id"],
852
+ client_secret=config["client_secret"],
853
+ scope=scope,
854
+ http_client=http_client,
855
+ )
856
+ return _Context(http_client, config["base_url"], backoff, _expected_project(args))
857
+
858
+
859
+ def _doctor(args: argparse.Namespace, dotenv_keys: list[str]) -> int:
860
+ """Check each thing a session depends on, in order, and report all of it. Exit 0 when
861
+ everything holds, 2 when the configuration or pin is wrong, 1 when the service is."""
862
+ checks: list[dict[str, Any]] = []
863
+ codes: list[int] = []
864
+
865
+ def record(name: str, ok: bool, detail: str, code: int = 1) -> bool:
866
+ checks.append({"name": name, "ok": ok, "detail": detail})
867
+ if not ok:
868
+ codes.append(code)
869
+ return ok
870
+
871
+ record(
872
+ "dotenv",
873
+ True,
874
+ f"{len(dotenv_keys)} {DOTENV_PREFIX}* value(s) loaded from ./{DOTENV_NAME}"
875
+ if dotenv_keys
876
+ else f"nothing loaded from ./{DOTENV_NAME}",
877
+ )
878
+ try:
879
+ config = _resolve_config(args)
880
+ timeout = _positive_seconds_from_env(HTTP_TIMEOUT_ENV, DEFAULT_HTTP_TIMEOUT)
881
+ backoff = _positive_seconds_from_env(RETRY_BACKOFF_ENV, DEFAULT_RETRY_BACKOFF)
882
+ except CliError as exc:
883
+ record("configuration", False, str(exc), exc.code)
884
+ config = None
885
+ if config is not None:
886
+ record("configuration", True, "client id, client secret and API origin are set")
887
+ base = config["base_url"]
888
+ record("api", True, f"{urlsplit(base).netloc} ({_environment_label(base)}); workspace {_workspace_base(base) or 'unknown'}")
889
+ expected = _expected_project(args)
890
+ with ExitStack() as stack:
891
+ ctx = _open_ctx(stack, args, config, READ_SCOPE, timeout, backoff)
892
+ try:
893
+ ctx.http.auth.token()
894
+ except (OAuthTokenError, httpx.HTTPError) as exc:
895
+ record("token", False, str(exc))
896
+ else:
897
+ record("token", True, "exchanged for a bearer token")
898
+ project = ctx.project_id
899
+ record("project", project is not None, project or "the token response names no project")
900
+ pin_ok = True
901
+ if expected is not None:
902
+ pin_ok = record(
903
+ "pin",
904
+ project == expected,
905
+ f"{project} matches the pin"
906
+ if project == expected
907
+ else f"the key resolves to {project!r} but {expected!r} is pinned",
908
+ 2,
909
+ )
910
+ record(
911
+ "scopes",
912
+ READ_SCOPE in (ctx.http.auth.granted_scope or "").split(),
913
+ f"granted: {ctx.http.auth.granted_scope or 'none reported'}",
914
+ )
915
+ permits = _probe_permits(ctx.http.auth)
916
+ for scope, label, refused in (
917
+ (ASSETS_SCOPE, "assets scope", "staging will be refused"),
918
+ (KITS_SCOPE, "kits scope", "kit writes will be refused"),
919
+ (GAPS_SCOPE, "gaps scope", "gap writes will be refused"),
920
+ ):
921
+ granted = permits[scope]
922
+ if granted is None:
923
+ detail = "could not be checked (the token endpoint failed for another reason)"
924
+ else:
925
+ detail = "granted" if granted else f"not granted ({refused})"
926
+ record(label, True, detail)
927
+ if pin_ok:
928
+ try:
929
+ kits = _cmd_kits(args, ctx)
930
+ record("read", True, f"kits: {len(kits['kits'])}")
931
+ except CliError as exc:
932
+ record("read", False, str(exc), exc.code)
933
+ except (OAuthTokenError, httpx.HTTPError) as exc:
934
+ record("read", False, str(exc))
935
+ exit_code = 2 if 2 in codes else (1 if codes else 0)
936
+ if args.json:
937
+ print(json.dumps({"ok": exit_code == 0, "checks": checks, "exit_code": exit_code}, sort_keys=True, separators=(",", ":")))
938
+ else:
939
+ for check in checks:
940
+ print(f"{'ok ' if check['ok'] else 'FAIL'} {check['name']}: {check['detail']}")
941
+ return exit_code
942
+
943
+
944
+ def main(argv: list[str] | None = None) -> int:
945
+ _Parser.json_errors = "--json" in (sys.argv[1:] if argv is None else argv)
946
+ args = build_parser().parse_args(argv)
947
+ try:
948
+ dotenv_keys = _load_dotenv(Path.cwd() / DOTENV_NAME)
949
+ except CliError as exc:
950
+ return _fail(exc, exc.code, args.json)
951
+ if args.action == "doctor":
952
+ return _doctor(args, dotenv_keys)
953
+ handler, scope_of = _HANDLERS[args.action]
954
+ try:
955
+ _check_retry_is_pinned(args)
956
+ config = _resolve_config(args)
957
+ timeout = _positive_seconds_from_env(HTTP_TIMEOUT_ENV, DEFAULT_HTTP_TIMEOUT)
958
+ backoff = _positive_seconds_from_env(RETRY_BACKOFF_ENV, DEFAULT_RETRY_BACKOFF)
959
+ with ExitStack() as stack:
960
+ ctx = _open_ctx(stack, args, config, scope_of(args), timeout, backoff)
961
+ result = handler(args, ctx)
962
+ if isinstance(result, dict):
963
+ if args.action in _PROJECT_ID_ACTIONS and ctx.project_id:
964
+ result.setdefault("project_id", ctx.project_id)
965
+ if args.action in ("changeset", "stage"):
966
+ _add_workspace_url(result, ctx)
967
+ except CliError as exc:
968
+ return _fail(exc, exc.code, args.json)
969
+ except OAuthTokenError as exc:
970
+ return _fail(exc, 1, args.json)
971
+ except httpx.HTTPError as exc:
972
+ return _fail(CliError(f"could not reach the token endpoint: {exc}"), 1, args.json)
973
+
974
+ if args.json:
975
+ print(json.dumps(result, sort_keys=True, separators=(",", ":")))
976
+ elif args.action == "kits":
977
+ print(_format_kits(result))
978
+ elif args.action == "assets":
979
+ print(_format_assets(result))
980
+ elif args.action == "whoami":
981
+ print(_format_whoami(result))
982
+ else:
983
+ print(json.dumps(result, indent=2, sort_keys=True))
984
+ return 0
985
+
986
+
987
+ if __name__ == "__main__":
988
+ sys.exit(main())