Skip to content

Commit 307e4d5

Browse files
cliffhallclaude
andcommitted
docs(skills): keep argument placeholders out of skill bodies
Claude Code substitutes $0-$9 and $ARGUMENTS in a skill's body when the skill is invoked with arguments, so board-ops's opt() reached the model with its field-name parameter rewritten and resolved no option id. - board-ops: opt() hands its arguments to jq with --args and reads $ARGS.positional, so it has no shell positional parameters. - verify:skills: parseSkill rejects $<digit> and $ARGUMENTS anywhere in a body, reporting each by file line. - docs/skill-authoring.md gains "Writing the body"; AGENTS.md states the rule under Maintaining the skills. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com>
1 parent f18beb7 commit 307e4d5

5 files changed

Lines changed: 95 additions & 7 deletions

File tree

‎.claude/skills/board-ops/SKILL.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ every time**:
5353
# The board's fields, once per session. Refuses to go on from a failed call.
5454
FIELDS=$(gh project field-list 43 --owner modelcontextprotocol --format json) || FIELDS=
5555
# opt <Field> <Option name> → the option's current id, or nothing.
56+
# The arguments reach jq through --args, never as shell positional parameters:
57+
# Claude Code rewrites those in a skill invoked with arguments (#5160).
5658
opt() {
57-
jq -r --arg f "$1" --arg o "$2" \
58-
'.fields[] | select(.name == $f) | .options[] | select(.name == $o) | .id' \
59-
<<<"$FIELDS"
59+
jq -r '$ARGS.positional as [$f, $o]
60+
| .fields[] | select(.name == $f) | .options[] | select(.name == $o) | .id' \
61+
--args "$@" <<<"$FIELDS"
6062
}
6163

6264
STATUS_OPT=$(opt Status "Todo")

‎AGENTS.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,9 @@ measure it, is [`docs/skill-authoring.md`](./docs/skill-authoring.md).
429429
works while the skill can never auto-fire; an unquoted `#` truncates the
430430
description silently.
431431
**Quote any description containing `#` or `:`**, and keep the opening `---`
432-
on the file's first line.
432+
on the file's first line. **A body holds no `$0`–`$9` and no `$ARGUMENTS`**:
433+
Claude Code substitutes them when a skill is invoked with arguments, so a
434+
recipe's shell parameters or awk fields reach the model rewritten.
433435
2. **Every skill declares `disable-model-invocation` explicitly. Default it to
434436
`false`.** Invoking a skill has no side effects (it loads instructions), and
435437
work is usually asked for in prose, which a `true` skill can never answer. A

‎docs/skill-authoring.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ Three further rules, each learned the expensive way:
8181
rate, and a prompt-only eval can never exercise a path trigger, so shipping it
8282
means shipping an untestable claim.
8383

