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
96 changes: 96 additions & 0 deletions .github/schemas/plugin-manifest.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Claude Code Plugin Manifest",
"description": "Schema for .claude-plugin/plugin.json files, based on https://code.claude.com/docs/en/plugins-reference",
"type": "object",
"required": ["name"],
"additionalProperties": false,
"$defs": {
"stringOrStringArray": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
]
},
"stringOrArrayOrObject": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } },
{ "type": "object" }
]
}
},
"properties": {
"name": {
"type": "string",
"description": "Unique plugin identifier (kebab-case, no spaces)"
},
"displayName": {
"type": "string",
"description": "Human-readable plugin name for marketplace display"
},
"version": {
"type": "string",
"description": "Semantic version (MAJOR.MINOR.PATCH)"
},
"description": {
"type": "string",
"description": "Brief explanation of plugin purpose"
},
"author": {
"type": "object",
"description": "Author information",
"properties": {
"name": { "type": "string" },
"email": { "type": "string" },
"url": { "type": "string" }
},
"additionalProperties": false
},
"homepage": {
"type": "string",
"description": "Documentation URL"
},
"repository": {
"type": "string",
"description": "Source code URL"
},
"license": {
"type": "string",
"description": "License identifier (e.g. MIT, Apache-2.0)"
},
"keywords": {
"type": "array",
"items": { "type": "string" },
"description": "Discovery tags"
},
"commands": {
"$ref": "#/$defs/stringOrStringArray",
"description": "Additional command files/directories (string or array of strings)"
},
"agents": {
"$ref": "#/$defs/stringOrStringArray",
"description": "Additional agent files/directories (string or array of strings)"
},
"skills": {
"$ref": "#/$defs/stringOrStringArray",
"description": "Additional skill directories (string or array of strings)"
},
"hooks": {
"$ref": "#/$defs/stringOrArrayOrObject",
"description": "Hook config paths or inline hook configuration (string, array, or object)"
},
"mcpServers": {
"$ref": "#/$defs/stringOrArrayOrObject",
"description": "MCP server config paths or inline configuration (string, array, or object)"
},
"outputStyles": {
"$ref": "#/$defs/stringOrStringArray",
"description": "Additional output style files/directories (string or array of strings)"
},
"lspServers": {
"$ref": "#/$defs/stringOrArrayOrObject",
"description": "LSP server config paths or inline configuration (string, array, or object)"
}
}
}
103 changes: 103 additions & 0 deletions .github/scripts/validate-frontmatter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Validate YAML frontmatter in markdown files and standalone YAML files under plugins/."""

import os
import subprocess
import sys
import tempfile

PLUGINS_DIR = "plugins"
YAMLLINT_CONFIG = ".yamllint.yml"


def extract_frontmatter(filepath: str) -> tuple[str | None, int]:
"""Extract YAML frontmatter from a markdown file.

Returns (frontmatter_content, start_line) or (None, 0) if no frontmatter.
"""
with open(filepath, "r") as f:
lines = f.readlines()

if not lines or lines[0].rstrip("\n") != "---":
return None, 0

end = None
for i in range(1, len(lines)):
if lines[i].rstrip("\n") == "---":
end = i
break

if end is None:
return None, 0

# Return frontmatter content (between the two --- delimiters)
return "".join(lines[1:end]), 1


def run_yamllint(content: str, original_file: str, line_offset: int) -> list[str]:
"""Run yamllint on content and return error messages with adjusted line numbers."""
errors = []
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as tmp:
tmp.write(content)
tmp_path = tmp.name

try:
result = subprocess.run(
["yamllint", "-c", YAMLLINT_CONFIG, tmp_path],
capture_output=True,
text=True,
)
if result.returncode != 0:
for line in result.stdout.strip().splitlines():
# yamllint output format: "file:line:col: [level] message"
if tmp_path in line:
# Replace temp path with original file path and adjust line number
rest = line.split(tmp_path, 1)[1]
if rest.startswith(":"):
parts = rest[1:].split(":", 2)
if len(parts) >= 2 and parts[0].strip().isdigit():
adjusted_line = int(parts[0].strip()) + line_offset
errors.append(
f"{original_file}:{adjusted_line}:{':'.join(parts[1:])}"
)
continue
errors.append(f"{original_file}{rest}")
finally:
os.unlink(tmp_path)

return errors


def main() -> int:
all_errors: list[str] = []

# Find all markdown files with frontmatter
for root, _dirs, files in os.walk(PLUGINS_DIR):
for filename in files:
filepath = os.path.join(root, filename)

if filename.endswith(".md"):
frontmatter, offset = extract_frontmatter(filepath)
if frontmatter is not None:
errors = run_yamllint(frontmatter, filepath, offset)
all_errors.extend(errors)

elif filename.endswith((".yml", ".yaml")):
with open(filepath) as f:
content = f.read()
errors = run_yamllint(content, filepath, 0)
all_errors.extend(errors)

if all_errors:
print("YAML validation errors found:\n")
for error in all_errors:
print(f" {error}")
print(f"\n{len(all_errors)} error(s) found.")
return 1

print("All YAML frontmatter and YAML files are valid.")
return 0


if __name__ == "__main__":
sys.exit(main())
173 changes: 173 additions & 0 deletions .github/scripts/validate-marketplace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Validate marketplace.json registry against the actual plugin directories."""

