diff --git a/CHANGELOG.md b/CHANGELOG.md index 7158dac..f82e068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 655e344..479f179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/skill_seekers/cli/doctor.py b/src/skill_seekers/cli/doctor.py new file mode 100644 index 0000000..6bac5a9 --- /dev/null +++ b/src/skill_seekers/cli/doctor.py @@ -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()) diff --git a/src/skill_seekers/cli/main.py b/src/skill_seekers/cli/main.py index f33c38e..e46f9e5 100644 --- a/src/skill_seekers/cli/main.py +++ b/src/skill_seekers/cli/main.py @@ -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", diff --git a/src/skill_seekers/cli/parsers/__init__.py b/src/skill_seekers/cli/parsers/__init__.py index ddf04ee..d5faaef 100644 --- a/src/skill_seekers/cli/parsers/__init__.py +++ b/src/skill_seekers/cli/parsers/__init__.py @@ -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(), diff --git a/src/skill_seekers/cli/parsers/doctor_parser.py b/src/skill_seekers/cli/parsers/doctor_parser.py new file mode 100644 index 0000000..5edad00 --- /dev/null +++ b/src/skill_seekers/cli/parsers/doctor_parser.py @@ -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" + ) diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..e8ac14b --- /dev/null +++ b/tests/test_doctor.py @@ -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