Files
skill-seekers-reference/src/skill_seekers/cli/install_agent.py
yusyus 596b219599 fix: Resolve remaining 188 linting errors (249 total fixed)
Second batch of comprehensive linting fixes:

Unused Arguments/Variables (136 errors):
- ARG002/ARG001 (91 errors): Prefixed unused method/function arguments with '_'
  - Interface methods in adaptors (base.py, gemini.py, markdown.py)
  - AST analyzer methods maintaining signatures (code_analyzer.py)
  - Test fixtures and hooks (conftest.py)
  - Added noqa: ARG001/ARG002 for pytest hooks requiring exact names
- F841 (45 errors): Prefixed unused local variables with '_'
  - Tuple unpacking where some values aren't needed
  - Variables assigned but not referenced

Loop & Boolean Quality (28 errors):
- B007 (18 errors): Prefixed unused loop control variables with '_'
  - enumerate() loops where index not used
  - for-in loops where loop variable not referenced
- E712 (10 errors): Simplified boolean comparisons
  - Changed '== True' to direct boolean check
  - Changed '== False' to 'not' expression
  - Improved test readability

Code Quality (24 errors):
- SIM201 (4 errors): Already fixed in previous commit
- SIM118 (2 errors): Already fixed in previous commit
- E741 (4 errors): Already fixed in previous commit
- Config manager loop variable fix (1 error)

All Tests Passing:
- test_scraper_features.py: 42 passed
- test_integration.py: 51 passed
- test_architecture_scenarios.py: 11 passed
- test_real_world_fastmcp.py: 19 passed, 1 skipped

Note: Some SIM errors (nested if, multiple with) remain unfixed as they
would require non-trivial refactoring. Focus was on functional correctness.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-17 23:02:11 +03:00

462 lines
15 KiB
Python

#!/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 <skill_directory> --agent <agent_name> [--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 difflib import get_close_matches
from pathlib import Path
# 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
"neovate": "~/.neovate/skills/", # Global
}
def get_agent_path(agent_name: str, project_root: Path | None = None) -> Path:
"""
Resolve the installation path for a given agent.
Handles both global paths (~/.<agent>/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, str | None]:
"""
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 <skill_directory> --agent {suggestions[0] if suggestions else 'claude'}"
return False, error_msg
def validate_skill_directory(skill_dir: Path) -> tuple[bool, str | None]:
"""
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: 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 = "❌ 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 = "🔍 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 += "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 += "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")
or f == "__pycache__"
or f == ".DS_Store"
or 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 = "✅ 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: 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, neovate, 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("\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())