xmemcli 0.0.1__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.
xmemcli/cli.py ADDED
@@ -0,0 +1,1826 @@
1
+ """``xmem`` — single-command CLI for the xmemory HTTP API.
2
+
3
+ This file is the CLI entry point. It deliberately uses only the standard
4
+ library so the demo runs anywhere a Python 3.12+ interpreter exists without
5
+ ``pip install``. The interesting structural decision is that **commands do
6
+ not format output themselves** — every subcommand builds a
7
+ :class:`_output.CliOutput` and returns ``(output, exit_code)``. The
8
+ ``main`` dispatch then emits via :func:`_output.emit`, which picks
9
+ between strict JSON (machine consumption) and a one-line human summary
10
+ (interactive terminal) per the rules documented in :mod:`_output`.
11
+
12
+ Endpoints exercised (under ``$XMEM_API_URL`` unless noted; default
13
+ ``https://api.xmemory.ai``):
14
+
15
+ - ``POST /instances/{instance_id}/write`` — sync write
16
+ - ``POST /instances/{instance_id}/write_async`` — queue an async write
17
+ - ``POST /instances/{instance_id}/write_status`` — poll an async write
18
+ - ``POST /instances/{instance_id}/read`` — read
19
+ - ``GET /api-keys`` — list your keys
20
+ - ``GET /whoami`` — whose key / session is this?
21
+ - ``GET /clusters`` · ``GET /clusters/{id}/quotas`` ·
22
+ ``GET /clusters/{id}/quotas/{quota_id}`` — ``quota get``
23
+ - ``POST /cli/token`` (via ``cli/_login.py`` during ``auth login``) — PKCE key redeem
24
+
25
+ The CLI exposes only the parameters the API already supports.
26
+
27
+ Curated output
28
+ --------------
29
+
30
+ The CLI does NOT echo the raw API payload. Every subcommand reduces the
31
+ server response to the fields the user actually asked for (see the
32
+ ``_curate_*`` helpers): a read returns the answer / rows, a write returns
33
+ its ``write_id``, a status poll returns ``write_id`` + ``write_status``.
34
+ Server internals — ``sql``, ``diff_plan``, the raw ``cleaned_objects``
35
+ tree, ``pending_suggestions`` — are never emitted, in either output mode.
36
+ ``--verbose`` adds *extra* (still non-internal) context: ``trace_id`` and
37
+ ``console_url``, when the server provided them.
38
+
39
+ Sync vs async writes
40
+ --------------------
41
+
42
+ ``xmem write "<text>"`` is **synchronous** by default — the CLI blocks on
43
+ ``POST …/write`` until the server has finished extraction and applied the
44
+ diff. Use ``--no-wait`` to queue the work via ``POST …/write_async`` and
45
+ get back a ``write_id`` immediately. Poll that id with
46
+ ``xmemcli write-status <write_id>``; the command accepts **one or
47
+ more** ids and waits for *all* of them to reach a terminal state
48
+ (``completed`` / ``failed`` / ``not_found``) before printing one JSON
49
+ array. That's the bulk-status capability — a single round of CLI work
50
+ covers three concurrently-queued writes.
51
+
52
+ The choice between sync and async matters for the operator:
53
+
54
+ - Synchronous writes are simpler — one round-trip, the server finishes
55
+ extraction before responding, and the CLI exits with a meaningful
56
+ status. Best for one-off facts.
57
+ - Asynchronous writes return in milliseconds and let the server batch
58
+ extraction work in the queue worker. Best when you're pushing many
59
+ facts in a short window — e.g. seeding an instance from a script —
60
+ because the CLI doesn't have to hold a connection open per fact.
61
+
62
+ Both paths are covered by the Harness-First Test
63
+ ([`HARNESS-FIRST-TEST.md`](HARNESS-FIRST-TEST.md)) and unit tests under `tests/`.
64
+ """
65
+
66
+ from __future__ import annotations
67
+
68
+ import argparse
69
+ import concurrent.futures
70
+ import json
71
+ import os
72
+ import sys
73
+ import time
74
+ import urllib.error
75
+ import urllib.request
76
+ import webbrowser
77
+ from pathlib import Path
78
+ from typing import Any
79
+ from urllib.parse import urlsplit
80
+
81
+ from . import _help # noqa: E402 (sibling-script import)
82
+ from . import _login # noqa: E402 (sibling-script import)
83
+ from ._envelope import batch_envelope_error # noqa: E402 (sibling-script import)
84
+ from ._output import CliOutput, OutputKind, emit, make_error, stdout_is_tty # noqa: E402 (sibling-script import)
85
+ from ._rc import ( # noqa: E402 (sibling-script import)
86
+ ENV_RC_DIR,
87
+ RC_FILENAME,
88
+ RcError,
89
+ RcRecord,
90
+ find_rc,
91
+ gitignore_warning,
92
+ load_rc,
93
+ remove_rc,
94
+ resolve_rc,
95
+ write_rc,
96
+ )
97
+ from ._version import CLI_NAME, __version__ # noqa: E402 (sibling-script import)
98
+
99
+ # Single source of truth for env var names. Mirrored in cli/README.md.
100
+ ENV_API_KEY = "XMEM_API_KEY"
101
+ ENV_INSTANCE_ID = "XMEM_INSTANCE_ID"
102
+ ENV_API_URL = "XMEM_API_URL"
103
+ ENV_CLUSTER_ID = "XMEM_CLUSTER_ID"
104
+ ENV_CONSOLE_URL = "XMEM_CONSOLE_URL"
105
+ DEFAULT_API_URL = "https://api.xmemory.ai"
106
+
107
+ # Warn on stderr when a quota window is at or above this fraction used.
108
+ _QUOTA_WARN_RATIO = 0.9
109
+
110
+ # Exit codes. Distinguishing "no key" from "rejected key" from "request
111
+ # failed" lets the Playwright authorisation scenario assert specifically on
112
+ # EXIT_AUTH_ERROR instead of "any non-zero".
113
+ EXIT_OK = 0
114
+ EXIT_USAGE = 2
115
+ EXIT_HTTP_ERROR = 3
116
+ EXIT_AUTH_ERROR = 4
117
+ EXIT_TIMEOUT = 5
118
+ # Distinct exit code for quota-exceeded responses so callers can branch on
119
+ # "wait / upgrade" instead of the generic EXIT_HTTP_ERROR. Carries the
120
+ # ``QuotaExceededKind`` discriminator on the wire body so the printed
121
+ # message can match (TRIAL ENDED / DAILY QUOTA / MONTHLY QUOTA).
122
+ EXIT_QUOTA_EXCEEDED = 6
123
+ # Conventional shell exit for SIGINT (128 + 2) — no stack trace, not a usage error.
124
+ EXIT_INTERRUPTED = 130
125
+
126
+
127
+ def _eprint(message: str) -> None:
128
+ sys.stderr.write(f"{message}\n")
129
+
130
+
131
+ def _note_cli_warning(args: argparse.Namespace, message: str) -> None:
132
+ """Record a warning for stderr and, in machine JSON mode, the stdout document."""
133
+ _eprint(f"warning: {message}")
134
+ warnings = getattr(args, "_cli_warnings", None)
135
+ if warnings is None:
136
+ warnings = []
137
+ setattr(args, "_cli_warnings", warnings)
138
+ warnings.append(message)
139
+
140
+
141
+ def _with_cli_warnings(output: CliOutput, args: argparse.Namespace, *, force_json: bool) -> CliOutput:
142
+ if not force_json:
143
+ return output
144
+ warnings = getattr(args, "_cli_warnings", None)
145
+ if not warnings or not isinstance(output.payload, dict) or output.kind == OutputKind.ERROR:
146
+ return output
147
+ payload = dict(output.payload)
148
+ existing = payload.get("warnings")
149
+ if isinstance(existing, list):
150
+ payload["warnings"] = [*existing, *warnings]
151
+ else:
152
+ payload["warnings"] = list(warnings)
153
+ return CliOutput(output.kind, payload)
154
+
155
+
156
+ # ── HTTP helper ────────────────────────────────────────────────────────────
157
+
158
+
159
+ class ApiError(Exception):
160
+ """An HTTP error response from the API, carrying status + raw body."""
161
+
162
+ def __init__(self, status: int, body: str, message: str):
163
+ super().__init__(message)
164
+ self.status = status
165
+ self.body = body
166
+
167
+
168
+ # Human-friendly copy for each ``QuotaExceededKind`` the API may surface
169
+ # via ``ApiError.body``'s structured ``details.kind`` field. Mirrors the
170
+ # console SPA's ``QUOTA_KIND_LABELS``.
171
+ _QUOTA_KIND_MESSAGES: dict[str, str] = {
172
+ "trial_exceeded": "Your trial token cap is exhausted. Upgrade your plan to continue using xmemory.",
173
+ "daily_quota_exceeded": (
174
+ "Your daily quota is exhausted. Retry once the daily window resets, or request a higher limit."
175
+ ),
176
+ "monthly_quota_exceeded": (
177
+ "Your monthly quota is exhausted. Wait until the next monthly window, or request a higher limit."
178
+ ),
179
+ }
180
+
181
+
182
+ # Deliberately mirrors ``xmemory/exceptions.py::quota_exceeded_from_body`` (same
183
+ # ``_QUOTA_ERROR_CODES`` set and ``errors[0] -> details.kind`` walk). The two cannot
184
+ # share one helper: this CLI is stdlib-only and standalone — it runs without a
185
+ # ``pip install`` of the ``xmemory`` library (see the module docstring) — and the
186
+ # published library cannot depend on the CLI. Keep this walk in sync by hand if the
187
+ # error-envelope shape ever changes.
188
+ _QUOTA_ERROR_CODES = frozenset({"QUOTA_EXCEEDED", "TRIAL_ENDED"})
189
+
190
+
191
+ def _quota_exceeded_message(exc: ApiError) -> str | None:
192
+ """If the API responded with a quota-class error, project the
193
+ structured body into a human-friendly message. Returns ``None`` for
194
+ any other error so the caller falls through to the generic HTTP path.
195
+
196
+ Two server-side sources land here:
197
+
198
+ * ``QUOTA_EXCEEDED`` carries a ``details.kind`` discriminator
199
+ (``trial_exceeded`` / ``daily_quota_exceeded`` /
200
+ ``monthly_quota_exceeded``); we look up the kind-specific copy.
201
+ * ``TRIAL_ENDED`` is emitted by the paywall middleware before the
202
+ endpoint runs, with no ``details`` payload. Fall back to the
203
+ server-supplied ``errors[0].message`` in that case so the caller
204
+ sees the actual reason rather than a generic stub.
205
+ """
206
+ try:
207
+ body = json.loads(exc.body)
208
+ except (json.JSONDecodeError, TypeError):
209
+ return None
210
+ errors = body.get("errors") if isinstance(body, dict) else None
211
+ if not isinstance(errors, list) or not errors:
212
+ return None
213
+ first = errors[0]
214
+ if not isinstance(first, dict) or first.get("code") not in _QUOTA_ERROR_CODES:
215
+ return None
216
+ details = first.get("details") if isinstance(first.get("details"), dict) else {}
217
+ kind = details.get("kind") if isinstance(details, dict) else None
218
+ if kind is None:
219
+ # ``TRIAL_ENDED`` (paywall) and any future quota code that opts
220
+ # out of details: surface the server message verbatim.
221
+ server_message = first.get("message")
222
+ return str(server_message) if isinstance(server_message, str) and server_message else "Quota exceeded."
223
+ base = _QUOTA_KIND_MESSAGES.get(str(kind), "Quota exceeded.")
224
+ retry_after = details.get("retry_after_seconds") if isinstance(details, dict) else None
225
+ if isinstance(retry_after, int) and retry_after > 0:
226
+ return f"{base} (retry_after: {retry_after}s)"
227
+ return base
228
+
229
+
230
+ def _post(url: str, body: dict[str, Any], api_key: str, timeout_s: float = 60.0) -> dict[str, Any]:
231
+ data = json.dumps(body).encode("utf-8")
232
+ req = urllib.request.Request(
233
+ url,
234
+ data=data,
235
+ method="POST",
236
+ headers={
237
+ "Content-Type": "application/json",
238
+ "Accept": "application/json",
239
+ "Authorization": f"Bearer {api_key}",
240
+ },
241
+ )
242
+ try:
243
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
244
+ raw = resp.read().decode("utf-8")
245
+ except urllib.error.HTTPError as e:
246
+ raw = e.read().decode("utf-8", errors="replace")
247
+ raise ApiError(e.code, raw, f"HTTP {e.code}: {raw[:200]}") from None
248
+ except urllib.error.URLError as e:
249
+ raise ApiError(0, "", f"Network error: {e.reason}") from None
250
+ try:
251
+ return json.loads(raw)
252
+ except json.JSONDecodeError:
253
+ raise ApiError(200, raw, f"API returned non-JSON body: {raw[:200]}") from None
254
+
255
+
256
+ def _put(url: str, body: dict[str, Any], api_key: str, timeout_s: float = 60.0) -> dict[str, Any]:
257
+ data = json.dumps(body).encode("utf-8")
258
+ req = urllib.request.Request(
259
+ url,
260
+ data=data,
261
+ method="PUT",
262
+ headers={
263
+ "Content-Type": "application/json",
264
+ "Accept": "application/json",
265
+ "Authorization": f"Bearer {api_key}",
266
+ },
267
+ )
268
+ try:
269
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
270
+ raw = resp.read().decode("utf-8")
271
+ except urllib.error.HTTPError as e:
272
+ raw = e.read().decode("utf-8", errors="replace")
273
+ raise ApiError(e.code, raw, f"HTTP {e.code}: {raw[:200]}") from None
274
+ except urllib.error.URLError as e:
275
+ raise ApiError(0, "", f"Network error: {e.reason}") from None
276
+ try:
277
+ return json.loads(raw)
278
+ except json.JSONDecodeError:
279
+ raise ApiError(200, raw, f"API returned non-JSON body: {raw[:200]}") from None
280
+
281
+
282
+ def _delete(url: str, api_key: str, timeout_s: float = 60.0) -> dict[str, Any]:
283
+ req = urllib.request.Request(
284
+ url,
285
+ method="DELETE",
286
+ headers={"Accept": "application/json", "Authorization": f"Bearer {api_key}"},
287
+ )
288
+ try:
289
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
290
+ raw = resp.read().decode("utf-8")
291
+ except urllib.error.HTTPError as e:
292
+ raw = e.read().decode("utf-8", errors="replace")
293
+ raise ApiError(e.code, raw, f"HTTP {e.code}: {raw[:200]}") from None
294
+ except urllib.error.URLError as e:
295
+ raise ApiError(0, "", f"Network error: {e.reason}") from None
296
+ if not raw.strip():
297
+ return {}
298
+ try:
299
+ return json.loads(raw)
300
+ except json.JSONDecodeError:
301
+ raise ApiError(200, raw, f"API returned non-JSON body: {raw[:200]}") from None
302
+
303
+
304
+ def _get(url: str, api_key: str, timeout_s: float = 30.0) -> dict[str, Any]:
305
+ req = urllib.request.Request(
306
+ url,
307
+ method="GET",
308
+ headers={"Accept": "application/json", "Authorization": f"Bearer {api_key}"},
309
+ )
310
+ try:
311
+ with urllib.request.urlopen(req, timeout=timeout_s) as resp:
312
+ raw = resp.read().decode("utf-8")
313
+ except urllib.error.HTTPError as e:
314
+ raw = e.read().decode("utf-8", errors="replace")
315
+ raise ApiError(e.code, raw, f"HTTP {e.code}: {raw[:200]}") from None
316
+ except urllib.error.URLError as e:
317
+ raise ApiError(0, "", f"Network error: {e.reason}") from None
318
+ try:
319
+ return json.loads(raw)
320
+ except json.JSONDecodeError:
321
+ raise ApiError(200, raw, f"API returned non-JSON body: {raw[:200]}") from None
322
+
323
+
324
+ def _console_url_for_args(args: argparse.Namespace) -> str | None:
325
+ explicit = getattr(args, "console_url", None) or os.environ.get(ENV_CONSOLE_URL)
326
+ return explicit.strip() if isinstance(explicit, str) and explicit.strip() else None
327
+
328
+
329
+ def _fetch_whoami(base: str, api_key: str, *, console_url: str | None = None) -> dict[str, Any]:
330
+ """``GET /whoami`` with bearer auth, trying Console proxy URLs before direct API origins."""
331
+ last_err: ApiError | None = None
332
+ for url in _login.whoami_urls(api_url=base, console_url=console_url):
333
+ try:
334
+ return _get(url, api_key)
335
+ except ApiError as e:
336
+ last_err = e
337
+ if last_err is not None:
338
+ raise last_err
339
+ raise ApiError(0, "", "whoami unreachable")
340
+
341
+
342
+ def _unwrap_items(envelope: dict[str, Any]) -> dict[str, Any]:
343
+ """Unwrap the API's ``{"items":[T], "errors":[]}`` envelope.
344
+
345
+ Every endpoint we call returns exactly one item on success. We unwrap
346
+ in the CLI so the JSON the user sees is the actual payload, not the
347
+ batch wrapper. Rejects a non-empty ``errors`` array before reading
348
+ ``items`` — even on HTTP 200.
349
+ """
350
+ err = batch_envelope_error(envelope)
351
+ if err:
352
+ raise ApiError(200, json.dumps(envelope), err)
353
+ if isinstance(envelope, dict) and isinstance(envelope.get("items"), list):
354
+ items = envelope["items"]
355
+ if items:
356
+ return items[0] if isinstance(items[0], dict) else {"value": items[0]}
357
+ return envelope
358
+
359
+
360
+ # ── Output curation ────────────────────────────────────────────────────────
361
+ # The CLI never echoes the raw API payload. Each ``_curate_*`` helper keeps
362
+ # only the fields the user asked for; ``--verbose`` adds the extra (but still
363
+ # non-internal) context below. Internals — ``sql``, ``diff_plan``, the raw
364
+ # ``cleaned_objects`` tree, ``pending_suggestions`` — are dropped in BOTH
365
+ # output modes, verbose or not.
366
+
367
+ #: Fields ``--verbose`` is allowed to surface when the server sent them.
368
+ _VERBOSE_EXTRAS = ("trace_id", "console_url")
369
+
370
+
371
+ def _add_verbose_extras(curated: dict[str, Any], item: dict[str, Any], verbose: bool) -> dict[str, Any]:
372
+ """Copy the whitelisted ``--verbose`` fields from ``item`` into ``curated``."""
373
+ if verbose:
374
+ for key in _VERBOSE_EXTRAS:
375
+ value = item.get(key)
376
+ if value is not None and key not in curated:
377
+ curated[key] = value
378
+ return curated
379
+
380
+
381
+ def _summarize_extracted(item: dict[str, Any]) -> list[str]:
382
+ """Reduce the write response's ``cleaned_objects`` tree to ``Type(name)`` tags.
383
+
384
+ The full tree is an internal — what the user cares about is *what got
385
+ stored*, so we keep a compact summary and drop the rest.
386
+ """
387
+ objs = ((item.get("cleaned_objects") or {}).get("objects")) or []
388
+ parts: list[str] = []
389
+ for obj in objs:
390
+ if not isinstance(obj, dict):
391
+ continue
392
+ kind = obj.get("type") or obj.get("kind") or "?"
393
+ name = obj.get("name") or obj.get("first_name") or obj.get("title") or "…"
394
+ parts.append(f"{kind}({name})")
395
+ return parts
396
+
397
+
398
+ def _curate_write(item: dict[str, Any], *, sync: bool, verbose: bool) -> dict[str, Any]:
399
+ curated: dict[str, Any] = {"write_id": item.get("write_id")}
400
+ if sync:
401
+ curated["extracted"] = _summarize_extracted(item)
402
+ return _add_verbose_extras(curated, item, verbose)
403
+
404
+
405
+ def _curate_write_status(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
406
+ curated: dict[str, Any] = {
407
+ "write_id": item.get("write_id"),
408
+ "write_status": item.get("write_status", "unknown"),
409
+ }
410
+ # Carry the failure / bookkeeping fields only when they say something.
411
+ for key in ("error_detail", "completed_at", "error", "http_status", "timed_out"):
412
+ value = item.get(key)
413
+ if value is not None:
414
+ curated[key] = value
415
+ return _add_verbose_extras(curated, item, verbose)
416
+
417
+
418
+ def _extract_single_answer(reader_result: dict[str, Any]) -> str | None:
419
+ """Pull the most likely "headline" string out of a ReaderResult.
420
+
421
+ The reader can put the answer in any of several spots depending on the
422
+ code path that produced it. We probe the most common ones in order and
423
+ return the first non-empty string. None ⇒ caller falls back to the
424
+ reader_result subtree rather than inventing an answer.
425
+ """
426
+ for key in ("answer", "text", "summary"):
427
+ v = reader_result.get(key)
428
+ if isinstance(v, str) and v.strip():
429
+ return v
430
+ single = reader_result.get("single_answer")
431
+ if isinstance(single, dict):
432
+ v = single.get("text") or single.get("answer")
433
+ if isinstance(v, str) and v.strip():
434
+ return v
435
+ elif isinstance(single, str) and single.strip():
436
+ return single
437
+ return None
438
+
439
+
440
+ def _curate_read(item: dict[str, Any], *, read_mode: str, verbose: bool) -> dict[str, Any]:
441
+ rr = item.get("reader_result")
442
+ rr_dict = rr if isinstance(rr, dict) else {}
443
+ if read_mode == "single":
444
+ answer = _extract_single_answer(rr_dict)
445
+ # No extractable answer string → emit the reader_result subtree so
446
+ # the user still sees something truthful rather than `answer: null`.
447
+ curated = {"answer": answer} if answer is not None else dict(rr_dict)
448
+ else:
449
+ # raw / xresponse: the reader_result IS the thing the user asked for.
450
+ curated = dict(rr_dict)
451
+ return _add_verbose_extras(curated, item, verbose)
452
+
453
+
454
+ #: Per-key fields ``xmem keys list`` shows. The raw DTO additionally carries
455
+ #: ids (key/user/role/cluster) that are internals at the CLI surface.
456
+ _KEY_LIST_FIELDS = ("name", "key_prefix", "is_active")
457
+
458
+
459
+ def _curate_key(item: dict[str, Any], verbose: bool) -> dict[str, Any]:
460
+ if verbose:
461
+ return item
462
+ return {k: item[k] for k in _KEY_LIST_FIELDS if k in item}
463
+
464
+
465
+ # ── Argument resolution ────────────────────────────────────────────────────
466
+
467
+
468
+ class UsageError(Exception):
469
+ """A pre-flight argument / credential error raised before a subcommand can
470
+ build its own ``CliOutput`` — a missing API key, a malformed ``.xmemrc.json``,
471
+ an unset instance id, or the localhost-key-at-prod guard.
472
+
473
+ It carries an ``OutputKind.ERROR`` payload so ``main`` renders it through
474
+ :func:`emit` like any other result. That keeps the machine-mode invariant:
475
+ a failed command still emits **exactly one** JSON document on stdout, never
476
+ bare prose to stderr (see ``cli/_output.py``).
477
+ """
478
+
479
+ def __init__(self, message: str, *, exit_code: int = EXIT_USAGE):
480
+ super().__init__(message)
481
+ self.output = make_error(message, stage="usage")
482
+ self.exit_code = exit_code
483
+
484
+
485
+ def _normalize_api_origin(url: str) -> str | None:
486
+ """Canonical API origin for comparison: scheme + lowercased host + explicit port.
487
+
488
+ Ignores path, query, fragment, and trailing slashes. Returns ``None`` when the
489
+ URL does not carry a usable scheme + host (the localhost-key-at-prod guard then
490
+ falls back to a direct string compare).
491
+ """
492
+ raw = url.strip()
493
+ if not raw:
494
+ return None
495
+ parsed = urlsplit(raw)
496
+ if not parsed.scheme or not parsed.hostname:
497
+ return None
498
+ scheme = parsed.scheme.lower()
499
+ host = parsed.hostname.lower()
500
+ port = parsed.port
501
+ if port is None:
502
+ port = 443 if scheme == "https" else 80
503
+ if (scheme == "http" and port == 80) or (scheme == "https" and port == 443):
504
+ return f"{scheme}://{host}"
505
+ return f"{scheme}://{host}:{port}"
506
+
507
+
508
+ def _origins_match(a: str, b: str) -> bool:
509
+ norm_a = _normalize_api_origin(a)
510
+ norm_b = _normalize_api_origin(b)
511
+ if norm_a is not None and norm_b is not None:
512
+ return norm_a == norm_b
513
+ return a.rstrip("/") == b.rstrip("/")
514
+
515
+
516
+ def _rc_record(args: argparse.Namespace) -> RcRecord | None:
517
+ """Resolve the project-local ``.xmemrc.json`` (or ``None``); raise on a malformed one."""
518
+ try:
519
+ return resolve_rc(getattr(args, "rc_dir", None))
520
+ except RcError as exc:
521
+ raise UsageError(str(exc)) from exc
522
+
523
+
524
+ def _require(args: argparse.Namespace, *, need_instance: bool) -> tuple[str, str | None]:
525
+ # Precedence: --api-key → $XMEM_API_KEY → .xmemrc.json. Resolve the rc ONLY when neither
526
+ # explicit source supplies a key, so a malformed .xmemrc.json never breaks a command that
527
+ # already passed --api-key / set $XMEM_API_KEY (the explicit key wins before the rc is read).
528
+ explicit_key = args.api_key or os.environ.get(ENV_API_KEY)
529
+ rc = None if explicit_key else _rc_record(args)
530
+ api_key = explicit_key or (rc.api_key if rc else "")
531
+ if not api_key:
532
+ raise UsageError(f"Not logged in — run `{CLI_NAME} auth login` first.")
533
+ # localhost-key-at-prod guard: a key read from .xmemrc.json is bound to that file's api_url;
534
+ # refuse to send it to a different, explicitly-set origin (pass --api-key to override). Only
535
+ # reachable when the key came from the rc — `rc` is None whenever an explicit key was given.
536
+ if rc is not None:
537
+ explicit_url = args.api_url or os.environ.get(ENV_API_URL)
538
+ if explicit_url and not _origins_match(explicit_url, rc.api_url):
539
+ raise UsageError(
540
+ f"refusing to use the {RC_FILENAME} key (bound to {rc.api_url}) against "
541
+ f"{explicit_url}; pass --api-key to use a key against a different origin."
542
+ )
543
+ instance_id: str | None = None
544
+ if need_instance:
545
+ instance_id = args.instance_id or os.environ.get(ENV_INSTANCE_ID) or ""
546
+ if not instance_id:
547
+ resolved, auto_warn = _auto_resolve_single_instance_id(args, api_key)
548
+ if auto_warn:
549
+ _note_cli_warning(args, auto_warn)
550
+ instance_id = resolved or ""
551
+ if not instance_id:
552
+ raise UsageError(f"instance id not provided. Pass --instance-id or set {ENV_INSTANCE_ID}.")
553
+ return api_key, instance_id
554
+
555
+
556
+ def _api_url(args: argparse.Namespace) -> str:
557
+ # Precedence: --api-url → $XMEM_API_URL → .xmemrc.json api_url → built-in default.
558
+ explicit_url = args.api_url or os.environ.get(ENV_API_URL)
559
+ if explicit_url:
560
+ return _normalize_api_origin(explicit_url) or explicit_url.rstrip("/")
561
+ # No explicit origin. The rc supplies the default origin ONLY when the rc is also the
562
+ # credential source — i.e. when no explicit key was given. With an explicit --api-key /
563
+ # $XMEM_API_KEY the rc is irrelevant (its api_url binds only the rc's own key), and a
564
+ # malformed .xmemrc.json must not block such a command — so fall back to the built-in
565
+ # default WITHOUT reading the rc. This mirrors the same explicit-key escape hatch in
566
+ # _require(), so `--api-key` + a broken rc + no --api-url resolves cleanly end to end.
567
+ if args.api_key or os.environ.get(ENV_API_KEY):
568
+ return _normalize_api_origin(DEFAULT_API_URL) or DEFAULT_API_URL.rstrip("/")
569
+ rc = _rc_record(args)
570
+ fallback = (rc.api_url if rc else None) or DEFAULT_API_URL
571
+ return _normalize_api_origin(fallback) or fallback.rstrip("/")
572
+
573
+
574
+ def _explicit_api_url(args: argparse.Namespace) -> str | None:
575
+ """Origin from flags/env only — never from ``.xmemrc.json``."""
576
+ explicit = args.api_url or os.environ.get(ENV_API_URL)
577
+ if not explicit:
578
+ return None
579
+ return _normalize_api_origin(explicit) or explicit.rstrip("/")
580
+
581
+
582
+ def _rc_dir(args: argparse.Namespace) -> Path:
583
+ return Path(getattr(args, "rc_dir", None) or os.environ.get(ENV_RC_DIR) or os.getcwd())
584
+
585
+
586
+ # ── Subcommand handlers ────────────────────────────────────────────────────
587
+ # Each handler returns ``(CliOutput, exit_code)``. ``main`` is responsible
588
+ # for emitting the CliOutput on stdout — handlers never print directly.
589
+
590
+
591
+ def cmd_write(args: argparse.Namespace) -> tuple[CliOutput, int]:
592
+ api_key, instance_id = _require(args, need_instance=True)
593
+ base = _api_url(args)
594
+ payload: dict[str, Any] = {"text": args.text, "extraction_logic": args.extraction}
595
+ url_path = "write_async" if args.no_wait else "write"
596
+ kind = OutputKind.WRITE_ASYNC if args.no_wait else OutputKind.WRITE_SYNC
597
+ try:
598
+ envelope = _post(
599
+ f"{base}/instances/{instance_id}/{url_path}",
600
+ payload,
601
+ api_key,
602
+ timeout_s=30.0 if args.no_wait else 120.0,
603
+ )
604
+ except ApiError as e:
605
+ if e.status in (401, 403):
606
+ return (
607
+ make_error(
608
+ "write rejected: the key may not have write permission",
609
+ status=e.status,
610
+ stage="auth",
611
+ ),
612
+ EXIT_AUTH_ERROR,
613
+ )
614
+ quota_msg = _quota_exceeded_message(e)
615
+ if quota_msg is not None:
616
+ return (make_error(quota_msg, status=e.status or None, stage="quota"), EXIT_QUOTA_EXCEEDED)
617
+ return (make_error(str(e), status=e.status or None, stage="request"), EXIT_HTTP_ERROR)
618
+ try:
619
+ item = _unwrap_items(envelope)
620
+ except ApiError as e:
621
+ return (make_error(str(e), status=e.status or None, stage="request"), EXIT_HTTP_ERROR)
622
+ curated = _curate_write(item, sync=not args.no_wait, verbose=args.verbose)
623
+ return (CliOutput(kind, curated), EXIT_OK)
624
+
625
+
626
+ def _parse_scope_arg(raw: str) -> dict[str, Any]:
627
+ """Parse one ``--scope`` token ``TYPE:ID`` into a scope-object dict.
628
+
629
+ ``ID`` is a user-defined primary key when it contains ``=`` (one or more
630
+ ``field=value`` pairs, comma-separated), otherwise an xuid.
631
+ """
632
+ type_name, sep, identifier = raw.partition(":")
633
+ if not sep or not type_name.strip() or not identifier.strip():
634
+ raise ValueError(f"Invalid --scope {raw!r}; expected 'TYPE:<xuid>' or 'TYPE:field=value'.")
635
+ type_name = type_name.strip()
636
+ if "=" in identifier:
637
+ key: dict[str, str] = {}
638
+ for pair in identifier.split(","):
639
+ field, eq, value = pair.partition("=")
640
+ if not eq or not field.strip():
641
+ raise ValueError(f"Invalid --scope key {pair!r} in {raw!r}; expected 'field=value'.")
642
+ key[field.strip()] = value
643
+ return {"type": type_name, "key": {"key": key}}
644
+ return {"type": type_name, "key": {"xuid": identifier.strip()}}
645
+
646
+
647
+ def cmd_read(args: argparse.Namespace) -> tuple[CliOutput, int]:
648
+ api_key, instance_id = _require(args, need_instance=True)
649
+ base = _api_url(args)
650
+ mode_map = {
651
+ "single": ("single-answer", OutputKind.READ_SINGLE),
652
+ "raw": ("raw-tables", OutputKind.READ_RAW),
653
+ "xresponse": ("xresponse", OutputKind.READ_XRESPONSE),
654
+ }
655
+ api_mode, kind = mode_map[args.read_mode]
656
+ payload: dict[str, Any] = {"query": args.query, "mode": api_mode}
657
+ scope_args = getattr(args, "scope", None)
658
+ if scope_args:
659
+ try:
660
+ scope_objects = [_parse_scope_arg(raw) for raw in scope_args]
661
+ except ValueError as e:
662
+ return (make_error(str(e), stage="request"), EXIT_USAGE)
663
+ payload["scope"] = {
664
+ "objects": scope_objects,
665
+ "relations_scope": "all_relations" if getattr(args, "scope_relations", False) else "no_relations",
666
+ }
667
+ try:
668
+ envelope = _post(
669
+ f"{base}/instances/{instance_id}/read",
670
+ payload,
671
+ api_key,
672
+ timeout_s=120.0,
673
+ )
674
+ except ApiError as e:
675
+ if e.status in (401, 403):
676
+ return (
677
+ make_error(
678
+ "read rejected: check the API key permissions",
679
+ status=e.status,
680
+ stage="auth",
681
+ ),
682
+ EXIT_AUTH_ERROR,
683
+ )
684
+ quota_msg = _quota_exceeded_message(e)
685
+ if quota_msg is not None:
686
+ return (make_error(quota_msg, status=e.status or None, stage="quota"), EXIT_QUOTA_EXCEEDED)
687
+ return (make_error(str(e), status=e.status or None, stage="request"), EXIT_HTTP_ERROR)
688
+ try:
689
+ item = _unwrap_items(envelope)
690
+ except ApiError as e:
691
+ return (make_error(str(e), status=e.status or None, stage="request"), EXIT_HTTP_ERROR)
692
+ curated = _curate_read(item, read_mode=args.read_mode, verbose=args.verbose)
693
+ return (CliOutput(kind, curated), EXIT_OK)
694
+
695
+
696
+ def cmd_write_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
697
+ """Poll one or more queued async writes until terminal or timeout.
698
+
699
+ The bulk shape is intentional: ``xmem write --no-wait`` is the way to
700
+ push many facts quickly, so we want the operator to be able to "wait for
701
+ all of them" in a single command — without having to script a shell
702
+ loop with retries. We poll the ids concurrently with a thread pool so a
703
+ bulk of three writes finishes in roughly ``max(individual_latencies)``
704
+ rather than the sum.
705
+
706
+ Output:
707
+
708
+ - One id → emits the single status object (same shape as the
709
+ per-write payload).
710
+ - Multiple → emits a list of status objects, one per requested id,
711
+ preserving the order the user passed them.
712
+
713
+ Either way it is exactly one JSON document on stdout in JSON mode.
714
+ """
715
+ api_key, instance_id = _require(args, need_instance=True)
716
+ base = _api_url(args)
717
+ deadline = time.monotonic() + args.timeout
718
+
719
+ def poll(write_id: str) -> dict[str, Any]:
720
+ """Block until ``write_id`` reaches a terminal status (or deadline)."""
721
+ last_status = "unknown"
722
+ last_body: dict[str, Any] = {"write_id": write_id, "write_status": last_status}
723
+ while True:
724
+ try:
725
+ envelope = _post(
726
+ f"{base}/instances/{instance_id}/write_status",
727
+ {"write_id": write_id},
728
+ api_key,
729
+ timeout_s=30.0,
730
+ )
731
+ except ApiError as e:
732
+ # Carry the error inline so a single failing id doesn't kill
733
+ # the bulk call — the other writes' final statuses are still
734
+ # useful to the caller.
735
+ return {
736
+ "write_id": write_id,
737
+ "write_status": "error",
738
+ "error": str(e),
739
+ "http_status": e.status,
740
+ }
741
+ try:
742
+ item = _unwrap_items(envelope)
743
+ except ApiError as e:
744
+ return {
745
+ "write_id": write_id,
746
+ "write_status": "error",
747
+ "error": str(e),
748
+ "http_status": e.status,
749
+ }
750
+ last_status = item.get("write_status", "unknown")
751
+ last_body = item
752
+ if last_status in ("completed", "failed", "not_found"):
753
+ return item
754
+ if time.monotonic() >= deadline:
755
+ last_body["timed_out"] = True
756
+ return last_body
757
+ time.sleep(0.5)
758
+
759
+ write_ids: list[str] = list(args.write_ids)
760
+ if len(write_ids) == 1:
761
+ result = poll(write_ids[0])
762
+ kind = OutputKind.WRITE_STATUS
763
+ exit_code = EXIT_OK if result.get("write_status") == "completed" else EXIT_HTTP_ERROR
764
+ if result.get("timed_out"):
765
+ exit_code = EXIT_TIMEOUT
766
+ return (CliOutput(kind, _curate_write_status(result, args.verbose)), exit_code)
767
+
768
+ # Bulk: poll in parallel, preserve input order, decide exit code from
769
+ # the worst individual outcome (timeout > error > failed > completed).
770
+ results: list[dict[str, Any]] = [{} for _ in write_ids]
771
+ with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(write_ids), 8)) as pool:
772
+ futures = {pool.submit(poll, wid): i for i, wid in enumerate(write_ids)}
773
+ for fut in concurrent.futures.as_completed(futures):
774
+ results[futures[fut]] = fut.result()
775
+
776
+ statuses = {r.get("write_status", "unknown") for r in results}
777
+ if any(r.get("timed_out") for r in results):
778
+ exit_code = EXIT_TIMEOUT
779
+ elif "error" in statuses or "failed" in statuses or "not_found" in statuses:
780
+ exit_code = EXIT_HTTP_ERROR
781
+ else:
782
+ exit_code = EXIT_OK if statuses == {"completed"} else EXIT_HTTP_ERROR
783
+ curated = [_curate_write_status(r, args.verbose) for r in results]
784
+ return (CliOutput(OutputKind.WRITE_STATUS, curated), exit_code)
785
+
786
+
787
+
788
+ def cmd_auth_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
789
+ """``xmem auth status`` — report whether the CLI holds a credential and if it resolves.
790
+
791
+ No credential (after ``auth logout``, or before ``auth login``) →
792
+ ``{authenticated: false, logged_in: false}``, exit ``0``.
793
+
794
+ Credential present but ``/whoami`` returns ``AuthNone`` →
795
+ ``{authenticated: false, logged_in: true, key_prefix}``, exit ``EXIT_AUTH_ERROR``.
796
+
797
+ Valid key → ``{authenticated: true, email, key_prefix}``, exit ``0``.
798
+ """
799
+ explicit_key = args.api_key or os.environ.get(ENV_API_KEY)
800
+ rc = None if explicit_key else _rc_record(args)
801
+ api_key = explicit_key or (rc.api_key if rc else "")
802
+ if not api_key:
803
+ return (
804
+ CliOutput(OutputKind.AUTH_STATUS, {"authenticated": False, "logged_in": False}),
805
+ EXIT_OK,
806
+ )
807
+
808
+ if rc is not None:
809
+ explicit_url = args.api_url or os.environ.get(ENV_API_URL)
810
+ if explicit_url and not _origins_match(explicit_url, rc.api_url):
811
+ return (
812
+ make_error(
813
+ f"refusing to use the {RC_FILENAME} key (bound to {rc.api_url}) against "
814
+ f"{explicit_url}; pass --api-key to use a key against a different origin.",
815
+ stage="usage",
816
+ ),
817
+ EXIT_USAGE,
818
+ )
819
+
820
+ base = _api_url(args)
821
+ console = _console_url_for_args(args)
822
+ try:
823
+ whoami = _fetch_whoami(base, api_key, console_url=console)
824
+ except ApiError as e:
825
+ if e.status in (401, 403):
826
+ return (
827
+ make_error("auth status rejected: check the key", status=e.status, stage="auth"),
828
+ EXIT_AUTH_ERROR,
829
+ )
830
+ return (make_error(f"/whoami failed: {e}", status=e.status), EXIT_HTTP_ERROR)
831
+ auth_api_key = whoami.get("AuthApiKey")
832
+ if not isinstance(auth_api_key, dict) or not auth_api_key.get("email"):
833
+ return (
834
+ CliOutput(
835
+ OutputKind.AUTH_STATUS,
836
+ {"authenticated": False, "logged_in": True, "key_prefix": api_key[:8]},
837
+ ),
838
+ EXIT_AUTH_ERROR,
839
+ )
840
+ return (
841
+ CliOutput(
842
+ OutputKind.AUTH_STATUS,
843
+ {
844
+ "authenticated": True,
845
+ "logged_in": True,
846
+ "email": auth_api_key.get("email"),
847
+ "key_prefix": api_key[:8],
848
+ },
849
+ ),
850
+ EXIT_OK,
851
+ )
852
+
853
+
854
+ def cmd_auth_login(args: argparse.Namespace) -> tuple[CliOutput, int]:
855
+ """``xmem auth login`` — PKCE handoff via the Console, store in ``.xmemrc.json``."""
856
+ explicit_api = _explicit_api_url(args) or os.environ.get(ENV_API_URL, "").strip()
857
+ console = _console_url_for_args(args)
858
+ api_origin = explicit_api or DEFAULT_API_URL
859
+ base = _normalize_api_origin(api_origin) or api_origin.rstrip("/")
860
+ verifier = _login.new_verifier()
861
+ interactive = stdout_is_tty()
862
+ offer_paste = interactive and _login.paste_from_stdin_supported()
863
+
864
+ def on_url(url: str) -> None:
865
+ _login.write_login_handoff_prompt(url, offer_paste=offer_paste)
866
+ if not args.no_browser:
867
+ try:
868
+ webbrowser.open(url)
869
+ except OSError:
870
+ pass
871
+
872
+ try:
873
+ result = _login.run_pkce_login(
874
+ base,
875
+ console_url=console,
876
+ verifier=verifier,
877
+ on_url=on_url,
878
+ timeout_s=args.timeout,
879
+ interactive=interactive,
880
+ notify=_eprint,
881
+ )
882
+ except (_login.LoginAborted, KeyboardInterrupt):
883
+ return (CliOutput(OutputKind.LOGIN, {"status": "aborted"}), EXIT_INTERRUPTED)
884
+ except _login.LoginTimeout as e:
885
+ return (make_error(str(e), stage="login"), EXIT_TIMEOUT)
886
+ except _login.LoginError as e:
887
+ return (make_error(str(e), stage="login"), EXIT_USAGE)
888
+
889
+ if not result.email:
890
+ return (
891
+ make_error("login did not return a validated account email", stage="login"),
892
+ EXIT_USAGE,
893
+ )
894
+
895
+ target_dir = _rc_dir(args)
896
+ rc_path = write_rc(target_dir, email=result.email, api_url=base, api_key=result.api_key)
897
+ warning = gitignore_warning(rc_path)
898
+ if warning:
899
+ _eprint(f"warning: {warning}")
900
+ env_key = os.environ.get(ENV_API_KEY, "").strip()
901
+ if env_key and env_key != result.api_key:
902
+ _eprint(
903
+ f"warning: {ENV_API_KEY} is set (prefix {env_key[:8]}…) and overrides the key "
904
+ f"just saved to {rc_path}; unset it or update it to use the new key."
905
+ )
906
+ payload: dict[str, Any] = {
907
+ "status": "authorized",
908
+ "email": result.email,
909
+ "api_url": base,
910
+ "key_prefix": result.key_prefix,
911
+ "rc_file": str(rc_path),
912
+ }
913
+ return (CliOutput(OutputKind.LOGIN, payload), EXIT_OK)
914
+
915
+
916
+ def cmd_auth_logout(args: argparse.Namespace) -> tuple[CliOutput, int]:
917
+ """``xmem auth logout`` — remove the active ``.xmemrc.json``."""
918
+ try:
919
+ path = find_rc(_rc_dir(args))
920
+ if path is None:
921
+ return (CliOutput(OutputKind.LOGIN, {"status": "not_logged_in"}), EXIT_OK)
922
+ load_rc(path)
923
+ except RcError as exc:
924
+ return (make_error(str(exc), stage="usage"), EXIT_USAGE)
925
+ remove_rc(path)
926
+ return (CliOutput(OutputKind.LOGIN, {"status": "logged_out", "rc_file": str(path)}), EXIT_OK)
927
+
928
+
929
+ def _operator_cluster_override(args: argparse.Namespace) -> str | None:
930
+ """Advanced cluster override for operators (``$XMEM_CLUSTER_ID`` or suppressed ``--cluster-id``)."""
931
+ explicit = getattr(args, "cluster_id", None) or os.environ.get(ENV_CLUSTER_ID)
932
+ return str(explicit) if explicit else None
933
+
934
+
935
+ def _cluster_id_from_instance_context(
936
+ args: argparse.Namespace, base: str, api_key: str
937
+ ) -> tuple[str | None, tuple[CliOutput, int] | None]:
938
+ """Derive cluster from ``--instance-id`` / ``$XMEM_INSTANCE_ID`` via ``GET /instances/{id}``."""
939
+ instance_id = getattr(args, "instance_id", None) or os.environ.get(ENV_INSTANCE_ID)
940
+ if not instance_id:
941
+ return None, None
942
+ try:
943
+ envelope = _get(f"{base.rstrip('/')}/instances/{instance_id}", api_key)
944
+ except ApiError as e:
945
+ if e.status in (401, 403):
946
+ return None, (
947
+ make_error("instance lookup rejected: check the key", status=e.status, stage="auth"),
948
+ EXIT_AUTH_ERROR,
949
+ )
950
+ if e.status == 404:
951
+ return None, (
952
+ make_error(
953
+ f"instance {instance_id} not found or not visible to this key",
954
+ status=e.status,
955
+ stage="usage",
956
+ ),
957
+ EXIT_USAGE,
958
+ )
959
+ return None, (
960
+ make_error(f"could not load instance {instance_id}: {e}", status=e.status or None, stage="request"),
961
+ EXIT_HTTP_ERROR,
962
+ )
963
+ err = batch_envelope_error(envelope)
964
+ if err:
965
+ return None, (make_error(f"instance lookup failed: {err}", stage="request"), EXIT_HTTP_ERROR)
966
+ items = envelope.get("items") or []
967
+ if not items or not isinstance(items[0], dict):
968
+ return None, (
969
+ make_error(f"instance {instance_id} response missing cluster", stage="request"),
970
+ EXIT_HTTP_ERROR,
971
+ )
972
+ cluster_id = items[0].get("cluster_id")
973
+ if not cluster_id:
974
+ return None, (
975
+ make_error(f"instance {instance_id} response missing cluster_id", stage="request"),
976
+ EXIT_HTTP_ERROR,
977
+ )
978
+ return str(cluster_id), None
979
+
980
+
981
+ def _resolve_cluster_id(
982
+ args: argparse.Namespace, base: str, api_key: str
983
+ ) -> tuple[str | None, tuple[CliOutput, int] | None]:
984
+ """Resolve cluster for cluster-scoped calls (e.g. ``quota get``).
985
+
986
+ Order: operator override → instance context → sole visible cluster.
987
+ Agent-facing errors steer toward ``--instance-id``, not cluster ID
988
+ (see ``maxims/API-CLIENTS.yml`` — *One addressing axis for agents*).
989
+ """
990
+ override = _operator_cluster_override(args)
991
+ if override:
992
+ return override, None
993
+ from_instance, instance_err = _cluster_id_from_instance_context(args, base, api_key)
994
+ if instance_err is not None:
995
+ return None, instance_err
996
+ if from_instance:
997
+ return from_instance, None
998
+ try:
999
+ envelope = _get(f"{base}/clusters", api_key)
1000
+ except ApiError as e:
1001
+ if e.status in (401, 403):
1002
+ return None, (
1003
+ make_error("cluster lookup rejected: check the key", status=e.status, stage="auth"),
1004
+ EXIT_AUTH_ERROR,
1005
+ )
1006
+ return None, (
1007
+ make_error(f"could not list clusters: {e}", status=e.status or None, stage="request"),
1008
+ EXIT_HTTP_ERROR,
1009
+ )
1010
+ err = batch_envelope_error(envelope)
1011
+ if err:
1012
+ return None, (make_error(f"cluster lookup failed: {err}", stage="request"), EXIT_HTTP_ERROR)
1013
+ clusters = envelope.get("items") or []
1014
+ ids = [c["id"] for c in clusters if isinstance(c, dict) and c.get("id")]
1015
+ if len(ids) == 1:
1016
+ return str(ids[0]), None
1017
+ if not ids:
1018
+ return None, (
1019
+ make_error(
1020
+ f"no clusters visible to this key; pass --instance-id or set {ENV_INSTANCE_ID}",
1021
+ stage="usage",
1022
+ ),
1023
+ EXIT_USAGE,
1024
+ )
1025
+ return None, (
1026
+ make_error(
1027
+ f"{len(ids)} clusters visible; pass --instance-id or set {ENV_INSTANCE_ID} to pin an instance",
1028
+ stage="usage",
1029
+ ),
1030
+ EXIT_USAGE,
1031
+ )
1032
+
1033
+
1034
+ def _flatten_quota_row(row: dict[str, Any]) -> dict[str, Any]:
1035
+ """Lift nested ``usage`` (per-quota GET) into the flat shape ``_curate_quota_row`` expects."""
1036
+ usage = row.get("usage")
1037
+ if not isinstance(usage, dict):
1038
+ return row
1039
+ flat = dict(row)
1040
+ for key in ("window_start", "used_tokens", "remaining_tokens"):
1041
+ if key in usage:
1042
+ flat[key] = usage[key]
1043
+ return flat
1044
+
1045
+
1046
+ def _fetch_single_quota_row(
1047
+ base: str, cluster_id: str, api_key: str, item: dict[str, Any]
1048
+ ) -> tuple[dict[str, Any], tuple[CliOutput, int] | None]:
1049
+ """Fetch one quota row with live usage (list endpoint omits counters)."""
1050
+ quota_id = item.get("id")
1051
+ if quota_id is None:
1052
+ return item, None
1053
+ try:
1054
+ detail_env = _get(f"{base}/clusters/{cluster_id}/quotas/{quota_id}", api_key)
1055
+ except ApiError as e:
1056
+ if e.status in (401, 403):
1057
+ return item, (
1058
+ make_error("quota read rejected: check the key permissions", status=e.status, stage="auth"),
1059
+ EXIT_AUTH_ERROR,
1060
+ )
1061
+ return item, (make_error(str(e), status=e.status or None, stage="request"), EXIT_HTTP_ERROR)
1062
+ detail_err = batch_envelope_error(detail_env)
1063
+ if detail_err:
1064
+ return item, (make_error(f"quota read failed: {detail_err}", stage="request"), EXIT_HTTP_ERROR)
1065
+ detail_items = detail_env.get("items")
1066
+ if isinstance(detail_items, list) and detail_items and isinstance(detail_items[0], dict):
1067
+ return detail_items[0], None
1068
+ return item, None
1069
+
1070
+
1071
+ def _fetch_cluster_quota_rows(
1072
+ base: str, cluster_id: str, api_key: str
1073
+ ) -> tuple[list[dict[str, Any]], tuple[CliOutput, int] | None]:
1074
+ """Return quota rows with current-window usage for ``quota get``.
1075
+
1076
+ ``GET /clusters/{id}/quotas`` (verbose list) omits usage.
1077
+ Each quota needs a detail GET for live window counters.
1078
+ Conscious N+1 (we are not fixing this in PR2): there is no batch usage endpoint.
1079
+ Typical clusters expose a small handful of rows; a few sequential round-trips are fine for ``quota get``.
1080
+ Parallel fetch would shave wall-clock marginally while adding executor complexity.
1081
+ Revisit if the list endpoint starts returning usage.
1082
+ """
1083
+ try:
1084
+ envelope = _get(f"{base}/clusters/{cluster_id}/quotas?verbose=true", api_key)
1085
+ except ApiError as e:
1086
+ if e.status in (401, 403):
1087
+ return [], (
1088
+ make_error("quota read rejected: check the key permissions", status=e.status, stage="auth"),
1089
+ EXIT_AUTH_ERROR,
1090
+ )
1091
+ return [], (make_error(str(e), status=e.status or None, stage="request"), EXIT_HTTP_ERROR)
1092
+ err = batch_envelope_error(envelope)
1093
+ if err:
1094
+ return [], (make_error(f"quota list failed: {err}", stage="request"), EXIT_HTTP_ERROR)
1095
+ listed = envelope.get("items") or []
1096
+ detailed: list[dict[str, Any]] = []
1097
+ for item in listed:
1098
+ if not isinstance(item, dict):
1099
+ continue
1100
+ row, fetch_err = _fetch_single_quota_row(base, cluster_id, api_key, item)
1101
+ if fetch_err is not None:
1102
+ return [], fetch_err
1103
+ detailed.append(row)
1104
+ return detailed, None
1105
+
1106
+
1107
+ def _curate_quota_row(row: dict[str, Any]) -> dict[str, Any]:
1108
+ window = row.get("quota_window") or row.get("window")
1109
+ limit = row.get("token_limit")
1110
+ used = row.get("used_tokens", row.get("tokens_used", row.get("used")))
1111
+ window_start = row.get("window_start")
1112
+ remaining: int | float | None = None
1113
+ usage_ratio: float | None = None
1114
+ near_limit = False
1115
+ server_remaining = row.get("remaining_tokens")
1116
+ if isinstance(server_remaining, (int, float)) and not isinstance(server_remaining, bool):
1117
+ remaining = server_remaining
1118
+ # Reject bool JSON values — ``True``/``False`` are ints in Python and would skew arithmetic.
1119
+ if (
1120
+ isinstance(limit, (int, float))
1121
+ and not isinstance(limit, bool)
1122
+ and isinstance(used, (int, float))
1123
+ and not isinstance(used, bool)
1124
+ ):
1125
+ limit_num: int | float = limit
1126
+ used_num: int | float = used
1127
+ if remaining is None:
1128
+ remaining = limit_num - used_num
1129
+ if limit_num > 0:
1130
+ usage_ratio = round(used_num / limit_num, 4)
1131
+ near_limit = usage_ratio >= _QUOTA_WARN_RATIO
1132
+ return {
1133
+ "quota_window": window,
1134
+ "token_limit": limit,
1135
+ "used_tokens": used,
1136
+ "remaining_tokens": remaining,
1137
+ "usage_ratio": usage_ratio,
1138
+ "near_limit": near_limit,
1139
+ "window_start": window_start,
1140
+ }
1141
+
1142
+
1143
+ def cmd_quota_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
1144
+ """``xmem quota`` — usage / limit / window start for the key's cluster."""
1145
+ api_key, _ = _require(args, need_instance=False)
1146
+ base = _api_url(args)
1147
+ cluster_id, err = _resolve_cluster_id(args, base, api_key)
1148
+ if err is not None:
1149
+ return err
1150
+ if cluster_id is None:
1151
+ return (
1152
+ make_error(
1153
+ f"cluster could not be resolved; pass --instance-id or set {ENV_INSTANCE_ID}",
1154
+ stage="usage",
1155
+ ),
1156
+ EXIT_USAGE,
1157
+ )
1158
+ rows, fetch_err = _fetch_cluster_quota_rows(base, cluster_id, api_key)
1159
+ if fetch_err is not None:
1160
+ return fetch_err
1161
+ curated = [_curate_quota_row(_flatten_quota_row(r)) for r in rows]
1162
+ fail_threshold: float | None = None
1163
+ raw_fail = getattr(args, "fail_if_under", None)
1164
+ if raw_fail is not None:
1165
+ try:
1166
+ fail_threshold = _parse_remaining_threshold(str(raw_fail))
1167
+ except ValueError:
1168
+ return (
1169
+ make_error(
1170
+ f"invalid --fail-if-under value {raw_fail!r}; use a fraction (0.1) or percent (10%)",
1171
+ stage="usage",
1172
+ ),
1173
+ EXIT_USAGE,
1174
+ )
1175
+ exit_code = EXIT_OK
1176
+ for row in curated:
1177
+ if row.get("near_limit"):
1178
+ pct = round((row.get("usage_ratio") or 0) * 100)
1179
+ _eprint(
1180
+ f"warning: the {row.get('quota_window') or 'quota'} window is {pct}% used "
1181
+ f"({row.get('used_tokens')} / {row.get('token_limit')} tokens)."
1182
+ )
1183
+ if fail_threshold is not None:
1184
+ limit = row.get("token_limit")
1185
+ remaining = row.get("remaining_tokens")
1186
+ if (
1187
+ isinstance(limit, (int, float))
1188
+ and not isinstance(limit, bool)
1189
+ and limit > 0
1190
+ and isinstance(remaining, (int, float))
1191
+ and not isinstance(remaining, bool)
1192
+ and (remaining / limit) < fail_threshold
1193
+ ):
1194
+ exit_code = EXIT_QUOTA_EXCEEDED
1195
+ return (CliOutput(OutputKind.QUOTA, curated), exit_code)
1196
+
1197
+
1198
+ def cmd_version(_args: argparse.Namespace) -> tuple[CliOutput, int]:
1199
+ """``xmem version`` — print the CLI release version (no network)."""
1200
+ return (CliOutput(OutputKind.VERSION, {"version": __version__}), EXIT_OK)
1201
+
1202
+
1203
+ def cmd_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
1204
+ """``xmem status`` — local release health: version + credential file presence (no network)."""
1205
+ rc_path = find_rc(_rc_dir(args))
1206
+ payload: dict[str, Any] = {
1207
+ "version": __version__,
1208
+ "cli": CLI_NAME,
1209
+ "authenticated": False,
1210
+ "status": "not_logged_in",
1211
+ }
1212
+ if rc_path is not None:
1213
+ payload["rc_file"] = str(rc_path)
1214
+ try:
1215
+ rc = load_rc(rc_path)
1216
+ except RcError as exc:
1217
+ payload["status"] = "invalid_rc"
1218
+ payload["error"] = str(exc)
1219
+ else:
1220
+ payload["authenticated"] = True
1221
+ payload["status"] = "credentials_present"
1222
+ payload["email"] = rc.email
1223
+ payload["api_url"] = rc.api_url
1224
+ payload["key_prefix"] = rc.api_key[:8]
1225
+ return (CliOutput(OutputKind.STATUS, payload), EXIT_OK)
1226
+
1227
+
1228
+ from ._instance_schema import ( # noqa: E402 — after ``xmem`` helpers; avoids import cycle at module start
1229
+ cmd_instance_create,
1230
+ cmd_instance_delete,
1231
+ cmd_instance_get,
1232
+ cmd_schema_dry_run,
1233
+ cmd_schema_get,
1234
+ cmd_schema_migrations_get,
1235
+ cmd_schema_migrations_list,
1236
+ cmd_schema_update,
1237
+ cmd_xmd_convert,
1238
+ cmd_xmd_enhance,
1239
+ cmd_xmd_generate,
1240
+ cmd_xmd_validate,
1241
+ cmd_xmd_validate_json,
1242
+ cmd_xmd_validate_yaml,
1243
+ )
1244
+ from ._org_cluster import ( # noqa: E402
1245
+ cmd_org_list_clusters,
1246
+ cmd_org_list_instances,
1247
+ cmd_org_list_keys,
1248
+ )
1249
+
1250
+
1251
+ # ── argparse plumbing ──────────────────────────────────────────────────────
1252
+
1253
+
1254
+ def _build_parser() -> argparse.ArgumentParser:
1255
+ p = _help.XmemCliArgumentParser(
1256
+ prog=CLI_NAME,
1257
+ description="xmemcli — CLI for the xmemory HTTP API (read/write, instance ops, key list).",
1258
+ )
1259
+ p.add_argument("--api-url", help=f"API origin (default ${ENV_API_URL} or {DEFAULT_API_URL})")
1260
+ p.add_argument("--api-key", help=f"API key (default ${ENV_API_KEY})")
1261
+ p.add_argument("--instance-id", help=f"Target instance id (default ${ENV_INSTANCE_ID})")
1262
+ p.add_argument(
1263
+ "--rc-dir",
1264
+ help=f"Directory for {RC_FILENAME} (auth login writes here; reads walk up to $HOME)",
1265
+ )
1266
+ # Operator-only override; omitted from --help (maxims/API-CLIENTS.yml — One addressing axis for agents).
1267
+ p.add_argument("--cluster-id", help=argparse.SUPPRESS)
1268
+ p.add_argument(
1269
+ "--json",
1270
+ action="store_true",
1271
+ help=(
1272
+ "Force JSON output (a single JSON document) even when stdout is "
1273
+ "an interactive terminal. The default for a TTY is a short "
1274
+ "human-readable summary; the default for anything else (pipe, "
1275
+ "redirect, captured subprocess) is already JSON."
1276
+ ),
1277
+ )
1278
+ p.add_argument(
1279
+ "--verbose",
1280
+ action="store_true",
1281
+ help=(
1282
+ "Include extra context in the output (trace_id, console_url) "
1283
+ "when the server provides it. Server internals (SQL, diff "
1284
+ "plans) are never shown, verbose or not."
1285
+ ),
1286
+ )
1287
+
1288
+ sub = p.add_subparsers(dest="command", required=True, parser_class=_help.XmemCliArgumentParser)
1289
+
1290
+ pw = sub.add_parser("write", help="Write a fact to the active instance", add_help=False)
1291
+ pw.add_argument("text", help="Text to write")
1292
+ pw.add_argument(
1293
+ "--extraction",
1294
+ choices=["fast", "deep"],
1295
+ default="fast",
1296
+ help="extraction_logic on the API (default: fast)",
1297
+ )
1298
+ pw.add_argument(
1299
+ "--no-wait",
1300
+ action="store_true",
1301
+ help=(
1302
+ "Queue the write via write_async and return immediately with "
1303
+ f"the assigned write_id. Use `{CLI_NAME} write-status "
1304
+ "<write_id>` to wait for the queued write to land."
1305
+ ),
1306
+ )
1307
+ pw.set_defaults(func=cmd_write)
1308
+
1309
+ pr = sub.add_parser("read", help="Read from the active instance", add_help=False)
1310
+ pr.add_argument("query", help="Free-form query")
1311
+ pr.add_argument(
1312
+ "--read-mode",
1313
+ choices=["single", "raw", "xresponse"],
1314
+ default="single",
1315
+ help=(
1316
+ "Read mode passed to the API: single→single-answer, "
1317
+ "raw→raw-tables, xresponse→xresponse (default: single). "
1318
+ "This is the SERVER-side answer shape — output format "
1319
+ "(JSON vs pretty) is governed by --json / the TTY rule."
1320
+ ),
1321
+ )
1322
+ pr.add_argument(
1323
+ "--scope",
1324
+ action="append",
1325
+ metavar="TYPE:ID",
1326
+ help=(
1327
+ "Restrict the read to a concrete object (repeatable). Identify it by "
1328
+ "xuid — 'Person:<uuid>' — or by user-defined primary key — "
1329
+ "'Person:name=Alice' (composite: 'Order:customer=Alice,sku=ABC'). "
1330
+ "When any --scope is given the reader only sees/queries those objects."
1331
+ ),
1332
+ )
1333
+ pr.add_argument(
1334
+ "--scope-relations",
1335
+ action="store_true",
1336
+ help="With --scope, also expose relations among the in-scope objects (opt-in).",
1337
+ )
1338
+ pr.set_defaults(func=cmd_read)
1339
+
1340
+ ps = sub.add_parser(
1341
+ "write-status",
1342
+ help="Poll async writes until they complete (one or more write_ids).",
1343
+ add_help=False,
1344
+ )
1345
+ ps.add_argument(
1346
+ "write_ids",
1347
+ nargs="+",
1348
+ help=f"One or more write_ids returned by `{CLI_NAME} write --no-wait`",
1349
+ )
1350
+ ps.add_argument(
1351
+ "--timeout",
1352
+ type=float,
1353
+ default=60.0,
1354
+ help="Seconds to wait per id before giving up (default: 60)",
1355
+ )
1356
+ ps.set_defaults(func=cmd_write_status)
1357
+
1358
+ sub.add_parser("version", help="Print the CLI release version (no network)", add_help=False).set_defaults(
1359
+ func=cmd_version
1360
+ )
1361
+ sub.add_parser(
1362
+ "status",
1363
+ help="Local health: version and whether .xmemrc.json is present (no network)",
1364
+ add_help=False,
1365
+ ).set_defaults(func=cmd_status)
1366
+
1367
+ po = sub.add_parser("org", help="Org-scoped inventory (permission-filtered account view)", add_help=False)
1368
+ po_sub = po.add_subparsers(dest="org_command", required=True, parser_class=_help.XmemCliArgumentParser)
1369
+ pol = po_sub.add_parser("list", help="List org resources visible to this credential", add_help=False)
1370
+ pol_sub = pol.add_subparsers(dest="org_list_target", required=True, parser_class=_help.XmemCliArgumentParser)
1371
+ pol_sub.add_parser("clusters", help="List clusters visible to this credential", add_help=False).set_defaults(
1372
+ func=cmd_org_list_clusters
1373
+ )
1374
+ poli = pol_sub.add_parser(
1375
+ "instances",
1376
+ help="List instances visible to this credential",
1377
+ add_help=False,
1378
+ )
1379
+ poli.add_argument("--cluster", help=argparse.SUPPRESS)
1380
+ poli.set_defaults(func=cmd_org_list_instances)
1381
+ pol_sub.add_parser("keys", help="List API keys visible to this credential", add_help=False).set_defaults(
1382
+ func=cmd_org_list_keys
1383
+ )
1384
+
1385
+ pi = sub.add_parser("instance", help="Instance operations (list, get, create, delete, schema)", add_help=False)
1386
+ pi_sub = pi.add_subparsers(dest="instance_command", required=True, parser_class=_help.XmemCliArgumentParser)
1387
+ pil = pi_sub.add_parser(
1388
+ "list",
1389
+ help="List instances visible to this credential",
1390
+ add_help=False,
1391
+ )
1392
+ pil.set_defaults(func=cmd_org_list_instances)
1393
+ pig = pi_sub.add_parser("get", help="Get a single instance by id", add_help=False)
1394
+ pig.add_argument("instance_id_arg", metavar="instance_id", help="Instance id to fetch")
1395
+ pig.add_argument("--cluster", help=argparse.SUPPRESS)
1396
+ pig.set_defaults(func=cmd_instance_get)
1397
+ pic = pi_sub.add_parser(
1398
+ "create",
1399
+ help="Create a new instance from an XMD/JSON schema",
1400
+ add_help=False,
1401
+ )
1402
+ pic.add_argument("--name", required=True, help="Instance name")
1403
+ pic.add_argument("--description", help="Free-form description (optional)")
1404
+ pic.add_argument("--schema-file", help="Path to an XMD/JSON schema file")
1405
+ pic.add_argument(
1406
+ "--schema-stdin",
1407
+ action="store_true",
1408
+ help="Read the schema from stdin (mutually exclusive with --schema-file)",
1409
+ )
1410
+ pic.add_argument(
1411
+ "--schema-type",
1412
+ choices=["yaml", "json"],
1413
+ help="Schema format (default: inferred from file extension; falls back to yaml)",
1414
+ )
1415
+ pic.set_defaults(func=cmd_instance_create)
1416
+ pid = pi_sub.add_parser(
1417
+ "delete",
1418
+ help=argparse.SUPPRESS,
1419
+ add_help=False,
1420
+ )
1421
+ pid.add_argument(
1422
+ "instance_id_arg",
1423
+ metavar="instance_id",
1424
+ nargs="?",
1425
+ help=f"Instance id (default ${ENV_INSTANCE_ID} or --instance-id)",
1426
+ )
1427
+ pid.set_defaults(func=cmd_instance_delete)
1428
+
1429
+ psch = sub.add_parser(
1430
+ "schema",
1431
+ help="Live-instance schema get / update / dry-run / migration history",
1432
+ add_help=False,
1433
+ )
1434
+ psch_sub = psch.add_subparsers(dest="schema_command", required=True, parser_class=_help.XmemCliArgumentParser)
1435
+ pschg = psch_sub.add_parser("get", help="Get the live schema of an instance", add_help=False)
1436
+ pschg.add_argument(
1437
+ "instance_id_arg",
1438
+ metavar="instance_id",
1439
+ nargs="?",
1440
+ help=f"Instance id (default ${ENV_INSTANCE_ID} or --instance-id)",
1441
+ )
1442
+ pschg.add_argument("--output", "-o", help="Write the XMD schema (YAML) to this file")
1443
+ pschg.set_defaults(func=cmd_schema_get)
1444
+ pschu = psch_sub.add_parser("update", help="Replace the schema of an existing instance", add_help=False)
1445
+ pschu.add_argument(
1446
+ "instance_id_arg",
1447
+ metavar="instance_id",
1448
+ nargs="?",
1449
+ help=f"Instance id (default ${ENV_INSTANCE_ID} or --instance-id)",
1450
+ )
1451
+ pschu.add_argument("--schema-file", required=True, help="Path to the new XMD/JSON schema")
1452
+ pschu.add_argument(
1453
+ "--schema-type",
1454
+ choices=["yaml", "json"],
1455
+ help="Schema format (default: inferred from file extension; falls back to yaml)",
1456
+ )
1457
+ pschu.add_argument(
1458
+ "--migration-plan",
1459
+ help="Path to migration_plan JSON (from `xmd enhance --plan-output`)",
1460
+ )
1461
+ pschu.add_argument(
1462
+ "--confirm-destructive",
1463
+ action="store_true",
1464
+ help="Required acknowledgment for destructive ops (drops, lossy casts)",
1465
+ )
1466
+ pschu.set_defaults(func=cmd_schema_update)
1467
+ pschd = psch_sub.add_parser(
1468
+ "dry-run",
1469
+ help="Preview what schema update would do without applying (MCP dry_run_schema_migration)",
1470
+ add_help=False,
1471
+ )
1472
+ pschd.add_argument(
1473
+ "instance_id_arg",
1474
+ metavar="instance_id",
1475
+ nargs="?",
1476
+ help=f"Instance id (default ${ENV_INSTANCE_ID} or --instance-id)",
1477
+ )
1478
+ pschd.add_argument("--schema-file", required=True, help="Path to the candidate XMD/JSON schema")
1479
+ pschd.add_argument(
1480
+ "--schema-type",
1481
+ choices=["yaml", "json"],
1482
+ help="Schema format (default: inferred from file extension; falls back to yaml)",
1483
+ )
1484
+ pschd.add_argument(
1485
+ "--migration-plan",
1486
+ help="Path to migration_plan JSON (from `xmd enhance --plan-output`)",
1487
+ )
1488
+ pschd.add_argument(
1489
+ "--confirm-destructive",
1490
+ action="store_true",
1491
+ help="Preview destructive ops without the destructive gate rejecting",
1492
+ )
1493
+ pschd.set_defaults(func=cmd_schema_dry_run)
1494
+ pschm = psch_sub.add_parser(
1495
+ "migrations",
1496
+ help="Applied schema migration history for an instance",
1497
+ add_help=False,
1498
+ )
1499
+ pschm_sub = pschm.add_subparsers(
1500
+ dest="schema_migrations_command",
1501
+ required=True,
1502
+ parser_class=_help.XmemCliArgumentParser,
1503
+ )
1504
+ pschml = pschm_sub.add_parser(
1505
+ "list",
1506
+ help="List applied migrations, newest first (MCP list_schema_migrations)",
1507
+ add_help=False,
1508
+ )
1509
+ pschml.add_argument(
1510
+ "instance_id_arg",
1511
+ metavar="instance_id",
1512
+ nargs="?",
1513
+ help=f"Instance id (default ${ENV_INSTANCE_ID} or --instance-id)",
1514
+ )
1515
+ pschml.add_argument("--limit", type=int, default=50, help="Max records (1-200, default 50)")
1516
+ pschml.add_argument(
1517
+ "--before-id",
1518
+ help="Pagination cursor: return migrations applied before this migration id",
1519
+ )
1520
+ pschml.add_argument(
1521
+ "--include-yaml",
1522
+ action="store_true",
1523
+ help="Include before/after YAML snapshots (can be large)",
1524
+ )
1525
+ pschml.set_defaults(func=cmd_schema_migrations_list)
1526
+ pschmg = pschm_sub.add_parser(
1527
+ "get",
1528
+ help="Fetch one migration record by id (MCP get_schema_migration)",
1529
+ add_help=False,
1530
+ )
1531
+ pschmg.add_argument(
1532
+ "migration_id_arg",
1533
+ metavar="migration_id",
1534
+ help="UUID of the migration record (from `schema migrations list`)",
1535
+ )
1536
+ pschmg.add_argument(
1537
+ "--include-yaml",
1538
+ action="store_true",
1539
+ help="Include before/after YAML snapshots for the record",
1540
+ )
1541
+ pschmg.set_defaults(func=cmd_schema_migrations_get)
1542
+
1543
+ px = sub.add_parser("xmd", help="Work with XMD (xmemory Data) schemas", add_help=False)
1544
+ px_sub = px.add_subparsers(dest="xmd_command", required=True, parser_class=_help.XmemCliArgumentParser)
1545
+ pxg = px_sub.add_parser("generate", help="Synthesize an XMD schema from a description", add_help=False)
1546
+ pxg.add_argument("description", help="Natural-language description of what to store")
1547
+ pxg.add_argument("--output", "-o", help="Write the YAML schema to this file")
1548
+ pxg.set_defaults(func=cmd_xmd_generate)
1549
+ pxe = px_sub.add_parser(
1550
+ "enhance",
1551
+ help="Enhance (augment) an existing XMD schema",
1552
+ add_help=False,
1553
+ )
1554
+ pxe.add_argument(
1555
+ "schema_file",
1556
+ nargs="?",
1557
+ help="Path to the existing schema (.yml/.yaml/.json); omit with --from-instance",
1558
+ )
1559
+ pxe.add_argument("description", help="Natural-language description of the change")
1560
+ pxe.add_argument(
1561
+ "--from-instance",
1562
+ action="store_true",
1563
+ help="Use live schema from instance (MCP enhance_schema on bound instance)",
1564
+ )
1565
+ pxe.add_argument("--output", "-o", help="Write the enhanced YAML to this file")
1566
+ pxe.add_argument(
1567
+ "--plan-output",
1568
+ help="Write migration_plan JSON (feed to `schema update --migration-plan`)",
1569
+ )
1570
+ pxe.set_defaults(func=cmd_xmd_enhance)
1571
+ pxv = px_sub.add_parser(
1572
+ "validate",
1573
+ help="Validate an XMD schema (format inferred from .json extension, else YAML)",
1574
+ add_help=False,
1575
+ )
1576
+ pxv.add_argument("schema_file", help="Path to schema (.yml/.yaml/.json)")
1577
+ pxv.set_defaults(func=cmd_xmd_validate)
1578
+ pxvy = px_sub.add_parser("validate-yaml", help="Validate an XMD YAML schema against the server", add_help=False)
1579
+ pxvy.add_argument("schema_file", help="Path to schema (.yml/.yaml)")
1580
+ pxvy.set_defaults(func=cmd_xmd_validate_yaml)
1581
+ pxvj = px_sub.add_parser("validate-json", help="Validate a JSON Schema against the server", add_help=False)
1582
+ pxvj.add_argument("schema_file", help="Path to a JSON Schema file (.json)")
1583
+ pxvj.set_defaults(func=cmd_xmd_validate_json)
1584
+ pxc = px_sub.add_parser(
1585
+ "convert",
1586
+ help="Convert JSON Schema to XMD YAML via the API (validate_json_schema)",
1587
+ add_help=False,
1588
+ )
1589
+ pxc.add_argument("schema_file", help="Path to a JSON Schema file (.json)")
1590
+ pxc.add_argument("--output", "-o", help="Write XMD YAML to this file (default: same basename with .yml)")
1591
+ pxc.add_argument(
1592
+ "--root-object",
1593
+ dest="root_object",
1594
+ help="Root object name for the converted schema (default: MainObject)",
1595
+ )
1596
+ pxc.set_defaults(func=cmd_xmd_convert)
1597
+
1598
+ pa = sub.add_parser("auth", help="Acquire / drop / inspect credentials (.xmemrc.json)", add_help=False)
1599
+ pa_sub = pa.add_subparsers(dest="auth_command", required=True, parser_class=_help.XmemCliArgumentParser)
1600
+ pal = pa_sub.add_parser(
1601
+ "login",
1602
+ help="Acquire a key via the Console PKCE handoff → .xmemrc.json (key never printed)",
1603
+ add_help=False,
1604
+ )
1605
+ pal.add_argument("--no-browser", action="store_true", help="Print the URL only; do not open a browser")
1606
+ pal.add_argument("--timeout", type=float, default=300.0, help="Seconds to wait for the browser (default: 300)")
1607
+ pal.add_argument(
1608
+ "--console-url",
1609
+ help=f"Console origin (default ${ENV_CONSOLE_URL} or {_login.DEFAULT_CONSOLE_URL})",
1610
+ )
1611
+ pal.set_defaults(func=cmd_auth_login)
1612
+ palo = pa_sub.add_parser("logout", help="Remove the credential from the active .xmemrc.json", add_help=False)
1613
+ palo.set_defaults(func=cmd_auth_logout)
1614
+ pas = pa_sub.add_parser("status", help="Report whose key the CLI is holding (via /whoami)", add_help=False)
1615
+ pas.set_defaults(func=cmd_auth_status)
1616
+
1617
+ pq = sub.add_parser(
1618
+ "quota",
1619
+ help="Show usage / limit / window start for the key's cluster",
1620
+ add_help=False,
1621
+ )
1622
+ pq.add_argument(
1623
+ "--fail-if-under",
1624
+ metavar="THRESHOLD",
1625
+ help=(
1626
+ "Exit non-zero when remaining quota falls below THRESHOLD of the limit "
1627
+ "(fraction 0.1 or whole-number percent like 10). Default: exit 0 whenever quota is readable."
1628
+ ),
1629
+ )
1630
+ pq.set_defaults(func=cmd_quota_get)
1631
+
1632
+ return p
1633
+
1634
+
1635
+ _WRITE_STATUS_ALIASES = frozenset({
1636
+ "write_status",
1637
+ "writestatus",
1638
+ "async_write_status",
1639
+ "asyncwritestatus",
1640
+ "async-write-status",
1641
+ })
1642
+
1643
+ # Silent auth shortcuts — normalized before parse; not listed in the overview.
1644
+ _AUTH_LOGIN_ALIASES = frozenset({"login", "signin"})
1645
+ _AUTH_LOGOUT_ALIASES = frozenset({"logout", "logoff", "signout"})
1646
+
1647
+
1648
+ def _parse_remaining_threshold(raw: str) -> float:
1649
+ """Parse ``--fail-if-under`` value: ``0.1`` or ``10%`` → minimum remaining fraction."""
1650
+ text = raw.strip()
1651
+ if text.endswith("%"):
1652
+ value = float(text[:-1].strip())
1653
+ return value / 100.0
1654
+ value = float(text)
1655
+ if value > 1:
1656
+ return value / 100.0
1657
+ return value
1658
+
1659
+
1660
+ def _auto_resolve_single_instance_id(args: argparse.Namespace, api_key: str) -> tuple[str | None, str | None]:
1661
+ """When exactly one instance is visible, use it and return a warning message."""
1662
+ base = _api_url(args)
1663
+ try:
1664
+ envelope = _get(f"{base}/instances?verbose=true", api_key)
1665
+ except ApiError:
1666
+ return None, None
1667
+ err = batch_envelope_error(envelope)
1668
+ if err:
1669
+ return None, None
1670
+ items = [i for i in (envelope.get("items") or []) if isinstance(i, dict) and i.get("id")]
1671
+ if len(items) != 1:
1672
+ return None, None
1673
+ instance_id = str(items[0]["id"])
1674
+ name = items[0].get("name") or instance_id
1675
+ warning = (
1676
+ f"no --instance-id / ${ENV_INSTANCE_ID}; using the only visible instance "
1677
+ f"{instance_id} ({name})."
1678
+ )
1679
+ return instance_id, warning
1680
+
1681
+
1682
+ def _normalize_auth_argv(stripped: list[str]) -> list[str] | None:
1683
+ """Map silent login/logout spellings to ``auth login`` / ``auth logout``."""
1684
+ if not stripped:
1685
+ return None
1686
+ head, *rest = stripped
1687
+ if head in _AUTH_LOGIN_ALIASES:
1688
+ return ["auth", "login", *rest]
1689
+ if head in _AUTH_LOGOUT_ALIASES:
1690
+ return ["auth", "logout", *rest]
1691
+ if head == "sign" and rest:
1692
+ if rest[0] == "in":
1693
+ return ["auth", "login", *rest[1:]]
1694
+ if rest[0] == "out":
1695
+ return ["auth", "logout", *rest[1:]]
1696
+ if head == "log" and rest:
1697
+ if rest[0] == "in":
1698
+ return ["auth", "login", *rest[1:]]
1699
+ if rest[0] == "out":
1700
+ return ["auth", "logout", *rest[1:]]
1701
+ if head == "auth" and rest:
1702
+ sub, *tail = rest[0], *rest[1:]
1703
+ if sub in _AUTH_LOGIN_ALIASES:
1704
+ return ["auth", "login", *tail]
1705
+ if sub in _AUTH_LOGOUT_ALIASES:
1706
+ return ["auth", "logout", *tail]
1707
+ if sub == "sign" and tail:
1708
+ if tail[0] == "in":
1709
+ return ["auth", "login", *tail[1:]]
1710
+ if tail[0] == "out":
1711
+ return ["auth", "logout", *tail[1:]]
1712
+ if sub == "log" and tail:
1713
+ if tail[0] == "in":
1714
+ return ["auth", "login", *tail[1:]]
1715
+ if tail[0] == "out":
1716
+ return ["auth", "logout", *tail[1:]]
1717
+ return None
1718
+
1719
+
1720
+ def _normalize_argv(argv: list[str]) -> list[str]:
1721
+ """Map silent command aliases to canonical parser paths (after global flags)."""
1722
+ stripped = _help.argv_without_globals(argv)
1723
+ if not stripped:
1724
+ return stripped
1725
+ if stripped[0] in _WRITE_STATUS_ALIASES:
1726
+ return ["write-status", *stripped[1:]]
1727
+ if len(stripped) >= 2 and stripped[0] == "quota" and stripped[1] == "get":
1728
+ return ["quota", *stripped[2:]]
1729
+ auth_normalized = _normalize_auth_argv(stripped)
1730
+ if auth_normalized is not None:
1731
+ return auth_normalized
1732
+ return stripped
1733
+
1734
+
1735
+ def _global_prefix(argv: list[str]) -> list[str]:
1736
+ """Return leading global flags from ``argv`` (preserving order)."""
1737
+ out: list[str] = []
1738
+ i = 0
1739
+ while i < len(argv):
1740
+ tok = argv[i]
1741
+ if tok in _help.GLOBAL_STORE_TRUE_FLAGS:
1742
+ out.append(tok)
1743
+ i += 1
1744
+ continue
1745
+ if tok in _help.GLOBAL_OPTION_FLAGS:
1746
+ out.append(tok)
1747
+ if i + 1 < len(argv) and not argv[i + 1].startswith("-"):
1748
+ out.append(argv[i + 1])
1749
+ i += 2
1750
+ else:
1751
+ i += 1
1752
+ continue
1753
+ break
1754
+ return out
1755
+
1756
+
1757
+ def _parse_argv(argv: list[str]) -> list[str]:
1758
+ """``argv`` for :func:`argparse.ArgumentParser.parse_args` — globals + normalized command."""
1759
+ globals_part, stripped = _help.collect_global_flags(argv)
1760
+ command = _normalize_argv(stripped)
1761
+ if not command:
1762
+ return globals_part
1763
+ return globals_part + command
1764
+
1765
+
1766
+ def _emit_help(
1767
+ parser: argparse.ArgumentParser,
1768
+ path: tuple[str, ...],
1769
+ *,
1770
+ machine_json: bool,
1771
+ ) -> None:
1772
+ doc = _help.build_help_document(parser, path)
1773
+ if machine_json:
1774
+ emit(CliOutput(OutputKind.HELP, doc), force_json=True)
1775
+ else:
1776
+ _help.render_help_document(doc)
1777
+
1778
+
1779
+ def main(argv: list[str] | None = None) -> int:
1780
+ argv_list = list(argv) if argv is not None else sys.argv[1:]
1781
+ parser = _build_parser()
1782
+ machine_json = "--json" in argv_list or not stdout_is_tty()
1783
+ command = _normalize_argv(argv_list)
1784
+ if not command:
1785
+ _emit_help(parser, (), machine_json=machine_json)
1786
+ return EXIT_OK
1787
+ help_path = _help.help_target(command)
1788
+ if help_path is not None:
1789
+ _emit_help(parser, help_path, machine_json=machine_json)
1790
+ return EXIT_OK
1791
+ try:
1792
+ args = parser.parse_args(_parse_argv(argv_list))
1793
+ except _help.CliParseError as exc:
1794
+ if machine_json:
1795
+ emit(make_error(exc.message, stage="usage"), force_json=True)
1796
+ else:
1797
+ _help.print_parse_error(exc.message)
1798
+ return EXIT_USAGE
1799
+ except SystemExit as exc:
1800
+ code = int(exc.code) if isinstance(exc.code, int) else EXIT_USAGE
1801
+ if machine_json:
1802
+ if code != 0:
1803
+ emit(make_error("invalid usage", stage="usage"), force_json=True)
1804
+ else:
1805
+ emit(CliOutput(OutputKind.STATUS, {"ok": True}), force_json=True)
1806
+ return code if code != 0 else EXIT_OK
1807
+ try:
1808
+ output, exit_code = args.func(args)
1809
+ except UsageError as exc:
1810
+ # Pre-flight resolution errors (missing key, malformed rc, …) still go
1811
+ # through emit() so machine mode gets a single JSON ERROR document.
1812
+ emit(_with_cli_warnings(exc.output, args, force_json=machine_json or args.json), force_json=machine_json or args.json)
1813
+ return exc.exit_code
1814
+ except KeyboardInterrupt:
1815
+ if machine_json or args.json:
1816
+ emit(make_error("interrupted", stage="interrupt"), force_json=True)
1817
+ else:
1818
+ _eprint("Interrupted.")
1819
+ return EXIT_INTERRUPTED
1820
+ force_json = machine_json or args.json
1821
+ emit(_with_cli_warnings(output, args, force_json=force_json), force_json=force_json)
1822
+ return exit_code
1823
+
1824
+
1825
+ if __name__ == "__main__":
1826
+ sys.exit(main())