Files
EvoScientist/tests/test_skills_manager.py
dinos 73928a2d78 fix(onboard): detect installed skill packs via install manifest (#199)
* fix(onboard): detect installed skill packs via install manifest

Onboarding's _step_skills only inspected USER_SKILLS_DIR and matched
recommended entries by directory-name hint, so a pack like
EvoScientist/EvoSkills@skills (which explodes into paper-writing/,
evo-memory/, etc. under GLOBAL_SKILLS_DIR) was never detected and kept
appearing as not-yet-installed.

skills_manager now writes a per-tier .installed.yaml mapping skill
directory name -> original install source on every install, removes the
entry on uninstall, and exposes installed_sources(). _step_skills checks
both tiers and treats a recommended source as installed when present in
any manifest -- so packs are recognized regardless of how their child
dirs are named.

* fix(onboard): write install manifest atomically

Stage to a sibling temp file, fsync, then os.replace into place. A crash
mid-write can no longer leave a half-written .installed.yaml behind,
which would otherwise wipe out pack detection until the next reinstall.

* style: fmt

* fix(onboard): catch decode errors when loading install manifest

read_text() can raise UnicodeDecodeError on a hand-edited or corrupt .installed.yaml; pin encoding="utf-8" and add UnicodeError to the except clause so the function honors its "returns {} on any error" contract.
2026-05-01 13:23:59 +02:00

946 lines
33 KiB
Python

"""Tests for EvoScientist.tools.skills_manager module."""
from pathlib import Path
from unittest.mock import patch
import pytest
from EvoScientist.tools.skills_manager import (
_is_github_url,
_load_manifest,
_parse_github_url,
_parse_skill_md,
_record_install,
_validate_skill_dir,
fetch_remote_skill_index,
get_all_tags,
install_skill,
installed_provenance,
installed_sources,
list_skills,
list_skills_by_tag,
resolve_remote_head,
uninstall_skill,
)
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def temp_skills_dir(tmp_path):
"""Create a temporary skills directory, isolated from the real global tier."""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
empty_global = tmp_path / "global_skills"
empty_global.mkdir()
with patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_global):
yield skills_dir
@pytest.fixture
def sample_skill_dir(tmp_path):
"""Create a sample skill directory with SKILL.md."""
skill_dir = tmp_path / "sample-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"""---
name: sample-skill
description: A sample skill for testing
---
# Sample Skill
This is a sample skill for testing purposes.
"""
)
return skill_dir
@pytest.fixture
def sample_skill_no_frontmatter(tmp_path):
"""Create a skill directory without YAML frontmatter."""
skill_dir = tmp_path / "no-frontmatter-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"""# No Frontmatter Skill
This skill has no YAML frontmatter.
"""
)
return skill_dir
# =============================================================================
# Tests for _parse_skill_md
# =============================================================================
class TestParseSkillMd:
"""Tests for _parse_skill_md function."""
def test_parse_with_frontmatter(self, sample_skill_dir):
skill_md = sample_skill_dir / "SKILL.md"
result = _parse_skill_md(skill_md)
assert result.name == "sample-skill"
assert result.description == "A sample skill for testing"
def test_parse_without_frontmatter(self, sample_skill_no_frontmatter):
skill_md = sample_skill_no_frontmatter / "SKILL.md"
result = _parse_skill_md(skill_md)
# Should use directory name as fallback
assert result.name == "no-frontmatter-skill"
assert result.description == "(no description)"
def test_parse_with_partial_frontmatter(self, tmp_path):
skill_dir = tmp_path / "partial-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"""---
name: my-skill
---
# My Skill
"""
)
result = _parse_skill_md(skill_md)
assert result.name == "my-skill"
assert result.description == "(no description)"
# =============================================================================
# Tests for _parse_github_url
# =============================================================================
class TestParseGithubUrl:
"""Tests for _parse_github_url function."""
def test_parse_full_url_with_path(self):
url = "https://github.com/owner/repo/tree/main/my-skill"
repo, ref, path = _parse_github_url(url)
assert repo == "owner/repo"
assert ref == "main"
assert path == "my-skill"
def test_parse_full_url_without_path(self):
url = "https://github.com/owner/repo/tree/develop"
repo, ref, path = _parse_github_url(url)
assert repo == "owner/repo"
assert ref == "develop"
assert path is None
def test_parse_simple_repo_url(self):
url = "https://github.com/owner/repo"
repo, ref, path = _parse_github_url(url)
assert repo == "owner/repo"
assert ref is None
assert path is None
def test_parse_shorthand(self):
url = "owner/repo@my-skill"
repo, ref, path = _parse_github_url(url)
assert repo == "owner/repo"
assert ref is None
assert path == "my-skill"
def test_parse_url_without_protocol(self):
url = "github.com/owner/repo/tree/v1.0/path/to/skill"
repo, ref, path = _parse_github_url(url)
assert repo == "owner/repo"
assert ref == "v1.0"
assert path == "path/to/skill"
def test_invalid_url_raises(self):
with pytest.raises(ValueError, match="Cannot parse"):
_parse_github_url("not-a-valid-url")
# =============================================================================
# Tests for _is_github_url
# =============================================================================
class TestIsGithubUrl:
"""Tests for _is_github_url function."""
def test_github_com_url(self):
assert _is_github_url("https://github.com/owner/repo") is True
assert _is_github_url("http://github.com/owner/repo/tree/main/skill") is True
def test_shorthand(self):
assert _is_github_url("owner/repo@skill-name") is True
def test_local_path(self):
assert _is_github_url("./my-skill") is False
assert _is_github_url("/absolute/path/skill") is False
assert _is_github_url("../relative/path") is False
def test_other_urls(self):
assert _is_github_url("https://gitlab.com/owner/repo") is False
assert _is_github_url("file:///path/to/file") is False
# =============================================================================
# Tests for _validate_skill_dir
# =============================================================================
class TestValidateSkillDir:
"""Tests for _validate_skill_dir function."""
def test_valid_skill_dir(self, sample_skill_dir):
assert _validate_skill_dir(sample_skill_dir) is True
def test_invalid_skill_dir_no_skillmd(self, tmp_path):
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
assert _validate_skill_dir(empty_dir) is False
def test_invalid_skill_dir_file_not_dir(self, tmp_path):
file_path = tmp_path / "file.txt"
file_path.write_text("not a directory")
assert _validate_skill_dir(file_path) is False
# =============================================================================
# Tests for install_skill
# =============================================================================
class TestInstallSkill:
"""Tests for install_skill function."""
def test_install_from_local_path(self, sample_skill_dir, temp_skills_dir):
result = install_skill(str(sample_skill_dir), str(temp_skills_dir))
assert result["success"] is True
assert result["name"] == "sample-skill"
assert "sample-skill" in result["path"]
# Verify the skill was copied
installed_path = Path(result["path"])
assert installed_path.exists()
assert (installed_path / "SKILL.md").exists()
def test_install_nonexistent_path(self, temp_skills_dir):
result = install_skill("/nonexistent/path", str(temp_skills_dir))
assert result["success"] is False
assert "does not exist" in result["error"]
def test_install_invalid_skill_no_skillmd(self, tmp_path, temp_skills_dir):
empty_dir = tmp_path / "empty-skill"
empty_dir.mkdir()
result = install_skill(str(empty_dir), str(temp_skills_dir))
assert result["success"] is False
assert "No SKILL.md" in result["error"]
def test_install_replaces_existing(self, sample_skill_dir, temp_skills_dir):
# Install first time
result1 = install_skill(str(sample_skill_dir), str(temp_skills_dir))
assert result1["success"] is True
# Modify the original skill
skill_md = sample_skill_dir / "SKILL.md"
skill_md.write_text(
"""---
name: sample-skill
description: Modified description
---
# Modified
"""
)
# Install again
result2 = install_skill(str(sample_skill_dir), str(temp_skills_dir))
assert result2["success"] is True
assert result2["description"] == "Modified description"
# =============================================================================
# Tests for list_skills
# =============================================================================
class TestListSkills:
"""Tests for list_skills function."""
def test_list_empty_dir(self, temp_skills_dir):
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
skills = list_skills(include_system=False)
assert skills == []
def test_list_with_skills(self, sample_skill_dir, temp_skills_dir):
# Install a skill
install_skill(str(sample_skill_dir), str(temp_skills_dir))
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
skills = list_skills(include_system=False)
assert len(skills) == 1
assert skills[0].name == "sample-skill"
assert skills[0].description == "A sample skill for testing"
assert skills[0].source == "workspace"
def test_list_multiple_skills(self, tmp_path, temp_skills_dir):
# Create and install multiple skills
for i in range(3):
skill_dir = tmp_path / f"skill-{i}"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
f"""---
name: skill-{i}
description: Skill number {i}
---
"""
)
install_skill(str(skill_dir), str(temp_skills_dir))
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
skills = list_skills(include_system=False)
assert len(skills) == 3
names = [s.name for s in skills]
assert "skill-0" in names
assert "skill-1" in names
assert "skill-2" in names
# =============================================================================
# Tests for uninstall_skill
# =============================================================================
class TestUninstallSkill:
"""Tests for uninstall_skill function."""
def test_uninstall_existing_skill(self, sample_skill_dir, temp_skills_dir):
# Install first
install_skill(str(sample_skill_dir), str(temp_skills_dir))
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
result = uninstall_skill("sample-skill")
assert result["success"] is True
# Verify the skill was removed
skill_path = temp_skills_dir / "sample-skill"
assert not skill_path.exists()
def test_uninstall_nonexistent_skill(self, temp_skills_dir):
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
result = uninstall_skill("nonexistent-skill")
assert result["success"] is False
assert "not found" in result["error"]
# =============================================================================
# Tests for install manifest
# =============================================================================
class TestInstallManifest:
"""Tests for the per-tier .installed.yaml manifest."""
def _make_skill(self, parent: Path, name: str) -> Path:
d = parent / name
d.mkdir()
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {name} description\n---\n\n# {name}\n"
)
return d
def test_local_install_records_source(self, sample_skill_dir, temp_skills_dir):
result = install_skill(str(sample_skill_dir), str(temp_skills_dir))
assert result["success"]
manifest = _load_manifest(temp_skills_dir)
# Local installs have no upstream commit — record source only.
assert manifest == {"sample-skill": {"source": str(sample_skill_dir)}}
def test_pack_install_records_one_source_for_all_children(
self, tmp_path, temp_skills_dir
):
"""A pack (root has no SKILL.md, multiple child skills) should record
the same user-facing source for every child skill, so detection by
source still works."""
repo = tmp_path / "evoskills-fake"
repo.mkdir()
self._make_skill(repo, "paper-writing")
self._make_skill(repo, "evo-memory")
self._make_skill(repo, "research-survey")
result = install_skill(str(repo), str(temp_skills_dir))
assert result["success"]
assert result["batch"]
manifest = _load_manifest(temp_skills_dir)
assert set(manifest) == {"paper-writing", "evo-memory", "research-survey"}
sources = {entry["source"] for entry in manifest.values()}
assert sources == {str(repo)}
def test_uninstall_clears_manifest_entry(self, sample_skill_dir, temp_skills_dir):
install_skill(str(sample_skill_dir), str(temp_skills_dir))
assert "sample-skill" in _load_manifest(temp_skills_dir)
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
result = uninstall_skill("sample-skill")
assert result["success"]
assert "sample-skill" not in _load_manifest(temp_skills_dir)
def test_save_is_atomic_and_leaves_no_temp(self, sample_skill_dir, temp_skills_dir):
"""_save_manifest must rename a temp file into place, not overwrite,
so a crash mid-write can't leave a half-written manifest behind."""
install_skill(str(sample_skill_dir), str(temp_skills_dir))
manifest_files = sorted(p.name for p in temp_skills_dir.iterdir())
# Only the manifest itself should remain — no leftover .tmp siblings.
assert ".installed.yaml" in manifest_files
assert not any(name.endswith(".tmp") for name in manifest_files), (
f"unexpected temp file left behind: {manifest_files}"
)
def test_installed_sources_filters_missing_dirs(
self, sample_skill_dir, temp_skills_dir, tmp_path
):
"""If a skill dir was removed manually but the manifest entry lingers,
installed_sources() must not report it as installed."""
empty_global = tmp_path / "empty_global"
empty_global.mkdir()
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", empty_global),
):
install_skill(str(sample_skill_dir), str(temp_skills_dir))
assert installed_sources() == {str(sample_skill_dir)}
# Manually wipe the dir, manifest still has the entry.
import shutil as _shutil
_shutil.rmtree(temp_skills_dir / "sample-skill")
assert "sample-skill" in _load_manifest(temp_skills_dir)
assert installed_sources() == set()
def test_record_install_persists_commit(self, temp_skills_dir, sample_skill_dir):
"""When _record_install gets a commit SHA it makes it through the
normalize-on-write pass and is readable as provenance."""
install_skill(str(sample_skill_dir), str(temp_skills_dir))
# Simulate a github install by manually recording a commit.
_record_install(
temp_skills_dir,
"sample-skill",
"owner/repo@skill",
commit="abc123def456",
)
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir),
patch(
"EvoScientist.paths.GLOBAL_SKILLS_DIR",
temp_skills_dir.parent / "missing",
),
):
prov = installed_provenance()
assert prov == {"owner/repo@skill": {"commit": "abc123def456"}}
class TestResolveRemoteHead:
"""Tests for resolve_remote_head — the upstream-SHA helper used by onboard."""
def test_returns_none_for_local_path(self):
assert resolve_remote_head("/tmp/some/local/path") is None
def test_returns_none_when_git_unavailable(self):
with patch("EvoScientist.tools.skills_manager.subprocess.run") as run:
run.side_effect = FileNotFoundError("git not on PATH")
assert resolve_remote_head("owner/repo@skill") is None
def test_returns_none_on_timeout(self):
import subprocess as _sp
with patch("EvoScientist.tools.skills_manager.subprocess.run") as run:
run.side_effect = _sp.TimeoutExpired(cmd="git", timeout=5)
assert resolve_remote_head("owner/repo@skill") is None
def test_parses_first_sha_from_ls_remote_output(self):
proc = type(
"P",
(),
{
"returncode": 0,
"stdout": "deadbeef0000000000000000000000000000abcd\trefs/heads/main\n",
"stderr": "",
},
)()
with patch(
"EvoScientist.tools.skills_manager.subprocess.run", return_value=proc
):
sha = resolve_remote_head("owner/repo@skill")
assert sha == "deadbeef0000000000000000000000000000abcd"
# =============================================================================
# Tests for batch install
# =============================================================================
class TestBatchInstall:
"""Tests for batch installing multiple skills from one directory."""
def _make_skill(self, parent: Path, name: str, desc: str) -> Path:
"""Helper to create a minimal skill directory."""
d = parent / name
d.mkdir()
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {desc}\n---\n\n# {name}\n"
)
return d
def test_batch_install_local_multiple_skills(self, tmp_path, temp_skills_dir):
"""Local path with no root SKILL.md but 3 sub-skills installs all."""
repo = tmp_path / "multi-repo"
repo.mkdir()
self._make_skill(repo, "skill-a", "Alpha")
self._make_skill(repo, "skill-b", "Beta")
self._make_skill(repo, "skill-c", "Gamma")
result = install_skill(str(repo), str(temp_skills_dir))
assert result["success"] is True
assert result.get("batch") is True
assert len(result["installed"]) == 3
assert result["failed"] == []
names = {r["name"] for r in result["installed"]}
assert names == {"skill-a", "skill-b", "skill-c"}
# Verify files copied
for name in names:
assert (temp_skills_dir / name / "SKILL.md").exists()
def test_batch_install_local_single_still_works(self, tmp_path, temp_skills_dir):
"""Local path with root SKILL.md still installs as single."""
self._make_skill(tmp_path, "single", "Just one")
result = install_skill(str(tmp_path / "single"), str(temp_skills_dir))
assert result["success"] is True
assert result.get("batch") is not True
assert result["name"] == "single"
def test_batch_install_local_empty_repo_fails(self, tmp_path, temp_skills_dir):
"""Local path with no skills at any level fails."""
empty = tmp_path / "empty-repo"
empty.mkdir()
result = install_skill(str(empty), str(temp_skills_dir))
assert result["success"] is False
assert "No SKILL.md" in result["error"]
def test_batch_install_local_mixed_dirs(self, tmp_path, temp_skills_dir):
"""Directories without SKILL.md are silently skipped."""
repo = tmp_path / "mixed"
repo.mkdir()
self._make_skill(repo, "real-skill", "Real")
(repo / "not-a-skill").mkdir() # no SKILL.md
(repo / "readme.md").write_text("# Readme") # file, not dir
result = install_skill(str(repo), str(temp_skills_dir))
assert result["success"] is True
assert result.get("batch") is not True # only 1 skill → single install
assert result["name"] == "real-skill"
# =============================================================================
# Tests for tag parsing
# =============================================================================
class TestParseSkillMdTags:
"""Tests for tag extraction in _parse_skill_md."""
def test_parse_with_metadata_tags(self, tmp_path):
"""Tags under metadata.tags are extracted."""
skill_dir = tmp_path / "tagged-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: tagged-skill
description: A skill with tags
metadata:
tags: [core, research, ideation]
---
"""
)
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.tags == ["core", "research", "ideation"]
def test_parse_with_top_level_tags(self, tmp_path):
"""Top-level tags field takes precedence over metadata.tags."""
skill_dir = tmp_path / "top-tags"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: top-tags
description: Top-level tags
tags: [writing, review]
metadata:
tags: [should, not, appear]
---
"""
)
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.tags == ["writing", "review"]
def test_parse_no_tags(self, tmp_path):
"""Skills without tags return empty list."""
skill_dir = tmp_path / "no-tags"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: no-tags
description: No tags at all
---
"""
)
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.tags == []
def test_parse_comma_string_tags(self, tmp_path):
"""Tags given as comma-separated string are split into list."""
skill_dir = tmp_path / "string-tags"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: string-tags
description: Tags as string
tags: "core, research, writing"
---
"""
)
result = _parse_skill_md(skill_dir / "SKILL.md")
assert result.tags == ["core", "research", "writing"]
def test_parse_no_frontmatter_returns_empty_tags(self, sample_skill_no_frontmatter):
"""Skills without frontmatter return empty tags."""
result = _parse_skill_md(sample_skill_no_frontmatter / "SKILL.md")
assert result.tags == []
# =============================================================================
# Tests for list_skills_by_tag
# =============================================================================
class TestListSkillsByTag:
"""Tests for list_skills_by_tag function."""
def _make_tagged_skill(self, parent: Path, name: str, tags: list[str]) -> Path:
d = parent / name
d.mkdir()
tags_yaml = ", ".join(tags)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Skill {name}\n"
f"metadata:\n tags: [{tags_yaml}]\n---\n"
)
return d
def test_filter_by_tag(self, tmp_path, temp_skills_dir):
self._make_tagged_skill(temp_skills_dir, "skill-a", ["core", "writing"])
self._make_tagged_skill(temp_skills_dir, "skill-b", ["core", "research"])
self._make_tagged_skill(temp_skills_dir, "skill-c", ["research"])
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
core = list_skills_by_tag("core")
assert len(core) == 2
assert {s.name for s in core} == {"skill-a", "skill-b"}
research = list_skills_by_tag("research")
assert len(research) == 2
assert {s.name for s in research} == {"skill-b", "skill-c"}
writing = list_skills_by_tag("writing")
assert len(writing) == 1
assert writing[0].name == "skill-a"
def test_filter_case_insensitive(self, tmp_path, temp_skills_dir):
self._make_tagged_skill(temp_skills_dir, "skill-x", ["Core", "Writing"])
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
result = list_skills_by_tag("core")
assert len(result) == 1
assert result[0].name == "skill-x"
def test_filter_nonexistent_tag(self, tmp_path, temp_skills_dir):
self._make_tagged_skill(temp_skills_dir, "skill-y", ["core"])
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
result = list_skills_by_tag("nonexistent")
assert result == []
# =============================================================================
# Tests for get_all_tags
# =============================================================================
class TestGetAllTags:
"""Tests for get_all_tags function."""
def _make_tagged_skill(self, parent: Path, name: str, tags: list[str]) -> Path:
d = parent / name
d.mkdir()
tags_yaml = ", ".join(tags)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Skill {name}\n"
f"metadata:\n tags: [{tags_yaml}]\n---\n"
)
return d
def test_returns_tags_with_counts(self, tmp_path, temp_skills_dir):
self._make_tagged_skill(temp_skills_dir, "skill-a", ["core", "writing"])
self._make_tagged_skill(temp_skills_dir, "skill-b", ["core", "research"])
self._make_tagged_skill(temp_skills_dir, "skill-c", ["research"])
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
tags = get_all_tags()
tag_dict = dict(tags)
assert tag_dict["core"] == 2
assert tag_dict["research"] == 2
assert tag_dict["writing"] == 1
def test_empty_when_no_skills(self, temp_skills_dir):
with patch("EvoScientist.paths.USER_SKILLS_DIR", temp_skills_dir):
tags = get_all_tags()
assert tags == []
# =============================================================================
# Tests for fetch_remote_skill_index
# =============================================================================
class TestFetchRemoteSkillIndex:
"""Tests for fetch_remote_skill_index function."""
def test_fetch_from_local_clone(self, tmp_path):
"""Verify index is built correctly from cloned skills."""
# Create a fake repo structure
skills_root = tmp_path / "repo" / "skills"
skills_root.mkdir(parents=True)
for name, tags in [
("skill-a", ["core", "writing"]),
("skill-b", ["core", "research"]),
]:
d = skills_root / name
d.mkdir()
tags_yaml = ", ".join(tags)
(d / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Skill {name}\n"
f"metadata:\n tags: [{tags_yaml}]\n---\n"
)
# Mock _clone_repo to copy our fake repo to the temp dir
def fake_clone(repo, ref, dest):
import shutil
shutil.copytree(tmp_path / "repo", dest)
with patch(
"EvoScientist.tools.skills_manager._clone_repo", side_effect=fake_clone
):
# Clear cache to ensure fresh fetch
from EvoScientist.tools.skills_manager import _REMOTE_INDEX_CACHE
_REMOTE_INDEX_CACHE.clear()
index = fetch_remote_skill_index(repo="test/repo", path="skills")
assert len(index) == 2
names = {s["name"] for s in index}
assert names == {"skill-a", "skill-b"}
# Verify tags are populated
skill_a = next(s for s in index if s["name"] == "skill-a")
assert "core" in skill_a["tags"]
assert "writing" in skill_a["tags"]
# Verify install_source is set
assert "test/repo@" in skill_a["install_source"]
def test_fetch_caches_results(self, tmp_path):
"""Second call within TTL uses cache without cloning again."""
skills_root = tmp_path / "repo" / "skills"
skills_root.mkdir(parents=True)
d = skills_root / "cached-skill"
d.mkdir()
(d / "SKILL.md").write_text(
"---\nname: cached-skill\ndescription: Cached\n"
"metadata:\n tags: [core]\n---\n"
)
call_count = 0
def fake_clone(repo, ref, dest):
nonlocal call_count
call_count += 1
import shutil
shutil.copytree(tmp_path / "repo", dest)
with patch(
"EvoScientist.tools.skills_manager._clone_repo", side_effect=fake_clone
):
from EvoScientist.tools.skills_manager import _REMOTE_INDEX_CACHE
_REMOTE_INDEX_CACHE.clear()
index1 = fetch_remote_skill_index(repo="cache/test", path="skills")
index2 = fetch_remote_skill_index(repo="cache/test", path="skills")
assert call_count == 1 # Only cloned once
assert index1 == index2
# =============================================================================
# Tests for skill_manager tool — action="list" filtering
# =============================================================================
class TestSkillManagerList:
"""Tests for the skill_manager() tool's action='list' output.
These tests verify that the source-based filtering in skill_manager.py
correctly maps skills_manager.py's tier names ("workspace", "global",
"builtin") to the User Skills / System Skills display sections.
"""
def _make_skill(self, tmp_path, name, description="A skill"):
skill_dir = tmp_path / name
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n"
)
return skill_dir
def test_list_user_skills_workspace(self, tmp_path):
"""Workspace-tier skills appear under 'User Skills'."""
from EvoScientist.tools.skill_manager import skill_manager
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
global_dir = tmp_path / "global"
global_dir.mkdir()
self._make_skill(tmp_path, "ws-skill")
install_skill(str(tmp_path / "ws-skill"), str(workspace_dir))
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", workspace_dir),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir),
):
result = skill_manager.invoke({"action": "list", "include_system": False})
assert "User Skills (1)" in result
assert "ws-skill" in result
assert "System Skills" not in result
def test_list_user_skills_global(self, tmp_path):
"""Global-tier skills appear under 'User Skills'."""
from EvoScientist.tools.skill_manager import skill_manager
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
global_dir = tmp_path / "global"
global_dir.mkdir()
self._make_skill(tmp_path, "global-skill")
install_skill(str(tmp_path / "global-skill"), str(global_dir))
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", workspace_dir),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir),
):
result = skill_manager.invoke({"action": "list", "include_system": False})
assert "User Skills (1)" in result
assert "global-skill" in result
def test_list_include_system_shows_both_sections(self, tmp_path):
"""include_system=True shows both User Skills and System Skills sections."""
from EvoScientist.tools.skill_manager import skill_manager
from EvoScientist.tools.skills_manager import SkillInfo
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
global_dir = tmp_path / "global"
global_dir.mkdir()
self._make_skill(tmp_path, "user-skill")
install_skill(str(tmp_path / "user-skill"), str(workspace_dir))
builtin_skill = SkillInfo(
name="builtin-skill",
description="A built-in skill",
path=tmp_path / "builtin-skill",
source="builtin",
)
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", workspace_dir),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir),
patch(
"EvoScientist.tools.skills_manager.list_skills",
return_value=[
SkillInfo(
name="user-skill",
description="A user skill",
path=workspace_dir / "user-skill",
source="workspace",
),
builtin_skill,
],
),
):
result = skill_manager.invoke({"action": "list", "include_system": True})
assert "User Skills (1)" in result
assert "user-skill" in result
assert "System Skills (1)" in result
assert "builtin-skill" in result
def test_list_no_user_skills_returns_message(self, tmp_path):
"""Empty workspace and global dirs return the 'no user skills' message."""
from EvoScientist.tools.skill_manager import skill_manager
workspace_dir = tmp_path / "workspace"
workspace_dir.mkdir()
global_dir = tmp_path / "global"
global_dir.mkdir()
with (
patch("EvoScientist.paths.USER_SKILLS_DIR", workspace_dir),
patch("EvoScientist.paths.GLOBAL_SKILLS_DIR", global_dir),
):
result = skill_manager.invoke({"action": "list", "include_system": False})
assert "No user skills installed" in result