feat: add skill-seekers doctor health check command (#316)

8 diagnostic checks: Python version (3.10+), package install, git,
14 core deps, 10 optional deps, API keys, MCP server, output dir.
Each check reports pass/warn/fail with --verbose for extra detail.
Exit code 0 if no critical failures, 1 otherwise.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yusyus
2026-03-28 21:27:17 +03:00
parent 43bdabb84f
commit 006cccabae
7 changed files with 497 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
"""
skill-seekers doctor — Environment health check command.
Runs 8 quick offline checks and prints a diagnostic summary,
similar to `brew doctor` or `flutter doctor`.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Literal
@dataclass
class CheckResult:
"""Result of a single health check."""
name: str
status: Literal["pass", "warn", "fail"]
detail: str
critical: bool = False
verbose_detail: str = ""
# ── Core dependency import names (from pyproject.toml dependencies) ──────────
CORE_DEPS = {
"requests": "requests",
"beautifulsoup4": "bs4",
"PyGithub": "github",
"GitPython": "git",
"httpx": "httpx",
"anthropic": "anthropic",
"PyMuPDF": "fitz",
"Pillow": "PIL",
"pydantic": "pydantic",
"pydantic-settings": "pydantic_settings",
"python-dotenv": "dotenv",
"jsonschema": "jsonschema",
"click": "click",
"Pygments": "pygments",
}
# ── Optional dependency import names ─────────────────────────────────────────
OPTIONAL_DEPS = {
"mammoth": "mammoth",
"ebooklib": "ebooklib",
"yt-dlp": "yt_dlp",
"mcp": "mcp",
"google-generativeai": "google.generativeai",
"openai": "openai",
"chromadb": "chromadb",
"weaviate-client": "weaviate",
"nbformat": "nbformat",
"feedparser": "feedparser",
}
# ── API keys to check ────────────────────────────────────────────────────────
API_KEYS = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN", "GOOGLE_API_KEY", "OPENAI_API_KEY"]
def _try_import(module_name: str) -> tuple[bool, str]:
"""Try to import a module and return (success, version_or_error)."""
try:
mod = __import__(module_name.split(".")[0])
version = getattr(mod, "__version__", getattr(mod, "VERSION", "installed"))
return True, str(version)
except Exception as e:
return False, str(e)
def check_python_version() -> CheckResult:
"""Check 1: Python version >= 3.10."""
v = sys.version_info
version_str = f"{v.major}.{v.minor}.{v.micro}"
if v >= (3, 10):
return CheckResult("Python version", "pass", version_str, critical=True)
return CheckResult("Python version", "fail", f"{version_str} (requires 3.10+)", critical=True)
def check_package_installed() -> CheckResult:
"""Check 2: skill-seekers package importable with version."""
try:
from skill_seekers._version import __version__
return CheckResult("skill-seekers installed", "pass", f"v{__version__}", critical=True)
except ImportError:
return CheckResult(
"skill-seekers installed", "fail", "Cannot import skill_seekers", critical=True
)
def check_git() -> CheckResult:
"""Check 3: git available in PATH."""
git_path = shutil.which("git")
if not git_path:
return CheckResult("Git", "warn", "git not found in PATH")
try:
result = subprocess.run(["git", "--version"], capture_output=True, text=True, timeout=5)
version = result.stdout.strip()
return CheckResult("Git", "pass", version, verbose_detail=f"Path: {git_path}")
except Exception as e:
return CheckResult("Git", "warn", f"git found but error: {e}")
def check_core_deps() -> CheckResult:
"""Check 4: Core dependencies importable."""
found = []
missing = []
details = []
for pkg_name, import_name in CORE_DEPS.items():
ok, info = _try_import(import_name)
if ok:
found.append(pkg_name)
details.append(f" {pkg_name}: {info}")
else:
missing.append(pkg_name)
details.append(f" {pkg_name}: MISSING")
if not missing:
return CheckResult(
"Core dependencies",
"pass",
f"All {len(found)} found",
critical=True,
verbose_detail="\n".join(details),
)
return CheckResult(
"Core dependencies",
"fail",
f"{len(missing)} missing: {', '.join(missing)}",
critical=True,
verbose_detail="\n".join(details),
)
def check_optional_deps() -> CheckResult:
"""Check 5: Optional dependencies status."""
found = []
missing = []
details = []
for pkg_name, import_name in OPTIONAL_DEPS.items():
ok, info = _try_import(import_name)
if ok:
found.append(pkg_name)
details.append(f" {pkg_name}: {info}")
else:
missing.append(pkg_name)
details.append(f" {pkg_name}: not installed")
total = len(OPTIONAL_DEPS)
if not missing:
return CheckResult(
"Optional dependencies",
"pass",
f"{total}/{total} installed",
verbose_detail="\n".join(details),
)
return CheckResult(
"Optional dependencies",
"warn",
f"{len(found)}/{total} installed (not installed: {', '.join(missing)})",
verbose_detail="\n".join(details),
)
def check_api_keys() -> CheckResult:
"""Check 6: API keys set in environment."""
set_keys = []
missing_keys = []
details = []
for key in API_KEYS:
val = os.environ.get(key)
if val:
set_keys.append(key)
masked = val[:4] + "..." + val[-4:] if len(val) > 12 else "***"
details.append(f" {key}: {masked}")
else:
missing_keys.append(key)
details.append(f" {key}: not set")
if not missing_keys:
return CheckResult(
"API keys",
"pass",
f"All {len(API_KEYS)} set",
verbose_detail="\n".join(details),
)
if set_keys:
return CheckResult(
"API keys",
"warn",
f"{len(set_keys)} set ({', '.join(missing_keys)} not set)",
verbose_detail="\n".join(details),
)
return CheckResult(
"API keys",
"warn",
"None set (enhancement features will use LOCAL mode)",
verbose_detail="\n".join(details),
)
def check_mcp_server() -> CheckResult:
"""Check 7: MCP server module importable."""
try:
from skill_seekers.mcp import server_fastmcp # noqa: F401
return CheckResult("MCP server", "pass", "Importable")
except ImportError as e:
return CheckResult("MCP server", "warn", f"Not available ({e})")
except Exception as e:
return CheckResult("MCP server", "warn", f"Import error: {e}")
def check_output_directory() -> CheckResult:
"""Check 8: Current directory is writable."""
cwd = os.getcwd()
if os.access(cwd, os.W_OK):
return CheckResult("Output directory", "pass", f"{cwd} (writable)", critical=True)
return CheckResult("Output directory", "fail", f"{cwd} (NOT writable)", critical=True)
def run_all_checks() -> list[CheckResult]:
"""Run all 8 health checks and return results."""
return [
check_python_version(),
check_package_installed(),
check_git(),
check_core_deps(),
check_optional_deps(),
check_api_keys(),
check_mcp_server(),
check_output_directory(),
]
STATUS_ICONS = {"pass": "\u2705", "warn": "\u26a0\ufe0f ", "fail": "\u274c"}
def print_report(results: list[CheckResult], verbose: bool = False) -> int:
"""Print formatted report and return exit code."""
try:
from skill_seekers._version import __version__
version = __version__
except ImportError:
version = "unknown"
print()
print("=" * 50)
print(f" Skill Seekers Doctor (v{version})")
print("=" * 50)
print()
for r in results:
icon = STATUS_ICONS[r.status]
print(f" {icon} {r.name}{r.detail}")
if verbose and r.verbose_detail:
for line in r.verbose_detail.split("\n"):
print(f" {line}")
passed = sum(1 for r in results if r.status == "pass")
warnings = sum(1 for r in results if r.status == "warn")
errors = sum(1 for r in results if r.status == "fail")
print()
print("-" * 50)
print(f" {passed} passed, {warnings} warnings, {errors} errors")
if errors == 0:
if warnings > 0:
print(" All critical checks passed with some warnings.")
else:
print(" All checks passed!")
else:
print(" Some critical checks failed. Fix errors above.")
print()
return 1 if errors > 0 else 0
def main() -> int:
"""Entry point for doctor command."""
parser = argparse.ArgumentParser(
prog="skill-seekers doctor",
description="Check environment health and dependencies",
)
parser.add_argument(
"--verbose", "-v", action="store_true", help="Show detailed diagnostic info"
)
args = parser.parse_args()
results = run_all_checks()
return print_report(results, verbose=args.verbose)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -56,6 +56,7 @@ from skill_seekers.cli import __version__
# Command module mapping (command name -> module path)
COMMAND_MODULES = {
"create": "skill_seekers.cli.create_command", # NEW: Unified create command
"doctor": "skill_seekers.cli.doctor",
"config": "skill_seekers.cli.config_command",
"scrape": "skill_seekers.cli.doc_scraper",
"github": "skill_seekers.cli.github_scraper",

View File

@@ -32,6 +32,7 @@ from .multilang_parser import MultilangParser
from .quality_parser import QualityParser
from .workflows_parser import WorkflowsParser
from .sync_config_parser import SyncConfigParser
from .doctor_parser import DoctorParser
# New source type parsers (v3.2.0+)
from .jupyter_parser import JupyterParser
@@ -48,6 +49,7 @@ from .chat_parser import ChatParser
# Registry of all parsers (in order of usage frequency)
PARSERS = [
CreateParser(), # NEW: Unified create command (placed first for prominence)
DoctorParser(),
ConfigParser(),
ScrapeParser(),
GitHubParser(),

View File

@@ -0,0 +1,25 @@
"""Doctor subcommand parser."""
from .base import SubcommandParser
class DoctorParser(SubcommandParser):
"""Parser for doctor subcommand."""
@property
def name(self) -> str:
return "doctor"
@property
def help(self) -> str:
return "Check environment health and dependencies"
@property
def description(self) -> str:
return "Run diagnostic checks on Python version, dependencies, API keys, and more"
def add_arguments(self, parser):
"""Add doctor-specific arguments."""
parser.add_argument(
"--verbose", "-v", action="store_true", help="Show detailed diagnostic info"
)