Invoke a skill
# grade this stage (from your course directory)
npx theinference test --stage invoke --watchParsing got you the body. Invoking makes it live. When the user types /fix auth bug, the agent loads the skill's body and the model never sees the raw template — it sees a body where every placeholder has been filled in. Fix $ARGUMENTS[0] becomes Fix auth. That substitution pass is this step.
The arguments the user typed after the command, the current session id, the skill's own directory — all of them get spliced into the body before it reaches the model. This is also where dynamic context lands in real Claude Code; here you build the variable-expansion core that the rest is layered on.
$ARGUMENTS all args, space-joined "auth bug"
$ARGUMENTS[0] the arg at index 0 "auth"
$0, $1, ... positional arg by index $0 → "auth"
${CLAUDE_SESSION_ID} the current session id "s1"
${CLAUDE_SKILL_DIR} the skill's own directory "/skills/x"
/fix auth bug → args = ["auth", "bug"]
What to build
Implement expandSubstitutions
Implement expandSubstitutions(body, env) in src/skills.ts. env carries { args: string[], sessionId?: string, skillDir?: string }. Replace every occurrence of each placeholder in body:
$ARGUMENTS[n]→env.args[n], or""if out of range. Do this first — before bare$ARGUMENTS— or the[n]gets eaten.$ARGUMENTS→env.args.join(" ").$n(a bare digit, e.g.$0,$1) →env.args[n], or"".${CLAUDE_SESSION_ID}→env.sessionId ?? "".${CLAUDE_SKILL_DIR}→env.skillDir ?? "".
Wire program.ts skill-invoke to call it and print { expanded: expandSubstitutions(C.body, C.env) }.
import { expandSubstitutions, type SubstitutionEnv } from '../src/skills'
export function runExpand(body: string, env: SubstitutionEnv): string {
return expandSubstitutions(body, env)
}
Order is load-bearing. If you replace $ARGUMENTS first, the text $ARGUMENTS[0] becomes auth bug[0] — the indexed match is gone and the [0] is left dangling. So compile the indexed pattern (/\$ARGUMENTS\[(\d+)\]/g) and run it before the plain $ARGUMENTS replacement. The same care applies to $n: it is just a bare digit after $, so write the regex so it doesn't accidentally swallow part of a longer token. Replace every occurrence (use a global replace), not just the first.
A placeholder that has no value resolves to the empty string — never to the literal $ARGUMENTS[5] text. An out-of-range index, an absent sessionId, an unset skillDir: all of them become "". The model should never see a raw ${...} token in the expanded body; if it does, your substitution missed a case.
Process mode runs program.ts skill-invoke over the fixture and compares { expanded } to expected.json. The case body is Fix $ARGUMENTS[0] in ${CLAUDE_SKILL_DIR}; all=$ARGUMENTS; first=$0 with env = { args: ["auth", "bug"], skillDir: "/skills/x", sessionId: "s1" }, so the expected output is Fix auth in /skills/x; all=auth bug; first=auth. Note how $ARGUMENTS[0] and $0 both resolve to auth while bare $ARGUMENTS joins to auth bug — that's the ordering trap, graded directly.