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>
462 lines
15 KiB
Python
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())
|