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.
- cli_mcp/__init__.py +32 -0
- cli_mcp/cli/__init__.py +7 -0
- cli_mcp/cli/main.py +410 -0
- cli_mcp/config/__init__.py +31 -0
- cli_mcp/config/loader.py +133 -0
- cli_mcp/config/settings.py +171 -0
- cli_mcp/core.py +133 -0
- cli_mcp/detect.py +143 -0
- cli_mcp/discovery/__init__.py +49 -0
- cli_mcp/discovery/base.py +129 -0
- cli_mcp/discovery/engine.py +238 -0
- cli_mcp/discovery/grammars.py +170 -0
- cli_mcp/discovery/help_parser.py +627 -0
- cli_mcp/discovery/introspect.py +276 -0
- cli_mcp/discovery/manifest.py +161 -0
- cli_mcp/discovery/node_reflection.py +126 -0
- cli_mcp/discovery/pipeline.py +108 -0
- cli_mcp/discovery/python_reflection.py +54 -0
- cli_mcp/discovery/reflection_payload.py +92 -0
- cli_mcp/discovery/registry.py +124 -0
- cli_mcp/errors.py +37 -0
- cli_mcp/executor/__init__.py +19 -0
- cli_mcp/executor/approval.py +64 -0
- cli_mcp/executor/argv.py +63 -0
- cli_mcp/executor/runner.py +384 -0
- cli_mcp/executor/sandbox.py +31 -0
- cli_mcp/mcp/__init__.py +22 -0
- cli_mcp/mcp/server.py +80 -0
- cli_mcp/mcp/tool_adapter.py +120 -0
- cli_mcp/models.py +213 -0
- cli_mcp/plugins/__init__.py +41 -0
- cli_mcp/schema/__init__.py +31 -0
- cli_mcp/schema/docgen.py +104 -0
- cli_mcp/schema/jsonschema.py +104 -0
- cli_mcp/schema/openapi.py +57 -0
- cli_mcp/schema/validate.py +84 -0
- cli_mcp/security/__init__.py +26 -0
- cli_mcp/security/policy.py +91 -0
- cli_mcp/security/readonly.py +173 -0
- cli_mcp/transport/__init__.py +49 -0
- cli_mcp/version.py +3 -0
- universal_cli_mcp_server-0.1.0.dist-info/METADATA +381 -0
- universal_cli_mcp_server-0.1.0.dist-info/RECORD +46 -0
- universal_cli_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- universal_cli_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- 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:"))
|