""" 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 18 agents are listed.""" agents = get_available_agents() assert len(agents) == 18 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 "roo" in agents assert "cline" in agents assert "aider" in agents assert "bolt" in agents assert "kilo" in agents assert "continue" in agents assert "kimi-code" in agents assert sorted(agents) == agents # Should be sorted def test_new_agents_project_relative(self): """Test that project-relative new agents resolve correctly.""" for agent in ["roo", "cline", "bolt", "kilo"]: path = get_agent_path(agent) assert path.is_absolute() assert str(Path.cwd()) in str(path) def test_new_agents_global(self): """Test that global new agents resolve to home directory.""" for agent in ["aider", "continue", "kimi-code"]: path = get_agent_path(agent) assert path.is_absolute() assert str(path).startswith(str(Path.home())) 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 18 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) == 18 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) == 18 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) == 18 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"])