/**
* Lint SKILL.md files for patterns that break Claude Code's bash permission checker.
*
* Claude Code scans skill content for shell-like patterns. Inline backtick code
* containing `!` (history expansion) or `>` (output redirection) outside of fenced
* code blocks triggers false positives and prevents the skill from loading.
*
* Safe: fenced code blocks (```...```), HTML tags in backticks (`
`)
* Unsafe: `!` followed by `>` later in the same text block
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const SKILLS_DIR = join(import.meta.dirname, "..", "skills");
interface Violation {
file: string;
line: number;
message: string;
text: string;
}
// Patterns that trigger Claude Code's bash permission checker when found in
// inline backtick spans (not fenced code blocks).
// - Backtick-wrapped `!` — interpreted as bash history expansion
// - Bare `>` outside fenced blocks when preceded by `!` — interpreted as redirection
const DANGEROUS_INLINE_PATTERNS: { pattern: RegExp; message: string }[] = [
{
// `!` in backticks triggers bash history expansion detection, which then
// causes Claude Code to scan surrounding text for `>` (redirection).
pattern: /`[^`]*![^`]*`/,
message:
'Inline backtick contains `!` — Claude Code interprets this as bash history expansion. Use the word instead (e.g., "exclamation").',
},
{
// Bare `>` followed by a word char (e.g., `>file`, `>150ms`) looks like
// output redirection. HTML tag closers (`
`, ``) are fine
// because `>` is followed by `<`, space, backtick, or end of string.
pattern: /`[^`]*>\w[^`]*`/,
message:
'Inline backtick contains `>` followed by a word character — Claude Code may interpret this as output redirection. Rephrase (e.g., "150ms+" instead of ">150ms").',
},
];
function collectSkillFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectSkillFiles(full));
} else if (entry.name === "SKILL.md") {
files.push(full);
}
}
return files;
}
/**
* Flag YAML frontmatter that won't parse, which aborts `skills add` for the
* WHOLE repo (one bad SKILL.md blocks installing every skill).
*
* ponytail: targets the one failure mode we've actually hit — an unquoted
* top-level scalar whose value contains `: ` (colon-space), which YAML 1.2
* reads as a nested mapping ("Nested mappings are not allowed in compact
* mappings"). Not a full YAML parse; if a different malformation appears,
* swap this for a real parser (the `yaml` package).
*/
function lintFrontmatter(content: string): Omit[] {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return [];
const violations: Omit[] = [];
const fmLines = match[1].split("\n");
for (let i = 0; i < fmLines.length; i++) {
const line = fmLines[i];
// Top-level `key: value` (no indentation). Skip block scalars (> |),
// already-quoted values, and flow collections — those handle colons fine.
const m = line.match(/^([A-Za-z0-9_-]+):[ \t]+(.+)$/);
if (!m) continue;
const value = m[2].trim();
if (/^["'>|[{]/.test(value)) continue;
if (/:[ \t]/.test(value)) {
violations.push({
line: i + 2, // +1 for the opening `---`, +1 for 1-based
message:
`Unquoted frontmatter value for "${m[1]}" contains ": " — YAML reads ` +
`this as a nested mapping and the parse fails, which aborts ` +
`\`skills add\` for the entire repo. Quote the value or rephrase the colon.`,
text: line.trim(),
});
}
}
return violations;
}
/** Strip fenced code blocks so we only lint prose + inline code. */
function stripFencedBlocks(content: string): string {
return content.replace(/^```[\s\S]*?^```/gm, (match) =>
match
.split("\n")
.map(() => "")
.join("\n"),
);
}
function lintFile(filePath: string): Violation[] {
const raw = readFileSync(filePath, "utf-8");
const file = relative(process.cwd(), filePath);
const violations: Violation[] = lintFrontmatter(raw).map((v) => ({
...v,
file,
}));
const stripped = stripFencedBlocks(raw);
const lines = stripped.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
for (const { pattern, message } of DANGEROUS_INLINE_PATTERNS) {
if (pattern.test(line)) {
violations.push({
file: relative(process.cwd(), filePath),
line: i + 1,
message,
text: line.trim(),
});
}
}
}
return violations;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
if (!statSync(SKILLS_DIR, { throwIfNoEntry: false })?.isDirectory()) {
console.log("No skills/ directory found — skipping skill lint.");
process.exit(0);
}
const files = collectSkillFiles(SKILLS_DIR);
if (files.length === 0) {
console.log("No SKILL.md files found.");
process.exit(0);
}
let totalViolations = 0;
for (const file of files) {
const violations = lintFile(file);
for (const v of violations) {
console.error(`${v.file}:${v.line}: ${v.message}`);
console.error(` ${v.text}\n`);
totalViolations++;
}
}
if (totalViolations > 0) {
console.error(`\n${totalViolations} skill lint error(s) found.`);
process.exit(1);
} else {
console.log(`Checked ${files.length} skill file(s) — no issues found.`);
}