fix(cli): auto-copy all templates to dist and add skill lint (#153)

- Replace hardcoded template list in build:copy with `cp -r src/templates/*`
  so new templates are included automatically (kinetic-type, decision-tree,
  product-promo, nyt-graph were missing from published package)
- Fix captions SKILL.md: reword `!` and `>` in inline backticks that
  triggered Claude Code's bash permission checker
- Add scripts/lint-skills.ts to catch shell-unsafe patterns in SKILL.md
  files (runs as part of `bun run lint` in CI)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-03-30 20:33:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 25767fd8dd
commit a9d49cd528
4 changed files with 129 additions and 3 deletions
+2 -1
View File
@@ -18,7 +18,8 @@
"build:hyperframes-runtime:modular": "bun run --filter @hyperframes/core build:hyperframes-runtime:modular",
"verify:packed-manifests": "node scripts/verify-packed-manifests.mjs",
"set-version": "tsx scripts/set-version.ts",
"lint": "oxlint .",
"lint": "oxlint . && tsx scripts/lint-skills.ts",
"lint:skills": "tsx scripts/lint-skills.ts",
"lint:fix": "oxlint --fix .",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
+1 -1
View File
@@ -21,7 +21,7 @@
"build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts",
"build:studio": "cd ../studio && bun run build",
"build:runtime": "tsx scripts/build-runtime.ts",
"build:copy": "mkdir -p dist/studio dist/docs dist/templates dist/skills && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/blank src/templates/warm-grain src/templates/play-mode src/templates/swiss-grid src/templates/vignelli src/templates/_shared dist/templates/ && cp -r ../../skills/hyperframes-compose ../../skills/hyperframes-captions dist/skills/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)",
"build:copy": "mkdir -p dist/studio dist/docs dist/templates dist/skills && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/* dist/templates/ && cp -r ../../skills/hyperframes-compose ../../skills/hyperframes-captions dist/skills/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+125
View File
@@ -0,0 +1,125 @@
/**
* 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;
}
/** 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 stripped = stripFencedBlocks(raw);
const lines = stripped.split("\n");
const violations: Violation[] = [];
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.`);
}
+1 -1
View File
@@ -193,7 +193,7 @@ Group size affects pacing. Fast content needs fast caption turnover.
- **Conversational:** 3-5 words per group. Natural phrase length.
- **Measured/calm:** 4-6 words per group. Longer groups match slower pace.
Break groups on sentence boundaries (`.` `?` `!`), pauses (>150ms gap), or max word count — whichever comes first.
Break groups on sentence boundaries (period, question mark, exclamation), pauses (150ms+ gap), or max word count — whichever comes first.
## Positioning