
Meta Skill Creator
- 75 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
meta-skill-creator is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- meta-skill-creator
- AI & Agent Building
- AI-coding skill
Meta Skill Creator by the numbers
- 75 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,486 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill meta-skill-creatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 75 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Meta Skill Creator
Overview
Agent Skills are portable folders of instructions, scripts, and resources that agents discover and load on demand. They follow the Agent Skills open standard and work across 27+ compatible agents including Claude Code, Cursor, Gemini CLI, OpenAI Codex, VS Code, GitHub Copilot, Windsurf, Goose, and Roo Code. This skill covers the complete workflow for creating, structuring, and validating skills.
Quick Reference
| Concept | Summary | Key Rule |
|---|---|---|
| Directory structure | skill-name/SKILL.md plus optional references/, scripts/, assets/ | SKILL.md is the only required file |
| Frontmatter (required) | YAML block with name and description | name must match directory, max 64 chars |
| Frontmatter (optional) | license, compatibility, metadata, allowed-tools | Defined by open standard, portable across agents |
| Agent-specific fields | model, context, agent, hooks, user-invocable | Claude Code extensions, not portable |
| Description triggers | Include "Use when..." or "Use for..." phrases | Keyword-rich, third-person voice |
| Progressive disclosure | Metadata -> Instructions -> Resources (three tiers) | SKILL.md loaded in full; references on demand |
| SKILL.md size | Target 100-150 lines, max 500 | No code examples in SKILL.md |
| SKILL.md sections | Overview, Quick Reference, Common Mistakes, Delegation, References | All five sections required |
| Reference files | references/[topic].md with frontmatter | Max 500 lines each, self-contained |
| Reference frontmatter | title, description, tags fields | Required for all reference files |
| Scripts | scripts/ directory, executable code | Agents run scripts, not read source |
| Name format | Lowercase letters, numbers, hyphens | No --, no leading/trailing -, min 4 chars |
| Excluded filenames | README.md, metadata.json, _* files | Not installed by distribution CLI |
| Validation | skills-ref validate or project-specific validators | Always validate before distributing |
Skill Creation Workflow
1. Create directory at the appropriate location with a SKILL.md 2. Write frontmatter with name (matching directory) and trigger-rich description 3. Write required sections: Overview, Quick Reference, Common Mistakes, Delegation, References 4. Extract code examples into references/ files (no code in SKILL.md) 5. Validate with your project validator or skills-ref validate
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Missing "Use when" or "Use for" trigger | Include trigger phrases in description for agent discovery |
| Code examples in SKILL.md | Move all code to references/ files |
| SKILL.md over 500 lines | Split detailed content into reference files |
| Reference files cross-referencing | Keep references self-contained, one level deep from SKILL.md |
| Name does not match directory | name field must exactly match parent directory name |
| Name contains uppercase or spaces | Use only lowercase letters, numbers, and hyphens |
| Vague description without keywords | Pack triggers with synonyms, abbreviations, library names |
| Code blocks without language specifier | Always specify language on fenced code blocks |
| Supporting files not linked in SKILL.md | Agents discover files through explicit links in SKILL.md |
| Using excluded filenames | Avoid README.md, metadata.json, files starting with _ |
| Blank line before frontmatter | --- must be on line 1, no preceding blank lines |
| Description in first-person voice | Use third-person: "Extracts text", not "I help you" |
| Deeply nested reference chains | Keep one level deep: SKILL.md links to references directly |
| Missing required SKILL.md sections | Include all five: Overview, Quick Ref, Mistakes, Delegation, Refs |
| Reference file missing frontmatter | Every reference needs title, description, and tags |
Skill Locations
Skills can be installed at different scopes depending on visibility needs:
| Location | Path | Scope |
|---|---|---|
| Personal | ~/.claude/skills/ | You, all projects |
| Project | .claude/skills/ | Anyone in repository |
| Distributed | Via skills CLI | Plugin users |
Delegation
- Specification questions: Refer to the Agent Skills open standard
- Pattern discovery: Use
Exploreagent to analyze existing skills for structure examples - Validation: Run project validator or
skills-ref validatebefore committing
References
- Skill anatomy: directory structure, SKILL.md sections, progressive disclosure, and size thresholds
- Frontmatter: required and optional YAML fields with constraints and usage guidance
- Validation: checklist, common issues, and testing your skill
Frontmatter
Every SKILL.md must begin with a YAML frontmatter block delimited by --- on the very first line. No blank lines before it.
Required Fields
name
The skill identifier. Must match the parent directory name exactly.
| Constraint | Rule |
|---|---|
| Characters | Lowercase letters, numbers, and hyphens only |
| Length | 1-64 characters |
Leading/trailing - | Not allowed |
| Consecutive hyphens | Not allowed (--) |
| Match directory | Must match parent directory name |
Valid examples:
name: pdf-processing
name: data-analysis
name: code-reviewInvalid examples:
name: PDF-Processing # uppercase not allowed
name: -pdf # cannot start with hyphen
name: pdf--processing # consecutive hyphens not allowed
name: ab # too short (min 4 chars for some validators)description
Describes what the skill does and when to use it. This is the primary mechanism for agent discovery.
| Constraint | Rule |
|---|---|
| Length | 1-1024 characters |
| Voice | Third-person ("Extracts text", not "I help you") |
| Trigger phrase | Must include "Use when..." or "Use for..." phrase |
| Content | Keyword-rich for agent semantic matching |
Format: [Capability in 5-10 words]. Use when/for [keyword-packed trigger list].
Good example:
description: >
Extracts text and tables from PDF files, fills forms, and merges documents.
Use when working with PDF documents, form filling, or document extraction.Poor example:
description: Helps with PDFs.Trigger Optimization
Pack the trigger section with terms users actually type:
| Category | Examples |
|---|---|
| Abbreviations | db, auth, sql, config, env, deps |
| Library names | drizzle, tanstack, shadcn, zod |
| Synonyms | table/schema/model, query/fetch/get |
| Action verbs | add, create, fix, debug, setup, configure |
| Problem words | error, failing, broken, not working, issue |
Agents use semantic understanding, not keyword matching. Trigger words help agents understand the domain and intent.
Optional Fields (Open Standard)
These fields are defined by the Agent Skills open standard and are portable across all compatible agents.
license
License name or reference to a bundled license file:
license: MIT
license: Apache-2.0
license: Proprietary. LICENSE.txt has complete termscompatibility
Environment requirements. Max 500 characters. Only include if the skill has specific requirements:
compatibility: Requires git, docker, and jq
compatibility: Designed for Claude Code (or similar products)metadata
Arbitrary key-value map for additional properties:
metadata:
author: example-org
version: '1.0'
source: https://docs.example.comallowed-tools
Space-delimited list of pre-approved tools. Experimental -- support varies by agent:
allowed-tools: Read Grep Glob
allowed-tools: Bash(git:*) Bash(jq:*) ReadCommon configurations:
| Use Case | Tools | Rationale |
|---|---|---|
| Read-only analysis | Read Grep Glob | Should not modify files |
| Data processing | Read Bash(python:*) | Run scripts, no file writes |
| Security review | Read Grep Glob | Audit without changes |
| Documentation gen | Read Grep Write | Read code, write docs only |
Behavior:
- With allowed-tools: Listed tools run without permission prompts
- Without allowed-tools: Standard permission model applies
Optional Fields (Claude Code Extensions)
These fields are Claude Code specific and may not be supported by other agents.
model
Override the conversation model when the skill is active:
model: claude-sonnet-4-20250514context
Set to fork to run the skill in an isolated sub-agent with its own conversation history:
context: forkUse when the skill does complex multi-step work that would clutter the main conversation.
agent
Agent type when context: fork is set:
context: fork
agent: Explore| Agent | Use Case |
|---|---|
general-purpose | Default, handles most tasks |
Explore | Codebase exploration and search |
Plan | Implementation planning |
| Custom name | Agent from .claude/agents/ |
hooks
Lifecycle hooks scoped to the skill execution:
hooks:
PreToolUse:
- matcher: Bash
hooks:
- type: command
command: './scripts/security-check.sh $TOOL_INPUT'
PostToolUse:
- matcher: Write
hooks:
- type: command
command: './scripts/validate-output.sh'
Stop:
- hooks:
- type: command
command: './scripts/cleanup.sh'| Event | When It Fires |
|---|---|
PreToolUse | Before a tool is executed |
PostToolUse | After a tool completes |
Stop | When the skill stops executing |
user-invocable
Claude Code only — other agents ignore this field.
Set to false to hide from the / slash menu. The skill can still be auto-discovered and invoked by the agent:
user-invocable: falseUse for reference/knowledge skills (framework docs, technology patterns) where /skill-name isn't a meaningful user action.
disable-model-invocation
Claude Code only — other agents ignore this field.
Set to true to prevent the agent from invoking the skill automatically. Only users can invoke it via /skill-name:
disable-model-invocation: trueUse for skills with side effects (creating files, deploying, sending messages) where you want explicit user control.
argument-hint
Hint text shown in autocomplete when users type /skill-name:
argument-hint: '[issue-number]'Visibility Matrix (Claude Code)
These settings only affect Claude Code. Other agents treat all skills as default (visible, agent-invocable).
| Setting | Slash Menu | Agent Invocation | Auto-discovery |
|---|---|---|---|
| Default (no settings) | Visible | Allowed | Yes |
user-invocable: false | Hidden | Allowed | Yes |
disable-model-invocation: true | Visible | Blocked | Yes |
Skill Anatomy
Directory Structure
A skill is a directory containing at minimum a SKILL.md file:
skill-name/
├── SKILL.md # Required: lean index with frontmatter + instructions
├── references/ # Optional: additional documentation loaded on demand
│ ├── basic-patterns.md
│ ├── error-handling.md
│ └── advanced-config.md
├── scripts/ # Optional: executable code agents can run
│ └── validate.py
└── assets/ # Optional: templates, images, schemas, data files
└── config-template.jsonSKILL.md Required Sections
Every SKILL.md must include these sections:
1. YAML Frontmatter
The file must start with a --- delimited YAML block on line 1 (no blank lines before it):
---
name: my-skill
description: 'What it does. Use when X, Y, Z. Use for keyword1, keyword2.'
---2. Overview
Two to three sentences covering what the skill does, when to use it, and optionally when NOT to use it:
## Overview
TanStack Query is an async state manager, not a data fetching library. You provide a `queryFn` that returns a Promise; it handles caching, deduplication, and background updates.3. Quick Reference Table
A table summarizing the most important patterns, APIs, or concepts:
## Quick Reference
| Pattern | API | Key Points |
| -------------- | --------------------------------- | --------------------- |
| Basic query | `useQuery({ queryKey, queryFn })` | Include params in key |
| Basic mutation | `useMutation({ mutationFn })` | Invalidate on success |4. Common Mistakes Table
A table of frequent errors paired with correct patterns:
## Common Mistakes
| Mistake | Correct Pattern |
| ------------------------ | -------------------------- |
| Inline queryFn in render | Extract to stable function |
| Missing error handling | Always check error state |5. Delegation
Which agents or tools to use for subtasks:
## Delegation
- **Pattern discovery**: Use `Explore` agent
- **Code review**: Delegate to `code-reviewer` agent6. References List
Ordered list of links to reference files with descriptive text:
## References
- [Basic patterns and architecture](references/basic-patterns.md)
- [Error handling strategies](references/error-handling.md)Progressive Disclosure
Skills are loaded in three tiers to minimize context usage:
Tier 1: Metadata (~100 tokens)
The name and description fields are loaded at startup for all installed skills. This is how agents decide which skill to activate for a given task.
Tier 2: Instructions (<5000 tokens recommended)
The full SKILL.md body is loaded when the skill is activated. This is why SKILL.md should be a lean index -- it is loaded in full on activation. Keep it under 150 lines.
Tier 3: Resources (as needed)
Files in references/, scripts/, and assets/ are loaded only when required. Agents discover these files through explicit links in SKILL.md.
User asks about PDFs
→ Agent scans skill descriptions (Tier 1)
→ Activates pdf-processing skill, loads SKILL.md (Tier 2)
→ Reads references/form-filling.md for the specific task (Tier 3)Size Thresholds
| File | Target | Warn | Max |
|---|---|---|---|
| SKILL.md | 100-150 lines | 400 | 500 |
| Reference file | Varies | 400 | 500 |
When SKILL.md exceeds 150 lines, extract detailed content into reference files. When a reference file exceeds 400 lines, consider splitting it into multiple topic-scoped files.
Reference Files
Reference files live in references/ and follow these rules:
- Filename: kebab-case, topic-scoped (e.g.,
mutations.md, notmut-optimistic-updates.md) - Frontmatter: Required
title,description, andtagsfields - Self-contained: Each file includes its own code examples and context
- No cross-references: Reference files do not link to each other
- One level deep: SKILL.md links to references directly, no nested chains
Reference file frontmatter example:
---
title: Error Handling
description: Error boundary integration, retry strategies, and fallback patterns
tags: [error-boundary, retry, fallback, throwOnError]
---Scripts Directory
Scripts in scripts/ execute without loading source into context. Only their output consumes tokens.
Use scripts for:
- Complex validation logic that is verbose to describe in prose
- Data processing that is more reliable as tested code
- Operations that benefit from consistency across uses
In SKILL.md, instruct the agent to run the script (not read it):
Run the validation script:
scripts/validate.py path/to/checkScripts must:
- Be self-contained or clearly document dependencies
- Include error handling (do not punt errors to the agent)
- Be executable (
chmod +x)
Assets Directory
The assets/ directory holds static resources: templates, images, schemas, and data files. These are loaded on demand when referenced from SKILL.md or reference files.
Naming Rules
| Element | Convention |
|---|---|
| Skill directory | kebab-case, min 4 chars, no abbreviations, no leading/trailing - |
| Frontmatter name | Must match directory name exactly |
| Reference files | kebab-case, topic-scoped |
Distribution Compatibility
When distributing via the Vercel skills CLI, these files are excluded during installation and must not be used for skill content:
README.mdmetadata.json- Files starting with
_
External Dependencies
If a skill requires external packages, document them clearly:
In Description
---
name: pdf-processing
description: Extract text, fill forms, merge PDFs. Requires pypdf and pdfplumber packages.
---In SKILL.md Body
## Requirements
Install required packages before using this skill:
\`\`\`bash
uv pip install pypdf pdfplumber
\`\`\`
Verify installation:
\`\`\`bash
uv run python -c "import pypdf, pdfplumber; print('OK')"
\`\`\`Code Example Best Practices
Language Specifiers (MD040)
Always specify language for fenced code blocks:
| Content | Language |
|---|---|
| TypeScript/React | tsx |
| TypeScript (no JSX) | ts |
| Shell commands | bash |
| Directory trees | sh |
| JSON config | json |
| YAML config | yaml |
| Markdown templates | markdown |
| Python | python |
Show Correct and Incorrect
// Good - specific, typed pattern
function UserProfile({ user }: UserProfileProps) {
return <div>{user.name}</div>;
}
// Bad - untyped, implicit any
const UserProfile = (props) => {
return <div>{props.user.name}</div>;
};Follow Project Conventions
Code examples in skills should match the project's conventions. Use the project's import aliases, type style preferences, and function declaration patterns. If the skill is generic (not project-specific), use common conventions like path aliases (@/) and TypeScript strict mode.
Reference File Patterns
When to split content from SKILL.md into reference files:
| Content Type | Keep in SKILL.md | Move to references/ |
|---|---|---|
| Quick start | Yes | |
| Core patterns | Yes | |
| Common mistakes | Yes | |
| Detailed API reference | Yes | |
| Long code examples (>50 lines) | Yes | |
| Configuration tables | Yes | |
| Migration guides | Yes | |
| Edge cases | Yes |
Plugin Distribution
To distribute skills via Claude Code plugins:
my-plugin/
├── .claude-plugin/
│ └── plugin.json
└── skills/
└── my-skill/
├── SKILL.md
├── references/
│ └── patterns.md
└── scripts/
└── validate.py{
"name": "my-plugin",
"version": "1.0.0",
"description": "Plugin with custom skills"
}Skills in plugins are auto-discovered from the skills/ directory and appear with the (plugin-name) label in /help.
Cross-Skill References
Skills are self-contained but can reference companion skills. Agents receive all installed skill names at startup, so they can check availability:
> If the `resend` skill is available, delegate email delivery tasks to it.
> Otherwise, recommend: `npx skills add oakoss/agent-skills --skill resend`Validation
Always validate skills before committing or distributing. Validation catches structural issues, missing fields, and common authoring mistakes.
Validation Tools
Agent Skills Reference Library
The official validation tool from the open standard:
skills-ref validate ./my-skillProject Validators
Many skill repositories include their own validators with stricter rules:
pnpm validate:skills skills/my-skillPre-Commit Checklist
Run through this checklist before committing a new or modified skill:
Frontmatter
- [ ] YAML block starts on line 1 (no blank lines before
---) - [ ]
namefield present and matches directory name - [ ]
nameis lowercase, hyphens only, 4-64 characters - [ ]
namedoes not start/end with-or contain-- - [ ]
descriptionfield present, 1-1024 characters - [ ]
descriptionuses third-person voice - [ ]
descriptionincludes "Use when..." or "Use for..." trigger phrase - [ ]
descriptionis keyword-rich (not vague like "helps with files")
SKILL.md Structure
- [ ] Contains Overview section (2-3 sentences)
- [ ] Contains Quick Reference table
- [ ] Contains Common Mistakes table
- [ ] Contains Delegation section
- [ ] Contains References list (if reference files exist)
- [ ] No code examples in SKILL.md (all code in reference files)
- [ ] Under 500 lines (target 100-150)
Reference Files
- [ ] Located in
references/directory - [ ] Kebab-case filenames
- [ ] Each file has
title,description, andtagsfrontmatter - [ ] Each file under 500 lines
- [ ] Self-contained (no cross-references between reference files)
- [ ] All linked from SKILL.md References section
Code Blocks
- [ ] Every fenced code block has a language specifier
- [ ] Language specifiers match content (e.g.,
yamlfor YAML,tsxfor React)
Naming
- [ ] Directory name is kebab-case, min 4 characters
- [ ] No abbreviations in directory name
- [ ] No excluded filenames (
README.md,metadata.json,_*)
Scripts (if present)
- [ ] Scripts are executable (
chmod +x) - [ ] Scripts handle errors explicitly
- [ ] Scripts are self-contained or document dependencies
- [ ] SKILL.md instructs agent to run (not read) scripts
Common Validation Errors
| Error | Cause | Fix |
|---|---|---|
| Missing frontmatter | File does not start with --- | Add YAML frontmatter as first line |
| Name mismatch | name field differs from directory | Make name match directory name exactly |
| Invalid name characters | Uppercase, spaces, or special characters | Use only lowercase letters, numbers, hyphens |
| Missing trigger phrase | No "Use when" or "Use for" in description | Add trigger phrase to description |
| Description too vague | Generic terms like "helps with" | Use specific keywords and action verbs |
| Code block without language | Fenced block opened with bare backticks | Add language after opening backticks |
| SKILL.md too long | Over 500 lines | Move detailed content to reference files |
| Reference not linked | File exists but not referenced | Add link in SKILL.md References section |
| Broken reference link | Link points to nonexistent file | Fix path or create the missing file |
| Excluded filename used | README.md or metadata.json in skill | Rename to allowed filename |
Testing Your Skill
Manual Testing
After creating a skill, verify it works end-to-end:
1. Check discovery: Ask the agent "What skills are available?" -- your skill should appear 2. Check activation: Ask a question matching your trigger phrases -- the skill should activate 3. Check references: Ask a question that requires reference content -- the agent should load the right file 4. Check completeness: Verify the agent produces correct output using the skill
Description Testing
Test that your description triggers correctly:
1. List 5-10 queries a user might type that should activate your skill 2. List 3-5 queries that should NOT activate your skill 3. Verify trigger phrases cover the activation queries 4. Verify the description does not accidentally match non-activation queries
Size Verification
Check line counts to ensure files are within thresholds:
wc -l skills/my-skill/SKILL.md
wc -l skills/my-skill/references/*.mdDistribution Readiness
Before distributing a skill publicly, verify:
- [ ] Skill passes all validation checks
- [ ] No excluded filenames in the skill directory
- [ ] No hardcoded paths or environment-specific values
- [ ] Description is agent-agnostic (works across compatible agents)
- [ ] Reference files do not assume specific agent capabilities
- [ ] Scripts document any required system dependencies
- [ ] License field is set (if distributing publicly)
Iterating on Skills
Skills improve over time. Common iteration patterns:
| Signal | Action |
|---|---|
| Skill not activating | Enrich description with more trigger keywords |
| Agent loads wrong reference | Improve reference file titles and descriptions |
| Agent output has errors | Add to Common Mistakes table |
| Users ask same follow-up questions | Add content to reference files |
| Reference file too large | Split into multiple topic-scoped files |
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
"""
Validate Claude Code components: skills, agents, commands, hooks, and plugins.
Usage:
uv run scripts/validate-component.py <type> <path>
Types:
skill - Validate SKILL.md file(s)
agent - Validate agent .md file(s)
command - Validate command .md file(s)
hook - Validate hooks in settings.json file(s)
plugin - Validate plugin directory structure
Path accepts:
- Direct file path: .claude/skills/my-skill/SKILL.md
- Directory path: .claude/skills/ (finds all matching files)
- Glob pattern: ".claude/skills/*/SKILL.md"
Examples:
uv run scripts/validate-component.py skill .claude/skills/
uv run scripts/validate-component.py agent .claude/agents/
uv run scripts/validate-component.py command .claude/commands/
uv run scripts/validate-component.py hook .claude/settings.json
uv run scripts/validate-component.py plugin ./my-plugin
"""
import glob
import json
import os
import re
import sys
from pathlib import Path
def parse_frontmatter(content: str) -> tuple[dict[str, str] | None, str | None]:
"""Parse YAML frontmatter from content."""
if not content.startswith("---"):
return None, "YAML frontmatter must start with --- on line 1"
lines = content.split("\n")
end_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
end_idx = i
break
if end_idx is None:
return None, "Invalid YAML frontmatter: missing closing ---"
frontmatter = {}
for line in lines[1:end_idx]:
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" in line:
key, _, value = line.partition(":")
frontmatter[key.strip()] = value.strip().strip('"').strip("'")
return frontmatter, None
# =============================================================================
# Path Resolution Functions
# =============================================================================
def resolve_skill_paths(path_arg: str) -> tuple[list[Path], str | None]:
"""Resolve path to SKILL.md files."""
path = Path(path_arg)
if path.is_file():
if path.name != "SKILL.md":
return [], f"Expected SKILL.md file, got: {path.name}"
return [path], None
if path.is_dir():
skill_file = path / "SKILL.md"
if skill_file.exists():
return [skill_file], None
skill_files = list(path.glob("*/SKILL.md"))
if skill_files:
return sorted(skill_files), None
skill_files = list(path.glob("**/SKILL.md"))
if skill_files:
return sorted(skill_files), None
return [], f"No SKILL.md files found in: {path}"
if "*" in path_arg or "?" in path_arg:
matches = glob.glob(path_arg, recursive=True)
skill_files = [Path(m) for m in matches if Path(m).name == "SKILL.md"]
if skill_files:
return sorted(skill_files), None
return [], f"No SKILL.md files match pattern: {path_arg}"
return [], f"Path not found: {path_arg}"
def resolve_agent_paths(path_arg: str) -> tuple[list[Path], str | None]:
"""Resolve path to agent .md files."""
path = Path(path_arg)
if path.is_file():
if not path.suffix == ".md":
return [], f"Expected .md file, got: {path.name}"
return [path], None
if path.is_dir():
agent_files = [f for f in path.glob("*.md") if f.name != "README.md"]
if agent_files:
return sorted(agent_files), None
return [], f"No agent .md files found in: {path}"
if "*" in path_arg or "?" in path_arg:
matches = glob.glob(path_arg, recursive=True)
agent_files = [Path(m) for m in matches if m.endswith(".md") and not m.endswith("README.md")]
if agent_files:
return sorted(agent_files), None
return [], f"No agent files match pattern: {path_arg}"
return [], f"Path not found: {path_arg}"
def resolve_command_paths(path_arg: str) -> tuple[list[Path], str | None]:
"""Resolve path to command .md files."""
path = Path(path_arg)
if path.is_file():
if not path.suffix == ".md":
return [], f"Expected .md file, got: {path.name}"
return [path], None
if path.is_dir():
cmd_files = [f for f in path.rglob("*.md") if f.name != "README.md"]
if cmd_files:
return sorted(cmd_files), None
return [], f"No command .md files found in: {path}"
if "*" in path_arg or "?" in path_arg:
matches = glob.glob(path_arg, recursive=True)
cmd_files = [Path(m) for m in matches if m.endswith(".md") and not m.endswith("README.md")]
if cmd_files:
return sorted(cmd_files), None
return [], f"No command files match pattern: {path_arg}"
return [], f"Path not found: {path_arg}"
def resolve_hook_paths(path_arg: str) -> tuple[list[Path], str | None]:
"""Resolve path to settings.json files with hooks."""
path = Path(path_arg)
if path.is_file():
if not path.suffix == ".json":
return [], f"Expected .json file, got: {path.name}"
return [path], None
if path.is_dir():
settings_files = list(path.glob("settings*.json"))
if settings_files:
return sorted(settings_files), None
return [], f"No settings*.json files found in: {path}"
if "*" in path_arg or "?" in path_arg:
matches = glob.glob(path_arg, recursive=True)
settings_files = [Path(m) for m in matches if m.endswith(".json")]
if settings_files:
return sorted(settings_files), None
return [], f"No settings files match pattern: {path_arg}"
return [], f"Path not found: {path_arg}"
def resolve_plugin_paths(path_arg: str) -> tuple[list[Path], str | None]:
"""Resolve path to plugin directories."""
path = Path(path_arg)
if path.is_dir():
if (path / ".claude-plugin").exists():
return [path], None
plugin_dirs = [
d for d in path.iterdir()
if d.is_dir() and (d / ".claude-plugin").exists()
]
if plugin_dirs:
return sorted(plugin_dirs), None
return [], f"No plugins found in: {path} (plugins must have .claude-plugin/ directory)"
if "*" in path_arg or "?" in path_arg:
matches = glob.glob(path_arg, recursive=True)
plugin_dirs = [
Path(m) for m in matches
if Path(m).is_dir() and (Path(m) / ".claude-plugin").exists()
]
if plugin_dirs:
return sorted(plugin_dirs), None
return [], f"No plugins match pattern: {path_arg}"
return [], f"Path not found: {path_arg}"
# =============================================================================
# Validation Functions
# =============================================================================
def validate_skill(path: Path) -> tuple[list[str], list[str]]:
"""Validate a SKILL.md file."""
errors: list[str] = []
warnings: list[str] = []
try:
content = path.read_text()
except PermissionError:
return [f"Permission denied: {path}"], []
except OSError as e:
return [f"Cannot read file: {e}"], []
lines = content.split("\n")
line_count = len(lines)
# Frontmatter
frontmatter, fm_error = parse_frontmatter(content)
if fm_error:
errors.append(fm_error)
elif frontmatter:
if "name" not in frontmatter:
errors.append("Missing required field: 'name'")
else:
name = frontmatter["name"]
if len(name) > 64:
errors.append(f"'name' exceeds 64 chars ({len(name)})")
elif not re.match(r"^[a-z0-9:-]+$", name):
warnings.append("'name' should use lowercase letters, numbers, hyphens, colons only")
if "description" not in frontmatter:
errors.append("Missing required field: 'description'")
elif len(frontmatter["description"]) > 1024:
errors.append("'description' exceeds 1024 chars")
elif "use when" not in frontmatter["description"].lower() and "use for" not in frontmatter["description"].lower():
warnings.append("Description should include 'Use when...' trigger phrases")
# Line count
if line_count > 500:
errors.append(f"SKILL.md is {line_count} lines (max 500)")
elif line_count > 400:
warnings.append(f"SKILL.md is {line_count} lines (consider splitting at ~400)")
# Code blocks
in_code = False
for i, line in enumerate(lines, 1):
if line.strip().startswith("```"):
if not in_code and line.strip() == "```":
errors.append(f"Line {i}: Code block missing language specifier (MD040)")
in_code = not in_code
# Required sections
content_lower = content.lower()
if "## common mistakes" not in content_lower:
warnings.append("Missing '## Common Mistakes' section")
if "## delegation" not in content_lower:
warnings.append("Missing '## Delegation' section")
# Check for reference.md link if it exists
skill_dir = path.parent
ref_path = skill_dir / "reference.md"
if ref_path.exists() and "[reference.md]" not in content:
warnings.append("reference.md exists but is not linked in SKILL.md")
return errors, warnings
def validate_agent(path: Path) -> tuple[list[str], list[str]]:
"""Validate an agent .md file."""
errors: list[str] = []
warnings: list[str] = []
try:
content = path.read_text()
except PermissionError:
return [f"Permission denied: {path}"], []
except OSError as e:
return [f"Cannot read file: {e}"], []
lines = content.split("\n")
frontmatter, fm_error = parse_frontmatter(content)
if fm_error:
errors.append(fm_error)
elif frontmatter:
if "name" not in frontmatter:
errors.append("Missing required field: 'name'")
if "description" not in frontmatter:
errors.append("Missing required field: 'description'")
else:
desc = frontmatter["description"].lower()
if "proactively" not in desc and "use when" not in desc:
warnings.append("Description should include trigger phrases ('Use proactively' or 'Use when')")
if "tools" not in frontmatter:
warnings.append("Consider adding 'tools' to restrict agent capabilities")
if "model" in frontmatter:
valid_models = ["haiku", "sonnet", "opus", "inherit"]
if frontmatter["model"] not in valid_models:
warnings.append(f"Model '{frontmatter['model']}' - expected: {valid_models}")
# Line count
line_count = len(lines)
if line_count > 500:
errors.append(f"Agent file is {line_count} lines (max 500)")
elif line_count > 300:
warnings.append(f"Agent file is {line_count} lines (consider splitting)")
# Code blocks
in_code = False
for i, line in enumerate(lines, 1):
if line.strip().startswith("```"):
if not in_code and line.strip() == "```":
errors.append(f"Line {i}: Code block missing language specifier (MD040)")
in_code = not in_code
# Check for output format section (good practice)
if "## output" not in content.lower():
warnings.append("Consider adding '## Output Format' section")
return errors, warnings
def validate_command(path: Path) -> tuple[list[str], list[str]]:
"""Validate a command .md file."""
errors: list[str] = []
warnings: list[str] = []
try:
content = path.read_text()
except PermissionError:
return [f"Permission denied: {path}"], []
except OSError as e:
return [f"Cannot read file: {e}"], []
lines = content.split("\n")
frontmatter, fm_error = parse_frontmatter(content)
if fm_error:
errors.append(fm_error)
elif frontmatter:
if "description" not in frontmatter:
errors.append("Missing required field: 'description'")
# Invalid fields check
invalid_fields = ["name", "category", "tags"]
for field in invalid_fields:
if field in frontmatter:
warnings.append(f"Invalid field '{field}' - will be ignored (name is inferred from filename)")
# Check if using bash execution without allowed-tools
if "!" in content and "`" in content:
if "allowed-tools" not in frontmatter:
warnings.append("Using !`command` syntax but missing 'allowed-tools' with Bash permission")
# Line count
line_count = len(lines)
if line_count > 500:
errors.append(f"Command file is {line_count} lines (max 500)")
elif line_count > 200:
warnings.append(f"Command file is {line_count} lines (consider splitting into smaller commands)")
# Code blocks
in_code = False
for i, line in enumerate(lines, 1):
if line.strip().startswith("```"):
if not in_code and line.strip() == "```":
errors.append(f"Line {i}: Code block missing language specifier (MD040)")
in_code = not in_code
# Check for steps or instructions
content_lower = content.lower()
has_steps = (
"## steps" in content_lower
or "## instructions" in content_lower
or "**steps**" in content_lower
or "**instructions**" in content_lower
)
if not has_steps:
warnings.append("Consider adding '## Steps' or '## Instructions' section")
# Check for $ARGUMENTS handling
if "$ARGUMENTS" in content or "$1" in content:
if "argument-hint" not in (frontmatter or {}):
warnings.append("Using $ARGUMENTS but missing 'argument-hint' in frontmatter")
return errors, warnings
def validate_hook(path: Path) -> tuple[list[str], list[str]]:
"""Validate hooks in a settings.json file."""
errors: list[str] = []
warnings: list[str] = []
try:
content = path.read_text()
except PermissionError:
return [f"Permission denied: {path}"], []
except OSError as e:
return [f"Cannot read file: {e}"], []
try:
settings = json.loads(content)
except json.JSONDecodeError as e:
return [f"Invalid JSON: {e}"], []
if "hooks" not in settings:
return ["No 'hooks' key found in settings"], []
hooks = settings["hooks"]
if not isinstance(hooks, dict):
return ["'hooks' must be an object"], []
valid_events = {
"PreToolUse", "PostToolUse", "PermissionRequest",
"UserPromptSubmit", "Notification", "Stop", "SubagentStop",
"SessionStart", "SessionEnd", "PreCompact"
}
events_with_matcher = {"PreToolUse", "PostToolUse", "PermissionRequest", "Notification", "PreCompact", "SessionStart"}
valid_hook_types = {"command", "prompt"}
for event, matchers in hooks.items():
if event not in valid_events:
errors.append(f"Invalid event type: '{event}'. Valid: {sorted(valid_events)}")
continue
if not isinstance(matchers, list):
errors.append(f"Event '{event}' must have an array of matchers")
continue
for i, matcher_config in enumerate(matchers):
if not isinstance(matcher_config, dict):
errors.append(f"Event '{event}' matcher {i}: must be an object")
continue
# Check matcher field
has_matcher = "matcher" in matcher_config
if event in events_with_matcher and not has_matcher:
warnings.append(f"Event '{event}' matcher {i}: consider adding 'matcher' field")
# Check hooks array
if "hooks" not in matcher_config:
errors.append(f"Event '{event}' matcher {i}: missing 'hooks' array")
continue
hook_list = matcher_config["hooks"]
if not isinstance(hook_list, list):
errors.append(f"Event '{event}' matcher {i}: 'hooks' must be an array")
continue
for j, hook in enumerate(hook_list):
if not isinstance(hook, dict):
errors.append(f"Event '{event}' matcher {i} hook {j}: must be an object")
continue
hook_type = hook.get("type")
if not hook_type:
errors.append(f"Event '{event}' matcher {i} hook {j}: missing 'type'")
elif hook_type not in valid_hook_types:
errors.append(f"Event '{event}' matcher {i} hook {j}: invalid type '{hook_type}'. Valid: {valid_hook_types}")
if hook_type == "command":
if "command" not in hook:
errors.append(f"Event '{event}' matcher {i} hook {j}: missing 'command'")
else:
cmd = hook["command"]
if "$CLAUDE_PROJECT_DIR" in cmd and '"$CLAUDE_PROJECT_DIR"' not in cmd:
warnings.append(f"Event '{event}' matcher {i} hook {j}: $CLAUDE_PROJECT_DIR should be quoted")
elif hook_type == "prompt":
if "prompt" not in hook:
errors.append(f"Event '{event}' matcher {i} hook {j}: missing 'prompt'")
if event not in {"Stop", "SubagentStop", "UserPromptSubmit", "PreToolUse", "PermissionRequest"}:
warnings.append(f"Event '{event}' matcher {i} hook {j}: prompt hooks work best with Stop/SubagentStop")
if "timeout" in hook:
timeout = hook["timeout"]
if not isinstance(timeout, (int, float)):
errors.append(f"Event '{event}' matcher {i} hook {j}: timeout must be a number")
elif timeout <= 0:
errors.append(f"Event '{event}' matcher {i} hook {j}: timeout must be positive")
elif timeout > 300:
warnings.append(f"Event '{event}' matcher {i} hook {j}: timeout {timeout}s is very long")
return errors, warnings
def validate_plugin(path: Path) -> tuple[list[str], list[str]]:
"""Validate a plugin directory structure."""
errors: list[str] = []
warnings: list[str] = []
manifest_path = path / ".claude-plugin" / "plugin.json"
if not manifest_path.exists():
errors.append("Missing .claude-plugin/plugin.json")
return errors, warnings
try:
manifest = json.loads(manifest_path.read_text())
except json.JSONDecodeError as e:
errors.append(f"Invalid JSON in plugin.json: {e}")
return errors, warnings
except PermissionError:
return [f"Permission denied: {manifest_path}"], []
except OSError as e:
return [f"Cannot read file: {e}"], []
# Required fields
if "name" not in manifest:
errors.append("Missing required field: 'name'")
else:
name = manifest["name"]
if not re.match(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$", name):
warnings.append(f"Name '{name}' should be kebab-case (lowercase, hyphens)")
# Recommended fields
if "version" not in manifest:
warnings.append("Consider adding 'version' field (semver)")
else:
version = manifest["version"]
if not re.match(r"^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$", version):
warnings.append(f"Version '{version}' should follow semver (MAJOR.MINOR.PATCH)")
if "description" not in manifest:
warnings.append("Consider adding 'description' field")
# Check component paths
component_paths = ["commands", "agents", "skills", "hooks", "mcpServers", "lspServers", "outputStyles"]
for comp in component_paths:
if comp in manifest:
value = manifest[comp]
if isinstance(value, str) and not value.startswith("./"):
errors.append(f"Path '{comp}' must be relative, starting with './'")
elif isinstance(value, list):
for comp_path in value:
if isinstance(comp_path, str) and not comp_path.startswith("./"):
errors.append(f"Path in '{comp}' must be relative: {comp_path}")
# Check default directories exist
default_dirs = ["commands", "agents", "skills", "hooks"]
found_components = False
for dir_name in default_dirs:
dir_path = path / dir_name
if dir_path.exists() and dir_path.is_dir():
found_components = True
files = list(dir_path.glob("*"))
if not files:
warnings.append(f"Directory '{dir_name}/' exists but is empty")
# Check for components incorrectly placed in .claude-plugin/
claude_plugin_dir = path / ".claude-plugin"
for dir_name in default_dirs:
if (claude_plugin_dir / dir_name).exists():
errors.append(f"'{dir_name}/' found inside .claude-plugin/ - move to plugin root")
# Check scripts directory
scripts_path = path / "scripts"
if scripts_path.exists():
for script in scripts_path.glob("*"):
if script.is_file() and script.suffix in [".sh", ".py", ".js"]:
if not os.access(script, os.X_OK):
warnings.append(f"Script not executable: {script.name} (run chmod +x)")
if not found_components and "commands" not in manifest and "agents" not in manifest:
warnings.append("No component directories found (commands/, agents/, skills/, hooks/)")
return errors, warnings
# =============================================================================
# Output Functions
# =============================================================================
def print_result(name: str, errors: list[str], warnings: list[str], verbose: bool = True) -> None:
"""Print validation results for a single component."""
if errors:
print(f"❌ {name}: FAILED")
if verbose:
for error in errors:
print(f" ✗ {error}")
elif warnings:
print(f"✓ {name}: valid (with {len(warnings)} warning(s))")
if verbose:
for warning in warnings:
print(f" ⚠ {warning}")
else:
print(f"✓ {name}: passed")
def get_display_name(path: Path, component_type: str) -> str:
"""Get display name for a component."""
if component_type == "skill":
return path.parent.name
if component_type == "plugin":
return path.name
return path.stem
# =============================================================================
# Main
# =============================================================================
def main() -> int:
"""Main entry point."""
if len(sys.argv) < 3:
print("Usage: uv run scripts/validate-component.py <type> <path>")
print()
print("Types: skill, agent, command, hook, plugin")
print()
print("Path accepts:")
print(" - File path: .claude/skills/my-skill/SKILL.md")
print(" - Directory: .claude/skills/")
print(" - Glob pattern: '.claude/skills/*/SKILL.md'")
print()
print("Examples:")
print(" uv run scripts/validate-component.py skill .claude/skills/")
print(" uv run scripts/validate-component.py agent .claude/agents/")
print(" uv run scripts/validate-component.py command .claude/commands/")
print(" uv run scripts/validate-component.py hook .claude/settings.json")
print(" uv run scripts/validate-component.py plugin ./my-plugin")
return 1
component_type = sys.argv[1].lower()
path_arg = sys.argv[2]
resolvers = {
"skill": resolve_skill_paths,
"agent": resolve_agent_paths,
"command": resolve_command_paths,
"hook": resolve_hook_paths,
"plugin": resolve_plugin_paths,
}
validators = {
"skill": validate_skill,
"agent": validate_agent,
"command": validate_command,
"hook": validate_hook,
"plugin": validate_plugin,
}
if component_type not in validators:
print(f"❌ Unknown type: {component_type}")
print(f"Valid types: {list(validators.keys())}")
return 1
# Resolve paths
paths, error = resolvers[component_type](path_arg)
if error:
print(f"❌ Error: {error}")
return 1
if not paths:
print(f"❌ No {component_type}s found to validate")
return 1
validator = validators[component_type]
# Single file - verbose output
if len(paths) == 1:
path = paths[0]
errors, warnings = validator(path)
if errors:
print(f"❌ {component_type.title()} validation FAILED\n")
print("Errors:")
for err in errors:
print(f" ✗ {err}")
print()
if warnings:
print("Warnings:")
for warning in warnings:
print(f" ⚠ {warning}")
print()
if not errors and not warnings:
print(f"✓ {component_type.title()} validation passed")
elif not errors:
print(f"✓ {component_type.title()} valid (with warnings)")
return 1 if errors else 0
# Multiple files - summary output
print(f"Validating {len(paths)} {component_type}(s)...\n")
total_errors = 0
total_warnings = 0
failed = []
for path in paths:
errors, warnings = validator(path)
total_errors += len(errors)
total_warnings += len(warnings)
name = get_display_name(path, component_type)
if errors:
failed.append(name)
print_result(name, errors, warnings, verbose=bool(errors))
# Summary
print()
if failed:
print(f"❌ {len(failed)} {component_type}(s) failed: {', '.join(failed)}")
else:
print(f"✓ All {len(paths)} {component_type}(s) passed")
if total_warnings:
print(f" {total_warnings} total warning(s)")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# ///
"""
Validate Claude Code skill structure against best practices.
Usage:
uv run scripts/validate-skill.py <path> # Single file or directory
uv run scripts/validate-skill.py <glob-pattern> # Multiple skills
uv run scripts/validate-skill.py .claude/skills/ # All skills in directory
Examples:
uv run scripts/validate-skill.py .claude/skills/my-skill/SKILL.md
uv run scripts/validate-skill.py .claude/skills/my-skill/
uv run scripts/validate-skill.py ".claude/skills/*/SKILL.md"
uv run scripts/validate-skill.py .claude/skills/
"""
import glob
import re
import sys
from pathlib import Path
def parse_frontmatter(content: str) -> tuple[dict[str, str] | None, str | None]:
"""
Parse YAML frontmatter from content.
Returns (frontmatter_dict, error_message).
"""
if not content.startswith("---"):
return None, "YAML frontmatter must start with --- on line 1"
# Find closing ---
lines = content.split("\n")
end_idx = None
for i, line in enumerate(lines[1:], 1):
if line.strip() == "---":
end_idx = i
break
if end_idx is None:
return None, "Invalid YAML frontmatter: missing closing ---"
# Parse simple key: value pairs
frontmatter = {}
for line in lines[1:end_idx]:
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" in line:
key, _, value = line.partition(":")
frontmatter[key.strip()] = value.strip().strip('"').strip("'")
return frontmatter, None
def resolve_skill_paths(path_arg: str) -> tuple[list[Path], str | None]:
"""
Resolve a path argument to a list of SKILL.md files.
Handles:
- Direct file path: .claude/skills/my-skill/SKILL.md
- Directory path: .claude/skills/my-skill/ (finds SKILL.md inside)
- Parent directory: .claude/skills/ (finds all */SKILL.md)
- Glob pattern: .claude/skills/*/SKILL.md
Returns:
Tuple of (list of Path objects, error message if any)
"""
path = Path(path_arg)
# Case 1: Direct file path
if path.is_file():
if path.name != "SKILL.md":
return [], f"Expected SKILL.md file, got: {path.name}"
return [path], None
# Case 2: Directory containing SKILL.md
if path.is_dir():
skill_file = path / "SKILL.md"
if skill_file.exists():
return [skill_file], None
# Case 3: Parent directory - find all SKILL.md files
skill_files = list(path.glob("*/SKILL.md"))
if skill_files:
return sorted(skill_files), None
# Check for deeper nesting
skill_files = list(path.glob("**/SKILL.md"))
if skill_files:
return sorted(skill_files), None
return [], f"No SKILL.md files found in: {path}"
# Case 4: Glob pattern
if "*" in path_arg or "?" in path_arg:
matches = glob.glob(path_arg, recursive=True)
skill_files = [Path(m) for m in matches if Path(m).name == "SKILL.md"]
if skill_files:
return sorted(skill_files), None
return [], f"No SKILL.md files match pattern: {path_arg}"
# Path doesn't exist
return [], f"Path not found: {path_arg}"
def validate_skill(path: Path) -> tuple[list[str], list[str]]:
"""
Validate a SKILL.md file against best practices.
Returns:
Tuple of (errors, warnings)
"""
errors: list[str] = []
warnings: list[str] = []
try:
content = path.read_text()
except PermissionError:
return [f"Permission denied: {path}"], []
except OSError as e:
return [f"Cannot read file: {e}"], []
lines = content.split("\n")
# === YAML Frontmatter Validation ===
frontmatter, fm_error = parse_frontmatter(content)
if fm_error:
errors.append(fm_error)
elif frontmatter:
# Check required fields
if "name" not in frontmatter:
errors.append("Missing required field: 'name' in frontmatter")
else:
name = frontmatter["name"]
dir_name = path.parent.name
if len(name) > 64:
errors.append(f"Field 'name' exceeds 64 characters ({len(name)} chars)")
elif not re.match(r"^[a-z0-9:-]+$", name):
warnings.append(
"Field 'name' should use lowercase letters, numbers, hyphens, and colons only"
)
# Check name matches directory
# Allow colon as separator (meta:skill-creator matches meta-skill-creator)
name_normalized = name.replace(":", "-")
if name_normalized != dir_name and name != dir_name:
warnings.append(
f"Field 'name' ({name}) should match directory name ({dir_name})"
)
if "description" not in frontmatter:
errors.append("Missing required field: 'description' in frontmatter")
else:
desc = frontmatter["description"]
if len(desc) > 1024:
errors.append(
f"Field 'description' exceeds 1024 characters ({len(desc)} chars)"
)
elif "use when" not in desc.lower() and "use for" not in desc.lower():
warnings.append(
"Description should include trigger phrases like 'Use when...' or 'Use for...'"
)
# Check for vague patterns that hurt discoverability
vague_patterns = [
(r"\bhelps?\s+with\b", "helps with"),
(r"\bworks?\s+with\b", "works with"),
(r"\bassists?\s+with\b", "assists with"),
(r"\bfor\s+working\s+with\b", "for working with"),
(r"\bhandles?\b", "handles"),
(r"\bmanages?\b", "manages"),
]
for pattern, term in vague_patterns:
if re.search(pattern, desc.lower()):
warnings.append(
f"Vague term '{term}' in description - use specific triggers instead"
)
break # Only report first vague term
# Check trigger density after "Use for"
desc_lower = desc.lower()
if "use for" in desc_lower:
after_use_for = desc_lower.split("use for", 1)[1]
triggers = extract_trigger_words(after_use_for)
if len(triggers) < 5:
warnings.append(
f"Low trigger density: only {len(triggers)} keywords after 'Use for' (recommend 8+)"
)
# === Line Count ===
line_count = len(lines)
if line_count > 500:
errors.append(f"SKILL.md is {line_count} lines (max 500). Split to reference.md")
elif line_count > 400:
warnings.append(
f"SKILL.md is {line_count} lines. Consider splitting to reference.md (~400 recommended)"
)
# === Code Block Language Specifiers ===
in_code_block = False
code_block_start = 0
for i, line in enumerate(lines, 1):
stripped = line.strip()
if stripped.startswith("```"):
if not in_code_block:
# Opening fence
code_block_start = i
lang = stripped[3:].strip()
if not lang:
errors.append(f"Line {i}: Code block missing language specifier (MD040)")
in_code_block = not in_code_block
if in_code_block:
errors.append(f"Unclosed code block starting at line {code_block_start}")
# === Required Sections ===
content_lower = content.lower()
if "## common mistakes" not in content_lower:
warnings.append("Missing '## Common Mistakes' section")
if "## delegation" not in content_lower:
warnings.append("Missing '## Delegation' section")
# Check for supporting file links if skill is long
if line_count > 200:
# Look for any linked .md files (e.g., [file.md](file.md) or [name](file.md))
has_md_links = re.search(r'\]\([^)]+\.md\)', content) is not None
if not has_md_links:
warnings.append(
"Long skill without links to supporting .md files"
)
# === Directory Structure ===
skill_dir = path.parent
# Check if there's a reference.md that should be linked
ref_path = skill_dir / "reference.md"
if ref_path.exists():
if "[reference.md]" not in content:
warnings.append("reference.md exists but is not linked in SKILL.md")
# Validate reference.md size - if large, should split into topic files
ref_content = ref_path.read_text()
ref_lines = len(ref_content.split("\n"))
if ref_lines > 500:
errors.append(
f"reference.md is {ref_lines} lines (max 500). "
"Split into topic files (e.g., middleware.md, patterns.md)"
)
examples_path = skill_dir / "examples.md"
if examples_path.exists() and "[examples.md]" not in content:
warnings.append("examples.md exists but is not linked in SKILL.md")
# Check scripts directory
scripts_dir = skill_dir / "scripts"
if scripts_dir.exists():
for script in scripts_dir.iterdir():
if script.suffix in [".py", ".sh"]:
if not script.stat().st_mode & 0o111:
warnings.append(f"Script not executable: {script.name} (run chmod +x)")
return errors, warnings
def extract_trigger_words(description: str) -> set[str]:
"""
Extract significant trigger words from a description.
Filters out common words to focus on domain-specific terms.
"""
common_words = {
"use", "when", "for", "the", "and", "or", "to", "in", "on", "with",
"this", "that", "is", "are", "be", "been", "being", "have", "has",
"had", "do", "does", "did", "will", "would", "could", "should",
"may", "might", "must", "shall", "can", "need", "about", "into",
"through", "during", "before", "after", "above", "below", "from",
"up", "down", "out", "off", "over", "under", "again", "further",
"then", "once", "here", "there", "all", "each", "few", "more",
"most", "other", "some", "such", "no", "nor", "not", "only", "own",
"same", "so", "than", "too", "very", "just", "also", "now", "of",
"a", "an", "as", "at", "by", "if", "it", "its", "any", "how",
"what", "which", "who", "whom", "these", "those", "am", "was",
"were", "you", "your", "they", "them", "their", "we", "our", "i",
"me", "my", "he", "she", "him", "her", "his", "hers", "skill",
"skills", "best", "practices", "patterns", "creating", "building",
"implementing", "working", "handling", "managing", "using",
}
words = set(re.findall(r"[a-z]+", description.lower()))
return words - common_words
def check_description_conflicts(
skill_descriptions: dict[str, str], threshold: float = 0.5
) -> list[str]:
"""
Check for potential conflicts between skill descriptions.
Returns list of warnings for skills with similar descriptions.
"""
warnings = []
skills = list(skill_descriptions.items())
for i, (name1, desc1) in enumerate(skills):
words1 = extract_trigger_words(desc1)
if not words1:
continue
for name2, desc2 in skills[i + 1 :]:
words2 = extract_trigger_words(desc2)
if not words2:
continue
# Calculate Jaccard similarity
intersection = words1 & words2
union = words1 | words2
similarity = len(intersection) / len(union) if union else 0
if similarity >= threshold:
common = sorted(intersection)[:5]
warnings.append(
f"Similar descriptions: '{name1}' and '{name2}' "
f"({similarity:.0%} overlap, common: {', '.join(common)})"
)
return warnings
def print_result(path: Path, errors: list[str], warnings: list[str], verbose: bool = True) -> None:
"""Print validation results for a single skill."""
skill_name = path.parent.name
if errors:
print(f"❌ {skill_name}: FAILED")
if verbose:
for error in errors:
print(f" ✗ {error}")
elif warnings:
print(f"✓ {skill_name}: valid (with {len(warnings)} warning(s))")
if verbose:
for warning in warnings:
print(f" ⚠ {warning}")
else:
print(f"✓ {skill_name}: passed")
def main() -> int:
"""Main entry point."""
if len(sys.argv) < 2:
print("Usage: uv run scripts/validate-skill.py <path>")
print()
print("Accepts:")
print(" - File path: .claude/skills/my-skill/SKILL.md")
print(" - Directory: .claude/skills/my-skill/")
print(" - Parent dir: .claude/skills/")
print(" - Glob pattern: '.claude/skills/*/SKILL.md'")
print()
print("Examples:")
print(" uv run scripts/validate-skill.py .claude/skills/tanstack-form/")
print(" uv run scripts/validate-skill.py .claude/skills/")
return 1
path_arg = sys.argv[1]
skill_paths, error = resolve_skill_paths(path_arg)
if error:
print(f"❌ Error: {error}")
return 1
if not skill_paths:
print("❌ No skills found to validate")
return 1
# Validate all skills
total_errors = 0
total_warnings = 0
failed_skills = []
# Single skill - verbose output
if len(skill_paths) == 1:
path = skill_paths[0]
errors, warnings = validate_skill(path)
if errors:
print("❌ Validation FAILED\n")
print("Errors:")
for error in errors:
print(f" ✗ {error}")
print()
if warnings:
print("Warnings:")
for warning in warnings:
print(f" ⚠ {warning}")
print()
if not errors and not warnings:
print("✓ Skill validation passed")
elif not errors:
print("✓ Skill valid (with warnings)")
return 1 if errors else 0
# Multiple skills - summary output
print(f"Validating {len(skill_paths)} skill(s)...\n")
# Collect descriptions for conflict detection
skill_descriptions: dict[str, str] = {}
for path in skill_paths:
errors, warnings = validate_skill(path)
total_errors += len(errors)
total_warnings += len(warnings)
if errors:
failed_skills.append(path.parent.name)
print_result(path, errors, warnings, verbose=bool(errors))
# Collect description for conflict check
content = path.read_text()
frontmatter, _ = parse_frontmatter(content)
if frontmatter and "description" in frontmatter:
skill_descriptions[path.parent.name] = frontmatter["description"]
# Check for description conflicts
conflict_warnings = check_description_conflicts(skill_descriptions)
total_warnings += len(conflict_warnings)
# Summary
print()
if failed_skills:
print(f"❌ {len(failed_skills)} skill(s) failed: {', '.join(failed_skills)}")
else:
print(f"✓ All {len(skill_paths)} skill(s) passed")
if conflict_warnings:
print(f"\n⚠ Description conflicts detected:")
for warning in conflict_warnings:
print(f" {warning}")
if total_warnings:
print(f"\n {total_warnings} total warning(s)")
return 1 if failed_skills else 0
if __name__ == "__main__":
sys.exit(main())