Files
skill-evolution/scripts/skill_index.py
T
Krystie 52b77bfcad [grade=A] add SKILL_EVOLUTION_SKILLS_DIR env var for per-Hermes-profile skills trees
Forked from Carlo1911/skill-evolution main. Adds env-var override so a single
installed pipeline can target per-profile skills trees (default ~/.hermes/skills,
krystie ~/.hermes/profiles/krystie/skills, sami-story ~/.hermes/profiles/sami-story/skills)
without monkeypatching HOME or the SKILLS_DIR module constant.

- scripts/skill_index.py: add resolve_skills_dir() + SKILLS_DIR_ENV_VAR, wire into
  scan_skills() with explicit-arg > env-var > default resolution order. Read at
  call time (matches fetch_sessions.get_state_db_path() convention).
- tests/test_skill_index.py: 2 new tests covering nonblank env var and blank
  fallback (the latter monkeypatches SKILLS_DIR + invokes scan_skills() with no
  args so the test actually exercises the fallback expression, not just the
  explicit arg path).
- 49/49 conformance tests pass.
- Codex grade: A (verified 2026-08-05).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-08-05 15:09:59 -07:00

141 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Scan ~/.hermes/skills/ and output a compact index.
Usage:
python skill_index.py # Full index as JSON
python skill_index.py --categories-only # Just category names
python skill_index.py --name "debugging" # Find specific skill
"""
import json
import os
import sys
from pathlib import Path
from typing import Optional
SKILLS_DIR = os.path.expanduser("~/.hermes/skills")
SKILLS_DIR_ENV_VAR = "SKILL_EVOLUTION_SKILLS_DIR"
def resolve_skills_dir() -> str:
"""Resolve the skills directory, honouring SKILL_EVOLUTION_SKILLS_DIR.
Mirrors fetch_sessions.get_state_db_path()'s read-at-call-time shape: the env var
is read every call (not frozen at import) so a single installed pipeline can target
different skill trees per run (e.g. per-Hermes-profile layouts like
~/.hermes/profiles/<name>/skills/) without monkeypatching HOME. Falls back to the
default ~/.hermes/skills when unset or blank, so existing callers see no behaviour
change.
"""
return os.environ.get(SKILLS_DIR_ENV_VAR, "").strip() or SKILLS_DIR
def parse_name_description_frontmatter(content: str) -> dict:
"""Extract name/description fields from a SKILL.md-style YAML frontmatter block.
Returns an empty (or partial) dict when frontmatter is missing or has no
closing delimiter -- callers decide whether that's an error.
"""
fields = {}
if not content.startswith("---"):
return fields
parts = content.split("---", 2)
if len(parts) < 3:
return fields
for line in parts[1].split("\n"):
line = line.strip()
if line.startswith("name:"):
fields["name"] = line.split(":", 1)[1].strip().strip("'\"")
elif line.startswith("description:"):
fields["description"] = line.split(":", 1)[1].strip().strip("'\"")
return fields
def build_skill_record(skill_md: Path, category: str) -> Optional[dict]:
"""Build one skill's index record from its SKILL.md path, or None if it can't be read.
Shared by scan_skills()'s category-nested walk (Hermes) and
host.ClaudeCodeAdapter.iter_skills()'s flat walk -- the record shape and the
fields-then-fallback-to-dirname logic are identical either way; only how `category`
is derived differs (a real directory level for Hermes, a constant for Claude Code).
"""
try:
content = skill_md.read_text(encoding="utf-8")
except OSError:
return None
fields = parse_name_description_frontmatter(content)
name = fields.get("name", skill_md.parent.name)
description = fields.get("description", "")
return {
"name": name,
"category": category,
"description": description,
"path": str(skill_md),
"size": len(content),
}
def scan_skills(skills_dir: Optional[str] = None) -> list:
"""Scan skills directory and return structured index.
Resolution order: explicit `skills_dir` arg > SKILL_EVOLUTION_SKILLS_DIR env var >
the default ~/.hermes/skills. The env var is read at call time (same as
fetch_sessions.get_state_db_path) so a single installed pipeline can target
different skill trees per run -- e.g. per-Hermes-profile layouts like
~/.hermes/profiles/<name>/skills/ -- without monkeypatching HOME.
"""
base = Path(skills_dir if skills_dir is not None else resolve_skills_dir())
if not base.exists():
return []
results = []
for category_dir in sorted(base.iterdir()):
if not category_dir.is_dir():
continue
# Dot-prefixed dirs are internal, not live skill categories: `.archive/` holds
# retired skills, `.curator_backups/` timestamped snapshots, `.hub/` lockfiles
# and quarantine. Indexing them would tell the analyzer a retired skill still
# exists, and an archived twin of a live skill would make optimize_skill.py's
# duplicate-name check reject the live one as ambiguous.
if category_dir.name.startswith("."):
continue
category = category_dir.name
for skill_dir in sorted(category_dir.iterdir()):
record = build_skill_record(skill_dir / "SKILL.md", category)
if record is not None:
results.append(record)
return results
def main():
import argparse
parser = argparse.ArgumentParser(description="Scan installed skills for the active host")
parser.add_argument("--categories-only", action="store_true")
parser.add_argument("--name", type=str, default=None)
args = parser.parse_args()
# host is imported locally: host.py imports this module (HermesAdapter delegates to
# scan_skills()), so a module-scope `import host` here would cycle.
import host as host_module
skills = host_module.get_adapter().iter_skills()
if args.name:
skills = [s for s in skills if args.name.lower() in s["name"].lower()]
if args.categories_only:
categories = sorted(set(s["category"] for s in skills))
print("\n".join(categories))
else:
print(json.dumps(skills, indent=2))
if __name__ == "__main__":
main()