diff --git a/clide/services/skill_installer.py b/clide/services/skill_installer.py new file mode 100644 index 00000000..f5d5e18e --- /dev/null +++ b/clide/services/skill_installer.py @@ -0,0 +1,285 @@ +"""Skill installer service for Claude Code skills. + +This module provides functionality to install skill templates +into the user's Claude Code configuration. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Literal + +# Path to bundled skill templates within Clide package +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "skills" + +# Default installation locations +USER_SKILLS_DIR = Path.home() / ".claude" / "skills" + + +class SkillInstaller: + """Installs Claude Code skills from templates. + + Skills can be installed to: + - User level: ~/.claude/skills/ (available globally) + - Project level: .claude/skills/ (available in project only) + + Example: + installer = SkillInstaller() + + # Check if skill exists + if not installer.is_installed("git-workflow"): + installer.install("git-workflow") + + # Install to project instead of user + installer.install("git-workflow", scope="project") + """ + + def __init__( + self, + templates_dir: Path | None = None, + project_dir: Path | None = None, + ) -> None: + """Initialize the skill installer. + + Args: + templates_dir: Override the templates directory. + project_dir: Project directory for project-scoped skills. + """ + self._templates_dir = templates_dir or TEMPLATES_DIR + self._project_dir = project_dir or Path.cwd() + + @property + def templates_dir(self) -> Path: + """Get the templates directory.""" + return self._templates_dir + + @property + def user_skills_dir(self) -> Path: + """Get the user skills directory.""" + return USER_SKILLS_DIR + + @property + def project_skills_dir(self) -> Path: + """Get the project skills directory.""" + return self._project_dir / ".claude" / "skills" + + def list_available_templates(self) -> list[str]: + """List all available skill templates. + + Returns: + List of skill names that can be installed. + """ + if not self._templates_dir.exists(): + return [] + + return [ + d.name + for d in self._templates_dir.iterdir() + if d.is_dir() and (d / "SKILL.md").exists() + ] + + def list_installed_skills( + self, + scope: Literal["user", "project", "all"] = "all", + ) -> list[dict[str, str]]: + """List installed skills. + + Args: + scope: Which skills to list - user, project, or all. + + Returns: + List of dicts with 'name', 'scope', and 'path' keys. + """ + skills = [] + + if scope in ("user", "all"): + if self.user_skills_dir.exists(): + for d in self.user_skills_dir.iterdir(): + if d.is_dir() and (d / "SKILL.md").exists(): + skills.append( + { + "name": d.name, + "scope": "user", + "path": str(d), + } + ) + + if scope in ("project", "all"): + if self.project_skills_dir.exists(): + for d in self.project_skills_dir.iterdir(): + if d.is_dir() and (d / "SKILL.md").exists(): + skills.append( + { + "name": d.name, + "scope": "project", + "path": str(d), + } + ) + + return skills + + def is_installed( + self, + skill_name: str, + scope: Literal["user", "project", "any"] = "any", + ) -> bool: + """Check if a skill is installed. + + Args: + skill_name: The skill name to check. + scope: Where to check - user, project, or any. + + Returns: + True if the skill is installed. + """ + if scope in ("user", "any"): + user_skill = self.user_skills_dir / skill_name / "SKILL.md" + if user_skill.exists(): + return True + + if scope in ("project", "any"): + project_skill = self.project_skills_dir / skill_name / "SKILL.md" + if project_skill.exists(): + return True + + return False + + def get_skill_path( + self, + skill_name: str, + scope: Literal["user", "project", "any"] = "any", + ) -> Path | None: + """Get the path to an installed skill. + + Args: + skill_name: The skill name. + scope: Where to look - user, project, or any (project takes priority). + + Returns: + Path to the skill directory, or None if not found. + """ + # Project scope takes priority when scope is "any" + if scope in ("project", "any"): + project_skill = self.project_skills_dir / skill_name + if (project_skill / "SKILL.md").exists(): + return project_skill + + if scope in ("user", "any"): + user_skill = self.user_skills_dir / skill_name + if (user_skill / "SKILL.md").exists(): + return user_skill + + return None + + def install( + self, + skill_name: str, + scope: Literal["user", "project"] = "user", + overwrite: bool = False, + ) -> Path: + """Install a skill from templates. + + Args: + skill_name: The skill name to install. + scope: Where to install - user or project level. + overwrite: Whether to overwrite existing installation. + + Returns: + Path to the installed skill. + + Raises: + ValueError: If skill template doesn't exist. + FileExistsError: If skill exists and overwrite is False. + """ + # Check template exists + template_dir = self._templates_dir / skill_name + if not template_dir.exists() or not (template_dir / "SKILL.md").exists(): + raise ValueError(f"Skill template '{skill_name}' not found") + + # Determine target directory + if scope == "user": + target_dir = self.user_skills_dir / skill_name + else: + target_dir = self.project_skills_dir / skill_name + + # Check if already exists + if target_dir.exists(): + if not overwrite: + raise FileExistsError(f"Skill '{skill_name}' already installed at {target_dir}") + shutil.rmtree(target_dir) + + # Create parent directory + target_dir.parent.mkdir(parents=True, exist_ok=True) + + # Copy template + shutil.copytree(template_dir, target_dir) + + return target_dir + + def uninstall( + self, + skill_name: str, + scope: Literal["user", "project"] = "user", + ) -> bool: + """Uninstall a skill. + + Args: + skill_name: The skill name to uninstall. + scope: Where to uninstall from - user or project level. + + Returns: + True if skill was uninstalled, False if it wasn't installed. + """ + if scope == "user": + skill_dir = self.user_skills_dir / skill_name + else: + skill_dir = self.project_skills_dir / skill_name + + if skill_dir.exists(): + shutil.rmtree(skill_dir) + return True + + return False + + def ensure_installed( + self, + skill_name: str, + scope: Literal["user", "project"] = "project", + ) -> Path: + """Ensure a skill is installed, installing if needed. + + Args: + skill_name: The skill name. + scope: Where to install if not present (default: project). + + Returns: + Path to the skill directory. + + Raises: + ValueError: If skill template doesn't exist. + """ + existing = self.get_skill_path(skill_name) + if existing: + return existing + + return self.install(skill_name, scope=scope) + + +# Global instance +_skill_installer: SkillInstaller | None = None + + +def get_skill_installer(project_dir: Path | None = None) -> SkillInstaller: + """Get or create the global skill installer. + + Args: + project_dir: Project directory (only used on first call). + + Returns: + The SkillInstaller instance. + """ + global _skill_installer + if _skill_installer is None: + _skill_installer = SkillInstaller(project_dir=project_dir) + return _skill_installer diff --git a/clide/templates/skills/branch/SKILL.md b/clide/templates/skills/branch/SKILL.md new file mode 100644 index 00000000..c56629f1 --- /dev/null +++ b/clide/templates/skills/branch/SKILL.md @@ -0,0 +1,31 @@ +--- +name: branch +description: Create, switch, or manage git branches +--- + +# Git Branch + +Create, switch, or manage branches. + +## Steps + +1. If no argument, list branches with `git branch -a` +2. If branch name provided: + - Check if it exists + - If exists: `git checkout ` + - If not: `git checkout -b ` +3. Show current branch status after switch + +## Common Operations + +- List all branches: `git branch -a` +- Create and switch: `git checkout -b ` +- Switch to existing: `git checkout ` +- Delete local branch: `git branch -d ` +- Delete remote branch: `git push origin --delete ` + +## Best Practices + +- Use descriptive branch names (feature/*, fix/*, etc.) +- Keep branches short-lived +- Delete merged branches to keep repo clean diff --git a/clide/templates/skills/commit/SKILL.md b/clide/templates/skills/commit/SKILL.md new file mode 100644 index 00000000..a3893395 --- /dev/null +++ b/clide/templates/skills/commit/SKILL.md @@ -0,0 +1,38 @@ +--- +name: commit +description: Create a well-formatted git commit with staged changes +--- + +# Git Commit + +Create a well-formatted commit with staged changes following best practices. + +## Steps + +1. Run `git status --porcelain` to check for changes +2. If no staged changes, show unstaged files and ask what to stage +3. Run `git diff --cached` to review staged changes +4. Generate a commit message following Conventional Commits format: + - `feat:` new feature + - `fix:` bug fix + - `docs:` documentation + - `refactor:` code restructuring + - `test:` adding tests + - `chore:` maintenance +5. Create commit with the message, adding Co-Authored-By trailer + +## Commit Message Format + +``` +(): + + + +Co-Authored-By: Claude +``` + +## Best Practices + +- Warn about large commits (>500 lines changed) +- Suggest splitting large changes into smaller commits +- Never skip pre-commit hooks unless explicitly requested diff --git a/clide/templates/skills/pull/SKILL.md b/clide/templates/skills/pull/SKILL.md new file mode 100644 index 00000000..fd124913 --- /dev/null +++ b/clide/templates/skills/pull/SKILL.md @@ -0,0 +1,33 @@ +--- +name: pull +description: Pull changes from remote with rebase +--- + +# Git Pull + +Pull changes from remote with rebase to keep history clean. + +## Steps + +1. Check for uncommitted changes - stash if needed +2. Run `git pull --rebase origin ` +3. If conflicts occur: + - Show conflicting files + - Help resolve conflicts one by one + - Continue rebase after resolution +4. Pop stash if we stashed earlier + +## Conflict Resolution + +When conflicts are found: +1. Show the conflicting files with `git status` +2. For each file, show the conflict markers +3. Help user decide how to resolve +4. Stage resolved files with `git add` +5. Continue with `git rebase --continue` + +## Best Practices + +- Always use rebase for pulls to keep history clean +- Stash local changes before pulling +- Never force push after rebase on shared branches diff --git a/clide/templates/skills/push/SKILL.md b/clide/templates/skills/push/SKILL.md new file mode 100644 index 00000000..33c79f90 --- /dev/null +++ b/clide/templates/skills/push/SKILL.md @@ -0,0 +1,23 @@ +--- +name: push +description: Push current branch to remote +--- + +# Git Push + +Push current branch to remote repository. + +## Steps + +1. Check if branch has upstream: `git rev-parse --abbrev-ref @{u}` +2. If no upstream, set it: `git push -u origin ` +3. Otherwise: `git push` +4. If push is rejected (non-fast-forward): + - Suggest pull --rebase first + - Never force push to main/master without explicit request + +## Best Practices + +- Never force push to protected branches (main, master, develop) +- Always set upstream on first push with `-u` flag +- If rejected, pull with rebase first rather than force pushing diff --git a/clide/templates/skills/stash/SKILL.md b/clide/templates/skills/stash/SKILL.md new file mode 100644 index 00000000..0de25d45 --- /dev/null +++ b/clide/templates/skills/stash/SKILL.md @@ -0,0 +1,27 @@ +--- +name: stash +description: Stash current working directory changes +--- + +# Git Stash + +Stash current working directory changes for later use. + +## Steps + +1. Run `git status` to show what will be stashed +2. Ask for optional stash message +3. Run `git stash push -m ""` or `git stash push` if no message +4. Confirm stash was created with `git stash list` + +## Options + +- Include untracked files: `git stash push -u` +- Stash specific files: `git stash push -- ` + +## Related Commands + +- `git stash list` - List all stashes +- `git stash pop` - Apply and remove most recent stash +- `git stash apply` - Apply but keep stash +- `git stash drop` - Remove a stash