📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Lint hooks.json files for correct structure.
|
||||
*
|
||||
* Validates:
|
||||
* - hooks.json has root-level "hooks" wrapper (required for Claude Code)
|
||||
* - plugin.json does NOT have "hooks" field (auto-discovery pattern)
|
||||
* - Event types are valid (PreToolUse, PostToolUse, etc.)
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint/claude-plugin/hooks.ts [path]
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/lint/claude-plugin/hooks.ts # Lint all plugins
|
||||
* bun scripts/lint/claude-plugin/hooks.ts outfitter/ # Lint specific plugin
|
||||
*/
|
||||
|
||||
import { Glob } from "bun";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* A hooks validation violation.
|
||||
*/
|
||||
export interface Violation {
|
||||
/** File path where violation was found */
|
||||
file: string;
|
||||
/** Severity of the issue */
|
||||
severity: "error" | "warning";
|
||||
/** Description of the issue */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of hooks validation.
|
||||
*/
|
||||
export interface LintResult {
|
||||
/** Whether validation passed (no errors) */
|
||||
passed: boolean;
|
||||
/** All violations found */
|
||||
violations: Violation[];
|
||||
}
|
||||
|
||||
/** Valid hook event types in Claude Code */
|
||||
const VALID_HOOK_EVENTS = new Set([
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
"SessionStart",
|
||||
"SessionEnd",
|
||||
"UserPromptSubmit",
|
||||
"PreCompact",
|
||||
"Notification",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate a hooks.json file structure.
|
||||
*/
|
||||
function validateHooksJson(filePath: string): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const json = JSON.parse(content);
|
||||
|
||||
// Check for root-level "hooks" wrapper (REQUIRED)
|
||||
if (!json.hooks) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message:
|
||||
'hooks.json must have root-level "hooks" wrapper: { "hooks": { "PreToolUse": [...] } }',
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Validate hook event types
|
||||
for (const eventType of Object.keys(json.hooks)) {
|
||||
if (!VALID_HOOK_EVENTS.has(eventType)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Invalid hook event type: "${eventType}". Valid types: ${[...VALID_HOOK_EVENTS].join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate hook array structure
|
||||
const hooks = json.hooks[eventType];
|
||||
if (!Array.isArray(hooks)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `"${eventType}" must be an array of hook configurations`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate each hook entry
|
||||
for (let i = 0; i < hooks.length; i++) {
|
||||
const hook = hooks[i];
|
||||
if (!hook.matcher && !hook.hooks) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: `${eventType}[${i}]: hook entry should have "matcher" and "hooks" fields`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a plugin.json file doesn't have hooks field.
|
||||
*/
|
||||
function validatePluginJson(filePath: string): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const json = JSON.parse(content);
|
||||
|
||||
// plugin.json should NOT have "hooks" field - hooks are auto-discovered
|
||||
if (json.hooks !== undefined) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message:
|
||||
'plugin.json should NOT have "hooks" field. Hooks are auto-discovered from hooks/hooks.json',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON parse errors are handled by other validators
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and validate all hooks-related files in a path.
|
||||
*/
|
||||
export async function lintHooks(searchPath: string): Promise<LintResult> {
|
||||
const violations: Violation[] = [];
|
||||
const resolvedPath = resolve(searchPath);
|
||||
|
||||
// Find all hooks.json files
|
||||
const hooksGlob = new Glob("**/hooks/hooks.json");
|
||||
for await (const file of hooksGlob.scan({
|
||||
cwd: resolvedPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
if (file.includes("node_modules") || file.includes(".git")) continue;
|
||||
violations.push(...validateHooksJson(file));
|
||||
}
|
||||
|
||||
// Find all plugin.json files and check they don't have hooks field
|
||||
const pluginGlob = new Glob("**/.claude-plugin/plugin.json");
|
||||
for await (const file of pluginGlob.scan({
|
||||
cwd: resolvedPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
if (file.includes("node_modules") || file.includes(".git")) continue;
|
||||
violations.push(...validatePluginJson(file));
|
||||
}
|
||||
|
||||
const hasErrors = violations.some((v) => v.severity === "error");
|
||||
|
||||
return {
|
||||
passed: !hasErrors,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format path relative to cwd for cleaner output.
|
||||
*/
|
||||
function relativePath(absolutePath: string): string {
|
||||
return absolutePath.replace(process.cwd() + "/", "");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const paths = args.filter((arg) => !arg.startsWith("--"));
|
||||
const searchPath = paths[0] || ".";
|
||||
|
||||
// Validate search path exists
|
||||
try {
|
||||
const stat = statSync(searchPath);
|
||||
if (!stat.isDirectory()) {
|
||||
console.error(`Error: ${searchPath} is not a directory`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.error(`Error: Path not found: ${searchPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nLinting hooks in ${relativePath(resolve(searchPath))}...\n`);
|
||||
|
||||
const result = await lintHooks(searchPath);
|
||||
|
||||
// Group violations by file
|
||||
const byFile = new Map<string, Violation[]>();
|
||||
for (const v of result.violations) {
|
||||
const existing = byFile.get(v.file) || [];
|
||||
existing.push(v);
|
||||
byFile.set(v.file, existing);
|
||||
}
|
||||
|
||||
// Output results
|
||||
for (const [file, fileViolations] of byFile) {
|
||||
console.log(`${relativePath(file)}:`);
|
||||
for (const v of fileViolations) {
|
||||
const prefix = v.severity === "error" ? "✗" : "△";
|
||||
console.log(` ${prefix} ${v.message}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Summary
|
||||
const errors = result.violations.filter((v) => v.severity === "error").length;
|
||||
const warnings = result.violations.filter(
|
||||
(v) => v.severity === "warning"
|
||||
).length;
|
||||
|
||||
if (result.violations.length === 0) {
|
||||
console.log("✓ No hooks issues found\n");
|
||||
} else {
|
||||
console.log(`Found ${errors} error(s), ${warnings} warning(s)\n`);
|
||||
}
|
||||
|
||||
process.exit(result.passed ? 0 : 1);
|
||||
}
|
||||
|
||||
// Run if executed directly
|
||||
if (import.meta.main) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Claude Code Plugin Linting Utilities
|
||||
*
|
||||
* Individual linters for different plugin components.
|
||||
* Use lint-claude-plugin.ts for unified CLI access.
|
||||
*/
|
||||
|
||||
export { lintHooks, type LintResult as HooksLintResult, type Violation as HooksViolation } from "./hooks";
|
||||
export { lintSkills, type LintResult as SkillsLintResult, type Violation as SkillsViolation } from "./skills";
|
||||
export { lintPlugins, type LintResult as PluginsLintResult, type Violation as PluginsViolation } from "./plugins";
|
||||
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Lint plugin structure and configuration.
|
||||
*
|
||||
* Validates:
|
||||
* - Standalone plugins: .claude-plugin/plugin.json inside plugin directory
|
||||
* - Marketplace plugins: .claude-plugin/marketplace.json at repo root
|
||||
* - Required plugin.json fields: name, version, description
|
||||
* - Plugin structure conventions
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint/claude-plugin/plugins.ts [path]
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/lint/claude-plugin/plugins.ts # Lint all plugins
|
||||
* bun scripts/lint/claude-plugin/plugins.ts outfitter/ # Lint specific plugin
|
||||
*/
|
||||
|
||||
import { Glob } from "bun";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* A plugin validation violation.
|
||||
*/
|
||||
export interface Violation {
|
||||
/** File path where violation was found */
|
||||
file: string;
|
||||
/** Severity of the issue */
|
||||
severity: "error" | "warning";
|
||||
/** Description of the issue */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of plugin validation.
|
||||
*/
|
||||
export interface LintResult {
|
||||
/** Whether validation passed (no errors) */
|
||||
passed: boolean;
|
||||
/** All violations found */
|
||||
violations: Violation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of plugin.json configuration.
|
||||
*/
|
||||
interface PluginJson {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author?: { name: string; email?: string; url?: string };
|
||||
keywords?: string[];
|
||||
hooks?: unknown; // Should NOT be present
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of marketplace.json configuration.
|
||||
*/
|
||||
interface MarketplaceJson {
|
||||
name: string;
|
||||
owner?: { name: string; email?: string };
|
||||
metadata?: { description?: string; version?: string };
|
||||
plugins: Array<{
|
||||
name: string;
|
||||
source: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a plugin.json file.
|
||||
*/
|
||||
function validatePluginJson(filePath: string): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const json: PluginJson = JSON.parse(content);
|
||||
|
||||
// Required fields
|
||||
if (!json.name) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: 'Missing required field: "name"',
|
||||
});
|
||||
}
|
||||
|
||||
if (!json.version) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: 'Missing required field: "version"',
|
||||
});
|
||||
}
|
||||
|
||||
if (!json.description) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: 'Missing required field: "description"',
|
||||
});
|
||||
}
|
||||
|
||||
// Should NOT have hooks field (auto-discovery)
|
||||
if (json.hooks !== undefined) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message:
|
||||
'plugin.json should NOT have "hooks" field. Hooks are auto-discovered from hooks/hooks.json',
|
||||
});
|
||||
}
|
||||
|
||||
// Recommended fields
|
||||
if (!json.author) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: 'Missing recommended field: "author"',
|
||||
});
|
||||
}
|
||||
|
||||
if (!json.keywords || json.keywords.length === 0) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: 'Missing recommended field: "keywords"',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate version format (semver)
|
||||
if (json.version && !/^\d+\.\d+\.\d+/.test(json.version)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: `Version "${json.version}" does not follow semver format (MAJOR.MINOR.PATCH)`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a marketplace.json file.
|
||||
*/
|
||||
function validateMarketplaceJson(filePath: string): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const json: MarketplaceJson = JSON.parse(content);
|
||||
|
||||
// Required fields
|
||||
if (!json.name) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: 'Missing required field: "name"',
|
||||
});
|
||||
}
|
||||
|
||||
if (!json.plugins || !Array.isArray(json.plugins)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: 'Missing or invalid "plugins" array',
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Validate each plugin entry
|
||||
for (const plugin of json.plugins) {
|
||||
if (!plugin.name) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: 'Plugin entry missing "name"',
|
||||
});
|
||||
}
|
||||
|
||||
if (!plugin.source) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Plugin "${plugin.name}" missing "source"`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!plugin.version) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: `Plugin "${plugin.name}" missing "version"`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate source doesn't escape repo
|
||||
if (plugin.source) {
|
||||
const normalized = plugin.source.replace(/^\.\//, "");
|
||||
if (normalized.includes("..") || normalized.startsWith("/")) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Plugin "${plugin.name}" has invalid source path: "${plugin.source}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate plugin directory structure.
|
||||
*/
|
||||
function validatePluginStructure(pluginPath: string): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const pluginName = basename(pluginPath);
|
||||
|
||||
// Check for README
|
||||
if (!existsSync(join(pluginPath, "README.md"))) {
|
||||
violations.push({
|
||||
file: pluginPath,
|
||||
severity: "warning",
|
||||
message: `Plugin "${pluginName}" missing README.md`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check skills directory structure
|
||||
const skillsPath = join(pluginPath, "skills");
|
||||
if (existsSync(skillsPath)) {
|
||||
const skills = readdirSync(skillsPath, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
|
||||
for (const skill of skills) {
|
||||
const skillMdPath = join(skillsPath, skill, "SKILL.md");
|
||||
if (!existsSync(skillMdPath)) {
|
||||
violations.push({
|
||||
file: join(skillsPath, skill),
|
||||
severity: "error",
|
||||
message: `Skill directory "${skill}" missing SKILL.md`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and validate all plugin configurations in a path.
|
||||
*/
|
||||
export async function lintPlugins(searchPath: string): Promise<LintResult> {
|
||||
const violations: Violation[] = [];
|
||||
const resolvedPath = resolve(searchPath);
|
||||
|
||||
// Check for marketplace.json at root
|
||||
const marketplacePath = join(resolvedPath, ".claude-plugin/marketplace.json");
|
||||
if (existsSync(marketplacePath)) {
|
||||
violations.push(...validateMarketplaceJson(marketplacePath));
|
||||
|
||||
// Validate each plugin referenced in marketplace
|
||||
try {
|
||||
const marketplace: MarketplaceJson = JSON.parse(
|
||||
readFileSync(marketplacePath, "utf-8")
|
||||
);
|
||||
|
||||
for (const plugin of marketplace.plugins || []) {
|
||||
const normalized = plugin.source.replace(/^\.\//, "");
|
||||
const pluginPath = join(resolvedPath, normalized);
|
||||
|
||||
if (!existsSync(pluginPath)) {
|
||||
violations.push({
|
||||
file: marketplacePath,
|
||||
severity: "error",
|
||||
message: `Plugin "${plugin.name}" source not found: ${plugin.source}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate plugin.json inside plugin directory
|
||||
const pluginJsonPath = join(pluginPath, ".claude-plugin/plugin.json");
|
||||
if (existsSync(pluginJsonPath)) {
|
||||
violations.push(...validatePluginJson(pluginJsonPath));
|
||||
} else {
|
||||
violations.push({
|
||||
file: pluginPath,
|
||||
severity: "error",
|
||||
message: `Plugin "${plugin.name}" missing .claude-plugin/plugin.json`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate plugin structure
|
||||
violations.push(...validatePluginStructure(pluginPath));
|
||||
}
|
||||
} catch {
|
||||
// Parse errors handled by validateMarketplaceJson
|
||||
}
|
||||
} else {
|
||||
// Look for standalone plugins
|
||||
const pluginGlob = new Glob("**/.claude-plugin/plugin.json");
|
||||
for await (const file of pluginGlob.scan({
|
||||
cwd: resolvedPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
if (file.includes("node_modules") || file.includes(".git")) continue;
|
||||
violations.push(...validatePluginJson(file));
|
||||
|
||||
// Validate plugin structure
|
||||
const pluginPath = dirname(dirname(file)); // Go up from .claude-plugin/plugin.json
|
||||
violations.push(...validatePluginStructure(pluginPath));
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = violations.some((v) => v.severity === "error");
|
||||
|
||||
return {
|
||||
passed: !hasErrors,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format path relative to cwd for cleaner output.
|
||||
*/
|
||||
function relativePath(absolutePath: string): string {
|
||||
return absolutePath.replace(process.cwd() + "/", "");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const paths = args.filter((arg) => !arg.startsWith("--"));
|
||||
const searchPath = paths[0] || ".";
|
||||
|
||||
try {
|
||||
const stat = statSync(searchPath);
|
||||
if (!stat.isDirectory()) {
|
||||
console.error(`Error: ${searchPath} is not a directory`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.error(`Error: Path not found: ${searchPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nLinting plugins in ${relativePath(resolve(searchPath))}...\n`);
|
||||
|
||||
const result = await lintPlugins(searchPath);
|
||||
|
||||
// Group violations by file
|
||||
const byFile = new Map<string, Violation[]>();
|
||||
for (const v of result.violations) {
|
||||
const existing = byFile.get(v.file) || [];
|
||||
existing.push(v);
|
||||
byFile.set(v.file, existing);
|
||||
}
|
||||
|
||||
// Output results
|
||||
for (const [file, fileViolations] of byFile) {
|
||||
console.log(`${relativePath(file)}:`);
|
||||
for (const v of fileViolations) {
|
||||
const prefix = v.severity === "error" ? "✗" : "△";
|
||||
console.log(` ${prefix} ${v.message}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Summary
|
||||
const errors = result.violations.filter((v) => v.severity === "error").length;
|
||||
const warnings = result.violations.filter(
|
||||
(v) => v.severity === "warning"
|
||||
).length;
|
||||
|
||||
if (result.violations.length === 0) {
|
||||
console.log("✓ No plugin issues found\n");
|
||||
} else {
|
||||
console.log(`Found ${errors} error(s), ${warnings} warning(s)\n`);
|
||||
}
|
||||
|
||||
process.exit(result.passed ? 0 : 1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Lint SKILL.md files for correct frontmatter and structure.
|
||||
*
|
||||
* Validates:
|
||||
* - Required fields: name, description
|
||||
* - Field formats and lengths
|
||||
* - Redundant fields (e.g., user-invocable: true is default)
|
||||
* - Line count recommendations
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint/claude-plugin/skills.ts [path]
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/lint/claude-plugin/skills.ts # Lint all skills
|
||||
* bun scripts/lint/claude-plugin/skills.ts outfitter/ # Lint specific plugin
|
||||
*/
|
||||
|
||||
import { Glob } from "bun";
|
||||
import { readFileSync, statSync } from "node:fs";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
|
||||
/**
|
||||
* A skill validation violation.
|
||||
*/
|
||||
export interface Violation {
|
||||
/** File path where violation was found */
|
||||
file: string;
|
||||
/** Severity of the issue */
|
||||
severity: "error" | "warning";
|
||||
/** Description of the issue */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of skill validation.
|
||||
*/
|
||||
export interface LintResult {
|
||||
/** Whether validation passed (no errors) */
|
||||
passed: boolean;
|
||||
/** All violations found */
|
||||
violations: Violation[];
|
||||
}
|
||||
|
||||
const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
||||
const RESERVED_WORDS = ["anthropic", "claude"];
|
||||
const MAX_LINES = 500;
|
||||
const MAX_DESCRIPTION_LENGTH = 1024;
|
||||
const MIN_DESCRIPTION_LENGTH = 10;
|
||||
|
||||
/** Base spec fields (cross-platform) */
|
||||
const BASE_FIELDS = new Set([
|
||||
"name",
|
||||
"description",
|
||||
"version",
|
||||
"license",
|
||||
"compatibility",
|
||||
"metadata",
|
||||
]);
|
||||
|
||||
/** Claude-specific extension fields */
|
||||
const CLAUDE_FIELDS = new Set([
|
||||
"allowed-tools",
|
||||
"user-invocable",
|
||||
"disable-model-invocation",
|
||||
"context",
|
||||
"agent",
|
||||
"model",
|
||||
"hooks",
|
||||
"argument-hint",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Extract YAML frontmatter from markdown content.
|
||||
*/
|
||||
function extractFrontmatter(content: string): {
|
||||
yaml: string | null;
|
||||
lineCount: number;
|
||||
} {
|
||||
const lines = content.split("\n");
|
||||
const lineCount = lines.length;
|
||||
|
||||
if (!lines[0]?.trim().startsWith("---")) {
|
||||
return { yaml: null, lineCount };
|
||||
}
|
||||
|
||||
let endIndex = -1;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === "---") {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex === -1) {
|
||||
return { yaml: null, lineCount };
|
||||
}
|
||||
|
||||
const yaml = lines.slice(1, endIndex).join("\n");
|
||||
return { yaml, lineCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single SKILL.md file.
|
||||
*/
|
||||
function validateSkillFile(filePath: string): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const { yaml, lineCount } = extractFrontmatter(content);
|
||||
|
||||
// Check for frontmatter
|
||||
if (yaml === null) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: "Missing or invalid frontmatter. SKILL.md must start with ---",
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Check for tabs
|
||||
if (yaml.includes("\t")) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: "YAML contains tabs. Use spaces for indentation",
|
||||
});
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
let frontmatter: Record<string, unknown>;
|
||||
try {
|
||||
frontmatter = parseYaml(yaml) as Record<string, unknown>;
|
||||
} catch (e) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `YAML parse error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
if (!frontmatter || typeof frontmatter !== "object") {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: "Frontmatter must be a YAML object",
|
||||
});
|
||||
return violations;
|
||||
}
|
||||
|
||||
// Required fields
|
||||
if (!frontmatter.name) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: "Missing required field: name",
|
||||
});
|
||||
}
|
||||
|
||||
if (!frontmatter.description) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: "Missing required field: description",
|
||||
});
|
||||
}
|
||||
|
||||
// Name validation
|
||||
if (typeof frontmatter.name === "string") {
|
||||
const name = frontmatter.name;
|
||||
|
||||
if (!NAME_PATTERN.test(name)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Invalid name format: "${name}". Must be lowercase, numbers, hyphens only`,
|
||||
});
|
||||
}
|
||||
|
||||
if (name.length < 2 || name.length > 64) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Name length must be 2-64 characters. Got: ${name.length}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Reserved word check - warning only, as skills ABOUT Claude/Anthropic are legitimate
|
||||
for (const reserved of RESERVED_WORDS) {
|
||||
if (name.toLowerCase().includes(reserved)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: `Name contains reserved word "${reserved}". Ensure this skill is ABOUT ${reserved}, not impersonating official products`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Directory match check
|
||||
const parentDir = basename(dirname(filePath));
|
||||
if (parentDir !== name && parentDir !== "skills") {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: `Name "${name}" does not match parent directory "${parentDir}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Description validation
|
||||
if (typeof frontmatter.description === "string") {
|
||||
const desc = frontmatter.description;
|
||||
|
||||
if (desc.length < MIN_DESCRIPTION_LENGTH) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Description too short: ${desc.length} chars. Minimum: ${MIN_DESCRIPTION_LENGTH}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (desc.length > MAX_DESCRIPTION_LENGTH) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "error",
|
||||
message: `Description too long: ${desc.length} chars. Maximum: ${MAX_DESCRIPTION_LENGTH}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for redundant user-invocable: true (true is the default)
|
||||
if (frontmatter["user-invocable"] === true) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message:
|
||||
'Redundant "user-invocable: true" (true is the default). Remove or only use "user-invocable: false"',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for top-level version (should be under metadata per agentskills.io spec)
|
||||
if (frontmatter.version !== undefined) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message:
|
||||
'Top-level "version" should be under "metadata.version" per agentskills.io spec',
|
||||
});
|
||||
}
|
||||
|
||||
// Check for custom fields at top level
|
||||
const allKnownFields = new Set([...BASE_FIELDS, ...CLAUDE_FIELDS]);
|
||||
for (const key of Object.keys(frontmatter)) {
|
||||
if (!allKnownFields.has(key)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: `Custom field "${key}" should be nested under "metadata"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Line count warning
|
||||
if (lineCount > MAX_LINES) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
severity: "warning",
|
||||
message: `SKILL.md has ${lineCount} lines (recommended max: ${MAX_LINES}). Consider moving details to references/`,
|
||||
});
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and validate all SKILL.md files in a path.
|
||||
*/
|
||||
export async function lintSkills(searchPath: string): Promise<LintResult> {
|
||||
const violations: Violation[] = [];
|
||||
const resolvedPath = resolve(searchPath);
|
||||
|
||||
const glob = new Glob("**/SKILL.md");
|
||||
for await (const file of glob.scan({
|
||||
cwd: resolvedPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
if (
|
||||
file.includes("node_modules") ||
|
||||
file.includes(".git") ||
|
||||
file.includes(".beads") ||
|
||||
file.includes("templates/") ||
|
||||
file.includes(".archive")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
violations.push(...validateSkillFile(file));
|
||||
}
|
||||
|
||||
const hasErrors = violations.some((v) => v.severity === "error");
|
||||
|
||||
return {
|
||||
passed: !hasErrors,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format path relative to cwd for cleaner output.
|
||||
*/
|
||||
function relativePath(absolutePath: string): string {
|
||||
return absolutePath.replace(process.cwd() + "/", "");
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const paths = args.filter((arg) => !arg.startsWith("--"));
|
||||
const searchPath = paths[0] || ".";
|
||||
|
||||
try {
|
||||
const stat = statSync(searchPath);
|
||||
if (!stat.isDirectory()) {
|
||||
console.error(`Error: ${searchPath} is not a directory`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch {
|
||||
console.error(`Error: Path not found: ${searchPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\nLinting skills in ${relativePath(resolve(searchPath))}...\n`);
|
||||
|
||||
const result = await lintSkills(searchPath);
|
||||
|
||||
// Group violations by file
|
||||
const byFile = new Map<string, Violation[]>();
|
||||
for (const v of result.violations) {
|
||||
const existing = byFile.get(v.file) || [];
|
||||
existing.push(v);
|
||||
byFile.set(v.file, existing);
|
||||
}
|
||||
|
||||
// Output results
|
||||
for (const [file, fileViolations] of byFile) {
|
||||
console.log(`${relativePath(file)}:`);
|
||||
for (const v of fileViolations) {
|
||||
const prefix = v.severity === "error" ? "✗" : "△";
|
||||
console.log(` ${prefix} ${v.message}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Summary
|
||||
const errors = result.violations.filter((v) => v.severity === "error").length;
|
||||
const warnings = result.violations.filter(
|
||||
(v) => v.severity === "warning"
|
||||
).length;
|
||||
|
||||
if (result.violations.length === 0) {
|
||||
console.log("✓ No skill issues found\n");
|
||||
} else {
|
||||
console.log(`Found ${errors} error(s), ${warnings} warning(s)\n`);
|
||||
}
|
||||
|
||||
process.exit(result.passed ? 0 : 1);
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user