xmemcli 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xmemcli/_help.py ADDED
@@ -0,0 +1,720 @@
1
+ """Styled help pages for ``xmemcli`` (scsh-inspired layout, xmemory palette).
2
+
3
+ Stdlib-only ANSI styling — no ``rich``. Color is stripped when stdout is not a
4
+ TTY or ``NO_COLOR`` is set, so tests and pipes still see plain text.
5
+ """
6
+
7
+ import argparse
8
+ import os
9
+ import re
10
+ import sys
11
+ from typing import Any, IO, NoReturn
12
+
13
+ from ._version import CLI_NAME, __version__
14
+
15
+ OPTIONS_HELP_TOPIC = "options"
16
+ _LABEL_WIDTH = 28 # overview command list only
17
+ _OPTION_DESC_INDENT = " " # description lines under a flag name
18
+ _INTERNAL_DESTS = frozenset(
19
+ {
20
+ "command",
21
+ "instance_command",
22
+ "org_command",
23
+ "org_list_target",
24
+ "cluster_command",
25
+ "cluster_list_target",
26
+ "auth_command",
27
+ "schema_command",
28
+ "schema_migrations_command",
29
+ "xmd_command",
30
+ "help",
31
+ "func",
32
+ }
33
+ )
34
+ # argparse alias names we hide from the auto-generated overview (canonical names only).
35
+ _HIDDEN_COMMAND_NAMES = frozenset(
36
+ {
37
+ "write_status",
38
+ "writestatus",
39
+ "async_write_status",
40
+ }
41
+ )
42
+ _RESET = "\x1b[0m"
43
+ _BOLD = "\x1b[1m"
44
+ _DIM = "\x1b[2m"
45
+ # Console-aligned accent green (matches public Console theme tokens).
46
+ _GREEN = "\x1b[38;2;159;232;112m" # --xm-green / agenda accent
47
+
48
+ # Short ``xmemcli help <topic>`` paths → canonical parser path (auth, write-status, quota aliases).
49
+ _HELP_TOPIC_ALIASES: dict[tuple[str, ...], tuple[str, ...]] = {
50
+ ("login",): ("auth", "login"),
51
+ ("logout",): ("auth", "logout"),
52
+ ("signin",): ("auth", "login"),
53
+ ("signout",): ("auth", "logout"),
54
+ ("logoff",): ("auth", "logout"),
55
+ ("sign", "in"): ("auth", "login"),
56
+ ("sign", "out"): ("auth", "logout"),
57
+ ("auth", "signin",): ("auth", "login"),
58
+ ("auth", "signout",): ("auth", "logout"),
59
+ ("auth", "logoff",): ("auth", "logout"),
60
+ ("auth", "sign", "in"): ("auth", "login"),
61
+ ("auth", "sign", "out"): ("auth", "logout"),
62
+ ("auth", "log", "in"): ("auth", "login"),
63
+ ("auth", "log", "out"): ("auth", "logout"),
64
+ ("log", "in"): ("auth", "login"),
65
+ ("log", "out"): ("auth", "logout"),
66
+ ("write-status",): ("write-status",),
67
+ ("write_status",): ("write-status",),
68
+ ("writestatus",): ("write-status",),
69
+ ("async_write_status",): ("write-status",),
70
+ ("asyncwritestatus",): ("write-status",),
71
+ ("async-write-status",): ("write-status",),
72
+ ("quota", "get"): ("quota",),
73
+ ("quota-get",): ("quota",),
74
+ }
75
+
76
+ # Top-level shortcuts → canonical command paths (also listed in the overview Shortcuts section).
77
+
78
+ # Curated overview — grouped; canonical command names only.
79
+ _OVERVIEW_SECTIONS: tuple[tuple[str, tuple[tuple[str, str], ...]], ...] = (
80
+ (
81
+ "Data",
82
+ (
83
+ ("write", "Write a fact to the active instance"),
84
+ ("write-status", "Poll async writes until they complete (one or more write_ids)"),
85
+ ("read", "Read from the active instance"),
86
+ ),
87
+ ),
88
+ (
89
+ "Org",
90
+ (
91
+ ("org list clusters", "Clusters visible to this credential"),
92
+ ("org list instances", "Instances visible to this credential"),
93
+ ("org list keys", "API keys visible to this credential"),
94
+ ),
95
+ ),
96
+ (
97
+ "Instances",
98
+ (
99
+ ("instance list", "List instances visible to this credential (alias for org list instances)"),
100
+ ("instance get", "Get a single instance by id"),
101
+ ("instance create", "Create a new instance from an XMD/JSON schema"),
102
+ ),
103
+ ),
104
+ (
105
+ "Schema & XMD",
106
+ (
107
+ ("schema get", "Get the live schema of an instance"),
108
+ ("schema update", "Replace the schema of an existing instance"),
109
+ ("schema dry-run", "Preview a schema migration without applying it"),
110
+ ("schema migrations list", "Migration history for an instance (newest first)"),
111
+ ("schema migrations get", "One applied migration record by id"),
112
+ ("xmd generate", "Synthesize an XMD schema from a description"),
113
+ ("xmd enhance", "Enhance (augment) an existing XMD schema"),
114
+ ("xmd validate", "Validate an XMD schema (format inferred from file extension)"),
115
+ ("xmd validate-yaml", "Validate an XMD YAML schema against the server"),
116
+ ("xmd validate-json", "Validate a JSON Schema against the server"),
117
+ ("xmd convert", "Convert JSON Schema to XMD YAML via the API"),
118
+ ),
119
+ ),
120
+ (
121
+ "Auth & meta",
122
+ (
123
+ ("auth login", "Acquire a key via Console PKCE handoff → .xmemrc.json"),
124
+ ("auth logout", "Remove the credential from the active .xmemrc.json"),
125
+ ("auth status", "Report whose key the CLI is holding (via /whoami)"),
126
+ ("version", "Print the CLI release version (no network)"),
127
+ ("status", "Local health: version and whether .xmemrc.json is present"),
128
+ ),
129
+ ),
130
+ )
131
+
132
+
133
+ def _use_color(stream: IO[str]) -> bool:
134
+ if os.environ.get("NO_COLOR"):
135
+ return False
136
+ if os.environ.get("FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE"):
137
+ return True
138
+ try:
139
+ return stream.isatty()
140
+ except (OSError, AttributeError, ValueError):
141
+ return False
142
+
143
+
144
+ class _Style:
145
+ def __init__(self, enabled: bool) -> None:
146
+ self.enabled = enabled
147
+
148
+ def green_bold(self, text: str) -> str:
149
+ if not self.enabled:
150
+ return text
151
+ return f"{_BOLD}{_GREEN}{text}{_RESET}"
152
+
153
+ def bold(self, text: str) -> str:
154
+ if not self.enabled:
155
+ return text
156
+ return f"{_BOLD}{text}{_RESET}"
157
+
158
+ def dim(self, text: str) -> str:
159
+ if not self.enabled:
160
+ return text
161
+ return f"{_DIM}{text}{_RESET}"
162
+
163
+
164
+ def _subparsers_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction | None:
165
+ for action in parser._actions:
166
+ if isinstance(action, argparse._SubParsersAction):
167
+ return action
168
+ return None
169
+
170
+
171
+ def _visible_actions(parser: argparse.ArgumentParser) -> list[argparse.Action]:
172
+ # argparse exposes actions only via private _actions; there is no stable public API.
173
+ return [a for a in parser._actions if a.help is not argparse.SUPPRESS and a.option_strings]
174
+
175
+
176
+ def _option_label(action: argparse.Action) -> str:
177
+ opts = [o for o in action.option_strings if o.startswith("-")]
178
+ if opts:
179
+ longest = opts[-1]
180
+ if action.metavar:
181
+ return f"{longest} {action.metavar}"
182
+ if action.nargs in (argparse.ONE_OR_MORE, argparse.ZERO_OR_MORE, "+", "*"):
183
+ return f"{longest} …"
184
+ return longest
185
+ if action.metavar:
186
+ return action.metavar
187
+ return action.dest
188
+
189
+
190
+ def _help_row(style: _Style, name: str, desc: str, *, width: int = _LABEL_WIDTH) -> str:
191
+ bullet = style.dim("•")
192
+ label = style.bold(f"{name:<{width}}")
193
+ return f" {bullet} {label} {style.dim(desc)}"
194
+
195
+
196
+ def _emit(stream: IO[str], text: str = "") -> None:
197
+ stream.write(text + "\n")
198
+
199
+
200
+ def _split_help_two_lines(text: str, *, first_max: int = 48) -> tuple[str, str]:
201
+ """Split a help blurb across two lines for the options page."""
202
+ collapsed = " ".join(text.split())
203
+ for sep in (". ", "; "):
204
+ if sep in collapsed:
205
+ head, tail = collapsed.split(sep, 1)
206
+ tail = tail.strip()
207
+ if head and tail:
208
+ return head + ("" if sep == "; " else "."), tail
209
+ if len(collapsed) <= first_max:
210
+ return collapsed, ""
211
+ cut = collapsed.rfind(" ", 0, first_max + 1)
212
+ if cut < 20:
213
+ cut = collapsed.find(" ", first_max)
214
+ if cut < 0:
215
+ return collapsed, ""
216
+ return collapsed[:cut], collapsed[cut + 1:]
217
+
218
+
219
+ # Two-line blurbs for global flags (overview lists `help options` instead).
220
+ _GLOBAL_OPTION_LINES: dict[str, tuple[str, str]] = {
221
+ "api_url": (
222
+ "API origin.",
223
+ "Default $XMEM_API_URL or https://api.xmemory.ai.",
224
+ ),
225
+ "api_key": (
226
+ "API key.",
227
+ "Default $XMEM_API_KEY.",
228
+ ),
229
+ "instance_id": (
230
+ "Target instance id.",
231
+ "Default $XMEM_INSTANCE_ID.",
232
+ ),
233
+ "rc_dir": (
234
+ "Directory for .xmemrc.json (auth login writes here).",
235
+ "Reads walk up toward $HOME.",
236
+ ),
237
+ "json": (
238
+ "Force JSON output — one JSON document on stdout.",
239
+ "TTY default is pretty text; pipes and redirects already emit JSON.",
240
+ ),
241
+ "verbose": (
242
+ "Add trace_id and console_url when the server sends them.",
243
+ "Never shows SQL, diff plans, or other server internals.",
244
+ ),
245
+ }
246
+
247
+
248
+ def _option_desc_lines(action: argparse.Action) -> tuple[str, str]:
249
+ override = _GLOBAL_OPTION_LINES.get(action.dest)
250
+ if override is not None:
251
+ return override
252
+ return _split_help_two_lines(action.help or "")
253
+
254
+
255
+ def _emit_option_two_lines(
256
+ stream: IO[str],
257
+ style: _Style,
258
+ label: str,
259
+ line1: str,
260
+ line2: str,
261
+ ) -> None:
262
+ bullet = style.dim("•")
263
+ _emit(stream, f" {bullet} {style.bold(label)}")
264
+ _emit(stream, f"{_OPTION_DESC_INDENT}{style.dim(line1)}")
265
+ if line2:
266
+ _emit(stream, f"{_OPTION_DESC_INDENT}{style.dim(line2)}")
267
+
268
+
269
+ def normalize_help_path(path: tuple[str, ...]) -> tuple[str, ...]:
270
+ """Map short help topics (``help login``, ``help write-status``) to parser paths."""
271
+ if not path:
272
+ return path
273
+ alias = _HELP_TOPIC_ALIASES.get(path)
274
+ if alias is not None:
275
+ return alias
276
+ if len(path) == 1:
277
+ single = _HELP_TOPIC_ALIASES.get((path[0],))
278
+ if single is not None:
279
+ return single
280
+ return path
281
+
282
+
283
+ def _global_options_document(root: argparse.ArgumentParser) -> dict[str, Any]:
284
+ return {
285
+ "kind": "options",
286
+ "cli": CLI_NAME,
287
+ "version": __version__,
288
+ "title": "Global flags for every command",
289
+ "usage": f"{CLI_NAME} help options",
290
+ "note": f"These flags apply before any subcommand, e.g. `{CLI_NAME} --json read q`.",
291
+ "options": [
292
+ {
293
+ "name": _option_label(action),
294
+ "dest": action.dest,
295
+ "description": list(filter(None, _option_desc_lines(action))),
296
+ }
297
+ for action in _visible_actions(root)
298
+ ],
299
+ "see_also": [f"{CLI_NAME} for the command list."],
300
+ }
301
+
302
+
303
+ def _overview_document(root: argparse.ArgumentParser) -> dict[str, Any]:
304
+ del root # overview is curated; parser kept for API symmetry with command topics.
305
+ return {
306
+ "kind": "overview",
307
+ "cli": CLI_NAME,
308
+ "version": __version__,
309
+ "title": "xmemory CLI",
310
+ "tagline": "Read, write, and manage xmemory instances over the HTTP API.",
311
+ "credentials": "Credentials: `.xmemrc.json`, `$XMEM_API_KEY`, or `auth login`.",
312
+ "usage": f"{CLI_NAME} <command> [options]",
313
+ "prefix_commands": [
314
+ {
315
+ "name": "help options",
316
+ "description": "Global flags (`--api-url`, `--api-key`, `--json`, …).",
317
+ },
318
+ ],
319
+ "sections": [
320
+ {
321
+ "name": section,
322
+ "commands": [{"name": name, "description": desc} for name, desc in commands],
323
+ }
324
+ for section, commands in _OVERVIEW_SECTIONS
325
+ ],
326
+ "notes": [
327
+ f"Global flags: `{CLI_NAME} help options`.",
328
+ f"More: `{CLI_NAME} help <topic>` (e.g. `{CLI_NAME} help write-status`, `{CLI_NAME} help org`).",
329
+ "Output: interactive terminals get one-line summaries; pipes and `--json` emit a single JSON document.",
330
+ ],
331
+ }
332
+
333
+
334
+ def _local_command_options(parser: argparse.ArgumentParser) -> list[argparse.Action]:
335
+ return [
336
+ a
337
+ for a in parser._actions
338
+ if a.help is not argparse.SUPPRESS
339
+ and a.dest not in _INTERNAL_DESTS
340
+ and not (
341
+ a.option_strings
342
+ and a.dest in ("json", "verbose", "api_url", "api_key", "instance_id", "rc_dir", "cluster_id")
343
+ )
344
+ ]
345
+
346
+
347
+ def _command_usage(parser: argparse.ArgumentParser, path: tuple[str, ...]) -> str:
348
+ usage_parts = [CLI_NAME, *path]
349
+ sub = _subparsers_action(parser)
350
+ for action in parser._actions:
351
+ if action.dest in _INTERNAL_DESTS or action.option_strings:
352
+ continue
353
+ if action.metavar:
354
+ usage_parts.append(f"<{action.metavar}>")
355
+ elif action.nargs in (argparse.ONE_OR_MORE, "+"):
356
+ usage_parts.append("<…>")
357
+ else:
358
+ usage_parts.append(f"<{action.dest}>")
359
+ if sub is not None and not any(
360
+ a.dest not in _INTERNAL_DESTS and not a.option_strings for a in parser._actions
361
+ ):
362
+ usage_suffix = " <command> [options]"
363
+ else:
364
+ usage_suffix = " [options]"
365
+ return " ".join(usage_parts) + usage_suffix
366
+
367
+
368
+ def _command_help_document(
369
+ root: argparse.ArgumentParser,
370
+ path: tuple[str, ...],
371
+ parser: argparse.ArgumentParser,
372
+ ) -> dict[str, Any]:
373
+ command = " ".join(path)
374
+ sub = _subparsers_action(parser)
375
+ subcommands: list[dict[str, str]] = []
376
+ if sub is not None:
377
+ for name, child in sub.choices.items():
378
+ if name in _HIDDEN_COMMAND_NAMES:
379
+ continue
380
+ label = f"{command} {name}".strip()
381
+ subcommands.append(
382
+ {
383
+ "name": label,
384
+ "description": _choice_help(sub, name, child),
385
+ }
386
+ )
387
+ return {
388
+ "kind": "command",
389
+ "cli": CLI_NAME,
390
+ "command": command,
391
+ "title": _parser_title(root, path, parser),
392
+ "usage": _command_usage(parser, path),
393
+ "options": [
394
+ {"name": _option_label(action), "description": action.help or ""}
395
+ for action in _local_command_options(parser)
396
+ ],
397
+ "subcommands": subcommands,
398
+ "see_also": f"{CLI_NAME} help options",
399
+ }
400
+
401
+
402
+ def build_help_document(root: argparse.ArgumentParser, path: tuple[str, ...]) -> dict[str, Any]:
403
+ """Single source of truth for help — rendered as human text or emitted as JSON."""
404
+ path = normalize_help_path(path)
405
+ if path == (OPTIONS_HELP_TOPIC,):
406
+ return _global_options_document(root)
407
+ if not path:
408
+ return _overview_document(root)
409
+ parser = navigate_parser(root, path)
410
+ if parser is None:
411
+ topic = " ".join(path)
412
+ return {
413
+ "kind": "unknown",
414
+ "cli": CLI_NAME,
415
+ "topic": list(path),
416
+ "message": f"Unknown help topic: {topic} (try `{CLI_NAME}` or `{CLI_NAME} help options`).",
417
+ }
418
+ return _command_help_document(root, path, parser)
419
+
420
+
421
+ def _render_overview_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
422
+ _emit(stream)
423
+ _emit(
424
+ stream,
425
+ f"{style.green_bold(doc['cli'])} {style.dim(doc['version'])} {style.bold('—')} {style.bold(doc['title'])}",
426
+ )
427
+ _emit(stream, style.dim(doc["tagline"]))
428
+ _emit(stream, style.dim(doc["credentials"]))
429
+ _emit(stream)
430
+ cli_name = doc["cli"]
431
+ _emit(
432
+ stream,
433
+ f"{style.green_bold('Usage:')} {style.bold(doc['usage'])} "
434
+ f"{style.dim(f'— a bare `{cli_name}` prints this help')}",
435
+ )
436
+ _emit(stream)
437
+ _emit(stream, style.green_bold("Commands:"))
438
+ for entry in doc["prefix_commands"]:
439
+ _emit(stream, _help_row(style, entry["name"], entry["description"]))
440
+ for section in doc["sections"]:
441
+ _emit(stream)
442
+ _emit(stream, style.green_bold(f"{section['name']}:"))
443
+ for entry in section["commands"]:
444
+ _emit(stream, _help_row(style, entry["name"], entry["description"]))
445
+ for note in doc["notes"]:
446
+ _emit(stream)
447
+ _emit(stream, style.dim(note))
448
+
449
+
450
+ def _render_options_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
451
+ _emit(stream)
452
+ _emit(
453
+ stream,
454
+ f"{style.green_bold('help options')} {style.bold('—')} {style.bold(doc['title'])}",
455
+ )
456
+ _emit(stream)
457
+ _emit(stream, f"{style.green_bold('Usage:')} {style.bold(doc['usage'])}")
458
+ _emit(stream, style.dim(doc["note"]))
459
+ _emit(stream)
460
+ _emit(stream, style.green_bold("Options:"))
461
+ for option in doc["options"]:
462
+ lines = option["description"]
463
+ line1 = lines[0] if lines else ""
464
+ line2 = lines[1] if len(lines) > 1 else ""
465
+ _emit_option_two_lines(stream, style, option["name"], line1, line2)
466
+ _emit(stream)
467
+ _emit(stream, f"{style.green_bold('More help:')} {style.dim(f'`{doc['see_also'][0]}`.')}")
468
+
469
+
470
+ def _render_command_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
471
+ command = doc["command"]
472
+ _emit(stream)
473
+ _emit(
474
+ stream,
475
+ f"{style.green_bold(command)} {style.bold('—')} {style.bold(doc['title'])}",
476
+ )
477
+ _emit(stream)
478
+ _emit(stream, f"{style.green_bold('Usage:')} {style.bold(doc['usage'])}")
479
+ _emit(stream)
480
+ if doc["options"]:
481
+ _emit(stream, style.green_bold("Options:"))
482
+ for option in doc["options"]:
483
+ _emit(stream, _help_row(style, option["name"], option["description"]))
484
+ if doc["subcommands"]:
485
+ _emit(stream)
486
+ _emit(stream, style.green_bold("Subcommands:"))
487
+ for entry in doc["subcommands"]:
488
+ _emit(stream, _help_row(style, entry["name"], entry["description"]))
489
+ _emit(stream)
490
+ _emit(
491
+ stream,
492
+ style.dim(f"Global flags (`--api-url`, `--api-key`, `--json`, …): `{doc['see_also']}`.")
493
+ )
494
+
495
+
496
+ def _render_unknown_document(doc: dict[str, Any], stream: IO[str], style: _Style) -> None:
497
+ _emit(stream, doc["message"])
498
+
499
+
500
+ 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."""
502
+ out = stream if stream is not None else sys.stdout
503
+ style = _Style(_use_color(out))
504
+ kind = doc.get("kind")
505
+ if kind == "overview":
506
+ _render_overview_document(doc, out, style)
507
+ elif kind == "options":
508
+ _render_options_document(doc, out, style)
509
+ elif kind == "command":
510
+ _render_command_document(doc, out, style)
511
+ else:
512
+ _render_unknown_document(doc, out, style)
513
+
514
+
515
+ class CliParseError(Exception):
516
+ """Raised instead of argparse ``SystemExit`` for human-friendly usage errors."""
517
+
518
+ def __init__(self, message: str) -> None:
519
+ self.message = message
520
+ super().__init__(message)
521
+
522
+
523
+ _INVALID_CHOICE_RE = re.compile(
524
+ r"argument (?P<arg>\S+): invalid choice: '(?P<choice>[^']+)'(?: \(choose from (?P<choices>[^)]+)\))?"
525
+ )
526
+
527
+
528
+ def _format_cli_parse_error(message: str) -> str:
529
+ match = _INVALID_CHOICE_RE.search(message)
530
+ if match:
531
+ arg = match.group("arg")
532
+ choice = match.group("choice")
533
+ if arg == "command":
534
+ return (
535
+ f'{CLI_NAME}: unknown command "{choice}"\n\n'
536
+ f"Run `{CLI_NAME}` for an overview, or `{CLI_NAME} help <topic>`."
537
+ )
538
+ choices_raw = match.group("choices") or ""
539
+ choices = [part.strip().strip("'\"") for part in choices_raw.split(",") if part.strip()]
540
+ if choices:
541
+ lines = [f'{CLI_NAME}: invalid {arg.replace("_", " ")} "{choice}"', "", "Choose one of:"]
542
+ lines.extend(f" {name}" for name in choices)
543
+ return "\n".join(lines)
544
+ return f'{CLI_NAME}: invalid {arg.replace("_", " ")} "{choice}"'
545
+
546
+ if message.startswith("the following arguments are required:"):
547
+ missing = message.split(":", 1)[1].strip()
548
+ return f"{CLI_NAME}: missing required argument {missing}"
549
+
550
+ if message.startswith("unrecognized arguments:"):
551
+ extra = message.split(":", 1)[1].strip()
552
+ return f"{CLI_NAME}: unrecognized argument(s): {extra}"
553
+
554
+ return f"{CLI_NAME}: {message}"
555
+
556
+
557
+ class XmemCliArgumentParser(argparse.ArgumentParser):
558
+ """ArgumentParser that raises :class:`CliParseError` instead of dumping usage."""
559
+
560
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
561
+ kwargs.setdefault("usage", argparse.SUPPRESS)
562
+ kwargs.setdefault("add_help", False)
563
+ super().__init__(*args, **kwargs)
564
+
565
+ def error(self, message: str) -> NoReturn:
566
+ raise CliParseError(_format_cli_parse_error(message))
567
+
568
+
569
+ def print_parse_error(message: str, *, stream: IO[str] | None = None) -> None:
570
+ out = stream if stream is not None else sys.stderr
571
+ style = _Style(_use_color(out))
572
+ for line in message.splitlines():
573
+ if not line:
574
+ _emit(out)
575
+ continue
576
+ if line.endswith(":") and line[:-1] in ("Shortcuts", "Choose one of"):
577
+ _emit(out, style.green_bold(line))
578
+ continue
579
+ if line.startswith(f"{CLI_NAME}:"):
580
+ _emit(out, style.bold(line))
581
+ continue
582
+ if line.startswith(" "):
583
+ _emit(out, line)
584
+ continue
585
+ if line.startswith("Run `"):
586
+ _emit(out, style.dim(line))
587
+ continue
588
+ _emit(out, line)
589
+
590
+
591
+ def _strip_trailing_globals(argv: list[str]) -> list[str]:
592
+ """Drop trailing ``--json`` / global flags from a help topic path."""
593
+ out = list(argv)
594
+ while out:
595
+ tok = out[-1]
596
+ if tok in _GLOBAL_STORE_TRUE_FLAGS:
597
+ out.pop()
598
+ continue
599
+ if tok in _GLOBAL_OPTION_FLAGS:
600
+ out.pop()
601
+ if out and not out[-1].startswith("-"):
602
+ out.pop()
603
+ continue
604
+ break
605
+ return out
606
+
607
+
608
+ def help_target(argv: list[str]) -> tuple[str, ...] | None:
609
+ """Return the command path before ``--help``/``-h``, ``()`` for overview, or ``None``."""
610
+ if not argv:
611
+ return ()
612
+ if argv in (["-h"], ["--help"]):
613
+ return ()
614
+ if argv[0] == "help":
615
+ return normalize_help_path(tuple(_strip_trailing_globals(argv[1:])))
616
+ for index, token in enumerate(argv):
617
+ if token in ("-h", "--help"):
618
+ return normalize_help_path(tuple(_strip_trailing_globals(argv[:index])))
619
+ return None
620
+
621
+
622
+ # Root-level flags that may appear before the subcommand (``xmemcli --api-url … write …``).
623
+ _GLOBAL_OPTION_FLAGS = frozenset(
624
+ {
625
+ "--api-url",
626
+ "--api-key",
627
+ "--instance-id",
628
+ "--rc-dir",
629
+ "--cluster-id",
630
+ }
631
+ )
632
+ _GLOBAL_STORE_TRUE_FLAGS = frozenset({"--json", "--verbose"})
633
+
634
+ # Public aliases for xmem._parse_argv (argparse has no stable global-flag API).
635
+ GLOBAL_OPTION_FLAGS = _GLOBAL_OPTION_FLAGS
636
+ GLOBAL_STORE_TRUE_FLAGS = _GLOBAL_STORE_TRUE_FLAGS
637
+
638
+
639
+ def collect_global_flags(argv: list[str]) -> tuple[list[str], list[str]]:
640
+ """Split *argv* into root global flags (any position) and the remaining tokens."""
641
+ globals_out: list[str] = []
642
+ command: list[str] = []
643
+ i = 0
644
+ while i < len(argv):
645
+ tok = argv[i]
646
+ if tok in _GLOBAL_STORE_TRUE_FLAGS:
647
+ globals_out.append(tok)
648
+ i += 1
649
+ continue
650
+ if tok in _GLOBAL_OPTION_FLAGS:
651
+ globals_out.append(tok)
652
+ if i + 1 < len(argv) and not argv[i + 1].startswith("-"):
653
+ globals_out.append(argv[i + 1])
654
+ i += 2
655
+ else:
656
+ i += 1
657
+ continue
658
+ command.append(tok)
659
+ i += 1
660
+ return globals_out, command
661
+
662
+
663
+ def argv_without_globals(argv: list[str]) -> list[str]:
664
+ """Drop *leading* root-level global flags so ``--rc-dir …`` alone can mean "show overview".
665
+
666
+ Trailing globals (e.g. ``read q --json``) are preserved so machine mode stays active.
667
+ """
668
+ out: list[str] = []
669
+ i = 0
670
+ while i < len(argv):
671
+ tok = argv[i]
672
+ if tok in _GLOBAL_STORE_TRUE_FLAGS:
673
+ i += 1
674
+ continue
675
+ if tok in _GLOBAL_OPTION_FLAGS:
676
+ i += 2 if i + 1 < len(argv) and not argv[i + 1].startswith("-") else 1
677
+ continue
678
+ break
679
+ while i < len(argv):
680
+ out.append(argv[i])
681
+ i += 1
682
+ return out
683
+
684
+
685
+ def navigate_parser(root: argparse.ArgumentParser, path: tuple[str, ...]) -> argparse.ArgumentParser | None:
686
+ current = root
687
+ for part in path:
688
+ sub = _subparsers_action(current)
689
+ if sub is None or part not in sub.choices:
690
+ return None
691
+ current = sub.choices[part]
692
+ return current
693
+
694
+
695
+ def _choice_help(sub: argparse._SubParsersAction, name: str, child: argparse.ArgumentParser) -> str:
696
+ for pseudo in sub._choices_actions:
697
+ if pseudo.dest == name:
698
+ return pseudo.help or ""
699
+ return child.description or ""
700
+
701
+
702
+ def _parser_title(root: argparse.ArgumentParser, path: tuple[str, ...], parser: argparse.ArgumentParser) -> str:
703
+ if parser.description:
704
+ return parser.description
705
+ if not path:
706
+ return ""
707
+ parent = navigate_parser(root, path[:-1]) if len(path) > 1 else root
708
+ sub = _subparsers_action(parent) if parent is not None else None
709
+ if sub is not None:
710
+ return _choice_help(sub, path[-1], parser)
711
+ return " ".join(path)
712
+
713
+
714
+ def print_help(
715
+ root: argparse.ArgumentParser,
716
+ path: tuple[str, ...],
717
+ *,
718
+ stream: IO[str] | None = None,
719
+ ) -> None:
720
+ render_help_document(build_help_document(root, path), stream=stream)