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

@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **`skill-seekers doctor` command** — 8 diagnostic checks (Python version, package install, git, core/optional deps, API keys, MCP server, output dir) with pass/warn/fail status and `--verbose` flag (#316)
- **Prompt injection check workflow** — bundled `prompt-injection-check` workflow scans scraped content for injection patterns (role assumption, instruction overrides, delimiter injection, hidden instructions). Added as first stage in `default` and `security-focus` workflows. Flags suspicious content without removing it (#324)
- **6 behavioral UML diagrams** — 3 sequence (create pipeline, GitHub+C3.x flow, MCP invocation), 2 activity (source detection, enhancement pipeline), 1 component (runtime dependencies with interface contracts)

View File

@@ -299,6 +299,7 @@ skill-seekers = "skill_seekers.cli.main:main"
# Individual tool entry points
skill-seekers-create = "skill_seekers.cli.create_command:main" # NEW: Unified create command
skill-seekers-doctor = "skill_seekers.cli.doctor:main"
skill-seekers-config = "skill_seekers.cli.config_command:main"
skill-seekers-resume = "skill_seekers.cli.resume_command:main"
skill-seekers-scrape = "skill_seekers.cli.doc_scraper:main"

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"
)

164
tests/test_doctor.py Normal file
View File

@@ -0,0 +1,164 @@
"""Tests for skill-seekers doctor command (#316)."""
from __future__ import annotations
import os
from unittest.mock import patch
from skill_seekers.cli.doctor import (
CheckResult,
check_api_keys,
check_core_deps,
check_git,
check_mcp_server,
check_optional_deps,
check_output_directory,
check_package_installed,
check_python_version,
print_report,
run_all_checks,
)
class TestCheckPythonVersion:
def test_passes_on_current_python(self):
result = check_python_version()
assert result.status == "pass"
assert result.critical is True
def test_detail_contains_version(self):
result = check_python_version()
assert "." in result.detail # e.g. "3.14.3"
class TestCheckPackageInstalled:
def test_passes_when_installed(self):
result = check_package_installed()
assert result.status == "pass"
assert result.detail.startswith("v")
def test_fails_when_import_broken(self):
with (
patch.dict("sys.modules", {"skill_seekers._version": None}),
patch("builtins.__import__", side_effect=ImportError("mocked")),
):
result = check_package_installed()
assert result.status == "fail"
class TestCheckGit:
def test_passes_when_git_available(self):
result = check_git()
# Most CI/dev environments have git
assert result.status in ("pass", "warn")
def test_warns_when_git_missing(self):
with patch("skill_seekers.cli.doctor.shutil.which", return_value=None):
result = check_git()
assert result.status == "warn"
class TestCheckCoreDeps:
def test_passes_in_normal_environment(self):
result = check_core_deps()
assert result.status == "pass"
assert result.critical is True
def test_detail_shows_count(self):
result = check_core_deps()
assert "found" in result.detail.lower() or "missing" in result.detail.lower()
class TestCheckOptionalDeps:
def test_returns_result(self):
result = check_optional_deps()
assert result.status in ("pass", "warn")
assert "/" in result.detail # e.g. "7/10 installed"
class TestCheckApiKeys:
def test_warns_when_no_keys(self):
with patch.dict(os.environ, {}, clear=True):
result = check_api_keys()
assert result.status == "warn"
def test_passes_when_all_set(self):
env = {
"ANTHROPIC_API_KEY": "sk-ant-test123456789",
"GITHUB_TOKEN": "ghp_test123456789",
"GOOGLE_API_KEY": "AIza_test123456789",
"OPENAI_API_KEY": "sk-test123456789",
}
with patch.dict(os.environ, env, clear=True):
result = check_api_keys()
assert result.status == "pass"
def test_partial_keys_warns(self):
env = {"ANTHROPIC_API_KEY": "sk-ant-test123456789"}
with patch.dict(os.environ, env, clear=True):
result = check_api_keys()
assert result.status == "warn"
assert "1 set" in result.detail
class TestCheckMcpServer:
def test_returns_result(self):
result = check_mcp_server()
assert result.status in ("pass", "warn")
class TestCheckOutputDirectory:
def test_passes_in_writable_dir(self):
result = check_output_directory()
assert result.status == "pass"
assert result.critical is True
class TestRunAllChecks:
def test_returns_8_results(self):
results = run_all_checks()
assert len(results) == 8
def test_all_have_name_and_status(self):
results = run_all_checks()
for r in results:
assert isinstance(r, CheckResult)
assert r.name
assert r.status in ("pass", "warn", "fail")
class TestPrintReport:
def test_returns_0_when_no_failures(self, capsys):
results = [
CheckResult("Test1", "pass", "ok", critical=True),
CheckResult("Test2", "warn", "meh"),
]
code = print_report(results)
assert code == 0
captured = capsys.readouterr()
assert "1 passed" in captured.out
assert "1 warnings" in captured.out
def test_returns_1_when_critical_failure(self, capsys):
results = [
CheckResult("Test1", "pass", "ok"),
CheckResult("Test2", "fail", "broken", critical=True),
]
code = print_report(results)
assert code == 1
def test_verbose_shows_detail(self, capsys):
results = [
CheckResult("Test1", "pass", "ok", verbose_detail=" extra: info"),
]
print_report(results, verbose=True)
captured = capsys.readouterr()
assert "extra: info" in captured.out
def test_no_verbose_hides_detail(self, capsys):
results = [
CheckResult("Test1", "pass", "ok", verbose_detail=" secret: hidden"),
]
print_report(results, verbose=False)
captured = capsys.readouterr()
assert "secret: hidden" not in captured.out