mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
0f8eb892e3
* fix(motion-graphics): make the catalog search fire before hand-authoring
The workflow's only reuse instruction pointed at catalog-map.md, a
hand-maintained snapshot of ~60 registry items, and no file in the skill
ever named `hyperframes catalog --query`. An agent asked mid-build for
CRT scanlines and a glitch effect had no instruction to search, so it
hand-authored both while caption-glitch-rgb ("RGB chromatic aberration
with CRT scanline overlay") ranks first for that query on either tier.
The search reads the hosted registry and needs nothing installed, from
any directory with no project, so "the components were not installed"
was never the cause. Say that where the reader is, since the wrong
diagnosis is the intuitive one.
Director Part 2 and the Builder now run the search before naming a
block, and catalog-map.md is labelled a partial snapshot whose misses
prove nothing. Pinned by a content test in coreSkillContent.test.ts.
* fix(skills): search the component catalog before hand-building a look
Authoring workflows never told the agent to search the component library,
so agents rebuilt effects the registry already shipped. A user reported
building an effect from scratch that the registry already contained; the
search that would have found it needs nothing installed, which is why the
usual self-diagnosis ("I forgot to install the components") is wrong.
All ten workflow skills carried zero mentions of `hyperframes catalog`.
The instruction lived only in hyperframes-cli and hyperframes-registry,
both loaded on demand, and the registry skill's own trigger named the
command rather than the symptom - circular, because an agent that never
thought to search could not reach the doc telling it to search.
- Eight workflows now run the search at the point they decide what to
build, before authoring. The two that compile through a closed
authoring vocabulary (embedded-captions, talking-head-recut) document
why they deliberately do not.
- hyperframes-registry triggers on the symptom (a named look, effect,
treatment or transition) instead of the command name; the router table
and the catalog surfaces carry the same framing.
- Fixes hand-maintained lists that had drifted: bar-chart-race was listed
as a hand-author gap in two files while shipping in the registry;
stat-motion was named as an installable block and is not one; the
caption-* family count was one high; the registry discovery tables
claimed to be the block list while covering 97 of 180.
- bun run lint:skills now fails when a doc marked as a registry snapshot
names an item the registry does not have.
* refactor(scripts): reuse native recursive readdir and the shared registry type
Simplify pass on the new registry-snapshot check, behaviour identical:
- collectMarkdownFiles uses readdirSync({ recursive: true }) instead of
hand-rolled recursion, matching scripts/generate-template-previews.ts.
- registryItemNames types registry.json with the exported RegistryManifest
instead of an ad hoc inline shape, matching scripts/catalog/build-local-vectors.ts.
The runtime guard stays: a cast describes the file, it does not validate it.
- One report() helper replaces the duplicated print-and-count block in both
lint passes.
* refactor(scripts): name the registry check's blind spots and stop self-arming
Applies the review findings on the new check, behaviour identical except
where noted:
- lintRegistryItemRefs returns null for an unmarked file instead of an
empty array, so "not a snapshot" and "a clean snapshot" have one owner
and the marker is matched once rather than twice.
- Marker detection ignores fenced blocks, so a doc that documents the
marker syntax in an example no longer arms the check on itself. The id
scan still reads full content, so fenced examples stay covered.
- The header comment and two tests now pin both known false negatives:
identifiers outside backticks, and single-word item names. Measured on
the six marked files, dropping the hyphen requirement would monitor 3
more items and force 46 allow= entries for ordinary prose words, so the
requirement stays and the gap is stated instead of silent.
* chore(skills): regenerate skills manifest after catalog-search edits
390 lines
15 KiB
TypeScript
390 lines
15 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";
|
|
import { parse as parseYaml, YAMLParseError } from "yaml";
|
|
import type { RegistryManifest } from "../packages/core/src/index.js";
|
|
|
|
const REPO_ROOT = join(import.meta.dirname, "..");
|
|
// Every location that ships SKILL.md files gets linted. `skills/` is the
|
|
// marketplace-distributed set; `.claude/skills/` and `.agents/skills/` are the
|
|
// repo-native project skills auto-discovered by Claude Code and Codex CLI.
|
|
const SKILLS_DIRS = [
|
|
join(REPO_ROOT, "skills"),
|
|
join(REPO_ROOT, ".claude", "skills"),
|
|
join(REPO_ROOT, ".agents", "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).
|
|
*/
|
|
// SKILL.md frontmatter schema.
|
|
//
|
|
// Two required top-level string keys plus three optional ones. Parsed with a
|
|
// real YAML parser (the `yaml` npm package) so we can validate value TYPES
|
|
// (name/description must be strings, allowed-tools must be a sequence or
|
|
// string, metadata must be a mapping), not just line-level patterns.
|
|
//
|
|
// This is a NECESSARY-but-not-SUFFICIENT gate. Catches:
|
|
// * unsupported top-level keys (e.g. `category:`)
|
|
// * missing name / description
|
|
// * malformed YAML
|
|
// * type errors (name is a list; description is a number; metadata is a scalar)
|
|
// * empty string values
|
|
//
|
|
// The canonical Claude Code / Codex CLI / marketplace loaders may enforce
|
|
// stricter rules (name regex, description length, nested-schema shape); those
|
|
// are validated at load / install time. Positive + negative fixtures live in
|
|
// scripts/lint-skills.test.mjs.
|
|
|
|
const REQUIRED_FRONTMATTER_KEYS = new Set(["name", "description"]);
|
|
const OPTIONAL_FRONTMATTER_KEYS = new Set(["license", "allowed-tools", "metadata"]);
|
|
const KNOWN_FRONTMATTER_KEYS = new Set([
|
|
...REQUIRED_FRONTMATTER_KEYS,
|
|
...OPTIONAL_FRONTMATTER_KEYS,
|
|
]);
|
|
|
|
type LineViolation = Omit<Violation, "file">;
|
|
|
|
function violation(line: number, message: string, text: string): LineViolation {
|
|
return { line, message, text };
|
|
}
|
|
|
|
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
}
|
|
|
|
function parseFrontmatterYaml(body: string): { data?: unknown; error?: string } {
|
|
try {
|
|
return { data: parseYaml(body) };
|
|
} catch (err) {
|
|
if (err instanceof YAMLParseError) {
|
|
return { error: err.message };
|
|
}
|
|
return { error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
function typeLabel(v: unknown): string {
|
|
if (v === null) return "null";
|
|
if (Array.isArray(v)) return "list";
|
|
return typeof v;
|
|
}
|
|
|
|
function missingRequired(data: Record<string, unknown>): LineViolation[] {
|
|
return [...REQUIRED_FRONTMATTER_KEYS]
|
|
.filter((k) => !(k in data))
|
|
.map((k) => violation(-1, `Missing required frontmatter key "${k}".`, "<top of file>"));
|
|
}
|
|
|
|
function unsupportedKeys(data: Record<string, unknown>): LineViolation[] {
|
|
return Object.keys(data)
|
|
.filter((k) => !KNOWN_FRONTMATTER_KEYS.has(k))
|
|
.map((k) =>
|
|
violation(
|
|
-1,
|
|
`Unsupported frontmatter key "${k}" — SKILL.md accepts required { ` +
|
|
`${[...REQUIRED_FRONTMATTER_KEYS].join(", ")} } plus optional { ` +
|
|
`${[...OPTIONAL_FRONTMATTER_KEYS].join(", ")} }.`,
|
|
`${k}: ...`,
|
|
),
|
|
);
|
|
}
|
|
|
|
function stringFieldError(key: string, value: unknown, allowEmpty: boolean): LineViolation | null {
|
|
if (typeof value !== "string") {
|
|
return violation(
|
|
-1,
|
|
`Frontmatter "${key}" must be a string (got ${typeLabel(value)}).`,
|
|
`${key}: ${JSON.stringify(value)}`,
|
|
);
|
|
}
|
|
if (!allowEmpty && value.trim().length === 0) {
|
|
return violation(-1, `Frontmatter "${key}" must not be empty.`, `${key}: ""`);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function validateStringField(
|
|
data: Record<string, unknown>,
|
|
key: string,
|
|
allowEmpty: boolean,
|
|
): LineViolation | null {
|
|
return key in data ? stringFieldError(key, data[key], allowEmpty) : null;
|
|
}
|
|
|
|
function isValidAllowedTools(value: unknown): boolean {
|
|
if (typeof value === "string") return true;
|
|
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
}
|
|
|
|
function validateAllowedTools(data: Record<string, unknown>): LineViolation | null {
|
|
if (!("allowed-tools" in data)) return null;
|
|
if (isValidAllowedTools(data["allowed-tools"])) return null;
|
|
return violation(
|
|
-1,
|
|
`Frontmatter "allowed-tools" must be a string or a list of strings.`,
|
|
`allowed-tools: ${JSON.stringify(data["allowed-tools"])}`,
|
|
);
|
|
}
|
|
|
|
function validateMetadata(data: Record<string, unknown>): LineViolation | null {
|
|
if (!("metadata" in data)) return null;
|
|
if (isPlainObject(data.metadata)) return null;
|
|
return violation(
|
|
-1,
|
|
`Frontmatter "metadata" must be a mapping / object.`,
|
|
`metadata: ${JSON.stringify(data.metadata)}`,
|
|
);
|
|
}
|
|
|
|
function validateShape(data: Record<string, unknown>): LineViolation[] {
|
|
const fieldChecks = [
|
|
validateStringField(data, "name", false),
|
|
validateStringField(data, "description", false),
|
|
validateStringField(data, "license", true),
|
|
validateAllowedTools(data),
|
|
validateMetadata(data),
|
|
].filter((v): v is LineViolation => v !== null);
|
|
return [...missingRequired(data), ...unsupportedKeys(data), ...fieldChecks];
|
|
}
|
|
|
|
function parsedDataError(parsed: { data?: unknown; error?: string }): LineViolation | null {
|
|
if (parsed.error) {
|
|
return violation(1, `Malformed YAML frontmatter: ${parsed.error}`, "<frontmatter block>");
|
|
}
|
|
if (isPlainObject(parsed.data)) return null;
|
|
return violation(
|
|
1,
|
|
`Frontmatter must be a YAML mapping at the top level (got ${typeLabel(parsed.data)}).`,
|
|
"<frontmatter block>",
|
|
);
|
|
}
|
|
|
|
export function lintFrontmatter(content: string): LineViolation[] {
|
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
if (!match) {
|
|
return [
|
|
violation(1, `Missing SKILL.md YAML frontmatter (must start with '---').`, "<top of file>"),
|
|
];
|
|
}
|
|
const parsed = parseFrontmatterYaml(match[1] ?? "");
|
|
const preflightError = parsedDataError(parsed);
|
|
if (preflightError) return [preflightError];
|
|
return validateShape(parsed.data as Record<string, unknown>);
|
|
}
|
|
|
|
/** 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 matchDangerousPatterns(file: string, line: string, lineNumber: number): Violation[] {
|
|
return DANGEROUS_INLINE_PATTERNS.filter((p) => p.pattern.test(line)).map((p) => ({
|
|
file,
|
|
line: lineNumber,
|
|
message: p.message,
|
|
text: line.trim(),
|
|
}));
|
|
}
|
|
|
|
function lintInlinePatterns(file: string, stripped: string): Violation[] {
|
|
return stripped
|
|
.split("\n")
|
|
.flatMap((line, i) => (line ? matchDangerousPatterns(file, line, i + 1) : []));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Registry-item references in hand-maintained snapshots
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// A skill doc that snapshots part of the component registry rots in silence:
|
|
// nothing fails when an item is renamed or dropped, and the agent that follows
|
|
// the doc runs `hyperframes add <gone>` and dies. A doc opts into this check
|
|
// with a marker line, after which every registry-item-shaped identifier in a
|
|
// backtick span must name a real item in registry/registry.json:
|
|
//
|
|
// <!-- registry-items: allow=some-suffix,another-suffix -->
|
|
//
|
|
// Opt-in rather than repo-wide on purpose. Kebab-case backticks are also CSS
|
|
// properties, `data-*` attributes, skill directory names, script names, and
|
|
// motion-graphics category names, and a check that flags those is a check
|
|
// people turn off. `allow=` carries the few non-item identifiers a snapshot
|
|
// legitimately names (bare suffixes under a spelled-out prefix, ids the doc
|
|
// itself marks as hand-authored).
|
|
//
|
|
// TWO KNOWN BLIND SPOTS, both deliberate, both false NEGATIVES (this check
|
|
// never invents a violation, it only misses some):
|
|
//
|
|
// 1. Identifiers outside a backtick span are not seen. A bare `bar-chart-race`
|
|
// in prose slipped past this check while it was a live defect elsewhere.
|
|
// 2. Single-word item names are not seen, because the pattern below requires a
|
|
// hyphen. Measured on the six currently-marked files: dropping the hyphen
|
|
// requirement would monitor 3 more real items (`glitch`, `flowchart`,
|
|
// `typewriter`) and force 46 new allow= entries for ordinary prose words
|
|
// ("add", "line", "name", "height", "text"). A 15:1 noise ratio is how a
|
|
// check gets switched off, so the hyphen requirement stays.
|
|
const REGISTRY_MARKER = /<!--\s*registry-items:\s*(?:allow=([^\s]*))?\s*-->/;
|
|
const REGISTRY_ITEM_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
|
|
function registryItemNames(): Set<string> {
|
|
const raw = readFileSync(join(REPO_ROOT, "registry", "registry.json"), "utf-8");
|
|
// The cast describes the file; it does not validate it. The runtime filter and
|
|
// the throw below are what actually stop us linting against an empty set.
|
|
const parsed = JSON.parse(raw) as RegistryManifest;
|
|
const names = (parsed.items ?? []).map((item) => item.name).filter((name) => Boolean(name));
|
|
if (names.length === 0) {
|
|
throw new Error("registry/registry.json parsed to zero item names — refusing to lint blind.");
|
|
}
|
|
return new Set(names);
|
|
}
|
|
|
|
/** `null` when the file is not marked as a registry snapshot; otherwise its violations. */
|
|
export function lintRegistryItemRefs(content: string, known: Set<string>): LineViolation[] | null {
|
|
// Marker detection ignores fenced blocks so a doc that *documents* the marker
|
|
// syntax in an example does not arm the check on itself. The scan below still
|
|
// reads full content, so ids inside fenced examples stay covered.
|
|
const marker = stripFencedBlocks(content).match(REGISTRY_MARKER);
|
|
if (!marker) return null;
|
|
const allowed = new Set((marker[1] ?? "").split(",").filter(Boolean));
|
|
return content.split("\n").flatMap((line, index) => {
|
|
const dead = [...new Set([...line.matchAll(/`([^`\n]+)`/g)].map((m) => (m[1] ?? "").trim()))]
|
|
.filter((token) => REGISTRY_ITEM_ID.test(token))
|
|
.filter((token) => !known.has(token) && !allowed.has(token));
|
|
return dead.map((token) =>
|
|
violation(
|
|
index + 1,
|
|
`"${token}" is not an item in registry/registry.json, but this file is marked as a registry snapshot. Correct the name, remove it, or add it to the marker's allow= list if it is legitimately not an item.`,
|
|
line.trim(),
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
function collectMarkdownFiles(dir: string): string[] {
|
|
return readdirSync(dir, { withFileTypes: true, recursive: true })
|
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
.map((entry) => join(entry.parentPath, entry.name));
|
|
}
|
|
|
|
function lintFile(filePath: string): Violation[] {
|
|
const raw = readFileSync(filePath, "utf-8");
|
|
const file = relative(process.cwd(), filePath);
|
|
return [
|
|
...lintFrontmatter(raw).map((v) => ({ ...v, file })),
|
|
...lintInlinePatterns(file, stripFencedBlocks(raw)),
|
|
];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const files: string[] = [];
|
|
for (const dir of SKILLS_DIRS) {
|
|
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) continue;
|
|
files.push(...collectSkillFiles(dir));
|
|
}
|
|
if (files.length === 0) {
|
|
console.log("No SKILL.md files found across skills/, .claude/skills/, .agents/skills/.");
|
|
process.exit(0);
|
|
}
|
|
|
|
let totalViolations = 0;
|
|
|
|
function report(file: string, violations: LineViolation[]): void {
|
|
for (const v of violations) {
|
|
console.error(`${file}:${v.line}: ${v.message}`);
|
|
console.error(` ${v.text}\n`);
|
|
totalViolations++;
|
|
}
|
|
}
|
|
|
|
for (const file of files) {
|
|
report(relative(process.cwd(), file), lintFile(file));
|
|
}
|
|
|
|
const knownItems = registryItemNames();
|
|
let snapshotsChecked = 0;
|
|
for (const dir of SKILLS_DIRS) {
|
|
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) continue;
|
|
for (const path of collectMarkdownFiles(dir)) {
|
|
const found = lintRegistryItemRefs(readFileSync(path, "utf-8"), knownItems);
|
|
if (found === null) continue;
|
|
snapshotsChecked++;
|
|
report(relative(process.cwd(), path), found);
|
|
}
|
|
}
|
|
|
|
if (totalViolations > 0) {
|
|
console.error(`\n${totalViolations} skill lint error(s) found.`);
|
|
process.exit(1);
|
|
} else {
|
|
console.log(
|
|
`Checked ${files.length} skill file(s) and ${snapshotsChecked} registry snapshot(s) against ${knownItems.size} registry items — no issues found.`,
|
|
);
|
|
}
|