"""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)