feat(skills): implement batch installation of multiple skills and enhance install feedback

This commit is contained in:
X-iZhang
2026-02-25 23:38:08 +00:00
parent 35135ee79c
commit 931fc8a88d
3 changed files with 169 additions and 61 deletions
+13 -1
View File
@@ -55,7 +55,19 @@ def _cmd_install_skill(source: str) -> None:
result = install_skill(source)
if result["success"]:
if result.get("batch"):
# Batch install — multiple skills
for item in result.get("installed", []):
console.print(f"[green]Installed:[/green] {item['name']}")
console.print(f" [dim]Description:[/dim] {item.get('description', '(none)')}")
console.print(f" [dim]Path:[/dim] [cyan]{_shorten_path(item['path'])}[/cyan]")
for item in result.get("failed", []):
console.print(f"[red]Failed:[/red] {item['name']} — {item['error']}")
installed_count = len(result.get("installed", []))
if installed_count:
console.print(f"\n[green]{installed_count} skill(s) installed.[/green]")
console.print("[dim]Reload with /new to apply.[/dim]")
elif result["success"]:
console.print(f"[green]Installed:[/green] {result['name']}")
console.print(f"[dim]Description:[/dim] {result.get('description', '(none)')}")
console.print(f"[dim]Path:[/dim] [cyan]{_shorten_path(result['path'])}[/cyan]")
+82 -60
View File
@@ -248,25 +248,44 @@ def _install_from_local(source: str, dest_dir: str) -> dict:
return {"success": False, "error": f"Not a directory: {source}"}
if not _validate_skill_dir(source_path):
# No SKILL.md at root — check immediate subdirectories for batch install
found = [
entry
for entry in sorted(source_path.iterdir())
if entry.is_dir() and _validate_skill_dir(entry)
]
if len(found) == 1:
return _install_single_local(found[0], dest_dir)
if found:
return _batch_install_local(found, dest_dir)
return {"success": False, "error": f"No SKILL.md found in: {source}"}
# Parse SKILL.md to get the skill name
return _install_single_local(source_path, dest_dir)
def _install_single_local(
source_path: Path, dest_dir: str, *, ignore_fn=None
) -> dict:
"""Install one skill directory into *dest_dir*."""
skill_info = _parse_skill_md(source_path / "SKILL.md")
skill_name = _sanitize_name(skill_info["name"])
if not skill_name:
return {"success": False, "error": f"Invalid skill name in SKILL.md: {skill_info['name']!r}"}
return {
"success": False,
"error": f"Invalid skill name in SKILL.md: {skill_info['name']!r}",
}
# Destination path — resolve and verify it stays inside dest_dir
target_path = (Path(dest_dir) / skill_name).resolve()
if not str(target_path).startswith(str(Path(dest_dir).resolve())):
return {"success": False, "error": f"Skill name escapes destination: {skill_info['name']!r}"}
return {
"success": False,
"error": f"Skill name escapes destination: {skill_info['name']!r}",
}
# Remove existing if present
if target_path.exists():
shutil.rmtree(target_path)
# Copy skill directory
shutil.copytree(source_path, target_path)
shutil.copytree(source_path, target_path, ignore=ignore_fn)
return {
"success": True,
@@ -276,6 +295,28 @@ def _install_from_local(source: str, dest_dir: str) -> dict:
}
def _batch_install_local(
skill_dirs: list[Path], dest_dir: str, *, ignore_fn=None
) -> dict:
"""Install multiple skill directories and return a batch result."""
installed: list[dict] = []
failed: list[dict] = []
for sd in skill_dirs:
result = _install_single_local(sd, dest_dir, ignore_fn=ignore_fn)
if result["success"]:
installed.append(result)
else:
failed.append({"name": sd.name, "error": result["error"]})
return {
"success": len(installed) > 0,
"batch": True,
"installed": installed,
"failed": failed,
}
def _install_from_github(source: str, dest_dir: str) -> dict:
"""Install a skill from a GitHub URL or shorthand."""
try:
@@ -291,6 +332,10 @@ def _install_from_github(source: str, dest_dir: str) -> dict:
except RuntimeError as e:
return {"success": False, "error": str(e)}
# Exclude .git from copies
def ignore_git(dir_name: str, files: list[str]) -> list[str]:
return [f for f in files if f == ".git"]
# Determine the skill source directory
if path:
skill_source = Path(clone_dir) / path
@@ -299,63 +344,40 @@ def _install_from_github(source: str, dest_dir: str) -> dict:
# Validate — if the direct path doesn't have SKILL.md, try auto-resolve
if not skill_source.exists() or not _validate_skill_dir(skill_source):
if path:
# The shorthand path (e.g. "canvas-design") may be nested deeper
# Walk the tree to find a directory with that name + SKILL.md
skill_name_hint = path.rstrip("/").rsplit("/", 1)[-1]
resolved = _find_skill_in_tree(clone_dir, skill_name_hint)
if resolved:
skill_source = resolved
else:
return {"success": False, "error": f"No SKILL.md found at '{path}' (also searched subdirectories) in: {source}"}
else:
# No path specified — list available skills in repo root
found_skills = []
for entry in os.listdir(clone_dir):
entry_path = Path(clone_dir) / entry
if entry_path.is_dir() and _validate_skill_dir(entry_path):
found_skills.append(entry)
# If the path directory exists, scan its children for skills
if skill_source.is_dir():
found_children = sorted(
entry.name
for entry in skill_source.iterdir()
if entry.is_dir() and _validate_skill_dir(entry)
)
if len(found_children) == 1:
skill_source = skill_source / found_children[0]
elif found_children:
skill_dirs = [skill_source / s for s in found_children]
return _batch_install_local(
skill_dirs, dest_dir, ignore_fn=ignore_git
)
if len(found_skills) == 1:
# Only one skill in repo — just install it
skill_source = Path(clone_dir) / found_skills[0]
elif found_skills:
return {
"success": False,
"error": (
f"Multiple skills found in repo. "
f"Please specify one: {', '.join(sorted(found_skills))}"
),
}
# Still not resolved — try tree search by name hint
if not _validate_skill_dir(skill_source):
if path:
skill_name_hint = path.rstrip("/").rsplit("/", 1)[-1]
resolved = _find_skill_in_tree(clone_dir, skill_name_hint)
if resolved:
skill_source = resolved
else:
return {"success": False, "error": f"No SKILL.md found at '{path}' (also searched subdirectories) in: {source}"}
else:
return {"success": False, "error": f"No SKILL.md found in: {source}"}
# Parse skill info and copy
skill_info = _parse_skill_md(skill_source / "SKILL.md")
skill_name = _sanitize_name(skill_info["name"])
if not skill_name:
return {"success": False, "error": f"Invalid skill name in SKILL.md: {skill_info['name']!r}"}
target_path = (Path(dest_dir) / skill_name).resolve()
if not str(target_path).startswith(str(Path(dest_dir).resolve())):
return {"success": False, "error": f"Skill name escapes destination: {skill_info['name']!r}"}
if target_path.exists():
shutil.rmtree(target_path)
# Copy, excluding .git directory
def ignore_git(dir_name: str, files: list[str]) -> list[str]:
return [f for f in files if f == ".git"]
shutil.copytree(skill_source, target_path, ignore=ignore_git)
return {
"success": True,
"name": skill_name,
"path": str(target_path),
"description": skill_info["description"],
"source": source,
}
# Single skill — install it
result = _install_single_local(
skill_source, dest_dir, ignore_fn=ignore_git
)
if result.get("success"):
result["source"] = source
return result
def list_skills(include_system: bool = False) -> list[SkillInfo]:
+74
View File
@@ -339,3 +339,77 @@ class TestUninstallSkill:
assert result["success"] is False
assert "not found" in result["error"]
# =============================================================================
# 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"