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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
303
src/skill_seekers/cli/doctor.py
Normal file
303
src/skill_seekers/cli/doctor.py
Normal 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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
25
src/skill_seekers/cli/parsers/doctor_parser.py
Normal file
25
src/skill_seekers/cli/parsers/doctor_parser.py
Normal 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
164
tests/test_doctor.py
Normal 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
|
||||
Reference in New Issue
Block a user