84+
## Writing the body
85+
86+
**No `$0`–`$9` and no `$ARGUMENTS` anywhere in a body.** When a skill is
87+
invoked _with_ arguments (`/board-ops add #5159 as In Progress`, or the model
88+
passing `args`), Claude Code substitutes those placeholders before the model
89+
reads the body, so a recipe that uses shell positional parameters or awk fields
90+
arrives rewritten. `board-ops`'s `opt()` once reached the model as
91+
`--arg f "issue"` in place of its field-name parameter, and so resolved no
92+
option id for any field (#5160). Invoked without arguments the body is
93+
untouched, which is why reading the skill by hand never shows it.
94+
95+
Name the values instead: hand a shell function's arguments to jq with
96+
`--args "$@"` and read `$ARGS.positional`, pass awk values with `-v`, or set a
97+
named variable before the call. `${NAME}` and jq's `$ARGS` are not
98+
placeholders. `verify:skills` rejects every occurrence, by line.
99+
84100
## Writing an eval case
85101

86102
### The first-move rule
@@ -567,7 +583,8 @@ release changes the default model.
567583
description from that point on so it stays *non-empty* — which is why it went
568584
unnoticed until `board-ops` lost both its board numbers. `verify:skills` now
569585
rejects that truncation, so the gate catches it; quote the value rather than
570-
relying on the check to tell you.
586+
relying on the check to tell you. The body holds no `$<digit>` or
587+
`$ARGUMENTS` (see [Writing the body](#writing-the-body)).
571588
2. `disable-model-invocation` is declared explicitly; default it to `false`.
572589
3. Description is action-first, with `Use when …; when …; when …`.
573590
4. **If the skill is model-invoked**, `evals/evals.json` carries **at least

‎scripts/lib/skill-manifest.mjs‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,35 @@ export function splitFrontmatter(text) {
7373
}
7474

7575
/**
76-
* Parse and validate one skill's frontmatter.
76+
* Argument placeholders in a skill's body: `$0`–`$9` and `$ARGUMENTS`.
77+
*
78+
* When a skill is invoked WITH arguments, Claude Code substitutes these before
79+
* the model reads the body, so a shell positional parameter or an awk field in
80+
* a recipe arrives rewritten. `board-ops`'s option lookup reached the model with
81+
* its field name replaced by a word of the arguments, and resolved no option id
82+
* (#5160). Invoked without arguments the body is untouched, which is why a spot
83+
* check passes. No skill here takes arguments on purpose, so every occurrence is
84+
* the hazard; a skill that did would need an allowance here.
85+
*
86+
* @param {string} text Full file contents.
87+
* @param {string} body The part after the frontmatter, from `splitFrontmatter`.
88+
* @returns {Array<{ line: number, placeholder: string }>} 1-based file lines.
89+
*/
90+
export function findArgumentPlaceholders(text, body) {
91+
const firstBodyLine = text
92+
.slice(0, text.length - body.length)
93+
.split("\n").length;
94+
const hits = [];
95+
body.split("\n").forEach((line, i) => {
96+
for (const m of line.matchAll(/\$(?:ARGUMENTS|\d)/g)) {
97+
hits.push({ line: firstBodyLine + i, placeholder: m[0] });
98+
}
99+
});
100+
return hits;
101+
}
102+
103+
/**
104+
* Parse and validate one skill's frontmatter and body.
77105
*
78106
* @param {string} dirName Directory the SKILL.md lives in — the skill's identity.
79107
* @param {string} text Full file contents.
@@ -82,7 +110,7 @@ export function splitFrontmatter(text) {
82110
*/
83111
export function parseSkill(dirName, text) {
84112
const errors = [];
85-
const { frontmatter, error } = splitFrontmatter(text);
113+
const { frontmatter, body, error } = splitFrontmatter(text);
86114
if (error) return { errors: [error] };
87115

88116
let doc;
@@ -197,6 +225,13 @@ export function parseSkill(dirName, text) {
197225
);
198226
}
199227

228+
for (const hit of findArgumentPlaceholders(text, body)) {
229+
errors.push(
230+
`line ${hit.line} has \`${hit.placeholder}\`, which Claude Code replaces when the skill is invoked with arguments (#5160); ` +
231+
"write the recipe without it (jq `--args`, awk `-v`, a named variable)",
232+
);
233+
}
234+
200235
// A skill nothing can reach is dead weight that still costs a directory.
201236
if (!modelInvoked && meta["user-invocable"] === false) {
202237
errors.push(

‎scripts/lib/skill-manifest.test.mjs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import assert from "node:assert/strict";
99
import {
1010
splitFrontmatter,
1111
parseSkill,
12+
findArgumentPlaceholders,
1213
isChainCase,
1314
MIN_CHAIN_LENGTH,
1415
MIN_POSITIVE_CASES,
@@ -147,6 +148,37 @@ test("parseSkill rejects a skill nothing can reach", () => {
147148
assert.match(r.errors.join(), /unreachable/);
148149
});
149150

151+
test("parseSkill rejects argument placeholders in the body", () => {
152+
// Claude Code substitutes these when a skill is invoked with arguments, so a
153+
// recipe's shell positional parameters arrive rewritten (#5160).
154+
const head =
155+
"---\nname: x\ndescription: d\ndisable-model-invocation: true\n---\n";
156+
const r = parseSkill(
157+
"x",
158+
head + '\nopt() {\n jq --arg f "$1" --arg o "$2"\n}\nSee $ARGUMENTS.\n',
159+
);
160+
assert.deepEqual(
161+
r.errors.map((e) => e.split(",")[0]),
162+
["line 8 has `$1`", "line 8 has `$2`", "line 10 has `$ARGUMENTS`"],
163+
);
164+
// jq's `$ARGS`, named variables and `${VAR}` are not placeholders.
165+
assert.deepEqual(
166+
parseSkill(
167+
"x",
168+
head + 'jq -r \'$ARGS.positional as [$f]\' --args "$@" "${FIELD}"\n',
169+
).errors,
170+
[],
171+
);
172+
});
173+
174+
test("findArgumentPlaceholders counts lines from the top of the file", () => {
175+
const text = "---\na: b\n---\nfirst\n$0 here\n";
176+
const { body } = splitFrontmatter(text);
177+
assert.deepEqual(findArgumentPlaceholders(text, body), [
178+
{ line: 5, placeholder: "$0" },
179+
]);
180+
});
181+
150182
test("parseSkill validates the paths list", () => {
151183
assert.match(
152184
parseSkill(

0 commit comments

Comments
 (0)