xmemcli 1.3.0__tar.gz → 1.3.1__tar.gz

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.
@@ -0,0 +1 @@
1
+ .venv/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xmemcli
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
5
  Keywords: api,cli,llm,memory,xmemory
6
6
  Requires-Python: <4,>=3.12
@@ -128,7 +128,7 @@ xmemcli version
128
128
  xmemcli auth login
129
129
  ```
130
130
 
131
- Version: **1.3.0**.
131
+ Version: **1.3.1**.
132
132
 
133
133
 
134
134
  ---
@@ -228,7 +228,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
228
228
 
229
229
  # `xmemcli` — capabilities reference
230
230
 
231
- **Version:** `1.3.0` (see `xmemcli version`)
231
+ **Version:** `1.3.1` (see `xmemcli version`)
232
232
 
233
233
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
234
234
 
@@ -472,9 +472,9 @@ learn; the CLI composes the Console's search clauses itself.
472
472
 
473
473
  **Every operation row carries its internal ids.** `operations[]` rows have `operation_type`, `trace_id`, `instance_id`, `timestamp`, `status`, `is_success`, `error_code`, `session_id`, `user_id`, `user_email`, `api_key_id`, `api_key_name`, `api_key_prefix`, `time_taken_ms`, `text_snippet`, `object_names`, `field_names`.
474
474
 
475
- > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `sql_queries`, `ddl_queries`, and `schema_generation`.
475
+ > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `ddl_queries`, and `schema_generation`.
476
476
 
477
- **A scoped key gets `[]` for `sql_queries` / `ddl_queries` / `schema_generation`, not the rows.** Those three come from `read_sql_queries`, `instance_create_ddl_queries` and `schema_generate_*`, which carry no instance ownership column — `schema_generate_*`'s trace id is client-supplied — so they cannot be scoped in SQL and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran no SQL". `llm_calls[]` behaves the same way and always has. **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
477
+ **A scoped key gets `[]` for `ddl_queries` / `schema_generation`, not the rows.** Those two come from `instance_create_ddl_queries` and `schema_generate_*`, which carry no instance ownership column — `schema_generate_*`'s trace id is client-supplied — so they cannot be scoped in SQL and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran none". `llm_calls[]` behaves the same way and always has. `sql_queries` is not among them: `read_sql_queries` records the owning instance, so every caller who may open a read gets its SQL, attributed to that read even when another instance reused the trace id. **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
478
478
 
479
479
  **Who sees what — the Console decides; `auth status` tells you which view to expect.**
480
480
 
@@ -630,7 +630,7 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
630
630
 
631
631
  **Envelope errors** (HTTP **200** with non-empty `errors[]`) are not HTTP 401/403; they typically exit **3** with `stage: "request"`. Two exceptions. `QUOTA_EXCEEDED` exits **6** with `stage: "quota"`, the same as it does on a real HTTP 402 — the keepalive flattening does not change the exit code — and this applies to every command that classifies the error, not just `write` and `read`. `WRITE_STILL_PROCESSING` exits **8** with `stage: "processing"` and is not a failure at all; unlike the quota rung this one is **`write`-only**, because its guidance is to poll `write-status`, which is meaningless for any other verb — elsewhere it falls through to **3**/`request`.
632
632
 
633
- > **Known bug.** Some subcommands in the `instance`, `schema` and `xmd` groups — `instance get`, `schema get` and `xmd generate` among them — unwrap the response outside their error handling, so an envelope error never reaches the classifier and escapes as an uncaught exception: a traceback and shell exit **1**, which is not in the table above. Those report **1**, not the **6** this section describes, until that is fixed. It is not whole groups: `instance instructions`, for example, reaches the classifier and exits **6** as documented.
633
+ > **Known bug.** Some subcommands in the `instance` and `schema` groups — `instance get` and `schema get` among them — unwrap the response outside their error handling, so an envelope error never reaches the classifier and escapes as an uncaught exception: a traceback and shell exit **1**, which is not in the table above. Those report **1**, not the **6** this section describes, until that is fixed. It is not whole groups: `instance instructions`, for example, reaches the classifier and exits **6** as documented, and every heartbeat-wrapped command (`instance create`, `xmd generate`, `xmd enhance`, `schema update`, `schema dry-run`) unwraps inside its handler and exits **6**.
634
634
 
635
635
  **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
636
636
 
@@ -120,7 +120,7 @@ xmemcli version
120
120
  xmemcli auth login
121
121
  ```
