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:
co-authored by
Claude
parent
8b2d7c57ef
commit
f9d37e3e7e
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user