Discover + parse
# grade this stage (from your course directory)
npx theinference test --stage discover-and-parse --watchNow make the file real. A skill on disk is just text until something reads it — so this step builds the two functions that turn a SKILL.md into a thing the agent can use: parse the frontmatter + body, and resolve the slash-command name from where the file lives.
Keep the two jobs separate, because they answer different questions. Parsing answers "what does this skill say about itself?" — the name, description, allowed tools, and the invocation toggle, plus the body. Naming answers "what do I type to run it?" — and that comes not from the frontmatter name but from the path on disk. Getting that distinction right is the whole point of this step.
What to build
Implement parseSkill and commandName
Implement both in src/skills.ts.
parseSkill(md, dirSlug) — split md into YAML frontmatter (between the leading --- markers) and body, then return a Skill:
name— frontmattername, falling back todirSlugif absent.description— frontmatterdescription, or"".body— everything after the frontmatter, trimmed.disableModelInvocation—trueonly when the frontmatter value is the booleantrue.allowedTools— theallowed-toolsfield as astring[]. It may be a YAML list or a space-/comma-separated string ("Bash Read"→["Bash", "Read"]); empty/absent →[].
commandName(relPath) — resolve the slash command from the file path:
- legacy
.claude/commands/deploy.md→/deploy(strip the.md, use the filename). .claude/skills/deploy/SKILL.md→/deploy(use the directory name, never thenamefield).- nested under a package, e.g.
apps/web/.claude/skills/deploy/SKILL.md→/apps/web:deploy(the path before.claudebecomes apath:nameprefix).
Wire program.ts skill-parse to call both and print { parsed: { name, description, disableModelInvocation, allowedTools }, commandName }.
import { parseSkill, commandName, type Skill } from '../src/skills'
export function runParseSkill(md: string, dirSlug: string): Skill {
return parseSkill(md, dirSlug)
}
export function runCommandName(relPath: string): string {
return commandName(relPath)
}
This is the trap the fixtures probe. The frontmatter name is a display label for skill listings — it does not set what you type. The command you type comes from where the file lives: the directory name for a SKILL.md, the filename for a legacy command. So a skill in skills/deploy/SKILL.md with name: shipit is still invoked as /deploy. (The single exception in real Claude Code is a plugin-root SKILL.md, which has no directory to take the name from — you don't need to handle that case here.)
The allowed-tools field accepts three shapes and you must normalize all of them to string[]: a YAML list ([Bash, Read]), a space-separated string ("Bash Read"), or a comma-separated string ("Bash, Read"). Split a string on runs of whitespace and commas, and drop empty pieces — "Bash, Read" must not yield a stray "". An absent or empty field is [], not [""].
Process mode runs program.ts skill-parse over the fixture and compares your JSON to expected.json. The case feeds md with allowed-tools: Bash Read and disable-model-invocation: true, a dirSlug, and a relPath of .claude/skills/deploy/SKILL.md — so the expected output is parsed.name = "deploy", disableModelInvocation = true, allowedTools = ["Bash", "Read"], and commandName = "/deploy". Every field must match exactly.