diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 23bd21148..4e8e126d9 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -478,6 +478,8 @@ cursor: # for Cursor-specific parameters (optional) disable-model-invocation: true # (optional) only include the skill when invoked via /skill-name metadata: # (optional) free-form metadata author: rulesync +factorydroid: # for Factory Droid-specific parameters (optional) + disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 23bd21148..4e8e126d9 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -478,6 +478,8 @@ cursor: # for Cursor-specific parameters (optional) disable-model-invocation: true # (optional) only include the skill when invoked via /skill-name metadata: # (optional) free-form metadata author: rulesync +factorydroid: # for Factory Droid-specific parameters (optional) + disable-model-invocation: true # (optional) prevent the model from auto-invoking this skill takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) name: "renamed-stem" # (optional) override the emitted filename stem (no path separators or "..") extends: "base" # (optional) emit a leading `{extends:}` facet-inheritance directive (Takt 0.39.0+) diff --git a/src/features/skills/claudecode-skill.ts b/src/features/skills/claudecode-skill.ts index 3c26df247..33a9a3d7f 100644 --- a/src/features/skills/claudecode-skill.ts +++ b/src/features/skills/claudecode-skill.ts @@ -11,6 +11,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { resolveDisableModelInvocation } from "./skills-utils.js"; import { ToolSkill, ToolSkillForDeletionParams, @@ -164,9 +165,10 @@ export class ClaudecodeSkill extends ToolSkill { }: ToolSkillFromRulesyncSkillParams): ClaudecodeSkill { const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); - const resolvedDisableModelInvocation = - rulesyncFrontmatter.claudecode?.["disable-model-invocation"] ?? - rulesyncFrontmatter["disable-model-invocation"]; + const resolvedDisableModelInvocation = resolveDisableModelInvocation({ + rootFrontmatter: rulesyncFrontmatter, + section: rulesyncFrontmatter.claudecode, + }); const claudecodeFrontmatter: ClaudecodeSkillFrontmatter = { name: rulesyncFrontmatter.name, diff --git a/src/features/skills/cursor-skill.ts b/src/features/skills/cursor-skill.ts index 5ce5b1c87..88bf16414 100644 --- a/src/features/skills/cursor-skill.ts +++ b/src/features/skills/cursor-skill.ts @@ -8,6 +8,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { resolveDisableModelInvocation } from "./skills-utils.js"; import { ToolSkill, ToolSkillForDeletionParams, @@ -151,9 +152,10 @@ export class CursorSkill extends ToolSkill { const settablePaths = CursorSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); const cursorSection = rulesyncFrontmatter.cursor; - const resolvedDisableModelInvocation = - cursorSection?.["disable-model-invocation"] ?? - rulesyncFrontmatter["disable-model-invocation"]; + const resolvedDisableModelInvocation = resolveDisableModelInvocation({ + rootFrontmatter: rulesyncFrontmatter, + section: cursorSection, + }); const cursorFrontmatter: CursorSkillFrontmatter = { name: rulesyncFrontmatter.name, diff --git a/src/features/skills/factorydroid-skill.test.ts b/src/features/skills/factorydroid-skill.test.ts index 46ac5a182..c079ade5f 100644 --- a/src/features/skills/factorydroid-skill.test.ts +++ b/src/features/skills/factorydroid-skill.test.ts @@ -158,6 +158,48 @@ This is a test factorydroid skill content.`; const factorydroidSkill = FactorydroidSkill.fromRulesyncSkill({ rulesyncSkill }); expect(factorydroidSkill.getFrontmatter()["disable-model-invocation"]).toBeUndefined(); }); + + it("should let the factorydroid section override the root disable-model-invocation", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "section-override", + frontmatter: { + name: "Section Override", + description: "Section flag", + targets: ["factorydroid"], + "disable-model-invocation": false, + factorydroid: { + "disable-model-invocation": true, + }, + }, + body: "Body", + }); + + const factorydroidSkill = FactorydroidSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(factorydroidSkill.getFrontmatter()["disable-model-invocation"]).toBe(true); + }); + + it("should let a false factorydroid section override a true root value", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "section-false-override", + frontmatter: { + name: "Section False Override", + description: "Section flag", + targets: ["factorydroid"], + "disable-model-invocation": true, + factorydroid: { + "disable-model-invocation": false, + }, + }, + body: "Body", + }); + + const factorydroidSkill = FactorydroidSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(factorydroidSkill.getFrontmatter()["disable-model-invocation"]).toBe(false); + }); }); describe("toRulesyncSkill", () => { @@ -184,6 +226,32 @@ This is a test factorydroid skill content.`; }); expect(rulesyncSkill.getBody()).toBe("Test body"); }); + + it("should round-trip disable-model-invocation into the factorydroid section", () => { + const skill = new FactorydroidSkill({ + outputRoot: testDir, + relativeDirPath: join(".factory", "skills"), + dirName: "dmi-skill", + frontmatter: { + name: "DMI Skill", + description: "DMI description", + "disable-model-invocation": true, + }, + body: "Test body", + validate: true, + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + + expect(rulesyncSkill.getFrontmatter()).toEqual({ + name: "DMI Skill", + description: "DMI description", + targets: ["*"], + factorydroid: { + "disable-model-invocation": true, + }, + }); + }); }); describe("fromDir", () => { diff --git a/src/features/skills/factorydroid-skill.ts b/src/features/skills/factorydroid-skill.ts index c3e7241ab..8c9d87d9d 100644 --- a/src/features/skills/factorydroid-skill.ts +++ b/src/features/skills/factorydroid-skill.ts @@ -8,6 +8,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { resolveDisableModelInvocation } from "./skills-utils.js"; import { ToolSkill, ToolSkillForDeletionParams, @@ -126,10 +127,16 @@ export class FactorydroidSkill extends ToolSkill { toRulesyncSkill(): RulesyncSkill { const frontmatter = this.getFrontmatter(); + const factorydroidBlock = { + ...(frontmatter["disable-model-invocation"] !== undefined && { + "disable-model-invocation": frontmatter["disable-model-invocation"], + }), + }; const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { name: frontmatter.name, description: frontmatter.description, targets: ["*"], + ...(Object.keys(factorydroidBlock).length > 0 && { factorydroid: factorydroidBlock }), }; return new RulesyncSkill({ @@ -152,7 +159,10 @@ export class FactorydroidSkill extends ToolSkill { }: ToolSkillFromRulesyncSkillParams): FactorydroidSkill { const settablePaths = FactorydroidSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); - const resolvedDisableModelInvocation = rulesyncFrontmatter["disable-model-invocation"]; + const resolvedDisableModelInvocation = resolveDisableModelInvocation({ + rootFrontmatter: rulesyncFrontmatter, + section: rulesyncFrontmatter.factorydroid, + }); const factorydroidFrontmatter: FactorydroidSkillFrontmatter = { name: rulesyncFrontmatter.name, diff --git a/src/features/skills/pi-skill.ts b/src/features/skills/pi-skill.ts index 547666755..508dc7d36 100644 --- a/src/features/skills/pi-skill.ts +++ b/src/features/skills/pi-skill.ts @@ -8,6 +8,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { resolveDisableModelInvocation } from "./skills-utils.js"; import { ToolSkill, ToolSkillForDeletionParams, @@ -169,12 +170,17 @@ export class PiSkill extends ToolSkill { const settablePaths = PiSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); const piSection = rulesyncFrontmatter.pi; - const resolvedDisableModelInvocation = - piSection?.["disable-model-invocation"] ?? rulesyncFrontmatter["disable-model-invocation"]; + const resolvedDisableModelInvocation = resolveDisableModelInvocation({ + rootFrontmatter: rulesyncFrontmatter, + section: piSection, + }); const piFrontmatter: PiSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, + // Spread the section first to carry over any tool-specific keys, then + // re-apply the resolved `disable-model-invocation` so the root default is + // honored when the section omits the key. ...piSection, ...(resolvedDisableModelInvocation !== undefined && { "disable-model-invocation": resolvedDisableModelInvocation, diff --git a/src/features/skills/qwencode-skill.ts b/src/features/skills/qwencode-skill.ts index 323aa554b..b09f871f6 100644 --- a/src/features/skills/qwencode-skill.ts +++ b/src/features/skills/qwencode-skill.ts @@ -8,6 +8,7 @@ import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-path import { ValidationResult } from "../../types/ai-dir.js"; import { formatError } from "../../utils/error.js"; import { RulesyncSkill, RulesyncSkillFrontmatterInput, SkillFile } from "./rulesync-skill.js"; +import { resolveDisableModelInvocation } from "./skills-utils.js"; import { ToolSkill, ToolSkillForDeletionParams, @@ -167,9 +168,10 @@ export class QwencodeSkill extends ToolSkill { const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); const qwencodeSection = (rulesyncFrontmatter as { qwencode?: QwencodeRulesyncSection }) .qwencode; - const resolvedDisableModelInvocation = - qwencodeSection?.["disable-model-invocation"] ?? - rulesyncFrontmatter["disable-model-invocation"]; + const resolvedDisableModelInvocation = resolveDisableModelInvocation({ + rootFrontmatter: rulesyncFrontmatter, + section: qwencodeSection, + }); const qwencodeFrontmatter: QwencodeSkillFrontmatter = { name: rulesyncFrontmatter.name, diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index d2d441bb2..e2abb02df 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -137,6 +137,11 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ metadata: z.optional(z.looseObject({})), }), ), + factorydroid: z.optional( + z.looseObject({ + "disable-model-invocation": z.optional(z.boolean()), + }), + ), agentsskills: z.optional( z.looseObject({ license: z.optional(z.string()), @@ -255,6 +260,9 @@ export type RulesyncSkillFrontmatterInput = { "disable-model-invocation"?: boolean; metadata?: Record; }; + factorydroid?: { + "disable-model-invocation"?: boolean; + }; agentsskills?: { license?: string; compatibility?: string | Record; diff --git a/src/features/skills/skills-utils.test.ts b/src/features/skills/skills-utils.test.ts new file mode 100644 index 000000000..49fbbc99f --- /dev/null +++ b/src/features/skills/skills-utils.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDisableModelInvocation } from "./skills-utils.js"; + +describe("resolveDisableModelInvocation", () => { + it("returns the section value when it is set", () => { + expect( + resolveDisableModelInvocation({ + rootFrontmatter: { "disable-model-invocation": false }, + section: { "disable-model-invocation": true }, + }), + ).toBe(true); + }); + + it("lets a false section value override a true root value", () => { + expect( + resolveDisableModelInvocation({ + rootFrontmatter: { "disable-model-invocation": true }, + section: { "disable-model-invocation": false }, + }), + ).toBe(false); + }); + + it("falls back to the root value when the section omits the key", () => { + expect( + resolveDisableModelInvocation({ + rootFrontmatter: { "disable-model-invocation": true }, + section: {}, + }), + ).toBe(true); + }); + + it("falls back to the root value when the section is undefined", () => { + expect( + resolveDisableModelInvocation({ + rootFrontmatter: { "disable-model-invocation": true }, + section: undefined, + }), + ).toBe(true); + }); + + it("returns undefined when neither value is set", () => { + expect( + resolveDisableModelInvocation({ + rootFrontmatter: {}, + section: undefined, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/features/skills/skills-utils.ts b/src/features/skills/skills-utils.ts index 917d34d44..cc90c97ee 100644 --- a/src/features/skills/skills-utils.ts +++ b/src/features/skills/skills-utils.ts @@ -27,3 +27,24 @@ export async function getLocalSkillDirNames(outputRoot: string): Promise