feat(coding-agent): support disable-model-invocation frontmatter for skills

When set to true, the skill is hidden from the system prompt, preventing
agentic invocation. Users can still invoke explicitly via /skill:name.

Also fixes pre-existing test bug where source expectation was wrong.

Fixes #927
This commit is contained in:
Mario Zechner 2026-01-24 03:34:40 +01:00
parent 3235926eed
commit 951fb953ed
8 changed files with 99 additions and 3 deletions

View file

@ -0,0 +1,9 @@
---
name: disable-model-invocation
description: A skill that cannot be invoked by the model.
disable-model-invocation: true
---
# Manual Only Skill
This skill can only be invoked via /skill:disable-model-invocation.

View file

@ -155,6 +155,7 @@ Content`,
filePath: "/fake/path",
baseDir: "/fake",
source: "custom",
disableModelInvocation: false,
};
const loader = new DefaultResourceLoader({
cwd,

View file

@ -80,6 +80,7 @@ This is a test skill.
filePath: "/fake/path/SKILL.md",
baseDir: "/fake/path",
source: "custom" as const,
disableModelInvocation: false,
};
const resourceLoader: ResourceLoader = {

View file

@ -168,6 +168,31 @@ describe("skills", () => {
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("valid-skill");
});
it("should parse disable-model-invocation frontmatter field", () => {
const { skills, diagnostics } = loadSkillsFromDir({
dir: join(fixturesDir, "disable-model-invocation"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(skills[0].name).toBe("disable-model-invocation");
expect(skills[0].disableModelInvocation).toBe(true);
// Should not warn about unknown field
expect(diagnostics.some((d: ResourceDiagnostic) => d.message.includes("unknown frontmatter field"))).toBe(
false,
);
});
it("should default disableModelInvocation to false when not specified", () => {
const { skills } = loadSkillsFromDir({
dir: join(fixturesDir, "valid-skill"),
source: "test",
});
expect(skills).toHaveLength(1);
expect(skills[0].disableModelInvocation).toBe(false);
});
});
describe("formatSkillsForPrompt", () => {
@ -184,6 +209,7 @@ describe("skills", () => {
filePath: "/path/to/skill/SKILL.md",
baseDir: "/path/to/skill",
source: "test",
disableModelInvocation: false,
},
];
@ -205,6 +231,7 @@ describe("skills", () => {
filePath: "/path/to/skill/SKILL.md",
baseDir: "/path/to/skill",
source: "test",
disableModelInvocation: false,
},
];
@ -224,6 +251,7 @@ describe("skills", () => {
filePath: "/path/to/skill/SKILL.md",
baseDir: "/path/to/skill",
source: "test",
disableModelInvocation: false,
},
];
@ -242,6 +270,7 @@ describe("skills", () => {
filePath: "/path/one/SKILL.md",
baseDir: "/path/one",
source: "test",
disableModelInvocation: false,
},
{
name: "skill-two",
@ -249,6 +278,7 @@ describe("skills", () => {
filePath: "/path/two/SKILL.md",
baseDir: "/path/two",
source: "test",
disableModelInvocation: false,
},
];
@ -258,6 +288,49 @@ describe("skills", () => {
expect(result).toContain("<name>skill-two</name>");
expect((result.match(/<skill>/g) || []).length).toBe(2);
});
it("should exclude skills with disableModelInvocation from prompt", () => {
const skills: Skill[] = [
{
name: "visible-skill",
description: "A visible skill.",
filePath: "/path/visible/SKILL.md",
baseDir: "/path/visible",
source: "test",
disableModelInvocation: false,
},
{
name: "hidden-skill",
description: "A hidden skill.",
filePath: "/path/hidden/SKILL.md",
baseDir: "/path/hidden",
source: "test",
disableModelInvocation: true,
},
];
const result = formatSkillsForPrompt(skills);
expect(result).toContain("<name>visible-skill</name>");
expect(result).not.toContain("<name>hidden-skill</name>");
expect((result.match(/<skill>/g) || []).length).toBe(1);
});
it("should return empty string when all skills have disableModelInvocation", () => {
const skills: Skill[] = [
{
name: "hidden-skill",
description: "A hidden skill.",
filePath: "/path/hidden/SKILL.md",
baseDir: "/path/hidden",
source: "test",
disableModelInvocation: true,
},
];
const result = formatSkillsForPrompt(skills);
expect(result).toBe("");
});
});
describe("loadSkills with options", () => {
@ -271,7 +344,7 @@ describe("skills", () => {
skillPaths: [join(fixturesDir, "valid-skill")],
});
expect(skills).toHaveLength(1);
expect(skills[0].source).toBe("custom");
expect(skills[0].source).toBe("path");
expect(diagnostics).toHaveLength(0);
});