feat(services): add skill installer for Claude Code skills

Add SkillInstaller service to install bundled skill templates
(commit, branch, push, pull, stash) to user or project scope.
Includes template management and installation status checking.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-02-01 21:17:36 +01:00
co-authored by Claude
parent 8b2d7c57ef
commit f9d37e3e7e
6 changed files with 437 additions and 0 deletions
+285
View File
@@ -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
+31
View File
@@ -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 <branch>`
- If not: `git checkout -b <branch>`
3. Show current branch status after switch
## Common Operations
- List all branches: `git branch -a`
- Create and switch: `git checkout -b <name>`
- Switch to existing: `git checkout <name>`
- Delete local branch: `git branch -d <name>`
- Delete remote branch: `git push origin --delete <name>`
## Best Practices
- Use descriptive branch names (feature/*, fix/*, etc.)
- Keep branches short-lived
- Delete merged branches to keep repo clean
+38
View File
@@ -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
```
<type>(<scope>): <short description>
<body - what and why, not how>
Co-Authored-By: Claude <noreply@anthropic.com>
```
## Best Practices
- Warn about large commits (>500 lines changed)
- Suggest splitting large changes into smaller commits
- Never skip pre-commit hooks unless explicitly requested
+33
View File
@@ -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 <current-branch>`
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
+23
View File
@@ -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 <branch>`
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
+27
View File
@@ -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 "<message>"` 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 -- <files>`
## 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