#!/usr/bin/env python3 """ dependency_checker.py — Cross-Wizard Dependency Validator Each skill may declare binary or environment-variable dependencies in its SKILL.md frontmatter under a `dependencies` key: --- name: my-skill dependencies: binaries: [ffmpeg, imagemagick] env_vars: [MY_API_KEY, MY_SECRET] --- This script scans all installed skills, extracts declared dependencies, and checks whether each is satisfied in the current environment. Usage: python wizard-bootstrap/dependency_checker.py python wizard-bootstrap/dependency_checker.py --json python wizard-bootstrap/dependency_checker.py --skill software-development/code-review """ import argparse import json import os import shutil import sys from dataclasses import dataclass, field from pathlib import Path from typing import Optional try: import yaml HAS_YAML = True except ImportError: HAS_YAML = False # --------------------------------------------------------------------------- # Data model # --------------------------------------------------------------------------- @dataclass class SkillDep: skill_path: str skill_name: str binary: Optional[str] = None env_var: Optional[str] = None satisfied: bool = False detail: str = "" @dataclass class DepReport: deps: list[SkillDep] = field(default_factory=list) @property def all_satisfied(self) -> bool: return all(d.satisfied for d in self.deps) @property def unsatisfied(self) -> list[SkillDep]: return [d for d in self.deps if not d.satisfied] # --------------------------------------------------------------------------- # Frontmatter parser # --------------------------------------------------------------------------- def _parse_frontmatter(text: str) -> dict: """Extract YAML frontmatter from a SKILL.md file.""" if not text.startswith("---"): return {} end = text.find("\n---", 3) if end == -1: return {} fm_text = text[3:end].strip() if not HAS_YAML: return {} try: return yaml.safe_load(fm_text) or {} except Exception: return {} def _load_skill_deps(skill_md: Path) -> tuple[str, list[str], list[str]]: """ Returns (skill_name, binaries, env_vars) from a SKILL.md frontmatter. """ text = skill_md.read_text(encoding="utf-8", errors="replace") fm = _parse_frontmatter(text) skill_name = fm.get("name", skill_md.parent.name) deps = fm.get("dependencies", {}) if not isinstance(deps, dict): return skill_name, [], [] binaries = deps.get("binaries") or [] env_vars = deps.get("env_vars") or [] if isinstance(binaries, str): binaries = [binaries] if isinstance(env_vars, str): env_vars = [env_vars] return skill_name, list(binaries), list(env_vars) # --------------------------------------------------------------------------- # Checks # --------------------------------------------------------------------------- def _check_binary(binary: str) -> tuple[bool, str]: path = shutil.which(binary) if path: return True, f"found at {path}" return False, f"not found in PATH" def _check_env_var(var: str) -> tuple[bool, str]: val = os.environ.get(var) if val: return True, "set" return False, "not set" # --------------------------------------------------------------------------- # Scanner # --------------------------------------------------------------------------- def _find_skills_dir() -> Optional[Path]: """Resolve skills directory: prefer repo root, fall back to HERMES_HOME.""" # Check if we're inside the repo repo_root = Path(__file__).parent.parent repo_skills = repo_root / "skills" if repo_skills.exists(): return repo_skills hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) for candidate in [hermes_home / "skills", hermes_home / "hermes-agent" / "skills"]: if candidate.exists(): return candidate return None def run_dep_check(skills_dir: Optional[Path] = None, skill_filter: Optional[str] = None) -> DepReport: resolved = skills_dir or _find_skills_dir() report = DepReport() if resolved is None or not resolved.exists(): return report # Load ~/.hermes/.env so env var checks work hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) env_path = hermes_home / ".env" if env_path.exists(): try: from dotenv import load_dotenv # noqa: PLC0415 load_dotenv(env_path, override=False) except Exception: pass for skill_md in sorted(resolved.rglob("SKILL.md")): rel = str(skill_md.parent.relative_to(resolved)) if skill_filter and skill_filter not in rel: continue skill_name, binaries, env_vars = _load_skill_deps(skill_md) for binary in binaries: ok, detail = _check_binary(binary) report.deps.append(SkillDep( skill_path=rel, skill_name=skill_name, binary=binary, satisfied=ok, detail=detail, )) for var in env_vars: ok, detail = _check_env_var(var) report.deps.append(SkillDep( skill_path=rel, skill_name=skill_name, env_var=var, satisfied=ok, detail=detail, )) return report # --------------------------------------------------------------------------- # Rendering # --------------------------------------------------------------------------- _GREEN = "\033[32m" _RED = "\033[31m" _YELLOW = "\033[33m" _BOLD = "\033[1m" _RESET = "\033[0m" def _render_terminal(report: DepReport) -> None: print(f"\n{_BOLD}=== Cross-Wizard Dependency Check ==={_RESET}\n") if not report.deps: print("No skill dependencies declared. Skills use implicit deps only.\n") print( f"{_YELLOW}Tip:{_RESET} Declare binary/env_var deps in SKILL.md frontmatter " "under a 'dependencies' key to make them checkable.\n" ) return for dep in report.deps: icon = f"{_GREEN}✓{_RESET}" if dep.satisfied else f"{_RED}✗{_RESET}" if dep.binary: dep_type = "binary" dep_name = dep.binary else: dep_type = "env_var" dep_name = dep.env_var print(f" {icon} [{dep.skill_path}] {dep_type}:{dep_name} — {dep.detail}") total = len(report.deps) satisfied = sum(1 for d in report.deps if d.satisfied) print() if report.all_satisfied: print(f"{_GREEN}{_BOLD}All {total} dependencies satisfied.{_RESET}\n") else: failed = total - satisfied print( f"{_RED}{_BOLD}{failed}/{total} dependencies unsatisfied.{_RESET} " "Install missing binaries and set missing env vars.\n" ) def _render_json(report: DepReport) -> None: out = { "all_satisfied": report.all_satisfied, "summary": { "total": len(report.deps), "satisfied": sum(1 for d in report.deps if d.satisfied), "unsatisfied": len(report.unsatisfied), }, "deps": [ { "skill_path": d.skill_path, "skill_name": d.skill_name, "type": "binary" if d.binary else "env_var", "name": d.binary or d.env_var, "satisfied": d.satisfied, "detail": d.detail, } for d in report.deps ], } print(json.dumps(out, indent=2)) # --------------------------------------------------------------------------- # CLI entry point # --------------------------------------------------------------------------- def main() -> None: if not HAS_YAML: print("WARNING: pyyaml not installed — cannot parse SKILL.md frontmatter. " "Dependency declarations will be skipped.", file=sys.stderr) parser = argparse.ArgumentParser( description="Check cross-wizard skill dependencies (binaries, env vars)." ) parser.add_argument( "--skills-dir", default=None, help="Skills directory to scan (default: auto-detect)", ) parser.add_argument( "--skill", default=None, help="Filter to a specific skill path substring", ) parser.add_argument( "--json", action="store_true", help="Output results as JSON", ) args = parser.parse_args() skills_dir = Path(args.skills_dir).resolve() if args.skills_dir else None report = run_dep_check(skills_dir=skills_dir, skill_filter=args.skill) if args.json: _render_json(report) else: _render_terminal(report) sys.exit(0 if report.all_satisfied else 1) if __name__ == "__main__": main()