Files
hermes-agent/wizard-bootstrap/dependency_checker.py
Claude (Opus 4.6) 8150b5c66b
Some checks failed
Docker Build and Publish / build-and-push (push) Failing after 16s
Nix / nix (ubuntu-latest) (push) Failing after 1s
Tests / test (push) Failing after 4s
Nix / nix (macos-latest) (push) Has been cancelled
[claude] Wizard Council Automation — Shared Tooling & Environment Validation (#148) (#158)
2026-04-07 01:55:46 +00:00

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