|
2 | 2 | from __future__ import annotations |
3 | 3 |
|
4 | 4 | import argparse |
| 5 | +import builtins |
| 6 | +import difflib |
5 | 7 | import json |
| 8 | +import re |
6 | 9 | import subprocess |
7 | 10 | import sys |
8 | 11 | from pathlib import Path |
9 | 12 | from typing import Any |
10 | 13 |
|
| 14 | +try: |
| 15 | + from rich.console import Console |
| 16 | +except Exception: # pragma: no cover - optional runtime dependency |
| 17 | + Console = None # type: ignore[assignment] |
| 18 | + |
| 19 | + |
| 20 | +_INVALID_CHOICE_RE = re.compile(r"invalid choice: '([^']+)' \(choose from (.+)\)") |
| 21 | + |
| 22 | + |
| 23 | +def _console_print(*values: Any, sep: str = " ", end: str = "\n", file: Any | None = None, flush: bool = False) -> None: |
| 24 | + if Console is None: |
| 25 | + builtins.print(*values, sep=sep, end=end, file=file, flush=flush) |
| 26 | + return |
| 27 | + |
| 28 | + target = file if file is not None else sys.stdout |
| 29 | + text = sep.join(str(v) for v in values) |
| 30 | + console = Console(file=target, markup=False, highlight=False, soft_wrap=True) |
| 31 | + console.print(text, end=end) |
| 32 | + if flush and hasattr(target, "flush"): |
| 33 | + target.flush() |
| 34 | + |
| 35 | + |
| 36 | +print = _console_print # type: ignore[assignment] |
| 37 | + |
| 38 | + |
| 39 | +class SuggestingArgumentParser(argparse.ArgumentParser): |
| 40 | + def add_subparsers(self, **kwargs: Any) -> Any: |
| 41 | + kwargs.setdefault("parser_class", type(self)) |
| 42 | + return super().add_subparsers(**kwargs) |
| 43 | + |
| 44 | + def error(self, message: str) -> None: |
| 45 | + suggestion = self._build_command_suggestion(message) |
| 46 | + if suggestion: |
| 47 | + message = f"{message}\n{suggestion}" |
| 48 | + self.print_usage(sys.stderr) |
| 49 | + self.exit(2, f"{self.prog}: error: {message}\n") |
| 50 | + |
| 51 | + def _build_command_suggestion(self, message: str) -> str | None: |
| 52 | + match = _INVALID_CHOICE_RE.search(message) |
| 53 | + if not match: |
| 54 | + return None |
| 55 | + invalid, raw_choices = match.groups() |
| 56 | + choices = re.findall(r"'([^']+)'", raw_choices) |
| 57 | + if not choices: |
| 58 | + return None |
| 59 | + suggestion = difflib.get_close_matches(invalid, choices, n=1, cutoff=0.45) |
| 60 | + if not suggestion: |
| 61 | + return None |
| 62 | + best = suggestion[0] |
| 63 | + return f"Did you mean '{best}'? Use the valid command: {best}" |
| 64 | + |
11 | 65 |
|
12 | 66 | def _json_dumps(data: Any) -> str: |
13 | 67 | return json.dumps(data, ensure_ascii=False, separators=(",", ":")) |
@@ -159,7 +213,7 @@ def _default_server_cmd() -> list[str]: |
159 | 213 |
|
160 | 214 |
|
161 | 215 | def _build_parser() -> argparse.ArgumentParser: |
162 | | - parser = argparse.ArgumentParser(description="Minimal client for the local Skilldock MCP server.") |
| 216 | + parser = SuggestingArgumentParser(description="Minimal client for the local Skilldock MCP server.") |
163 | 217 | parser.add_argument( |
164 | 218 | "--server-cmd", |
165 | 219 | nargs="+", |
|
0 commit comments