commit 561327d17054c83e9f42e6150f87079a9194d441 Author: Zhongwei Li Date: Sun Nov 30 08:42:18 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..8faad75 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "validate-plugin", + "description": "Automatically validates plugin files (marketplace.json, plugin.json, hooks.json) after edits using claude plugin validate", + "version": "1.0.0", + "author": { + "name": "Nathan Vale", + "email": "hi@nathanvale.com" + }, + "hooks": [ + "./hooks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..927b6a3 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# validate-plugin + +Automatically validates plugin files (marketplace.json, plugin.json, hooks.json) after edits using claude plugin validate diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..3a7e9bb --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Read|Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bun run ${CLAUDE_PLUGIN_ROOT}/hooks/validate-plugin.ts", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/hooks/validate-plugin.test.ts b/hooks/validate-plugin.test.ts new file mode 100644 index 0000000..10ad3ef --- /dev/null +++ b/hooks/validate-plugin.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from "bun:test"; +import { findPluginRoot, isPluginFile, processHook } from "./validate-plugin"; + +describe("isPluginFile", () => { + test("returns true for marketplace.json", () => { + expect(isPluginFile("marketplace.json")).toBe(true); + }); + + test("returns true for plugin.json", () => { + expect(isPluginFile("plugin.json")).toBe(true); + }); + + test("returns true for hooks.json", () => { + expect(isPluginFile("hooks.json")).toBe(true); + }); + + test("returns false for package.json", () => { + expect(isPluginFile("package.json")).toBe(false); + }); + + test("returns false for index.ts", () => { + expect(isPluginFile("index.ts")).toBe(false); + }); + + test("returns false for random.json", () => { + expect(isPluginFile("random.json")).toBe(false); + }); +}); + +describe("findPluginRoot", () => { + const MARKETPLACE_ROOT = "/Users/nathanvale/code/side-quest-marketplace"; + const GIT_PLUGIN_ROOT = `${MARKETPLACE_ROOT}/plugins/git`; + + test("finds root for file in .claude-plugin/", () => { + const result = findPluginRoot( + `${GIT_PLUGIN_ROOT}/.claude-plugin/plugin.json`, + ); + expect(result).toBe(GIT_PLUGIN_ROOT); + }); + + test("finds root for marketplace.json in .claude-plugin/", () => { + const result = findPluginRoot( + `${MARKETPLACE_ROOT}/.claude-plugin/marketplace.json`, + ); + expect(result).toBe(MARKETPLACE_ROOT); + }); + + test("finds root for hooks.json in hooks/ subdirectory", () => { + const result = findPluginRoot(`${GIT_PLUGIN_ROOT}/hooks/hooks.json`); + expect(result).toBe(GIT_PLUGIN_ROOT); + }); + + test("returns null for file with no plugin root", () => { + const result = findPluginRoot("/tmp/some/random/hooks.json"); + expect(result).toBeNull(); + }); + + test("returns null for non-plugin file", () => { + // findPluginRoot still searches for plugin root even for non-plugin filenames + // The isPluginFile check happens before calling findPluginRoot + const result = findPluginRoot(`${GIT_PLUGIN_ROOT}/package.json`); + expect(result).toBeNull(); + }); +}); + +describe("processHook", () => { + const MARKETPLACE_ROOT = "/Users/nathanvale/code/side-quest-marketplace"; + const GIT_PLUGIN_ROOT = `${MARKETPLACE_ROOT}/plugins/git`; + + test("passes through when no file_path", async () => { + const result = await processHook({}); + expect(result.status).toBe("pass"); + }); + + test("passes through when tool_input is empty", async () => { + const result = await processHook({ tool_input: {} }); + expect(result.status).toBe("pass"); + }); + + test("passes through for non-plugin files", async () => { + const result = await processHook({ + tool_input: { file_path: `${MARKETPLACE_ROOT}/package.json` }, + }); + expect(result.status).toBe("pass"); + }); + + test("passes through for TypeScript files", async () => { + const result = await processHook({ + tool_input: { + file_path: `${GIT_PLUGIN_ROOT}/hooks/git-context-loader.ts`, + }, + }); + expect(result.status).toBe("pass"); + }); + + test("validates and passes for valid marketplace.json", async () => { + const result = await processHook({ + tool_input: { + file_path: `${MARKETPLACE_ROOT}/.claude-plugin/marketplace.json`, + }, + }); + expect(result.status).toBe("pass"); + }); + + test("validates and passes for valid plugin.json", async () => { + const result = await processHook({ + tool_input: { + file_path: `${GIT_PLUGIN_ROOT}/.claude-plugin/plugin.json`, + }, + }); + expect(result.status).toBe("pass"); + }); + + test("validates and passes for valid hooks.json", async () => { + const result = await processHook({ + tool_input: { file_path: `${GIT_PLUGIN_ROOT}/hooks/hooks.json` }, + }); + expect(result.status).toBe("pass"); + }); + + test("fails for invalid plugin.json", async () => { + // Create a temporary invalid plugin + const tempDir = "/tmp/test-invalid-plugin"; + await Bun.$`mkdir -p ${tempDir}/.claude-plugin`; + await Bun.write( + `${tempDir}/.claude-plugin/plugin.json`, + '{"version": "1.0.0"}', + ); + + const result = await processHook({ + tool_input: { file_path: `${tempDir}/.claude-plugin/plugin.json` }, + }); + + expect(result.status).toBe("fail"); + expect(result.message).toContain("name"); + expect(result.message).toContain("Required"); + + // Cleanup + await Bun.$`rm -rf ${tempDir}`; + }); + + test("fails for invalid marketplace.json", async () => { + // Create a temporary invalid marketplace + const tempDir = "/tmp/test-invalid-marketplace"; + await Bun.$`mkdir -p ${tempDir}/.claude-plugin`; + await Bun.write( + `${tempDir}/.claude-plugin/marketplace.json`, + '{"name": "test", "plugins": []}', + ); + + const result = await processHook({ + tool_input: { file_path: `${tempDir}/.claude-plugin/marketplace.json` }, + }); + + expect(result.status).toBe("fail"); + expect(result.message).toContain("owner"); + expect(result.message).toContain("Required"); + + // Cleanup + await Bun.$`rm -rf ${tempDir}`; + }); + + test("passes through for non-existent file", async () => { + const result = await processHook({ + tool_input: { file_path: "/tmp/does-not-exist/plugin.json" }, + }); + expect(result.status).toBe("pass"); + }); + + test("passes through when plugin root not found", async () => { + // Create a hooks.json file without a .claude-plugin parent + const tempDir = "/tmp/test-no-plugin-root"; + await Bun.$`mkdir -p ${tempDir}/hooks`; + await Bun.write(`${tempDir}/hooks/hooks.json`, "{}"); + + const result = await processHook({ + tool_input: { file_path: `${tempDir}/hooks/hooks.json` }, + }); + + expect(result.status).toBe("pass"); + + // Cleanup + await Bun.$`rm -rf ${tempDir}`; + }); + + test("fails with warnings for plugin missing optional fields", async () => { + // Create a valid plugin with only required fields (missing version, description, author) + const tempDir = "/tmp/test-plugin-with-warnings"; + await Bun.$`mkdir -p ${tempDir}/.claude-plugin`; + await Bun.write( + `${tempDir}/.claude-plugin/plugin.json`, + '{"name": "test-plugin"}', + ); + + const result = await processHook({ + tool_input: { file_path: `${tempDir}/.claude-plugin/plugin.json` }, + }); + + // Warnings cause fail so user sees the message (Claude Code ignores messages on pass) + expect(result.status).toBe("fail"); + expect(result.message).toContain("warning"); + expect(result.message).toContain("version"); + + // Cleanup + await Bun.$`rm -rf ${tempDir}`; + }); +}); diff --git a/hooks/validate-plugin.ts b/hooks/validate-plugin.ts new file mode 100755 index 0000000..4c1109d --- /dev/null +++ b/hooks/validate-plugin.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env bun + +/** + * PostToolUse hook to validate plugin files after Edit/Write operations. + * Validates: marketplace.json, plugin.json, hooks.json + * + * Input: JSON via stdin with tool_input.file_path + * Output: JSON with pass/fail status + */ + +import { existsSync } from "node:fs"; +import { basename, dirname } from "node:path"; +import { spawn } from "bun"; + +// --- Types --- + +export interface HookInput { + tool_input?: { + file_path?: string; + }; +} + +export interface HookResult { + status: "pass" | "fail"; + message?: string; +} + +// --- Constants --- + +const PLUGIN_FILES = new Set(["marketplace.json", "plugin.json", "hooks.json"]); + +// --- Exported Functions (for testing) --- + +/** + * Check if a filename is a plugin-related file that should be validated + */ +export function isPluginFile(filename: string): boolean { + return PLUGIN_FILES.has(filename); +} + +/** + * Find the plugin root directory by walking up from the file path + * Returns the directory containing .claude-plugin/ + */ +export function findPluginRoot(filePath: string): string | null { + const filename = basename(filePath); + const dir = dirname(filePath); + + // If file is inside .claude-plugin/, the parent is the plugin root + if (basename(dir) === ".claude-plugin") { + return dirname(dir); + } + + // For hooks.json or plugin.json outside .claude-plugin, walk up to find it + if (filename === "plugin.json" || filename === "hooks.json") { + let searchDir = dir; + while (searchDir !== "/") { + if (existsSync(`${searchDir}/.claude-plugin`)) { + return searchDir; + } + searchDir = dirname(searchDir); + } + } + + return null; +} + +/** + * Run claude plugin validate on a directory + * Returns the validation output, whether it passed, and whether there are warnings + */ +export async function runValidation( + pluginRoot: string, +): Promise<{ passed: boolean; hasWarnings: boolean; output: string }> { + const proc = spawn({ + cmd: ["claude", "plugin", "validate", pluginRoot], + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const output = `${stdout}${stderr}`.trim(); + + // Check if validation passed (exit code 0 or output contains "Validation passed") + const passed = exitCode === 0 || output.includes("Validation passed"); + // Check if there are warnings + const hasWarnings = output.includes("warning"); + + return { passed, hasWarnings, output }; +} + +/** + * Process the hook input and return the result + */ +export async function processHook(input: HookInput): Promise { + const filePath = input.tool_input?.file_path; + + // No file path - pass through + if (!filePath) { + return { status: "pass" }; + } + + const filename = basename(filePath); + + // Not a plugin file - pass through + if (!isPluginFile(filename)) { + return { status: "pass" }; + } + + // File doesn't exist (might have been deleted) - pass through + if (!existsSync(filePath)) { + return { status: "pass" }; + } + + // Find the plugin root + const pluginRoot = findPluginRoot(filePath); + + // Couldn't find plugin root - pass through + if (!pluginRoot) { + return { status: "pass" }; + } + + // Run validation + const { passed, hasWarnings, output } = await runValidation(pluginRoot); + + if (passed) { + // Fail on warnings to ensure user sees them (Claude Code ignores messages on pass) + if (hasWarnings) { + return { + status: "fail", + message: `Plugin validation has warnings:\n\n${output}\n\nFix warnings or ignore to continue.`, + }; + } + return { status: "pass" }; + } + + return { + status: "fail", + message: `Plugin validation failed:\n\n${output}\n\nPlease fix the issues before continuing.`, + }; +} + +// --- Main --- + +async function main() { + try { + // Read input from stdin + const inputText = await Bun.stdin.text(); + const input: HookInput = inputText ? JSON.parse(inputText) : {}; + + // Process and output result + const result = await processHook(input); + console.log(JSON.stringify(result)); + } catch (_error) { + // On any error, pass through to avoid blocking the user + console.log(JSON.stringify({ status: "pass" })); + } +} + +// Only run main when executed directly, not when imported for tests +if (import.meta.main) { + main(); +} diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..baa3195 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,53 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:nathanvale/side-quest-marketplace:plugins/validate-plugin", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "e7fa5d2e8084ac3f1b72f4ef6ea0b93751313216", + "treeHash": "c503114abaa54861238ba06dbe37c462201324bcebe39ddec38e8fbc5a31935a", + "generatedAt": "2025-11-28T10:27:15.001360Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "validate-plugin", + "description": "Automatically validates plugin files (marketplace.json, plugin.json, hooks.json) after edits using claude plugin validate", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "cbe920bdc8193c83263af70065622dbee107a42b1920f6d13dd4e41a8665f2dd" + }, + { + "path": "hooks/validate-plugin.test.ts", + "sha256": "d1259c871df5b9478bece13569e8568e15be52cde2cd75d7b47f49bb3692e610" + }, + { + "path": "hooks/validate-plugin.ts", + "sha256": "13f6d805a2839252c097921291ab7fa69cc6e18d342064968e17a97e6e053cd1" + }, + { + "path": "hooks/hooks.json", + "sha256": "b617c73659ec5da348fe13ff55f66d3402b71a0fd82f95a987db26088f80be31" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "7fe13d8d44a53c2fd8394002ae543b532165f8cfb1353621cde6b010969ede64" + } + ], + "dirSha256": "c503114abaa54861238ba06dbe37c462201324bcebe39ddec38e8fbc5a31935a" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file