Skip to content

Commit bbe1e10

Browse files
committed
Feat: Recursive skill fetching and new default repos
Updates skill fetching logic to recursively scan for SKILL.md files within configured repositories, enabling support for nested skill directories. Also updates skill_repos.json with a new set of default repositories including ComposioHQ, superpowers, and others.
1 parent b4878d8 commit bbe1e10

2 files changed

Lines changed: 138 additions & 23 deletions

File tree

code_assistant_manager/skills.py

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -633,34 +633,40 @@ def _fetch_skills_from_repo(self, repo: SkillRepo) -> List[Skill]:
633633
logger.warning(f"Skills path not found: {scan_dir}")
634634
return skills
635635

636-
# Scan for skill directories (those containing SKILL.md)
637-
for item in scan_dir.iterdir():
638-
if not item.is_dir():
639-
continue
640-
641-
skill_md = item / "SKILL.md"
642-
if not skill_md.exists():
636+
# Scan for SKILL.md files recursively
637+
for skill_md in scan_dir.rglob("SKILL.md"):
638+
skill_dir = skill_md.parent
639+
if not skill_dir.is_dir():
643640
continue
644641

645642
# Parse skill metadata from SKILL.md
646643
meta = self._parse_skill_metadata(skill_md)
647-
directory = item.name
644+
645+
try:
646+
# Calculate relative path from scan_dir
647+
rel_path = skill_dir.relative_to(scan_dir)
648+
directory = str(rel_path).replace("\\", "/")
649+
650+
# Skip if SKILL.md is at the root of scan_dir (directory == ".")
651+
# to avoid conflicts when installing to the root of skills directory
652+
if directory == ".":
653+
continue
654+
except ValueError:
655+
continue
648656

649657
# Build README URL using actual branch
650-
if repo.skills_path:
651-
readme_path = f"{repo.skills_path.strip('/')}/{directory}"
652-
else:
653-
readme_path = directory
658+
path_from_repo_root = skill_dir.relative_to(temp_dir)
659+
readme_path = str(path_from_repo_root).replace("\\", "/")
654660

655661
skill = Skill(
656662
key=f"{repo.owner}/{repo.name}:{directory}",
657-
name=meta.get("name", directory),
663+
name=meta.get("name", directory.split("/")[-1]),
658664
description=meta.get("description", ""),
659665
directory=directory,
660666
installed=False,
661667
repo_owner=repo.owner,
662668
repo_name=repo.name,
663-
repo_branch=actual_branch, # Use actual branch, not configured branch
669+
repo_branch=actual_branch,
664670
skills_path=repo.skills_path,
665671
readme_url=f"https://github.com/{repo.owner}/{repo.name}/tree/{actual_branch}/{readme_path}",
666672
)
@@ -723,12 +729,17 @@ def get_installed_skills(self, app_type: str = "claude") -> List[Skill]:
723729
installed_skills = []
724730
existing_skills = self._load_skills()
725731

726-
for item in install_dir.iterdir():
727-
if not item.is_dir():
732+
# Scan recursively for SKILL.md
733+
for skill_md in install_dir.rglob("SKILL.md"):
734+
skill_dir = skill_md.parent
735+
if not skill_dir.is_dir():
728736
continue
729737

730-
directory = item.name
731-
skill_md = item / "SKILL.md"
738+
try:
739+
rel_path = skill_dir.relative_to(install_dir)
740+
directory = str(rel_path).replace("\\", "/")
741+
except ValueError:
742+
continue
732743

