Skip to content

Commit d3d07df

Browse files
committed
fix(site/src/pages/AgentsPage): mark manual-only downloads with padded frontmatter delimiters
Match parseSkillMarkdown's trimmed delimiter handling when adding disable-model-invocation to downloaded SKILL.md files, use the sm badge size that remains after the base merge, and document what an import without the line does.
1 parent 94887b9 commit d3d07df

4 files changed

Lines changed: 52 additions & 17 deletions

File tree

‎docs/ai-coder/agents/extending-agents.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ Skills tables mark these skills **Manual only**.
325325
The agent's list of skills leaves them out, and `read_skill` reports that the skill was not found.
326326

327327
Importing a `SKILL.md` file with `disable-model-invocation: true` in its frontmatter turns the switch off.
328+
When you add a skill, importing a file without that line turns the switch back on.
329+
When you edit a skill, it leaves the switch unchanged.
328330
Downloading or exporting a manual-only skill adds that line back, so Claude Code, Cursor, and VS Code also treat the file as manual-only.
329331
The exception is a skill so close to the 64 KB limit that the extra line would push the file over it.
330332
Coder downloads that file without the line, so other tools let agents use it automatically, and importing it again creates a skill that agents can use automatically.

‎site/src/pages/AgentsPage/components/SkillsTableView.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ const SkillNameCell: React.FC<SkillNameCellProps> = ({ skill }) => (
257257
<TableCell className="max-w-48" title={skill.name}>
258258
<div className="flex items-center gap-1.5">
259259
<span className="truncate">{skill.name}</span>
260-
{!skill.model_invocable && <Badge size="xs">Manual only</Badge>}
260+
{!skill.model_invocable && <Badge size="sm">Manual only</Badge>}
261261
</div>
262262
</TableCell>
263263
);

‎site/src/pages/AgentsPage/utils/skills.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,15 @@ describe("skillFileContent", () => {
455455
);
456456
});
457457

458+
it("marks frontmatter with whitespace around the delimiters", () => {
459+
const content = " --- \nname: triage\n --- \nTriage issues.\n";
460+
const marked = skillFileContent(manualSkill(content));
461+
expect(marked).toBe(
462+
" --- \ndisable-model-invocation: true\nname: triage\n --- \nTriage issues.\n",
463+
);
464+
expect(parseSkillMarkdown(marked).modelInvocable).toBe(false);
465+
});
466+
458467
it("leaves content unmarked when the marker would pass the size limit", () => {
459468
const content = `---\nname: big\n---\n${"a".repeat(SKILL_MAX_SIZE_BYTES - 20)}`;
460469
expect(skillFileContent(manualSkill(content))).toBe(content);

‎site/src/pages/AgentsPage/utils/skills.ts‎

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,40 @@ const frontmatterStringField = (
170170
return value.replace(/[\r\n]+$/, "");
171171
};
172172

173-
// The API re-validates on submit; this only projects content into form fields.
174-
export const parseSkillMarkdown = (content: string): SkillFormValues => {
175-
const normalizedContent = content.replace(/^\uFEFF/, "");
176-
const lines = normalizedContent.split("\n");
177-
if (lines[0]?.trim() !== "---") {
178-
throw new SkillMarkdownError("Missing opening frontmatter delimiter.");
179-
}
173+
type SkillMarkdownLines = {
174+
/** A leading UTF-8 BOM, which the server accepts and stores. */
175+
bom: string;
176+
lines: string[];
177+
/** Index of the closing `---`, or -1 when it is missing. */
178+
closingIndex: number;
179+
};
180180

181+
/**
182+
* Splits SKILL.md content into lines and locates its frontmatter delimiters,
183+
* allowing whitespace around `---`. Returns undefined when the first line is
184+
* not a delimiter.
185+
*/
186+
const splitSkillMarkdown = (
187+
content: string,
188+
): SkillMarkdownLines | undefined => {
189+
const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
190+
const lines = content.slice(bom.length).split("\n");
191+
if (lines[0].trim() !== "---") {
192+
return undefined;
193+
}
181194
const closingIndex = lines.findIndex(
182195
(line, index) => index > 0 && line.trim() === "---",
183196
);
197+
return { bom, lines, closingIndex };
198+
};
199+
200+
// The API re-validates on submit; this only projects content into form fields.
201+
export const parseSkillMarkdown = (content: string): SkillFormValues => {
202+
const split = splitSkillMarkdown(content);
203+
if (!split) {
204+
throw new SkillMarkdownError("Missing opening frontmatter delimiter.");
205+
}
206+
const { lines, closingIndex } = split;
184207
if (closingIndex < 0) {
185208
throw new SkillMarkdownError("Missing closing frontmatter delimiter.");
186209
}
@@ -290,19 +313,20 @@ export const buildSkillMarkdown = (values: SkillFormValues): string => {
290313
* Content the marker would push past the size limit is returned unchanged.
291314
*/
292315
const withDisabledModelInvocation = (content: string): string => {
293-
// The server accepts and stores content that starts with a UTF-8 BOM.
294-
const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
295-
const text = content.slice(bom.length);
296-
const opening = /^---(\r?\n)/.exec(text);
297-
const frontmatterEnd = text.search(/\r?\n---/);
298-
if (!opening || frontmatterEnd < 3) {
316+
const split = splitSkillMarkdown(content);
317+
if (!split || split.closingIndex < 0) {
299318
return content;
300319
}
301-
if (/^disable-model-invocation:/m.test(text.slice(0, frontmatterEnd))) {
320+
const { bom, lines, closingIndex } = split;
321+
const frontmatter = lines.slice(1, closingIndex);
322+
if (
323+
frontmatter.some((line) => line.startsWith("disable-model-invocation:"))
324+
) {
302325
return content;
303326
}
304-
const newline = opening[1];
305-
const marked = `${bom}---${newline}disable-model-invocation: true${newline}${text.slice(opening[0].length)}`;
327+
const lineEnding = lines[0].endsWith("\r") ? "\r" : "";
328+
lines.splice(1, 0, `disable-model-invocation: true${lineEnding}`);
329+
const marked = `${bom}${lines.join("\n")}`;
306330
return getSkillContentSizeBytes(marked) > SKILL_MAX_SIZE_BYTES
307331
? content
308332
: marked;

0 commit comments

Comments
 (0)