import { Command } from "commander"; import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, chmodSync, } from "node:fs"; import { resolve, join } from "node:path"; import { homedir } from "node:os"; import chalk from "chalk"; import { parseSkillMd } from "@skills-hub-ai/skill-parser"; import { CATEGORY_SLUGS } from "@skills-hub-ai/shared"; export type Severity = "error" | "warning"; export interface LintViolation { file: string; line: number; rule: string; severity: Severity; message: string; fixable: boolean; } export interface LintResult { violations: LintViolation[]; filesChecked: number; fixed: number; } const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?(\+[\w.]+)?$/; const DASH_RE = /[—–]/g; const UNFENCED_RE = /^```\s*$/gm; const TRAILING_WS_RE = /[ \t]+(?=\r?\n|$)/gm; function lineNumber(content: string, index: number): number { return content.slice(0, index).split("\n").length; } function lintFile( filePath: string, content: string, isSkillMd: boolean, username: string, fix: boolean, ): { violations: LintViolation[]; content: string; fixed: number } { const violations: LintViolation[] = []; let fixed = 0; let out = content; const lines = content.split("\n"); const rel = filePath; // Rule: 200-line cap if (lines.length > 200) { violations.push({ file: rel, line: 200, rule: "line-limit", severity: "error", message: `File has ${lines.length} lines (max 200)`, fixable: false, }); } // Frontmatter rules — only for SKILL.md if (isSkillMd) { const parsed = parseSkillMd(content); if (!parsed.success) { for (const err of parsed.errors) { violations.push({ file: rel, line: 1, rule: "frontmatter", severity: "error", message: `${err.field}: ${err.message}`, fixable: false, }); } } else if (parsed.skill) { const { version, category } = parsed.skill; if (version && !SEMVER_RE.test(version)) { violations.push({ file: rel, line: 1, rule: "semver", severity: "error", message: `version "${version}" is not valid semver (expected x.y.z)`, fixable: false, }); } if ( category && !(CATEGORY_SLUGS as readonly string[]).includes(category) ) { violations.push({ file: rel, line: 1, rule: "category", severity: "error", message: `category "${category}" is not in the allowed list`, fixable: false, }); } } } // Rule: no em/en dashes (warning, fixable → replace with hyphen) { let m: RegExpExecArray | null; const re = new RegExp(DASH_RE.source, "g"); while ((m = re.exec(content)) !== null) { violations.push({ file: rel, line: lineNumber(content, m.index), rule: "no-fancy-dash", severity: "warning", message: `Em/en dash found — use a plain hyphen instead`, fixable: true, }); } if (fix && DASH_RE.test(out)) { out = out.replace(new RegExp(DASH_RE.source, "g"), "-"); fixed++; } } // Rule: no personal username in paths if (username) { for (let i = 0; i < lines.length; i++) { if ( lines[i].includes(`/Users/${username}`) || lines[i].includes(`/home/${username}`) ) { violations.push({ file: rel, line: i + 1, rule: "no-personal-path", severity: "error", message: `Personal path containing "${username}" found — remove before publishing`, fixable: false, }); } } } // Rule: code fences must have a language tag { let m: RegExpExecArray | null; const re = new RegExp(UNFENCED_RE.source, "gm"); while ((m = re.exec(content)) !== null) { violations.push({ file: rel, line: lineNumber(content, m.index), rule: "code-fence-lang", severity: "warning", message: "Code fence is missing a language tag (e.g. ```bash)", fixable: false, }); } } // Rule: no trailing whitespace (warning, fixable) { let m: RegExpExecArray | null; const re = new RegExp(TRAILING_WS_RE.source, "gm"); let hasTrailing = false; while ((m = re.exec(content)) !== null) { if (m[0].length > 0) { violations.push({ file: rel, line: lineNumber(content, m.index), rule: "no-trailing-whitespace", severity: "warning", message: "Trailing whitespace", fixable: true, }); hasTrailing = true; break; // one violation per file is sufficient } } if (fix && hasTrailing) { out = out.replace(new RegExp(TRAILING_WS_RE.source, "gm"), ""); fixed++; } } // Rule: file must end with a single trailing newline (warning, fixable) if (!content.endsWith("\n") || content.endsWith("\n\n")) { violations.push({ file: rel, line: lines.length, rule: "trailing-newline", severity: "warning", message: content.endsWith("\n\n") ? "File has multiple trailing newlines (expected exactly one)" : "File must end with a single newline", fixable: true, }); if (fix) { out = out.replace(/\n*$/, "\n"); fixed++; } } return { violations, content: out, fixed }; } export function lintSkillDir( dir: string, options: { fix?: boolean } = {}, ): LintResult { const absDir = resolve(dir); if (!existsSync(absDir)) { throw new Error(`Path does not exist: ${absDir}`); } const username = homedir().split("/").pop() ?? ""; const fix = options.fix ?? false; const entries = readdirSync(absDir).filter((f) => f.endsWith(".md")); if (entries.length === 0) { throw new Error(`No .md files found in ${absDir}`); } const violations: LintViolation[] = []; let totalFixed = 0; for (const entry of entries) { const filePath = join(absDir, entry); const isSkillMd = entry === "SKILL.md"; let content: string; try { content = readFileSync(filePath, "utf-8"); } catch { violations.push({ file: entry, line: 0, rule: "readable", severity: "error", message: `Cannot read file`, fixable: false, }); continue; } const { violations: fileViolations, content: fixedContent, fixed, } = lintFile(entry, content, isSkillMd, username, fix); violations.push(...fileViolations); totalFixed += fixed; if (fix && fixedContent !== content) { writeFileSync(filePath, fixedContent, "utf-8"); } } return { violations, filesChecked: entries.length, fixed: totalFixed, }; } function installPreCommitHook(skillDir: string): void { const gitRoot = findGitRoot(resolve(skillDir)); if (!gitRoot) { throw new Error("Not inside a git repository — cannot install hook"); } const hooksDir = join(gitRoot, ".git", "hooks"); if (!existsSync(hooksDir)) { mkdirSync(hooksDir, { recursive: true }); } const hookPath = join(hooksDir, "pre-commit"); const script = `#!/bin/sh # skills-hub pre-commit lint hook # Installed by: skills-hub lint --install-hook set -e npx @skills-hub-ai/cli lint . `; writeFileSync(hookPath, script, "utf-8"); chmodSync(hookPath, 0o755); } function findGitRoot(dir: string): string | null { let current = dir; while (true) { if (existsSync(join(current, ".git"))) return current; const parent = join(current, ".."); if (parent === current) return null; current = parent; } } function formatViolations(result: LintResult, dir: string): void { const errors = result.violations.filter((v) => v.severity === "error"); const warnings = result.violations.filter((v) => v.severity === "warning"); for (const v of result.violations) { const loc = chalk.dim(`${v.file}:${v.line}`); const badge = v.severity === "error" ? chalk.red("error") : chalk.yellow("warning"); const rule = chalk.dim(`[${v.rule}]`); console.log(` ${loc} ${badge} ${v.message} ${rule}`); } if (result.violations.length > 0) { console.log(); } const filesLabel = `${result.filesChecked} file${result.filesChecked !== 1 ? "s" : ""}`; if (errors.length === 0 && warnings.length === 0) { console.log(chalk.green(`✓ OK — ${filesLabel} checked, no issues found`)); } else { const summary = [ errors.length > 0 ? chalk.red(`${errors.length} error${errors.length !== 1 ? "s" : ""}`) : null, warnings.length > 0 ? chalk.yellow( `${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`, ) : null, ] .filter(Boolean) .join(", "); console.log(`${filesLabel} checked — ${summary}`); if (warnings.length > 0) { console.log( chalk.dim("Run with --fix to auto-fix warnings marked as fixable"), ); } } } export const lintCommand = new Command("lint") .description("Lint a skill directory for policy violations before publishing") .argument( "[path]", "path to skill directory (defaults to current directory)", ".", ) .option( "--fix", "auto-fix warnings that are fixable (em dashes, trailing whitespace, newlines)", ) .option( "--install-hook", "install a git pre-commit hook that runs lint automatically", ) .action((dir: string, options: { fix?: boolean; installHook?: boolean }) => { if (options.installHook) { try { installPreCommitHook(dir); console.log( chalk.green("✓ Pre-commit hook installed at .git/hooks/pre-commit"), ); console.log(chalk.dim(" Hook runs: npx @skills-hub-ai/cli lint .")); } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } return; } let result: LintResult; try { result = lintSkillDir(dir, { fix: options.fix }); } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); process.exit(1); } if (options.fix && result.fixed > 0) { console.log( chalk.green( `✓ Fixed ${result.fixed} auto-fixable issue${result.fixed !== 1 ? "s" : ""}`, ), ); } formatViolations(result, dir); const errors = result.violations.filter((v) => v.severity === "error"); if (errors.length > 0) { process.exit(1); } });