"""Skills manager — scans for and loads skill markdown files.""" from __future__ import annotations import logging from pathlib import Path from pydantic import BaseModel from app.models.config import SkillsConfig logger = logging.getLogger(__name__) class Skill(BaseModel): """Metadata for a discovered skill file.""" name: str description: str path: Path class SkillsManager: """Discovers, indexes, and loads skill files from configured directories.""" def __init__(self, config: SkillsConfig, workspace_root: Path) -> None: self._config = config self._workspace = workspace_root self._skills: dict[str, Skill] = {} self._scan() def _scan(self) -> None: """Scan configured directories for .md skill 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) @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 load_skill(self, name: str) -> str | None: """Load the full content of a skill by name. Returns None if not found.""" skill = self._skills.get(name) return skill.path.read_text() if skill else None 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(): 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)