xmemcli 0.0.3__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.
- {xmemcli-0.0.3 → xmemcli-0.0.4}/PKG-INFO +31 -10
- {xmemcli-0.0.3 → xmemcli-0.0.4}/README.md +30 -9
- {xmemcli-0.0.3 → xmemcli-0.0.4}/pyproject.toml +1 -1
- {xmemcli-0.0.3 → xmemcli-0.0.4}/uv.lock +1 -1
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/__init__.py +1 -1
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_help.py +23 -7
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_instance_schema.py +57 -46
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_login.py +12 -9
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_output.py +26 -26
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_pretty.py +7 -7
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_version.py +1 -1
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/cli.py +64 -63
- {xmemcli-0.0.3 → xmemcli-0.0.4}/.gitignore +0 -0
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_envelope.py +0 -0
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_org_cluster.py +0 -0
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_rc.py +0 -0
- {xmemcli-0.0.3 → xmemcli-0.0.4}/xmemcli/_xmem_bridge.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xmemcli
|
|
3
|
-
Version: 0.0.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -212,7 +218,7 @@ XMD (xmemory Data) is the typed schema format for instances. These commands mirr
|
|
|
212
218
|
| Apply migration | **`schema update`** `[instance_id]` | `--schema-file`, optional `--migration-plan`, `--confirm-destructive`. |
|
|
213
219
|
| Preview migration | **`schema dry-run`** `[instance_id]` | Same flags as `schema update`; returns planned DDL without applying. |
|
|
214
220
|
| List migrations | **`schema migrations list`** `[instance_id]` | Newest first. `--limit`, `--before-id`, `--include-yaml`. |
|
|
215
|
-
| Fetch one migration | **`schema migrations get`** `<migration_id>` |
|
|
221
|
+
| Fetch one migration | **`schema migrations get`** `[instance_id]` `<migration_id>` | Optional positional instance id; else `--instance-id` / `$XMEM_INSTANCE_ID`. `--include-yaml`. |
|
|
216
222
|
| Enhance bound schema | **`xmd enhance --from-instance`** `<description>` | Uses live schema; requires instance id. |
|
|
217
223
|
| Synthesize schema | **`xmd generate`** `<description>` | Cluster-scoped LLM synthesis. `-o` writes YAML. |
|
|
218
224
|
| Enhance from file | **`xmd enhance`** `[schema_file]` `<description>` | From a file, or `--from-instance` for bound enhance. `-o`, `--plan-output`. |
|
|
@@ -239,7 +245,7 @@ xmemcli schema update INST_ID --schema-file next.yml --migration-plan plan.json
|
|
|
239
245
|
|
|
240
246
|
# 4. Inspect history
|
|
241
247
|
xmemcli schema migrations list INST_ID
|
|
242
|
-
xmemcli schema migrations get MIGRATION_UUID
|
|
248
|
+
xmemcli schema migrations get INST_ID MIGRATION_UUID
|
|
243
249
|
```
|
|
244
250
|
|
|
245
251
|
#### Commands
|
|
@@ -250,7 +256,7 @@ xmemcli schema migrations get MIGRATION_UUID
|
|
|
250
256
|
| **`schema update`** `[instance_id]` | Replace schema from `--schema-file`. Optional `--schema-type`, `--migration-plan`, `--confirm-destructive`. |
|
|
251
257
|
| **`schema dry-run`** `[instance_id]` | Preview migration (same schema/plan flags as update). |
|
|
252
258
|
| **`schema migrations list`** `[instance_id]` | Applied migrations, newest first. `--limit` (default 50), `--before-id`, `--include-yaml`. |
|
|
253
|
-
| **`schema migrations get`** `<migration_id>` | One record
|
|
259
|
+
| **`schema migrations get`** `[instance_id]` `<migration_id>` | One record. Optional positional instance id; else global instance flags. `--include-yaml`. |
|
|
254
260
|
| **`xmd generate`** `<description>` | LLM-synthesize XMD. Optional `-o`. Requires cluster resolution. |
|
|
255
261
|
| **`xmd enhance`** `[schema_file]` `<description>` | LLM-augment schema from file, or **`--from-instance`** for live schema. `-o`, `--plan-output`. |
|
|
256
262
|
| **`xmd validate`** `<schema_file>` | Validate; YAML vs JSON inferred from `.json` extension. |
|
|
@@ -258,7 +264,7 @@ xmemcli schema migrations get MIGRATION_UUID
|
|
|
258
264
|
| **`xmd validate-json`** `<schema_file>` | Force JSON Schema validation endpoint. |
|
|
259
265
|
| **`xmd convert`** `<json_schema_file>` | JSON Schema → XMD YAML via the API (`validate_json_schema`). Optional `-o`, `--root-object`. |
|
|
260
266
|
|
|
261
|
-
Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID
|
|
267
|
+
Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID`**.
|
|
262
268
|
|
|
263
269
|
---
|
|
264
270
|
|
|
@@ -286,10 +292,23 @@ Instance id for `schema *` commands: positional `[instance_id]`, else **`--insta
|
|
|
286
292
|
| **`quota`** | Usage / limit / window start for the key's cluster (derived from instance → cluster). |
|
|
287
293
|
|
|
288
294
|
- **Default:** exit `0` when quota is readable; stderr **warning** when usage is high.
|
|
289
|
-
- **`--fail-if-under THRESHOLD`** — exit
|
|
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`.
|
|
290
296
|
|
|
291
297
|
Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-xmemcli-does-not-do).
|
|
292
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
|
+
|
|
293
312
|
---
|
|
294
313
|
|
|
295
314
|
## Help
|
|
@@ -304,6 +323,8 @@ Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-
|
|
|
304
323
|
| **`xmemcli help <topic>`** | Usage, options, and subcommands for that topic |
|
|
305
324
|
| **`xmemcli <cmd> … -h`** / **`--help`** | Same as `help <topic>` for that command path |
|
|
306
325
|
|
|
326
|
+
Unknown help topics exit **2** (same as other usage errors).
|
|
327
|
+
|
|
307
328
|
**Every command in this document has help.** Examples:
|
|
308
329
|
|
|
309
330
|
```text
|
|
@@ -348,7 +369,7 @@ Nested topics work at any level that exists in the parser (`help org`, `help org
|
|
|
348
369
|
| **Cluster create/delete** | CLI lists clusters; provisioning in Console |
|
|
349
370
|
| **`read --extraction deep`** | Server has no read-depth knob today |
|
|
350
371
|
| **Raw REST proxy** | Curated response fields only |
|
|
351
|
-
| **Instance deletion** | **
|
|
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. |
|
|
352
373
|
|
|
353
374
|
On HTTP **402** (quota exhausted), stop and ask a human to extend quota in the Console.
|
|
354
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -204,7 +210,7 @@ XMD (xmemory Data) is the typed schema format for instances. These commands mirr
|
|
|
204
210
|
| Apply migration | **`schema update`** `[instance_id]` | `--schema-file`, optional `--migration-plan`, `--confirm-destructive`. |
|
|
205
211
|
| Preview migration | **`schema dry-run`** `[instance_id]` | Same flags as `schema update`; returns planned DDL without applying. |
|
|
206
212
|
| List migrations | **`schema migrations list`** `[instance_id]` | Newest first. `--limit`, `--before-id`, `--include-yaml`. |
|
|
207
|
-
| Fetch one migration | **`schema migrations get`** `<migration_id>` |
|
|
213
|
+
| Fetch one migration | **`schema migrations get`** `[instance_id]` `<migration_id>` | Optional positional instance id; else `--instance-id` / `$XMEM_INSTANCE_ID`. `--include-yaml`. |
|
|
208
214
|
| Enhance bound schema | **`xmd enhance --from-instance`** `<description>` | Uses live schema; requires instance id. |
|
|
209
215
|
| Synthesize schema | **`xmd generate`** `<description>` | Cluster-scoped LLM synthesis. `-o` writes YAML. |
|
|
210
216
|
| Enhance from file | **`xmd enhance`** `[schema_file]` `<description>` | From a file, or `--from-instance` for bound enhance. `-o`, `--plan-output`. |
|
|
@@ -231,7 +237,7 @@ xmemcli schema update INST_ID --schema-file next.yml --migration-plan plan.json
|
|
|
231
237
|
|
|
232
238
|
# 4. Inspect history
|
|
233
239
|
xmemcli schema migrations list INST_ID
|
|
234
|
-
xmemcli schema migrations get MIGRATION_UUID
|
|
240
|
+
xmemcli schema migrations get INST_ID MIGRATION_UUID
|
|
235
241
|
```
|
|
236
242
|
|
|
237
243
|
#### Commands
|
|
@@ -242,7 +248,7 @@ xmemcli schema migrations get MIGRATION_UUID
|
|
|
242
248
|
| **`schema update`** `[instance_id]` | Replace schema from `--schema-file`. Optional `--schema-type`, `--migration-plan`, `--confirm-destructive`. |
|
|
243
249
|
| **`schema dry-run`** `[instance_id]` | Preview migration (same schema/plan flags as update). |
|
|
244
250
|
| **`schema migrations list`** `[instance_id]` | Applied migrations, newest first. `--limit` (default 50), `--before-id`, `--include-yaml`. |
|
|
245
|
-
| **`schema migrations get`** `<migration_id>` | One record
|
|
251
|
+
| **`schema migrations get`** `[instance_id]` `<migration_id>` | One record. Optional positional instance id; else global instance flags. `--include-yaml`. |
|
|
246
252
|
| **`xmd generate`** `<description>` | LLM-synthesize XMD. Optional `-o`. Requires cluster resolution. |
|
|
247
253
|
| **`xmd enhance`** `[schema_file]` `<description>` | LLM-augment schema from file, or **`--from-instance`** for live schema. `-o`, `--plan-output`. |
|
|
248
254
|
| **`xmd validate`** `<schema_file>` | Validate; YAML vs JSON inferred from `.json` extension. |
|
|
@@ -250,7 +256,7 @@ xmemcli schema migrations get MIGRATION_UUID
|
|
|
250
256
|
| **`xmd validate-json`** `<schema_file>` | Force JSON Schema validation endpoint. |
|
|
251
257
|
| **`xmd convert`** `<json_schema_file>` | JSON Schema → XMD YAML via the API (`validate_json_schema`). Optional `-o`, `--root-object`. |
|
|
252
258
|
|
|
253
|
-
Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID
|
|
259
|
+
Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID`**.
|
|
254
260
|
|
|
255
261
|
---
|
|
256
262
|
|
|
@@ -278,10 +284,23 @@ Instance id for `schema *` commands: positional `[instance_id]`, else **`--insta
|
|
|
278
284
|
| **`quota`** | Usage / limit / window start for the key's cluster (derived from instance → cluster). |
|
|
279
285
|
|
|
280
286
|
- **Default:** exit `0` when quota is readable; stderr **warning** when usage is high.
|
|
281
|
-
- **`--fail-if-under THRESHOLD`** — exit
|
|
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`.
|
|
282
288
|
|
|
283
289
|
Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-xmemcli-does-not-do).
|
|
284
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
|
+
|
|
285
304
|
---
|
|
286
305
|
|
|
287
306
|
## Help
|
|
@@ -296,6 +315,8 @@ Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-
|
|
|
296
315
|
| **`xmemcli help <topic>`** | Usage, options, and subcommands for that topic |
|
|
297
316
|
| **`xmemcli <cmd> … -h`** / **`--help`** | Same as `help <topic>` for that command path |
|
|
298
317
|
|
|
318
|
+
Unknown help topics exit **2** (same as other usage errors).
|
|
319
|
+
|
|
299
320
|
**Every command in this document has help.** Examples:
|
|
300
321
|
|
|
301
322
|
```text
|
|
@@ -340,7 +361,7 @@ Nested topics work at any level that exists in the parser (`help org`, `help org
|
|
|
340
361
|
| **Cluster create/delete** | CLI lists clusters; provisioning in Console |
|
|
341
362
|
| **`read --extraction deep`** | Server has no read-depth knob today |
|
|
342
363
|
| **Raw REST proxy** | Curated response fields only |
|
|
343
|
-
| **Instance deletion** | **
|
|
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. |
|
|
344
365
|
|
|
345
366
|
On HTTP **402** (quota exhausted), stop and ask a human to extend quota in the Console.
|
|
346
367
|
|
|
@@ -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
|
|
|
@@ -33,6 +33,17 @@ _INTERNAL_DESTS = frozenset(
|
|
|
33
33
|
)
|
|
34
34
|
# argparse alias names we hide from the auto-generated overview (canonical names only).
|
|
35
35
|
_HIDDEN_COMMAND_NAMES = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"write_status",
|
|
38
|
+
"writestatus",
|
|
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(
|
|
36
47
|
{
|
|
37
48
|
"write_status",
|
|
38
49
|
"writestatus",
|
|
@@ -184,7 +195,10 @@ def _option_label(action: argparse.Action) -> str:
|
|
|
184
195
|
return f"{longest} …"
|
|
185
196
|
return longest
|
|
186
197
|
if action.metavar:
|
|
187
|
-
|
|
198
|
+
meta = action.metavar
|
|
199
|
+
if isinstance(meta, tuple):
|
|
200
|
+
return " ".join(meta)
|
|
201
|
+
return meta
|
|
188
202
|
return action.dest
|
|
189
203
|
|
|
190
204
|
|
|
@@ -499,7 +513,7 @@ def _render_unknown_document(doc: dict[str, Any], stream: IO[str], style: _Style
|
|
|
499
513
|
|
|
500
514
|
|
|
501
515
|
def render_help_document(doc: dict[str, Any], *, stream: IO[str] | None = None) -> None:
|
|
502
|
-
"""Render a
|
|
516
|
+
"""Render a `build_help_document` payload for human terminals."""
|
|
503
517
|
out = stream if stream is not None else sys.stdout
|
|
504
518
|
style = _Style(_use_color(out))
|
|
505
519
|
kind = doc.get("kind")
|
|
@@ -556,13 +570,14 @@ def _format_cli_parse_error(message: str) -> str:
|
|
|
556
570
|
|
|
557
571
|
|
|
558
572
|
class XmemCliArgumentParser(argparse.ArgumentParser):
|
|
559
|
-
"""ArgumentParser that raises
|
|
573
|
+
"""ArgumentParser that raises `CliParseError` instead of dumping usage."""
|
|
560
574
|
|
|
561
575
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
562
576
|
kwargs.setdefault("usage", argparse.SUPPRESS)
|
|
563
577
|
kwargs.setdefault("add_help", False)
|
|
564
578
|
super().__init__(*args, **kwargs)
|
|
565
579
|
|
|
580
|
+
@override
|
|
566
581
|
def error(self, message: str) -> NoReturn:
|
|
567
582
|
raise CliParseError(_format_cli_parse_error(message))
|
|
568
583
|
|
|
@@ -703,12 +718,13 @@ def _choice_help(sub: argparse._SubParsersAction, name: str, child: argparse.Arg
|
|
|
703
718
|
def _parser_title(root: argparse.ArgumentParser, path: tuple[str, ...], parser: argparse.ArgumentParser) -> str:
|
|
704
719
|
if parser.description:
|
|
705
720
|
return parser.description
|
|
706
|
-
if
|
|
721
|
+
if len(path) == 0:
|
|
707
722
|
return ""
|
|
708
|
-
|
|
723
|
+
*parent_path, leaf = path
|
|
724
|
+
parent = navigate_parser(root, tuple(parent_path)) if parent_path else root
|
|
709
725
|
sub = _subparsers_action(parent) if parent is not None else None
|
|
710
726
|
if sub is not None:
|
|
711
|
-
return _choice_help(sub,
|
|
727
|
+
return _choice_help(sub, leaf, parser)
|
|
712
728
|
return " ".join(path)
|
|
713
729
|
|
|
714
730
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Instance lifecycle and XMD/schema workflow commands for ``xmemcli``.
|
|
2
2
|
|
|
3
|
-
|
|
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,
|
|
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
|
-
|
|
247
|
+
url = f"{base}/clusters/{explicit_cluster}/instances/{instance_id}"
|
|
225
248
|
else:
|
|
226
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return (
|
|
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
|
-
|
|
247
|
-
|
|
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
|
|
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 ``
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
23
|
-
with an
|
|
24
|
-
how to render it via
|
|
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
|
|
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 —
|
|
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
|
|
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
|
|
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
|
|
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 ``
|
|
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 ``
|
|
117
|
+
#: Output of ``xmemcli auth login`` / ``xmemcli auth logout``.
|
|
118
118
|
LOGIN = "login"
|
|
119
119
|
|
|
120
|
-
#: Output of ``
|
|
120
|
+
#: Output of ``xmemcli version``.
|
|
121
121
|
VERSION = "version"
|
|
122
122
|
|
|
123
|
-
#: Local-only release health (``
|
|
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 ``
|
|
132
|
+
#: Output of ``xmemcli instance get``.
|
|
133
133
|
INSTANCE_GET = "instance_get"
|
|
134
134
|
|
|
135
|
-
#: Output of ``
|
|
135
|
+
#: Output of ``xmemcli instance create``.
|
|
136
136
|
INSTANCE_CREATE = "instance_create"
|
|
137
137
|
|
|
138
|
-
#: Output of ``
|
|
138
|
+
#: Output of ``xmemcli instance delete``.
|
|
139
139
|
INSTANCE_DELETE = "instance_delete"
|
|
140
140
|
|
|
141
|
-
#: Output of ``
|
|
141
|
+
#: Output of ``xmemcli schema update``.
|
|
142
142
|
SCHEMA_UPDATE = "schema_update"
|
|
143
143
|
|
|
144
|
-
#: Output of ``
|
|
144
|
+
#: Output of ``xmemcli schema dry-run``.
|
|
145
145
|
SCHEMA_DRY_RUN = "schema_dry_run"
|
|
146
146
|
|
|
147
|
-
#: Output of ``
|
|
147
|
+
#: Output of ``xmemcli schema migrations list`` / ``get``.
|
|
148
148
|
SCHEMA_MIGRATIONS = "schema_migrations"
|
|
149
149
|
|
|
150
|
-
#: Output of ``
|
|
150
|
+
#: Output of ``xmemcli xmd generate`` / ``enhance`` / ``validate`` / ``schema get``.
|
|
151
151
|
XMD_SCHEMA = "xmd_schema"
|
|
152
152
|
|
|
153
|
-
#: Output of ``
|
|
153
|
+
#: Output of ``xmemcli xmd enhance`` when a migration plan is included.
|
|
154
154
|
XMD_ENHANCE = "xmd_enhance"
|
|
155
155
|
|
|
156
|
-
#: Output of ``
|
|
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
|
-
#:
|
|
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
|
|
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
|
|
223
|
-
only; see
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
17
|
-
here keyed by the enum value in
|
|
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
|
|
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
|
|
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)
|
|
@@ -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
|
-
|
|
8
|
-
``main`` dispatch then emits via
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
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
|