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,276 @@
|
|
|
1
|
+
"""Native reflection helper executed inside the target CLI's interpreter.
|
|
2
|
+
|
|
3
|
+
Run as::
|
|
4
|
+
|
|
5
|
+
python -m cli_mcp.discovery.introspect --target /path/to/cli.py
|
|
6
|
+
|
|
7
|
+
It imports the target module (which executes module-level code, so cli-mcp keeps
|
|
8
|
+
this behind ``discovery.allow_import_reflection``) and prints a JSON document
|
|
9
|
+
describing the commands declared by click, typer or argparse. Everything is
|
|
10
|
+
printed to stdout; failures are reported as JSON so the parent process can fall
|
|
11
|
+
back to help parsing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import importlib
|
|
18
|
+
import importlib.util
|
|
19
|
+
import inspect
|
|
20
|
+
import json
|
|
21
|
+
from types import ModuleType
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _json_default(value: Any) -> str:
|
|
26
|
+
return str(value)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_module(target: str) -> ModuleType:
|
|
30
|
+
"""Import a script file or an importable module name."""
|
|
31
|
+
if target.endswith(".py") or target.endswith(".pyw"):
|
|
32
|
+
spec = importlib.util.spec_from_file_location("cli_mcp_target", target)
|
|
33
|
+
if spec is None or spec.loader is None:
|
|
34
|
+
raise ImportError(f"cannot load {target}")
|
|
35
|
+
module = importlib.util.module_from_spec(spec)
|
|
36
|
+
module.__name__ = "cli_mcp_target"
|
|
37
|
+
spec.loader.exec_module(module)
|
|
38
|
+
return module
|
|
39
|
+
return importlib.import_module(target)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _click_type(param: Any) -> tuple[str, list[str] | None]:
|
|
43
|
+
"""Map a click Param type onto a JSON Schema type plus choices."""
|
|
44
|
+
type_obj = getattr(param, "type", None)
|
|
45
|
+
choices = getattr(type_obj, "choices", None)
|
|
46
|
+
name = getattr(type_obj, "name", None) or str(type_obj)
|
|
47
|
+
lowered = str(name).lower()
|
|
48
|
+
if "int" in lowered:
|
|
49
|
+
return "integer", list(choices) if choices else None
|
|
50
|
+
if "float" in lowered:
|
|
51
|
+
return "number", list(choices) if choices else None
|
|
52
|
+
if "bool" in lowered:
|
|
53
|
+
return "boolean", list(choices) if choices else None
|
|
54
|
+
return "string", list(choices) if choices else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _walk_click(command: Any, path: list[str], out: list[dict[str, Any]]) -> None:
|
|
58
|
+
"""Collect a click command and its children."""
|
|
59
|
+
entry: dict[str, Any] = {
|
|
60
|
+
"path": list(path),
|
|
61
|
+
"description": command.help or command.short_help or "",
|
|
62
|
+
"parameters": [],
|
|
63
|
+
"has_subcommands": bool(getattr(command, "commands", None)),
|
|
64
|
+
}
|
|
65
|
+
for param in getattr(command, "params", []):
|
|
66
|
+
opts = list(getattr(param, "opts", []) or [])
|
|
67
|
+
secondary = list(getattr(param, "secondary_opts", []) or [])
|
|
68
|
+
long_opts = [opt for opt in opts if opt.startswith("--")]
|
|
69
|
+
short_opts = [opt for opt in opts if opt.startswith("-") and not opt.startswith("--")] + secondary
|
|
70
|
+
has_flag_opt = any(opt.startswith("-") for opt in opts)
|
|
71
|
+
type_name, choices = _click_type(param)
|
|
72
|
+
default = getattr(param, "default", None)
|
|
73
|
+
if callable(default):
|
|
74
|
+
default = None
|
|
75
|
+
entry["parameters"].append(
|
|
76
|
+
{
|
|
77
|
+
"name": getattr(param, "name", None) or (long_opts[0].lstrip("-") if long_opts else "arg"),
|
|
78
|
+
"kind": "flag" if has_flag_opt and getattr(param, "is_flag", False) else (
|
|
79
|
+
"option" if has_flag_opt else "positional"
|
|
80
|
+
),
|
|
81
|
+
"cli_name": long_opts[0].lstrip("-") if long_opts else None,
|
|
82
|
+
"short": short_opts[0].lstrip("-") if short_opts else None,
|
|
83
|
+
"type": type_name,
|
|
84
|
+
"description": getattr(param, "help", None) or "",
|
|
85
|
+
"required": bool(getattr(param, "required", False)),
|
|
86
|
+
"default": default,
|
|
87
|
+
"choices": choices,
|
|
88
|
+
"repeatable": bool(getattr(param, "multiple", False)),
|
|
89
|
+
"metavar": getattr(param, "metavar", None),
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
out.append(entry)
|
|
93
|
+
for name, child in sorted((getattr(command, "commands", {}) or {}).items()):
|
|
94
|
+
_walk_click(child, [*path, name], out)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _argparse_type(action: Any) -> str:
|
|
98
|
+
"""Map an argparse action's type callable onto a JSON Schema type."""
|
|
99
|
+
type_callable = action.type
|
|
100
|
+
if type_callable is None:
|
|
101
|
+
return "string"
|
|
102
|
+
if type_callable is int:
|
|
103
|
+
return "integer"
|
|
104
|
+
if type_callable is float:
|
|
105
|
+
return "number"
|
|
106
|
+
if type_callable is bool:
|
|
107
|
+
return "boolean"
|
|
108
|
+
if type_callable is str:
|
|
109
|
+
return "string"
|
|
110
|
+
return "string"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _walk_argparse(parser: Any, path: list[str], out: list[dict[str, Any]]) -> None:
|
|
114
|
+
"""Collect an argparse parser and its subparsers."""
|
|
115
|
+
entry: dict[str, Any] = {
|
|
116
|
+
"path": list(path),
|
|
117
|
+
"description": getattr(parser, "description", None) or getattr(parser, "help", "") or "",
|
|
118
|
+
"parameters": [],
|
|
119
|
+
"has_subcommands": False,
|
|
120
|
+
"usage": getattr(parser, "usage", None),
|
|
121
|
+
}
|
|
122
|
+
actions = list(getattr(parser, "_actions", []))
|
|
123
|
+
subparsers_action = None
|
|
124
|
+
for action in actions:
|
|
125
|
+
if getattr(action, "dest", None) == "help":
|
|
126
|
+
continue
|
|
127
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
128
|
+
subparsers_action = action
|
|
129
|
+
continue
|
|
130
|
+
option_strings = list(getattr(action, "option_strings", []))
|
|
131
|
+
long_opts = [opt for opt in option_strings if opt.startswith("--")]
|
|
132
|
+
short_opts = [opt for opt in option_strings if opt.startswith("-") and not opt.startswith("--")]
|
|
133
|
+
nargs = getattr(action, "nargs", None)
|
|
134
|
+
entry["parameters"].append(
|
|
135
|
+
{
|
|
136
|
+
"name": getattr(action, "dest", None) or "arg",
|
|
137
|
+
"kind": "positional" if not option_strings else ("flag" if nargs == 0 else "option"),
|
|
138
|
+
"cli_name": long_opts[0].lstrip("-") if long_opts else None,
|
|
139
|
+
"short": short_opts[0].lstrip("-") if short_opts else None,
|
|
140
|
+
"type": _argparse_type(action),
|
|
141
|
+
"description": getattr(action, "help", None) or "",
|
|
142
|
+
"required": bool(getattr(action, "required", False)),
|
|
143
|
+
"default": getattr(action, "default", None),
|
|
144
|
+
"choices": list(action.choices) if getattr(action, "choices", None) else None,
|
|
145
|
+
"repeatable": False,
|
|
146
|
+
"metavar": getattr(action, "metavar", None),
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
if subparsers_action is not None:
|
|
150
|
+
entry["has_subcommands"] = True
|
|
151
|
+
for name, sub in sorted((getattr(subparsers_action, "choices", {}) or {}).items()):
|
|
152
|
+
if isinstance(sub, argparse.ArgumentParser):
|
|
153
|
+
_walk_argparse(sub, [*path, name], out)
|
|
154
|
+
out.append(entry)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _typer_command(app: Any) -> Any | None:
|
|
158
|
+
"""Resolve a typer app to its underlying click command."""
|
|
159
|
+
try:
|
|
160
|
+
import typer
|
|
161
|
+
except ImportError:
|
|
162
|
+
return None
|
|
163
|
+
try:
|
|
164
|
+
return typer.main.get_command(app)
|
|
165
|
+
except Exception:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _click_classes() -> tuple[type, ...]:
|
|
170
|
+
"""Click command classes, or an empty tuple when click is not installed."""
|
|
171
|
+
try:
|
|
172
|
+
import click
|
|
173
|
+
except ImportError:
|
|
174
|
+
return ()
|
|
175
|
+
return (click.Group, click.Command)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _descendant_ids(command: Any) -> set[int]:
|
|
179
|
+
"""Identity of every click command nested inside *command*."""
|
|
180
|
+
ids = set()
|
|
181
|
+
for child in (getattr(command, "commands", {}) or {}).values():
|
|
182
|
+
ids.add(id(child))
|
|
183
|
+
ids.update(_descendant_ids(child))
|
|
184
|
+
return ids
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _click_roots(values: list[Any], click_classes: tuple[type, ...]) -> list[Any]:
|
|
188
|
+
"""Top-level click objects: every group, plus commands no group already contains."""
|
|
189
|
+
if not click_classes:
|
|
190
|
+
return []
|
|
191
|
+
group_class, command_class = click_classes
|
|
192
|
+
groups = [value for value in values if isinstance(value, group_class)]
|
|
193
|
+
covered = {id(group) for group in groups}
|
|
194
|
+
for group in groups:
|
|
195
|
+
covered.update(_descendant_ids(group))
|
|
196
|
+
roots = list(groups)
|
|
197
|
+
for value in values:
|
|
198
|
+
if isinstance(value, command_class) and not isinstance(value, group_class) and id(value) not in covered:
|
|
199
|
+
roots.append(value)
|
|
200
|
+
return roots
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _scan_namespace(values: list[Any], click_classes: tuple[type, ...]) -> tuple[list[Any], list[Any], list[Any]]:
|
|
204
|
+
"""Split a module namespace into click objects, typer apps and argparse parsers."""
|
|
205
|
+
typer_apps: list[Any] = []
|
|
206
|
+
parsers: list[Any] = []
|
|
207
|
+
for value in values:
|
|
208
|
+
if isinstance(value, argparse.ArgumentParser):
|
|
209
|
+
parsers.append(value)
|
|
210
|
+
elif type(value).__name__ == "Typer":
|
|
211
|
+
typer_apps.append(value)
|
|
212
|
+
return _click_roots(values, click_classes), typer_apps, parsers
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _parser_factory(module: ModuleType) -> Any | None:
|
|
216
|
+
"""An argparse parser built by a module-level ``build_parser``-style factory."""
|
|
217
|
+
for attr in ("build_parser", "create_parser", "get_parser"):
|
|
218
|
+
factory = getattr(module, attr, None)
|
|
219
|
+
if not callable(factory) or isinstance(factory, argparse.ArgumentParser):
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
built = factory()
|
|
223
|
+
except (TypeError, SystemExit, OSError):
|
|
224
|
+
continue
|
|
225
|
+
if isinstance(built, argparse.ArgumentParser):
|
|
226
|
+
return built
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def discover(target: str) -> dict[str, Any]:
|
|
231
|
+
"""Import *target* and describe the commands it declares."""
|
|
232
|
+
module = _load_module(target)
|
|
233
|
+
namespace = vars(module)
|
|
234
|
+
values = [value for value in namespace.values() if not inspect.ismodule(value)]
|
|
235
|
+
click_objects, typer_apps, parsers = _scan_namespace(values, _click_classes())
|
|
236
|
+
if not parsers and not click_objects and not typer_apps:
|
|
237
|
+
built = _parser_factory(module)
|
|
238
|
+
if built is not None:
|
|
239
|
+
parsers.append(built)
|
|
240
|
+
|
|
241
|
+
commands: list[dict[str, Any]] = []
|
|
242
|
+
framework: str | None = None
|
|
243
|
+
|
|
244
|
+
for app in typer_apps:
|
|
245
|
+
command = _typer_command(app)
|
|
246
|
+
if command is not None:
|
|
247
|
+
framework = "typer"
|
|
248
|
+
_walk_click(command, [], commands)
|
|
249
|
+
for obj in click_objects:
|
|
250
|
+
if framework != "typer":
|
|
251
|
+
framework = "click"
|
|
252
|
+
_walk_click(obj, [], commands)
|
|
253
|
+
for parser in parsers:
|
|
254
|
+
if framework is None:
|
|
255
|
+
framework = "argparse"
|
|
256
|
+
_walk_argparse(parser, [], commands)
|
|
257
|
+
|
|
258
|
+
return {"framework": framework, "commands": commands}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def main(argv: list[str] | None = None) -> int:
|
|
262
|
+
"""Entry point: emit the discovery document as JSON."""
|
|
263
|
+
parser = argparse.ArgumentParser(prog="cli-mcp-introspect")
|
|
264
|
+
parser.add_argument("--target", required=True, help="Script path or importable module name")
|
|
265
|
+
args = parser.parse_args(argv)
|
|
266
|
+
try:
|
|
267
|
+
payload = discover(args.target)
|
|
268
|
+
except BaseException as exc:
|
|
269
|
+
print(json.dumps({"framework": None, "commands": [], "error": str(exc)}))
|
|
270
|
+
return 0
|
|
271
|
+
print(json.dumps(payload, default=_json_default))
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
if __name__ == "__main__": # pragma: no cover
|
|
276
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Strategy 3: user-provided manifest metadata, which overrides discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from cli_mcp.config.loader import find_config_file, load_yaml, unwrap
|
|
12
|
+
from cli_mcp.config.settings import Settings
|
|
13
|
+
from cli_mcp.detect import TargetInfo
|
|
14
|
+
from cli_mcp.discovery.base import DiscoveryContext
|
|
15
|
+
from cli_mcp.errors import ConfigError
|
|
16
|
+
from cli_mcp.models import CommandSpec, DiscoveryResult, Parameter, sanitize_tool_name
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ManifestDocument:
|
|
21
|
+
"""The parts of a manifest that affect discovery."""
|
|
22
|
+
|
|
23
|
+
path: Path | None = None
|
|
24
|
+
binary: str | None = None
|
|
25
|
+
tools: list[CommandSpec] = field(default_factory=list)
|
|
26
|
+
include: list[str] = field(default_factory=list)
|
|
27
|
+
exclude: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def manifest_path(settings: Settings, cwd: Path | None = None) -> Path | None:
|
|
31
|
+
"""Locate the manifest file: explicit path first, then a config file in *cwd*."""
|
|
32
|
+
if settings.manifest:
|
|
33
|
+
explicit = Path(settings.manifest)
|
|
34
|
+
if not explicit.is_absolute() and cwd is not None:
|
|
35
|
+
explicit = cwd / explicit
|
|
36
|
+
if explicit.is_file():
|
|
37
|
+
return explicit
|
|
38
|
+
raise ConfigError(f"manifest file {settings.manifest} does not exist")
|
|
39
|
+
if cwd is not None:
|
|
40
|
+
return find_config_file(cwd)
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: Manifest spellings that map onto :class:`Parameter` fields.
|
|
45
|
+
_PARAMETER_ALIASES = {"cli-name": "cli_name", "cliName": "cli_name", "enum": "choices"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parameter_from(raw: dict[str, Any]) -> Parameter:
|
|
49
|
+
"""Build a parameter from a manifest entry."""
|
|
50
|
+
payload: dict[str, Any] = {str(key): value for key, value in raw.items()}
|
|
51
|
+
for alias, canonical in _PARAMETER_ALIASES.items():
|
|
52
|
+
if alias in payload and canonical not in payload:
|
|
53
|
+
payload[canonical] = payload.pop(alias)
|
|
54
|
+
name = str(payload.get("name") or "arg")
|
|
55
|
+
payload.setdefault("kind", "option")
|
|
56
|
+
payload.setdefault("type", "string")
|
|
57
|
+
payload["name"] = name
|
|
58
|
+
try:
|
|
59
|
+
return Parameter.model_validate(payload)
|
|
60
|
+
except ValidationError as exc:
|
|
61
|
+
raise ConfigError(f"Invalid manifest parameter {name!r}: {exc}") from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _tool_from(raw: dict[str, Any], default_provider: str) -> CommandSpec:
|
|
65
|
+
"""Build a command from a manifest entry."""
|
|
66
|
+
payload: dict[str, Any] = dict(raw)
|
|
67
|
+
name = str(payload.get("name") or payload.get("command") or "tool")
|
|
68
|
+
path_value = payload.get("path") or payload.get("command") or name
|
|
69
|
+
path = [str(part) for part in path_value] if isinstance(path_value, list) else str(path_value).split()
|
|
70
|
+
parameters_raw: list[Any] = payload.get("parameters") or []
|
|
71
|
+
parameters = [_parameter_from(item) for item in parameters_raw if isinstance(item, dict)]
|
|
72
|
+
examples_raw: list[Any] = payload.get("examples") or []
|
|
73
|
+
try:
|
|
74
|
+
return CommandSpec(
|
|
75
|
+
name=sanitize_tool_name(name),
|
|
76
|
+
path=path,
|
|
77
|
+
description=str(payload.get("description") or ""),
|
|
78
|
+
parameters=parameters,
|
|
79
|
+
provider=default_provider,
|
|
80
|
+
examples=[str(item) for item in examples_raw],
|
|
81
|
+
destructive=bool(payload.get("destructive", False)),
|
|
82
|
+
has_subcommands=bool(payload.get("has_subcommands", False)),
|
|
83
|
+
)
|
|
84
|
+
except ValidationError as exc:
|
|
85
|
+
raise ConfigError(f"Invalid manifest tool {name!r}: {exc}") from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_manifest(settings: Settings, cwd: Path | None = None) -> ManifestDocument:
|
|
89
|
+
"""Read manifest metadata from the configured file, if there is one."""
|
|
90
|
+
path = manifest_path(settings, cwd)
|
|
91
|
+
if path is None:
|
|
92
|
+
return ManifestDocument()
|
|
93
|
+
payload: dict[str, Any] = unwrap(load_yaml(path))
|
|
94
|
+
tools_raw: list[Any] = payload.get("tools") or []
|
|
95
|
+
tools = [_tool_from(item, "manifest") for item in tools_raw if isinstance(item, dict)]
|
|
96
|
+
include = [str(item) for item in (payload.get("include") or [])]
|
|
97
|
+
exclude = [str(item) for item in (payload.get("exclude") or [])]
|
|
98
|
+
binary = payload.get("binary")
|
|
99
|
+
return ManifestDocument(
|
|
100
|
+
path=path,
|
|
101
|
+
binary=str(binary) if binary else None,
|
|
102
|
+
tools=tools,
|
|
103
|
+
include=include,
|
|
104
|
+
exclude=exclude,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ManifestProvider:
|
|
109
|
+
"""Publish commands declared in the manifest; manifest data is authoritative."""
|
|
110
|
+
|
|
111
|
+
name = "manifest"
|
|
112
|
+
strategy = "manifest"
|
|
113
|
+
frameworks: tuple[str, ...] = ()
|
|
114
|
+
|
|
115
|
+
def __init__(self, settings: Settings) -> None:
|
|
116
|
+
"""Store the settings that locate the manifest file."""
|
|
117
|
+
self.settings = settings
|
|
118
|
+
|
|
119
|
+
def supports(self, target: TargetInfo) -> bool:
|
|
120
|
+
"""Report whether a manifest can apply to any binary; :meth:`discover` decides whether one exists."""
|
|
121
|
+
del target
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def discover(self, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
|
|
125
|
+
"""Return manifest-declared commands for *target*."""
|
|
126
|
+
document = read_manifest(self.settings, ctx.cwd)
|
|
127
|
+
if not document.tools:
|
|
128
|
+
return None
|
|
129
|
+
return DiscoveryResult(
|
|
130
|
+
binary=target.binary,
|
|
131
|
+
resolved_path=target.resolved_path,
|
|
132
|
+
framework=target.framework or self.name,
|
|
133
|
+
commands=document.tools,
|
|
134
|
+
strategies=[self.name],
|
|
135
|
+
warnings=[f"manifest: {document.path}"] if document.path else [],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def matches(name: str, path: list[str], patterns: list[str]) -> bool:
|
|
140
|
+
"""Whether a command matches one of the include/exclude patterns."""
|
|
141
|
+
lowered_name = name.lower()
|
|
142
|
+
lowered_last = path[-1].lower() if path else ""
|
|
143
|
+
for pattern in patterns:
|
|
144
|
+
candidate = pattern.lower().strip()
|
|
145
|
+
if not candidate:
|
|
146
|
+
continue
|
|
147
|
+
if lowered_name == candidate or lowered_last == candidate:
|
|
148
|
+
return True
|
|
149
|
+
if lowered_name.startswith(f"{candidate}_"):
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def apply_filters(commands: list[CommandSpec], include: list[str], exclude: list[str]) -> list[CommandSpec]:
|
|
155
|
+
"""Keep commands matching *include* (when given) and drop those matching *exclude*."""
|
|
156
|
+
kept = commands
|
|
157
|
+
if include:
|
|
158
|
+
kept = [command for command in kept if matches(command.name, command.path, include)]
|
|
159
|
+
if exclude:
|
|
160
|
+
kept = [command for command in kept if not matches(command.name, command.path, exclude)]
|
|
161
|
+
return kept
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Strategy 1 for Node.js CLIs: reflect on a commander.js program object."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from cli_mcp.config.settings import Settings
|
|
8
|
+
from cli_mcp.detect import TargetInfo
|
|
9
|
+
from cli_mcp.discovery.base import DiscoveryContext
|
|
10
|
+
from cli_mcp.discovery.reflection_payload import commands_from_payload, parse_payload
|
|
11
|
+
from cli_mcp.models import DiscoveryResult
|
|
12
|
+
|
|
13
|
+
#: Probe executed with ``node -e``. It requires the entry module and walks any
|
|
14
|
+
#: commander program it can find. Requires modules that export their program.
|
|
15
|
+
NODE_PROBE = """
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const target = process.env.CLI_MCP_NODE_TARGET;
|
|
18
|
+
const out = [];
|
|
19
|
+
|
|
20
|
+
function optionType(option) {
|
|
21
|
+
const hint = (option.argName || option.flag || '').toLowerCase();
|
|
22
|
+
if (/\\b(number|count|int)\\b/.test(hint)) return 'number';
|
|
23
|
+
return 'string';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function walk(command, prefix) {
|
|
27
|
+
const entry = {
|
|
28
|
+
path: prefix,
|
|
29
|
+
description: command.description() || '',
|
|
30
|
+
parameters: [],
|
|
31
|
+
has_subcommands: (command.commands || []).length > 0,
|
|
32
|
+
};
|
|
33
|
+
for (const option of command.options || []) {
|
|
34
|
+
const long = (option.long || '').replace(/^--/, '');
|
|
35
|
+
const short = (option.short || '').replace(/^--?/, '');
|
|
36
|
+
entry.parameters.push({
|
|
37
|
+
name: long || short || 'arg',
|
|
38
|
+
kind: option.required ? 'option' : 'flag',
|
|
39
|
+
cli_name: long || short || null,
|
|
40
|
+
short: short || null,
|
|
41
|
+
short_only: !long && Boolean(short),
|
|
42
|
+
type: optionType(option),
|
|
43
|
+
description: option.description || '',
|
|
44
|
+
required: Boolean(option.required),
|
|
45
|
+
default: option.defaultValue === undefined ? null : option.defaultValue,
|
|
46
|
+
repeatable: Boolean(option.variadic)
|
|
47
|
+
|| Boolean(option.implied && option.implied.value && option.implied.value.repeatable),
|
|
48
|
+
metavar: option.argName || null,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const positionals = command.registeredArguments || command._args || [];
|
|
52
|
+
for (const argument of positionals) {
|
|
53
|
+
entry.parameters.push({
|
|
54
|
+
name: argument.name || 'arg',
|
|
55
|
+
kind: 'positional',
|
|
56
|
+
type: argument.variadic ? 'array' : 'string',
|
|
57
|
+
description: argument.description || '',
|
|
58
|
+
required: Boolean(argument.required),
|
|
59
|
+
repeatable: Boolean(argument.variadic),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
out.push(entry);
|
|
63
|
+
for (const sub of command.commands || []) {
|
|
64
|
+
walk(sub, prefix.concat(sub.name()));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const loaded = require(path.resolve(target));
|
|
70
|
+
const candidates = [];
|
|
71
|
+
if (loaded && typeof loaded === 'object') {
|
|
72
|
+
for (const value of Object.values(loaded)) {
|
|
73
|
+
if (value && typeof value === 'object' && Array.isArray(value.commands)) candidates.push(value);
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(loaded.commands) && typeof loaded.description === 'function') candidates.push(loaded);
|
|
76
|
+
}
|
|
77
|
+
for (const candidate of candidates) walk(candidate, []);
|
|
78
|
+
process.stdout.write(JSON.stringify({ framework: 'commander', commands: out }));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
process.stdout.write(JSON.stringify({ framework: null, commands: [], error: String(error) }));
|
|
81
|
+
}
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class NodeReflectionProvider:
|
|
86
|
+
"""Require a Node CLI entry module and read its commander command tree."""
|
|
87
|
+
|
|
88
|
+
name = "reflection-node"
|
|
89
|
+
strategy = "reflection"
|
|
90
|
+
frameworks: tuple[str, ...] = ("node", "commander")
|
|
91
|
+
|
|
92
|
+
def __init__(self, settings: Settings) -> None:
|
|
93
|
+
"""Store the settings that gate reflection."""
|
|
94
|
+
self.settings = settings
|
|
95
|
+
|
|
96
|
+
def supports(self, target: TargetInfo) -> bool:
|
|
97
|
+
"""Reflection executes the target module, so it needs a Node target and opt-in."""
|
|
98
|
+
if not self.settings.discovery.allow_import_reflection:
|
|
99
|
+
return False
|
|
100
|
+
return target.origin == "node"
|
|
101
|
+
|
|
102
|
+
def discover(self, target: TargetInfo, ctx: DiscoveryContext) -> DiscoveryResult | None:
|
|
103
|
+
"""Run the probe and convert its JSON document."""
|
|
104
|
+
node = os.environ.get("CLI_MCP_NODE") or ctx.interpreter("node")
|
|
105
|
+
if not node:
|
|
106
|
+
return None
|
|
107
|
+
help_result = ctx.run(
|
|
108
|
+
[node, "-e", NODE_PROBE],
|
|
109
|
+
timeout=self.settings.discovery.help_timeout_seconds,
|
|
110
|
+
env={"CLI_MCP_NODE_TARGET": target.resolved_path},
|
|
111
|
+
)
|
|
112
|
+
payload = parse_payload(help_result.stdout)
|
|
113
|
+
if payload is None:
|
|
114
|
+
return None
|
|
115
|
+
commands = commands_from_payload(payload, target, self.settings.mcp.tool_prefix)
|
|
116
|
+
if not commands:
|
|
117
|
+
return None
|
|
118
|
+
warnings = [f"reflection error: {payload['error']}"] if payload.get("error") else []
|
|
119
|
+
return DiscoveryResult(
|
|
120
|
+
binary=target.binary,
|
|
121
|
+
resolved_path=target.resolved_path,
|
|
122
|
+
framework=payload.get("framework") or target.framework,
|
|
123
|
+
commands=commands,
|
|
124
|
+
strategies=[self.name],
|
|
125
|
+
warnings=warnings,
|
|
126
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Discovery orchestration: configured strategies, then the manifest override."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cli_mcp.config.settings import Settings
|
|
8
|
+
from cli_mcp.detect import TargetInfo
|
|
9
|
+
from cli_mcp.discovery.base import DiscoveryContext
|
|
10
|
+
from cli_mcp.discovery.manifest import apply_filters, read_manifest
|
|
11
|
+
from cli_mcp.discovery.registry import ProviderRegistry
|
|
12
|
+
from cli_mcp.errors import DiscoveryError
|
|
13
|
+
from cli_mcp.models import CommandSpec, DiscoveryResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def discover(
|
|
17
|
+
target: TargetInfo,
|
|
18
|
+
settings: Settings,
|
|
19
|
+
ctx: DiscoveryContext | None = None,
|
|
20
|
+
registry: ProviderRegistry | None = None,
|
|
21
|
+
) -> DiscoveryResult:
|
|
22
|
+
"""Run the configured strategies and merge manifest metadata into the result."""
|
|
23
|
+
context = ctx or DiscoveryContext(settings.discovery, cwd=Path.cwd())
|
|
24
|
+
provider_registry = registry or ProviderRegistry(settings)
|
|
25
|
+
manifest = read_manifest(settings, context.cwd)
|
|
26
|
+
|
|
27
|
+
result: DiscoveryResult | None = None
|
|
28
|
+
strategies: list[str] = []
|
|
29
|
+
for strategy in settings.discovery.strategies:
|
|
30
|
+
found = provider_registry.run(strategy, target, context)
|
|
31
|
+
if found is not None and found.commands:
|
|
32
|
+
result = found
|
|
33
|
+
strategies.extend(found.strategies or [strategy])
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
commands = list(result.commands) if result is not None else []
|
|
37
|
+
warnings = list(result.warnings) if result is not None else []
|
|
38
|
+
if result is None:
|
|
39
|
+
warnings.append(f"no commands discovered by strategies: {', '.join(settings.discovery.strategies)}")
|
|
40
|
+
|
|
41
|
+
if manifest.tools:
|
|
42
|
+
commands = merge_manifest(commands, manifest.tools, warnings)
|
|
43
|
+
strategies.append("manifest")
|
|
44
|
+
|
|
45
|
+
include = list(settings.discovery.include or []) + manifest.include
|
|
46
|
+
exclude = list(settings.discovery.exclude or []) + manifest.exclude
|
|
47
|
+
if include or exclude:
|
|
48
|
+
filtered = apply_filters(commands, include, exclude)
|
|
49
|
+
if len(filtered) < len(commands):
|
|
50
|
+
dropped = len(commands) - len(filtered)
|
|
51
|
+
warnings.append(f"filtered out {dropped} command(s) by include/exclude")
|
|
52
|
+
commands = filtered
|
|
53
|
+
|
|
54
|
+
commands = _dedupe(commands, warnings)
|
|
55
|
+
if len(commands) > settings.discovery.max_commands:
|
|
56
|
+
commands = commands[: settings.discovery.max_commands]
|
|
57
|
+
warnings.append(f"discovery capped at {settings.discovery.max_commands} commands")
|
|
58
|
+
|
|
59
|
+
if manifest.binary and manifest.binary != target.binary:
|
|
60
|
+
warnings.append(f"manifest declares binary {manifest.binary!r}, exposing {target.binary!r}")
|
|
61
|
+
if not commands:
|
|
62
|
+
raise DiscoveryError(
|
|
63
|
+
f"no commands found for {target.binary!r}. Check the binary, or declare commands in a cli-mcp manifest."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return DiscoveryResult(
|
|
67
|
+
binary=target.binary,
|
|
68
|
+
resolved_path=target.resolved_path,
|
|
69
|
+
framework=result.framework if result is not None else ("manifest" if manifest.tools else None),
|
|
70
|
+
commands=commands,
|
|
71
|
+
strategies=_ordered_unique(strategies),
|
|
72
|
+
warnings=warnings,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def merge_manifest(commands: list[CommandSpec], tools: list[CommandSpec], warnings: list[str]) -> list[CommandSpec]:
|
|
77
|
+
"""Manifest entries replace discovered commands of the same tool name."""
|
|
78
|
+
merged: dict[str, CommandSpec] = {command.name: command for command in commands}
|
|
79
|
+
for tool in tools:
|
|
80
|
+
if tool.name in merged:
|
|
81
|
+
warnings.append(f"manifest overrides tool {tool.name!r}")
|
|
82
|
+
merged[tool.name] = tool
|
|
83
|
+
ordered = list(commands) + [tool for tool in tools if tool.name not in {command.name for command in commands}]
|
|
84
|
+
return [merged[command.name] for command in ordered]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _dedupe(commands: list[CommandSpec], warnings: list[str]) -> list[CommandSpec]:
|
|
88
|
+
"""Drop later commands whose tool name already exists."""
|
|
89
|
+
seen: set[str] = set()
|
|
90
|
+
kept: list[CommandSpec] = []
|
|
91
|
+
for command in commands:
|
|
92
|
+
if command.name in seen:
|
|
93
|
+
warnings.append(f"duplicate tool name {command.name!r} dropped")
|
|
94
|
+
continue
|
|
95
|
+
seen.add(command.name)
|
|
96
|
+
kept.append(command)
|
|
97
|
+
return kept
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ordered_unique(items: list[str]) -> list[str]:
|
|
101
|
+
"""Return unique items, preserving first-seen order."""
|
|
102
|
+
seen: set[str] = set()
|
|
103
|
+
ordered: list[str] = []
|
|
104
|
+
for item in items:
|
|
105
|
+
if item not in seen:
|
|
106
|
+
seen.add(item)
|
|
107
|
+
ordered.append(item)
|
|
108
|
+
return ordered
|