sonicoder / code /skills /__init__.py
R-Kentaren's picture
feat(agent): add Claude Code-style agent, skills, slash-commands, hooks, todos, sandboxed workspace, and full-stack scaffolding
81aa0b5 verified
Raw
History Blame Contribute Delete
6.71 kB
"""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 <name> 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)