"""Skills system — load markdown skill files at runtime. Inspired by Claude Code's Skill system. Each skill is a directory with: - SKILL.md: the skill instructions (markdown with YAML frontmatter) - references/ (optional): supporting docs - scripts/ (optional): helper scripts Skills are discovered under code/skills/builtins/ and can also be loaded from the workspace's .sonicoder/skills/ directory. """ from __future__ import annotations import logging import os import re from pathlib import Path from typing import Any logger = logging.getLogger(__name__) # ─── Skill discovery roots ────────────────────────────────────────────── _BUILTIN_SKILLS_DIR = os.path.join(os.path.dirname(__file__), "builtins") _USER_SKILLS_DIRNAME = ".sonicoder/skills" # relative to workspace root def _skill_dirs() -> list[str]: """Return all directories to search for skills.""" dirs = [_BUILTIN_SKILLS_DIR] # Add user skills dir from workspace try: from code.tools.fs import get_workspace_root user_dir = os.path.join(get_workspace_root(), _USER_SKILLS_DIRNAME) if os.path.isdir(user_dir): dirs.append(user_dir) except Exception: pass return dirs # ─── YAML frontmatter parsing ─────────────────────────────────────────── _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL) def _parse_frontmatter(content: str) -> tuple[dict[str, str], str]: """Parse YAML frontmatter from markdown. Returns (metadata, body).""" match = _FRONTMATTER_RE.match(content) if not match: return {}, content raw_yaml = match.group(1) body = match.group(2) # Very simple YAML parser (key: value pairs only) meta: dict[str, str] = {} for line in raw_yaml.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if ":" in line: key, _, value = line.partition(":") meta[key.strip()] = value.strip().strip("\"'") return meta, body # ─── Skill loading ────────────────────────────────────────────────────── def _load_skill(skill_dir: str) -> dict[str, Any] | None: """Load a single skill from a directory.""" skill_md = os.path.join(skill_dir, "SKILL.md") if not os.path.isfile(skill_md): return None try: with open(skill_md, "r", encoding="utf-8") as f: content = f.read() except Exception as exc: logger.warning("Failed to read %s: %s", skill_md, exc) return None meta, body = _parse_frontmatter(content) # Load any reference files references: dict[str, str] = {} refs_dir = os.path.join(skill_dir, "references") if os.path.isdir(refs_dir): for fname in os.listdir(refs_dir): if fname.endswith((".md", ".txt")): try: with open(os.path.join(refs_dir, fname), "r", encoding="utf-8") as f: references[fname] = f.read() except Exception: pass return { "name": meta.get("name", os.path.basename(skill_dir)), "description": meta.get("description", ""), "language": meta.get("language", ""), "tags": [t.strip() for t in meta.get("tags", "").split(",") if t.strip()], "body": body.strip(), "references": references, "path": skill_dir, } def list_skills() -> list[dict[str, Any]]: """List all available skills (metadata only, no body).""" skills: list[dict[str, Any]] = [] seen_names: set[str] = set() for skills_dir in _skill_dirs(): if not os.path.isdir(skills_dir): continue for entry in sorted(os.listdir(skills_dir)): entry_path = os.path.join(skills_dir, entry) if not os.path.isdir(entry_path): continue skill = _load_skill(entry_path) if skill and skill["name"] not in seen_names: seen_names.add(skill["name"]) skills.append({ "name": skill["name"], "description": skill["description"], "language": skill["language"], "tags": skill["tags"], }) return skills def get_skill(name: str) -> dict[str, Any] | None: """Get full skill content by name.""" for skills_dir in _skill_dirs(): if not os.path.isdir(skills_dir): continue # Try directory match for entry in os.listdir(skills_dir): entry_path = os.path.join(skills_dir, entry) if not os.path.isdir(entry_path): continue skill = _load_skill(entry_path) if skill and skill["name"] == name: return skill return None def invoke_skill(name: str) -> dict[str, Any]: """Invoke a skill by name — returns its full body and references.""" skill = get_skill(name) if not skill: return { "success": False, "error": f"Skill not found: {name}", "available": [s["name"] for s in list_skills()], } return { "success": True, "name": skill["name"], "description": skill["description"], "body": skill["body"], "references": skill["references"], } def build_skills_context(skill_names: list[str] | None = None) -> str: """Build a context string with skill bodies to inject into the prompt. If skill_names is None, includes all skills (brief listing only). """ if not skill_names: # List all skills briefly skills = list_skills() if not skills: return "" lines = ["Available skills (use /skill to load full instructions):"] for s in skills: desc = s["description"][:120] lines.append(f"- {s['name']}: {desc}") return "\n".join(lines) # Load full bodies for requested skills parts: list[str] = [] for name in skill_names: skill = get_skill(name) if skill: parts.append(f"# Skill: {skill['name']}\n\n{skill['body']}") for ref_name, ref_body in skill["references"].items(): parts.append(f"\n## Reference: {ref_name}\n\n{ref_body}") else: parts.append(f"# Skill: {name}\n\n(Skill not found)") return "\n\n---\n\n".join(parts)