diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index d21d558a5..7b918b541 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -379,6 +379,10 @@ name: example-skill # skill name description: >- # skill description A sample skill that demonstrates the skill format targets: ["*"] # * = all, or specific tools +# (optional) shared default for tools that support the flag — claudecode, cursor, +# zed, pi, qwencode, and factorydroid. Any of those tool sections can override it +# by setting their own `disable-model-invocation` value below. +disable-model-invocation: true claudecode: # for claudecode-specific parameters model: sonnet # opus, sonnet, haiku, or any string allowed-tools: diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index d21d558a5..7b918b541 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -379,6 +379,10 @@ name: example-skill # skill name description: >- # skill description A sample skill that demonstrates the skill format targets: ["*"] # * = all, or specific tools +# (optional) shared default for tools that support the flag — claudecode, cursor, +# zed, pi, qwencode, and factorydroid. Any of those tool sections can override it +# by setting their own `disable-model-invocation` value below. +disable-model-invocation: true claudecode: # for claudecode-specific parameters model: sonnet # opus, sonnet, haiku, or any string allowed-tools: diff --git a/src/features/skills/claudecode-skill.test.ts b/src/features/skills/claudecode-skill.test.ts index a0d26cf2e..46f2129ec 100644 --- a/src/features/skills/claudecode-skill.test.ts +++ b/src/features/skills/claudecode-skill.test.ts @@ -607,6 +607,57 @@ describe("ClaudecodeSkill", () => { expect(claudecodeSkill.getFrontmatter()["disable-model-invocation"]).toBe(false); }); + it("should pick up root-level disable-model-invocation when claudecode section omits it", () => { + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: "root-default-skill", + description: "Skill with root-level disable-model-invocation", + "disable-model-invocation": true, + }; + + const rulesyncSkill = new RulesyncSkill({ + dirName: "root-default-skill", + frontmatter: rulesyncFrontmatter, + body: "Body", + }); + + const claudecodeSkill = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(claudecodeSkill.getFrontmatter()["disable-model-invocation"]).toBe(true); + }); + + it("should let claudecode disable-model-invocation override the root-level value", () => { + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: "override-skill", + description: "Skill where the claudecode section overrides the root default", + "disable-model-invocation": true, + claudecode: { "disable-model-invocation": false }, + }; + + const rulesyncSkill = new RulesyncSkill({ + dirName: "override-skill", + frontmatter: rulesyncFrontmatter, + body: "Body", + }); + + const claudecodeSkill = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(claudecodeSkill.getFrontmatter()["disable-model-invocation"]).toBe(false); + }); + + it("should omit disable-model-invocation when neither root nor claudecode set it", () => { + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: "no-flag-skill", + description: "Skill without the flag", + }; + + const rulesyncSkill = new RulesyncSkill({ + dirName: "no-flag-skill", + frontmatter: rulesyncFrontmatter, + body: "Body", + }); + + const claudecodeSkill = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(claudecodeSkill.getFrontmatter()["disable-model-invocation"]).toBeUndefined(); + }); + it("should convert from RulesyncSkill with paths as string", () => { const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { name: "paths-string-skill", diff --git a/src/features/skills/claudecode-skill.ts b/src/features/skills/claudecode-skill.ts index 639b7fcad..3c26df247 100644 --- a/src/features/skills/claudecode-skill.ts +++ b/src/features/skills/claudecode-skill.ts @@ -164,6 +164,10 @@ export class ClaudecodeSkill extends ToolSkill { }: ToolSkillFromRulesyncSkillParams): ClaudecodeSkill { const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const resolvedDisableModelInvocation = + rulesyncFrontmatter.claudecode?.["disable-model-invocation"] ?? + rulesyncFrontmatter["disable-model-invocation"]; + const claudecodeFrontmatter: ClaudecodeSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, @@ -176,8 +180,8 @@ export class ClaudecodeSkill extends ToolSkill { ...(rulesyncFrontmatter.claudecode?.model && { model: rulesyncFrontmatter.claudecode.model, }), - ...(rulesyncFrontmatter.claudecode?.["disable-model-invocation"] !== undefined && { - "disable-model-invocation": rulesyncFrontmatter.claudecode["disable-model-invocation"], + ...(resolvedDisableModelInvocation !== undefined && { + "disable-model-invocation": resolvedDisableModelInvocation, }), ...(rulesyncFrontmatter.claudecode?.paths !== undefined && { paths: rulesyncFrontmatter.claudecode.paths, diff --git a/src/features/skills/cursor-skill.test.ts b/src/features/skills/cursor-skill.test.ts index 0f068e0b6..99cc7d83a 100644 --- a/src/features/skills/cursor-skill.test.ts +++ b/src/features/skills/cursor-skill.test.ts @@ -233,6 +233,48 @@ This is the body of the cursor skill.`; expect(fm["disable-model-invocation"]).toBe(true); expect(fm.metadata).toEqual({ author: "rulesync" }); }); + + it("should pick up root-level disable-model-invocation when cursor section omits it", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "root-default", + frontmatter: { + name: "Root Default", + description: "Root-level flag", + "disable-model-invocation": true, + }, + body: "Body", + }); + + const cursorSkill = CursorSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(cursorSkill.getFrontmatter()["disable-model-invocation"]).toBe(true); + }); + + it("should let cursor disable-model-invocation override the root-level value", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "override", + frontmatter: { + name: "Override", + description: "Cursor opts out of root default", + "disable-model-invocation": true, + cursor: { "disable-model-invocation": false }, + }, + body: "Body", + }); + + const cursorSkill = CursorSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(cursorSkill.getFrontmatter()["disable-model-invocation"]).toBe(false); + }); + + it("should omit disable-model-invocation when neither root nor cursor set it", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "no-flag", + frontmatter: { name: "No Flag", description: "No flag" }, + body: "Body", + }); + + const cursorSkill = CursorSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(cursorSkill.getFrontmatter()["disable-model-invocation"]).toBeUndefined(); + }); }); describe("forDeletion", () => { diff --git a/src/features/skills/cursor-skill.ts b/src/features/skills/cursor-skill.ts index f841c0143..5ce5b1c87 100644 --- a/src/features/skills/cursor-skill.ts +++ b/src/features/skills/cursor-skill.ts @@ -151,13 +151,16 @@ 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 cursorFrontmatter: CursorSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, ...(cursorSection?.paths !== undefined && { paths: cursorSection.paths }), - ...(cursorSection?.["disable-model-invocation"] !== undefined && { - "disable-model-invocation": cursorSection["disable-model-invocation"], + ...(resolvedDisableModelInvocation !== undefined && { + "disable-model-invocation": resolvedDisableModelInvocation, }), ...(cursorSection?.metadata !== undefined && { metadata: cursorSection.metadata }), }; diff --git a/src/features/skills/factorydroid-skill.test.ts b/src/features/skills/factorydroid-skill.test.ts index 7f353d3a5..46ac5a182 100644 --- a/src/features/skills/factorydroid-skill.test.ts +++ b/src/features/skills/factorydroid-skill.test.ts @@ -123,6 +123,41 @@ This is a test factorydroid skill content.`; expect(factorydroidSkill.getGlobal()).toBe(true); expect(factorydroidSkill.getRelativeDirPath()).toBe(join(".factory", "skills")); }); + + it("should pick up root-level disable-model-invocation", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "root-default", + frontmatter: { + name: "Root Default", + description: "Root flag", + targets: ["factorydroid"], + "disable-model-invocation": true, + }, + body: "Body", + }); + + const factorydroidSkill = FactorydroidSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(factorydroidSkill.getFrontmatter()["disable-model-invocation"]).toBe(true); + }); + + it("should omit disable-model-invocation when the root value is not set", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "no-flag", + frontmatter: { + name: "No Flag", + description: "No flag", + targets: ["factorydroid"], + }, + body: "Body", + }); + + const factorydroidSkill = FactorydroidSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(factorydroidSkill.getFrontmatter()["disable-model-invocation"]).toBeUndefined(); + }); }); describe("toRulesyncSkill", () => { diff --git a/src/features/skills/factorydroid-skill.ts b/src/features/skills/factorydroid-skill.ts index 7df485f6a..c3e7241ab 100644 --- a/src/features/skills/factorydroid-skill.ts +++ b/src/features/skills/factorydroid-skill.ts @@ -152,10 +152,14 @@ export class FactorydroidSkill extends ToolSkill { }: ToolSkillFromRulesyncSkillParams): FactorydroidSkill { const settablePaths = FactorydroidSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const resolvedDisableModelInvocation = rulesyncFrontmatter["disable-model-invocation"]; const factorydroidFrontmatter: FactorydroidSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, + ...(resolvedDisableModelInvocation !== undefined && { + "disable-model-invocation": resolvedDisableModelInvocation, + }), }; return new FactorydroidSkill({ diff --git a/src/features/skills/pi-skill.test.ts b/src/features/skills/pi-skill.test.ts index 7d8fc3ea8..e31a95ab9 100644 --- a/src/features/skills/pi-skill.test.ts +++ b/src/features/skills/pi-skill.test.ts @@ -263,6 +263,57 @@ Body`, metadata: { author: "rulesync" }, }); }); + + it("should pick up root-level disable-model-invocation when pi section omits it", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "root-default", + frontmatter: { + name: "root-default", + description: "Root flag", + "disable-model-invocation": true, + }, + body: "Body", + validate: true, + }); + + const skill = PiSkill.fromRulesyncSkill({ outputRoot: testDir, rulesyncSkill }); + expect(skill.getFrontmatter()["disable-model-invocation"]).toBe(true); + }); + + it("should let pi disable-model-invocation override the root-level value", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "override", + frontmatter: { + name: "override", + description: "Pi opts out of root default", + "disable-model-invocation": true, + pi: { "disable-model-invocation": false }, + }, + body: "Body", + validate: true, + }); + + const skill = PiSkill.fromRulesyncSkill({ outputRoot: testDir, rulesyncSkill }); + expect(skill.getFrontmatter()["disable-model-invocation"]).toBe(false); + }); + + it("should omit disable-model-invocation when neither root nor pi set it", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "no-flag", + frontmatter: { name: "no-flag", description: "No flag" }, + body: "Body", + validate: true, + }); + + const skill = PiSkill.fromRulesyncSkill({ outputRoot: testDir, rulesyncSkill }); + expect(skill.getFrontmatter()["disable-model-invocation"]).toBeUndefined(); + }); }); describe("toRulesyncSkill", () => { diff --git a/src/features/skills/pi-skill.ts b/src/features/skills/pi-skill.ts index 124bdd6b5..547666755 100644 --- a/src/features/skills/pi-skill.ts +++ b/src/features/skills/pi-skill.ts @@ -168,11 +168,17 @@ export class PiSkill extends ToolSkill { }: ToolSkillFromRulesyncSkillParams): PiSkill { const settablePaths = PiSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const piSection = rulesyncFrontmatter.pi; + const resolvedDisableModelInvocation = + piSection?.["disable-model-invocation"] ?? rulesyncFrontmatter["disable-model-invocation"]; const piFrontmatter: PiSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, - ...rulesyncFrontmatter.pi, + ...piSection, + ...(resolvedDisableModelInvocation !== undefined && { + "disable-model-invocation": resolvedDisableModelInvocation, + }), }; return new PiSkill({ diff --git a/src/features/skills/qwencode-skill.test.ts b/src/features/skills/qwencode-skill.test.ts index ae31253ae..c80f31be9 100644 --- a/src/features/skills/qwencode-skill.test.ts +++ b/src/features/skills/qwencode-skill.test.ts @@ -230,6 +230,48 @@ describe("QwencodeSkill", () => { expect(qwencodeSkill.getGlobal()).toBe(true); }); + + it("should pick up root-level disable-model-invocation when qwencode section omits it", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "root-default", + frontmatter: { + name: "root-default", + description: "Root flag", + "disable-model-invocation": true, + }, + body: "Body", + }); + + const qwencodeSkill = QwencodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(qwencodeSkill.getFrontmatter()["disable-model-invocation"]).toBe(true); + }); + + it("should let qwencode disable-model-invocation override the root-level value", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "override", + frontmatter: { + name: "override", + description: "Qwencode opts out", + "disable-model-invocation": true, + qwencode: { "disable-model-invocation": false }, + } as RulesyncSkillFrontmatterInput, + body: "Body", + }); + + const qwencodeSkill = QwencodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(qwencodeSkill.getFrontmatter()["disable-model-invocation"]).toBe(false); + }); + + it("should omit disable-model-invocation when neither root nor qwencode set it", () => { + const rulesyncSkill = new RulesyncSkill({ + dirName: "no-flag", + frontmatter: { name: "no-flag", description: "No flag" }, + body: "Body", + }); + + const qwencodeSkill = QwencodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(qwencodeSkill.getFrontmatter()["disable-model-invocation"]).toBeUndefined(); + }); }); describe("toRulesyncSkill", () => { diff --git a/src/features/skills/qwencode-skill.ts b/src/features/skills/qwencode-skill.ts index 995250c62..323aa554b 100644 --- a/src/features/skills/qwencode-skill.ts +++ b/src/features/skills/qwencode-skill.ts @@ -167,6 +167,9 @@ 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 qwencodeFrontmatter: QwencodeSkillFrontmatter = { name: rulesyncFrontmatter.name, @@ -176,8 +179,8 @@ export class QwencodeSkill extends ToolSkill { ...(qwencodeSection?.["user-invocable"] !== undefined && { "user-invocable": qwencodeSection["user-invocable"], }), - ...(qwencodeSection?.["disable-model-invocation"] !== undefined && { - "disable-model-invocation": qwencodeSection["disable-model-invocation"], + ...(resolvedDisableModelInvocation !== undefined && { + "disable-model-invocation": resolvedDisableModelInvocation, }), }; diff --git a/src/features/skills/rulesync-skill.test.ts b/src/features/skills/rulesync-skill.test.ts index 47b1863c4..c32df375e 100644 --- a/src/features/skills/rulesync-skill.test.ts +++ b/src/features/skills/rulesync-skill.test.ts @@ -588,6 +588,31 @@ This has leading and trailing whitespace. const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter); expect(result.success).toBe(false); }); + + it("should accept root-level disable-model-invocation", () => { + const frontmatter = { + name: "test-skill", + description: "Test", + "disable-model-invocation": true, + }; + + const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data["disable-model-invocation"]).toBe(true); + } + }); + + it("should reject non-boolean root-level disable-model-invocation", () => { + const frontmatter = { + name: "test-skill", + description: "Test", + "disable-model-invocation": "yes", + }; + + const result = RulesyncSkillFrontmatterSchema.safeParse(frontmatter); + expect(result.success).toBe(false); + }); }); describe("integration", () => { diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 22881e125..d2d441bb2 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -14,6 +14,9 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ name: z.string(), description: z.string(), targets: z._default(RulesyncTargetsSchema, ["*"]), + // Default for tools that support the flag (claudecode, cursor, zed, pi, qwencode, factorydroid). + // A target-section value of the same key overrides this default. + "disable-model-invocation": z.optional(z.boolean()), claudecode: z.optional( z.looseObject({ "allowed-tools": z.optional(z.array(z.string())), @@ -172,6 +175,7 @@ export type RulesyncSkillFrontmatterInput = { name: string; description: string; targets?: ("*" | string)[]; + "disable-model-invocation"?: boolean; claudecode?: { "allowed-tools"?: string[]; "disallowed-tools"?: string | string[]; diff --git a/src/features/skills/zed-skill.test.ts b/src/features/skills/zed-skill.test.ts index 1478dbf0d..dfa55370d 100644 --- a/src/features/skills/zed-skill.test.ts +++ b/src/features/skills/zed-skill.test.ts @@ -140,6 +140,57 @@ This is the body of the zed skill.`; expect(zedSkill.getFrontmatter()["disable-model-invocation"]).toBe(true); }); + + it("should pick up root-level disable-model-invocation when zed section omits it", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "root-default", + frontmatter: { + name: "root-default", + description: "Root flag", + "disable-model-invocation": true, + }, + body: "Body", + validate: true, + }); + + const zedSkill = ZedSkill.fromRulesyncSkill({ rulesyncSkill, validate: true }); + expect(zedSkill.getFrontmatter()["disable-model-invocation"]).toBe(true); + }); + + it("should let zed disable-model-invocation override the root-level value", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "override", + frontmatter: { + name: "override", + description: "Zed opts out of root default", + "disable-model-invocation": true, + zed: { "disable-model-invocation": false }, + }, + body: "Body", + validate: true, + }); + + const zedSkill = ZedSkill.fromRulesyncSkill({ rulesyncSkill, validate: true }); + expect(zedSkill.getFrontmatter()["disable-model-invocation"]).toBe(false); + }); + + it("should omit disable-model-invocation when neither root nor zed set it", () => { + const rulesyncSkill = new RulesyncSkill({ + outputRoot: testDir, + relativeDirPath: RULESYNC_SKILLS_RELATIVE_DIR_PATH, + dirName: "no-flag", + frontmatter: { name: "no-flag", description: "No flag" }, + body: "Body", + validate: true, + }); + + const zedSkill = ZedSkill.fromRulesyncSkill({ rulesyncSkill, validate: true }); + expect(zedSkill.getFrontmatter()["disable-model-invocation"]).toBeUndefined(); + }); }); describe("isTargetedByRulesyncSkill", () => { diff --git a/src/features/skills/zed-skill.ts b/src/features/skills/zed-skill.ts index 563a358e3..b8f67d675 100644 --- a/src/features/skills/zed-skill.ts +++ b/src/features/skills/zed-skill.ts @@ -145,11 +145,17 @@ export class ZedSkill extends ToolSkill { }: ToolSkillFromRulesyncSkillParams): ZedSkill { const settablePaths = ZedSkill.getSettablePaths({ global }); const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + const zedSection = rulesyncFrontmatter.zed; + const resolvedDisableModelInvocation = + zedSection?.["disable-model-invocation"] ?? rulesyncFrontmatter["disable-model-invocation"]; const zedFrontmatter: ZedSkillFrontmatter = { name: rulesyncFrontmatter.name, description: rulesyncFrontmatter.description, - ...rulesyncFrontmatter.zed, + ...zedSection, + ...(resolvedDisableModelInvocation !== undefined && { + "disable-model-invocation": resolvedDisableModelInvocation, + }), }; return new ZedSkill({