Files
SneakyCode/app/services/skills.py
Phillip Tarrant 2ae8294e29 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>
2026-03-11 19:06:05 -05:00

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)