forked from heygen-com/hyperframes
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlint-skills.ts
More file actions
167 lines (149 loc) · 5.62 KB
/
Copy pathlint-skills.ts
File metadata and controls
167 lines (149 loc) · 5.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
/**
* 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.`);
}