from __future__ import annotations

import json
import sys
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[2]
MARKETPLACE_JSON = REPO_ROOT / ".claude-plugin" / "marketplace.json"
PLUGINS_DIR = REPO_ROOT / "plugins"


def load_marketplace() -> dict | None:
"""Load and return the parsed marketplace.json, or None on parse error."""
with MARKETPLACE_JSON.open() as f:
try:
return json.load(f)
except json.JSONDecodeError as exc:
print(f"\n✗ Failed to parse marketplace.json: {exc}")
return None


def _resolve_source(plugin: dict) -> Path:
"""Resolve a plugin's source path to an absolute path under REPO_ROOT."""
return REPO_ROOT / plugin["source"].removeprefix("./")


def check_source_paths(plugins: list[dict]) -> list[str]:
"""Check that every plugin source path exists as a directory."""
errors: list[str] = []
for plugin in plugins:
name = plugin["name"]
source = _resolve_source(plugin)
if not source.is_dir():
errors.append(f" ✗ Plugin '{name}': source path does not exist: {plugin['source']}")
return errors


def check_duplicate_names(plugins: list[dict]) -> list[str]:
"""Check for duplicate plugin names in marketplace.json."""
errors: list[str] = []
seen: dict[str, int] = {}
for plugin in plugins:
name = plugin["name"]
seen[name] = seen.get(name, 0) + 1
for name, count in seen.items():
if count > 1:
errors.append(f" ✗ Plugin name '{name}' appears {count} times")
return errors


def check_orphan_directories(plugins: list[dict]) -> list[str]:
"""Find plugin directories not registered in marketplace.json."""
warnings: list[str] = []

# Resolve all registered source paths to absolute paths
registered: set[Path] = set()
for plugin in plugins:
registered.add(_resolve_source(plugin).resolve())

# Walk immediate children and known subdirectories of plugins/
plugin_dirs: list[Path] = []
if PLUGINS_DIR.is_dir():
for child in sorted(PLUGINS_DIR.iterdir()):
if not child.is_dir():
continue
# Some plugins live in subdirectories (e.g. plugins/commands/*, plugins/mcps/*)
has_plugin_json = (child / ".claude-plugin" / "plugin.json").exists()
has_subdirs = any(sub.is_dir() for sub in child.iterdir())
if has_plugin_json:
plugin_dirs.append(child)
elif has_subdirs:
# Check nested directories (e.g. plugins/commands/carta-devtools)
for sub in sorted(child.iterdir()):
if sub.is_dir() and (sub / ".claude-plugin" / "plugin.json").exists():
plugin_dirs.append(sub)
else:
plugin_dirs.append(child)

for d in plugin_dirs:
if d.resolve() not in registered:
rel = d.relative_to(REPO_ROOT)
warnings.append(f" ! Directory '{rel}' is not registered in marketplace.json")

return warnings


def check_required_files(plugins: list[dict]) -> list[str]:
"""Check that each registered plugin has the required plugin.json."""
errors: list[str] = []
for plugin in plugins:
name = plugin["name"]
source = _resolve_source(plugin)
manifest = source / ".claude-plugin" / "plugin.json"
if not manifest.is_file():
errors.append(f" ✗ Plugin '{name}': missing .claude-plugin/plugin.json")
return errors


def main() -> int:
print("=" * 60)
print("Marketplace Validation")
print("=" * 60)

if not MARKETPLACE_JSON.is_file():
print(f"\n✗ marketplace.json not found at {MARKETPLACE_JSON}")
return 1

data = load_marketplace()
if data is None:
return 1
plugins = data.get("plugins", [])

error_count = 0
warning_count = 0

# Check 1: Source paths exist
print("\n--- Check 1: Source paths exist ---")
errors = check_source_paths(plugins)
if errors:
for e in errors:
print(e)
error_count += len(errors)
else:
print(" ✓ All source paths exist")

# Check 2: No duplicate plugin names
print("\n--- Check 2: No duplicate plugin names ---")
errors = check_duplicate_names(plugins)
if errors:
for e in errors:
print(e)
error_count += len(errors)
else:
print(" ✓ All plugin names are unique")

# Check 3: Orphan plugin directories
print("\n--- Check 3: Orphan plugin directories ---")
warnings = check_orphan_directories(plugins)
if warnings:
for w in warnings:
print(w)
warning_count += len(warnings)
else:
print(" ✓ No orphan plugin directories found")

# Check 4: Required files
print("\n--- Check 4: Required files ---")
errors = check_required_files(plugins)
if errors:
for e in errors:
print(e)
error_count += len(errors)
else:
print(" ✓ All plugins have required files")

# Summary
print("\n" + "=" * 60)
print(f"Summary: {error_count} error(s), {warning_count} warning(s)")
if error_count:
print("FAILED")
else:
print("PASSED")
print("=" * 60)

return 1 if error_count else 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading