Skip to content

Commit 053c9af

Browse files
committed
feat: enhance CLI error handling with command suggestions
1 parent 4144772 commit 053c9af

6 files changed

Lines changed: 130 additions & 6 deletions

File tree

mcp/client/skilldock_mcp_client.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,66 @@
22
from __future__ import annotations
33

44
import argparse
5+
import builtins
6+
import difflib
57
import json
8+
import re
69
import subprocess
710
import sys
811
from pathlib import Path
912
from typing import Any
1013

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+
1165

1266
def _json_dumps(data: Any) -> str:
1367
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
@@ -159,7 +213,7 @@ def _default_server_cmd() -> list[str]:
159213

160214

161215
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.")
163217
parser.add_argument(
164218
"--server-cmd",
165219
nargs="+",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "skilldock"
7-
version = "2026.03.11954"
7+
version = "2026.03.30837"
88
description = "OpenAPI-driven Python SDK and CLI for the SkillDock.io API."
99
readme = "README.md"
1010
license = { file = "LICENSE" }

src/skilldock/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22

33
# NOTE: PEP 440 will normalize this version (e.g. 2026.02 -> 2026.2),
44
# but keeping the requested format here is useful for humans.
5-
__version__ = "2026.03.11954"
5+
__version__ = "2026.03.30837"
66

src/skilldock/cli.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import argparse
44
import base64
55
import builtins
6+
import difflib
67
import json
78
import mimetypes
89
import os
@@ -35,6 +36,7 @@
3536

3637
_USD_AMOUNT_RE = re.compile(r"^\d+(?:\.\d{1,2})?$")
3738
_TON_AMOUNT_RE = re.compile(r"^\d+(?:\.\d{1,9})?$")
39+
_INVALID_CHOICE_RE = re.compile(r"invalid choice: '([^']+)' \(choose from (.+)\)")
3840

3941

4042
def _console_print(*values: Any, sep: str = " ", end: str = "\n", file: Any | None = None, flush: bool = False) -> None:
@@ -59,6 +61,33 @@ def _console_print(*values: Any, sep: str = " ", end: str = "\n", file: Any | No
5961
print = _console_print # type: ignore[assignment]
6062

6163

64+
class SkilldockArgumentParser(argparse.ArgumentParser):
65+
def add_subparsers(self, **kwargs: Any) -> Any:
66+
kwargs.setdefault("parser_class", type(self))
67+
return super().add_subparsers(**kwargs)
68+
69+
def error(self, message: str) -> None:
70+
suggestion = self._build_command_suggestion(message)
71+
if suggestion:
72+
message = f"{message}\n{suggestion}"
73+
self.print_usage(sys.stderr)
74+
self.exit(2, f"{self.prog}: error: {message}\n")
75+
76+
def _build_command_suggestion(self, message: str) -> str | None:
77+
match = _INVALID_CHOICE_RE.search(message)
78+
if not match:
79+
return None
80+
invalid, raw_choices = match.groups()
81+
choices = re.findall(r"'([^']+)'", raw_choices)
82+
if not choices:
83+
return None
84+
suggestion = difflib.get_close_matches(invalid, choices, n=1, cutoff=0.45)
85+
if not suggestion:
86+
return None
87+
best = suggestion[0]
88+
return f"Did you mean '{best}'? Use the valid command: {best}"
89+
90+
6291
def _jsonish(v: str) -> Any:
6392
v = v.strip()
6493
if v == "":
@@ -601,8 +630,8 @@ def _bootstrap_personal_token_after_oauth(
601630
client.close()
602631

603632

604-
def build_parser() -> argparse.ArgumentParser:
605-
p = argparse.ArgumentParser(
633+
def build_parser() -> SkilldockArgumentParser:
634+
p = SkilldockArgumentParser(
606635
prog="skilldock",
607636
formatter_class=argparse.RawDescriptionHelpFormatter,
608637
description="SkillDock API client (OpenAPI-driven).",

src/skilldock/client.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import base64
4+
import builtins
45
import json
56
import sys
67
import time
@@ -11,10 +12,28 @@
1112

1213
import httpx
1314

15+
try:
16+
from rich.console import Console
17+
except Exception: # pragma: no cover - optional runtime dependency
18+
Console = None # type: ignore[assignment]
19+
1420
from .config import DEFAULT_OPENAPI_URL, DEFAULT_TIMEOUT_S
1521
from .openapi import AuthStrategy, OpenAPIOperation, OpenAPISpec, load_openapi, parse_spec
1622

1723

24+
def _console_print(*values: Any, sep: str = " ", end: str = "\n", file: Any | None = None, flush: bool = False) -> None:
25+
if Console is None:
26+
builtins.print(*values, sep=sep, end=end, file=file, flush=flush)
27+
return
28+
29+
target = file if file is not None else sys.stdout
30+
text = sep.join(str(v) for v in values)
31+
console = Console(file=target, markup=False, highlight=False, soft_wrap=True)
32+
console.print(text, end=end)
33+
if flush and hasattr(target, "flush"):
34+
target.flush()
35+
36+
1837
class SkilldockError(RuntimeError):
1938
pass
2039

@@ -267,7 +286,7 @@ def _warn_auth_optional_fallback(self, message: str) -> None:
267286
if self._auth_optional_warning_printed:
268287
return
269288
self._auth_optional_warning_printed = True
270-
print(f"warning: {message}", file=sys.stderr)
289+
_console_print(f"warning: {message}", file=sys.stderr)
271290

272291
def _should_retry_unauthenticated(self, err: SkilldockHTTPError) -> bool:
273292
if err.status_code in (401, 403):

tests/test_cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,28 @@ def test_help_topic_prints_argparse_help_for_command(self) -> None:
814814
self.assertIn("usage: skilldock skills", out)
815815
self.assertIn("search Search skills", out)
816816

817+
def test_invalid_top_level_command_suggests_closest_match(self) -> None:
818+
with patch("sys.stderr", new=io.StringIO()) as stderr:
819+
with self.assertRaises(SystemExit) as ctx:
820+
main(["skils"])
821+
822+
self.assertEqual(ctx.exception.code, 2)
823+
err = stderr.getvalue()
824+
self.assertIn("invalid choice: 'skils'", err)
825+
self.assertIn("Did you mean 'skills'?", err)
826+
self.assertIn("Use the valid command: skills", err)
827+
828+
def test_invalid_nested_command_suggests_closest_match(self) -> None:
829+
with patch("sys.stderr", new=io.StringIO()) as stderr:
830+
with self.assertRaises(SystemExit) as ctx:
831+
main(["auth", "logn"])
832+
833+
self.assertEqual(ctx.exception.code, 2)
834+
err = stderr.getvalue()
835+
self.assertIn("invalid choice: 'logn'", err)
836+
self.assertIn("Did you mean 'login'?", err)
837+
self.assertIn("Use the valid command: login", err)
838+
817839

818840
class TestHttpErrorFormatting(unittest.TestCase):
819841
def test_format_402_purchase_required(self) -> None:

0 commit comments

Comments
 (0)