📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-05-29 08:33:53 +00:00
parent fdb52f1e96
commit 06e0d13d57
1615 changed files with 232858 additions and 0 deletions
+109
View File
@@ -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
View File
@@ -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);
});
+234
View File
@@ -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
View File
@@ -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);
});
+351
View File
@@ -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);
});
+104
View File
@@ -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();