Add a skill package system where each skill is a directory with a skill.yaml manifest and prompt markdown files. Skills support /command triggers, scoped config overrides (temperature, max_tokens, tool filtering), chain dependencies with cycle-safe resolution, and a finish_skill completion signal. Includes four built-in skills: explore, brainstorm, write-document, and plan. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
163 lines
5.7 KiB
Python
163 lines
5.7 KiB
Python
"""Skills manager — scans for and loads skill packages and legacy markdown files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import yaml
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from app.models.config import SkillsConfig
|
|
from app.models.skill import SkillManifest
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class Skill(BaseModel):
|
|
"""Metadata for a discovered skill (package or legacy flat file)."""
|
|
|
|
name: str
|
|
description: str
|
|
path: Path
|
|
manifest: SkillManifest | None = None
|
|
|
|
|
|
class SkillsManager:
|
|
"""Discovers, indexes, and loads skill files from configured directories.
|
|
|
|
Supports both:
|
|
- Directory-based packages (contain skill.yaml + prompt .md files)
|
|
- Legacy flat .md files (backwards compatible)
|
|
"""
|
|
|
|
def __init__(self, config: SkillsConfig, workspace_root: Path) -> None:
|
|
self._config = config
|
|
self._workspace = workspace_root
|
|
self._skills: dict[str, Skill] = {}
|
|
self._trigger_map: dict[str, str] = {} # trigger -> skill name
|
|
self._scan()
|
|
|
|
def _scan(self) -> None:
|
|
"""Scan configured directories for skill packages and legacy .md files."""
|
|
for skill_dir in self._config.directories:
|
|
resolved = (self._workspace / skill_dir) if not skill_dir.is_absolute() else skill_dir
|
|
if not resolved.is_dir():
|
|
logger.debug("Skills directory does not exist: %s", resolved)
|
|
continue
|
|
|
|
for entry in sorted(resolved.iterdir()):
|
|
if entry.is_dir():
|
|
self._scan_package(entry)
|
|
elif entry.suffix == ".md":
|
|
self._scan_legacy(entry)
|
|
|
|
def _scan_package(self, pkg_dir: Path) -> None:
|
|
"""Scan a directory-based skill package containing skill.yaml."""
|
|
manifest_path = pkg_dir / "skill.yaml"
|
|
if not manifest_path.exists():
|
|
logger.debug("Skipping directory without skill.yaml: %s", pkg_dir)
|
|
return
|
|
|
|
try:
|
|
raw = yaml.safe_load(manifest_path.read_text())
|
|
manifest = SkillManifest(**raw)
|
|
except (yaml.YAMLError, ValidationError, TypeError) as e:
|
|
logger.warning("Failed to parse skill manifest %s: %s", manifest_path, e)
|
|
return
|
|
|
|
skill = Skill(
|
|
name=manifest.name,
|
|
description=manifest.description,
|
|
path=pkg_dir,
|
|
manifest=manifest,
|
|
)
|
|
self._skills[manifest.name] = skill
|
|
|
|
# Register triggers
|
|
for trigger in manifest.triggers:
|
|
normalized = trigger.lstrip("/").lower()
|
|
self._trigger_map[normalized] = manifest.name
|
|
|
|
logger.debug("Discovered skill package: %s (%s)", manifest.name, manifest.description)
|
|
|
|
def _scan_legacy(self, md_path: Path) -> None:
|
|
"""Scan a legacy flat .md skill file."""
|
|
name = md_path.stem
|
|
desc = self._extract_description(md_path)
|
|
self._skills[name] = Skill(name=name, description=desc, path=md_path)
|
|
logger.debug("Discovered legacy skill: %s (%s)", name, desc)
|
|
|
|
@staticmethod
|
|
def _extract_description(path: Path) -> str:
|
|
"""Extract the first non-blank, non-heading line as the description."""
|
|
for line in path.read_text().splitlines():
|
|
stripped = line.strip()
|
|
if stripped and not stripped.startswith("#"):
|
|
return stripped
|
|
return "(no description)"
|
|
|
|
def list_skills(self) -> list[Skill]:
|
|
"""Return all discovered skills."""
|
|
return list(self._skills.values())
|
|
|
|
def get_skill(self, name: str) -> Skill | None:
|
|
"""Look up a skill by name."""
|
|
return self._skills.get(name)
|
|
|
|
def get_skill_by_trigger(self, trigger: str) -> Skill | None:
|
|
"""Look up a skill by /command trigger.
|
|
|
|
Args:
|
|
trigger: The trigger string (with or without leading /).
|
|
|
|
Returns:
|
|
The matching Skill, or None.
|
|
"""
|
|
normalized = trigger.lstrip("/").lower()
|
|
skill_name = self._trigger_map.get(normalized)
|
|
if skill_name:
|
|
return self._skills.get(skill_name)
|
|
return None
|
|
|
|
def load_skill(self, name: str) -> str | None:
|
|
"""Load the full content of a skill by name.
|
|
|
|
For package skills, concatenates all prompt .md files.
|
|
For legacy skills, returns the .md file content.
|
|
|
|
Returns:
|
|
Concatenated prompt content, or None if not found.
|
|
"""
|
|
skill = self._skills.get(name)
|
|
if skill is None:
|
|
return None
|
|
|
|
if skill.manifest is not None:
|
|
# Package skill: load prompt files
|
|
parts: list[str] = []
|
|
for prompt_file in skill.manifest.prompts:
|
|
prompt_path = skill.path / prompt_file
|
|
if prompt_path.exists():
|
|
parts.append(prompt_path.read_text())
|
|
else:
|
|
logger.warning("Prompt file not found: %s", prompt_path)
|
|
return "\n\n".join(parts) if parts else None
|
|
else:
|
|
# Legacy flat file
|
|
return skill.path.read_text()
|
|
|
|
def get_system_prompt_snippet(self) -> str:
|
|
"""Generate a snippet for the system prompt listing available skills."""
|
|
if not self._skills:
|
|
return ""
|
|
lines = ["\nAvailable skills (invoke with /skill-name):"]
|
|
for s in self._skills.values():
|
|
if s.manifest and s.manifest.triggers:
|
|
trigger_str = ", ".join(s.manifest.triggers)
|
|
lines.append(f" - {trigger_str}: {s.description}")
|
|
else:
|
|
lines.append(f" - /{s.name}: {s.description}")
|
|
lines.append("To use a skill's full instructions, call the load_skill tool.")
|
|
return "\n".join(lines)
|