📦 deps(thirdparty): update snapshots
This commit is contained in:
+109
@@ -0,0 +1,109 @@
|
||||
#!/bin/bash
|
||||
# Cleanup script to remove all local outfitter plugin installations
|
||||
# Run this BEFORE reinstalling from GitHub
|
||||
#
|
||||
# Usage: bash scripts/cleanup-local-install.sh
|
||||
#
|
||||
# What this does:
|
||||
# 1. Removes cached plugin files
|
||||
# 2. Removes entries from installed_plugins.json
|
||||
# 3. Removes entries from known_marketplaces.json
|
||||
# 4. Removes entries from settings.json enabledPlugins
|
||||
|
||||
set -e
|
||||
|
||||
CLAUDE_DIR="$HOME/.claude"
|
||||
PLUGINS_DIR="$CLAUDE_DIR/plugins"
|
||||
|
||||
echo "=== Outfitter Plugin Cleanup ==="
|
||||
echo ""
|
||||
|
||||
# 1. Remove cache directories
|
||||
echo "1. Removing cache directories..."
|
||||
if [ -d "$PLUGINS_DIR/cache/outfitter" ]; then
|
||||
rm -rf "$PLUGINS_DIR/cache/outfitter"
|
||||
echo " ✓ Removed $PLUGINS_DIR/cache/outfitter"
|
||||
else
|
||||
echo " - $PLUGINS_DIR/cache/outfitter (not found)"
|
||||
fi
|
||||
|
||||
if [ -d "$PLUGINS_DIR/cache/outfitter-internal" ]; then
|
||||
rm -rf "$PLUGINS_DIR/cache/outfitter-internal"
|
||||
echo " ✓ Removed $PLUGINS_DIR/cache/outfitter-internal"
|
||||
else
|
||||
echo " - $PLUGINS_DIR/cache/outfitter-internal (not found)"
|
||||
fi
|
||||
|
||||
# 2. Remove from installed_plugins.json
|
||||
echo ""
|
||||
echo "2. Cleaning installed_plugins.json..."
|
||||
INSTALLED_FILE="$PLUGINS_DIR/installed_plugins.json"
|
||||
if [ -f "$INSTALLED_FILE" ]; then
|
||||
# Create backup
|
||||
cp "$INSTALLED_FILE" "$INSTALLED_FILE.bak"
|
||||
|
||||
# Remove all outfitter-related entries
|
||||
jq 'del(.plugins["but@outfitter"]) |
|
||||
del(.plugins["claude-dev@outfitter"]) |
|
||||
del(.plugins["cli-dev@outfitter"]) |
|
||||
del(.plugins["gt@outfitter"]) |
|
||||
del(.plugins["outfitter@outfitter"]) |
|
||||
del(.plugins["outfitter-dev@outfitter-internal"])' \
|
||||
"$INSTALLED_FILE.bak" > "$INSTALLED_FILE"
|
||||
|
||||
echo " ✓ Removed outfitter entries from installed_plugins.json"
|
||||
echo " ✓ Backup saved to installed_plugins.json.bak"
|
||||
fi
|
||||
|
||||
# 3. Remove from known_marketplaces.json
|
||||
echo ""
|
||||
echo "3. Cleaning known_marketplaces.json..."
|
||||
MARKETPLACES_FILE="$PLUGINS_DIR/known_marketplaces.json"
|
||||
if [ -f "$MARKETPLACES_FILE" ]; then
|
||||
# Create backup
|
||||
cp "$MARKETPLACES_FILE" "$MARKETPLACES_FILE.bak"
|
||||
|
||||
# Remove outfitter marketplace entry
|
||||
jq 'del(.outfitter) | del(.["outfitter-internal"])' \
|
||||
"$MARKETPLACES_FILE.bak" > "$MARKETPLACES_FILE"
|
||||
|
||||
echo " ✓ Removed outfitter from known_marketplaces.json"
|
||||
echo " ✓ Backup saved to known_marketplaces.json.bak"
|
||||
fi
|
||||
|
||||
# 4. Remove from settings.json enabledPlugins
|
||||
echo ""
|
||||
echo "4. Cleaning settings.json..."
|
||||
SETTINGS_FILE="$CLAUDE_DIR/settings.json"
|
||||
if [ -f "$SETTINGS_FILE" ]; then
|
||||
# Create backup
|
||||
cp "$SETTINGS_FILE" "$SETTINGS_FILE.bak"
|
||||
|
||||
# Remove outfitter entries from enabledPlugins
|
||||
jq 'del(.enabledPlugins["outfitter@outfitter"]) |
|
||||
del(.enabledPlugins["outfitter-dev@outfitter-internal"]) |
|
||||
del(.enabledPlugins["but@outfitter"]) |
|
||||
del(.enabledPlugins["gt@outfitter"]) |
|
||||
del(.enabledPlugins["cli-dev@outfitter"]) |
|
||||
del(.enabledPlugins["claude-dev@outfitter"])' \
|
||||
"$SETTINGS_FILE.bak" > "$SETTINGS_FILE"
|
||||
|
||||
echo " ✓ Removed outfitter entries from settings.json"
|
||||
echo " ✓ Backup saved to settings.json.bak"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Cleanup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Commit and push the version bump to GitHub"
|
||||
echo " 2. Run: /plugin marketplace add outfitter-dev/agents"
|
||||
echo " 3. Run: /plugin install outfitter@outfitter"
|
||||
echo " 4. Run: /plugin install gt@outfitter"
|
||||
echo " 5. Run: /plugin install but@outfitter"
|
||||
echo " 6. Run: /plugin install cli-dev@outfitter"
|
||||
echo ""
|
||||
echo "To restore from backups if needed:"
|
||||
echo " cp $INSTALLED_FILE.bak $INSTALLED_FILE"
|
||||
echo " cp $MARKETPLACES_FILE.bak $MARKETPLACES_FILE"
|
||||
echo " cp $SETTINGS_FILE.bak $SETTINGS_FILE"
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Ensures a changeset exists for the current branch if plugin files changed.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/ensure-changeset.ts [commit-message]
|
||||
*
|
||||
* If no commit message provided, reads from .git/COMMIT_EDITMSG or HEAD commit.
|
||||
*
|
||||
* Logic:
|
||||
* 1. Get current branch and parent branch
|
||||
* 2. Diff against parent to find files changed in this branch only
|
||||
* 3. Filter for plugin directories
|
||||
* 4. Parse conventional commit → bump type
|
||||
* 5. Create/update .changeset/{branch-name}.md
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = import.meta.dirname ? join(import.meta.dirname, "..") : process.cwd();
|
||||
const CHANGESET_DIR = join(ROOT, ".changeset");
|
||||
|
||||
// Local plugins that need changesets
|
||||
const PLUGIN_DIRS = ["outfitter", "but", "gt", "cli-dev"];
|
||||
|
||||
// Conventional commit types that skip changesets
|
||||
const SKIP_TYPES = new Set(["chore", "test", "ci", "build"]);
|
||||
|
||||
// Type to bump mapping
|
||||
const TYPE_TO_BUMP: Record<string, "major" | "minor" | "patch"> = {
|
||||
feat: "minor",
|
||||
fix: "patch",
|
||||
perf: "patch",
|
||||
refactor: "patch",
|
||||
docs: "patch",
|
||||
style: "patch",
|
||||
};
|
||||
|
||||
interface ParsedCommit {
|
||||
type: string;
|
||||
scope: string | null;
|
||||
breaking: boolean;
|
||||
description: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface ChangesetData {
|
||||
plugins: Map<string, "major" | "minor" | "patch">;
|
||||
description: string;
|
||||
}
|
||||
|
||||
function exec(cmd: string): string {
|
||||
try {
|
||||
return execSync(cmd, { cwd: ROOT, encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentBranch(): string {
|
||||
return exec("git rev-parse --abbrev-ref HEAD");
|
||||
}
|
||||
|
||||
function getParentBranch(): string {
|
||||
// Try gt parent first (Graphite)
|
||||
const gtParent = exec("gt parent 2>/dev/null");
|
||||
if (gtParent && !gtParent.includes("error")) {
|
||||
return gtParent;
|
||||
}
|
||||
|
||||
// Fall back to main/master
|
||||
const defaultBranch = exec("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null")
|
||||
.replace("refs/remotes/origin/", "") || "main";
|
||||
return defaultBranch;
|
||||
}
|
||||
|
||||
function getChangedFiles(parentBranch: string): string[] {
|
||||
// Get files changed in current branch vs parent
|
||||
// Include both committed and staged changes
|
||||
const committed = exec(`git diff --name-only ${JSON.stringify(parentBranch)}...HEAD 2>/dev/null`);
|
||||
const staged = exec("git diff --name-only --cached");
|
||||
|
||||
const files = new Set<string>();
|
||||
for (const f of committed.split("\n").filter(Boolean)) files.add(f);
|
||||
for (const f of staged.split("\n").filter(Boolean)) files.add(f);
|
||||
|
||||
return Array.from(files);
|
||||
}
|
||||
|
||||
function getAffectedPlugins(files: string[]): string[] {
|
||||
const plugins = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
for (const plugin of PLUGIN_DIRS) {
|
||||
if (file.startsWith(`${plugin}/`)) {
|
||||
plugins.add(plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(plugins).sort();
|
||||
}
|
||||
|
||||
function getCommitMessage(arg?: string): string {
|
||||
// Priority: CLI arg > COMMIT_EDITMSG > HEAD commit
|
||||
if (arg) return arg;
|
||||
|
||||
const commitMsgFile = join(ROOT, ".git", "COMMIT_EDITMSG");
|
||||
if (existsSync(commitMsgFile)) {
|
||||
return readFileSync(commitMsgFile, "utf-8").trim();
|
||||
}
|
||||
|
||||
return exec("git log -1 --format=%B HEAD");
|
||||
}
|
||||
|
||||
function parseConventionalCommit(message: string): ParsedCommit {
|
||||
const lines = message.split("\n");
|
||||
const firstLine = lines[0] || "";
|
||||
const body = lines.slice(1).join("\n").trim();
|
||||
|
||||
// Parse: type(scope)!: description
|
||||
// Regex: ^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$
|
||||
const match = firstLine.match(/^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/);
|
||||
|
||||
if (!match) {
|
||||
// Not a conventional commit, treat as patch with full message as description
|
||||
return {
|
||||
type: "patch",
|
||||
scope: null,
|
||||
breaking: false,
|
||||
description: firstLine,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
const [, type, scope, bang, description] = match;
|
||||
const breaking = bang === "!" || body.includes("BREAKING CHANGE:");
|
||||
|
||||
return {
|
||||
type: type.toLowerCase(),
|
||||
scope: scope || null,
|
||||
breaking,
|
||||
description,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function getBumpType(parsed: ParsedCommit): "major" | "minor" | "patch" | "skip" {
|
||||
if (parsed.breaking) return "major";
|
||||
if (SKIP_TYPES.has(parsed.type)) return "skip";
|
||||
return TYPE_TO_BUMP[parsed.type] || "patch";
|
||||
}
|
||||
|
||||
function sanitizeBranchName(branch: string): string {
|
||||
// Convert branch name to valid filename
|
||||
return branch.replace(/[^a-zA-Z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function getChangesetPath(branch: string): string {
|
||||
const sanitized = sanitizeBranchName(branch);
|
||||
return join(CHANGESET_DIR, `${sanitized}.md`);
|
||||
}
|
||||
|
||||
function readExistingChangeset(path: string): ChangesetData | null {
|
||||
if (!existsSync(path)) return null;
|
||||
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return null;
|
||||
|
||||
const [, frontmatter, description] = match;
|
||||
const plugins = new Map<string, "major" | "minor" | "patch">();
|
||||
|
||||
for (const line of frontmatter.split("\n")) {
|
||||
const pkgMatch = line.match(/^"([^"]+)":\s*(major|minor|patch)$/);
|
||||
if (pkgMatch) {
|
||||
plugins.set(pkgMatch[1], pkgMatch[2] as "major" | "minor" | "patch");
|
||||
}
|
||||
}
|
||||
|
||||
return { plugins, description: description.trim() };
|
||||
}
|
||||
|
||||
function writeChangeset(path: string, data: ChangesetData): void {
|
||||
const frontmatter = Array.from(data.plugins.entries())
|
||||
.map(([pkg, bump]) => `"${pkg}": ${bump}`)
|
||||
.join("\n");
|
||||
|
||||
const content = `---\n${frontmatter}\n---\n\n${data.description}\n`;
|
||||
writeFileSync(path, content);
|
||||
}
|
||||
|
||||
function mergeBump(
|
||||
existing: "major" | "minor" | "patch" | undefined,
|
||||
incoming: "major" | "minor" | "patch"
|
||||
): "major" | "minor" | "patch" {
|
||||
const order = { major: 3, minor: 2, patch: 1 };
|
||||
if (!existing) return incoming;
|
||||
return order[existing] >= order[incoming] ? existing : incoming;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const branch = getCurrentBranch();
|
||||
|
||||
if (branch === "main" || branch === "master" || branch === "HEAD") {
|
||||
console.log("⏭ Skipping changeset on trunk branch");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const parent = getParentBranch();
|
||||
const files = getChangedFiles(parent);
|
||||
const plugins = getAffectedPlugins(files);
|
||||
|
||||
if (plugins.length === 0) {
|
||||
console.log("⏭ No plugin changes detected");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const message = getCommitMessage(process.argv[2]);
|
||||
const parsed = parseConventionalCommit(message);
|
||||
const bump = getBumpType(parsed);
|
||||
|
||||
if (bump === "skip") {
|
||||
// Clean up stale changeset if one exists from previous non-skip commits
|
||||
const changesetPath = getChangesetPath(branch);
|
||||
if (existsSync(changesetPath)) {
|
||||
unlinkSync(changesetPath);
|
||||
execSync(`git add ${JSON.stringify(changesetPath)}`, { cwd: ROOT });
|
||||
console.log(`🗑 Removed stale changeset: ${changesetPath}`);
|
||||
}
|
||||
console.log(`⏭ Skipping changeset for ${parsed.type}: commit`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const changesetPath = getChangesetPath(branch);
|
||||
const existing = readExistingChangeset(changesetPath);
|
||||
|
||||
// Build new changeset data
|
||||
const data: ChangesetData = {
|
||||
plugins: existing?.plugins || new Map(),
|
||||
description: parsed.description,
|
||||
};
|
||||
|
||||
// Update/add affected plugins with appropriate bump
|
||||
for (const plugin of plugins) {
|
||||
const currentBump = data.plugins.get(plugin);
|
||||
data.plugins.set(plugin, mergeBump(currentBump, bump));
|
||||
}
|
||||
|
||||
writeChangeset(changesetPath, data);
|
||||
|
||||
// Stage the changeset
|
||||
execSync(`git add ${JSON.stringify(changesetPath)}`, { cwd: ROOT });
|
||||
|
||||
const pluginList = plugins.map((p) => `${p}:${bump}`).join(", ");
|
||||
console.log(`✓ Changeset updated: ${pluginList}`);
|
||||
console.log(` → ${changesetPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Format markdown tables for consistent spacing and alignment.
|
||||
*
|
||||
* Fixes:
|
||||
* - Separator lines: `|---|---|` -> `| --- | --- |`
|
||||
* - Column alignment: Pads cells to match widest content in each column
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/format-markdown-tables.ts [path] # Dry-run (lint mode)
|
||||
* bun scripts/format-markdown-tables.ts --fix [path] # Auto-fix
|
||||
* bun scripts/format-markdown-tables.ts --check [path] # Exit 1 if issues found
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/format-markdown-tables.ts # Check all .md files
|
||||
* bun scripts/format-markdown-tables.ts outfitter/ # Check specific dir
|
||||
* bun scripts/format-markdown-tables.ts --fix outfitter/ # Fix specific dir
|
||||
* bun scripts/format-markdown-tables.ts --fix path/to/file.md # Fix single file
|
||||
*/
|
||||
|
||||
import { Glob } from "bun";
|
||||
import { statSync } from "fs";
|
||||
|
||||
/**
|
||||
* A table formatting issue found in a markdown file.
|
||||
*/
|
||||
interface TableIssue {
|
||||
/** File path where issue was found */
|
||||
file: string;
|
||||
/** Line number of the issue */
|
||||
line: number;
|
||||
/** Description of the formatting issue */
|
||||
message: string;
|
||||
/** Original line content */
|
||||
before: string;
|
||||
/** Corrected line content */
|
||||
after: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of formatting tables in a file.
|
||||
*/
|
||||
interface FormatResult {
|
||||
/** Formatted file content */
|
||||
content: string;
|
||||
/** Issues found and fixed */
|
||||
issues: TableIssue[];
|
||||
/** Whether content was modified */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
// Match a table separator line (line with only |, -, :, and spaces)
|
||||
const SEPARATOR_PATTERN = /^\|[\s\-:|]+\|$/;
|
||||
|
||||
// Match a table row (starts and ends with |)
|
||||
const TABLE_ROW_PATTERN = /^\|.+\|$/;
|
||||
|
||||
function isTableRow(line: string): boolean {
|
||||
return TABLE_ROW_PATTERN.test(line.trim());
|
||||
}
|
||||
|
||||
function isSeparatorRow(line: string): boolean {
|
||||
return SEPARATOR_PATTERN.test(line.trim());
|
||||
}
|
||||
|
||||
function parseCells(line: string): string[] {
|
||||
// Remove leading/trailing pipes and split by |
|
||||
const trimmed = line.trim();
|
||||
const inner = trimmed.slice(1, -1); // Remove first and last |
|
||||
return inner.split("|").map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function parseSeparatorCell(cell: string): { align: "left" | "center" | "right" | "none"; width: number } {
|
||||
const trimmed = cell.trim();
|
||||
const leftColon = trimmed.startsWith(":");
|
||||
const rightColon = trimmed.endsWith(":");
|
||||
const dashes = trimmed.replace(/:/g, "");
|
||||
|
||||
let align: "left" | "center" | "right" | "none" = "none";
|
||||
if (leftColon && rightColon) align = "center";
|
||||
else if (leftColon) align = "left";
|
||||
else if (rightColon) align = "right";
|
||||
|
||||
return { align, width: dashes.length };
|
||||
}
|
||||
|
||||
function formatSeparatorCell(align: "left" | "center" | "right" | "none", width: number): string {
|
||||
const dashes = "-".repeat(Math.max(width, 3));
|
||||
switch (align) {
|
||||
case "left": return `:${dashes}`;
|
||||
case "right": return `${dashes}:`;
|
||||
case "center": return `:${dashes}:`;
|
||||
default: return dashes;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTable(lines: string[], startLine: number, filePath: string): { formatted: string[]; issues: TableIssue[] } {
|
||||
const issues: TableIssue[] = [];
|
||||
|
||||
if (lines.length < 2) {
|
||||
return { formatted: lines, issues };
|
||||
}
|
||||
|
||||
// Parse all rows to find column widths
|
||||
const allCells: string[][] = [];
|
||||
let separatorIndex = -1;
|
||||
const separatorAligns: ("left" | "center" | "right" | "none")[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (isSeparatorRow(line)) {
|
||||
separatorIndex = i;
|
||||
const sepCells = parseCells(line);
|
||||
for (const cell of sepCells) {
|
||||
const { align } = parseSeparatorCell(cell);
|
||||
separatorAligns.push(align);
|
||||
}
|
||||
allCells.push(sepCells.map(() => "---")); // Placeholder
|
||||
} else {
|
||||
allCells.push(parseCells(line));
|
||||
}
|
||||
}
|
||||
|
||||
if (separatorIndex === -1) {
|
||||
return { formatted: lines, issues };
|
||||
}
|
||||
|
||||
// Calculate max width for each column
|
||||
const columnCount = Math.max(...allCells.map((row) => row.length));
|
||||
const columnWidths: number[] = new Array(columnCount).fill(3); // Minimum 3 for ---
|
||||
|
||||
for (const row of allCells) {
|
||||
for (let col = 0; col < row.length; col++) {
|
||||
const cell = row[col];
|
||||
if (cell !== "---") { // Skip separator placeholders
|
||||
columnWidths[col] = Math.max(columnWidths[col], cell.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format each row
|
||||
const formatted: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const originalLine = lines[i];
|
||||
let formattedLine: string;
|
||||
|
||||
if (i === separatorIndex) {
|
||||
// Format separator row
|
||||
const sepParts: string[] = [];
|
||||
for (let col = 0; col < columnCount; col++) {
|
||||
const align = separatorAligns[col] || "none";
|
||||
const sepCell = formatSeparatorCell(align, columnWidths[col]);
|
||||
sepParts.push(sepCell);
|
||||
}
|
||||
formattedLine = "| " + sepParts.join(" | ") + " |";
|
||||
} else {
|
||||
// Format content row
|
||||
const cells = allCells[i];
|
||||
const paddedCells: string[] = [];
|
||||
for (let col = 0; col < columnCount; col++) {
|
||||
const cell = cells[col] || "";
|
||||
const padded = cell.padEnd(columnWidths[col]);
|
||||
paddedCells.push(padded);
|
||||
}
|
||||
formattedLine = "| " + paddedCells.join(" | ") + " |";
|
||||
}
|
||||
|
||||
if (formattedLine !== originalLine) {
|
||||
issues.push({
|
||||
file: filePath,
|
||||
line: startLine + i,
|
||||
message: "Table formatting",
|
||||
before: originalLine,
|
||||
after: formattedLine,
|
||||
});
|
||||
}
|
||||
|
||||
formatted.push(formattedLine);
|
||||
}
|
||||
|
||||
return { formatted, issues };
|
||||
}
|
||||
|
||||
function findAndFormatTables(content: string, filePath: string): FormatResult {
|
||||
const lines = content.split("\n");
|
||||
const result: string[] = [];
|
||||
const allIssues: TableIssue[] = [];
|
||||
|
||||
let i = 0;
|
||||
let inCodeBlock = false;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Track code blocks
|
||||
if (line.trim().startsWith("```") || line.trim().startsWith("~~~")) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
result.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip content inside code blocks
|
||||
if (inCodeBlock) {
|
||||
result.push(line);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this starts a table
|
||||
if (isTableRow(line)) {
|
||||
// Collect all consecutive table rows
|
||||
const tableLines: string[] = [];
|
||||
const tableStart = i + 1; // 1-indexed line number
|
||||
|
||||
while (i < lines.length && isTableRow(lines[i])) {
|
||||
tableLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
// Format the table
|
||||
const { formatted, issues } = formatTable(tableLines, tableStart, filePath);
|
||||
result.push(...formatted);
|
||||
allIssues.push(...issues);
|
||||
} else {
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
const newContent = result.join("\n");
|
||||
return {
|
||||
content: newContent,
|
||||
issues: allIssues,
|
||||
changed: newContent !== content,
|
||||
};
|
||||
}
|
||||
|
||||
async function processFile(filePath: string, fix: boolean): Promise<TableIssue[]> {
|
||||
const content = await Bun.file(filePath).text();
|
||||
const { content: formatted, issues, changed } = findAndFormatTables(content, filePath);
|
||||
|
||||
if (fix && changed) {
|
||||
await Bun.write(filePath, formatted);
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const fix = args.includes("--fix");
|
||||
const check = args.includes("--check");
|
||||
const paths = args.filter((arg) => !arg.startsWith("--"));
|
||||
const searchPath = paths[0] || ".";
|
||||
|
||||
// Check if searchPath is a file or directory
|
||||
let files: string[] = [];
|
||||
|
||||
try {
|
||||
const stat = statSync(searchPath);
|
||||
if (stat.isFile()) {
|
||||
files = [searchPath];
|
||||
} else {
|
||||
const glob = new Glob("**/*.md");
|
||||
for await (const file of glob.scan({
|
||||
cwd: searchPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
if (
|
||||
file.includes("node_modules") ||
|
||||
file.includes(".git") ||
|
||||
file.includes(".beads")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error(`Error: Path not found: ${searchPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let totalIssues = 0;
|
||||
let filesWithIssues = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
const issues = await processFile(filePath, fix);
|
||||
|
||||
if (issues.length > 0) {
|
||||
filesWithIssues++;
|
||||
totalIssues += issues.length;
|
||||
|
||||
if (!fix) {
|
||||
const relativePath = filePath.replace(process.cwd() + "/", "");
|
||||
console.log(`\n${relativePath}:`);
|
||||
for (const issue of issues) {
|
||||
console.log(` Line ${issue.line}:`);
|
||||
console.log(` - ${issue.before}`);
|
||||
console.log(` + ${issue.after}`);
|
||||
}
|
||||
} else {
|
||||
const relativePath = filePath.replace(process.cwd() + "/", "");
|
||||
console.log(`Fixed: ${relativePath} (${issues.length} tables)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (fix) {
|
||||
if (totalIssues > 0) {
|
||||
console.log(`Fixed ${totalIssues} table(s) in ${filesWithIssues} file(s)`);
|
||||
} else {
|
||||
console.log(`Checked ${files.length} file(s) - no issues found`);
|
||||
}
|
||||
} else {
|
||||
if (totalIssues > 0) {
|
||||
console.log(`Found ${totalIssues} table(s) to format in ${filesWithIssues} file(s)`);
|
||||
console.log("Run with --fix to auto-fix");
|
||||
if (check) {
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.log(`Checked ${files.length} file(s) - all tables formatted correctly`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Unified markdown formatter for Claude Code files.
|
||||
*
|
||||
* Runs formatting tools in sequence:
|
||||
* 1. format-markdown-tables.ts - Table alignment and separator spacing
|
||||
* 2. Custom fixes (XML tag spacing, etc.)
|
||||
* 3. markdownlint-cli2 --fix - Standard markdown linting
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/format-markdown.ts [path] # Format files
|
||||
* bun scripts/format-markdown.ts --check [path] # Check only (exit 1 if issues)
|
||||
* bun scripts/format-markdown.ts --dry-run [path] # Show what would change
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/format-markdown.ts # Format all .md files
|
||||
* bun scripts/format-markdown.ts outfitter/ # Format specific dir
|
||||
* bun scripts/format-markdown.ts path/to/file.md # Format single file
|
||||
* bun scripts/format-markdown.ts --check # CI mode
|
||||
*/
|
||||
|
||||
import { spawn } from "bun";
|
||||
import { statSync, existsSync } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
|
||||
const SCRIPTS_DIR = dirname(new URL(import.meta.url).pathname);
|
||||
|
||||
/**
|
||||
* Result of a single formatting step.
|
||||
*/
|
||||
interface FormatResult {
|
||||
/** Name of the formatting step */
|
||||
step: string;
|
||||
/** Whether the step completed without errors */
|
||||
success: boolean;
|
||||
/** Output from the formatting tool */
|
||||
output: string;
|
||||
/** Whether files were modified */
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
async function runCommand(
|
||||
cmd: string[],
|
||||
description: string
|
||||
): Promise<FormatResult> {
|
||||
const proc = spawn({
|
||||
cmd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
const output = stdout + stderr;
|
||||
const changed = output.includes("Fixed") || output.includes("fixed");
|
||||
|
||||
return {
|
||||
step: description,
|
||||
success: exitCode === 0,
|
||||
output: output.trim(),
|
||||
changed,
|
||||
};
|
||||
}
|
||||
|
||||
async function checkToolAvailable(tool: string): Promise<boolean> {
|
||||
try {
|
||||
const proc = spawn({
|
||||
cmd: ["which", tool],
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await proc.exited;
|
||||
return proc.exitCode === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function formatTables(
|
||||
path: string,
|
||||
dryRun: boolean
|
||||
): Promise<FormatResult> {
|
||||
const script = join(SCRIPTS_DIR, "format-markdown-tables.ts");
|
||||
const args = ["bun", script];
|
||||
|
||||
if (!dryRun) {
|
||||
args.push("--fix");
|
||||
}
|
||||
args.push(path);
|
||||
|
||||
return runCommand(args, "Table formatting");
|
||||
}
|
||||
|
||||
async function formatXmlTags(
|
||||
path: string,
|
||||
dryRun: boolean
|
||||
): Promise<FormatResult> {
|
||||
const script = join(SCRIPTS_DIR, "lint-xml-tags.ts");
|
||||
|
||||
if (!existsSync(script)) {
|
||||
return {
|
||||
step: "XML tag formatting",
|
||||
success: true,
|
||||
output: "Skipped (script not found)",
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const args = ["bun", script];
|
||||
if (!dryRun) {
|
||||
args.push("--fix");
|
||||
}
|
||||
args.push(path);
|
||||
|
||||
return runCommand(args, "XML tag formatting");
|
||||
}
|
||||
|
||||
async function runMarkdownlint(
|
||||
path: string,
|
||||
dryRun: boolean,
|
||||
isFile: boolean
|
||||
): Promise<FormatResult> {
|
||||
// Check if markdownlint-cli2 is available
|
||||
const hasMarkdownlint = await checkToolAvailable("markdownlint-cli2");
|
||||
|
||||
if (!hasMarkdownlint) {
|
||||
return {
|
||||
step: "markdownlint-cli2",
|
||||
success: true,
|
||||
output: "Skipped (markdownlint-cli2 not installed)\nInstall: bun add -g markdownlint-cli2",
|
||||
changed: false,
|
||||
};
|
||||
}
|
||||
|
||||
const args = ["markdownlint-cli2"];
|
||||
if (!dryRun) {
|
||||
args.push("--fix");
|
||||
}
|
||||
|
||||
// Always ignore config globs - we specify our own targets
|
||||
args.push("--no-globs");
|
||||
|
||||
// Handle path - literal file (: prefix) or glob pattern for directories
|
||||
if (isFile) {
|
||||
args.push(`:${path}`); // : prefix = literal file path
|
||||
} else {
|
||||
args.push(join(path, "**/*.md"));
|
||||
}
|
||||
|
||||
return runCommand(args, "markdownlint-cli2");
|
||||
}
|
||||
|
||||
function printResult(result: FormatResult, verbose: boolean) {
|
||||
const icon = result.success ? "✓" : "✗";
|
||||
const color = result.success ? "\x1b[32m" : "\x1b[31m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
console.log(`${color}${icon}${reset} ${result.step}`);
|
||||
|
||||
if (verbose || !result.success) {
|
||||
if (result.output) {
|
||||
const indented = result.output
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(indented);
|
||||
}
|
||||
} else if (result.changed) {
|
||||
console.log(" Files formatted");
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const check = args.includes("--check");
|
||||
const dryRun = args.includes("--dry-run") || check;
|
||||
const verbose = args.includes("--verbose") || args.includes("-v");
|
||||
const paths = args.filter((arg) => !arg.startsWith("-"));
|
||||
const targetPath = resolve(paths[0] || ".");
|
||||
|
||||
// Detect if target is a file or directory
|
||||
let isFile = false;
|
||||
try {
|
||||
const stat = statSync(targetPath);
|
||||
isFile = stat.isFile();
|
||||
} catch {
|
||||
console.error(`Error: Path not found: ${targetPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mode = check ? "check" : dryRun ? "dry-run" : "format";
|
||||
console.log(`\x1b[34mMarkdown Formatter\x1b[0m (${mode} mode)`);
|
||||
console.log(`Target: ${targetPath}\n`);
|
||||
|
||||
const results: FormatResult[] = [];
|
||||
|
||||
// Step 1: Format tables
|
||||
console.log("Running formatters...\n");
|
||||
results.push(await formatTables(targetPath, dryRun));
|
||||
printResult(results[results.length - 1], verbose);
|
||||
|
||||
// Step 2: Format XML tags
|
||||
results.push(await formatXmlTags(targetPath, dryRun));
|
||||
printResult(results[results.length - 1], verbose);
|
||||
|
||||
// Step 3: Run markdownlint
|
||||
results.push(await runMarkdownlint(targetPath, dryRun, isFile));
|
||||
printResult(results[results.length - 1], verbose);
|
||||
|
||||
// Summary
|
||||
console.log("");
|
||||
const failures = results.filter((r) => !r.success);
|
||||
const changes = results.filter((r) => r.changed);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log(`\x1b[31m✗ ${failures.length} step(s) failed\x1b[0m`);
|
||||
process.exit(1);
|
||||
} else if (check && changes.length > 0) {
|
||||
console.log(`\x1b[33m⚠ ${changes.length} step(s) would make changes\x1b[0m`);
|
||||
console.log("Run without --check to apply fixes");
|
||||
process.exit(1);
|
||||
} else if (changes.length > 0) {
|
||||
console.log(`\x1b[32m✓ Formatting complete (${changes.length} step(s) made changes)\x1b[0m`);
|
||||
} else {
|
||||
console.log("\x1b[32m✓ All files formatted correctly\x1b[0m");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Unified Claude Code Plugin Linter
|
||||
*
|
||||
* Runs all plugin-related linters and reports combined results.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint-claude-plugin.ts [options] [path]
|
||||
*
|
||||
* Options:
|
||||
* --hooks Only run hooks linter
|
||||
* --skills Only run skills linter
|
||||
* --plugins Only run plugins linter
|
||||
* --help Show this help message
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/lint-claude-plugin.ts # Run all linters
|
||||
* bun scripts/lint-claude-plugin.ts --hooks # Only lint hooks
|
||||
* bun scripts/lint-claude-plugin.ts outfitter/ # Lint specific directory
|
||||
*/
|
||||
|
||||
import { resolve } from "node:path";
|
||||
import { statSync } from "node:fs";
|
||||
import {
|
||||
lintHooks,
|
||||
lintSkills,
|
||||
lintPlugins,
|
||||
type HooksLintResult,
|
||||
type SkillsLintResult,
|
||||
type PluginsLintResult,
|
||||
} from "./lint/claude-plugin";
|
||||
|
||||
type LintResult = HooksLintResult | SkillsLintResult | PluginsLintResult;
|
||||
|
||||
interface CombinedResult {
|
||||
passed: boolean;
|
||||
hooks: HooksLintResult | null;
|
||||
skills: SkillsLintResult | null;
|
||||
plugins: PluginsLintResult | null;
|
||||
totalErrors: number;
|
||||
totalWarnings: number;
|
||||
}
|
||||
|
||||
function relativePath(absolutePath: string): string {
|
||||
return absolutePath.replace(process.cwd() + "/", "");
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
Claude Code Plugin Linter
|
||||
|
||||
Usage:
|
||||
bun scripts/lint-claude-plugin.ts [options] [path]
|
||||
|
||||
Options:
|
||||
--hooks Only run hooks linter
|
||||
--skills Only run skills linter
|
||||
--plugins Only run plugins linter
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
bun scripts/lint-claude-plugin.ts # Run all linters
|
||||
bun scripts/lint-claude-plugin.ts --hooks # Only lint hooks
|
||||
bun scripts/lint-claude-plugin.ts outfitter/ # Lint specific directory
|
||||
|
||||
Linters:
|
||||
hooks Validates hooks.json format (must have "hooks" wrapper)
|
||||
Ensures plugin.json doesn't have hooks field (auto-discovery)
|
||||
|
||||
skills Validates SKILL.md frontmatter (name, description, version)
|
||||
Checks for redundant fields (user-invocable: true)
|
||||
Warns on file length > 500 lines
|
||||
|
||||
plugins Validates plugin.json required fields
|
||||
Validates marketplace.json structure
|
||||
Checks plugin directory conventions
|
||||
`);
|
||||
}
|
||||
|
||||
function printViolations(
|
||||
title: string,
|
||||
result: LintResult | null
|
||||
): void {
|
||||
if (!result || result.violations.length === 0) return;
|
||||
|
||||
console.log(`\n${title}`);
|
||||
console.log("─".repeat(50));
|
||||
|
||||
// Group by file
|
||||
const byFile = new Map<string, typeof result.violations>();
|
||||
for (const v of result.violations) {
|
||||
const existing = byFile.get(v.file) || [];
|
||||
existing.push(v);
|
||||
byFile.set(v.file, existing);
|
||||
}
|
||||
|
||||
for (const [file, violations] of byFile) {
|
||||
console.log(`\n${relativePath(file)}:`);
|
||||
for (const v of violations) {
|
||||
const prefix = v.severity === "error" ? "✗" : "△";
|
||||
console.log(` ${prefix} ${v.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Handle help
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Parse options
|
||||
const runHooks = args.includes("--hooks");
|
||||
const runSkills = args.includes("--skills");
|
||||
const runPlugins = args.includes("--plugins");
|
||||
const runAll = !runHooks && !runSkills && !runPlugins;
|
||||
|
||||
// Get search path
|
||||
const paths = args.filter((arg) => !arg.startsWith("--"));
|
||||
const searchPath = paths[0] || ".";
|
||||
|
||||
// Validate search path
|
||||
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);
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(searchPath);
|
||||
console.log(`\n🔍 Linting Claude Code plugins in ${relativePath(resolvedPath)}\n`);
|
||||
|
||||
const result: CombinedResult = {
|
||||
passed: true,
|
||||
hooks: null,
|
||||
skills: null,
|
||||
plugins: null,
|
||||
totalErrors: 0,
|
||||
totalWarnings: 0,
|
||||
};
|
||||
|
||||
// Run selected linters
|
||||
if (runAll || runHooks) {
|
||||
console.log("Checking hooks...");
|
||||
result.hooks = await lintHooks(searchPath);
|
||||
if (!result.hooks.passed) result.passed = false;
|
||||
}
|
||||
|
||||
if (runAll || runSkills) {
|
||||
console.log("Checking skills...");
|
||||
result.skills = await lintSkills(searchPath);
|
||||
if (!result.skills.passed) result.passed = false;
|
||||
}
|
||||
|
||||
if (runAll || runPlugins) {
|
||||
console.log("Checking plugins...");
|
||||
result.plugins = await lintPlugins(searchPath);
|
||||
if (!result.plugins.passed) result.passed = false;
|
||||
}
|
||||
|
||||
// Count totals
|
||||
const allViolations = [
|
||||
...(result.hooks?.violations || []),
|
||||
...(result.skills?.violations || []),
|
||||
...(result.plugins?.violations || []),
|
||||
];
|
||||
|
||||
result.totalErrors = allViolations.filter((v) => v.severity === "error").length;
|
||||
result.totalWarnings = allViolations.filter((v) => v.severity === "warning").length;
|
||||
|
||||
// Print results
|
||||
printViolations("Hooks Issues", result.hooks);
|
||||
printViolations("Skills Issues", result.skills);
|
||||
printViolations("Plugins Issues", result.plugins);
|
||||
|
||||
// Summary
|
||||
console.log("\n" + "═".repeat(50));
|
||||
|
||||
if (result.totalErrors === 0 && result.totalWarnings === 0) {
|
||||
console.log("\n✅ All checks passed\n");
|
||||
} else if (result.totalErrors === 0) {
|
||||
console.log(`\n✓ Passed with ${result.totalWarnings} warning(s)\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`\n❌ Failed: ${result.totalErrors} error(s), ${result.totalWarnings} warning(s)\n`
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(result.passed ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Lint metadata.related-skills bidirectionality across all SKILL.md files.
|
||||
*
|
||||
* Validates that if skill A lists skill B in metadata.related-skills,
|
||||
* skill B must also list skill A.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint-related-skills.ts [path]
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/lint-related-skills.ts # Lint all SKILL.md files
|
||||
* bun scripts/lint-related-skills.ts outfitter/ # Lint specific plugin
|
||||
*/
|
||||
|
||||
import { Glob } from "bun";
|
||||
import { statSync } from "node:fs";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Parsed skill metadata from SKILL.md frontmatter.
|
||||
*/
|
||||
interface SkillInfo {
|
||||
/** File path to SKILL.md */
|
||||
path: string;
|
||||
/** Skill name from frontmatter or directory name */
|
||||
name: string;
|
||||
/** Related skills listed in metadata */
|
||||
relatedSkills: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A bidirectionality violation in related-skills metadata.
|
||||
*/
|
||||
interface Violation {
|
||||
/** Skill that declares the relationship */
|
||||
sourceSkill: string;
|
||||
/** Path to source skill's SKILL.md */
|
||||
sourcePath: string;
|
||||
/** Skill referenced in related-skills */
|
||||
targetSkill: string;
|
||||
/** Description of the violation */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the indentation level of a line (number of spaces).
|
||||
*/
|
||||
function getIndent(line: string): number {
|
||||
const match = line.match(/^(\s*)/);
|
||||
return match ? match[1].length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content.
|
||||
* Handles nested structures like metadata.related-skills.
|
||||
*/
|
||||
function parseYamlFrontmatter(content: string): Record<string, unknown> | null {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!match) return null;
|
||||
|
||||
const lines = match[1].split("\n");
|
||||
const result: Record<string, unknown> = {};
|
||||
const stack: Array<{ indent: number; obj: Record<string, unknown>; key?: string }> = [
|
||||
{ indent: -1, obj: result },
|
||||
];
|
||||
|
||||
let currentArray: string[] | null = null;
|
||||
let arrayIndent = -1;
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip empty lines
|
||||
if (line.trim() === "") continue;
|
||||
|
||||
const indent = getIndent(line);
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for array item
|
||||
if (trimmed.startsWith("- ")) {
|
||||
const value = trimmed.slice(2).trim();
|
||||
if (currentArray !== null) {
|
||||
currentArray.push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we were collecting an array, we're done with it
|
||||
if (currentArray !== null && indent <= arrayIndent) {
|
||||
currentArray = null;
|
||||
arrayIndent = -1;
|
||||
}
|
||||
|
||||
// Parse key: value
|
||||
const colonIndex = trimmed.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, colonIndex).trim();
|
||||
const value = trimmed.slice(colonIndex + 1).trim();
|
||||
|
||||
// Pop stack until we find the right parent
|
||||
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
const parent = stack[stack.length - 1].obj;
|
||||
|
||||
if (value === "" || value === "|" || value === ">") {
|
||||
// This could be a nested object or an array
|
||||
// We'll find out on the next line
|
||||
const newObj: Record<string, unknown> = {};
|
||||
parent[key] = newObj;
|
||||
stack.push({ indent, obj: newObj, key });
|
||||
|
||||
// Check next lines to see if it's an array
|
||||
const lineIndex = lines.indexOf(line);
|
||||
if (lineIndex < lines.length - 1) {
|
||||
const nextLine = lines[lineIndex + 1];
|
||||
if (nextLine.trim().startsWith("- ")) {
|
||||
// It's an array
|
||||
currentArray = [];
|
||||
arrayIndent = indent;
|
||||
parent[key] = currentArray;
|
||||
stack.pop(); // Don't need the object
|
||||
}
|
||||
}
|
||||
} else {
|
||||
parent[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract skill name from frontmatter or derive from path.
|
||||
*/
|
||||
function getSkillName(frontmatter: Record<string, unknown>, skillPath: string): string {
|
||||
// Prefer name from frontmatter
|
||||
if (typeof frontmatter.name === "string" && frontmatter.name) {
|
||||
return frontmatter.name;
|
||||
}
|
||||
// Fall back to directory name (parent of SKILL.md)
|
||||
return basename(dirname(skillPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract related-skills array from frontmatter.
|
||||
* Looks in metadata.related-skills (nested structure).
|
||||
*/
|
||||
function getRelatedSkills(frontmatter: Record<string, unknown>): string[] {
|
||||
// Check metadata.related-skills (the nested format)
|
||||
const metadata = frontmatter.metadata;
|
||||
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
|
||||
const metadataObj = metadata as Record<string, unknown>;
|
||||
const related = metadataObj["related-skills"];
|
||||
if (Array.isArray(related)) {
|
||||
return related.map((s) => String(s).trim()).filter((s) => s.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check top-level related_skills for backwards compatibility
|
||||
const topLevel = frontmatter["related-skills"] || frontmatter.related_skills;
|
||||
if (Array.isArray(topLevel)) {
|
||||
return topLevel.map((s) => String(s).trim()).filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all SKILL.md files and parse their metadata.
|
||||
*/
|
||||
async function findSkills(searchPath: string): Promise<SkillInfo[]> {
|
||||
const skills: SkillInfo[] = [];
|
||||
const glob = new Glob("**/SKILL.md");
|
||||
|
||||
for await (const file of glob.scan({
|
||||
cwd: searchPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
// Skip common excludes
|
||||
if (
|
||||
file.includes("node_modules") ||
|
||||
file.includes(".git") ||
|
||||
file.includes(".beads") ||
|
||||
file.includes("templates/") ||
|
||||
file.includes(".archive")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(file).text();
|
||||
const frontmatter = parseYamlFrontmatter(content);
|
||||
|
||||
if (!frontmatter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = getSkillName(frontmatter, file);
|
||||
const relatedSkills = getRelatedSkills(frontmatter);
|
||||
|
||||
skills.push({
|
||||
path: file,
|
||||
name,
|
||||
relatedSkills,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Warning: Failed to parse ${file}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of skill name -> SkillInfo for quick lookups.
|
||||
*/
|
||||
function buildSkillMap(skills: SkillInfo[]): Map<string, SkillInfo> {
|
||||
const map = new Map<string, SkillInfo>();
|
||||
for (const skill of skills) {
|
||||
if (map.has(skill.name)) {
|
||||
console.error(
|
||||
`Warning: Duplicate skill name "${skill.name}" found at:\n` +
|
||||
` - ${map.get(skill.name)!.path}\n` +
|
||||
` - ${skill.path}`
|
||||
);
|
||||
}
|
||||
map.set(skill.name, skill);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bidirectional relationships.
|
||||
*/
|
||||
function validateBidirectionality(
|
||||
skills: SkillInfo[],
|
||||
skillMap: Map<string, SkillInfo>
|
||||
): Violation[] {
|
||||
const violations: Violation[] = [];
|
||||
|
||||
for (const skill of skills) {
|
||||
for (const related of skill.relatedSkills) {
|
||||
const targetSkill = skillMap.get(related);
|
||||
|
||||
if (!targetSkill) {
|
||||
// Target skill doesn't exist - this is a warning, not a bidirectionality violation
|
||||
violations.push({
|
||||
sourceSkill: skill.name,
|
||||
sourcePath: skill.path,
|
||||
targetSkill: related,
|
||||
message: `"${skill.name}" references non-existent skill "${related}"`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if target skill lists source skill
|
||||
if (!targetSkill.relatedSkills.includes(skill.name)) {
|
||||
violations.push({
|
||||
sourceSkill: skill.name,
|
||||
sourcePath: skill.path,
|
||||
targetSkill: related,
|
||||
message: `"${skill.name}" lists "${related}" but "${related}" does not list "${skill.name}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 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 = resolve(paths[0] || ".");
|
||||
|
||||
// Validate search path
|
||||
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(`\nScanning for SKILL.md files in ${relativePath(searchPath)}...\n`);
|
||||
|
||||
// Find all skills
|
||||
const skills = await findSkills(searchPath);
|
||||
const skillsWithRelated = skills.filter((s) => s.relatedSkills.length > 0);
|
||||
|
||||
console.log(`Found ${skills.length} skills, ${skillsWithRelated.length} with metadata.related-skills\n`);
|
||||
|
||||
if (skillsWithRelated.length === 0) {
|
||||
console.log("No skills with metadata.related-skills found. Nothing to validate.\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Build lookup map
|
||||
const skillMap = buildSkillMap(skills);
|
||||
|
||||
// Validate bidirectionality
|
||||
const violations = validateBidirectionality(skills, skillMap);
|
||||
|
||||
// Separate missing skills from bidirectionality violations
|
||||
const missingSkills = violations.filter((v) => v.message.includes("non-existent"));
|
||||
const bidirectionalViolations = violations.filter((v) => !v.message.includes("non-existent"));
|
||||
|
||||
// Report missing skill references
|
||||
if (missingSkills.length > 0) {
|
||||
console.log("Missing skill references:");
|
||||
console.log("-".repeat(50));
|
||||
for (const v of missingSkills) {
|
||||
console.log(` ${relativePath(v.sourcePath)}:`);
|
||||
console.log(` ${v.message}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Report bidirectionality violations
|
||||
if (bidirectionalViolations.length > 0) {
|
||||
console.log("Bidirectionality violations:");
|
||||
console.log("-".repeat(50));
|
||||
for (const v of bidirectionalViolations) {
|
||||
console.log(` ${relativePath(v.sourcePath)}:`);
|
||||
console.log(` ${v.message}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("-".repeat(50));
|
||||
if (violations.length === 0) {
|
||||
console.log(`\nAll ${skillsWithRelated.length} skill relationships are bidirectional.\n`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(
|
||||
`\nFound ${violations.length} issue(s): ` +
|
||||
`${missingSkills.length} missing reference(s), ` +
|
||||
`${bidirectionalViolations.length} bidirectionality violation(s)\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Lint XML tags in markdown files for proper blank line formatting.
|
||||
*
|
||||
* Checks that skill/instruction XML tags have proper blank lines for GitHub rendering:
|
||||
* - Opening XML tags have a blank line after them
|
||||
* - Closing XML tags have a blank line before them
|
||||
*
|
||||
* Ignores HTML tags and content inside code blocks.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/lint-xml-tags.ts [path]
|
||||
* bun scripts/lint-xml-tags.ts --fix [path]
|
||||
*
|
||||
* Examples:
|
||||
* bun scripts/lint-xml-tags.ts # Lint all .md files
|
||||
* bun scripts/lint-xml-tags.ts outfitter/ # Lint specific directory
|
||||
* bun scripts/lint-xml-tags.ts path/to/file.md # Lint single file
|
||||
* bun scripts/lint-xml-tags.ts --fix # Auto-fix all issues
|
||||
* bun scripts/lint-xml-tags.ts --fix file.md # Fix single file
|
||||
*/
|
||||
|
||||
import { Glob } from "bun";
|
||||
import { statSync } from "fs";
|
||||
|
||||
/**
|
||||
* An XML tag formatting violation in a markdown file.
|
||||
*/
|
||||
interface Violation {
|
||||
/** File path where violation was found */
|
||||
file: string;
|
||||
/** Line number of the violation */
|
||||
line: number;
|
||||
/** The XML tag that has the issue */
|
||||
tag: string;
|
||||
/** Whether it's an opening or closing tag */
|
||||
type: "opening" | "closing";
|
||||
/** Description of the formatting issue */
|
||||
message: string;
|
||||
}
|
||||
|
||||
// XML tag patterns - matches tags like <when_to_use>, </rules>, etc.
|
||||
const OPENING_TAG = /^(\s*)<([a-z][a-z0-9_-]*)>\s*$/;
|
||||
const CLOSING_TAG = /^(\s*)<\/([a-z][a-z0-9_-]*)>\s*$/;
|
||||
|
||||
// HTML tags to ignore - these are meant for inline/code use, not skill structure
|
||||
const HTML_TAGS = new Set([
|
||||
// Common HTML elements
|
||||
"a",
|
||||
"abbr",
|
||||
"address",
|
||||
"article",
|
||||
"aside",
|
||||
"b",
|
||||
"blockquote",
|
||||
"body",
|
||||
"br",
|
||||
"button",
|
||||
"canvas",
|
||||
"caption",
|
||||
"cite",
|
||||
"code",
|
||||
"col",
|
||||
"colgroup",
|
||||
"data",
|
||||
"datalist",
|
||||
"dd",
|
||||
"del",
|
||||
"details",
|
||||
"dfn",
|
||||
"dialog",
|
||||
"div",
|
||||
"dl",
|
||||
"dt",
|
||||
"em",
|
||||
"embed",
|
||||
"fieldset",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"footer",
|
||||
"form",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"head",
|
||||
"header",
|
||||
"hgroup",
|
||||
"hr",
|
||||
"html",
|
||||
"i",
|
||||
"iframe",
|
||||
"img",
|
||||
"input",
|
||||
"ins",
|
||||
"kbd",
|
||||
"label",
|
||||
"legend",
|
||||
"li",
|
||||
"link",
|
||||
"main",
|
||||
"map",
|
||||
"mark",
|
||||
"menu",
|
||||
"meta",
|
||||
"meter",
|
||||
"nav",
|
||||
"noscript",
|
||||
"object",
|
||||
"ol",
|
||||
"optgroup",
|
||||
"option",
|
||||
"output",
|
||||
"p",
|
||||
"param",
|
||||
"picture",
|
||||
"pre",
|
||||
"progress",
|
||||
"q",
|
||||
"rp",
|
||||
"rt",
|
||||
"ruby",
|
||||
"s",
|
||||
"samp",
|
||||
"script",
|
||||
"section",
|
||||
"select",
|
||||
"slot",
|
||||
"small",
|
||||
"source",
|
||||
"span",
|
||||
"strong",
|
||||
"style",
|
||||
"sub",
|
||||
"summary",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"template",
|
||||
"textarea",
|
||||
"tfoot",
|
||||
"th",
|
||||
"thead",
|
||||
"time",
|
||||
"title",
|
||||
"tr",
|
||||
"track",
|
||||
"u",
|
||||
"ul",
|
||||
"var",
|
||||
"video",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
// Additional tags to ignore (example/documentation patterns)
|
||||
const IGNORE_TAGS = new Set([
|
||||
"example",
|
||||
"commentary",
|
||||
"turn",
|
||||
"user",
|
||||
"assistant",
|
||||
"snapshot-commit",
|
||||
]);
|
||||
|
||||
function isBlankLine(line: string): boolean {
|
||||
return line.trim() === "";
|
||||
}
|
||||
|
||||
function lintFile(content: string, filePath: string): Violation[] {
|
||||
const lines = content.split("\n");
|
||||
const violations: Violation[] = [];
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineNum = i + 1;
|
||||
|
||||
// Track code blocks (``` or ~~~)
|
||||
if (line.trim().startsWith("```") || line.trim().startsWith("~~~")) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip content inside code blocks
|
||||
if (inCodeBlock) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check opening tags
|
||||
const openMatch = line.match(OPENING_TAG);
|
||||
if (openMatch) {
|
||||
const tag = openMatch[2];
|
||||
if (!IGNORE_TAGS.has(tag) && !HTML_TAGS.has(tag)) {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine !== undefined && !isBlankLine(nextLine)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
tag: `<${tag}>`,
|
||||
type: "opening",
|
||||
message: `Opening tag <${tag}> should have a blank line after it`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check closing tags
|
||||
const closeMatch = line.match(CLOSING_TAG);
|
||||
if (closeMatch) {
|
||||
const tag = closeMatch[2];
|
||||
if (!IGNORE_TAGS.has(tag) && !HTML_TAGS.has(tag)) {
|
||||
const prevLine = lines[i - 1];
|
||||
if (prevLine !== undefined && !isBlankLine(prevLine)) {
|
||||
violations.push({
|
||||
file: filePath,
|
||||
line: lineNum,
|
||||
tag: `</${tag}>`,
|
||||
type: "closing",
|
||||
message: `Closing tag </${tag}> should have a blank line before it`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
function fixFile(content: string): string {
|
||||
const lines = content.split("\n");
|
||||
const result: string[] = [];
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const prevLine = result[result.length - 1];
|
||||
|
||||
// Track code blocks (``` or ~~~)
|
||||
if (line.trim().startsWith("```") || line.trim().startsWith("~~~")) {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip modifications inside code blocks
|
||||
if (inCodeBlock) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a closing tag that needs a blank line before
|
||||
const closeMatch = line.match(CLOSING_TAG);
|
||||
if (closeMatch && !IGNORE_TAGS.has(closeMatch[2]) && !HTML_TAGS.has(closeMatch[2])) {
|
||||
if (prevLine !== undefined && !isBlankLine(prevLine)) {
|
||||
result.push("");
|
||||
}
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
|
||||
// Check if this is an opening tag that needs a blank line after
|
||||
const openMatch = line.match(OPENING_TAG);
|
||||
if (openMatch && !IGNORE_TAGS.has(openMatch[2]) && !HTML_TAGS.has(openMatch[2])) {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine !== undefined && !isBlankLine(nextLine)) {
|
||||
result.push("");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const fix = args.includes("--fix");
|
||||
const paths = args.filter((arg) => !arg.startsWith("--"));
|
||||
const searchPath = paths[0] || ".";
|
||||
|
||||
// Check if searchPath is a file or directory
|
||||
let files: string[] = [];
|
||||
|
||||
try {
|
||||
const stat = statSync(searchPath);
|
||||
if (stat.isFile()) {
|
||||
files = [searchPath];
|
||||
} else {
|
||||
const glob = new Glob("**/*.md");
|
||||
for await (const file of glob.scan({
|
||||
cwd: searchPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
// Skip node_modules and other common excludes
|
||||
if (
|
||||
file.includes("node_modules") ||
|
||||
file.includes(".git") ||
|
||||
file.includes(".beads")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error(`Error: Path not found: ${searchPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let totalViolations = 0;
|
||||
let fixedFiles = 0;
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = await Bun.file(filePath).text();
|
||||
const violations = lintFile(content, filePath);
|
||||
|
||||
if (violations.length > 0) {
|
||||
if (fix) {
|
||||
const fixed = fixFile(content);
|
||||
await Bun.write(filePath, fixed);
|
||||
console.log(`Fixed: ${filePath} (${violations.length} issues)`);
|
||||
fixedFiles++;
|
||||
} else {
|
||||
for (const v of violations) {
|
||||
const relativePath = v.file.replace(process.cwd() + "/", "");
|
||||
console.log(`${relativePath}:${v.line}: ${v.message}`);
|
||||
}
|
||||
}
|
||||
totalViolations += violations.length;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
if (fix) {
|
||||
console.log(`Fixed ${totalViolations} issues in ${fixedFiles} files`);
|
||||
} else if (totalViolations > 0) {
|
||||
console.log(`Found ${totalViolations} issues in ${files.length} files`);
|
||||
console.log("Run with --fix to auto-fix");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`Checked ${files.length} files - no issues found`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Migrate SKILL.md files to move top-level `version` to `metadata.version`
|
||||
* per agentskills.io specification.
|
||||
*
|
||||
* Usage:
|
||||
* bun scripts/migrate-skill-version.ts [--dry-run] [path]
|
||||
*/
|
||||
|
||||
import { Glob } from "bun";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function relativePath(absolutePath: string): string {
|
||||
return absolutePath.replace(process.cwd() + "/", "");
|
||||
}
|
||||
|
||||
function migrateFile(filePath: string, dryRun: boolean): boolean {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
|
||||
// Check if file has frontmatter
|
||||
if (!content.startsWith("---\n")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endIndex = content.indexOf("\n---\n", 4);
|
||||
if (endIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const frontmatter = content.slice(4, endIndex);
|
||||
const body = content.slice(endIndex + 5);
|
||||
|
||||
// Check if there's a top-level version
|
||||
const versionMatch = frontmatter.match(/^version:\s*(.+)$/m);
|
||||
if (!versionMatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const version = versionMatch[1].trim().replace(/^["']|["']$/g, "");
|
||||
|
||||
// Remove top-level version
|
||||
let newFrontmatter = frontmatter.replace(/^version:\s*.+\n?/m, "");
|
||||
|
||||
// Check if metadata exists
|
||||
const metadataMatch = newFrontmatter.match(/^metadata:\s*$/m);
|
||||
|
||||
if (metadataMatch) {
|
||||
// Find metadata section and add version to it
|
||||
const metadataIndex = newFrontmatter.indexOf("metadata:");
|
||||
const afterMetadata = newFrontmatter.slice(metadataIndex + 9);
|
||||
|
||||
// Find the indentation of metadata items
|
||||
const indentMatch = afterMetadata.match(/\n(\s+)\S/);
|
||||
const indent = indentMatch ? indentMatch[1] : " ";
|
||||
|
||||
// Insert version after metadata:
|
||||
newFrontmatter =
|
||||
newFrontmatter.slice(0, metadataIndex + 9) +
|
||||
`\n${indent}version: "${version}"` +
|
||||
afterMetadata;
|
||||
} else {
|
||||
// Add metadata section with version
|
||||
newFrontmatter = newFrontmatter.trimEnd() + `\nmetadata:\n version: "${version}"\n`;
|
||||
}
|
||||
|
||||
// Clean up any double newlines in frontmatter
|
||||
newFrontmatter = newFrontmatter.replace(/\n{3,}/g, "\n\n").trim();
|
||||
|
||||
const newContent = `---\n${newFrontmatter}\n---\n${body}`;
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`Would update: ${relativePath(filePath)}`);
|
||||
console.log(` version: ${version} -> metadata.version: "${version}"`);
|
||||
} else {
|
||||
writeFileSync(filePath, newContent);
|
||||
console.log(`Updated: ${relativePath(filePath)}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const paths = args.filter((arg) => !arg.startsWith("--"));
|
||||
const searchPath = resolve(paths[0] || ".");
|
||||
|
||||
console.log(
|
||||
`\n${dryRun ? "[DRY RUN] " : ""}Migrating SKILL.md version to metadata.version...\n`
|
||||
);
|
||||
|
||||
const glob = new Glob("**/SKILL.md");
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for await (const file of glob.scan({
|
||||
cwd: searchPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
})) {
|
||||
if (
|
||||
file.includes("node_modules") ||
|
||||
file.includes(".git") ||
|
||||
file.includes(".archive") ||
|
||||
file.includes("templates/")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (migrateFile(file, dryRun)) {
|
||||
updated++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${dryRun ? "[DRY RUN] " : ""}Done.`);
|
||||
console.log(` Updated: ${updated}`);
|
||||
console.log(` Skipped: ${skipped} (no top-level version)\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Syncs package.json versions to marketplace.json after `changeset version` runs.
|
||||
*
|
||||
* - Reads each plugin's package.json version
|
||||
* - Updates corresponding entry in marketplace.json
|
||||
* - Updates metadata.version to match highest plugin version
|
||||
* - Skips external plugins (those with object source)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = import.meta.dirname ? join(import.meta.dirname, "..") : process.cwd();
|
||||
const MARKETPLACE_PATH = join(ROOT, ".claude-plugin/marketplace.json");
|
||||
|
||||
// Local plugins tracked by changesets
|
||||
const LOCAL_PLUGINS = ["outfitter", "but", "gt", "cli-dev", "outfitter-stack"];
|
||||
|
||||
interface Plugin {
|
||||
name: string;
|
||||
source: string | object;
|
||||
version?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Marketplace {
|
||||
metadata: { version: string; [key: string]: unknown };
|
||||
plugins: Plugin[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function readJson<T>(path: string): T {
|
||||
return JSON.parse(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
function writeJson(path: string, data: unknown): void {
|
||||
writeFileSync(path, JSON.stringify(data, null, "\t") + "\n");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const marketplace = readJson<Marketplace>(MARKETPLACE_PATH);
|
||||
let highestVersion = "0.0.0";
|
||||
|
||||
for (const pluginName of LOCAL_PLUGINS) {
|
||||
const pkgPath = join(ROOT, "plugins", pluginName, "package.json");
|
||||
|
||||
let pkg: { version: string };
|
||||
try {
|
||||
pkg = readJson<{ version: string }>(pkgPath);
|
||||
} catch {
|
||||
console.warn(`⚠ Skipping ${pluginName}: no package.json found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const version = pkg.version;
|
||||
if (!version) {
|
||||
console.warn(`⚠ Skipping ${pluginName}: no version in package.json`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update plugin version in marketplace.json
|
||||
const plugin = marketplace.plugins.find((p) => p.name === pluginName);
|
||||
if (plugin && typeof plugin.source === "string") {
|
||||
const oldVersion = plugin.version;
|
||||
plugin.version = version;
|
||||
if (oldVersion !== version) {
|
||||
console.log(`✓ ${pluginName}: ${oldVersion} → ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Track highest version for metadata
|
||||
if (compareVersions(version, highestVersion) > 0) {
|
||||
highestVersion = version;
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata.version to highest
|
||||
const oldMetaVersion = marketplace.metadata.version;
|
||||
if (oldMetaVersion !== highestVersion) {
|
||||
marketplace.metadata.version = highestVersion;
|
||||
console.log(`✓ metadata.version: ${oldMetaVersion} → ${highestVersion}`);
|
||||
}
|
||||
|
||||
writeJson(MARKETPLACE_PATH, marketplace);
|
||||
console.log("✓ marketplace.json synced");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semver versions. Returns:
|
||||
* - positive if a > b
|
||||
* - negative if a < b
|
||||
* - 0 if equal
|
||||
*/
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const [aMajor, aMinor, aPatch] = a.split(".").map(Number);
|
||||
const [bMajor, bMinor, bPatch] = b.split(".").map(Number);
|
||||
|
||||
if (aMajor !== bMajor) return aMajor - bMajor;
|
||||
if (aMinor !== bMinor) return aMinor - bMinor;
|
||||
return aPatch - bPatch;
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user