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,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