engram-dynamics 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.
engram_cli/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """engram-dynamics — the `engram` command-line client for the Engram platform.
2
+
3
+ Entry point is `engram_cli.main:main` (the `engram` console script). The package also
4
+ ships the repo's `mcp_server` stdio server, so one install gives a customer both the
5
+ CLI and their MCP server (CAAS-904).
6
+ """
7
+
8
+ __version__ = "0.1.0"
engram_cli/api.py ADDED
@@ -0,0 +1,525 @@
1
+ """HTTP client for the Engram control plane.
2
+
3
+ Every call goes through `Client.request`, so the error mapping lives in exactly one
4
+ place: the server's own message is what the user sees, and the CLI never invents a
5
+ reason of its own. Auth is `Authorization: Bearer <token>`; an `ek_`-prefixed API key
6
+ (CAAS-801) and a session JWT are both accepted there, so the CLI does not care which
7
+ one it is holding.
8
+
9
+ API VERSION (CAAS-802). The same client speaks both contracts, chosen per profile:
10
+
11
+ legacy (the default) the unversioned paths this CLI has always called
12
+ 1 the published /v1 contract — one error envelope, list
13
+ responses wrapped in {"items", "next_cursor"}, and
14
+ Idempotency-Key honoured on writes
15
+ auto ask the server: GET / lists `api_versions`, so a deployment
16
+ that advertises "1" gets it and an older one falls back
17
+
18
+ THE DEFAULT STAYS LEGACY on purpose. An installed CLI must not change behaviour
19
+ because a server rolled forward, and a customer on an older self-hosted control plane
20
+ has no /v1 to talk to. `engram login --api-version 1` (or `auto`) opts in, per profile.
21
+
22
+ Everything below this line is version-agnostic: the commands call the same methods and
23
+ see the same lists and the same ApiError messages either way. The two places the
24
+ contracts differ — the error body and the list envelope — are normalised in
25
+ `_detail_message` / `_detail_code` / `_retry_after` and in `Client._list`.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ from .config import Profile
34
+
35
+ # Generous enough for a control-plane call over a cold ECS task, short enough that a
36
+ # wedged network fails the command instead of hanging a script.
37
+ DEFAULT_TIMEOUT = 30.0
38
+
39
+ # Profile values for `api_version`. "legacy" is the sentinel for the unversioned paths; anything
40
+ # else is a version number and becomes the path prefix ("1" -> "/v1").
41
+ LEGACY_VERSION = "legacy"
42
+ AUTO_VERSION = "auto"
43
+ # The contract's maximum page size (backend/app/pagination.py). Asking for it is how a client that
44
+ # wants everything makes the fewest round trips.
45
+ MAX_PAGE = 500
46
+
47
+
48
+ def _items(body: Any) -> list[dict]:
49
+ """The rows out of a list response, whichever contract produced it."""
50
+ if isinstance(body, dict):
51
+ return body.get("items") or []
52
+ return body or []
53
+
54
+
55
+ class ApiError(Exception):
56
+ """A request failed. `message` is already user-facing — print it and exit 1.
57
+
58
+ `code` and `retry_after` carry the structured half of the error (CAAS-111: 429
59
+ `ingest_quota` / `beta_limit` / `plan_limit`, 402 `trial_expired`, 503
60
+ `serving_unavailable`) for the few callers that need to BRANCH on a failure rather
61
+ than just report it — `engram push` backs off and retries a 503 upload, and stops
62
+ dead on a quota 429 instead of hammering the rest of a 20,000-file manifest.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ message: str,
68
+ *,
69
+ status: int | None = None,
70
+ body: Any = None,
71
+ code: str | None = None,
72
+ retry_after: float | None = None,
73
+ ):
74
+ super().__init__(message)
75
+ self.message = message
76
+ self.status = status
77
+ self.body = body
78
+ self.code = code
79
+ self.retry_after = retry_after
80
+
81
+
82
+ class NotLoggedIn(ApiError):
83
+ def __init__(self, profile_name: str):
84
+ super().__init__(
85
+ f"No API key for profile '{profile_name}'. Run: engram login --profile "
86
+ f"{profile_name} --api-key ek_..."
87
+ )
88
+
89
+
90
+ def _envelope(body: Any) -> dict | None:
91
+ """The /v1 error envelope, `{"error": {"code", "message", ...}}`, or None.
92
+
93
+ Checked on `code` rather than on the key alone: a legacy structured detail also uses
94
+ the word "error", and mistaking one for the other would print a dict at the user."""
95
+ if not isinstance(body, dict):
96
+ return None
97
+ error = body.get("error")
98
+ return error if isinstance(error, dict) and "code" in error else None
99
+
100
+
101
+ def _validation_line(errors: Any) -> str | None:
102
+ """FastAPI's per-field validation list, flattened to one line. Same rendering for both
103
+ contracts — under /v1 the list moved into `details.errors`, its items did not change."""
104
+ if not isinstance(errors, list):
105
+ return None
106
+ parts = []
107
+ for item in errors:
108
+ if isinstance(item, dict) and item.get("msg"):
109
+ loc = ".".join(str(p) for p in (item.get("loc") or []) if p != "body")
110
+ parts.append(f"{loc}: {item['msg']}" if loc else str(item["msg"]))
111
+ else:
112
+ parts.append(str(item))
113
+ return "; ".join(parts) or None
114
+
115
+
116
+ def _detail_message(body: Any) -> str | None:
117
+ """Turn any error body — either contract — into one line.
118
+
119
+ Four shapes in the wild:
120
+ * {"detail": "plain string"} — the common legacy case
121
+ * {"detail": {"error": "beta_limit", "message": ...}} — beta caps (429); the
122
+ `message` is written for the end user, so it wins over the error code.
123
+ * {"detail": [{"loc": [...], "msg": ...}, ...]} — 422 validation errors.
124
+ * {"error": {"code": ..., "message": ..., "details": {...}}} — the /v1 envelope.
125
+
126
+ The /v1 branch is first and deliberately produces the SAME sentence the legacy branch
127
+ would: the envelope is a re-packaging of the same message and the same extras, so a
128
+ customer who switches contracts must not see the CLI start saying different things.
129
+ """
130
+ error = _envelope(body)
131
+ if error is not None:
132
+ details = error.get("details") if isinstance(error.get("details"), dict) else {}
133
+ message = str(error.get("message") or error.get("code") or "").strip()
134
+ fields = _validation_line(details.get("errors"))
135
+ if fields:
136
+ message = f"{message} {fields}".strip() if message else fields
137
+ upgrade = details.get("upgrade_url")
138
+ if upgrade and str(upgrade) not in message:
139
+ message = f"{message} Upgrade: {upgrade}"
140
+ return message or None
141
+
142
+ if not isinstance(body, dict):
143
+ return None
144
+ detail = body.get("detail")
145
+ if detail is None:
146
+ return None
147
+ if isinstance(detail, str):
148
+ return detail
149
+ if isinstance(detail, dict):
150
+ message = str(detail.get("message") or detail.get("error") or detail)
151
+ # A quota or plan wall is only actionable with the link out of it, and the server
152
+ # is the one that knows which plan page applies. Appended rather than reworded.
153
+ upgrade = detail.get("upgrade_url")
154
+ if upgrade and str(upgrade) not in message:
155
+ message = f"{message} Upgrade: {upgrade}"
156
+ return message
157
+ if isinstance(detail, list):
158
+ return _validation_line(detail)
159
+ return str(detail)
160
+
161
+
162
+ def _detail_code(body: Any) -> str | None:
163
+ """The machine-readable error code, from either contract.
164
+
165
+ This is what `engram push` branches on — it backs off on `serving_unavailable` and stops
166
+ dead on `ingest_quota` — so the codes have to come out identical on both. They do: /v1
167
+ carries the SAME code, just at `error.code` instead of `detail.error`."""
168
+ error = _envelope(body)
169
+ if error is not None:
170
+ code = error.get("code")
171
+ return str(code) if code else None
172
+ if not isinstance(body, dict):
173
+ return None
174
+ detail = body.get("detail")
175
+ if isinstance(detail, dict):
176
+ code = detail.get("error") or detail.get("code")
177
+ return str(code) if code else None
178
+ return None
179
+
180
+
181
+ def _retry_after(body: Any, resp: httpx.Response) -> float | None:
182
+ """Seconds to wait, from the structured detail or the standard header.
183
+
184
+ Read defensively from both places because the two sources disagree in the wild: a
185
+ proxy or the rate limiter may set the header while the application sets the field.
186
+ Either one is better than the CLI inventing a backoff of its own.
187
+ """
188
+ candidates: list[Any] = []
189
+ error = _envelope(body)
190
+ if error is not None and isinstance(error.get("details"), dict):
191
+ candidates.append(error["details"].get("retry_after"))
192
+ if isinstance(body, dict) and isinstance(body.get("detail"), dict):
193
+ candidates.append(body["detail"].get("retry_after"))
194
+ candidates.append(resp.headers.get("retry-after"))
195
+ for value in candidates:
196
+ if value is None:
197
+ continue
198
+ try:
199
+ return max(0.0, float(value))
200
+ except (TypeError, ValueError):
201
+ continue # an HTTP-date Retry-After; not worth parsing for a backoff hint
202
+ return None
203
+
204
+
205
+ class Client:
206
+ """Thin wrapper over httpx. One instance per command invocation."""
207
+
208
+ def __init__(self, profile: Profile, *, timeout: float = DEFAULT_TIMEOUT):
209
+ self.profile = profile
210
+ self.base_url = profile.api_url
211
+ self._timeout = timeout
212
+ # Resolved lazily, and once per client, because `auto` costs an extra round trip and a
213
+ # command that makes ten calls should pay for it no more than a command that makes one.
214
+ self._prefix: str | None = None
215
+
216
+ # --- plumbing ---------------------------------------------------------------
217
+ def _headers(self, *, auth: bool) -> dict[str, str]:
218
+ headers = {"Accept": "application/json"}
219
+ if auth:
220
+ if not self.profile.api_key:
221
+ raise NotLoggedIn(self.profile.name)
222
+ headers["Authorization"] = f"Bearer {self.profile.api_key}"
223
+ return headers
224
+
225
+ @property
226
+ def api_prefix(self) -> str:
227
+ """"" for the legacy paths, "/v1" for the published contract.
228
+
229
+ `auto` asks the server: GET / is unauthenticated and lists `api_versions`, so this works
230
+ before login and on a deployment that has never heard of versions (it 404s, and we fall
231
+ back to legacy, which is the right answer for exactly that deployment)."""
232
+ if self._prefix is not None:
233
+ return self._prefix
234
+ version = (self.profile.api_version or LEGACY_VERSION).strip().lower()
235
+ if version == AUTO_VERSION:
236
+ version = self._advertised_version()
237
+ self._prefix = "" if version == LEGACY_VERSION else f"/v{version.lstrip('v')}"
238
+ return self._prefix
239
+
240
+ @property
241
+ def resolved_version(self) -> str:
242
+ """The version this client actually settled on — "legacy" or a number, never "auto"."""
243
+ prefix = self.api_prefix
244
+ return prefix[2:] if prefix else LEGACY_VERSION
245
+
246
+ def _advertised_version(self) -> str:
247
+ """The newest version this deployment says it serves, or legacy if it says nothing.
248
+
249
+ Never raises: version discovery failing is not a reason for the user's actual command to
250
+ fail, and the legacy paths are the safe answer in every case where it does."""
251
+ try:
252
+ body = self.request("GET", "/", auth=False, versioned=False)
253
+ except ApiError:
254
+ return LEGACY_VERSION
255
+ versions = body.get("api_versions") if isinstance(body, dict) else None
256
+ if not isinstance(versions, list) or not versions:
257
+ return LEGACY_VERSION
258
+ return str(max(versions, key=lambda v: (len(str(v)), str(v))))
259
+
260
+ def request(
261
+ self,
262
+ method: str,
263
+ path: str,
264
+ *,
265
+ auth: bool = True,
266
+ json_body: Any = None,
267
+ params: dict[str, Any] | None = None,
268
+ headers: dict[str, str] | None = None,
269
+ versioned: bool = True,
270
+ ) -> Any:
271
+ """Call the API and return parsed JSON (None for a 204), or raise ApiError.
272
+
273
+ `versioned=False` is for the handful of paths that have no versioned copy and never will:
274
+ `/health` (the load balancer's target check) and `/` (version discovery itself)."""
275
+ url = f"{self.base_url}{self.api_prefix if versioned else ''}{path}"
276
+ try:
277
+ resp = httpx.request(
278
+ method,
279
+ url,
280
+ headers={**self._headers(auth=auth), **(headers or {})},
281
+ json=json_body,
282
+ params=params,
283
+ timeout=self._timeout,
284
+ follow_redirects=True,
285
+ )
286
+ except httpx.TimeoutException as exc:
287
+ raise ApiError(f"Timed out talking to {self.base_url} ({exc.__class__.__name__}).") from exc
288
+ except httpx.HTTPError as exc:
289
+ raise ApiError(f"Could not reach {self.base_url}: {exc}") from exc
290
+
291
+ if resp.status_code == 204 or not resp.content:
292
+ if resp.is_success:
293
+ return None
294
+
295
+ body: Any = None
296
+ try:
297
+ body = resp.json()
298
+ except ValueError:
299
+ body = None
300
+
301
+ if resp.is_success:
302
+ return body
303
+
304
+ message = _detail_message(body)
305
+ if message is None:
306
+ message = resp.text.strip() or f"HTTP {resp.status_code}"
307
+ if resp.status_code == 401:
308
+ message = f"{message} (the API key was rejected — check `engram login`)"
309
+ raise ApiError(
310
+ message,
311
+ status=resp.status_code,
312
+ body=body,
313
+ code=_detail_code(body),
314
+ retry_after=_retry_after(body, resp),
315
+ )
316
+
317
+ def _list(self, path: str, *, auth: bool = True, params: dict[str, Any] | None = None) -> list[dict]:
318
+ """A list endpoint, as a plain list, on either contract.
319
+
320
+ Legacy answers a bare array. /v1 answers `{"items": [...], "next_cursor": ...}` and pages,
321
+ so we FOLLOW the cursor to the end: every caller here wants the whole set (resolve_corpus
322
+ matches a name across it, `engram docs` slices it locally), and a CLI that silently showed
323
+ the first 50 of 400 documents would be worse than one that never paged at all. The page
324
+ size is the contract maximum, so the round trips are as few as the server allows."""
325
+ if not self.api_prefix:
326
+ return self.request("GET", path, auth=auth, params=params) or []
327
+
328
+ out: list[dict] = []
329
+ query = dict(params or {})
330
+ query.setdefault("limit", MAX_PAGE)
331
+ while True:
332
+ body = self.request("GET", path, auth=auth, params=query)
333
+ out.extend(_items(body))
334
+ cursor = body.get("next_cursor") if isinstance(body, dict) else None
335
+ if not cursor:
336
+ return out
337
+ query["cursor"] = cursor
338
+
339
+ # --- endpoints --------------------------------------------------------------
340
+ def health(self) -> Any:
341
+ """Liveness. Deliberately unauthenticated so `engram status` works logged out, and
342
+ unversioned because the load balancer's target check has no business moving."""
343
+ return self.request("GET", "/health", auth=False, versioned=False)
344
+
345
+ def platform_status(self) -> Any:
346
+ """Live serving state (CAAS-1302's GET /status, which CAAS-1303 is the query-path half of).
347
+
348
+ Needs a session since 2026-09-11 (the founder made the status page sign-in only), so the
349
+ profile's key rides along; `engram status` only asks when a key is configured. Cached
350
+ server-side, so polling this is cheap."""
351
+ return self.request("GET", "/status", auth=True)
352
+
353
+ def list_corpora(self) -> list[dict]:
354
+ return self._list("/corpora")
355
+
356
+ def create_corpus(self, name: str) -> dict:
357
+ return self.request("POST", "/corpora", json_body={"name": name})
358
+
359
+ def get_corpus(self, corpus_id: str) -> dict:
360
+ return self.request("GET", f"/corpora/{corpus_id}")
361
+
362
+ def list_documents(self, corpus_id: str, *, limit: int | None = None, offset: int = 0) -> list[dict]:
363
+ """Documents for a corpus.
364
+
365
+ GET /corpora/{id}/documents returns the whole list today — there is no
366
+ server-side paging yet (it arrives with CAAS-903), and guessing whether a
367
+ response was paged is not something a client can do reliably. So `--limit` and
368
+ `--offset` are applied here, deterministically. When the endpoint starts paging,
369
+ move the slice into query params; the command surface does not change.
370
+ """
371
+ docs = self._list(f"/corpora/{corpus_id}/documents")
372
+ if offset:
373
+ docs = docs[offset:]
374
+ if limit is not None:
375
+ docs = docs[:limit]
376
+ return docs
377
+
378
+ def list_jobs(self, corpus_id: str) -> list[dict]:
379
+ return self._list(f"/corpora/{corpus_id}/jobs")
380
+
381
+ # API keys (CAAS-801, built in parallel — paths are the agreed contract).
382
+ def list_api_keys(self) -> list[dict]:
383
+ return self._list("/api-keys")
384
+
385
+ def create_api_key(self, name: str, scopes: list[str], expires_at: str | None = None) -> dict:
386
+ body: dict[str, Any] = {"name": name, "scopes": scopes}
387
+ if expires_at:
388
+ body["expires_at"] = expires_at
389
+ return self.request("POST", "/api-keys", json_body=body)
390
+
391
+ def revoke_api_key(self, key_id: str) -> Any:
392
+ return self.request("DELETE", f"/api-keys/{key_id}")
393
+
394
+ # --- ingest contract (CAAS-106/107/109, consumed by `engram push`) ------------
395
+ #
396
+ # The three calls are one transaction in the user's head and three on the wire, on
397
+ # purpose: diff is cheap and answers "what actually changed", upload-urls is issued
398
+ # only for that delta, and commit is what makes any of it real. A run interrupted
399
+ # between any two of them is resumable precisely because nothing is registered until
400
+ # commit — the next diff sees the same work still outstanding.
401
+ def platform_info(self) -> dict:
402
+ """Region, egress guidance, and the extensions this deployment can ingest.
403
+
404
+ Unauthenticated (CAAS-608) so it also answers the pre-signup question, which is
405
+ why `engram push` can use it to learn the supported extensions before doing
406
+ anything that needs a key.
407
+ """
408
+ return self.request("GET", "/platform/info", auth=False) or {}
409
+
410
+ def diff_documents(self, corpus_id: str, files: list[dict]) -> dict:
411
+ """The rsync step: send the manifest, get back what is new/changed/unchanged/missing."""
412
+ return self.request(
413
+ "POST", f"/corpora/{corpus_id}/documents/diff", json_body={"files": files}
414
+ ) or {}
415
+
416
+ def upload_urls(self, corpus_id: str, files: list[dict]) -> list[dict]:
417
+ """Ask for a write target per file. Each may be a presigned bucket URL or a URL
418
+ on the API itself; `push.py` decides which credential to send from the origin."""
419
+ body = self.request(
420
+ "POST", f"/corpora/{corpus_id}/documents/upload-urls", json_body={"files": files}
421
+ ) or {}
422
+ return body.get("uploads") or []
423
+
424
+ def commit_documents(
425
+ self, corpus_id: str, files: list[dict], *, onboard: bool, delete_missing: bool
426
+ ) -> dict:
427
+ """Register the uploaded bytes and open a sync run.
428
+
429
+ `files` is the WHOLE manifest, not just the delta: `delete_missing` can only mean
430
+ "remove what this manifest does not mention", so the server has to see the full
431
+ desired state to compute a removal safely.
432
+ """
433
+ return self.request(
434
+ "POST",
435
+ f"/corpora/{corpus_id}/documents/commit",
436
+ json_body={"files": files, "onboard": onboard, "delete_missing": delete_missing},
437
+ ) or {}
438
+
439
+ def upsert_document(
440
+ self,
441
+ corpus_id: str,
442
+ path: str,
443
+ *,
444
+ text: str | None = None,
445
+ content_base64: str | None = None,
446
+ ) -> dict:
447
+ body: dict[str, Any] = {"path": path}
448
+ if text is not None:
449
+ body["text"] = text
450
+ if content_base64 is not None:
451
+ body["content_base64"] = content_base64
452
+ return self.request("POST", f"/corpora/{corpus_id}/documents/upsert", json_body=body) or {}
453
+
454
+ # --- sync runs (CAAS-109) -----------------------------------------------------
455
+ def get_sync_run(self, corpus_id: str, run_id: str) -> dict:
456
+ return self.request("GET", f"/corpora/{corpus_id}/sync-runs/{run_id}") or {}
457
+
458
+ def list_sync_runs(self, corpus_id: str) -> list[dict]:
459
+ """Run history, newest first if the server orders it; a bare list or a
460
+ {"runs": [...]} envelope are both accepted because either is a reasonable shape
461
+ and guessing wrong would break `engram status` for a cosmetic reason."""
462
+ if self.api_prefix:
463
+ return self._list(f"/corpora/{corpus_id}/sync-runs")
464
+ body = self.request("GET", f"/corpora/{corpus_id}/sync-runs")
465
+ if isinstance(body, dict):
466
+ return body.get("runs") or body.get("items") or []
467
+ return body or []
468
+
469
+ # --- sources (CAAS-601/903) ---------------------------------------------------
470
+ def create_source(self, corpus_id: str, body: dict) -> dict:
471
+ return self.request("POST", f"/corpora/{corpus_id}/sources", json_body=body) or {}
472
+
473
+ def list_sources(self, corpus_id: str) -> list[dict]:
474
+ return self._list(f"/corpora/{corpus_id}/sources")
475
+
476
+ def validate_source(self, corpus_id: str, source_id: str) -> dict:
477
+ return self.request(
478
+ "POST", f"/corpora/{corpus_id}/sources/{source_id}/validate"
479
+ ) or {}
480
+
481
+ def sync_source(self, corpus_id: str, source_id: str) -> dict:
482
+ """Kick a pull now instead of waiting for the schedule. Returns a sync run."""
483
+ return self.request("POST", f"/corpora/{corpus_id}/sources/{source_id}/sync") or {}
484
+
485
+ def delete_source(self, corpus_id: str, source_id: str) -> Any:
486
+ return self.request("DELETE", f"/corpora/{corpus_id}/sources/{source_id}")
487
+
488
+ def s3_setup(
489
+ self, *, external_id: str, bucket: str, prefix: str = "", kms_key_arn: str | None = None
490
+ ) -> dict:
491
+ """The customer-side IAM templates for one source. A pure renderer server-side,
492
+ so it holds no secret and needs nothing to exist first."""
493
+ params: dict[str, Any] = {"external_id": external_id, "bucket": bucket, "prefix": prefix}
494
+ if kms_key_arn:
495
+ params["kms_key_arn"] = kms_key_arn
496
+ return self.request("GET", "/sources/s3/setup", params=params) or {}
497
+
498
+
499
+ def resolve_corpus(client: Client, ref: str) -> dict:
500
+ """Accept a corpus by exact id OR exact name, anywhere a corpus is named.
501
+
502
+ One list call answers both, and gives good errors: an unknown ref names the closest
503
+ thing the tenant actually has, and a duplicated name lists the candidate ids rather
504
+ than silently picking one.
505
+ """
506
+ corpora = client.list_corpora()
507
+ for corpus in corpora:
508
+ if corpus.get("id") == ref:
509
+ return corpus
510
+
511
+ by_name = [c for c in corpora if c.get("name") == ref]
512
+ if not by_name:
513
+ # Case-insensitive second pass — people type `engram status sales` for "Sales".
514
+ by_name = [c for c in corpora if (c.get("name") or "").lower() == ref.lower()]
515
+
516
+ if len(by_name) == 1:
517
+ return by_name[0]
518
+ if len(by_name) > 1:
519
+ candidates = "\n".join(f" {c.get('id')} {c.get('name')}" for c in by_name)
520
+ raise ApiError(
521
+ f"'{ref}' matches {len(by_name)} document bases. Use the id:\n{candidates}"
522
+ )
523
+
524
+ known = ", ".join(sorted(c.get("name", "") for c in corpora)) or "(none)"
525
+ raise ApiError(f"No document base named or with id '{ref}'. You have: {known}")
engram_cli/common.py ADDED
@@ -0,0 +1,63 @@
1
+ """The conventions every `engram` command shares, in one importable place.
2
+
3
+ `main.py` owns the command surface, but `sources_cmd.py` needs the same `--profile` /
4
+ `--json` handling and the same error-to-exit-code mapping, and importing `main` from a
5
+ command module it registers would be circular. So the four pieces both need live here:
6
+ the merged option object, the two shared `typer.Option` declarations, the error decorator,
7
+ and the client factory.
8
+
9
+ Behaviour is unchanged from where it started in `main.py` — every command still accepts
10
+ its flags before OR after the subcommand, and still exits 1 with the SERVER's message on
11
+ stderr rather than a reason the CLI invented.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import functools
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ import typer
21
+
22
+ from . import output
23
+ from .api import ApiError, Client
24
+ from .config import ConfigError, load_profile
25
+
26
+
27
+ @dataclass
28
+ class CommonOpts:
29
+ profile: str | None = None
30
+ json_out: bool = False
31
+
32
+
33
+ PROFILE_OPT = typer.Option(None, "--profile", "-p", help="Config profile to use (default: 'default').")
34
+ JSON_OPT = typer.Option(False, "--json", help="Emit JSON on stdout for scripting.")
35
+
36
+
37
+ def opts(ctx: typer.Context, profile: str | None, json_out: bool) -> CommonOpts:
38
+ """Merge the group-level flags (`engram --json status`) with the command-level ones
39
+ (`engram status --json`). Either position works; the command-level one wins."""
40
+ base: CommonOpts = ctx.obj if isinstance(ctx.obj, CommonOpts) else CommonOpts()
41
+ return CommonOpts(profile=profile or base.profile, json_out=bool(json_out or base.json_out))
42
+
43
+
44
+ def handle_errors(func: Callable) -> Callable:
45
+ """Turn an ApiError/ConfigError into `error: <server message>` on stderr and exit 1.
46
+
47
+ Applied per command rather than around the whole app so the behaviour is identical
48
+ when a test drives a command through CliRunner.
49
+ """
50
+
51
+ @functools.wraps(func)
52
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
53
+ try:
54
+ return func(*args, **kwargs)
55
+ except (ApiError, ConfigError) as exc:
56
+ output.error(str(exc))
57
+ raise typer.Exit(code=1) from exc
58
+
59
+ return wrapper
60
+
61
+
62
+ def client(options: CommonOpts) -> Client:
63
+ return Client(load_profile(options.profile))