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/_output.py ADDED
@@ -0,0 +1,274 @@
1
+ """Tagged-union output payload + dispatch shared by every ``xmem`` subcommand.
2
+
3
+ Why this lives in its own module
4
+ --------------------------------
5
+
6
+ The CLI has two output personalities that the user-facing surface needs to
7
+ keep strictly separated:
8
+
9
+ 1. **Machine consumption** — any time stdout is *not* attached to a terminal
10
+ (a pipe, a shell redirect, CI capture, the Playwright runner reading
11
+ ``result.stdout``), or any time the human explicitly passes ``--json``.
12
+ In that mode every command MUST emit **exactly one** JSON document on
13
+ stdout, so a downstream consumer can rely on
14
+ ``xmem read … | jq .reader_result`` working unconditionally.
15
+ 2. **Human consumption** — the operator typed the command into an
16
+ interactive shell and did **not** pass ``--json``. In that mode the CLI
17
+ is allowed to format the payload into a short, opinionated, one-line (or
18
+ nearly-one-line) summary keyed off the *kind* of response.
19
+
20
+ To keep the dispatch logic in one place and force every new subcommand to
21
+ declare which family of output it produces, the commands no longer print
22
+ directly. They build a :class:`CliOutput` (a discriminated payload tagged
23
+ with an :class:`OutputKind`), and the entry point in ``cli/xmem.py`` decides
24
+ how to render it via :func:`emit`. The pretty-print branch lives in
25
+ ``cli/_pretty.py`` so the formatting code can grow without cluttering the
26
+ HTTP and argparse plumbing.
27
+
28
+ Invariants
29
+ ----------
30
+
31
+ - The default for any non-TTY stdout is JSON. There is no other default.
32
+ - ``--json`` forces JSON even on a TTY — always, unconditionally.
33
+ - Pretty-print is only ever reached when ``stdout.isatty()`` is true AND
34
+ ``--json`` was not passed. Anything else gets JSON.
35
+ - ``$XMEM_FORCE_TTY_OUTPUT_NOT_JSON`` is a **deliberate, documented escape hatch**
36
+ for manual E2E runbooks only (see :data:`ENV_FORCE_TTY_OUTPUT_NOT_JSON`). It does
37
+ not change server behavior, credentials, or API payloads — only how ``emit`` chooses
38
+ between JSON and pretty text. Agents, CI, and piped scripts must never set it;
39
+ they use ``--json`` or a real TTY. If unset (the normal case), the non-TTY⇒JSON
40
+ rule is unchanged.
41
+ - A failed command still emits exactly one JSON document on stdout when JSON
42
+ mode is in effect — :class:`OutputKind.ERROR` + a non-zero exit code. We
43
+ do not silently dump human prose to stderr in machine mode.
44
+ - The pretty-print renderers may fall back to JSON if they can't extract a
45
+ useful summary from the payload; that is intentional and keeps human
46
+ output truthful rather than guessing.
47
+
48
+ Wire format
49
+ -----------
50
+
51
+ The JSON document emitted by :func:`emit` is the CURATED payload built by
52
+ the subcommand (see the ``_curate_*`` helpers in ``cli/xmem.py``) — the
53
+ fields the user asked for, with server internals (SQL, diff plans, raw
54
+ extraction trees) stripped in both output modes. We do NOT wrap it in a
55
+ second envelope keyed by :class:`OutputKind` — adding a layer the consumer
56
+ didn't ask for would make scripts more fragile, not less. The kind is only
57
+ used internally for pretty-print dispatch.
58
+ """
59
+
60
+ from __future__ import annotations
61
+
62
+ import json
63
+ import os
64
+ import sys
65
+ from enum import StrEnum
66
+ from typing import IO, Any, NamedTuple
67
+
68
+
69
+ class OutputKind(StrEnum):
70
+ """Discriminator for what kind of payload a command produced.
71
+
72
+ Used purely for pretty-print dispatch. Has no effect on the JSON output —
73
+ the same enum value can produce wildly different JSON depending on the
74
+ server payload, and that's fine.
75
+ """
76
+
77
+ #: Synchronous write that completed (``POST …/write``). Curated payload:
78
+ #: ``write_id`` + ``extracted`` (compact ``Type(name)`` tags).
79
+ WRITE_SYNC = "write_sync"
80
+
81
+ #: Asynchronous write that was queued (``POST …/write_async``). Curated
82
+ #: payload: ``write_id``; the caller must poll.
83
+ WRITE_ASYNC = "write_async"
84
+
85
+ #: Terminal result of an ``async_write_status`` poll. Curated payload:
86
+ #: ``write_id`` + ``write_status`` in {``completed``, ``failed``,
87
+ #: ``not_found``} (+ ``error_detail`` / ``timed_out`` when applicable).
88
+ WRITE_STATUS = "write_status"
89
+
90
+ #: Read in ``mode=single-answer``. Curated payload: ``answer`` (falls
91
+ #: back to the reader_result subtree when no answer string exists).
92
+ READ_SINGLE = "read_single"
93
+
94
+ #: Read in ``mode=raw-tables``. Curated payload: the reader_result
95
+ #: subtree (columns + rows). Pretty-print renders as JSON because there
96
+ #: is no useful one-line summary.
97
+ READ_RAW = "read_raw"
98
+
99
+ #: Read in ``mode=xresponse``. Curated payload: the reader_result
100
+ #: subtree shaped as an XResponse (objects + relations). Pretty-print
101
+ #: renders as JSON for the same reason as :attr:`READ_RAW`.
102
+ READ_XRESPONSE = "read_xresponse"
103
+
104
+ #: Output of ``xmemcli org list keys``. Curated payload: a list of
105
+ #: ``{name, key_prefix, is_active}`` entries (full DTOs with
106
+ #: ``--verbose``).
107
+ KEY_LIST = "key_list"
108
+
109
+ #: Output of ``xmemcli org list clusters``.
110
+ CLUSTER_LIST = "cluster_list"
111
+
112
+ #: Output of ``xmem auth status``. Curated payload:
113
+ #: ``{authenticated: bool, key_prefix, email?}`` — whose key the CLI is
114
+ #: holding, resolved via ``/whoami``.
115
+ AUTH_STATUS = "auth_status"
116
+
117
+ #: Output of ``xmem auth login`` / ``xmem auth logout``.
118
+ LOGIN = "login"
119
+
120
+ #: Output of ``xmem version``.
121
+ VERSION = "version"
122
+
123
+ #: Local-only release health (``xmem status``) — no network.
124
+ STATUS = "status"
125
+
126
+ #: Output of ``xmemcli quota``. Curated payload: usage windows.
127
+ QUOTA = "quota"
128
+
129
+ #: Output of ``xmemcli org list instances`` (and ``instance list`` alias).
130
+ INSTANCE_LIST = "instance_list"
131
+
132
+ #: Output of ``xmem instance get``.
133
+ INSTANCE_GET = "instance_get"
134
+
135
+ #: Output of ``xmem instance create``.
136
+ INSTANCE_CREATE = "instance_create"
137
+
138
+ #: Output of ``xmem instance delete``.
139
+ INSTANCE_DELETE = "instance_delete"
140
+
141
+ #: Output of ``xmem schema update``.
142
+ SCHEMA_UPDATE = "schema_update"
143
+
144
+ #: Output of ``xmem schema dry-run``.
145
+ SCHEMA_DRY_RUN = "schema_dry_run"
146
+
147
+ #: Output of ``xmem schema migrations list`` / ``get``.
148
+ SCHEMA_MIGRATIONS = "schema_migrations"
149
+
150
+ #: Output of ``xmem xmd generate`` / ``enhance`` / ``validate`` / ``schema get``.
151
+ XMD_SCHEMA = "xmd_schema"
152
+
153
+ #: Output of ``xmem xmd enhance`` when a migration plan is included.
154
+ XMD_ENHANCE = "xmd_enhance"
155
+
156
+ #: Output of ``xmem xmd convert``.
157
+ XMD_CONVERT = "xmd_convert"
158
+
159
+ #: Structured help document from ``build_help_document`` (overview, options,
160
+ #: command topic, or unknown topic). Emitted when ``--json`` is set or stdout
161
+ #: is not a TTY — same JSON-by-default rule as data commands.
162
+ HELP = "help"
163
+
164
+ #: A failure surfaced as structured output. ``payload`` carries
165
+ #: ``{"error": str, "status": int|null, "stage": str}`` — see
166
+ #: :func:`make_error`.
167
+ ERROR = "error"
168
+
169
+
170
+ class CliOutput(NamedTuple):
171
+ """A command's structured result, ready for either renderer.
172
+
173
+ A ``typing.NamedTuple`` (immutable, typed, attribute-accessed) rather than
174
+ Pydantic or ``@dataclass``: this CLI is stdlib-only and standalone — it runs
175
+ without a ``pip install`` of the ``xmemory`` library (see ``cli/xmem.py``).
176
+
177
+ Construction is cheap; the only invariant is that ``payload`` must be
178
+ JSON-serialisable (dict / list / str / int / float / bool / None).
179
+ """
180
+
181
+ kind: OutputKind
182
+ payload: Any
183
+
184
+
185
+ def make_error(message: str, *, status: int | None = None, stage: str = "request") -> CliOutput:
186
+ """Construct an :class:`OutputKind.ERROR` payload.
187
+
188
+ ``status`` is the HTTP status code when the failure originated from a
189
+ server response; ``stage`` is a free-form label naming the step that
190
+ failed (``"request"``, ``"parse"``, ``"validate"``…). The pretty-print
191
+ renderer uses ``stage`` to decide how loudly to scream.
192
+ """
193
+ return CliOutput(
194
+ OutputKind.ERROR,
195
+ {"error": message, "status": status, "stage": stage},
196
+ )
197
+
198
+
199
+ # Deliberate escape hatch for manual harness runs with captured stdout — not for CI/scripts.
200
+ # See README.md ("Output personality"). ``--json`` still wins when passed.
201
+ ENV_FORCE_TTY_OUTPUT_NOT_JSON = "XMEM_FORCE_TTY_OUTPUT_NOT_JSON"
202
+
203
+
204
+ def _env_tty_override() -> bool | None:
205
+ raw = os.environ.get(ENV_FORCE_TTY_OUTPUT_NOT_JSON, "").strip().lower()
206
+ if not raw:
207
+ return None
208
+ if raw in ("1", "true", "yes", "on"):
209
+ return True
210
+ return None
211
+
212
+
213
+ def stdout_is_tty() -> bool:
214
+ """``True`` iff stdout is connected to an interactive terminal.
215
+
216
+ Wrapped in a function (not captured at module import time) so unit tests
217
+ can monkey-patch ``sys.stdout`` between cases without surprising us.
218
+ Returns ``False`` defensively if ``isatty`` raises — some pseudo-streams
219
+ don't implement it.
220
+
221
+ ``$XMEM_FORCE_TTY_OUTPUT_NOT_JSON`` — when set to a truthy value, pretend stdout is a
222
+ TTY so :func:`emit` uses pretty one-liners on piped/captured stdout. Manual Case 4
223
+ only; see :data:`ENV_FORCE_TTY_OUTPUT_NOT_JSON`. Does not apply when ``force_json``
224
+ is true (``--json``).
225
+ """
226
+ override = _env_tty_override()
227
+ if override is not None:
228
+ return override
229
+ try:
230
+ return sys.stdout.isatty()
231
+ except Exception:
232
+ return False
233
+
234
+
235
+ def emit(
236
+ out: CliOutput,
237
+ *,
238
+ force_json: bool,
239
+ is_tty: bool | None = None,
240
+ stream: IO[str] | None = None,
241
+ ) -> None:
242
+ """Render ``out`` to stdout (or ``stream``) using the JSON-by-default rule.
243
+
244
+ Parameters
245
+ ----------
246
+ out
247
+ The structured payload produced by a subcommand.
248
+ force_json
249
+ ``True`` when ``--json`` was passed. Pretty-print is suppressed
250
+ unconditionally and the renderer emits exactly one JSON document.
251
+ is_tty
252
+ Override for :func:`stdout_is_tty`. Tests can pass an explicit
253
+ boolean to exercise both branches without monkey-patching.
254
+ stream
255
+ Defaults to ``sys.stdout``. Tests pass ``io.StringIO()`` here to
256
+ capture output deterministically.
257
+
258
+ The JSON branch always terminates with a newline so log scrapers and
259
+ JSON-lines consumers behave identically. The pretty-print branch
260
+ delegates entirely to ``cli/_pretty.py`` and is only reached when the
261
+ user is sitting at a terminal AND did not opt into JSON.
262
+ """
263
+ out_stream = stream if stream is not None else sys.stdout
264
+ tty = stdout_is_tty() if is_tty is None else is_tty
265
+ if force_json or not tty:
266
+ json.dump(out.payload, out_stream, indent=2, default=str)
267
+ out_stream.write("\n")
268
+ return
269
+ # Lazy import so the JSON path stays import-cheap (the CLI is invoked
270
+ # hundreds of times per Playwright recording). Absolute import works
271
+ # because xmem.py runs as a script — its directory is on sys.path[0].
272
+ from . import _pretty # noqa: PLC0415
273
+
274
+ _pretty.render(out.kind, out.payload, out_stream)
xmemcli/_pretty.py ADDED
@@ -0,0 +1,373 @@
1
+ """Human-friendly renderers for :mod:`_output` payloads.
2
+
3
+ This module is **only** reached when the operator is sitting at a real
4
+ terminal (``sys.stdout.isatty()``) AND did not pass ``--json``. Every other
5
+ invocation gets strict JSON via :func:`_output.emit` — see that module's
6
+ docstring for the rationale.
7
+
8
+ The renderers are deliberately small, opinionated, and one-shot: they print
9
+ a single short summary (often a single line) keyed off the
10
+ :class:`~cli._output.OutputKind`. The principle is "give the operator the
11
+ headline they would have wanted to read aloud", not "render the entire
12
+ payload". If the renderer can't extract a useful headline, it falls through
13
+ to a JSON dump of the relevant sub-tree so the user still sees something
14
+ truthful rather than a guess.
15
+
16
+ Adding a new :class:`~cli._output.OutputKind` requires adding a renderer
17
+ here keyed by the enum value in :data:`_RENDERERS`. The default handler
18
+ emits the JSON payload, which is a safe (if uninspired) fallback.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from enum import Enum
25
+ from typing import IO, Any, Callable
26
+
27
+ from ._version import CLI_NAME
28
+
29
+ # Type alias so the dispatch table is self-documenting.
30
+ Renderer = Callable[[Any, IO[str]], None]
31
+
32
+
33
+ def render(kind: str | Enum, payload: Any, stream: IO[str]) -> None:
34
+ """Dispatch to a renderer by :class:`OutputKind`.
35
+
36
+ Unknown kinds (or any future kind without a registered renderer) fall
37
+ through to :func:`_default_handler`, which dumps the payload as JSON.
38
+ """
39
+ dispatch_key = kind.value if isinstance(kind, Enum) else kind
40
+ handler = _RENDERERS.get(dispatch_key, _default_handler)
41
+ handler(payload, stream)
42
+
43
+
44
+ # ── Per-kind renderers ────────────────────────────────────────────────────
45
+
46
+
47
+ def _render_write_sync(payload: Any, stream: IO[str]) -> None:
48
+ """One-liner for a completed synchronous write.
49
+
50
+ The headline is "write <id> ok · extracted: <obj-list>" — the curated
51
+ payload already reduced the extraction tree to compact ``Type(name)``
52
+ tags (see ``_summarize_extracted`` in ``cli/xmem.py``).
53
+ """
54
+ if not isinstance(payload, dict):
55
+ _default_handler(payload, stream)
56
+ return
57
+ wid = payload.get("write_id", "?")
58
+ parts = [p for p in (payload.get("extracted") or []) if isinstance(p, str)]
59
+ extracted = ", ".join(parts) or "(no objects extracted)"
60
+ stream.write(f"write {wid} ok · extracted: {extracted}\n")
61
+
62
+
63
+ def _render_write_async(payload: Any, stream: IO[str]) -> None:
64
+ """Async write was queued; surface the write_id for follow-up polling."""
65
+ if not isinstance(payload, dict):
66
+ _default_handler(payload, stream)
67
+ return
68
+ stream.write(f"write queued: {payload.get('write_id', '?')}\n")
69
+
70
+
71
+ def _render_write_status(payload: Any, stream: IO[str]) -> None:
72
+ """Terminal poll result — short status line, no body."""
73
+ if not isinstance(payload, dict):
74
+ _default_handler(payload, stream)
75
+ return
76
+ wid = payload.get("write_id", "?")
77
+ status = payload.get("write_status", "unknown")
78
+ stream.write(f"{wid}: {status}\n")
79
+
80
+
81
+ def _render_read_single(payload: Any, stream: IO[str]) -> None:
82
+ """Single-answer read — the answer text *is* the headline.
83
+
84
+ The curated payload carries ``answer`` when the reader produced one;
85
+ otherwise it is the reader_result subtree, which we dump as JSON.
86
+ Better to show structure than to invent.
87
+ """
88
+ if not isinstance(payload, dict):
89
+ _default_handler(payload, stream)
90
+ return
91
+ answer = payload.get("answer")
92
+ if isinstance(answer, str) and answer.strip():
93
+ stream.write(answer.strip() + "\n")
94
+ return
95
+ json.dump(payload, stream, indent=2, default=str)
96
+ stream.write("\n")
97
+
98
+
99
+ def _render_read_structured(payload: Any, stream: IO[str]) -> None:
100
+ """Raw-tables and xresponse reads — there is no useful one-line summary.
101
+
102
+ The curated payload IS the rows / xresponse the operator explicitly
103
+ asked for; dump it as JSON.
104
+ """
105
+ json.dump(payload, stream, indent=2, default=str)
106
+ stream.write("\n")
107
+
108
+
109
+ def _render_key_list(payload: Any, stream: IO[str]) -> None:
110
+ """``xmemcli org list keys`` — table of prefix / name / active.
111
+
112
+ Payload is the raw API items list (the envelope ``items`` field).
113
+ """
114
+ if not isinstance(payload, list):
115
+ _default_handler(payload, stream)
116
+ return
117
+ if not payload:
118
+ stream.write("(no keys)\n")
119
+ return
120
+ for k in payload:
121
+ if not isinstance(k, dict):
122
+ continue
123
+ prefix = k.get("key_prefix", "????????")
124
+ name = k.get("name", "(unnamed)")
125
+ active = k.get("is_active", "?")
126
+ stream.write(f"{prefix}… {name} active={active}\n")
127
+
128
+
129
+ def _render_auth_status(payload: Any, stream: IO[str]) -> None:
130
+ """``xmem auth status`` — one line on whose key the CLI is holding.
131
+
132
+ Payload is ``{authenticated, key_prefix, email?}``.
133
+ """
134
+ if not isinstance(payload, dict):
135
+ _default_handler(payload, stream)
136
+ return
137
+ prefix = payload.get("key_prefix") or "????????"
138
+ if payload.get("authenticated"):
139
+ email = payload.get("email") or "(unknown)"
140
+ stream.write(f"authenticated as {email} (key {prefix}…)\n")
141
+ elif payload.get("logged_in") is False:
142
+ stream.write("not logged in\n")
143
+ else:
144
+ stream.write(f"not authenticated — key {prefix}… is not recognized (unknown, inactive, or expired)\n")
145
+
146
+
147
+ def _render_login(payload: Any, stream: IO[str]) -> None:
148
+ """``xmem auth login`` / ``logout`` — one line for authorized or other status."""
149
+ if not isinstance(payload, dict):
150
+ _default_handler(payload, stream)
151
+ return
152
+ status = payload.get("status", "?")
153
+ if status == "authorized":
154
+ email = payload.get("email") or "(unknown account)"
155
+ prefix = payload.get("key_prefix") or "????????"
156
+ rc_file = payload.get("rc_file") or ".xmemrc.json"
157
+ stream.write("API key installed successfully.\n")
158
+ stream.write(f" Account: {email}\n")
159
+ stream.write(f" Key prefix: {prefix}…\n")
160
+ stream.write(f" Saved to: {rc_file}\n")
161
+ elif status == "aborted":
162
+ stream.write("Login aborted.\n")
163
+ else:
164
+ stream.write(f"auth {status}\n")
165
+
166
+
167
+ def _render_version(payload: Any, stream: IO[str]) -> None:
168
+ if not isinstance(payload, dict):
169
+ _default_handler(payload, stream)
170
+ return
171
+ stream.write(f"{CLI_NAME} {payload.get('version', '?')}\n")
172
+
173
+
174
+ def _render_status(payload: Any, stream: IO[str]) -> None:
175
+ if not isinstance(payload, dict):
176
+ _default_handler(payload, stream)
177
+ return
178
+ version = payload.get("version", "?")
179
+ status = payload.get("status", "?")
180
+ stream.write(f"{CLI_NAME} {version} · {status}\n")
181
+
182
+
183
+ def _render_instance_list(payload: Any, stream: IO[str]) -> None:
184
+ if not isinstance(payload, list):
185
+ _default_handler(payload, stream)
186
+ return
187
+ if not payload:
188
+ stream.write("(no instances)\n")
189
+ return
190
+ for row in payload:
191
+ if not isinstance(row, dict):
192
+ continue
193
+ iid = row.get("id", "?")
194
+ name = row.get("name", "(unnamed)")
195
+ stream.write(f"{iid} {name}\n")
196
+
197
+
198
+ def _render_instance_get(payload: Any, stream: IO[str]) -> None:
199
+ if not isinstance(payload, dict):
200
+ _default_handler(payload, stream)
201
+ return
202
+ iid = payload.get("id", "?")
203
+ name = payload.get("name", "(unnamed)")
204
+ stream.write(f"{iid} {name}\n")
205
+
206
+
207
+ def _render_instance_delete(payload: Any, stream: IO[str]) -> None:
208
+ if not isinstance(payload, dict):
209
+ _default_handler(payload, stream)
210
+ return
211
+ iid = payload.get("instance_id", "?")
212
+ name = payload.get("name", "")
213
+ if name:
214
+ stream.write(f"deleted {iid} {name}\n")
215
+ else:
216
+ stream.write(f"deleted {iid}\n")
217
+
218
+
219
+ def _render_instance_create(payload: Any, stream: IO[str]) -> None:
220
+ if not isinstance(payload, dict):
221
+ _default_handler(payload, stream)
222
+ return
223
+ iid = payload.get("instance_id", "?")
224
+ name = payload.get("name", "(unnamed)")
225
+ stream.write(f"created {iid} {name}\n")
226
+ url = payload.get("console_url")
227
+ if isinstance(url, str) and url.strip():
228
+ stream.write(f" console: {url.strip()}\n")
229
+
230
+
231
+ def _render_xmd_schema(payload: Any, stream: IO[str]) -> None:
232
+ if not isinstance(payload, dict):
233
+ _default_handler(payload, stream)
234
+ return
235
+ if payload.get("valid") is True:
236
+ stream.write("schema valid\n")
237
+ return
238
+ if payload.get("data_schema") is not None:
239
+ stream.write("schema generated\n")
240
+ return
241
+ _default_handler(payload, stream)
242
+
243
+
244
+ def _render_schema_dry_run(payload: Any, stream: IO[str]) -> None:
245
+ if isinstance(payload, dict) and payload.get("status") == "ok":
246
+ n = len(payload.get("statements") or [])
247
+ stream.write(f"migration dry-run ok ({n} statement{'s' if n != 1 else ''})\n")
248
+ return
249
+ _default_handler(payload, stream)
250
+
251
+
252
+ def _render_schema_migrations(payload: Any, stream: IO[str]) -> None:
253
+ if not isinstance(payload, dict):
254
+ _default_handler(payload, stream)
255
+ return
256
+ if isinstance(payload.get("record"), dict):
257
+ stream.write(f"migration {payload['record'].get('id', '?')}\n")
258
+ return
259
+ items = payload.get("items")
260
+ if isinstance(items, list):
261
+ stream.write(f"{len(items)} migration(s)\n")
262
+ return
263
+ _default_handler(payload, stream)
264
+
265
+
266
+ def _render_xmd_convert(payload: Any, stream: IO[str]) -> None:
267
+ if isinstance(payload, dict) and isinstance(payload.get("message"), str):
268
+ stream.write(payload["message"])
269
+ if not payload["message"].endswith("\n"):
270
+ stream.write("\n")
271
+ return
272
+ _default_handler(payload, stream)
273
+
274
+
275
+ def _render_quota(payload: Any, stream: IO[str]) -> None:
276
+ """``xmem quota get`` — one line per window: used/limit and server window start.
277
+
278
+ Reset instant is intentionally omitted — the API returns ``window_start`` only
279
+ (anchored billing windows are not inferable client-side).
280
+ """
281
+ if not isinstance(payload, list):
282
+ _default_handler(payload, stream)
283
+ return
284
+ if not payload:
285
+ stream.write("(no quotas)\n")
286
+ return
287
+ for row in payload:
288
+ if not isinstance(row, dict):
289
+ continue
290
+ window = row.get("quota_window") or "?"
291
+ used = row.get("used_tokens", "?")
292
+ limit = row.get("token_limit", "?")
293
+ started = row.get("window_start") or "?"
294
+ stream.write(f"{window}: {used}/{limit} used · window started {started}\n")
295
+
296
+
297
+ def _render_error(payload: Any, stream: IO[str]) -> None:
298
+ """Structured error — short red-ish line on stdout for the operator.
299
+
300
+ We deliberately write the human error to **stdout**, not stderr, in
301
+ pretty-print mode. The exit code already tells the shell something went
302
+ wrong; piping ``xmem read foo 2>&1`` is awkward, and operators are
303
+ already used to seeing errors inline. Machines never get here — they
304
+ see the JSON-mode error payload on stdout.
305
+ """
306
+ if not isinstance(payload, dict):
307
+ _default_handler(payload, stream)
308
+ return
309
+ msg = payload.get("error") or "(unspecified error)"
310
+ status = payload.get("status")
311
+ stage = payload.get("stage") or "request"
312
+ if stage == "usage":
313
+ stream.write(f"{msg}\n")
314
+ return
315
+ suffix = f" (HTTP {status})" if status else ""
316
+ stream.write(f"error [{stage}]: {msg}{suffix}\n")
317
+
318
+
319
+ def _render_cluster_list(payload: Any, stream: IO[str]) -> None:
320
+ if not isinstance(payload, list):
321
+ _default_handler(payload, stream)
322
+ return
323
+ if not payload:
324
+ stream.write("(no clusters)\n")
325
+ return
326
+ for row in payload:
327
+ if not isinstance(row, dict):
328
+ continue
329
+ cid = row.get("id", "?")
330
+ name = row.get("name", "(unnamed)")
331
+ stream.write(f"{cid} {name}\n")
332
+
333
+
334
+ def _default_handler(payload: Any, stream: IO[str]) -> None:
335
+ """Fallback when an OutputKind has no specific renderer.
336
+
337
+ Used for new kinds that haven't been wired up yet, and for malformed
338
+ payloads where the typed renderer would have crashed. Always emits the
339
+ raw JSON so the operator still sees the truth.
340
+ """
341
+ json.dump(payload, stream, indent=2, default=str)
342
+ stream.write("\n")
343
+
344
+
345
+ # ── Dispatch table ────────────────────────────────────────────────────────
346
+
347
+
348
+ _RENDERERS: dict[str, Renderer] = {
349
+ "write_sync": _render_write_sync,
350
+ "write_async": _render_write_async,
351
+ "write_status": _render_write_status,
352
+ "read_single": _render_read_single,
353
+ "read_raw": _render_read_structured,
354
+ "read_xresponse": _render_read_structured,
355
+ "key_list": _render_key_list,
356
+ "auth_status": _render_auth_status,
357
+ "login": _render_login,
358
+ "version": _render_version,
359
+ "status": _render_status,
360
+ "quota": _render_quota,
361
+ "cluster_list": _render_cluster_list,
362
+ "instance_list": _render_instance_list,
363
+ "instance_get": _render_instance_get,
364
+ "instance_create": _render_instance_create,
365
+ "instance_delete": _render_instance_delete,
366
+ "schema_update": _render_xmd_schema,
367
+ "schema_dry_run": _render_schema_dry_run,
368
+ "schema_migrations": _render_schema_migrations,
369
+ "xmd_schema": _render_xmd_schema,
370
+ "xmd_enhance": _render_xmd_schema,
371
+ "xmd_convert": _render_xmd_convert,
372
+ "error": _render_error,
373
+ }