cli2mcp 0.1.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.
- cli2mcp/__init__.py +3 -0
- cli2mcp/__main__.py +5 -0
- cli2mcp/cli.py +94 -0
- cli2mcp/parser.py +30 -0
- cli2mcp/parsers/__init__.py +6 -0
- cli2mcp/parsers/cobra.py +175 -0
- cli2mcp/parsers/common.py +42 -0
- cli2mcp/parsers/gnu.py +142 -0
- cli2mcp/parsers/plain.py +112 -0
- cli2mcp/scanner.py +89 -0
- cli2mcp/server.py +164 -0
- cli2mcp-0.1.1.dist-info/METADATA +153 -0
- cli2mcp-0.1.1.dist-info/RECORD +16 -0
- cli2mcp-0.1.1.dist-info/WHEEL +4 -0
- cli2mcp-0.1.1.dist-info/entry_points.txt +2 -0
- cli2mcp-0.1.1.dist-info/licenses/LICENSE +674 -0
cli2mcp/__init__.py
ADDED
cli2mcp/__main__.py
ADDED
cli2mcp/cli.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Command-line entry point for cli2mcp.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
cli2mcp scan <command> [-o tools.json]
|
|
6
|
+
cli2mcp serve <tools.json>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main():
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="cli2mcp",
|
|
16
|
+
description="Turn any CLI tool into an MCP server.",
|
|
17
|
+
)
|
|
18
|
+
subparsers = parser.add_subparsers(dest="action")
|
|
19
|
+
|
|
20
|
+
# --- scan ---
|
|
21
|
+
scan_parser = subparsers.add_parser(
|
|
22
|
+
"scan",
|
|
23
|
+
help="Scan a CLI tool's --help and generate a tools JSON file.",
|
|
24
|
+
)
|
|
25
|
+
scan_parser.add_argument(
|
|
26
|
+
"command",
|
|
27
|
+
help="The CLI command to scan (e.g. git, curl, docker).",
|
|
28
|
+
)
|
|
29
|
+
scan_parser.add_argument(
|
|
30
|
+
"-o",
|
|
31
|
+
"--output",
|
|
32
|
+
default=None,
|
|
33
|
+
help="Output JSON file path (default: <command>.tools.json).",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# --- serve ---
|
|
37
|
+
serve_parser = subparsers.add_parser(
|
|
38
|
+
"serve",
|
|
39
|
+
help="Start an MCP server from a tools JSON file.",
|
|
40
|
+
)
|
|
41
|
+
serve_parser.add_argument(
|
|
42
|
+
"tools_file",
|
|
43
|
+
help="Path to the tools JSON file.",
|
|
44
|
+
)
|
|
45
|
+
serve_parser.add_argument(
|
|
46
|
+
"-t",
|
|
47
|
+
"--transport",
|
|
48
|
+
choices=["stdio", "streamable-http"],
|
|
49
|
+
default="stdio",
|
|
50
|
+
help="Transport protocol (default: stdio).",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
if args.action is None:
|
|
56
|
+
parser.print_help()
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
if args.action == "scan":
|
|
60
|
+
from cli2mcp.scanner import scan, save
|
|
61
|
+
|
|
62
|
+
output = args.output or f"{args.command}.tools.json"
|
|
63
|
+
tools = scan(args.command)
|
|
64
|
+
save(tools, output)
|
|
65
|
+
|
|
66
|
+
elif args.action == "serve":
|
|
67
|
+
import json
|
|
68
|
+
import signal
|
|
69
|
+
from cli2mcp.server import create_server
|
|
70
|
+
|
|
71
|
+
with open(args.tools_file) as f:
|
|
72
|
+
data = json.load(f)
|
|
73
|
+
|
|
74
|
+
tool_count = len(data.get("tools", []))
|
|
75
|
+
command = data.get("command", "unknown")
|
|
76
|
+
|
|
77
|
+
print("cli2mcp server starting")
|
|
78
|
+
print(f" Command: {command}")
|
|
79
|
+
print(f" Tools: {tool_count}")
|
|
80
|
+
print(f" File: {args.tools_file}")
|
|
81
|
+
print(f" Transport: {args.transport}")
|
|
82
|
+
print()
|
|
83
|
+
print("Press Ctrl+C to stop the server.")
|
|
84
|
+
sys.stdout.flush()
|
|
85
|
+
|
|
86
|
+
def _shutdown(signum, frame):
|
|
87
|
+
print("\nShutting down.")
|
|
88
|
+
sys.exit(0)
|
|
89
|
+
|
|
90
|
+
signal.signal(signal.SIGINT, _shutdown)
|
|
91
|
+
signal.signal(signal.SIGTERM, _shutdown)
|
|
92
|
+
|
|
93
|
+
server = create_server(args.tools_file)
|
|
94
|
+
server.run(transport=args.transport)
|
cli2mcp/parser.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Auto-detect CLI help style and delegate to the right parser.
|
|
2
|
+
|
|
3
|
+
Detection order: Cobra first (most distinctive), then GNU, then Plain
|
|
4
|
+
(catch-all).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from cli2mcp.parsers.cobra import can_parse as _is_cobra, parse as _parse_cobra
|
|
8
|
+
from cli2mcp.parsers.gnu import can_parse as _is_gnu, parse as _parse_gnu
|
|
9
|
+
from cli2mcp.parsers.plain import parse as _parse_plain
|
|
10
|
+
|
|
11
|
+
_PARSERS = [
|
|
12
|
+
("cobra", _is_cobra, _parse_cobra),
|
|
13
|
+
("gnu", _is_gnu, _parse_gnu),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect_style(text):
|
|
18
|
+
"""Return 'cobra', 'gnu', or 'plain'."""
|
|
19
|
+
for name, can_parse, _ in _PARSERS:
|
|
20
|
+
if can_parse(text):
|
|
21
|
+
return name
|
|
22
|
+
return "plain"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_help(text):
|
|
26
|
+
"""Parse --help output into {description, args, subcommands}."""
|
|
27
|
+
for _, can_parse, parse_fn in _PARSERS:
|
|
28
|
+
if can_parse(text):
|
|
29
|
+
return parse_fn(text)
|
|
30
|
+
return _parse_plain(text)
|
cli2mcp/parsers/cobra.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Parser for Go Cobra style help text (kubectl, oc, docker, gh, ...).
|
|
2
|
+
|
|
3
|
+
Flags span two lines: name + default on line 1, description on line 2.
|
|
4
|
+
Subcommands are listed under category headers.
|
|
5
|
+
|
|
6
|
+
Manage your cluster:
|
|
7
|
+
get Display one or many resources
|
|
8
|
+
describe Show details of a resource
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
--all-namespaces=false:
|
|
12
|
+
If present, list across all namespaces.
|
|
13
|
+
|
|
14
|
+
-o, --output='':
|
|
15
|
+
Output format. One of: json|yaml|wide|name.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
from cli2mcp.parsers.common import extract_positional_args, make_description
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def can_parse(text):
|
|
24
|
+
"""True if text has Cobra-style multi-line flags or the Cobra footer."""
|
|
25
|
+
for line in text.splitlines():
|
|
26
|
+
stripped = line.strip()
|
|
27
|
+
if stripped.startswith("-") and "=" in stripped and stripped.endswith(":"):
|
|
28
|
+
return True
|
|
29
|
+
if re.match(r'Use ".+ <command> --help"', stripped):
|
|
30
|
+
return True
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse(text):
|
|
35
|
+
"""Parse Cobra-style help using a state machine.
|
|
36
|
+
|
|
37
|
+
States: description, options, subcommands, skip.
|
|
38
|
+
The key pattern is "pending flag": when we see '--flag=default:',
|
|
39
|
+
we store the name and read the next line as its description.
|
|
40
|
+
"""
|
|
41
|
+
description_lines = []
|
|
42
|
+
args = []
|
|
43
|
+
subcommands = []
|
|
44
|
+
state = "description"
|
|
45
|
+
pending_flag = None
|
|
46
|
+
|
|
47
|
+
for line in text.splitlines():
|
|
48
|
+
# If the previous line was a flag header, this line is its description.
|
|
49
|
+
if pending_flag is not None:
|
|
50
|
+
desc = line.strip()
|
|
51
|
+
if desc:
|
|
52
|
+
args.append(
|
|
53
|
+
{
|
|
54
|
+
"name": pending_flag,
|
|
55
|
+
"description": desc,
|
|
56
|
+
"required": False,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
pending_flag = None
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
header = _detect_header(line)
|
|
63
|
+
if header is not None:
|
|
64
|
+
if header in ("options", "flags"):
|
|
65
|
+
state = "options"
|
|
66
|
+
elif header in ("examples", "usage", "global flags"):
|
|
67
|
+
state = "skip"
|
|
68
|
+
else:
|
|
69
|
+
state = "subcommands"
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if state == "description":
|
|
73
|
+
stripped = line.strip()
|
|
74
|
+
if stripped.lower().startswith("usage"):
|
|
75
|
+
state = "skip"
|
|
76
|
+
continue
|
|
77
|
+
if stripped:
|
|
78
|
+
description_lines.append(stripped)
|
|
79
|
+
elif description_lines:
|
|
80
|
+
state = "skip"
|
|
81
|
+
|
|
82
|
+
elif state == "options":
|
|
83
|
+
flag_name = _parse_flag_header(line)
|
|
84
|
+
if flag_name is not None:
|
|
85
|
+
pending_flag = flag_name
|
|
86
|
+
else:
|
|
87
|
+
arg = _parse_single_line_flag(line)
|
|
88
|
+
if arg is not None:
|
|
89
|
+
args.append(arg)
|
|
90
|
+
|
|
91
|
+
elif state == "subcommands":
|
|
92
|
+
stripped = line.strip()
|
|
93
|
+
if not stripped:
|
|
94
|
+
continue
|
|
95
|
+
if stripped.startswith("-"):
|
|
96
|
+
flag_name = _parse_flag_header(line)
|
|
97
|
+
if flag_name is not None:
|
|
98
|
+
pending_flag = flag_name
|
|
99
|
+
else:
|
|
100
|
+
name = _parse_subcommand_line(line)
|
|
101
|
+
if name is not None:
|
|
102
|
+
subcommands.append(name)
|
|
103
|
+
|
|
104
|
+
args.extend(extract_positional_args(text))
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"description": make_description(description_lines, text),
|
|
108
|
+
"args": args,
|
|
109
|
+
"subcommands": subcommands,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _detect_header(line):
|
|
114
|
+
"""Return section name if line is a header like 'Options:', else None."""
|
|
115
|
+
stripped = line.strip()
|
|
116
|
+
if not stripped.endswith(":") or stripped.startswith("-"):
|
|
117
|
+
return None
|
|
118
|
+
if len(line) - len(line.lstrip()) > 4:
|
|
119
|
+
return None
|
|
120
|
+
return stripped[:-1].strip().lower()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_flag_header(line):
|
|
124
|
+
"""Parse '--output=default:' and return the flag name."""
|
|
125
|
+
stripped = line.strip()
|
|
126
|
+
if not stripped.startswith("-"):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
match = re.match(
|
|
130
|
+
r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
|
|
131
|
+
r"(?:=\S*)?" # optional default
|
|
132
|
+
r":$", # trailing colon
|
|
133
|
+
stripped,
|
|
134
|
+
)
|
|
135
|
+
if not match:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
flags = match.group(1)
|
|
139
|
+
names = [f.strip() for f in flags.split(",")]
|
|
140
|
+
return max(names, key=len)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_single_line_flag(line):
|
|
144
|
+
"""Parse '--output string Output format' (single-line modern Cobra flags)."""
|
|
145
|
+
stripped = line.strip()
|
|
146
|
+
if not stripped or not stripped.startswith("-"):
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
match = re.match(
|
|
150
|
+
r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
|
|
151
|
+
r"(?:\s+\S+)?" # optional type/metavar
|
|
152
|
+
r"\s{2,}" # gap (2+ spaces)
|
|
153
|
+
r"(.+)", # description
|
|
154
|
+
stripped,
|
|
155
|
+
)
|
|
156
|
+
if match:
|
|
157
|
+
flags = match.group(1)
|
|
158
|
+
names = [f.strip() for f in flags.split(",")]
|
|
159
|
+
return {
|
|
160
|
+
"name": max(names, key=len),
|
|
161
|
+
"description": match.group(2).strip(),
|
|
162
|
+
"required": False,
|
|
163
|
+
}
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_subcommand_line(line):
|
|
168
|
+
"""Parse ' get Display one or many resources' into the name."""
|
|
169
|
+
stripped = line.strip()
|
|
170
|
+
if not stripped:
|
|
171
|
+
return None
|
|
172
|
+
match = re.match(r"(\w[\w-]*)\s{2,}(.+)", stripped)
|
|
173
|
+
if match:
|
|
174
|
+
return match.group(1)
|
|
175
|
+
return None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Shared helpers used by all parsers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_description(description_lines, text):
|
|
7
|
+
"""Join description lines, or fall back to the command name from Usage."""
|
|
8
|
+
if description_lines:
|
|
9
|
+
return " ".join(description_lines)
|
|
10
|
+
|
|
11
|
+
for line in text.splitlines():
|
|
12
|
+
stripped = line.strip()
|
|
13
|
+
if stripped.lower().startswith("usage"):
|
|
14
|
+
# Capture "git commit" from "usage: git commit [-a] ..."
|
|
15
|
+
match = re.match(r"[Uu]sage:\s*([\w][\w.-]*(?:\s+[\w][\w.-]*)*)", stripped)
|
|
16
|
+
if match:
|
|
17
|
+
return f"{match.group(1)} command"
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_positional_args(text):
|
|
22
|
+
"""Extract positional args from the Usage line (e.g. <url>)."""
|
|
23
|
+
args = []
|
|
24
|
+
seen = set()
|
|
25
|
+
|
|
26
|
+
for line in text.splitlines():
|
|
27
|
+
if not line.strip().lower().startswith("usage"):
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
for match in re.finditer(r"<(\w[\w-]*)>", line):
|
|
31
|
+
name = match.group(1).lower()
|
|
32
|
+
if name not in seen:
|
|
33
|
+
args.append(
|
|
34
|
+
{
|
|
35
|
+
"name": name,
|
|
36
|
+
"description": name,
|
|
37
|
+
"required": True,
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
seen.add(name)
|
|
41
|
+
|
|
42
|
+
return args
|
cli2mcp/parsers/gnu.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Parser for GNU / Python argparse style help text.
|
|
2
|
+
|
|
3
|
+
These tools have section headers like "Options:" and "Commands:".
|
|
4
|
+
Flags and descriptions sit on the same line:
|
|
5
|
+
|
|
6
|
+
usage: mytool [-v] [-o FILE] filename
|
|
7
|
+
|
|
8
|
+
My awesome tool for processing files.
|
|
9
|
+
|
|
10
|
+
Options:
|
|
11
|
+
-v, --verbose Enable verbose output
|
|
12
|
+
-o, --output FILE Output file path
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
run Run the tool
|
|
16
|
+
check Check the input
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from cli2mcp.parsers.common import extract_positional_args, make_description
|
|
22
|
+
|
|
23
|
+
_POSITIONAL_HEADERS = {"positional arguments", "positional"}
|
|
24
|
+
_OPTION_HEADERS = {"options", "optional arguments", "flags"}
|
|
25
|
+
_SUBCOMMAND_HEADERS = {"commands", "subcommands", "available commands"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def can_parse(text):
|
|
29
|
+
"""True if text has at least one known section header."""
|
|
30
|
+
for line in text.splitlines():
|
|
31
|
+
stripped = line.strip()
|
|
32
|
+
if not stripped.endswith(":") or stripped.startswith("-"):
|
|
33
|
+
continue
|
|
34
|
+
name = stripped[:-1].strip().lower()
|
|
35
|
+
if name in _POSITIONAL_HEADERS | _OPTION_HEADERS | _SUBCOMMAND_HEADERS:
|
|
36
|
+
return True
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse(text):
|
|
41
|
+
"""Parse GNU-style help using a simple state machine.
|
|
42
|
+
|
|
43
|
+
States: description, positional, options, subcommands, skip.
|
|
44
|
+
Transitions happen when we hit a section header.
|
|
45
|
+
"""
|
|
46
|
+
description_lines = []
|
|
47
|
+
args = []
|
|
48
|
+
subcommands = []
|
|
49
|
+
state = "description"
|
|
50
|
+
|
|
51
|
+
for line in text.splitlines():
|
|
52
|
+
header = _detect_header(line)
|
|
53
|
+
if header is not None:
|
|
54
|
+
if header in _POSITIONAL_HEADERS:
|
|
55
|
+
state = "positional"
|
|
56
|
+
elif header in _OPTION_HEADERS:
|
|
57
|
+
state = "options"
|
|
58
|
+
elif header in _SUBCOMMAND_HEADERS:
|
|
59
|
+
state = "subcommands"
|
|
60
|
+
else:
|
|
61
|
+
state = "skip"
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if state == "description":
|
|
65
|
+
stripped = line.strip()
|
|
66
|
+
if stripped.lower().startswith("usage"):
|
|
67
|
+
state = "skip"
|
|
68
|
+
continue
|
|
69
|
+
if stripped:
|
|
70
|
+
description_lines.append(stripped)
|
|
71
|
+
elif description_lines:
|
|
72
|
+
state = "skip"
|
|
73
|
+
|
|
74
|
+
elif state == "positional":
|
|
75
|
+
arg = _parse_flag_line(line)
|
|
76
|
+
if arg is not None:
|
|
77
|
+
arg["required"] = True
|
|
78
|
+
args.append(arg)
|
|
79
|
+
|
|
80
|
+
elif state == "options":
|
|
81
|
+
arg = _parse_flag_line(line)
|
|
82
|
+
if arg is not None:
|
|
83
|
+
arg["required"] = False
|
|
84
|
+
args.append(arg)
|
|
85
|
+
|
|
86
|
+
elif state == "subcommands":
|
|
87
|
+
name = _parse_subcommand_line(line)
|
|
88
|
+
if name is not None:
|
|
89
|
+
subcommands.append(name)
|
|
90
|
+
|
|
91
|
+
args.extend(extract_positional_args(text))
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"description": make_description(description_lines, text),
|
|
95
|
+
"args": args,
|
|
96
|
+
"subcommands": subcommands,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _detect_header(line):
|
|
101
|
+
"""Return section name if line is a header like 'Options:', else None."""
|
|
102
|
+
stripped = line.strip()
|
|
103
|
+
if not stripped.endswith(":") or stripped.startswith("-"):
|
|
104
|
+
return None
|
|
105
|
+
if len(line) - len(line.lstrip()) > 4:
|
|
106
|
+
return None
|
|
107
|
+
return stripped[:-1].strip().lower()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_flag_line(line):
|
|
111
|
+
"""Parse '-v, --verbose Enable verbose output' into a dict."""
|
|
112
|
+
stripped = line.strip()
|
|
113
|
+
if not stripped or stripped.startswith("---"):
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
match = re.match(
|
|
117
|
+
r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
|
|
118
|
+
r"(?:\s+\S+)?" # optional metavar
|
|
119
|
+
r"\s{2,}" # gap
|
|
120
|
+
r"(.+)", # description
|
|
121
|
+
stripped,
|
|
122
|
+
)
|
|
123
|
+
if match:
|
|
124
|
+
flags = match.group(1)
|
|
125
|
+
names = [f.strip() for f in flags.split(",")]
|
|
126
|
+
return {
|
|
127
|
+
"name": max(names, key=len),
|
|
128
|
+
"description": match.group(2).strip(),
|
|
129
|
+
"required": False,
|
|
130
|
+
}
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_subcommand_line(line):
|
|
135
|
+
"""Parse ' commit Record changes' into the subcommand name."""
|
|
136
|
+
stripped = line.strip()
|
|
137
|
+
if not stripped:
|
|
138
|
+
return None
|
|
139
|
+
match = re.match(r"(\w[\w-]*)\s{2,}(.+)", stripped)
|
|
140
|
+
if match:
|
|
141
|
+
return match.group(1)
|
|
142
|
+
return None
|
cli2mcp/parsers/plain.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Parser for plain / minimal help text (curl, busybox, ...).
|
|
2
|
+
|
|
3
|
+
These tools print flags directly after a Usage: line with no
|
|
4
|
+
section headers like "Options:" or "Commands:".
|
|
5
|
+
|
|
6
|
+
Usage: curl [options...] <url>
|
|
7
|
+
-d, --data <data> HTTP POST data
|
|
8
|
+
-f, --fail Fail fast with no output on HTTP errors
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
from cli2mcp.parsers.common import extract_positional_args, make_description
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def can_parse(text):
|
|
17
|
+
"""True if text has flag lines but no section headers."""
|
|
18
|
+
has_flags = False
|
|
19
|
+
has_headers = False
|
|
20
|
+
|
|
21
|
+
for line in text.splitlines():
|
|
22
|
+
stripped = line.strip()
|
|
23
|
+
if stripped.startswith("-") and " " in stripped:
|
|
24
|
+
has_flags = True
|
|
25
|
+
if (
|
|
26
|
+
stripped.endswith(":")
|
|
27
|
+
and not stripped.startswith("-")
|
|
28
|
+
and len(line) - len(line.lstrip()) <= 4
|
|
29
|
+
):
|
|
30
|
+
name = stripped[:-1].strip().lower()
|
|
31
|
+
if name in (
|
|
32
|
+
"options",
|
|
33
|
+
"commands",
|
|
34
|
+
"positional arguments",
|
|
35
|
+
"optional arguments",
|
|
36
|
+
"subcommands",
|
|
37
|
+
"flags",
|
|
38
|
+
):
|
|
39
|
+
has_headers = True
|
|
40
|
+
|
|
41
|
+
return has_flags and not has_headers
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse(text):
|
|
45
|
+
"""Parse plain-style help text.
|
|
46
|
+
|
|
47
|
+
1. Collect description lines before "Usage:"
|
|
48
|
+
2. After Usage:, parse "-" lines as flags and "word desc" as subcommands
|
|
49
|
+
"""
|
|
50
|
+
description_lines = []
|
|
51
|
+
args = []
|
|
52
|
+
subcommands = []
|
|
53
|
+
past_usage = False
|
|
54
|
+
|
|
55
|
+
for line in text.splitlines():
|
|
56
|
+
stripped = line.strip()
|
|
57
|
+
|
|
58
|
+
if not past_usage:
|
|
59
|
+
if stripped.lower().startswith("usage"):
|
|
60
|
+
past_usage = True
|
|
61
|
+
continue
|
|
62
|
+
if stripped:
|
|
63
|
+
description_lines.append(stripped)
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if not stripped:
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if stripped.startswith("-"):
|
|
70
|
+
arg = _parse_flag_line(stripped)
|
|
71
|
+
if arg is not None:
|
|
72
|
+
args.append(arg)
|
|
73
|
+
else:
|
|
74
|
+
name = _parse_subcommand_line(stripped)
|
|
75
|
+
if name is not None:
|
|
76
|
+
subcommands.append(name)
|
|
77
|
+
|
|
78
|
+
args.extend(extract_positional_args(text))
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"description": make_description(description_lines, text),
|
|
82
|
+
"args": args,
|
|
83
|
+
"subcommands": subcommands,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_flag_line(stripped):
|
|
88
|
+
"""Parse '-d, --data <data> HTTP POST data' into a dict."""
|
|
89
|
+
match = re.match(
|
|
90
|
+
r"(-\S+(?:,\s*-\S+)*)" # flag name(s)
|
|
91
|
+
r"(?:\s+\S+)?" # optional metavar
|
|
92
|
+
r"\s{2,}" # gap
|
|
93
|
+
r"(.+)", # description
|
|
94
|
+
stripped,
|
|
95
|
+
)
|
|
96
|
+
if match:
|
|
97
|
+
flags = match.group(1)
|
|
98
|
+
names = [f.strip() for f in flags.split(",")]
|
|
99
|
+
return {
|
|
100
|
+
"name": max(names, key=len),
|
|
101
|
+
"description": match.group(2).strip(),
|
|
102
|
+
"required": False,
|
|
103
|
+
}
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_subcommand_line(stripped):
|
|
108
|
+
"""Parse ' clone Clone a repository' into the subcommand name."""
|
|
109
|
+
match = re.match(r"(\w[\w-]*)\s{2,}(.+)", stripped)
|
|
110
|
+
if match:
|
|
111
|
+
return match.group(1)
|
|
112
|
+
return None
|