Initial commit
This commit is contained in:
12
.claude-plugin/plugin.json
Normal file
12
.claude-plugin/plugin.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# validate-plugin
|
||||||
|
|
||||||
|
Automatically validates plugin files (marketplace.json, plugin.json, hooks.json) after edits using claude plugin validate
|
||||||
16
hooks/hooks.json
Normal file
16
hooks/hooks.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
207
hooks/validate-plugin.test.ts
Normal file
207
hooks/validate-plugin.test.ts
Normal file
@@ -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}`;
|
||||||
|
});
|
||||||
|
});
|
||||||
165
hooks/validate-plugin.ts
Executable file
165
hooks/validate-plugin.ts
Executable file
@@ -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<HookResult> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
53
plugin.lock.json
Normal file
53
plugin.lock.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user