122
122
 
123
- Version: **1.3.0**.
123
+ Version: **1.3.1**.
124
124
 
125
125
 
126
126
  ---
@@ -220,7 +220,7 @@ Each login binds its own port and names it in the login URL, so concurrent login
220
220
 
221
221
  # `xmemcli` — capabilities reference
222
222
 
223
- **Version:** `1.3.0` (see `xmemcli version`)
223
+ **Version:** `1.3.1` (see `xmemcli version`)
224
224
 
225
225
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff) or an approval of a sign-in email (`--email`). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
226
226
 
@@ -464,9 +464,9 @@ learn; the CLI composes the Console's search clauses itself.
464
464
 
465
465
  **Every operation row carries its internal ids.** `operations[]` rows have `operation_type`, `trace_id`, `instance_id`, `timestamp`, `status`, `is_success`, `error_code`, `session_id`, `user_id`, `user_email`, `api_key_id`, `api_key_name`, `api_key_prefix`, `time_taken_ms`, `text_snippet`, `object_names`, `field_names`.
466
466
 
467
- > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `sql_queries`, `ddl_queries`, and `schema_generation`.
467
+ > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and a `User:<email>` search clause matches nothing; filter by `user_id`. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects`, `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries`, and — **unrestricted callers only** (a super admin, or a browser session on a `KINGDOM_LOCAL` Console) — `llm_calls[]` (`llm_call_id`, `gateway_request_id`, `model_type`, `provider`, …), `full_error_message`, raw token counts, unsanitised xuids, `ddl_queries`, and `schema_generation`.
468
468
 
469
- **A scoped key gets `[]` for `sql_queries` / `ddl_queries` / `schema_generation`, not the rows.** Those three come from `read_sql_queries`, `instance_create_ddl_queries` and `schema_generate_*`, which carry no instance ownership column — `schema_generate_*`'s trace id is client-supplied — so they cannot be scoped in SQL and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran no SQL". `llm_calls[]` behaves the same way and always has. **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
469
+ **A scoped key gets `[]` for `ddl_queries` / `schema_generation`, not the rows.** Those two come from `instance_create_ddl_queries` and `schema_generate_*`, which carry no instance ownership column — `schema_generate_*`'s trace id is client-supplied — so they cannot be scoped in SQL and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran none". `llm_calls[]` behaves the same way and always has. `sql_queries` is not among them: `read_sql_queries` records the owning instance, so every caller who may open a read gets its SQL, attributed to that read even when another instance reused the trace id. **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
470
470
 
471
471
  **Who sees what — the Console decides; `auth status` tells you which view to expect.**
472
472
 
@@ -622,7 +622,7 @@ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not
622
622
 
623
623
  **Envelope errors** (HTTP **200** with non-empty `errors[]`) are not HTTP 401/403; they typically exit **3** with `stage: "request"`. Two exceptions. `QUOTA_EXCEEDED` exits **6** with `stage: "quota"`, the same as it does on a real HTTP 402 — the keepalive flattening does not change the exit code — and this applies to every command that classifies the error, not just `write` and `read`. `WRITE_STILL_PROCESSING` exits **8** with `stage: "processing"` and is not a failure at all; unlike the quota rung this one is **`write`-only**, because its guidance is to poll `write-status`, which is meaningless for any other verb — elsewhere it falls through to **3**/`request`.
624
624
 
625
- > **Known bug.** Some subcommands in the `instance`, `schema` and `xmd` groups — `instance get`, `schema get` and `xmd generate` among them — unwrap the response outside their error handling, so an envelope error never reaches the classifier and escapes as an uncaught exception: a traceback and shell exit **1**, which is not in the table above. Those report **1**, not the **6** this section describes, until that is fixed. It is not whole groups: `instance instructions`, for example, reaches the classifier and exits **6** as documented.
625
+ > **Known bug.** Some subcommands in the `instance` and `schema` groups — `instance get` and `schema get` among them — unwrap the response outside their error handling, so an envelope error never reaches the classifier and escapes as an uncaught exception: a traceback and shell exit **1**, which is not in the table above. Those report **1**, not the **6** this section describes, until that is fixed. It is not whole groups: `instance instructions`, for example, reaches the classifier and exits **6** as documented, and every heartbeat-wrapped command (`instance create`, `xmd generate`, `xmd enhance`, `schema update`, `schema dry-run`) unwraps inside its handler and exits **6**.
626
626
 
627
627
  **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
628
628
 
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "xmemcli"
3
+ version = "1.3.1"
4
+ description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12,<4"
7
+ dependencies = []
8
+ keywords = ["xmemory", "cli", "api", "memory", "llm"]
9
+
10
+ [project.scripts]
11
+ xmemcli = "xmemcli.cli:main"
12
+
13
+ [build-system]
14
+ requires = ["hatchling"]
15
+ build-backend = "hatchling.build"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["xmemcli"]
19
+
20
+ # The sdist names what it carries rather than what it excludes. Hatchling otherwise ships every
21
+ # file in the staging tree, which is how the smoke run's uv.lock reached PyPI in 1.3.0. The one
22
+ # file this list does not govern is .gitignore: hatchling force-includes the nearest one it finds
23
+ # walking up from the project root until a .git entry, so an sdist always carries one. The
24
+ # staging therefore writes its own, one line, or the archive would carry the monorepo's instead.
25
+ [tool.hatch.build.targets.sdist]
26
+ only-include = ["xmemcli", "README.md", "pyproject.toml"]
27
+
28
+ [tool.pytest.ini_options]
29
+ pythonpath = ["."]
30
+ testpaths = ["tests"]
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.3.1"
@@ -23,6 +23,23 @@ try:
23
23
  except ImportError:
24
24
  _yaml = None
25
25
 
26
+ # ``generate_schema`` is a long-running LLM call bounded server-side by ``execution.api_timeout``.
27
+ # That key is not one number: the code default and every deployment config say 900.0, while
28
+ # ``config.yml.template`` says 1200.0. This is the largest checked-in bound plus slack for a
29
+ # heartbeat-buffered response to arrive after the server's own deadline fires. Revisit when a
30
+ # deployment sets ``api_timeout`` above 1200.0.
31
+ #
32
+ # It is an idle bound, not a whole-call deadline: ``urlopen`` applies the timeout to each socket
33
+ # operation, and the server streams a heartbeat chunk every ``keepalive_interval_s`` once the
34
+ # grace period passes, so a healthy call never approaches it however long it runs. What it does
35
+ # guard is a proxy that buffers the whitespace chunks — then no bytes arrive until the body does,
36
+ # and a value below the server's deadline would be what aborts the call.
37
+ #
38
+ # Named for the endpoint, not for one command: `xmd generate` and `xmd enhance` both POST to it and
39
+ # are bounded by the same server deadline, so a bound that fitted only one of them would leave the
40
+ # other aborting early against the very same call.
41
+ _GENERATE_SCHEMA_TIMEOUT_S = 1260.0
42
+
26
43
 
27
44
  def _yaml_dump_or_json(value: Any) -> str:
28
45
  if _yaml is not None:
@@ -196,19 +213,90 @@ def _parse_schema_mutation_error_body(body: str) -> tuple[str, str, dict[str, An
196
213
  return "", body, {}
197
214
 
198
215
 
216
+ #: Every ``details`` list a schema-mutation 422 may carry that already names the individual
217
+ #: object/field/relation it is about. ``non_additive_changes``, ``uncovered_non_additive``, and
218
+ #: ``destructive_ops`` are ``NonAdditiveChange`` / ``DestructiveOpSummary`` dumps; ``missing_additive_ops``
219
+ #: and ``uncovered_additive_ops`` are ``MigrationOp`` dumps instead (see ``xmmodel.additive_plan_synthesizer``
220
+ #: and ``api.schema_helpers``).
221
+ _REJECTED_ELEMENT_DETAIL_KEYS = (
222
+ "non_additive_changes",
223
+ "uncovered_non_additive",
224
+ "uncovered_additive_ops",
225
+ "missing_additive_ops",
226
+ "destructive_ops",
227
+ )
228
+
229
+
230
+ def _rejected_elements(details: dict[str, Any]) -> list[dict[str, Any]]:
231
+ """Collect which object/field/relation each rejected element names, from ``details``.
232
+
233
+ Every list under `_REJECTED_ELEMENT_DETAIL_KEYS` the server may populate already carries
234
+ an identity for the thing it is about (``target``, or an ``object_name``/``field_name``/
235
+ ``name`` pair) plus a kind and a reason string; this only keeps the fields already present,
236
+ it never derives or invents one. Returns ``[]`` when ``details`` carries none of them, so a
237
+ rejection with no per-element detail stays without a fabricated ``elements`` list.
238
+ """
239
+ identity_fields = ("target", "name", "object_name", "field_name", "old_name", "new_name")
240
+ elements: list[dict[str, Any]] = []
241
+ for detail_key in _REJECTED_ELEMENT_DETAIL_KEYS:
242
+ items = details.get(detail_key)
243
+ if not isinstance(items, list):
244
+ continue
245
+ for item in items:
246
+ if not isinstance(item, dict):
247
+ continue
248
+ element: dict[str, Any] = {"detail": detail_key}
249
+ for field in identity_fields:
250
+ if item.get(field) is not None:
251
+ element[field] = item[field]
252
+ kind = item.get("kind") or item.get("op_type")
253
+ if kind is not None:
254
+ element["kind"] = kind
255
+ reason = item.get("message") or item.get("reason")
256
+ if reason is not None:
257
+ element["reason"] = reason
258
+ elements.append(element)
259
+ return elements
260
+
261
+
262
+ def _schema_mutation_error(message: str, *, status: int, stage: str, elements: list[dict[str, Any]]) -> CliOutput:
263
+ """Build the ``OutputKind.ERROR`` payload for a rejected schema mutation.
264
+
265
+ Same shape as `make_error` plus an ``elements`` key (only when non-empty) naming which
266
+ object/field/relation the server rejected, so a mixed edit does not just say "rejected".
267
+ """
268
+ payload: dict[str, Any] = {"error": message, "status": status, "stage": stage}
269
+ if elements:
270
+ payload["elements"] = elements
271
+ return CliOutput(OutputKind.ERROR, payload)
272
+
273
+
199
274
  def _handle_schema_mutation_error(e: HttpStatusError, *, instance_id: str) -> tuple[CliOutput, int]:
200
275
  m = xmem_module()
201
- if e.status == 422:
202
- error_type, msg, details = _parse_schema_mutation_error_body(e.body)
276
+ if e.status == 404:
277
+ return (
278
+ make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
279
+ m.EXIT_HTTP_ERROR,
280
+ )
281
+ error_type, msg, details = _parse_schema_mutation_error_body(e.body)
282
+ # Every schema-mutation rejection carries the same structured ``SchemaHelperErrorPayload``;
283
+ # only the HTTP status varies with ``error_type`` (422 for validation, 409 for
284
+ # ``destructive_confirmation_required`` and the other preconditions — see
285
+ # ``_SCHEMA_APPLY_ERROR_STATUS`` in api/accounts/instances.py — and 200 when the rejection was
286
+ # streamed past the keepalive grace period). Dispatch on the body, so the stage and the
287
+ # ``elements`` it names survive whatever status carried it.
288
+ if error_type or e.status == 422:
203
289
  code = error_type.lower()
290
+ elements = _rejected_elements(details)
204
291
 
205
292
  if code == "non_additive_change_requires_plan":
206
293
  return (
207
- make_error(
294
+ _schema_mutation_error(
208
295
  f"non-additive change requires --migration-plan "
209
296
  f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
210
297
  status=e.status,
211
298
  stage="plan_required",
299
+ elements=elements,
212
300
  ),
213
301
  m.EXIT_HTTP_ERROR,
214
302
  )
@@ -220,15 +308,16 @@ def _handle_schema_mutation_error(e: HttpStatusError, *, instance_id: str) -> tu
220
308
  detail_bits.append(f"{key}={len(items)}")
221
309
  suffix = f" ({', '.join(detail_bits)})" if detail_bits else ""
222
310
  return (
223
- make_error(f"{msg}{suffix}", status=e.status, stage="validate"),
311
+ _schema_mutation_error(f"{msg}{suffix}", status=e.status, stage="validate", elements=elements),
224
312
  m.EXIT_HTTP_ERROR,
225
313
  )
226
314
  if code == "destructive_confirmation_required" or ("destructive" in code and "confirmation" in code):
227
315
  return (
228
- make_error(
316
+ _schema_mutation_error(
229
317
  "destructive change requires --confirm-destructive",
230
318
  status=e.status,
231
319
  stage="destructive",
320
+ elements=elements,
232
321
  ),
233
322
  m.EXIT_HTTP_ERROR,
234
323
  )
@@ -237,32 +326,49 @@ def _handle_schema_mutation_error(e: HttpStatusError, *, instance_id: str) -> tu
237
326
  not error_type and "non_additive_change_requires" in msg.lower()
238
327
  ):
239
328
  return (
240
- make_error(
329
+ _schema_mutation_error(
241
330
  f"non-additive change requires --migration-plan "
242
331
  f"(produce one with `{m.CLI_NAME} xmd enhance --plan-output`)",
243
332
  status=e.status,
244
333
  stage="plan_required",
334
+ elements=elements,
245
335
  ),
246
336
  m.EXIT_HTTP_ERROR,
247
337
  )
248
338
  if "destructive" in code or (not error_type and "destructive" in msg.lower()):
249
339
  return (
250
- make_error(
340
+ _schema_mutation_error(
251
341
  "destructive change requires --confirm-destructive",
252
342
  status=e.status,
253
343
  stage="destructive",
344
+ elements=elements,
254
345
  ),
255
346
  m.EXIT_HTTP_ERROR,
256
347
  )
257
- return (make_error(msg, status=e.status, stage="validate"), m.EXIT_HTTP_ERROR)
258
- if e.status == 404:
348
+ stage = "validate" if e.status == 422 else "request"
259
349
  return (
260
- make_error(f"instance {instance_id} not found", status=e.status, stage="request"),
350
+ _schema_mutation_error(msg, status=e.status, stage=stage, elements=elements),
261
351
  m.EXIT_HTTP_ERROR,
262
352
  )
263
353
  return http_error_output(e, context="schema mutation rejected")
264
354
 
265
355
 
356
+ def _streamed_rejection(item: dict[str, Any]) -> Any | None:
357
+ """The ``ApiError`` a rejection streamed under HTTP 200 stands for, or ``None`` for a result.
358
+
359
+ ``_schema_apply_error_response`` answers a rejected mutation with the bare
360
+ ``SchemaHelperErrorPayload`` — ``status: "error"``, ``error_type``, ``error_message``,
361
+ ``details`` — and its 409/422 reaches the wire only on the fast path. A mutation that outlives
362
+ the server's keepalive grace period comes back as HTTP 200 with that same payload as the body:
363
+ there is no ``errors`` array for ``_unwrap_items`` to reject, so it returns the payload as if it
364
+ were the result. A result is ``status: "ok"`` and never carries ``error_type``.
365
+ """
366
+ if item.get("status") != "error" or not item.get("error_type"):
367
+ return None
368
+ m = xmem_module()
369
+ return m.ApiError(200, json.dumps(item), str(item.get("error_message") or item["error_type"]))
370
+
371
+
266
372
  # The accepted values, duplicated here on purpose: this package is self-contained and
267
373
  # published on its own, so it cannot import the server's SetupFormat. A server-side test
268
374
  # asserts this tuple equals that enum, so the duplication cannot drift silently.
@@ -417,8 +523,7 @@ def cmd_instance_instructions(args: argparse.Namespace) -> tuple[CliOutput, int]
417
523
  if e.status == 409:
418
524
  return (
419
525
  make_error(
420
- "the instructions changed while this command was composing its edit; "
421
- "read them again and reapply",
526
+ "the instructions changed while this command was composing its edit; read them again and reapply",
422
527
  status=409,
423
528
  stage="request",
424
529
  ),
@@ -702,6 +807,10 @@ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
702
807
  api_key,
703
808
  timeout_s=180.0,
704
809
  )
810
+ # Unwrapped inside the handler: a provision that outlives the server's keepalive grace
811
+ # period comes back as HTTP 200 with the failure in the envelope's `errors` array, which
812
+ # `_unwrap_items` raises as ApiError, and nothing above this command catches one.
813
+ item = m._unwrap_items(envelope)
705
814
  except m.ApiError as e:
706
815
  if e.status == 403:
707
816
  limit = _extract_limit_message(e.body)
@@ -719,8 +828,7 @@ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
719
828
  # a schema problem the list cannot help with.
720
829
  return (
721
830
  make_error(
722
- f"instance create rejected: {e}; "
723
- f"list valid template ids with `{CLI_NAME} instance templates`",
831
+ f"instance create rejected: {e}; list valid template ids with `{CLI_NAME} instance templates`",
724
832
  status=e.status,
725
833
  stage="request",
726
834
  ),
@@ -728,7 +836,6 @@ def cmd_instance_create(args: argparse.Namespace) -> tuple[CliOutput, int]:
728
836
  )
729
837
  return http_error_output(e, context="instance create rejected")
730
838
 
731
- item = m._unwrap_items(envelope)
732
839
  curated: dict[str, Any] = {
733
840
  "instance_id": item.get("id"),
734
841
  "name": item.get("name"),
@@ -770,11 +877,15 @@ def cmd_xmd_generate(args: argparse.Namespace) -> tuple[CliOutput, int]:
770
877
  f"{base}/clusters/{cluster_id}/instances/generate_schema",
771
878
  payload,
772
879
  api_key,
773
- timeout_s=120.0,
880
+ timeout_s=_GENERATE_SCHEMA_TIMEOUT_S,
774
881
  )
882
+ # Unwrapped inside the handler, as in ``cmd_xmd_enhance``: a generation that outlives the
883
+ # server's keepalive grace period comes back as HTTP 200 with the failure in the envelope's
884
+ # `errors` array, which `_unwrap_items` raises as ApiError, and nothing above this command
885
+ # catches one.
886
+ item = m._unwrap_items(envelope)
775
887
  except m.ApiError as e:
776
888
  return http_error_output(e, context="request rejected")
777
- item = m._unwrap_items(envelope)
778
889
  schema = item.get("data_schema") if isinstance(item, dict) else None
779
890
  if args.output and schema is not None:
780
891
  write_err = _write_text_file(args.output, _yaml_dump_or_json(schema), stage="generate")
@@ -808,6 +919,9 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
808
919
  return (make_error("instance id required", stage="usage"), m.EXIT_USAGE)
809
920
  try:
810
921
  envelope = m._get(f"{base}/instances/{instance_id}/schema", api_key)
922
+ # Unwrapped inside the handler: an HTTP 200 whose envelope carries `errors` raises
923
+ # ApiError too, and nothing above this command catches one.
924
+ item = m._unwrap_items(envelope)
811
925
  except m.ApiError as e:
812
926
  if e.status == 404:
813
927
  return (
@@ -815,7 +929,6 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
815
929
  m.EXIT_HTTP_ERROR,
816
930
  )
817
931
  return http_error_output(e, context="schema get rejected")
818
- item = m._unwrap_items(envelope)
819
932
  schema = item.get("data_schema") if isinstance(item, dict) else None
820
933
  if schema is None:
821
934
  return (make_error("instance has no live schema to enhance", stage="usage"), m.EXIT_USAGE)
@@ -841,11 +954,15 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
841
954
  f"{base}/clusters/{cluster_id}/instances/generate_schema",
842
955
  payload,
843
956
  api_key,
844
- timeout_s=120.0,
957
+ timeout_s=_GENERATE_SCHEMA_TIMEOUT_S,
845
958
  )
959
+ # Unwrapped inside the handler. An enhancement long enough to outlive the server's
960
+ # keepalive grace period comes back as HTTP 200 with the failure in the envelope's
961
+ # `errors` array, which `_unwrap_items` raises as ApiError — the shape a large-schema
962
+ # failure actually takes, and the one this command exists to report.
963
+ item = m._unwrap_items(envelope)
846
964
  except m.ApiError as e:
847
965
  return http_error_output(e, context="request rejected")
848
- item = m._unwrap_items(envelope)
849
966
  if args.output and isinstance(item, dict) and item.get("data_schema") is not None:
850
967
  write_err = _write_text_file(args.output, _yaml_dump_or_json(item["data_schema"]), stage="enhance")
851
968
  if write_err is not None:
@@ -1004,9 +1121,17 @@ def cmd_schema_update(args: argparse.Namespace) -> tuple[CliOutput, int]:
1004
1121
  api_key,
1005
1122
  timeout_s=300.0,
1006
1123
  )
1124
+ # Unwrapped inside the handler: a failure raised past the server's keepalive grace period
1125
+ # comes back as HTTP 200 with an `errors` array, which `_unwrap_items` raises as ApiError;
1126
+ # a *rejection* past it comes back as HTTP 200 with the bare rejection payload as the body,
1127
+ # which `_streamed_rejection` recognises. Either way `_handle_schema_mutation_error` turns
1128
+ # it into a JSON error document with a non-zero exit, not a success document or a traceback.
1129
+ item = m._unwrap_items(envelope)
1130
+ rejection = _streamed_rejection(item)
1131
+ if rejection is not None:
1132
+ raise rejection
1007
1133
  except m.ApiError as e:
1008
1134
  return _handle_schema_mutation_error(e, instance_id=instance_id)
1009
- item = m._unwrap_items(envelope)
1010
1135
  return (CliOutput(OutputKind.SCHEMA_UPDATE, item), m.EXIT_OK)
1011
1136
 
1012
1137
 
@@ -1031,9 +1156,15 @@ def cmd_schema_dry_run(args: argparse.Namespace) -> tuple[CliOutput, int]:
1031
1156
  api_key,
1032
1157
  timeout_s=120.0,
1033
1158
  )
1159
+ # Unwrapped inside the handler, same reasoning as `cmd_schema_update`: this endpoint is
1160
+ # heartbeat-wrapped too, so both 200 shapes past the grace period — a raised failure in
1161
+ # `errors`, a rejection payload as the body — must land on `_handle_schema_mutation_error`.
1162
+ item = m._unwrap_items(envelope)
1163
+ rejection = _streamed_rejection(item)
1164
+ if rejection is not None:
1165
+ raise rejection
1034
1166
  except m.ApiError as e:
1035
1167
  return _handle_schema_mutation_error(e, instance_id=instance_id)
1036
- item = m._unwrap_items(envelope)
1037
1168
  return (CliOutput(OutputKind.SCHEMA_DRY_RUN, item), m.EXIT_OK)
1038
1169
 
1039
1170
 
@@ -4,7 +4,7 @@ import platform
4
4
  import re
5
5
 
6
6
  CLI_NAME = "xmemcli"
7
- __version__ = "1.3.0"
7
+ __version__ = "1.3.1"
8
8
 
9
9
  # The header this CLI sends its identity in, restated rather than imported: the published package is
10
10
  # standalone and must not import anything outside this directory. The server's own constant is the other
xmemcli-1.3.0/.gitignore DELETED
@@ -1,4 +0,0 @@
1
- .venv/
2
- __pycache__/
3
- *.egg-info/
4
- .dist/
@@ -1,22 +0,0 @@
1
- [project]
2
- name = "xmemcli"
3
- version = "1.3.0"
4
- description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
- readme = "README.md"
6
- requires-python = ">=3.12,<4"
7
- dependencies = []
8
- keywords = ["xmemory", "cli", "api", "memory", "llm"]
9
-
10
- [project.scripts]
11
- xmemcli = "xmemcli.cli:main"
12
-
13
- [build-system]
14
- requires = ["hatchling"]
15
- build-backend = "hatchling.build"
16
-
17
- [tool.hatch.build.targets.wheel]
18
- packages = ["xmemcli"]
19
-
20
- [tool.pytest.ini_options]
21
- pythonpath = ["."]
22
- testpaths = ["tests"]
xmemcli-1.3.0/uv.lock DELETED
@@ -1,8 +0,0 @@
1
- version = 1
2
- revision = 3
3
- requires-python = ">=3.12, <4"
4
-
5
- [[package]]
6
- name = "xmemcli"
7
- version = "1.3.0"
8
- source = { editable = "." }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes