xmemcli 0.0.2__tar.gz → 0.0.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xmemcli
3
- Version: 0.0.2
3
+ Version: 0.0.4
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
@@ -70,9 +70,15 @@ xmemcli write-status "$ID1" "$ID2"
70
70
 
71
71
  Requires **Python 3.12+**. Stdlib only — no runtime dependencies.
72
72
 
73
+ Preferred:
74
+
73
75
  ```bash
74
76
  uv tool install xmemcli
75
- # or
77
+ ```
78
+
79
+ When uv is not available:
80
+
81
+ ```bash
76
82
  pip install xmemcli
77
83
  ```
78
84
 
@@ -83,7 +89,7 @@ xmemcli version
83
89
  xmemcli auth login
84
90
  ```
85
91
 
86
- Version: **0.0.2**.
92
+ Version: **0.0.4**.
87
93
 
88
94
 
89
95
  ---
@@ -153,7 +159,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
153
159
 
154
160
  # `xmemcli` — capabilities reference
155
161
 
156
- **Version:** `0.0.2` (see `xmemcli version`)
162
+ **Version:** `0.0.4` (see `xmemcli version`)
157
163
 
158
164
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-governed for key acquisition**; an AI-assisted IDE may run reads, writes, and inventory once credentials exist. Humans acquire API keys and change quota limits in the **Console UI**.
159
165
 
@@ -187,7 +193,6 @@ Permission-filtered view of what this API key can see.
187
193
  |---------|-------------|
188
194
  | **`org list clusters`** | Clusters visible to this credential |
189
195
  | **`org list instances`** | Instances visible to this credential |
190
- | **`instance list`** | Alias for **`org list instances`** |
191
196
  | **`org list keys`** | API keys visible to this credential (metadata only; plaintext never returned) |
192
197
 
193
198
  ---
@@ -199,8 +204,6 @@ Permission-filtered view of what this API key can see.
199
204
  | **`instance get`** `<instance_id>` | Fetch one instance |
200
205
  | **`instance create`** | Create from XMD/JSON schema. Required `--name`; schema via `--schema-file` or `--schema-stdin`. |
201
206
 
202
- **Operator-only (intentionally omitted from help and this table):** `instance delete` exists for humans at an interactive TTY (type `yes` to confirm) but is **not advertised** in primary CLI docs yet. Automation should use `DELETE /instances/{id}` over HTTP.
203
-
204
207
  ---
205
208
 
206
209
  ### Schema & XMD
@@ -215,7 +218,7 @@ XMD (xmemory Data) is the typed schema format for instances. These commands mirr
215
218
  | Apply migration | **`schema update`** `[instance_id]` | `--schema-file`, optional `--migration-plan`, `--confirm-destructive`. |
216
219
  | Preview migration | **`schema dry-run`** `[instance_id]` | Same flags as `schema update`; returns planned DDL without applying. |
217
220
  | List migrations | **`schema migrations list`** `[instance_id]` | Newest first. `--limit`, `--before-id`, `--include-yaml`. |
218
- | Fetch one migration | **`schema migrations get`** `<migration_id>` | Instance from `--instance-id` / `$XMEM_INSTANCE_ID`. `--include-yaml`. |
221
+ | Fetch one migration | **`schema migrations get`** `[instance_id]` `<migration_id>` | Optional positional instance id; else `--instance-id` / `$XMEM_INSTANCE_ID`. `--include-yaml`. |
219
222
  | Enhance bound schema | **`xmd enhance --from-instance`** `<description>` | Uses live schema; requires instance id. |
220
223
  | Synthesize schema | **`xmd generate`** `<description>` | Cluster-scoped LLM synthesis. `-o` writes YAML. |
221
224
  | Enhance from file | **`xmd enhance`** `[schema_file]` `<description>` | From a file, or `--from-instance` for bound enhance. `-o`, `--plan-output`. |
@@ -242,7 +245,7 @@ xmemcli schema update INST_ID --schema-file next.yml --migration-plan plan.json
242
245
 
243
246
  # 4. Inspect history
244
247
  xmemcli schema migrations list INST_ID
245
- xmemcli schema migrations get MIGRATION_UUID
248
+ xmemcli schema migrations get INST_ID MIGRATION_UUID
246
249
  ```
247
250
 
248
251
  #### Commands
@@ -253,7 +256,7 @@ xmemcli schema migrations get MIGRATION_UUID
253
256
  | **`schema update`** `[instance_id]` | Replace schema from `--schema-file`. Optional `--schema-type`, `--migration-plan`, `--confirm-destructive`. |
254
257
  | **`schema dry-run`** `[instance_id]` | Preview migration (same schema/plan flags as update). |
255
258
  | **`schema migrations list`** `[instance_id]` | Applied migrations, newest first. `--limit` (default 50), `--before-id`, `--include-yaml`. |
256
- | **`schema migrations get`** `<migration_id>` | One record; instance from global instance flags. `--include-yaml`. |
259
+ | **`schema migrations get`** `[instance_id]` `<migration_id>` | One record. Optional positional instance id; else global instance flags. `--include-yaml`. |
257
260
  | **`xmd generate`** `<description>` | LLM-synthesize XMD. Optional `-o`. Requires cluster resolution. |
258
261
  | **`xmd enhance`** `[schema_file]` `<description>` | LLM-augment schema from file, or **`--from-instance`** for live schema. `-o`, `--plan-output`. |
259
262
  | **`xmd validate`** `<schema_file>` | Validate; YAML vs JSON inferred from `.json` extension. |
@@ -261,7 +264,7 @@ xmemcli schema migrations get MIGRATION_UUID
261
264
  | **`xmd validate-json`** `<schema_file>` | Force JSON Schema validation endpoint. |
262
265
  | **`xmd convert`** `<json_schema_file>` | JSON Schema → XMD YAML via the API (`validate_json_schema`). Optional `-o`, `--root-object`. |
263
266
 
264
- Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID`** (except `migrations get`, which takes only `migration_id`).
267
+ Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID`**.
265
268
 
266
269
  ---
267
270
 
@@ -289,10 +292,23 @@ Instance id for `schema *` commands: positional `[instance_id]`, else **`--insta
289
292
  | **`quota`** | Usage / limit / window start for the key's cluster (derived from instance → cluster). |
290
293
 
291
294
  - **Default:** exit `0` when quota is readable; stderr **warning** when usage is high.
292
- - **`--fail-if-under THRESHOLD`** — exit non-zero when **remaining** quota is below that share of the limit (`0.1` or `10%`). See `xmemcli help quota`.
295
+ - **`--fail-if-under THRESHOLD`** — exit **7** when **remaining** quota is below that share of the limit (`0.1` or `10%`). Distinct from exit **6** (API quota exhausted). See `xmemcli help quota`.
293
296
 
294
297
  Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-xmemcli-does-not-do).
295
298
 
299
+ ### Exit codes
300
+
301
+ | Code | Meaning |
302
+ |------|---------|
303
+ | **0** | Success |
304
+ | **2** | Usage error (bad flags, unknown `help` topic, missing required args) |
305
+ | **3** | HTTP / request failure (includes unwritable `-o` paths) |
306
+ | **4** | Auth error (missing or rejected API key) |
307
+ | **5** | Timeout |
308
+ | **6** | Quota exhausted (API quota-exceeded response) |
309
+ | **7** | Quota headroom below `--fail-if-under` |
310
+ | **130** | Interrupted (Ctrl+C) |
311
+
296
312
  ---
297
313
 
298
314
  ## Help
@@ -307,6 +323,8 @@ Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-
307
323
  | **`xmemcli help <topic>`** | Usage, options, and subcommands for that topic |
308
324
  | **`xmemcli <cmd> … -h`** / **`--help`** | Same as `help <topic>` for that command path |
309
325
 
326
+ Unknown help topics exit **2** (same as other usage errors).
327
+
310
328
  **Every command in this document has help.** Examples:
311
329
 
312
330
  ```text
@@ -315,7 +333,6 @@ xmemcli help write-status
315
333
  xmemcli help read
316
334
  xmemcli help org
317
335
  xmemcli help org list instances
318
- xmemcli help instance list
319
336
  xmemcli help instance create
320
337
  xmemcli help schema
321
338
  xmemcli help schema get
@@ -340,26 +357,6 @@ Nested topics work at any level that exists in the parser (`help org`, `help org
340
357
 
341
358
  ---
342
359
 
343
- ## Command aliases
344
-
345
- Canonical names appear in `xmemcli` and in help. **Aliases are accepted on the command line but never listed in the overview** — use them in scripts if you already have them; prefer canonical names in new work.
346
-
347
- | Canonical command | Accepted aliases |
348
- |-------------------|------------------|
349
- | **`write-status`** | `write_status`, `writestatus`, `async_write_status`, `asyncwritestatus`, `async-write-status` |
350
- | **`quota`** | `quota get` |
351
-
352
- **Help topic aliases** (same targets, for `xmemcli help …` only):
353
-
354
- | `help` topic you type | Resolves to |
355
- |-----------------------|-------------|
356
- | `login` | `auth login` |
357
- | `logout` | `auth logout` |
358
- | `write-status`, `write_status`, `writestatus`, `async_write_status`, `asyncwritestatus`, `async-write-status` | `write-status` |
359
- | `quota get`, `quota-get` | `quota` |
360
-
361
- ---
362
-
363
360
  ## What `xmemcli` does **not** do
364
361
 
365
362
  | Area | Use the Console instead |
@@ -372,7 +369,7 @@ Canonical names appear in `xmemcli` and in help. **Aliases are accepted on the c
372
369
  | **Cluster create/delete** | CLI lists clusters; provisioning in Console |
373
370
  | **`read --extraction deep`** | Server has no read-depth knob today |
374
371
  | **Raw REST proxy** | Curated response fields only |
375
- | **`instance delete` (advertised)** | Implemented for human operators at a TTY (type `yes`); intentionally **omitted from help and this doc's command table** until product is ready to advertise it. Automation should use `DELETE /instances/{id}` over HTTP. |
372
+ | **Instance deletion** | **Human operators only** — a hidden `instance delete` exists for interactive TTY use (type `yes` to confirm). It is omitted from help and must not be used by agents or automation. Console UI remains the primary human path. |
376
373
 
377
374
  On HTTP **402** (quota exhausted), stop and ask a human to extend quota in the Console.
378
375
 
@@ -62,9 +62,15 @@ xmemcli write-status "$ID1" "$ID2"
62
62
 
63
63
  Requires **Python 3.12+**. Stdlib only — no runtime dependencies.
64
64
 
65
+ Preferred:
66
+
65
67
  ```bash
66
68
  uv tool install xmemcli
67
- # or
69
+ ```
70
+
71
+ When uv is not available:
72
+
73
+ ```bash
68
74
  pip install xmemcli
69
75
  ```
70
76
 
@@ -75,7 +81,7 @@ xmemcli version
75
81
  xmemcli auth login
76
82
  ```
77
83
 
78
- Version: **0.0.2**.
84
+ Version: **0.0.4**.
79
85
 
80
86
 
81
87
  ---
@@ -145,7 +151,7 @@ Fixed port **9636** ("xmem" on phone keypad) allows SSH/devcontainer forwarding;
145
151
 
146
152
  # `xmemcli` — capabilities reference
147
153
 
148
- **Version:** `0.0.2` (see `xmemcli version`)
154
+ **Version:** `0.0.4` (see `xmemcli version`)
149
155
 
150
156
  `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-governed for key acquisition**; an AI-assisted IDE may run reads, writes, and inventory once credentials exist. Humans acquire API keys and change quota limits in the **Console UI**.
151
157
 
@@ -179,7 +185,6 @@ Permission-filtered view of what this API key can see.
179
185
  |---------|-------------|
180
186
  | **`org list clusters`** | Clusters visible to this credential |
181
187
  | **`org list instances`** | Instances visible to this credential |
182
- | **`instance list`** | Alias for **`org list instances`** |
183
188
  | **`org list keys`** | API keys visible to this credential (metadata only; plaintext never returned) |
184
189
 
185
190
  ---
@@ -191,8 +196,6 @@ Permission-filtered view of what this API key can see.
191
196
  | **`instance get`** `<instance_id>` | Fetch one instance |
192
197
  | **`instance create`** | Create from XMD/JSON schema. Required `--name`; schema via `--schema-file` or `--schema-stdin`. |
193
198
 
194
- **Operator-only (intentionally omitted from help and this table):** `instance delete` exists for humans at an interactive TTY (type `yes` to confirm) but is **not advertised** in primary CLI docs yet. Automation should use `DELETE /instances/{id}` over HTTP.
195
-
196
199
  ---
197
200
 
198
201
  ### Schema & XMD
@@ -207,7 +210,7 @@ XMD (xmemory Data) is the typed schema format for instances. These commands mirr
207
210
  | Apply migration | **`schema update`** `[instance_id]` | `--schema-file`, optional `--migration-plan`, `--confirm-destructive`. |
208
211
  | Preview migration | **`schema dry-run`** `[instance_id]` | Same flags as `schema update`; returns planned DDL without applying. |
209
212
  | List migrations | **`schema migrations list`** `[instance_id]` | Newest first. `--limit`, `--before-id`, `--include-yaml`. |
210
- | Fetch one migration | **`schema migrations get`** `<migration_id>` | Instance from `--instance-id` / `$XMEM_INSTANCE_ID`. `--include-yaml`. |
213
+ | Fetch one migration | **`schema migrations get`** `[instance_id]` `<migration_id>` | Optional positional instance id; else `--instance-id` / `$XMEM_INSTANCE_ID`. `--include-yaml`. |
211
214
  | Enhance bound schema | **`xmd enhance --from-instance`** `<description>` | Uses live schema; requires instance id. |
212
215
  | Synthesize schema | **`xmd generate`** `<description>` | Cluster-scoped LLM synthesis. `-o` writes YAML. |
213
216
  | Enhance from file | **`xmd enhance`** `[schema_file]` `<description>` | From a file, or `--from-instance` for bound enhance. `-o`, `--plan-output`. |
@@ -234,7 +237,7 @@ xmemcli schema update INST_ID --schema-file next.yml --migration-plan plan.json
234
237
 
235
238
  # 4. Inspect history
236
239
  xmemcli schema migrations list INST_ID
237
- xmemcli schema migrations get MIGRATION_UUID
240
+ xmemcli schema migrations get INST_ID MIGRATION_UUID
238
241
  ```
239
242
 
240
243
  #### Commands
@@ -245,7 +248,7 @@ xmemcli schema migrations get MIGRATION_UUID
245
248
  | **`schema update`** `[instance_id]` | Replace schema from `--schema-file`. Optional `--schema-type`, `--migration-plan`, `--confirm-destructive`. |
246
249
  | **`schema dry-run`** `[instance_id]` | Preview migration (same schema/plan flags as update). |
247
250
  | **`schema migrations list`** `[instance_id]` | Applied migrations, newest first. `--limit` (default 50), `--before-id`, `--include-yaml`. |
248
- | **`schema migrations get`** `<migration_id>` | One record; instance from global instance flags. `--include-yaml`. |
251
+ | **`schema migrations get`** `[instance_id]` `<migration_id>` | One record. Optional positional instance id; else global instance flags. `--include-yaml`. |
249
252
  | **`xmd generate`** `<description>` | LLM-synthesize XMD. Optional `-o`. Requires cluster resolution. |
250
253
  | **`xmd enhance`** `[schema_file]` `<description>` | LLM-augment schema from file, or **`--from-instance`** for live schema. `-o`, `--plan-output`. |
251
254
  | **`xmd validate`** `<schema_file>` | Validate; YAML vs JSON inferred from `.json` extension. |
@@ -253,7 +256,7 @@ xmemcli schema migrations get MIGRATION_UUID
253
256
  | **`xmd validate-json`** `<schema_file>` | Force JSON Schema validation endpoint. |
254
257
  | **`xmd convert`** `<json_schema_file>` | JSON Schema → XMD YAML via the API (`validate_json_schema`). Optional `-o`, `--root-object`. |
255
258
 
256
- Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID`** (except `migrations get`, which takes only `migration_id`).
259
+ Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID`**.
257
260
 
258
261
  ---
259
262
 
@@ -281,10 +284,23 @@ Instance id for `schema *` commands: positional `[instance_id]`, else **`--insta
281
284
  | **`quota`** | Usage / limit / window start for the key's cluster (derived from instance → cluster). |
282
285
 
283
286
  - **Default:** exit `0` when quota is readable; stderr **warning** when usage is high.
284
- - **`--fail-if-under THRESHOLD`** — exit non-zero when **remaining** quota is below that share of the limit (`0.1` or `10%`). See `xmemcli help quota`.
287
+ - **`--fail-if-under THRESHOLD`** — exit **7** when **remaining** quota is below that share of the limit (`0.1` or `10%`). Distinct from exit **6** (API quota exhausted). See `xmemcli help quota`.
285
288
 
286
289
  Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-xmemcli-does-not-do).
287
290
 
291
+ ### Exit codes
292
+
293
+ | Code | Meaning |
294
+ |------|---------|
295
+ | **0** | Success |
296
+ | **2** | Usage error (bad flags, unknown `help` topic, missing required args) |
297
+ | **3** | HTTP / request failure (includes unwritable `-o` paths) |
298
+ | **4** | Auth error (missing or rejected API key) |
299
+ | **5** | Timeout |
300
+ | **6** | Quota exhausted (API quota-exceeded response) |
301
+ | **7** | Quota headroom below `--fail-if-under` |
302
+ | **130** | Interrupted (Ctrl+C) |
303
+
288
304
  ---
289
305
 
290
306
  ## Help
@@ -299,6 +315,8 @@ Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-
299
315
  | **`xmemcli help <topic>`** | Usage, options, and subcommands for that topic |
300
316
  | **`xmemcli <cmd> … -h`** / **`--help`** | Same as `help <topic>` for that command path |
301
317
 
318
+ Unknown help topics exit **2** (same as other usage errors).
319
+
302
320
  **Every command in this document has help.** Examples:
303
321
 
304
322
  ```text
@@ -307,7 +325,6 @@ xmemcli help write-status
307
325
  xmemcli help read
308
326
  xmemcli help org
309
327
  xmemcli help org list instances
310
- xmemcli help instance list
311
328
  xmemcli help instance create
312
329
  xmemcli help schema
313
330
  xmemcli help schema get
@@ -332,26 +349,6 @@ Nested topics work at any level that exists in the parser (`help org`, `help org
332
349
 
333
350
  ---
334
351
 
335
- ## Command aliases
336
-
337
- Canonical names appear in `xmemcli` and in help. **Aliases are accepted on the command line but never listed in the overview** — use them in scripts if you already have them; prefer canonical names in new work.
338
-
339
- | Canonical command | Accepted aliases |
340
- |-------------------|------------------|
341
- | **`write-status`** | `write_status`, `writestatus`, `async_write_status`, `asyncwritestatus`, `async-write-status` |
342
- | **`quota`** | `quota get` |
343
-
344
- **Help topic aliases** (same targets, for `xmemcli help …` only):
345
-
346
- | `help` topic you type | Resolves to |
347
- |-----------------------|-------------|
348
- | `login` | `auth login` |
349
- | `logout` | `auth logout` |
350
- | `write-status`, `write_status`, `writestatus`, `async_write_status`, `asyncwritestatus`, `async-write-status` | `write-status` |
351
- | `quota get`, `quota-get` | `quota` |
352
-
353
- ---
354
-
355
352
  ## What `xmemcli` does **not** do
356
353
 
357
354
  | Area | Use the Console instead |
@@ -364,7 +361,7 @@ Canonical names appear in `xmemcli` and in help. **Aliases are accepted on the c
364
361
  | **Cluster create/delete** | CLI lists clusters; provisioning in Console |
365
362
  | **`read --extraction deep`** | Server has no read-depth knob today |
366
363
  | **Raw REST proxy** | Curated response fields only |
367
- | **`instance delete` (advertised)** | Implemented for human operators at a TTY (type `yes`); intentionally **omitted from help and this doc's command table** until product is ready to advertise it. Automation should use `DELETE /instances/{id}` over HTTP. |
364
+ | **Instance deletion** | **Human operators only** — a hidden `instance delete` exists for interactive TTY use (type `yes` to confirm). It is omitted from help and must not be used by agents or automation. Console UI remains the primary human path. |
368
365
 
369
366
  On HTTP **402** (quota exhausted), stop and ask a human to extend quota in the Console.
370
367
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmemcli"
3
- version = "0.0.2"
3
+ version = "0.0.4"
4
4
  description = "Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<4"
@@ -4,5 +4,5 @@ requires-python = ">=3.12, <4"
4
4
 
5
5
  [[package]]
6
6
  name = "xmemcli"
7
- version = "0.0.2"
7
+ version = "0.0.4"
8
8
  source = { editable = "." }
@@ -1,3 +1,3 @@
1
1
  """Standalone xmemcli package (``xmemcli`` console script)."""
2
2
 
3
- __version__ = "0.0.2"
3
+ __version__ = "0.0.4"
@@ -8,7 +8,7 @@ import argparse
8
8
  import os
9
9
  import re
10
10
  import sys
11
- from typing import Any, IO, NoReturn
11
+ from typing import Any, IO, NoReturn, override
12
12
 
13
13
  from ._version import CLI_NAME, __version__
14
14
 
@@ -37,6 +37,19 @@ _HIDDEN_COMMAND_NAMES = frozenset(
37
37
  "write_status",
38
38
  "writestatus",
39
39
  "async_write_status",
40
+ "asyncwritestatus",
41
+ "async-write-status",
42
+ "delete",
43
+ }
44
+ )
45
+ # Silent argv aliases for write-status spellings (also hidden from help subcommand lists).
46
+ _WRITE_STATUS_ALIAS_NAMES = frozenset(
47
+ {
48
+ "write_status",
49
+ "writestatus",
50
+ "async_write_status",
51
+ "asyncwritestatus",
52
+ "async-write-status",
40
53
  }
41
54
  )
42
55
  _RESET = "\x1b[0m"
@@ -96,7 +109,6 @@ _OVERVIEW_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
96
109
  (
97
110
  "Instances",
98
111
  (
99
- ("instance list", "List instances visible to this credential (alias for org list instances)"),
100
112
  ("instance get", "Get a single instance by id"),
101
113
  ("instance create", "Create a new instance from an XMD/JSON schema"),
102
114
  ),
@@ -183,7 +195,10 @@ def _option_label(action: argparse.Action) -> str:
183
195
  return f"{longest} …"
184
196
  return longest
185
197
  if action.metavar:
186
- return action.metavar
198
+ meta = action.metavar
199
+ if isinstance(meta, tuple):
200
+ return " ".join(meta)
201
+ return meta
187
202
  return action.dest
188
203
 
189
204
 
@@ -498,7 +513,7 @@ def _render_unknown_document(doc: dict[str, Any], stream: IO[str], style: _Style
498
513
 
499
514
 
500
515
  def render_help_document(doc: dict[str, Any], *, stream: IO[str] | None = None) -> None:
501
- """Render a :func:`build_help_document` payload for human terminals."""
516
+ """Render a `build_help_document` payload for human terminals."""
502
517
  out = stream if stream is not None else sys.stdout
503
518
  style = _Style(_use_color(out))
504
519
  kind = doc.get("kind")
@@ -555,13 +570,14 @@ def _format_cli_parse_error(message: str) -> str:
555
570
 
556
571
 
557
572
  class XmemCliArgumentParser(argparse.ArgumentParser):
558
- """ArgumentParser that raises :class:`CliParseError` instead of dumping usage."""
573
+ """ArgumentParser that raises `CliParseError` instead of dumping usage."""
559
574
 
560
575
  def __init__(self, *args: Any, **kwargs: Any) -> None:
561
576
  kwargs.setdefault("usage", argparse.SUPPRESS)
562
577
  kwargs.setdefault("add_help", False)
563
578
  super().__init__(*args, **kwargs)
564
579
 
580
+ @override
565
581
  def error(self, message: str) -> NoReturn:
566
582
  raise CliParseError(_format_cli_parse_error(message))
567
583
 
@@ -702,12 +718,13 @@ def _choice_help(sub: argparse._SubParsersAction, name: str, child: argparse.Arg
702
718
  def _parser_title(root: argparse.ArgumentParser, path: tuple[str, ...], parser: argparse.ArgumentParser) -> str:
703
719
  if parser.description:
704
720
  return parser.description
705
- if not path:
721
+ if len(path) == 0:
706
722
  return ""
707
- parent = navigate_parser(root, path[:-1]) if len(path) > 1 else root
723
+ *parent_path, leaf = path
724
+ parent = navigate_parser(root, tuple(parent_path)) if parent_path else root
708
725
  sub = _subparsers_action(parent) if parent is not None else None
709
726
  if sub is not None:
710
- return _choice_help(sub, path[-1], parser)
727
+ return _choice_help(sub, leaf, parser)
711
728
  return " ".join(path)
712
729
 
713
730
 
@@ -1,6 +1,6 @@
1
1
  """Instance lifecycle and XMD/schema workflow commands for ``xmemcli``.
2
2
 
3
- Imported lazily from ``xmem._build_parser`` so ``xmem`` finishes loading before we bind helpers.
3
+ Bound from ``xmem._build_parser`` after core ``xmem`` helpers are defined.
4
4
  """
5
5
 
6
6
  import argparse
@@ -12,7 +12,7 @@ from typing import Any, Protocol
12
12
  from urllib.parse import urlencode
13
13
 
14
14
  from ._output import CliOutput, OutputKind, make_error
15
- from ._xmem_bridge import curate_instance, http_to_exit, xmem_module
15
+ from ._xmem_bridge import curate_instance, xmem_module
16
16
 
17
17
  try:
18
18
  import yaml as _yaml
@@ -26,6 +26,23 @@ def _yaml_dump_or_json(value: Any) -> str:
26
26
  return json.dumps(value, indent=2) + "\n"
27
27
 
28
28
 
29
+ def _write_text_file(
30
+ path: str | Path,
31
+ content: str,
32
+ *,
33
+ stage: str = "output",
34
+ ) -> tuple[CliOutput, int] | None:
35
+ """Write *content* to *path*; return ``(error, exit_code)`` on ``OSError``."""
36
+ target = Path(path)
37
+ try:
38
+ target.parent.mkdir(parents=True, exist_ok=True)
39
+ target.write_text(content, encoding="utf-8")
40
+ except OSError as exc:
41
+ m = xmem_module()
42
+ return (make_error(f"cannot write {target}: {exc}", stage=stage), m.EXIT_HTTP_ERROR)
43
+ return None
44
+
45
+
29
46
  def _infer_schema_type(path: str | None, explicit: str | None) -> str:
30
47
  if explicit:
31
48
  return explicit
@@ -214,6 +231,12 @@ def _get_with_query(url: str, api_key: str, params: dict[str, Any], *, timeout_s
214
231
 
215
232
 
216
233
  def cmd_instance_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
234
+ """Return one instance by id.
235
+
236
+ Uses cluster-less ``GET /instances/{id}`` by default; the API resolves
237
+ ``cluster_id`` from the instance record and checks access in one round trip.
238
+ When ``--cluster`` is set, uses ``GET /clusters/{cluster}/instances/{id}``.
239
+ """
217
240
  m = xmem_module()
218
241
  api_key, _ = m._require(args, need_instance=False)
219
242
  base = m._api_url(args)
@@ -221,45 +244,26 @@ def cmd_instance_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
221
244
 
222
245
  explicit_cluster = getattr(args, "cluster", None)
223
246
  if explicit_cluster:
224
- cluster_ids = [explicit_cluster]
247
+ url = f"{base}/clusters/{explicit_cluster}/instances/{instance_id}"
225
248
  else:
226
- try:
227
- clusters_env = m._get(f"{base}/clusters?verbose=true", api_key)
228
- except m.ApiError as e:
229
- return (make_error(f"/clusters failed: {e}", status=e.status), http_to_exit(e.status))
230
- cluster_ids = [c["id"] for c in (clusters_env.get("items") or []) if isinstance(c, dict) and c.get("id")]
249
+ url = f"{base.rstrip('/')}/instances/{instance_id}"
231
250
 
232
- last_auth_error: m.ApiError | None = None
233
- for cid in cluster_ids:
234
- try:
235
- envelope = m._get(f"{base}/clusters/{cid}/instances/{instance_id}", api_key)
236
- except m.ApiError as e:
237
- if e.status == 404:
238
- continue
239
- if e.status in (401, 403):
240
- last_auth_error = e
241
- continue
242
- return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
243
- item = m._unwrap_items(envelope)
244
- return (CliOutput(OutputKind.INSTANCE_GET, curate_instance(item, args.verbose)), m.EXIT_OK)
251
+ try:
252
+ envelope = m._get(url, api_key)
253
+ except m.ApiError as e:
254
+ if e.status == 404:
255
+ msg = (
256
+ f"instance {instance_id} not found in cluster {explicit_cluster}"
257
+ if explicit_cluster
258
+ else f"instance {instance_id} not found or not visible to this key"
259
+ )
260
+ return (make_error(msg, status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
261
+ if e.status in (401, 403):
262
+ return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
263
+ return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
245
264
 
246
- if last_auth_error is not None:
247
- return (
248
- make_error(
249
- f"instance {instance_id} exists in a cluster this key cannot access",
250
- status=last_auth_error.status,
251
- stage="auth",
252
- ),
253
- m.EXIT_AUTH_ERROR,
254
- )
255
- return (
256
- make_error(
257
- f"instance {instance_id} not found in any cluster visible to this key",
258
- status=404,
259
- stage="request",
260
- ),
261
- m.EXIT_HTTP_ERROR,
262
- )
265
+ item = m._unwrap_items(envelope)
266
+ return (CliOutput(OutputKind.INSTANCE_GET, curate_instance(item, args.verbose)), m.EXIT_OK)
263
267
 
264
268
 
265
269
  def _require_interactive_yes(prompt: str) -> tuple[bool, str | None]:
@@ -267,7 +271,7 @@ def _require_interactive_yes(prompt: str) -> tuple[bool, str | None]:
267
271
  if not sys.stdin.isatty():
268
272
  return False, "instance delete requires an interactive terminal; type yes when prompted (do not pipe yes)"
269
273
  try:
270
- # DELIBERATELY print: confirmation prompt must not pollute stdout in machine mode.
274
+ # DELIBERATELY write to stderr: confirmation prompt must not pollute stdout in machine mode.
271
275
  print(prompt, end="", file=sys.stderr, flush=True)
272
276
  answer = input().strip().lower()
273
277
  except EOFError:
@@ -282,8 +286,7 @@ def cmd_instance_delete(args: argparse.Namespace) -> tuple[CliOutput, int]:
282
286
 
283
287
  Partial by design: human operators at a real TTY must type ``yes``; piped or
284
288
  non-interactive stdin is refused. Agents, harnesses, and automation must use
285
- ``DELETE /instances/{id}`` over HTTP instead — see ``cli/HARNESS-FIRST-TEST.md``
286
- step 41.
289
+ ``DELETE /instances/{id}`` over HTTP instead — see harness step 41 in ``HARNESS-FIRST-TEST.md``.
287
290
  """
288
291
  m = xmem_module()
289
292
  api_key, _ = m._require(args, need_instance=False)
@@ -432,7 +435,9 @@ def cmd_xmd_generate(args: argparse.Namespace) -> tuple[CliOutput, int]:
432
435
  item = m._unwrap_items(envelope)
433
436
  schema = item.get("data_schema") if isinstance(item, dict) else None
434
437
  if args.output and schema is not None:
435
- Path(args.output).write_text(_yaml_dump_or_json(schema), encoding="utf-8")
438
+ write_err = _write_text_file(args.output, _yaml_dump_or_json(schema), stage="generate")
439
+ if write_err is not None:
440
+ return write_err
436
441
  return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
437
442
 
438
443
 
@@ -503,11 +508,15 @@ def cmd_xmd_enhance(args: argparse.Namespace) -> tuple[CliOutput, int]:
503
508
  return (make_error(str(e), status=e.status, stage="auth"), m.EXIT_AUTH_ERROR)
504
509
  return (make_error(str(e), status=e.status, stage="request"), m.EXIT_HTTP_ERROR)
505
510
  item = m._unwrap_items(envelope)
506
- migration_plan = item.get("migration_plan") if isinstance(item, dict) else None
507
511
  if args.output and isinstance(item, dict) and item.get("data_schema") is not None:
508
- Path(args.output).write_text(_yaml_dump_or_json(item["data_schema"]), encoding="utf-8")
512
+ write_err = _write_text_file(args.output, _yaml_dump_or_json(item["data_schema"]), stage="enhance")
513
+ if write_err is not None:
514
+ return write_err
509
515
  if args.plan_output and isinstance(item, dict):
510
- Path(args.plan_output).write_text(json.dumps(item.get("migration_plan") or {}, indent=2), encoding="utf-8")
516
+ plan_body = json.dumps(item.get("migration_plan") or {}, indent=2)
517
+ write_err = _write_text_file(args.plan_output, plan_body, stage="enhance")
518
+ if write_err is not None:
519
+ return write_err
511
520
  return (CliOutput(OutputKind.XMD_ENHANCE, item), m.EXIT_OK)
512
521
 
513
522
 
@@ -638,7 +647,9 @@ def cmd_schema_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
638
647
  item = m._unwrap_items(envelope)
639
648
  schema = item.get("data_schema") if isinstance(item, dict) else None
640
649
  if args.output and schema is not None:
641
- Path(args.output).write_text(_yaml_dump_or_json(schema), encoding="utf-8")
650
+ write_err = _write_text_file(args.output, _yaml_dump_or_json(schema), stage="schema")
651
+ if write_err is not None:
652
+ return write_err
642
653
  return (CliOutput(OutputKind.XMD_SCHEMA, item), m.EXIT_OK)
643
654
 
644
655
 
@@ -87,10 +87,6 @@ def redirect_uri_for_port(port: int) -> str:
87
87
  return f"http://127.0.0.1:{port}{CALLBACK_PATH}"
88
88
 
89
89
 
90
- def redirect_uri() -> str:
91
- return redirect_uri_for_port(CALLBACK_PORT)
92
-
93
-
94
90
  def new_verifier() -> str:
95
91
  return secrets.token_urlsafe(32)
96
92
 
@@ -151,8 +147,17 @@ def write_login_handoff_prompt(
151
147
  "",
152
148
  f"Authorize {CLI_NAME} on this machine.",
153
149
  "",
154
- _login_prompt_line("Browser", "Open this URL and click \"Use this key in the CLI\":"),
150
+ _login_prompt_line("Browser", "Open this URL in your browser:"),
155
151
  f"{body_indent}{login_url}",
152
+ "",
153
+ _login_prompt_line(
154
+ "Onboarding",
155
+ "During first-instance setup the Console auto-delivers the key — no extra clicks.",
156
+ ),
157
+ _login_prompt_line(
158
+ "After onboarding",
159
+ "Create a key on API Keys and click \"Use this key in the CLI\".",
160
+ ),
156
161
  ]
157
162
  if offer_paste:
158
163
  lines.extend(
@@ -168,7 +173,6 @@ def write_login_handoff_prompt(
168
173
  [
169
174
  "",
170
175
  _login_prompt_line("Manual", "Set $XMEM_API_KEY or `.xmemrc.json`"),
171
- f"{body_indent}See AUTH-FLOW.md.",
172
176
  "",
173
177
  ]
174
178
  )
@@ -331,8 +335,7 @@ def redeem_token(
331
335
  continue
332
336
  hint = (
333
337
  "Could not reach the accounts API token endpoint. "
334
- "Set $XMEM_CONSOLE_URL to your Console (e.g. dk-web…) and ensure it can reach the API "
335
- "(ACCOUNTS_API_URL on the Console server)."
338
+ "Set $XMEM_CONSOLE_URL to your Console origin and retry, or pass --console-url."
336
339
  )
337
340
  detail = "; ".join(errors[-3:]) if errors else "no endpoints tried"
338
341
  raise LoginError(f"token redeem failed: {detail}. {hint}")
@@ -380,7 +383,7 @@ def _read_pasted_api_key_no_echo(*, stop: threading.Event) -> str | None:
380
383
  return None
381
384
  finally:
382
385
  termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
383
- # DELIBERATELY print: newline after raw-mode paste must not touch stdout (machine JSON).
386
+ # DELIBERATELY write to stderr: newline after raw-mode paste must not touch stdout (machine JSON).
384
387
  sys.stderr.write("\n")
385
388
  sys.stderr.flush()
386
389
 
@@ -19,9 +19,9 @@ keep strictly separated:
19
19
 
20
20
  To keep the dispatch logic in one place and force every new subcommand to
21
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
22
+ directly. They build a `CliOutput` (a discriminated payload tagged
23
+ with an `OutputKind`), and the entry point in ``cli/xmem.py`` decides
24
+ how to render it via `emit`. The pretty-print branch lives in
25
25
  ``cli/_pretty.py`` so the formatting code can grow without cluttering the
26
26
  HTTP and argparse plumbing.
27
27
 
@@ -33,13 +33,13 @@ Invariants
33
33
  - Pretty-print is only ever reached when ``stdout.isatty()`` is true AND
34
34
  ``--json`` was not passed. Anything else gets JSON.
35
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
36
+ for manual E2E runbooks only (see `ENV_FORCE_TTY_OUTPUT_NOT_JSON`). It does
37
37
  not change server behavior, credentials, or API payloads — only how ``emit`` chooses
38
38
  between JSON and pretty text. Agents, CI, and piped scripts must never set it;
39
39
  they use ``--json`` or a real TTY. If unset (the normal case), the non-TTY⇒JSON
40
40
  rule is unchanged.
41
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
42
+ mode is in effect — `OutputKind.ERROR` + a non-zero exit code. We
43
43
  do not silently dump human prose to stderr in machine mode.
44
44
  - The pretty-print renderers may fall back to JSON if they can't extract a
45
45
  useful summary from the payload; that is intentional and keeps human
@@ -48,11 +48,11 @@ Invariants
48
48
  Wire format
49
49
  -----------
50
50
 
51
- The JSON document emitted by :func:`emit` is the CURATED payload built by
51
+ The JSON document emitted by `emit` is the CURATED payload built by
52
52
  the subcommand (see the ``_curate_*`` helpers in ``cli/xmem.py``) — the
53
53
  fields the user asked for, with server internals (SQL, diff plans, raw
54
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
55
+ second envelope keyed by `OutputKind` — adding a layer the consumer
56
56
  didn't ask for would make scripts more fragile, not less. The kind is only
57
57
  used internally for pretty-print dispatch.
58
58
  """
@@ -98,7 +98,7 @@ class OutputKind(StrEnum):
98
98
 
99
99
  #: Read in ``mode=xresponse``. Curated payload: the reader_result
100
100
  #: subtree shaped as an XResponse (objects + relations). Pretty-print
101
- #: renders as JSON for the same reason as :attr:`READ_RAW`.
101
+ #: renders as JSON for the same reason as `READ_RAW`.
102
102
  READ_XRESPONSE = "read_xresponse"
103
103
 
104
104
  #: Output of ``xmemcli org list keys``. Curated payload: a list of
@@ -109,18 +109,18 @@ class OutputKind(StrEnum):
109
109
  #: Output of ``xmemcli org list clusters``.
110
110
  CLUSTER_LIST = "cluster_list"
111
111
 
112
- #: Output of ``xmem auth status``. Curated payload:
112
+ #: Output of ``xmemcli auth status``. Curated payload:
113
113
  #: ``{authenticated: bool, key_prefix, email?}`` — whose key the CLI is
114
114
  #: holding, resolved via ``/whoami``.
115
115
  AUTH_STATUS = "auth_status"
116
116
 
117
- #: Output of ``xmem auth login`` / ``xmem auth logout``.
117
+ #: Output of ``xmemcli auth login`` / ``xmemcli auth logout``.
118
118
  LOGIN = "login"
119
119
 
120
- #: Output of ``xmem version``.
120
+ #: Output of ``xmemcli version``.
121
121
  VERSION = "version"
122
122
 
123
- #: Local-only release health (``xmem status``) — no network.
123
+ #: Local-only release health (``xmemcli status``) — no network.
124
124
  STATUS = "status"
125
125
 
126
126
  #: Output of ``xmemcli quota``. Curated payload: usage windows.
@@ -129,31 +129,31 @@ class OutputKind(StrEnum):
129
129
  #: Output of ``xmemcli org list instances`` (and ``instance list`` alias).
130
130
  INSTANCE_LIST = "instance_list"
131
131
 
132
- #: Output of ``xmem instance get``.
132
+ #: Output of ``xmemcli instance get``.
133
133
  INSTANCE_GET = "instance_get"
134
134
 
135
- #: Output of ``xmem instance create``.
135
+ #: Output of ``xmemcli instance create``.
136
136
  INSTANCE_CREATE = "instance_create"
137
137
 
138
- #: Output of ``xmem instance delete``.
138
+ #: Output of ``xmemcli instance delete``.
139
139
  INSTANCE_DELETE = "instance_delete"
140
140
 
141
- #: Output of ``xmem schema update``.
141
+ #: Output of ``xmemcli schema update``.
142
142
  SCHEMA_UPDATE = "schema_update"
143
143
 
144
- #: Output of ``xmem schema dry-run``.
144
+ #: Output of ``xmemcli schema dry-run``.
145
145
  SCHEMA_DRY_RUN = "schema_dry_run"
146
146
 
147
- #: Output of ``xmem schema migrations list`` / ``get``.
147
+ #: Output of ``xmemcli schema migrations list`` / ``get``.
148
148
  SCHEMA_MIGRATIONS = "schema_migrations"
149
149
 
150
- #: Output of ``xmem xmd generate`` / ``enhance`` / ``validate`` / ``schema get``.
150
+ #: Output of ``xmemcli xmd generate`` / ``enhance`` / ``validate`` / ``schema get``.
151
151
  XMD_SCHEMA = "xmd_schema"
152
152
 
153
- #: Output of ``xmem xmd enhance`` when a migration plan is included.
153
+ #: Output of ``xmemcli xmd enhance`` when a migration plan is included.
154
154
  XMD_ENHANCE = "xmd_enhance"
155
155
 
156
- #: Output of ``xmem xmd convert``.
156
+ #: Output of ``xmemcli xmd convert``.
157
157
  XMD_CONVERT = "xmd_convert"
158
158
 
159
159
  #: Structured help document from ``build_help_document`` (overview, options,
@@ -163,7 +163,7 @@ class OutputKind(StrEnum):
163
163
 
164
164
  #: A failure surfaced as structured output. ``payload`` carries
165
165
  #: ``{"error": str, "status": int|null, "stage": str}`` — see
166
- #: :func:`make_error`.
166
+ #: `make_error`.
167
167
  ERROR = "error"
168
168
 
169
169
 
@@ -183,7 +183,7 @@ class CliOutput(NamedTuple):
183
183
 
184
184
 
185
185
  def make_error(message: str, *, status: int | None = None, stage: str = "request") -> CliOutput:
186
- """Construct an :class:`OutputKind.ERROR` payload.
186
+ """Construct an `OutputKind.ERROR` payload.
187
187
 
188
188
  ``status`` is the HTTP status code when the failure originated from a
189
189
  server response; ``stage`` is a free-form label naming the step that
@@ -219,8 +219,8 @@ def stdout_is_tty() -> bool:
219
219
  don't implement it.
220
220
 
221
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``
222
+ TTY so `emit` uses pretty one-liners on piped/captured stdout. Manual Case 4
223
+ only; see `ENV_FORCE_TTY_OUTPUT_NOT_JSON`. Does not apply when ``force_json``
224
224
  is true (``--json``).
225
225
  """
226
226
  override = _env_tty_override()
@@ -249,7 +249,7 @@ def emit(
249
249
  ``True`` when ``--json`` was passed. Pretty-print is suppressed
250
250
  unconditionally and the renderer emits exactly one JSON document.
251
251
  is_tty
252
- Override for :func:`stdout_is_tty`. Tests can pass an explicit
252
+ Override for `stdout_is_tty`. Tests can pass an explicit
253
253
  boolean to exercise both branches without monkey-patching.
254
254
  stream
255
255
  Defaults to ``sys.stdout``. Tests pass ``io.StringIO()`` here to
@@ -1,20 +1,20 @@
1
- """Human-friendly renderers for :mod:`_output` payloads.
1
+ """Human-friendly renderers for `cli._output` payloads.
2
2
 
3
3
  This module is **only** reached when the operator is sitting at a real
4
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
5
+ invocation gets strict JSON via `cli._output.emit` — see that module's
6
6
  docstring for the rationale.
7
7
 
8
8
  The renderers are deliberately small, opinionated, and one-shot: they print
9
9
  a single short summary (often a single line) keyed off the
10
- :class:`~cli._output.OutputKind`. The principle is "give the operator the
10
+ `OutputKind`. The principle is "give the operator the
11
11
  headline they would have wanted to read aloud", not "render the entire
12
12
  payload". If the renderer can't extract a useful headline, it falls through
13
13
  to a JSON dump of the relevant sub-tree so the user still sees something
14
14
  truthful rather than a guess.
15
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
16
+ Adding a new `OutputKind` requires adding a renderer
17
+ here keyed by the enum value in `_RENDERERS`. The default handler
18
18
  emits the JSON payload, which is a safe (if uninspired) fallback.
19
19
  """
20
20
 
@@ -31,10 +31,10 @@ Renderer = Callable[[Any, IO[str]], None]
31
31
 
32
32
 
33
33
  def render(kind: str | Enum, payload: Any, stream: IO[str]) -> None:
34
- """Dispatch to a renderer by :class:`OutputKind`.
34
+ """Dispatch to a renderer by `OutputKind`.
35
35
 
36
36
  Unknown kinds (or any future kind without a registered renderer) fall
37
- through to :func:`_default_handler`, which dumps the payload as JSON.
37
+ through to `_default_handler`, which dumps the payload as JSON.
38
38
  """
39
39
  dispatch_key = kind.value if isinstance(kind, Enum) else kind
40
40
  handler = _RENDERERS.get(dispatch_key, _default_handler)
@@ -1,4 +1,4 @@
1
1
  """Release metadata for the xmemcli CLI (copied into standalone builds)."""
2
2
 
3
3
  CLI_NAME = "xmemcli"
4
- __version__ = "0.0.2"
4
+ __version__ = "0.0.4"
@@ -4,10 +4,10 @@ This file is the CLI entry point. It deliberately uses only the standard
4
4
  library so the demo runs anywhere a Python 3.12+ interpreter exists without
5
5
  ``pip install``. The interesting structural decision is that **commands do
6
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
7
+ `cli._output.CliOutput` and returns ``(output, exit_code)``. The
8
+ ``main`` dispatch then emits via `cli._output.emit`, which picks
9
9
  between strict JSON (machine consumption) and a one-line human summary
10
- (interactive terminal) per the rules documented in :mod:`_output`.
10
+ (interactive terminal) per the rules documented in `cli._output`.
11
11
 
12
12
  Endpoints exercised (under ``$XMEM_API_URL`` unless noted; default
13
13
  ``https://api.xmemory.ai``):
@@ -120,6 +120,8 @@ EXIT_TIMEOUT = 5
120
120
  # ``QuotaExceededKind`` discriminator on the wire body so the printed
121
121
  # message can match (TRIAL ENDED / DAILY QUOTA / MONTHLY QUOTA).
122
122
  EXIT_QUOTA_EXCEEDED = 6
123
+ # Local quota headroom gate (`quota --fail-if-under`) — distinct from API 429 / exhausted quota.
124
+ EXIT_QUOTA_THRESHOLD = 7
123
125
  # Conventional shell exit for SIGINT (128 + 2) — no stack trace, not a usage error.
124
126
  EXIT_INTERRUPTED = 130
125
127
 
@@ -471,7 +473,7 @@ class UsageError(Exception):
471
473
  an unset instance id, or the localhost-key-at-prod guard.
472
474
 
473
475
  It carries an ``OutputKind.ERROR`` payload so ``main`` renders it through
474
- :func:`emit` like any other result. That keeps the machine-mode invariant:
476
+ `emit` like any other result. That keeps the machine-mode invariant:
475
477
  a failed command still emits **exactly one** JSON document on stdout, never
476
478
  bare prose to stderr (see ``cli/_output.py``).
477
479
  """
@@ -845,6 +847,7 @@ def cmd_auth_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
845
847
  "logged_in": True,
846
848
  "email": auth_api_key.get("email"),
847
849
  "key_prefix": api_key[:8],
850
+ "onboarding": bool(auth_api_key.get("onboarding")),
848
851
  },
849
852
  ),
850
853
  EXIT_OK,
@@ -985,7 +988,7 @@ def _resolve_cluster_id(
985
988
 
986
989
  Order: operator override → instance context → sole visible cluster.
987
990
  Agent-facing errors steer toward ``--instance-id``, not cluster ID
988
- (see ``maxims/API-CLIENTS.yml`` — *One addressing axis for agents*).
991
+ Agent-facing errors steer toward ``--instance-id``, not cluster ID (one addressing axis for agents).
989
992
  """
990
993
  override = _operator_cluster_override(args)
991
994
  if override:
@@ -1191,7 +1194,7 @@ def cmd_quota_get(args: argparse.Namespace) -> tuple[CliOutput, int]:
1191
1194
  and not isinstance(remaining, bool)
1192
1195
  and (remaining / limit) < fail_threshold
1193
1196
  ):
1194
- exit_code = EXIT_QUOTA_EXCEEDED
1197
+ exit_code = EXIT_QUOTA_THRESHOLD
1195
1198
  return (CliOutput(OutputKind.QUOTA, curated), exit_code)
1196
1199
 
1197
1200
 
@@ -1225,33 +1228,32 @@ def cmd_status(args: argparse.Namespace) -> tuple[CliOutput, int]:
1225
1228
  return (CliOutput(OutputKind.STATUS, payload), EXIT_OK)
1226
1229
 
1227
1230
 
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
1231
  # ── argparse plumbing ──────────────────────────────────────────────────────
1252
1232
 
1253
1233
 
1254
1234
  def _build_parser() -> argparse.ArgumentParser:
1235
+ from ._instance_schema import (
1236
+ cmd_instance_create,
1237
+ cmd_instance_delete,
1238
+ cmd_instance_get,
1239
+ cmd_schema_dry_run,
1240
+ cmd_schema_get,
1241
+ cmd_schema_migrations_get,
1242
+ cmd_schema_migrations_list,
1243
+ cmd_schema_update,
1244
+ cmd_xmd_convert,
1245
+ cmd_xmd_enhance,
1246
+ cmd_xmd_generate,
1247
+ cmd_xmd_validate,
1248
+ cmd_xmd_validate_json,
1249
+ cmd_xmd_validate_yaml,
1250
+ )
1251
+ from ._org_cluster import (
1252
+ cmd_org_list_clusters,
1253
+ cmd_org_list_instances,
1254
+ cmd_org_list_keys,
1255
+ )
1256
+
1255
1257
  p = _help.XmemCliArgumentParser(
1256
1258
  prog=CLI_NAME,
1257
1259
  description="xmemcli — CLI for the xmemory HTTP API (read/write, instance ops, key list).",
@@ -1263,7 +1265,7 @@ def _build_parser() -> argparse.ArgumentParser:
1263
1265
  "--rc-dir",
1264
1266
  help=f"Directory for {RC_FILENAME} (auth login writes here; reads walk up to $HOME)",
1265
1267
  )
1266
- # Operator-only override; omitted from --help (maxims/API-CLIENTS.yml — One addressing axis for agents).
1268
+ # Operator-only cluster override; omitted from --help (not agent-facing).
1267
1269
  p.add_argument("--cluster-id", help=argparse.SUPPRESS)
1268
1270
  p.add_argument(
1269
1271
  "--json",
@@ -1528,6 +1530,12 @@ def _build_parser() -> argparse.ArgumentParser:
1528
1530
  help="Fetch one migration record by id (MCP get_schema_migration)",
1529
1531
  add_help=False,
1530
1532
  )
1533
+ pschmg.add_argument(
1534
+ "instance_id_arg",
1535
+ metavar="instance_id",
1536
+ nargs="?",
1537
+ help=f"Instance id (default ${ENV_INSTANCE_ID} or --instance-id)",
1538
+ )
1531
1539
  pschmg.add_argument(
1532
1540
  "migration_id_arg",
1533
1541
  metavar="migration_id",
@@ -1632,13 +1640,7 @@ def _build_parser() -> argparse.ArgumentParser:
1632
1640
  return p
1633
1641
 
1634
1642
 
1635
- _WRITE_STATUS_ALIASES = frozenset({
1636
- "write_status",
1637
- "writestatus",
1638
- "async_write_status",
1639
- "asyncwritestatus",
1640
- "async-write-status",
1641
- })
1643
+ _WRITE_STATUS_ALIASES = _help._WRITE_STATUS_ALIAS_NAMES
1642
1644
 
1643
1645
  # Silent auth shortcuts — normalized before parse; not listed in the overview.
1644
1646
  _AUTH_LOGIN_ALIASES = frozenset({"login", "signin"})
@@ -1679,6 +1681,17 @@ def _auto_resolve_single_instance_id(args: argparse.Namespace, api_key: str) ->
1679
1681
  return instance_id, warning
1680
1682
 
1681
1683
 
1684
+ def _normalize_org_list_instances_argv(stripped: list[str]) -> list[str] | None:
1685
+ """Map silent ``instances`` / ``list instances`` spellings to ``org list instances``."""
1686
+ if not stripped:
1687
+ return None
1688
+ if stripped[0] == "instances":
1689
+ return ["org", "list", "instances", *stripped[1:]]
1690
+ if len(stripped) >= 2 and stripped[0] == "list" and stripped[1] == "instances":
1691
+ return ["org", "list", "instances", *stripped[2:]]
1692
+ return None
1693
+
1694
+
1682
1695
  def _normalize_auth_argv(stripped: list[str]) -> list[str] | None:
1683
1696
  """Map silent login/logout spellings to ``auth login`` / ``auth logout``."""
1684
1697
  if not stripped:
@@ -1726,36 +1739,17 @@ def _normalize_argv(argv: list[str]) -> list[str]:
1726
1739
  return ["write-status", *stripped[1:]]
1727
1740
  if len(stripped) >= 2 and stripped[0] == "quota" and stripped[1] == "get":
1728
1741
  return ["quota", *stripped[2:]]
1742
+ org_list_instances = _normalize_org_list_instances_argv(stripped)
1743
+ if org_list_instances is not None:
1744
+ return org_list_instances
1729
1745
  auth_normalized = _normalize_auth_argv(stripped)
1730
1746
  if auth_normalized is not None:
1731
1747
  return auth_normalized
1732
1748
  return stripped
1733
1749
 
1734
1750
 
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
1751
  def _parse_argv(argv: list[str]) -> list[str]:
1758
- """``argv`` for :func:`argparse.ArgumentParser.parse_args` — globals + normalized command."""
1752
+ """``argv`` for `argparse.ArgumentParser.parse_args` — globals + normalized command."""
1759
1753
  globals_part, stripped = _help.collect_global_flags(argv)
1760
1754
  command = _normalize_argv(stripped)
1761
1755
  if not command:
@@ -1768,12 +1762,13 @@ def _emit_help(
1768
1762
  path: tuple[str, ...],
1769
1763
  *,
1770
1764
  machine_json: bool,
1771
- ) -> None:
1765
+ ) -> dict[str, Any]:
1772
1766
  doc = _help.build_help_document(parser, path)
1773
1767
  if machine_json:
1774
1768
  emit(CliOutput(OutputKind.HELP, doc), force_json=True)
1775
1769
  else:
1776
1770
  _help.render_help_document(doc)
1771
+ return doc
1777
1772
 
1778
1773
 
1779
1774
  def main(argv: list[str] | None = None) -> int:
@@ -1786,7 +1781,9 @@ def main(argv: list[str] | None = None) -> int:
1786
1781
  return EXIT_OK
1787
1782
  help_path = _help.help_target(command)
1788
1783
  if help_path is not None:
1789
- _emit_help(parser, help_path, machine_json=machine_json)
1784
+ doc = _emit_help(parser, help_path, machine_json=machine_json)
1785
+ if doc.get("kind") == "unknown":
1786
+ return EXIT_USAGE
1790
1787
  return EXIT_OK
1791
1788
  try:
1792
1789
  args = parser.parse_args(_parse_argv(argv_list))
@@ -1809,7 +1806,11 @@ def main(argv: list[str] | None = None) -> int:
1809
1806
  except UsageError as exc:
1810
1807
  # Pre-flight resolution errors (missing key, malformed rc, …) still go
1811
1808
  # 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)
1809
+ force_json = machine_json or args.json
1810
+ emit(
1811
+ _with_cli_warnings(exc.output, args, force_json=force_json),
1812
+ force_json=force_json,
1813
+ )
1813
1814
  return exc.exit_code
1814
1815
  except KeyboardInterrupt:
1815
1816
  if machine_json or args.json:
File without changes
File without changes
File without changes
File without changes
File without changes