Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/.markdownlint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"MD009": false,
"MD013": false,
"MD022": false,
"MD026": false,
"MD029": false,
"MD031": false,
"MD032": false,
"MD033": false,
"MD034": false,
"MD040": false,
"MD041": false,
"MD060": false
}
29 changes: 29 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
commands:
- changed-files:
- any-glob-to-any-file: "commands/**"

skills:
- changed-files:
- any-glob-to-any-file: "skills/**"

agents:
- changed-files:
- any-glob-to-any-file: "agents/**"

docs:
- changed-files:
- any-glob-to-any-file:
- "README.md"
- "CLAUDE.md"
- "examples/**"

config:
- changed-files:
- any-glob-to-any-file:
- ".claude-plugin/**"
- ".mcp.json"
- ".github/**"

examples:
- changed-files:
- any-glob-to-any-file: "examples/**"
63 changes: 63 additions & 0 deletions .github/scripts/check-internal-links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Check that internal markdown links resolve to existing files."""

import re
import sys
from pathlib import Path

# Matches [text](path) and ![alt](path) — excludes URLs
LINK_RE = re.compile(r"!?\[([^\]]*)\]\(([^)]+)\)")


def check_file_links(file_path: Path, root: Path) -> list[str]:
errors = []
text = file_path.read_text()
file_dir = file_path.parent

for match in LINK_RE.finditer(text):
target = match.group(2)

# Skip external URLs
if target.startswith(("http://", "https://", "mailto:")):
continue

# Skip anchor-only links
if target.startswith("#"):
continue

# Strip anchor fragments from file paths
target_path = target.split("#")[0]
if not target_path:
continue

# Resolve relative to the file's directory
resolved = (file_dir / target_path).resolve()
if not resolved.exists():
rel = file_path.relative_to(root)
errors.append(f"{rel}: Broken link '{target}' — file not found")

return errors


def main():
root = Path(__file__).resolve().parent.parent.parent
errors = []

for md_file in sorted(root.rglob("*.md")):
# Skip .git and node_modules
parts = md_file.relative_to(root).parts
if any(p.startswith(".git") or p == "node_modules" for p in parts):
continue
errors.extend(check_file_links(md_file, root))

if errors:
print("Link check failed:")
for e in errors:
print(f" ✗ {e}")
sys.exit(1)
else:
print("✓ Link check passed")


if __name__ == "__main__":
main()
125 changes: 125 additions & 0 deletions .github/scripts/validate-frontmatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Validate YAML frontmatter in commands, skills, and agents."""

import re
import sys
from pathlib import Path
from typing import Optional

# PyYAML is not guaranteed on all runners, so parse simple YAML manually
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)

KNOWN_TOOLS = {"Bash", "Read", "Write", "Glob", "Grep", "mcp__postman__*"}


def parse_frontmatter(text: str) -> Optional[dict]:
match = FRONTMATTER_RE.match(text)
if not match:
return None
result = {}
for line in match.group(1).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if ":" in line:
key, _, value = line.partition(":")
key = key.strip()
value = value.strip().strip('"').strip("'")
result[key] = value
return result


def validate_commands(root: Path) -> list[str]:
errors = []
commands_dir = root / "commands"
if not commands_dir.is_dir():
return [f"{commands_dir}: Directory not found"]

for f in sorted(commands_dir.glob("*.md")):
text = f.read_text()
fm = parse_frontmatter(text)
if fm is None:
errors.append(f"{f.name}: Missing YAML frontmatter")
continue
if "description" not in fm or not fm["description"]:
errors.append(f"{f.name}: Missing required field 'description'")
if "allowed-tools" in fm and fm["allowed-tools"]:
tools = [t.strip() for t in fm["allowed-tools"].split(",")]
for tool in tools:
if tool not in KNOWN_TOOLS:
errors.append(f"{f.name}: Unknown tool '{tool}' in allowed-tools")

return errors


def validate_skills(root: Path) -> list[str]:
errors = []
skills_dir = root / "skills"
if not skills_dir.is_dir():
return [f"{skills_dir}: Directory not found"]

