diff --git a/EvoScientist/cli/skills_cmd.py b/EvoScientist/cli/skills_cmd.py index d74667d..d8fd316 100644 --- a/EvoScientist/cli/skills_cmd.py +++ b/EvoScientist/cli/skills_cmd.py @@ -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]") diff --git a/EvoScientist/tools/skills_manager.py b/EvoScientist/tools/skills_manager.py index 8f7ab43..e1883c5 100644 --- a/EvoScientist/tools/skills_manager.py +++ b/EvoScientist/tools/skills_manager.py @@ -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]: diff --git a/tests/test_skills_manager.py b/tests/test_skills_manager.py index bdf2a2d..46c43e2 100644 --- a/tests/test_skills_manager.py +++ b/tests/test_skills_manager.py @@ -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"