diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b538c6..8f4b916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [2.3.0] - 2025-12-22 + +### šŸ¤– Multi-Agent Installation Support + +This release adds automatic skill installation to 10+ AI coding agents with a single command. + +### Added +- **Multi-agent installation support** (#210) + - New `install-agent` command to install skills to any AI coding agent + - Support for 10+ agents: Claude Code, Cursor, VS Code, Amp, Goose, OpenCode, Letta, Aide, Windsurf + - `--agent all` flag to install to all agents at once + - `--force` flag to overwrite existing installations + - `--dry-run` flag to preview installations + - Intelligent path resolution (global vs project-relative) + - Fuzzy matching for agent names with suggestions + - Comprehensive error handling and user feedback + +### Changed +- Skills are now compatible with the Agent Skills open standard (agentskills.io) +- Installation paths follow standard conventions for each agent +- CLI updated with install-agent subcommand + +### Documentation +- Added multi-agent installation guide to README.md +- Updated CLAUDE.md with install-agent examples +- Added agent compatibility table + +### Testing +- Added 32 comprehensive tests for install-agent functionality +- All tests passing (603 tests total, 86 skipped) +- No regressions in existing functionality + +--- + ## [2.2.0] - 2025-12-21 ### šŸš€ Private Config Repositories - Team Collaboration Unlocked diff --git a/CLAUDE.md b/CLAUDE.md index 1cf556b..508f0b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -321,6 +321,28 @@ skill-seekers upload output/godot.zip skill-seekers package output/godot/ --no-open ``` +### Install to AI Agents + +```bash +# Single agent installation +skill-seekers install-agent output/godot/ --agent cursor + +# Install to all agents +skill-seekers install-agent output/godot/ --agent all + +# Force overwrite +skill-seekers install-agent output/godot/ --agent claude --force + +# Dry run (preview only) +skill-seekers install-agent output/godot/ --agent cursor --dry-run +``` + +**Supported agents:** claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf, all + +**Installation paths:** +- Global agents (claude, amp, goose, etc.): Install to `~/.{agent}/skills/` +- Project agents (cursor, vscode): Install to `.{agent}/skills/` in current directory + ### Force Re-scrape ```bash diff --git a/README.md b/README.md index ebcef18..181ed44 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,60 @@ In Claude Code, just ask: --- +## šŸ¤– Installing to AI Agents + +Skill Seekers can automatically install skills to 10+ AI coding agents. + +### Quick Start + +```bash +# Install to specific agent +skill-seekers install-agent output/react/ --agent cursor + +# Install to all agents at once +skill-seekers install-agent output/react/ --agent all + +# Overwrite existing installation +skill-seekers install-agent output/react/ --agent claude --force + +# Preview without installing +skill-seekers install-agent output/react/ --agent cursor --dry-run +``` + +### Supported Agents + +| Agent | Path | Type | +|-------|------|------| +| **Claude Code** | `~/.claude/skills/` | Global | +| **Cursor** | `.cursor/skills/` | Project | +| **VS Code / Copilot** | `.github/skills/` | Project | +| **Amp** | `~/.amp/skills/` | Global | +| **Goose** | `~/.config/goose/skills/` | Global | +| **OpenCode** | `~/.opencode/skills/` | Global | +| **Letta** | `~/.letta/skills/` | Global | +| **Aide** | `~/.aide/skills/` | Global | +| **Windsurf** | `~/.windsurf/skills/` | Global | + +**Global paths** install to user's home directory (~/). +**Project paths** install to current project's root directory. + +### Complete Workflow + +```bash +# 1. Scrape documentation +skill-seekers scrape --config configs/react.json --enhance-local + +# 2. Package skill +skill-seekers package output/react/ + +# 3. Install to your agent +skill-seekers install-agent output/react/ --agent cursor + +# 4. Restart Cursor to load the skill +``` + +--- + ## šŸ“ Simple Structure ``` diff --git a/pyproject.toml b/pyproject.toml index b844742..e2f30e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "skill-seekers" -version = "2.2.0" +version = "2.3.0" description = "Convert documentation websites, GitHub repositories, and PDFs into Claude AI skills" readme = "README.md" requires-python = ">=3.10" @@ -110,6 +110,7 @@ skill-seekers-package = "skill_seekers.cli.package_skill:main" skill-seekers-upload = "skill_seekers.cli.upload_skill:main" skill-seekers-estimate = "skill_seekers.cli.estimate_pages:main" skill-seekers-install = "skill_seekers.cli.install_skill:main" +skill-seekers-install-agent = "skill_seekers.cli.install_agent:main" [tool.setuptools] packages = ["skill_seekers", "skill_seekers.cli", "skill_seekers.mcp", "skill_seekers.mcp.tools"] diff --git a/src/skill_seekers/cli/install_agent.py b/src/skill_seekers/cli/install_agent.py new file mode 100644 index 0000000..29f87b7 --- /dev/null +++ b/src/skill_seekers/cli/install_agent.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +""" +Install skills to AI coding agent directories. + +This module provides functionality to install Skill Seekers-generated skills +to various AI coding agents (Claude Code, Cursor, VS Code, Amp, Goose, etc.) +by copying skill directories to agent-specific installation paths. + +Usage: + skill-seekers install-agent --agent [--force] [--dry-run] + +Examples: + # Install to specific agent + skill-seekers install-agent output/react/ --agent cursor + + # Install to all agents at once + skill-seekers install-agent output/react/ --agent all + + # Force overwrite existing installation + skill-seekers install-agent output/react/ --agent claude --force + + # Preview installation without making changes + skill-seekers install-agent output/react/ --agent cursor --dry-run +""" + +import argparse +import shutil +import sys +from pathlib import Path +from typing import Dict, Optional, Tuple, Union +from difflib import get_close_matches + + +# Agent installation paths +# Global paths (install to home directory): Use ~/.{agent}/skills/ +# Project paths (install to current directory): Use .{agent}/skills/ +AGENT_PATHS = { + 'claude': '~/.claude/skills/', # Global (home) + 'cursor': '.cursor/skills/', # Project-relative + 'vscode': '.github/skills/', # Project-relative + 'copilot': '.github/skills/', # Same as VSCode + 'amp': '~/.amp/skills/', # Global + 'goose': '~/.config/goose/skills/', # Global + 'opencode': '~/.opencode/skills/', # Global + 'letta': '~/.letta/skills/', # Global + 'aide': '~/.aide/skills/', # Global + 'windsurf': '~/.windsurf/skills/', # Global +} + + +def get_agent_path(agent_name: str, project_root: Optional[Path] = None) -> Path: + """ + Resolve the installation path for a given agent. + + Handles both global paths (~/./skills/) and project-relative paths + (.cursor/skills/, .github/skills/). + + Args: + agent_name: Name of the agent (e.g., 'claude', 'cursor') + project_root: Optional project root directory for project-relative paths + (defaults to current working directory) + + Returns: + Absolute path to the agent's skill installation directory + + Raises: + ValueError: If agent_name is not recognized + """ + agent_name = agent_name.lower() + + if agent_name not in AGENT_PATHS: + raise ValueError(f"Unknown agent: {agent_name}") + + path_template = AGENT_PATHS[agent_name] + + # Handle home directory expansion (~) + if path_template.startswith('~'): + return Path(path_template).expanduser() + + # Handle project-relative paths + if project_root is None: + project_root = Path.cwd() + + return project_root / path_template + + +def get_available_agents() -> list: + """ + Get list of all supported agent names. + + Returns: + List of agent names (lowercase) + """ + return sorted(AGENT_PATHS.keys()) + + +def validate_agent_name(agent_name: str) -> Tuple[bool, Optional[str]]: + """ + Validate an agent name and provide suggestions if invalid. + + Performs case-insensitive matching and fuzzy matching to suggest + similar agent names if the provided name is invalid. + + Args: + agent_name: Agent name to validate + + Returns: + Tuple of (is_valid, error_message) + - is_valid: True if agent name is valid, False otherwise + - error_message: None if valid, error message with suggestions if invalid + """ + # Special case: 'all' is valid for installing to all agents + if agent_name.lower() == 'all': + return True, None + + # Case-insensitive check + if agent_name.lower() in AGENT_PATHS: + return True, None + + # Agent not found - provide suggestions + available = get_available_agents() + + # Try fuzzy matching (find similar names) + suggestions = get_close_matches(agent_name.lower(), available, n=1, cutoff=0.6) + + error_msg = f"Unknown agent '{agent_name}'\n\n" + + if suggestions: + error_msg += f"Did you mean: {suggestions[0]}?\n\n" + + error_msg += "Available agents:\n " + error_msg += ", ".join(available + ['all']) + error_msg += f"\n\nUsage:\n skill-seekers install-agent --agent {suggestions[0] if suggestions else 'claude'}" + + return False, error_msg + + +def validate_skill_directory(skill_dir: Path) -> Tuple[bool, Optional[str]]: + """ + Validate that a directory is a valid skill directory. + + A valid skill directory must: + - Exist + - Be a directory + - Contain a SKILL.md file + + Args: + skill_dir: Path to skill directory + + Returns: + Tuple of (is_valid, error_message) + """ + if not skill_dir.exists(): + return False, f"Skill directory does not exist: {skill_dir}" + + if not skill_dir.is_dir(): + return False, f"Path is not a directory: {skill_dir}" + + skill_md = skill_dir / "SKILL.md" + if not skill_md.exists(): + return False, f"SKILL.md not found in {skill_dir}" + + return True, None + + +def install_to_agent( + skill_dir: Union[str, Path], + agent_name: str, + force: bool = False, + dry_run: bool = False +) -> Tuple[bool, str]: + """ + Install a skill to a specific agent's directory. + + Copies the skill directory to the agent's installation path, excluding + backup files and temporary files. + + Args: + skill_dir: Path to skill directory + agent_name: Name of agent to install to + force: If True, overwrite existing installation without asking + dry_run: If True, preview installation without making changes + + Returns: + Tuple of (success, message) + - success: True if installation succeeded, False otherwise + - message: Success message or error description + """ + # Convert to Path + skill_dir = Path(skill_dir).resolve() + skill_name = skill_dir.name + + # Validate skill directory + is_valid, error_msg = validate_skill_directory(skill_dir) + if not is_valid: + return False, f"āŒ {error_msg}" + + # Validate agent name + is_valid, error_msg = validate_agent_name(agent_name) + if not is_valid: + return False, f"āŒ {error_msg}" + + # Get agent installation path + try: + agent_base_path = get_agent_path(agent_name.lower()) + except ValueError as e: + return False, f"āŒ {str(e)}" + + # Target path: {agent_base_path}/{skill_name}/ + target_path = agent_base_path / skill_name + + # Check if already exists + if target_path.exists() and not force: + error_msg = f"āŒ Skill already installed\n\n" + error_msg += f"Location: {target_path}\n\n" + error_msg += "Options:\n" + error_msg += f" 1. Overwrite: skill-seekers install-agent {skill_dir} --agent {agent_name} --force\n" + error_msg += f" 2. Remove: rm -rf {target_path}\n" + error_msg += f" 3. Rename: mv {skill_dir} {skill_dir.parent / (skill_name + '-v2')}" + return False, error_msg + + # Dry run mode - just preview + if dry_run: + msg = f"šŸ” DRY RUN - No changes will be made\n\n" + msg += f"Would install skill: {skill_name}\n" + msg += f" Source: {skill_dir}\n" + msg += f" Target: {target_path}\n\n" + + # Calculate total size + total_size = sum(f.stat().st_size for f in skill_dir.rglob('*') if f.is_file()) + + msg += f"Files to copy:\n" + msg += f" SKILL.md ({(skill_dir / 'SKILL.md').stat().st_size / 1024:.1f} KB)\n" + + references_dir = skill_dir / 'references' + if references_dir.exists(): + ref_files = list(references_dir.rglob('*.md')) + ref_size = sum(f.stat().st_size for f in ref_files) + msg += f" references/ ({len(ref_files)} files, {ref_size / 1024:.1f} KB)\n" + + for subdir in ['scripts', 'assets']: + subdir_path = skill_dir / subdir + if subdir_path.exists(): + files = list(subdir_path.rglob('*')) + if files: + msg += f" {subdir}/ ({len(files)} files)\n" + else: + msg += f" {subdir}/ (empty)\n" + + msg += f"\nTotal size: {total_size / 1024:.1f} KB\n\n" + msg += f"To actually install, run:\n" + msg += f" skill-seekers install-agent {skill_dir} --agent {agent_name}" + + return True, msg + + # Create parent directories if needed + try: + agent_base_path.mkdir(parents=True, exist_ok=True) + except PermissionError: + return False, f"āŒ Permission denied: {agent_base_path}\n\nTry: sudo mkdir -p {agent_base_path} && sudo chown -R $USER {agent_base_path}" + + # Copy skill directory + def ignore_files(directory, files): + """Filter function for shutil.copytree to exclude unwanted files.""" + ignored = [] + for f in files: + # Exclude backup files + if f.endswith('.backup'): + ignored.append(f) + # Exclude Python cache + elif f == '__pycache__': + ignored.append(f) + # Exclude macOS metadata + elif f == '.DS_Store': + ignored.append(f) + # Exclude hidden files (except .github for vscode) + elif f.startswith('.') and f not in ['.github', '.cursor']: + ignored.append(f) + return ignored + + try: + # Remove existing if force mode + if target_path.exists() and force: + shutil.rmtree(target_path) + + # Copy directory + shutil.copytree(skill_dir, target_path, ignore=ignore_files) + + # Success message + msg = f"āœ… Installation complete!\n\n" + msg += f"Skill '{skill_name}' installed to {agent_name}\n" + msg += f"Location: {target_path}\n\n" + + # Agent-specific restart instructions + if agent_name.lower() == 'claude': + msg += "Restart Claude Code to load the new skill." + elif agent_name.lower() == 'cursor': + msg += "Restart Cursor to load the new skill." + elif agent_name.lower() in ['vscode', 'copilot']: + msg += "Restart VS Code to load the new skill." + else: + msg += f"Restart {agent_name.capitalize()} to load the new skill." + + return True, msg + + except PermissionError as e: + return False, f"āŒ Permission denied: {e}\n\nTry: sudo mkdir -p {agent_base_path} && sudo chown -R $USER {agent_base_path}" + except Exception as e: + return False, f"āŒ Installation failed: {e}" + + +def install_to_all_agents( + skill_dir: Union[str, Path], + force: bool = False, + dry_run: bool = False +) -> Dict[str, Tuple[bool, str]]: + """ + Install a skill to all available agents. + + Attempts to install the skill to all agents in AGENT_PATHS, + collecting results for each agent. + + Args: + skill_dir: Path to skill directory + force: If True, overwrite existing installations + dry_run: If True, preview installations without making changes + + Returns: + Dictionary mapping agent names to (success, message) tuples + """ + results = {} + + for agent_name in get_available_agents(): + success, message = install_to_agent(skill_dir, agent_name, force=force, dry_run=dry_run) + results[agent_name] = (success, message) + + return results + + +def main() -> int: + """ + Main entry point for install-agent CLI. + + Returns: + Exit code (0 for success, 1 for error) + """ + parser = argparse.ArgumentParser( + prog="skill-seekers-install-agent", + description="Install skills to AI coding agent directories", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Install to specific agent + skill-seekers install-agent output/react/ --agent cursor + + # Install to all agents + skill-seekers install-agent output/react/ --agent all + + # Force overwrite + skill-seekers install-agent output/react/ --agent claude --force + + # Preview installation + skill-seekers install-agent output/react/ --agent cursor --dry-run + +Supported agents: + claude, cursor, vscode, copilot, amp, goose, opencode, letta, aide, windsurf, all + """ + ) + + parser.add_argument( + "skill_directory", + help="Path to skill directory (e.g., output/react/)" + ) + + parser.add_argument( + "--agent", + required=True, + help="Agent name (use 'all' to install to all agents)" + ) + + parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing installation without asking" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview installation without making changes" + ) + + args = parser.parse_args() + + # Convert skill directory to Path + skill_dir = Path(args.skill_directory) + skill_name = skill_dir.name + + # Handle 'all' agent + if args.agent.lower() == 'all': + print(f"\nšŸ“‹ Installing skill to all agents: {skill_name}\n") + + if args.dry_run: + print("šŸ” DRY RUN MODE - No changes will be made\n") + + results = install_to_all_agents(skill_dir, force=args.force, dry_run=args.dry_run) + + # Print results + installed_count = 0 + failed_count = 0 + skipped_count = 0 + + for agent_name, (success, message) in results.items(): + if success: + if args.dry_run: + print(f"ā³ Would install to {agent_name}...") + else: + agent_path = get_agent_path(agent_name) + print(f"ā³ Installing to {agent_name}... āœ… {agent_path / skill_name}") + installed_count += 1 + else: + # Check if it's a permission error or skip + if "Permission denied" in message: + print(f"ā³ Installing to {agent_name}... āŒ Permission denied") + failed_count += 1 + elif "does not exist" in message or "SKILL.md not found" in message: + # Validation error - only show once + print(message) + return 1 + else: + print(f"ā³ Installing to {agent_name}... āš ļø Skipped (not installed)") + skipped_count += 1 + + # Summary + print(f"\nšŸ“Š Summary:") + if args.dry_run: + print(f" Would install: {installed_count} agents") + else: + print(f" āœ… Installed: {installed_count} agents") + if failed_count > 0: + print(f" āŒ Failed: {failed_count} agent(s) (permission denied)") + if skipped_count > 0: + print(f" āš ļø Skipped: {skipped_count} agent(s) (not installed)") + + if not args.dry_run: + print("\nRestart your agents to load the skill.") + + if failed_count > 0: + print("\nFix permission errors:") + print(" sudo mkdir -p ~/.amp && sudo chown -R $USER ~/.amp") + + return 0 if installed_count > 0 else 1 + + # Single agent installation + agent_name = args.agent + + print(f"\nšŸ“‹ Installing skill: {skill_name}") + print(f" Agent: {agent_name}") + + if args.dry_run: + print("\nšŸ” DRY RUN MODE - No changes will be made\n") + + success, message = install_to_agent(skill_dir, agent_name, force=args.force, dry_run=args.dry_run) + + print(message) + + return 0 if success else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/skill_seekers/cli/main.py b/src/skill_seekers/cli/main.py index 2446a04..5c952e8 100644 --- a/src/skill_seekers/cli/main.py +++ b/src/skill_seekers/cli/main.py @@ -8,20 +8,22 @@ Usage: skill-seekers [options] Commands: - scrape Scrape documentation website - github Scrape GitHub repository - pdf Extract from PDF file - unified Multi-source scraping (docs + GitHub + PDF) - enhance AI-powered enhancement (local, no API key) - package Package skill into .zip file - upload Upload skill to Claude - estimate Estimate page count before scraping + scrape Scrape documentation website + github Scrape GitHub repository + pdf Extract from PDF file + unified Multi-source scraping (docs + GitHub + PDF) + enhance AI-powered enhancement (local, no API key) + package Package skill into .zip file + upload Upload skill to Claude + estimate Estimate page count before scraping + install-agent Install skill to AI agent directories Examples: skill-seekers scrape --config configs/react.json skill-seekers github --repo microsoft/TypeScript skill-seekers unified --config configs/react_unified.json skill-seekers package output/react/ + skill-seekers install-agent output/react/ --agent cursor """ import sys @@ -60,7 +62,7 @@ For more information: https://github.com/yusufkaraaslan/Skill_Seekers parser.add_argument( "--version", action="version", - version="%(prog)s 2.2.0" + version="%(prog)s 2.3.0" ) subparsers = parser.add_subparsers( @@ -156,6 +158,32 @@ For more information: https://github.com/yusufkaraaslan/Skill_Seekers estimate_parser.add_argument("config", help="Config JSON file") estimate_parser.add_argument("--max-discovery", type=int, help="Max pages to discover") + # === install-agent subcommand === + install_agent_parser = subparsers.add_parser( + "install-agent", + help="Install skill to AI agent directories", + description="Copy skill to agent-specific installation directories" + ) + install_agent_parser.add_argument( + "skill_directory", + help="Skill directory path (e.g., output/react/)" + ) + install_agent_parser.add_argument( + "--agent", + required=True, + help="Agent name (claude, cursor, vscode, amp, goose, opencode, all)" + ) + install_agent_parser.add_argument( + "--force", + action="store_true", + help="Overwrite existing installation without asking" + ) + install_agent_parser.add_argument( + "--dry-run", + action="store_true", + help="Preview installation without making changes" + ) + # === install subcommand === install_parser = subparsers.add_parser( "install", @@ -300,6 +328,15 @@ def main(argv: Optional[List[str]] = None) -> int: sys.argv.extend(["--max-discovery", str(args.max_discovery)]) return estimate_main() or 0 + elif args.command == "install-agent": + from skill_seekers.cli.install_agent import main as install_agent_main + sys.argv = ["install_agent.py", args.skill_directory, "--agent", args.agent] + if args.force: + sys.argv.append("--force") + if args.dry_run: + sys.argv.append("--dry-run") + return install_agent_main() or 0 + elif args.command == "install": from skill_seekers.cli.install_skill import main as install_main sys.argv = ["install_skill.py"] diff --git a/tests/test_install_agent.py b/tests/test_install_agent.py new file mode 100644 index 0000000..195d727 --- /dev/null +++ b/tests/test_install_agent.py @@ -0,0 +1,467 @@ +""" +Tests for install_agent CLI tool. + +Tests cover: +- Agent path mapping and resolution +- Agent name validation with fuzzy matching +- Skill directory validation +- Installation to single agent +- Installation to all agents +- CLI interface +""" + +import pytest +import tempfile +import shutil +from pathlib import Path +from unittest.mock import patch, MagicMock +import sys + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from skill_seekers.cli.install_agent import ( + AGENT_PATHS, + get_agent_path, + get_available_agents, + validate_agent_name, + validate_skill_directory, + install_to_agent, + install_to_all_agents, + main, +) + + +class TestAgentPathMapping: + """Test agent path resolution and mapping.""" + + def test_get_agent_path_home_expansion(self): + """Test that ~ expands to home directory for global agents.""" + # Test claude (global agent with ~) + path = get_agent_path('claude') + assert path.is_absolute() + assert '.claude' in str(path) + assert str(path).startswith(str(Path.home())) + + def test_get_agent_path_project_relative(self): + """Test that project-relative paths use current directory.""" + # Test cursor (project-relative agent) + path = get_agent_path('cursor') + assert path.is_absolute() + assert '.cursor' in str(path) + # Should be relative to current directory + assert str(Path.cwd()) in str(path) + + def test_get_agent_path_project_relative_with_custom_root(self): + """Test project-relative paths with custom project root.""" + custom_root = Path('/tmp/test-project') + path = get_agent_path('cursor', project_root=custom_root) + assert path.is_absolute() + assert str(custom_root) in str(path) + assert '.cursor' in str(path) + + def test_get_agent_path_invalid_agent(self): + """Test that invalid agent raises ValueError.""" + with pytest.raises(ValueError, match="Unknown agent"): + get_agent_path('invalid_agent') + + def test_get_available_agents(self): + """Test that all 10 agents are listed.""" + agents = get_available_agents() + assert len(agents) == 10 + assert 'claude' in agents + assert 'cursor' in agents + assert 'vscode' in agents + assert 'amp' in agents + assert 'goose' in agents + assert sorted(agents) == agents # Should be sorted + + def test_agent_path_case_insensitive(self): + """Test that agent names are case-insensitive.""" + path_lower = get_agent_path('claude') + path_upper = get_agent_path('CLAUDE') + path_mixed = get_agent_path('Claude') + assert path_lower == path_upper == path_mixed + + +class TestAgentNameValidation: + """Test agent name validation and fuzzy matching.""" + + def test_validate_valid_agent(self): + """Test that valid agent names pass validation.""" + is_valid, error = validate_agent_name('claude') + assert is_valid is True + assert error is None + + def test_validate_invalid_agent_suggests_similar(self): + """Test that similar agent names are suggested for typos.""" + is_valid, error = validate_agent_name('courser') + assert is_valid is False + assert 'cursor' in error.lower() # Should suggest 'cursor' + + def test_validate_special_all(self): + """Test that 'all' is a valid special agent name.""" + is_valid, error = validate_agent_name('all') + assert is_valid is True + assert error is None + + def test_validate_case_insensitive(self): + """Test that validation is case-insensitive.""" + for name in ['Claude', 'CLAUDE', 'claude', 'cLaUdE']: + is_valid, error = validate_agent_name(name) + assert is_valid is True + assert error is None + + def test_validate_shows_available_agents(self): + """Test that error message shows available agents.""" + is_valid, error = validate_agent_name('invalid') + assert is_valid is False + assert 'available agents' in error.lower() + assert 'claude' in error.lower() + assert 'cursor' in error.lower() + + +class TestSkillDirectoryValidation: + """Test skill directory validation.""" + + def test_validate_valid_skill_directory(self): + """Test that valid skill directory passes validation.""" + with tempfile.TemporaryDirectory() as tmpdir: + skill_dir = Path(tmpdir) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("# Test Skill") + + is_valid, error = validate_skill_directory(skill_dir) + assert is_valid is True + assert error is None + + def test_validate_missing_directory(self): + """Test that missing directory fails validation.""" + skill_dir = Path("/nonexistent/directory") + is_valid, error = validate_skill_directory(skill_dir) + assert is_valid is False + assert "does not exist" in error + + def test_validate_not_a_directory(self): + """Test that file (not directory) fails validation.""" + with tempfile.NamedTemporaryFile(delete=False) as tmpfile: + try: + is_valid, error = validate_skill_directory(Path(tmpfile.name)) + assert is_valid is False + assert "not a directory" in error + finally: + Path(tmpfile.name).unlink() + + def test_validate_missing_skill_md(self): + """Test that directory without SKILL.md fails validation.""" + with tempfile.TemporaryDirectory() as tmpdir: + skill_dir = Path(tmpdir) / "test-skill" + skill_dir.mkdir() + + is_valid, error = validate_skill_directory(skill_dir) + assert is_valid is False + assert "SKILL.md not found" in error + + +class TestInstallToAgent: + """Test installation to single agent.""" + + def setup_method(self): + """Create test skill directory before each test.""" + self.tmpdir = tempfile.mkdtemp() + self.skill_dir = Path(self.tmpdir) / "test-skill" + self.skill_dir.mkdir() + + # Create SKILL.md + (self.skill_dir / "SKILL.md").write_text("# Test Skill\n\nThis is a test skill.") + + # Create references directory with files + refs_dir = self.skill_dir / "references" + refs_dir.mkdir() + (refs_dir / "index.md").write_text("# Index") + (refs_dir / "getting_started.md").write_text("# Getting Started") + + # Create empty directories + (self.skill_dir / "scripts").mkdir() + (self.skill_dir / "assets").mkdir() + + def teardown_method(self): + """Clean up after each test.""" + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_install_creates_skill_subdirectory(self): + """Test that installation creates {agent_path}/{skill_name}/ directory.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + agent_path = Path(agent_tmpdir) / ".claude" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', return_value=agent_path): + success, message = install_to_agent(self.skill_dir, 'claude', force=True) + + assert success is True + target_path = agent_path / "test-skill" + assert target_path.exists() + assert target_path.is_dir() + + def test_install_preserves_structure(self): + """Test that installation preserves SKILL.md, references/, scripts/, assets/.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + agent_path = Path(agent_tmpdir) / ".claude" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', return_value=agent_path): + success, message = install_to_agent(self.skill_dir, 'claude', force=True) + + assert success is True + target_path = agent_path / "test-skill" + + # Check structure + assert (target_path / "SKILL.md").exists() + assert (target_path / "references").exists() + assert (target_path / "references" / "index.md").exists() + assert (target_path / "references" / "getting_started.md").exists() + assert (target_path / "scripts").exists() + assert (target_path / "assets").exists() + + def test_install_excludes_backups(self): + """Test that .backup files are excluded from installation.""" + # Create backup file + (self.skill_dir / "SKILL.md.backup").write_text("# Backup") + + with tempfile.TemporaryDirectory() as agent_tmpdir: + agent_path = Path(agent_tmpdir) / ".claude" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', return_value=agent_path): + success, message = install_to_agent(self.skill_dir, 'claude', force=True) + + assert success is True + target_path = agent_path / "test-skill" + + # Backup should NOT be copied + assert not (target_path / "SKILL.md.backup").exists() + # Main file should be copied + assert (target_path / "SKILL.md").exists() + + def test_install_existing_directory_no_force(self): + """Test that existing directory without --force fails with clear message.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + agent_path = Path(agent_tmpdir) / ".claude" / "skills" + target_path = agent_path / "test-skill" + target_path.mkdir(parents=True) + + with patch('skill_seekers.cli.install_agent.get_agent_path', return_value=agent_path): + success, message = install_to_agent(self.skill_dir, 'claude', force=False) + + assert success is False + assert "already installed" in message.lower() + assert "--force" in message + + def test_install_existing_directory_with_force(self): + """Test that existing directory with --force overwrites successfully.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + agent_path = Path(agent_tmpdir) / ".claude" / "skills" + target_path = agent_path / "test-skill" + target_path.mkdir(parents=True) + (target_path / "old_file.txt").write_text("old content") + + with patch('skill_seekers.cli.install_agent.get_agent_path', return_value=agent_path): + success, message = install_to_agent(self.skill_dir, 'claude', force=True) + + assert success is True + # Old file should be gone + assert not (target_path / "old_file.txt").exists() + # New structure should exist + assert (target_path / "SKILL.md").exists() + + def test_install_invalid_skill_directory(self): + """Test that installation fails for invalid skill directory.""" + invalid_dir = Path("/nonexistent/directory") + + success, message = install_to_agent(invalid_dir, 'claude') + + assert success is False + assert "does not exist" in message + + def test_install_missing_skill_md(self): + """Test that installation fails if SKILL.md is missing.""" + with tempfile.TemporaryDirectory() as tmpdir: + bad_skill_dir = Path(tmpdir) / "bad-skill" + bad_skill_dir.mkdir() + + success, message = install_to_agent(bad_skill_dir, 'claude') + + assert success is False + assert "SKILL.md not found" in message + + def test_install_dry_run(self): + """Test that dry-run mode previews without making changes.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + agent_path = Path(agent_tmpdir) / ".claude" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', return_value=agent_path): + success, message = install_to_agent(self.skill_dir, 'claude', dry_run=True) + + assert success is True + assert "DRY RUN" in message + # Directory should NOT be created + assert not (agent_path / "test-skill").exists() + + +class TestInstallToAllAgents: + """Test installation to all agents.""" + + def setup_method(self): + """Create test skill directory before each test.""" + self.tmpdir = tempfile.mkdtemp() + self.skill_dir = Path(self.tmpdir) / "test-skill" + self.skill_dir.mkdir() + (self.skill_dir / "SKILL.md").write_text("# Test Skill") + (self.skill_dir / "references").mkdir() + + def teardown_method(self): + """Clean up after each test.""" + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_install_to_all_success(self): + """Test that install_to_all_agents attempts all 10 agents.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + def mock_get_agent_path(agent_name, project_root=None): + return Path(agent_tmpdir) / f".{agent_name}" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', side_effect=mock_get_agent_path): + results = install_to_all_agents(self.skill_dir, force=True) + + assert len(results) == 10 + assert 'claude' in results + assert 'cursor' in results + + def test_install_to_all_partial_success(self): + """Test that install_to_all collects both successes and failures.""" + # This is hard to test without complex mocking, so we'll do dry-run + results = install_to_all_agents(self.skill_dir, dry_run=True) + + # All should succeed in dry-run mode + assert len(results) == 10 + for agent_name, (success, message) in results.items(): + assert success is True + assert "DRY RUN" in message + + def test_install_to_all_with_force(self): + """Test that install_to_all respects force flag.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + # Create existing directories for all agents + for agent in get_available_agents(): + agent_dir = Path(agent_tmpdir) / f".{agent}" / "skills" / "test-skill" + agent_dir.mkdir(parents=True) + + def mock_get_agent_path(agent_name, project_root=None): + return Path(agent_tmpdir) / f".{agent_name}" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', side_effect=mock_get_agent_path): + # Without force - should fail + results_no_force = install_to_all_agents(self.skill_dir, force=False) + # All should fail because directories exist + for agent_name, (success, message) in results_no_force.items(): + assert success is False + assert "already installed" in message.lower() + + # With force - should succeed + results_with_force = install_to_all_agents(self.skill_dir, force=True) + for agent_name, (success, message) in results_with_force.items(): + assert success is True + + def test_install_to_all_returns_results(self): + """Test that install_to_all returns dict with all results.""" + results = install_to_all_agents(self.skill_dir, dry_run=True) + + assert isinstance(results, dict) + assert len(results) == 10 + + for agent_name, (success, message) in results.items(): + assert isinstance(success, bool) + assert isinstance(message, str) + assert agent_name in get_available_agents() + + +class TestInstallAgentCLI: + """Test CLI interface.""" + + def setup_method(self): + """Create test skill directory before each test.""" + self.tmpdir = tempfile.mkdtemp() + self.skill_dir = Path(self.tmpdir) / "test-skill" + self.skill_dir.mkdir() + (self.skill_dir / "SKILL.md").write_text("# Test Skill") + (self.skill_dir / "references").mkdir() + + def teardown_method(self): + """Clean up after each test.""" + shutil.rmtree(self.tmpdir, ignore_errors=True) + + def test_cli_help_output(self): + """Test that --help shows usage information.""" + with pytest.raises(SystemExit) as exc_info: + with patch('sys.argv', ['install_agent.py', '--help']): + main() + + # --help exits with code 0 + assert exc_info.value.code == 0 + + def test_cli_requires_agent_flag(self): + """Test that CLI fails without --agent flag.""" + with pytest.raises(SystemExit) as exc_info: + with patch('sys.argv', ['install_agent.py', str(self.skill_dir)]): + main() + + # Missing required argument exits with code 2 + assert exc_info.value.code == 2 + + def test_cli_dry_run(self): + """Test that --dry-run flag works correctly.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + def mock_get_agent_path(agent_name, project_root=None): + return Path(agent_tmpdir) / f".{agent_name}" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', side_effect=mock_get_agent_path): + with patch('sys.argv', ['install_agent.py', str(self.skill_dir), '--agent', 'claude', '--dry-run']): + exit_code = main() + + assert exit_code == 0 + # Directory should NOT be created + assert not (Path(agent_tmpdir) / ".claude" / "skills" / "test-skill").exists() + + def test_cli_integration(self): + """Test end-to-end CLI execution.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + def mock_get_agent_path(agent_name, project_root=None): + return Path(agent_tmpdir) / f".{agent_name}" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', side_effect=mock_get_agent_path): + with patch('sys.argv', ['install_agent.py', str(self.skill_dir), '--agent', 'claude', '--force']): + exit_code = main() + + assert exit_code == 0 + # Directory should be created + target = Path(agent_tmpdir) / ".claude" / "skills" / "test-skill" + assert target.exists() + assert (target / "SKILL.md").exists() + + def test_cli_install_to_all(self): + """Test CLI with --agent all.""" + with tempfile.TemporaryDirectory() as agent_tmpdir: + def mock_get_agent_path(agent_name, project_root=None): + return Path(agent_tmpdir) / f".{agent_name}" / "skills" + + with patch('skill_seekers.cli.install_agent.get_agent_path', side_effect=mock_get_agent_path): + with patch('sys.argv', ['install_agent.py', str(self.skill_dir), '--agent', 'all', '--force']): + exit_code = main() + + assert exit_code == 0 + + # All agent directories should be created + for agent in get_available_agents(): + target = Path(agent_tmpdir) / f".{agent}" / "skills" / "test-skill" + assert target.exists(), f"Directory not created for {agent}" + + +if __name__ == "__main__": + # Run tests with pytest + pytest.main([__file__, "-v"])