feat: structured skill packages with config overrides, chaining, and TUI integration
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>
This commit is contained in:
@@ -1,46 +1,92 @@
|
||||
"""Skills manager — scans for and loads skill markdown files."""
|
||||
"""Skills manager — scans for and loads skill packages and legacy markdown files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
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 file."""
|
||||
"""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."""
|
||||
"""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 .md skill files."""
|
||||
"""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 md in sorted(resolved.glob("*.md")):
|
||||
name = md.stem
|
||||
desc = self._extract_description(md)
|
||||
self._skills[name] = Skill(name=name, description=desc, path=md)
|
||||
logger.debug("Discovered skill: %s (%s)", name, desc)
|
||||
|
||||
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:
|
||||
@@ -55,10 +101,51 @@ class SkillsManager:
|
||||
"""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. Returns None if not found."""
|
||||
"""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)
|
||||
return skill.path.read_text() if skill else None
|
||||
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."""
|
||||
@@ -66,6 +153,10 @@ class SkillsManager:
|
||||
return ""
|
||||
lines = ["\nAvailable skills (invoke with /skill-name):"]
|
||||
for s in self._skills.values():
|
||||
lines.append(f" - /{s.name}: {s.description}")
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user