diff --git a/scripts/validate.py b/scripts/validate.py index 3915513..efb69f8 100755 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -238,24 +238,25 @@ def validate_skill_file(filepath: Path) -> list[str]: return errors -def validate_skill_md_file(filepath: Path) -> list[str]: - """Validate a Claude Code-style SKILL.md file with YAML frontmatter.""" - errors = [] +def parse_skill_md_frontmatter(filepath: Path) -> tuple[dict[str, str], list[str]]: + """Parse YAML frontmatter from a SKILL.md file. + + Returns (metadata, errors). Metadata may be empty if parsing fails. + """ + errors: list[str] = [] rel = filepath.relative_to(filepath.parent.parent) try: text = filepath.read_text(encoding="utf-8") except OSError as e: - return [f"{rel}: Failed to read file: {e}"] + return {}, [f"{rel}: Failed to read file: {e}"] if not text.startswith("---"): - errors.append(f"{rel}: Missing YAML frontmatter (must start with '---')") - return errors + return {}, [f"{rel}: Missing YAML frontmatter (must start with '---')"] end = text.find("\n---", 3) if end == -1: - errors.append(f"{rel}: Unterminated YAML frontmatter (missing closing '---')") - return errors + return {}, [f"{rel}: Unterminated YAML frontmatter (missing closing '---')"] fm = text[3:end].strip() meta: dict[str, str] = {} @@ -268,12 +269,26 @@ def validate_skill_md_file(filepath: Path) -> list[str]: k, _, v = line.partition(":") meta[k.strip()] = v.strip().strip('"').strip("'") + return meta, errors + + +def validate_skill_md_file(filepath: Path) -> tuple[dict[str, str], list[str]]: + """Validate a Claude Code-style SKILL.md file with YAML frontmatter. + + Returns (metadata, errors) so callers can cross-check against skill.toml. + """ + meta, errors = parse_skill_md_frontmatter(filepath) + rel = filepath.relative_to(filepath.parent.parent) + + if errors: + return meta, errors + if not meta.get("name"): errors.append(f"{rel}: Missing frontmatter field 'name'") if not meta.get("description"): errors.append(f"{rel}: Missing frontmatter field 'description'") - return errors + return meta, errors def validate_aliases_file(filepath: Path) -> list[str]: @@ -409,12 +424,27 @@ def main(): for d in skill_dirs: skill_toml = d / "skill.toml" skill_md = d / "SKILL.md" + + if not skill_md.exists(): + all_errors.append(f"skills/{d.name}: Missing SKILL.md (required entry point)") + continue + + md_meta, md_errors = validate_skill_md_file(skill_md) + all_errors.extend(md_errors) + if skill_toml.exists(): all_errors.extend(validate_skill_file(skill_toml)) - elif skill_md.exists(): - all_errors.extend(validate_skill_md_file(skill_md)) - else: - all_errors.append(f"skills/{d.name}: Missing skill.toml or SKILL.md") + toml_data, _ = load_toml(skill_toml) + if toml_data: + toml_skill = toml_data.get("skill", {}) or {} + for field in ("name", "description"): + md_val = md_meta.get(field) + toml_val = toml_skill.get(field) + if md_val and toml_val and md_val != toml_val: + all_errors.append( + f"skills/{d.name}: {field} mismatch between SKILL.md " + f"('{md_val}') and skill.toml ('{toml_val}')" + ) stats["skills"] = len(skill_dirs) # --- Plugins --- diff --git a/skills/README.md b/skills/README.md index 88013fb..58fec75 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1,29 +1,58 @@ # Skills -Reusable skill definitions for LibreFang agents. Skills are either prompt templates or code scripts that agents can invoke to perform specific tasks. +Reusable skill definitions for LibreFang agents. A skill is either a prompt +template or a code script that an agent can invoke to perform a specific task. -## Structure +## File Convention + +Every skill directory **must** contain a `SKILL.md` (the entry point). +A `skill.toml` is **optional** and only needed for structured metadata that +does not fit in Markdown frontmatter (runtime, input schema, version, tags). ``` skills/ +├── docker/ +│ └── SKILL.md # Prompt-only expert — no skill.toml needed ├── custom-skill-prompt/ -│ └── skill.toml # Prompt-only skill +│ ├── SKILL.md # Prompt body + name/description +│ └── skill.toml # Runtime + input schema └── custom-skill-python/ - ├── skill.toml # Skill manifest - └── main.py # Python implementation + ├── SKILL.md # Overview (prompt body unused) + ├── skill.toml # Runtime = python, entry = main.py + └── main.py ``` -## Skill Types +### `SKILL.md` (required) -### Prompt-Only +The source of truth for the skill's prompt and identity. Must start with YAML +frontmatter containing at least `name` and `description`: -No code needed -- pure prompt engineering: +```markdown +--- +name: docker +description: Docker expert for containers, Compose, and Dockerfiles. +--- + +You are a Docker specialist. You help users build, run, debug, and optimize +containers... +``` + +This format is compatible with Claude Code skills, so a `SKILL.md` authored +here can be dropped into other tools without modification. + +### `skill.toml` (optional) + +Add one only when you need to declare any of: + +- `[runtime]` — `promptonly` / `python` / `node` / `shell` (default is `promptonly`) +- `[input]` — typed input parameter schema +- `version`, `author`, `tags` — structured metadata ```toml [skill] name = "meeting-agenda" version = "0.1.0" -description = "Generate a structured meeting agenda" +description = "Generate a structured meeting agenda from a topic and duration." tags = ["meeting", "productivity"] [runtime] @@ -32,36 +61,14 @@ type = "promptonly" [input] topic = { type = "string", description = "The meeting topic", required = true } duration_minutes = { type = "string", description = "Duration in minutes", required = true } - -[prompt] -template = """ -Create a meeting agenda for: -Topic: {{topic}} -Duration: {{duration_minutes}} minutes -""" ``` -### Python +**Consistency rule:** if both files exist, `skill.name` and `skill.description` +in `skill.toml` must match the `name` and `description` in `SKILL.md`'s +frontmatter. The validator enforces this to prevent drift. -Custom logic with a Python entry point: - -```toml -[skill] -name = "my-skill" -version = "0.1.0" -description = "Skill with custom logic" - -[runtime] -type = "python" -entry = "main.py" - -[input] -data = { type = "string", description = "Input data", required = true } -``` - -### Other Runtimes - -Also supported: `node`, `shell`. +**Do not duplicate the prompt body in TOML.** The prompt lives in `SKILL.md`; +`skill.toml` is for metadata the prompt cannot express. ## Testing Skills Locally @@ -72,9 +79,10 @@ librefang skill test ./skills/custom-skill-prompt \ ## Adding a New Skill -1. Create `skills//skill.toml` -2. Add implementation files if not `promptonly` -3. Run `python scripts/validate.py` -4. Submit a PR +1. Create `skills//SKILL.md` with frontmatter (`name`, `description`) and the prompt body. +2. If you need `[runtime]`, `[input]`, or structured metadata, add `skills//skill.toml`. +3. Add implementation files (`main.py`, etc.) when `runtime` is not `promptonly`. +4. Run `python scripts/validate.py`. +5. Submit a PR. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full guide. diff --git a/skills/custom-skill-prompt/SKILL.md b/skills/custom-skill-prompt/SKILL.md new file mode 100644 index 0000000..a326bea --- /dev/null +++ b/skills/custom-skill-prompt/SKILL.md @@ -0,0 +1,16 @@ +--- +name: meeting-agenda +description: Generate a structured meeting agenda from a topic and duration. +--- + +Create a structured meeting agenda for the following: + +Topic: {{topic}} +Duration: {{duration_minutes}} minutes + +Requirements: +- Include time allocations for each section +- Start with a brief intro/alignment (2-3 min) +- End with action items and next steps (3-5 min) +- Keep sections focused and actionable +- Format as a numbered list with time in brackets diff --git a/skills/custom-skill-prompt/skill.toml b/skills/custom-skill-prompt/skill.toml index c29220b..775a377 100644 --- a/skills/custom-skill-prompt/skill.toml +++ b/skills/custom-skill-prompt/skill.toml @@ -1,9 +1,10 @@ ## Custom Prompt Skill Example ## -## This skill uses pure prompt engineering — no code required. -## It generates a meeting agenda from a topic and duration. +## The prompt body and metadata (name, description) live in SKILL.md. +## This skill.toml only declares the runtime, input schema, and optional +## metadata that don't fit in Markdown frontmatter. ## -## To test: librefang skill test ./examples/custom-skill-prompt \ +## To test: librefang skill test ./skills/custom-skill-prompt \ ## --input '{"topic": "Q1 planning", "duration_minutes": "30"}' ## ## For the full reference, see: docs/skill-development.md @@ -21,18 +22,3 @@ type = "promptonly" [input] topic = { type = "string", description = "The meeting topic", required = true } duration_minutes = { type = "string", description = "Meeting duration in minutes", required = true } - -[prompt] -template = """ -Create a structured meeting agenda for the following: - -Topic: {{topic}} -Duration: {{duration_minutes}} minutes - -Requirements: -- Include time allocations for each section -- Start with a brief intro/alignment (2-3 min) -- End with action items and next steps (3-5 min) -- Keep sections focused and actionable -- Format as a numbered list with time in brackets -""" diff --git a/skills/custom-skill-python/SKILL.md b/skills/custom-skill-python/SKILL.md new file mode 100644 index 0000000..6d4db59 --- /dev/null +++ b/skills/custom-skill-python/SKILL.md @@ -0,0 +1,11 @@ +--- +name: word-counter +description: Count words, sentences, and characters in text. +--- + +# Word Counter + +Counts words, sentences, and characters in the provided text. + +The prompt body is unused for this skill — execution is delegated to +`main.py` via the `python` runtime declared in `skill.toml`.