refactor(skills): make SKILL.md the required entry point

Standardize on Claude Code's SKILL.md format as every skill's source of
truth. skill.toml becomes an optional metadata layer for runtime, input
schema, and versioning — never for the prompt body.

- validate.py: require SKILL.md in every skill dir; when skill.toml also
  exists, cross-check name/description consistency to prevent drift
- Add SKILL.md to the two custom-skill examples
- Move the meeting-agenda prompt body out of skill.toml into SKILL.md
- Rewrite skills/README.md to document the md-first, toml-as-metadata convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evan HuandClaude Opus 4.6 committed 2026-04-10 20:23:06 +09:00
1 parent adf2323330
commit 6c0faf06ef
5 files changed
+122 -71

No files matched your search

+43 -13
View File
@@ -238,24 +238,25 @@ def validate_skill_file(filepath: Path) -> list[str]:
return errors return errors
def validate_skill_md_file(filepath: Path) -> list[str]: def parse_skill_md_frontmatter(filepath: Path) -> tuple[dict[str, str], list[str]]:
"""Validate a Claude Code-style SKILL.md file with YAML frontmatter.""" """Parse YAML frontmatter from a SKILL.md file.
errors = []
Returns (metadata, errors). Metadata may be empty if parsing fails.
"""
errors: list[str] = []
rel = filepath.relative_to(filepath.parent.parent) rel = filepath.relative_to(filepath.parent.parent)
try: try:
text = filepath.read_text(encoding="utf-8") text = filepath.read_text(encoding="utf-8")
except OSError as e: except OSError as e:
return [f"{rel}: Failed to read file: {e}"] return {}, [f"{rel}: Failed to read file: {e}"]
if not text.startswith("---"): if not text.startswith("---"):
errors.append(f"{rel}: Missing YAML frontmatter (must start with '---')") return {}, [f"{rel}: Missing YAML frontmatter (must start with '---')"]
return errors
end = text.find("\n---", 3) end = text.find("\n---", 3)
if end == -1: if end == -1:
errors.append(f"{rel}: Unterminated YAML frontmatter (missing closing '---')") return {}, [f"{rel}: Unterminated YAML frontmatter (missing closing '---')"]
return errors
fm = text[3:end].strip() fm = text[3:end].strip()
meta: dict[str, str] = {} meta: dict[str, str] = {}
@@ -268,12 +269,26 @@ def validate_skill_md_file(filepath: Path) -> list[str]:
k, _, v = line.partition(":") k, _, v = line.partition(":")
meta[k.strip()] = v.strip().strip('"').strip("'") 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"): if not meta.get("name"):
errors.append(f"{rel}: Missing frontmatter field 'name'") errors.append(f"{rel}: Missing frontmatter field 'name'")
if not meta.get("description"): if not meta.get("description"):
errors.append(f"{rel}: Missing frontmatter field 'description'") errors.append(f"{rel}: Missing frontmatter field 'description'")
return errors return meta, errors
def validate_aliases_file(filepath: Path) -> list[str]: def validate_aliases_file(filepath: Path) -> list[str]:
@@ -409,12 +424,27 @@ def main():
for d in skill_dirs: for d in skill_dirs:
skill_toml = d / "skill.toml" skill_toml = d / "skill.toml"
skill_md = d / "SKILL.md" 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(): if skill_toml.exists():
all_errors.extend(validate_skill_file(skill_toml)) all_errors.extend(validate_skill_file(skill_toml))
elif skill_md.exists(): toml_data, _ = load_toml(skill_toml)
all_errors.extend(validate_skill_md_file(skill_md)) if toml_data:
else: toml_skill = toml_data.get("skill", {}) or {}
all_errors.append(f"skills/{d.name}: Missing skill.toml or SKILL.md") 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) stats["skills"] = len(skill_dirs)
# --- Plugins --- # --- Plugins ---
+48 -40
View File
@@ -1,29 +1,58 @@
# Skills # 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/ skills/
├── docker/
│ └── SKILL.md # Prompt-only expert — no skill.toml needed
├── custom-skill-prompt/ ├── custom-skill-prompt/
│ └── skill.toml # Prompt-only skill │ ├── SKILL.md # Prompt body + name/description
│ └── skill.toml # Runtime + input schema
└── custom-skill-python/ └── custom-skill-python/
├── skill.toml # Skill manifest ├── SKILL.md # Overview (prompt body unused)
└── main.py # Python implementation ├── 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 ```toml
[skill] [skill]
name = "meeting-agenda" name = "meeting-agenda"
version = "0.1.0" version = "0.1.0"
description = "Generate a structured meeting agenda" description = "Generate a structured meeting agenda from a topic and duration."
tags = ["meeting", "productivity"] tags = ["meeting", "productivity"]
[runtime] [runtime]
@@ -32,36 +61,14 @@ type = "promptonly"
[input] [input]
topic = { type = "string", description = "The meeting topic", required = true } topic = { type = "string", description = "The meeting topic", required = true }
duration_minutes = { type = "string", description = "Duration in minutes", 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: **Do not duplicate the prompt body in TOML.** The prompt lives in `SKILL.md`;
`skill.toml` is for metadata the prompt cannot express.
```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`.
## Testing Skills Locally ## Testing Skills Locally
@@ -72,9 +79,10 @@ librefang skill test ./skills/custom-skill-prompt \
## Adding a New Skill ## Adding a New Skill
1. Create `skills/<name>/skill.toml` 1. Create `skills/<name>/SKILL.md` with frontmatter (`name`, `description`) and the prompt body.
2. Add implementation files if not `promptonly` 2. If you need `[runtime]`, `[input]`, or structured metadata, add `skills/<name>/skill.toml`.
3. Run `python scripts/validate.py` 3. Add implementation files (`main.py`, etc.) when `runtime` is not `promptonly`.
4. Submit a PR 4. Run `python scripts/validate.py`.
5. Submit a PR.
See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full guide. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full guide.
+16
View File
@@ -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
+4 -18
View File
@@ -1,9 +1,10 @@
## Custom Prompt Skill Example ## Custom Prompt Skill Example
## ##
## This skill uses pure prompt engineering — no code required. ## The prompt body and metadata (name, description) live in SKILL.md.
## It generates a meeting agenda from a topic and duration. ## 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"}' ## --input '{"topic": "Q1 planning", "duration_minutes": "30"}'
## ##
## For the full reference, see: docs/skill-development.md ## For the full reference, see: docs/skill-development.md
@@ -21,18 +22,3 @@ type = "promptonly"
[input] [input]
topic = { type = "string", description = "The meeting topic", required = true } topic = { type = "string", description = "The meeting topic", required = true }
duration_minutes = { type = "string", description = "Meeting duration in minutes", 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
"""
+11
View File
@@ -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`.