for skill_dir in sorted(skills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
errors.append(f"skills/{skill_dir.name}/: Missing SKILL.md")
continue
text = skill_file.read_text()
fm = parse_frontmatter(text)
if fm is None:
errors.append(f"skills/{skill_dir.name}/SKILL.md: Missing YAML frontmatter")
continue
if "name" not in fm or not fm["name"]:
errors.append(f"skills/{skill_dir.name}/SKILL.md: Missing required field 'name'")
if "description" not in fm or not fm["description"]:
errors.append(f"skills/{skill_dir.name}/SKILL.md: Missing required field 'description'")

return errors


def validate_agents(root: Path) -> list[str]:
errors = []
agents_dir = root / "agents"
if not agents_dir.is_dir():
return [f"{agents_dir}: Directory not found"]

required_fields = ["name", "description", "model", "allowed-tools"]

for f in sorted(agents_dir.glob("*.md")):
text = f.read_text()
fm = parse_frontmatter(text)
if fm is None:
errors.append(f"agents/{f.name}: Missing YAML frontmatter")
continue
for field in required_fields:
if field not in fm or not fm[field]:
errors.append(f"agents/{f.name}: Missing required field '{field}'")
if "allowed-tools" in fm and fm["allowed-tools"]:
tools = [t.strip() for t in fm["allowed-tools"].split(",")]
for tool in tools:
if tool not in KNOWN_TOOLS:
errors.append(f"agents/{f.name}: Unknown tool '{tool}' in allowed-tools")

return errors


def main():
root = Path(__file__).resolve().parent.parent.parent
errors = []

errors.extend(validate_commands(root))
errors.extend(validate_skills(root))
errors.extend(validate_agents(root))

if errors:
print("Frontmatter validation failed:")
for e in errors:
print(f" ✗ {e}")
sys.exit(1)
else:
print("✓ Frontmatter validation passed")


if __name__ == "__main__":
main()
80 changes: 80 additions & 0 deletions .github/scripts/validate-json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Validate JSON config files for the Claude Code plugin."""

import json
import sys
from pathlib import Path


def validate_plugin_json(path: Path) -> list[str]:
errors = []
try:
with open(path) as f:
data = json.load(f)
except json.JSONDecodeError as e:
return [f"{path}: Invalid JSON — {e}"]

required_fields = ["name", "version", "description"]
for field in required_fields:
if field not in data:
errors.append(f"{path}: Missing required field '{field}'")
elif not isinstance(data[field], str) or not data[field].strip():
errors.append(f"{path}: Field '{field}' must be a non-empty string")

if "version" in data and isinstance(data["version"], str):
parts = data["version"].split(".")
if len(parts) != 3 or not all(p.isdigit() for p in parts):
errors.append(f"{path}: Field 'version' must be semver (e.g. 1.0.0)")

return errors


def validate_mcp_json(path: Path) -> list[str]:
errors = []
try:
with open(path) as f:
data = json.load(f)
except json.JSONDecodeError as e:
return [f"{path}: Invalid JSON — {e}"]

if "mcpServers" not in data:
errors.append(f"{path}: Missing required key 'mcpServers'")
elif not isinstance(data["mcpServers"], dict):
errors.append(f"{path}: 'mcpServers' must be an object")
else:
for name, config in data["mcpServers"].items():
if "type" not in config:
errors.append(f"{path}: Server '{name}' missing 'type'")
if "url" not in config:
errors.append(f"{path}: Server '{name}' missing 'url'")

return errors


def main():
root = Path(__file__).resolve().parent.parent.parent
errors = []

plugin_json = root / ".claude-plugin" / "plugin.json"
if plugin_json.exists():
errors.extend(validate_plugin_json(plugin_json))
else:
errors.append(f"{plugin_json}: File not found")

mcp_json = root / ".mcp.json"
if mcp_json.exists():
errors.extend(validate_mcp_json(mcp_json))
else:
errors.append(f"{mcp_json}: File not found")

if errors:
print("JSON validation failed:")
for e in errors:
print(f" ✗ {e}")
sys.exit(1)
else:
print("✓ JSON validation passed")


if __name__ == "__main__":
main()
Loading
Loading