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/__init__.py +3 -0
- xmemcli/_envelope.py +24 -0
- xmemcli/_help.py +720 -0
- xmemcli/_instance_schema.py +769 -0
- xmemcli/_login.py +593 -0
- xmemcli/_org_cluster.py +70 -0
- xmemcli/_output.py +274 -0
- xmemcli/_pretty.py +373 -0
- xmemcli/_rc.py +192 -0
- xmemcli/_version.py +4 -0
- xmemcli/_xmem_bridge.py +33 -0
- xmemcli/cli.py +1826 -0
- xmemcli-0.0.1.dist-info/METADATA +22 -0
- xmemcli-0.0.1.dist-info/RECORD +16 -0
- xmemcli-0.0.1.dist-info/WHEEL +4 -0
- xmemcli-0.0.1.dist-info/entry_points.txt +2 -0
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())
|