733744
# Check if we have this skill in our database
734745
matching_skill = None
@@ -740,12 +751,12 @@ def get_installed_skills(self, app_type: str = "claude") -> List[Skill]:
740751
if matching_skill:
741752
matching_skill.installed = True
742753
installed_skills.append(matching_skill)
743-
elif skill_md.exists():
754+
else:
744755
# Local skill not in our database
745756
meta = self._parse_skill_metadata(skill_md)
746757
skill = Skill(
747758
key=f"local:{directory}",
748-
name=meta.get("name", directory),
759+
name=meta.get("name", directory.split("/")[-1]),
749760
description=meta.get("description", ""),
750761
directory=directory,
751762
installed=True,
@@ -767,9 +778,14 @@ def sync_installed_status(self, app_type: str = "claude") -> None:
767778

768779
installed_dirs = set()
769780
if install_dir.exists():
770-
installed_dirs = {
771-
item.name.lower() for item in install_dir.iterdir() if item.is_dir()
772-
}
781+
# Scan recursively for SKILL.md
782+
for skill_md in install_dir.rglob("SKILL.md"):
783+
try:
784+
skill_dir = skill_md.parent
785+
rel_path = skill_dir.relative_to(install_dir)
786+
installed_dirs.add(str(rel_path).replace("\\", "/").lower())
787+
except ValueError:
788+
continue
773789

774790
skills = self._load_skills()
775791
for skill in skills.values():

tests/test_skills_recursive.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import shutil
2+
import tempfile
3+
from pathlib import Path
4+
from unittest.mock import MagicMock, patch
5+
6+
import pytest
7+
8+
from code_assistant_manager.skills import Skill, SkillManager, SkillRepo
9+
10+
11+
class TestRecursiveSkillDiscovery:
12+
@pytest.fixture
13+
def skill_manager(self):
14+
with tempfile.TemporaryDirectory() as tmp_config:
15+
return SkillManager(config_dir=Path(tmp_config))
16+
17+
@patch("code_assistant_manager.skills.SkillManager._download_repo")
18+
def test_fetch_skills_recursive(self, mock_download, skill_manager):
19+
# Setup mock repo structure
20+
# temp_dir/
21+
# skills/ (skills_path)
22+
# skill1/
23+
# SKILL.md
24+
# category/
25+
# skill2/
26+
# SKILL.md
27+
28+
with tempfile.TemporaryDirectory() as temp_dir_str:
29+
temp_dir = Path(temp_dir_str)
30+
mock_download.return_value = (temp_dir, "main")
31+
32+
skills_root = temp_dir / "skills"
33+
skills_root.mkdir()
34+
35+
# Skill 1 (flat)
36+
skill1_dir = skills_root / "skill1"
37+
skill1_dir.mkdir()
38+
(skill1_dir / "SKILL.md").write_text(
39+
"---\nname: Skill One\n---\n", encoding="utf-8"
40+
)
41+
42+
# Skill 2 (nested)
43+
skill2_dir = skills_root / "category" / "skill2"
44+
skill2_dir.mkdir(parents=True)
45+
(skill2_dir / "SKILL.md").write_text(
46+
"---\nname: Skill Two\n---\n", encoding="utf-8"
47+
)
48+
49+
repo = SkillRepo(owner="owner", name="repo", skills_path="skills")
50+
51+
skills = skill_manager._fetch_skills_from_repo(repo)
52+
53+
# Verify results
54+
assert len(skills) == 2
55+
56+
skill_map = {s.directory: s for s in skills}
57+
58+
# Check Skill 1
59+
assert "skill1" in skill_map
60+
s1 = skill_map["skill1"]
61+
assert s1.name == "Skill One"
62+
assert s1.key == "owner/repo:skill1"
63+
assert s1.readme_url.endswith("/skills/skill1")
64+
65+
# Check Skill 2
66+
# directory should use forward slashes
67+
assert "category/skill2" in skill_map
68+
s2 = skill_map["category/skill2"]
69+
assert s2.name == "Skill Two"
70+
assert s2.key == "owner/repo:category/skill2"
71+
assert s2.readme_url.endswith("/skills/category/skill2")
72+
73+
@patch("code_assistant_manager.skills.SkillManager._download_repo")
74+
def test_fetch_skills_root_structure(self, mock_download, skill_manager):
75+
# Test when skills_path is root ("/") or None
76+
77+
with tempfile.TemporaryDirectory() as temp_dir_str:
78+
temp_dir = Path(temp_dir_str)
79+
mock_download.return_value = (temp_dir, "main")
80+
81+
# Skill at root (should be skipped based on my implementation "directory == '.'")
82+
(temp_dir / "SKILL.md").write_text(
83+
"---\nname: Root Skill\n---\n", encoding="utf-8"
84+
)
85+
86+
# Nested skill
87+
(temp_dir / "nested" / "skill").mkdir(parents=True)
88+
(temp_dir / "nested" / "skill" / "SKILL.md").write_text(
89+
"---\nname: Nested\n---\n", encoding="utf-8"
90+
)
91+
92+
repo = SkillRepo(owner="owner", name="repo", skills_path=None)
93+
94+
skills = skill_manager._fetch_skills_from_repo(repo)
95+
96+
# Should find nested skill but skip root skill
97+
assert len(skills) == 1
98+
assert skills[0].directory == "nested/skill"
99+
assert skills[0].name == "Nested"

0 commit comments

Comments
 (0)