diff --git a/.github/schemas/plugin-manifest.schema.json b/.github/schemas/plugin-manifest.schema.json new file mode 100644 index 0000000..16d74fa --- /dev/null +++ b/.github/schemas/plugin-manifest.schema.json @@ -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)" + } + } +} diff --git a/.github/scripts/validate-frontmatter.py b/.github/scripts/validate-frontmatter.py new file mode 100644 index 0000000..16db874 --- /dev/null +++ b/.github/scripts/validate-frontmatter.py @@ -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()) diff --git a/.github/scripts/validate-marketplace.py b/.github/scripts/validate-marketplace.py new file mode 100644 index 0000000..aa12918 --- /dev/null +++ b/.github/scripts/validate-marketplace.py @@ -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()) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..243237b --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: Validate Plugins +on: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check for plugin changes + id: filter + run: | + CHANGED=$(gh pr diff ${{ github.event.pull_request.number }} --name-only | grep -c '^plugins/\|^\.claude-plugin/marketplace\.json\|^\.github/schemas/\|^\.github/scripts/\|^\.github/workflows/validate\.yml' || true) + echo "has_plugin_changes=$([ "$CHANGED" -gt 0 ] && echo true || echo false)" >> "$GITHUB_OUTPUT" + env: + GH_TOKEN: ${{ github.token }} + - uses: actions/setup-python@v5 + if: steps.filter.outputs.has_plugin_changes == 'true' + with: + python-version: '3.12' + - run: pip install yamllint check-jsonschema + if: steps.filter.outputs.has_plugin_changes == 'true' + - name: Validate command frontmatter + if: steps.filter.outputs.has_plugin_changes == 'true' + run: python .github/scripts/validate-frontmatter.py + - name: Validate plugin manifests + if: steps.filter.outputs.has_plugin_changes == 'true' + run: | + manifests=$(find plugins -name "plugin.json" -path "*/.claude-plugin/*") + if [ -z "$manifests" ]; then + echo "ERROR: No plugin manifests found. Check that the script is run from the repo root." + exit 1 + fi + echo "$manifests" | xargs check-jsonschema --schemafile .github/schemas/plugin-manifest.schema.json + - name: Validate marketplace registry + if: steps.filter.outputs.has_plugin_changes == 'true' + run: python .github/scripts/validate-marketplace.py diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..3312f4f --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,7 @@ +extends: default +rules: + line-length: disable + document-start: disable + truthy: disable + comments-indentation: disable + trailing-spaces: disable