""" 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 shutil import sys import tempfile from pathlib import Path from unittest.mock import patch import pytest # Add src to path for imports sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from skill_seekers.cli.install_agent import ( get_agent_path, get_available_agents, install_to_agent, install_to_all_agents, main, validate_agent_name, validate_skill_directory, ) 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 11 agents are listed.""" agents = get_available_agents() assert len(agents) == 11 assert "claude" in agents assert "cursor" in agents assert "vscode" in agents assert "amp" in agents assert "goose" in agents assert "neovate" 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 11 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) == 11 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) == 11 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) == 11 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, 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, 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, ), 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, ), 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, ), 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"])