Build your own Claude Code
Skills & slash commands·22 min

Discover + parse

0 / 1
# grade this stage (from your course directory)
npx theinference test --stage discover-and-parse --watch

Now 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

Your task

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 — frontmatter name, falling back to dirSlug if absent.
  • description — frontmatter description, or "".
  • body — everything after the frontmatter, trimmed.
  • disableModelInvocation — true only when the frontmatter value is the boolean true.
  • allowedTools — the allowed-tools field as a string[]. 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 the name field).
  • nested under a package, e.g. apps/web/.claude/skills/deploy/SKILL.md → /apps/web:deploy (the path before .claude becomes a path:name prefix).

Wire program.ts skill-parse to call both and print { parsed: { name, description, disableModelInvocation, allowedTools }, commandName }.

tests/adapters.ts
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)
}
The command name comes from the path, not the frontmatter

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.)

`allowed-tools` is a string *or* a list

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 [""].

How it's graded

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.