85453da49f
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
168 lines
5.6 KiB
TypeScript
168 lines
5.6 KiB
TypeScript
/**
|
|
* 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 (`<div>`)
|
|
* 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 (`<div>`, `</script>`) 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<Violation, "file">[] {
|
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!match) return [];
|
|
const violations: Omit<Violation, "file">[] = [];
|
|
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.`);
|
|
}
|