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:
1 parent
adf2323330
commit
6c0faf06ef
5 files changed
+122
-71
No files matched your search
+43
-13
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
"""
|
|
||||||
@@ -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`.
|
||||||
Reference in new issue
Block a user