"""Tests for scripts/skill_index.py's scan of the installed-skills tree.""" import os import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) import skill_index def _write_skill(root, category, name, description="does a thing"): skill_dir = root / category / name skill_dir.mkdir(parents=True, exist_ok=True) (skill_dir / "SKILL.md").write_text( f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n\nBody.\n" ) def test_scan_skills_indexes_live_categories(tmp_path): _write_skill(tmp_path, "general-skills", "money-admin-messaging") _write_skill(tmp_path, "devops", "deploy-helper") found = skill_index.scan_skills(str(tmp_path)) assert {s["name"] for s in found} == {"money-admin-messaging", "deploy-helper"} assert {s["category"] for s in found} == {"general-skills", "devops"} def test_scan_skills_skips_dot_directories(tmp_path): """Dot-prefixed dirs under skills/ are internal, not live skill categories. `~/.hermes/skills/` really holds `.archive/` (retired skills), `.curator_backups/` (timestamped snapshots) and `.hub/` (lockfiles, audit log, quarantine). Indexing them tells the analyzer a retired skill "currently exists", and -- because the same name can live in both places -- makes optimize_skill.py's duplicate-name check reject the live skill as ambiguous. """ _write_skill(tmp_path, "general-skills", "money-admin-messaging") _write_skill(tmp_path, ".archive", "money-admin-messaging") _write_skill(tmp_path, ".curator_backups", "old-thing") found = skill_index.scan_skills(str(tmp_path)) assert [s["category"] for s in found] == ["general-skills"] assert len(found) == 1, "a retired copy must not shadow or duplicate the live skill" def test_scan_skills_dot_filter_keeps_names_unambiguous(tmp_path): """The live skill must resolve to exactly one record even with an archived twin.""" _write_skill(tmp_path, "general-skills", "money-admin-messaging") _write_skill(tmp_path, ".archive", "money-admin-messaging") matches = [s for s in skill_index.scan_skills(str(tmp_path)) if s["name"] == "money-admin-messaging"] assert len(matches) == 1 assert matches[0]["category"] == "general-skills" def test_scan_skills_returns_empty_for_missing_dir(tmp_path): assert skill_index.scan_skills(str(tmp_path / "nope")) == [] def test_scan_skills_honors_skills_dir_env_var(tmp_path, monkeypatch): """SKILL_EVOLUTION_SKILLS_DIR redirects the scan at call time. Without this, a single installed pipeline can only target one skills tree (the hardcoded ~/.hermes/skills default). Per-Hermes-profile layouts (~/.hermes/profiles//skills/) need to override it per run without monkeypatching HOME or the module-level SKILLS_DIR constant. """ live = tmp_path / "live" other = tmp_path / "other" _write_skill(live, "general-skills", "live-skill") _write_skill(other, "general-skills", "other-skill") monkeypatch.setenv(skill_index.SKILLS_DIR_ENV_VAR, str(other)) found = skill_index.scan_skills() assert {s["name"] for s in found} == {"other-skill"} def test_scan_skills_env_var_blank_falls_back_to_default(tmp_path, monkeypatch): """A blank/whitespace env var disables the override (matches fetch_sessions' get_state_db_path()/get_state_file() convention -- the same read-at-call-time fallback behaviour for missing/blank env vars).""" _write_skill(tmp_path, "general-skills", "fallback-skill") # Monkeypatch the default to a path that is ONLY reachable via the env-var # fallback -- if the blank env var were ever silently winning the override, # the test would still pass; we want to confirm the default wins. monkeypatch.setattr(skill_index, "SKILLS_DIR", str(tmp_path)) monkeypatch.setenv(skill_index.SKILLS_DIR_ENV_VAR, " ") found = skill_index.scan_skills() assert {s["name"] for s in found} == {"fallback-skill"}