universal-cli-mcp-server 0.1.0__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.
Files changed (46) hide show
  1. cli_mcp/__init__.py +32 -0
  2. cli_mcp/cli/__init__.py +7 -0
  3. cli_mcp/cli/main.py +410 -0
  4. cli_mcp/config/__init__.py +31 -0
  5. cli_mcp/config/loader.py +133 -0
  6. cli_mcp/config/settings.py +171 -0
  7. cli_mcp/core.py +133 -0
  8. cli_mcp/detect.py +143 -0
  9. cli_mcp/discovery/__init__.py +49 -0
  10. cli_mcp/discovery/base.py +129 -0
  11. cli_mcp/discovery/engine.py +238 -0
  12. cli_mcp/discovery/grammars.py +170 -0
  13. cli_mcp/discovery/help_parser.py +627 -0
  14. cli_mcp/discovery/introspect.py +276 -0
  15. cli_mcp/discovery/manifest.py +161 -0
  16. cli_mcp/discovery/node_reflection.py +126 -0
  17. cli_mcp/discovery/pipeline.py +108 -0
  18. cli_mcp/discovery/python_reflection.py +54 -0
  19. cli_mcp/discovery/reflection_payload.py +92 -0
  20. cli_mcp/discovery/registry.py +124 -0
  21. cli_mcp/errors.py +37 -0
  22. cli_mcp/executor/__init__.py +19 -0
  23. cli_mcp/executor/approval.py +64 -0
  24. cli_mcp/executor/argv.py +63 -0
  25. cli_mcp/executor/runner.py +384 -0
  26. cli_mcp/executor/sandbox.py +31 -0
  27. cli_mcp/mcp/__init__.py +22 -0
  28. cli_mcp/mcp/server.py +80 -0
  29. cli_mcp/mcp/tool_adapter.py +120 -0
  30. cli_mcp/models.py +213 -0
  31. cli_mcp/plugins/__init__.py +41 -0
  32. cli_mcp/schema/__init__.py +31 -0
  33. cli_mcp/schema/docgen.py +104 -0
  34. cli_mcp/schema/jsonschema.py +104 -0
  35. cli_mcp/schema/openapi.py +57 -0
  36. cli_mcp/schema/validate.py +84 -0
  37. cli_mcp/security/__init__.py +26 -0
  38. cli_mcp/security/policy.py +91 -0
  39. cli_mcp/security/readonly.py +173 -0
  40. cli_mcp/transport/__init__.py +49 -0
  41. cli_mcp/version.py +3 -0
  42. universal_cli_mcp_server-0.1.0.dist-info/METADATA +381 -0
  43. universal_cli_mcp_server-0.1.0.dist-info/RECORD +46 -0
  44. universal_cli_mcp_server-0.1.0.dist-info/WHEEL +4 -0
  45. universal_cli_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
  46. universal_cli_mcp_server-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,627 @@
