301 lines
8.8 KiB
Python
301 lines
8.8 KiB
Python
#!/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()
|