1
+ """Generic help-text grammar: parse ``tool --help`` output into command metadata.
2
+
3
+ This engine backs the generic help strategy and every framework-specific
4
+ grammar provider (cobra, clap, System.CommandLine, yargs, click, argparse).
5
+ Framework providers reuse it and only add their own refinements, because the
6
+ marker vocabulary below already covers defaults, types, choices and required
7
+ flags across those frameworks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Literal
15
+
16
+ from cli_mcp.detect import strip_ansi
17
+
18
+ SectionName = Literal["body", "options", "commands", "positional", "other"]
19
+
20
+ HEADER_PATTERN = re.compile(r"^\s*([A-Za-z][A-Za-z0-9 /\-]*):\s*$")
21
+ SECTION_HEADERS: dict[str, SectionName] = {
22
+ "usage": "body",
23
+ "usage:": "body",
24
+ "options": "options",
25
+ "option": "options",
26
+ "flags": "options",
27
+ "common flags": "options",
28
+ "common options": "options",
29
+ "global flags": "options",
30
+ "global options": "options",
31
+ "optional arguments": "options",
32
+ "optional flags": "options",
33
+ "direct commands": "commands",
34
+ "default commands": "commands",
35
+ "available commands": "commands",
36
+ "commands": "commands",
37
+ "subcommands": "commands",
38
+ "additional commands": "commands",
39
+ "arguments": "positional",
40
+ "positionals": "positional",
41
+ "positional arguments": "positional",
42
+ "positional": "positional",
43
+ }
44
+
45
+ #: Bracket markers understood in option descriptions.
46
+ _MARKER_PATTERN = re.compile(r"\[([^\]]+)\]")
47
+ #: cobra spells defaults and required flags in parentheses instead of brackets.
48
+ _PAREN_MARKER = re.compile(
49
+ r"(?P<default>\((default(?: is| value)?[:\s]+[^)]*)\))|(?P<required>\(required\))",
50
+ re.IGNORECASE,
51
+ )
52
+ _TYPE_MARKERS = {
53
+ "string": "string",
54
+ "str": "string",
55
+ "text": "string",
56
+ "integer": "integer",
57
+ "int": "integer",
58
+ "number": "number",
59
+ "float": "number",
60
+ "double": "number",
61
+ "boolean": "boolean",
62
+ "bool": "boolean",
63
+ "array": "array",
64
+ "list": "array",
65
+ "duration": "string",
66
+ "date": "string",
67
+ "enum": "string",
68
+ }
69
+ _LONG_FLAG_PATTERN = re.compile(r"^--([A-Za-z0-9][A-Za-z0-9\-]*)(?:\[=[^\]]*\])?$")
70
+ _SHORT_FLAG_PATTERN = re.compile(r"^-([A-Za-z0-9])$")
71
+ _PLACEHOLDER_PATTERN = re.compile(r"^<([^>]+)>$")
72
+ _TYPEWORD_PATTERN = re.compile(r"^[A-Za-z]+$")
73
+
74
+
75
+ @dataclass
76
+ class ParsedOption:
77
+ """Option/flag parsed from a help section."""
78
+
79
+ name: str
80
+ short: str | None = None
81
+ short_only: bool = False
82
+ metavar: str | None = None
83
+ description: str = ""
84
+ type: str = "string"
85
+ default: str | None = None
86
+ choices: list[str] | None = None
87
+ required: bool = False
88
+ is_flag: bool = False
89
+
90
+
91
+ @dataclass
92
+ class ParsedPositional:
93
+ """Positional argument parsed from a help section or usage line."""
94
+
95
+ name: str
96
+ description: str = ""
97
+ required: bool = True
98
+ type: str = "string"
99
+
100
+
101
+ @dataclass
102
+ class ParsedSubcommand:
103
+ """Subcommand advertised by a help section."""
104
+
105
+ name: str
106
+ description: str = ""
107
+
108
+
109
+ @dataclass
110
+ class ParsedHelp:
111
+ """Structured view of one help page."""
112
+
113
+ description: str = ""
114
+ options: list[ParsedOption] = field(default_factory=list)
115
+ positionals: list[ParsedPositional] = field(default_factory=list)
116
+ subcommands: list[ParsedSubcommand] = field(default_factory=list)
117
+ usage: str = ""
118
+ required_options: set[str] = field(default_factory=set)
119
+
120
+
121
+ def _bracket_marker(token: str) -> tuple[str, str] | None:
122
+ """Turn one bracket marker into a field/value pair, or ``None`` if it is not one."""
123
+ lowered = token.lower()
124
+ if lowered.startswith("default:"):
125
+ return ("default", token.split(":", 1)[1].strip().strip('"').strip("'"))
126
+ if lowered in {"required", "required:"} or lowered.endswith("required"):
127
+ return ("required", "true")
128
+ if lowered == "optional":
129
+ return ("required", "false")
130
+ if lowered.startswith(("choices:", "possible values:")):
131
+ return ("choices", token.split(":", 1)[1].strip())
132
+ if lowered.startswith("aliases:"):
133
+ return ("aliases", token.split(":", 1)[1].strip())
134
+ if lowered in _TYPE_MARKERS:
135
+ return ("type", _TYPE_MARKERS[lowered])
136
+ if lowered.startswith("type:"):
137
+ type_name = _TYPE_MARKERS.get(token.split(":", 1)[1].strip().lower())
138
+ return None if type_name is None else ("type", type_name)
139
+ if lowered.startswith("env var:"):
140
+ return ("env", token.split(":", 1)[1].strip())
141
+ return None
142
+
143
+
144
+ def _bracket_markers(line: str, consumed: list[tuple[int, int]]) -> dict[str, str]:
145
+ """Collect bracket markers from a line and record the spans they occupy."""
146
+ markers: dict[str, str] = {}
147
+ for match in _MARKER_PATTERN.finditer(line):
148
+ marker = _bracket_marker(match.group(1).strip())
149
+ if marker is None:
150
+ continue
151
+ markers[marker[0]] = marker[1]
152
+ consumed.append(match.span())
153
+ return markers
154
+
155
+
156
+ def _paren_markers(line: str, consumed: list[tuple[int, int]]) -> dict[str, str]:
157
+ """Collect cobra's parenthesised default and required markers."""
158
+ markers: dict[str, str] = {}
159
+ for match in _PAREN_MARKER.finditer(line):
160
+ if match.group("required"):
161
+ markers["required"] = "true"
162
+ else:
163
+ markers["default"] = _paren_default(match.group("default"))
164
+ consumed.append(match.span())
165
+ return markers
166
+
167
+
168
+ def parse_markers(line: str) -> tuple[dict[str, str], str]:
169
+ """Pull framework markers out of a line and return them plus the clean text."""
170
+ consumed: list[tuple[int, int]] = []
171
+ markers = _bracket_markers(line, consumed)
172
+ markers.update(_paren_markers(line, consumed))
173
+ parts: list[str] = []
174
+ previous = 0
175
+ for start, end in sorted(consumed):
176
+ parts.append(line[previous:start])
177
+ previous = end
178
+ parts.append(line[previous:])
179
+ cleaned = " ".join(part.strip() for part in parts if part.strip())
180
+ return markers, cleaned
181
+
182
+
183
+ def _paren_default(text: str) -> str:
184
+ """Extract the value from cobra's ``(default "x")`` / ``(default is "x")`` marker."""
185
+ inner = text.strip().strip("(").strip(")").strip()
186
+ for prefix in ("default is", "default value", "default"):
187
+ if inner.lower().startswith(prefix):
188
+ inner = inner[len(prefix) :]
189
+ break
190
+ return inner.strip().strip(":=").strip().strip('"').strip("'")
191
+
192
+
193
+ _INTEGER_METAVAR_WORDS = re.compile(r"\b(count|num|number|level|max|min|n|retries|limit|index|port|pid)\b")
194
+
195
+
196
+ def _infer_type(metavar: str | None, markers: dict[str, str]) -> str:
197
+ """Derive a JSON Schema type from markers and the metavar placeholder."""
198
+ if "type" in markers:
199
+ return markers["type"]
200
+ if markers.get("choices"):
201
+ return "string"
202
+ if metavar:
203
+ lowered = metavar.lower()
204
+ if lowered in _TYPE_MARKERS:
205
+ return _TYPE_MARKERS[lowered]
206
+ if re.fullmatch(r"[A-Z0-9_]+", metavar):
207
+ return "integer" if _INTEGER_METAVAR_WORDS.search(lowered) else "string"
208
+ if any(word in lowered for word in ("bool", "flag")):
209
+ return "boolean"
210
+ return "string"
211
+
212
+
213
+ def _consume_flag_tokens(tokens: list[str]) -> tuple[list[str], str | None, int]:
214
+ """Read leading ``-x`` / ``--long`` tokens and return where the description starts."""
215
+ shorts: list[str] = []
216
+ long_name: str | None = None
217
+ index = 0
218
+ while index < len(tokens):
219
+ token = tokens[index].rstrip(",")
220
+ short_match = _SHORT_FLAG_PATTERN.match(token)
221
+ long_match = _LONG_FLAG_PATTERN.match(token)
222
+ if short_match:
223
+ shorts.append(short_match.group(1))
224
+ index += 1
225
+ if index < len(tokens) and tokens[index] == ",":
226
+ index += 1
227
+ continue
228
+ if long_match:
229
+ long_name = long_match.group(1)
230
+ index += 1
231
+ break
232
+ break
233
+ return shorts, long_name, index
234
+
235
+
236
+ _BRACE_PATTERN = re.compile(r"^\{(.+)\}$")
237
+
238
+
239
+ def _consume_metavar(tokens: list[str], index: int) -> tuple[str | None, int]:
240
+ """Read an option value placeholder such as ``--input <FILE>`` or ``--format {a,b}``."""
241
+ if index >= len(tokens):
242
+ return None, index
243
+ candidate = tokens[index]
244
+ placeholder = _PLACEHOLDER_PATTERN.match(candidate)
245
+ if placeholder:
246
+ return placeholder.group(1), index + 1
247
+ brace = _BRACE_PATTERN.match(candidate)
248
+ if brace:
249
+ return brace.group(0), index + 1
250
+ if (_TYPEWORD_PATTERN.match(candidate) and candidate.lower() in _TYPE_MARKERS) or candidate.isupper():
251
+ return candidate, index + 1
252
+ return None, index
253
+
254
+
255
+ def _parse_option_line(line: str) -> tuple[ParsedOption | None, str]:
256
+ """Parse an option entry; return the option and any trailing description text."""
257
+ markers, text = parse_markers(line)
258
+ tokens = text.split()
259
+ if not tokens:
260
+ return None, ""
261
+
262
+ shorts, long_name, index = _consume_flag_tokens(tokens)
263
+ if long_name is None and not shorts:
264
+ return None, text
265
+
266
+ metavar, index = _consume_metavar(tokens, index)
267
+ brace = _BRACE_PATTERN.match(metavar) if metavar else None
268
+ choices = _split_choices(brace.group(1)) if brace else _split_choices(markers.get("choices"))
269
+
270
+ description = " ".join(tokens[index:]).strip()
271
+ aliases = markers.get("aliases")
272
+ if aliases:
273
+ description = f"{description} (aliases: {aliases})".strip()
274
+ short_only = long_name is None
275
+ if not long_name:
276
+ # Only a short flag was advertised; publish it as an option using its own name.
277
+ long_name = shorts[0] if shorts else "arg"
278
+
279
+ option = ParsedOption(
280
+ name=long_name,
281
+ short=shorts[0] if shorts else None,
282
+ short_only=short_only,
283
+ metavar=metavar,
284
+ description=description,
285
+ type=_infer_type(metavar, markers),
286
+ default=markers.get("default"),
287
+ choices=choices,
288
+ required=markers.get("required") == "true",
289
+ is_flag=metavar is None and choices is None and markers.get("type") != "array",
290
+ )
291
+ return option, description
292
+
293
+
294
+ def _split_choices(value: str | None) -> list[str] | None:
295
+ """Split a ``choices: a, b`` marker into a list."""
296
+ if not value:
297
+ return None
298
+ cleaned = [item.strip().strip("'\"") for item in re.split(r"[,\s]+", value) if item.strip()]
299
+ return cleaned or None
300
+
301
+
302
+ def _parse_positional_line(line: str) -> ParsedPositional | None:
303
+ """Parse a positional entry such as ``<input> the input file``."""
304
+ markers, text = parse_markers(line)
305
+ tokens = text.split()
306
+ if not tokens:
307
+ return None
308
+ first = tokens[0]
309
+ placeholder = _PLACEHOLDER_PATTERN.match(first)
310
+ if placeholder:
311
+ name = placeholder.group(1)
312
+ required = True
313
+ elif first.startswith("{"):
314
+ name = first[1:].rstrip("}")
315
+ required = True
316
+ elif first.startswith("-"):
317
+ return None
318
+ else:
319
+ name = first
320
+ required = markers.get("required") != "false"
321
+ positional = ParsedPositional(
322
+ name=name,
323
+ description=" ".join(tokens[1:]).strip(),
324
+ required=required and markers.get("required") != "false",
325
+ type=_infer_type(None, markers),
326
+ )
327
+ return positional
328
+
329
+
330
+ def _parse_subcommand_line(line: str) -> ParsedSubcommand | None:
331
+ """Parse a subcommand entry such as ``reduce Reduce a nodeset``."""
332
+ markers, text = parse_markers(line)
333
+ tokens = text.split()
334
+ if not tokens:
335
+ return None
336
+ name = tokens[0].strip()
337
+ if name.startswith("-") or name.startswith("<"):
338
+ return None
339
+ return ParsedSubcommand(name=name, description=" ".join(tokens[1:]).strip() or markers.get("aliases", ""))
340
+
341
+
342
+ def _parse_usage(line: str, result: ParsedHelp) -> None:
343
+ """Extract required options and positional order from a usage line."""
344
+ tokens = line.split()
345
+ index = 0
346
+ while index < len(tokens):
347
+ token = tokens[index]
348
+ long_match = _LONG_FLAG_PATTERN.match(token)
349
+ if long_match:
350
+ result.required_options.add(long_match.group(1))
351
+ index += 1
352
+ if index < len(tokens) and (_PLACEHOLDER_PATTERN.match(tokens[index]) or tokens[index].isupper()):
353
+ index += 1
354
+ continue
355
+ if _SHORT_FLAG_PATTERN.match(token):
356
+ index += 1
357
+ if index < len(tokens) and (_PLACEHOLDER_PATTERN.match(tokens[index]) or tokens[index].isupper()):
358
+ index += 1
359
+ continue
360
+ if _usage_optional_flag_token(token, result) or _usage_positional_token(token, result):
361
+ index += 1
362
+ continue
363
+ index += 1
364
+
365
+
366
+ def _usage_optional_flag_token(token: str, result: ParsedHelp) -> bool:
367
+ """Handle an ``[--flag]`` or ``[-h]`` token, which marks the option as optional."""
368
+ if not token.startswith("[") or not token.endswith("]"):
369
+ return False
370
+ inner = token[1:-1].strip()
371
+ if not inner:
372
+ return True
373
+ head = inner.split()[0]
374
+ long_match = _LONG_FLAG_PATTERN.match(head)
375
+ if long_match:
376
+ result.required_options.discard(long_match.group(1))
377
+ return True
378
+ return head.startswith("-")
379
+
380
+
381
+ def _usage_positional_token(token: str, result: ParsedHelp) -> bool:
382
+ """Handle a ``<name>`` or ``[name]`` token describing a positional argument."""
383
+ if re.fullmatch(r"[A-Z][A-Z0-9_]*", token) and not _is_generic_usage_name(token):
384
+ if not _known_positional(result, token):
385
+ result.positionals.append(ParsedPositional(name=token, required=True))
386
+ return True
387
+ placeholder = _PLACEHOLDER_PATTERN.match(token)
388
+ if placeholder:
389
+ name = placeholder.group(1)
390
+ if (
391
+ "," not in name
392
+ and not name.startswith("{")
393
+ and not _known_positional(result, name)
394
+ and not _is_generic_usage_name(name)
395
+ ):
396
+ result.positionals.append(ParsedPositional(name=name, required=True))
397
+ return True
398
+ if token.startswith("[") and token.endswith("]"):
399
+ inner = token[1:-1].strip()
400
+ bracketed = _PLACEHOLDER_PATTERN.match(inner)
401
+ name = bracketed.group(1) if bracketed else inner
402
+ if (
403
+ name
404
+ and not _known_positional(result, name)
405
+ and not _is_generic_usage_name(name)
406
+ ):
407
+ result.positionals.append(ParsedPositional(name=name.split()[0], required=False))
408
+ return True
409
+ return False
410
+
411
+
412
+ #: Usage placeholders that stand for "any option/argument", not a real positional.
413
+ _GENERIC_USAGE_NAMES = frozenset({"options", "option", "args", "arg", "arguments", "argument", "command", "commands"})
414
+
415
+
416
+ def _is_generic_usage_name(name: str) -> bool:
417
+ """Whether a usage placeholder is boilerplate rather than a positional argument."""
418
+ return name.lower() in _GENERIC_USAGE_NAMES
419
+
420
+
421
+ def _known_positional(result: ParsedHelp, name: str) -> bool:
422
+ """Return whether a positional argument is already recorded."""
423
+ return any(positional.name == name for positional in result.positionals)
424
+
425
+
426
+ @dataclass
427
+ class _ParseState:
428
+ """Mutable bookkeeping for the line-by-line help scan."""
429
+
430
+ section: SectionName = "body"
431
+ base_indent: int | None = None
432
+ current_option: ParsedOption | None = None
433
+ option_indent: int | None = None
434
+ body_lines: list[str] = field(default_factory=list)
435
+
436
+
437
+ def _is_usage_continuation(stripped: str, indent: int) -> bool:
438
+ """Whether a body line only lists usage tokens, i.e. a wrapped ``usage:`` line."""
439
+ tokens = stripped.split()
440
+ if not tokens:
441
+ return False
442
+ if all(token.startswith("[") for token in tokens):
443
+ return True
444
+ # An indented flag/placeholder pair is an option entry, not wrapped usage text.
445
+ return indent < 2 and all(token.startswith(("-", "[", "<")) for token in tokens)
446
+
447
+
448
+ #: Column-aligned command list, as ``git --help`` groups its commands without a header.
449
+ _COMMAND_LIST_PATTERN = re.compile(r"^([a-z][a-z0-9_-]{1,60})\s{2,}\S")
450
+
451
+
452
+ def _handle_body(state: _ParseState, stripped: str, indent: int, result: ParsedHelp) -> None:
453
+ """Collect description lines, consume ``usage:`` lines and pick up command lists."""
454
+ if stripped.lower().startswith("usage:"):
455
+ result.usage = stripped.partition(":")[2].strip()
456
+ _parse_usage(result.usage, result)
457
+ return
458
+ if _is_usage_continuation(stripped, indent):
459
+ _parse_usage(stripped, result)
460
+ return
461
+ if indent >= 2 and _COMMAND_LIST_PATTERN.match(stripped):
462
+ _record_subcommand(stripped, result)
463
+ return
464
+ if indent >= 2 and stripped.startswith("-"):
465
+ option, _ = _parse_option_line(stripped)
466
+ if option is not None:
467
+ result.options.append(option)
468
+ state.current_option = option
469
+ state.option_indent = indent
470
+ return
471
+ if state.current_option is not None and state.option_indent is not None and indent > state.option_indent:
472
+ _append_option_continuation(state.current_option, stripped)
473
+ return
474
+ state.body_lines.append(stripped)
475
+
476
+
477
+ def _append_option_continuation(option: ParsedOption, text: str) -> None:
478
+ """Add a continuation line, and its markers, to the option being described."""
479
+ markers, cleaned = parse_markers(text)
480
+ if cleaned:
481
+ option.description = f"{option.description} {cleaned}".strip()
482
+ if option.default is None and markers.get("default"):
483
+ option.default = markers["default"]
484
+ if option.type == "string" and markers.get("type"):
485
+ option.type = markers["type"]
486
+ if markers.get("required"):
487
+ option.required = True
488
+
489
+
490
+ def _handle_options(state: _ParseState, stripped: str, indent: int, result: ParsedHelp) -> None:
491
+ """Parse an option entry, or append continuation text to the previous one."""
492
+ option, description = _parse_option_line(stripped)
493
+ if option is not None:
494
+ result.options.append(option)
495
+ state.current_option = option
496
+ return
497
+ if state.current_option is not None and (state.base_indent is None or indent > state.base_indent):
498
+ state.current_option.description = f"{state.current_option.description} {description}".strip()
499
+
500
+
501
+ def _handle_positional(state: _ParseState, stripped: str, indent: int, result: ParsedHelp) -> None:
502
+ """Parse positional entries, treating deeper indented lines as subcommands."""
503
+ if state.base_indent is None:
504
+ state.base_indent = indent
505
+ if indent > state.base_indent:
506
+ _record_subcommand(stripped, result)
507
+ return
508
+ positional = _parse_positional_line(stripped)
509
+ if positional is not None:
510
+ result.positionals.append(positional)
511
+
512
+
513
+ def _handle_commands(state: _ParseState, stripped: str, indent: int, result: ParsedHelp) -> None:
514
+ """Parse entries of a ``commands:`` section."""
515
+ if state.base_indent is None:
516
+ state.base_indent = indent
517
+ _record_subcommand(stripped, result)
518
+
519
+
520
+ def _record_subcommand(stripped: str, result: ParsedHelp) -> None:
521
+ """Append a parsed subcommand when the line describes one."""
522
+ subcommand = _parse_subcommand_line(stripped)
523
+ if subcommand is not None:
524
+ result.subcommands.append(subcommand)
525
+
526
+
527
+ SECTION_HANDLERS = {
528
+ "body": _handle_body,
529
+ "options": _handle_options,
530
+ "positional": _handle_positional,
531
+ "commands": _handle_commands,
532
+ }
533
+
534
+
535
+ def _apply_header(raw: str, stripped: str, state: _ParseState, result: ParsedHelp) -> bool:
536
+ """Switch to the section a header line announces; return whether the line was consumed."""
537
+ header_match = HEADER_PATTERN.match(raw)
538
+ if not header_match:
539
+ return False
540
+ header = header_match.group(1).lower()
541
+ section = SECTION_HEADERS.get(header)
542
+ if section is None and not _is_section_title(header):
543
+ return False # prose that happens to end with a colon, e.g. git's group introductions
544
+ state.section = section or "other"
545
+ state.base_indent = None
546
+ state.current_option = None
547
+ if state.section == "body":
548
+ result.usage = stripped.partition(":")[2].strip()
549
+ _parse_usage(result.usage, result)
550
+ return True
551
+
552
+
553
+ def _is_section_title(header: str) -> bool:
554
+ """Whether an unrecognised colon-terminated line reads like a section title."""
555
+ words = header.split()
556
+ return len(words) <= 4 and "(" not in header and "," not in header
557
+
558
+
559
+ _PANEL_TITLE = re.compile(r"^[+\u256d][-\u2500]+\s*([A-Za-z][A-Za-z /-]*?)\s*[-\u2500]+[+\u256e]$")
560
+ _PANEL_BOTTOM = re.compile(r"^[+\u2570][-\u2500]+[+\u256f]$")
561
+
562
+
563
+ def _plain_help_lines(text: str) -> list[str]:
564
+ """Unwrap Rich panels while preserving table-row indentation."""
565
+ lines: list[str] = []
566
+ for raw in strip_ansi(text).splitlines():
567
+ stripped = raw.strip()
568
+ title = _PANEL_TITLE.match(stripped)
569
+ if title:
570
+ lines.append(f"{title.group(1).strip()}:")
571
+ elif _PANEL_BOTTOM.match(stripped):
572
+ lines.append("")
573
+ elif stripped.startswith(("|", "\u2502")) and stripped.endswith(("|", "\u2502")):
574
+ lines.append(raw[raw.index(stripped[0]) + 1 : raw.rindex(stripped[-1])].rstrip())
575
+ else:
576
+ lines.append(raw)
577
+ return lines
578
+
579
+
580
+ def parse_help(text: str) -> ParsedHelp:
581
+ """Parse a single help page into description, options, positionals, subcommands."""
582
+ result = ParsedHelp()
583
+ state = _ParseState()
584
+
585
+ for raw in _plain_help_lines(text):
586
+ if not raw.strip():
587
+ state.current_option = None
588
+ continue
589
+ indent = len(raw) - len(raw.lstrip())
590
+ stripped = raw.strip()
591
+
592
+ if _apply_header(raw, stripped, state, result):
593
+ continue
594
+
595
+ handler = SECTION_HANDLERS.get(state.section)
596
+ if handler is None:
597
+ state.current_option = None
598
+ continue
599
+ handler(state, stripped, indent, result)
600
+
601
+ result.description = " ".join(state.body_lines).strip()
602
+ for option in result.options:
603
+ if option.name in result.required_options:
604
+ option.required = True
605
+ _drop_subcommand_selectors(result)
606
+ return result
607
+
608
+
609
+ def _drop_subcommand_selectors(result: ParsedHelp) -> None:
610
+ """Remove positionals that only enumerate the subcommands listed beneath them."""
611
+ names = {item.name for item in result.subcommands}
612
+ if not names:
613
+ return
614
+ kept = [positional for positional in result.positionals if not _lists_subcommands(positional.name, names)]
615
+ result.positionals = kept
616
+
617
+
618
+ def _lists_subcommands(name: str, names: set[str]) -> bool:
619
+ """Whether a positional name is really the comma-separated subcommand list."""
620
+ parts = [part.strip() for part in name.split(",")]
621
+ return len(parts) > 1 and all(part in names for part in parts)
622
+
623
+
624
+ def is_help_noise(description: str) -> bool:
625
+ """Return whether a description is boilerplate help text rather than real documentation."""
626
+ lowered = description.lower()
627
+ return lowered.startswith(("show this message", "show help", "help for", "print usage", "usage:"))