From 90d2acb61dfaa22dbae46e6a0eae49786feb2fef Mon Sep 17 00:00:00 2001 From: Simon Jornet Date: Thu, 7 May 2026 16:48:43 +0200 Subject: [PATCH] feat(skills): support paths in Claude Code skills Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/reference/file-formats.md | 3 + skills/rulesync/file-formats.md | 3 + src/features/skills/claudecode-skill.test.ts | 150 +++++++++++++++++++ src/features/skills/claudecode-skill.ts | 5 + src/features/skills/rulesync-skill.ts | 2 + 5 files changed, 163 insertions(+) diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index df46de47a..e1e39af9f 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -267,6 +267,9 @@ claudecode: # for claudecode-specific parameters - "Grep" disable-model-invocation: true # (optional) disable model invocation for this skill scheduled-task: true # (optional) emit to .claude/scheduled-tasks//SKILL.md instead of .claude/skills//SKILL.md + paths: # (optional) glob patterns (string or list) limiting auto-activation + - "src/**/*.ts" + - "test/**/*.ts" codexcli: # for codexcli-specific parameters short-description: A brief user-facing description takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index df46de47a..e1e39af9f 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -267,6 +267,9 @@ claudecode: # for claudecode-specific parameters - "Grep" disable-model-invocation: true # (optional) disable model invocation for this skill scheduled-task: true # (optional) emit to .claude/scheduled-tasks//SKILL.md instead of .claude/skills//SKILL.md + paths: # (optional) glob patterns (string or list) limiting auto-activation + - "src/**/*.ts" + - "test/**/*.ts" codexcli: # for codexcli-specific parameters short-description: A brief user-facing description takt: # takt specific parameters (optional; emitted under .takt/facets/knowledge/ — frontmatter is dropped on emit) diff --git a/src/features/skills/claudecode-skill.test.ts b/src/features/skills/claudecode-skill.test.ts index ec7a66446..09b77bb16 100644 --- a/src/features/skills/claudecode-skill.test.ts +++ b/src/features/skills/claudecode-skill.test.ts @@ -348,6 +348,48 @@ describe("ClaudecodeSkill", () => { }); }); + it("should convert to RulesyncSkill with paths as string", () => { + const frontmatter: ClaudecodeSkillFrontmatter = { + name: "paths-string-skill", + description: "Skill with comma-separated paths", + paths: "src/**/*.ts,test/**/*.ts", + }; + + const skill = new ClaudecodeSkill({ + dirName: "paths-string-skill", + frontmatter, + body: "Paths string body", + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + + expect(rulesyncFrontmatter.claudecode).toEqual({ + paths: "src/**/*.ts,test/**/*.ts", + }); + }); + + it("should convert to RulesyncSkill with paths as array", () => { + const frontmatter: ClaudecodeSkillFrontmatter = { + name: "paths-array-skill", + description: "Skill with paths list", + paths: ["src/**/*.ts", "test/**/*.ts"], + }; + + const skill = new ClaudecodeSkill({ + dirName: "paths-array-skill", + frontmatter, + body: "Paths array body", + }); + + const rulesyncSkill = skill.toRulesyncSkill(); + const rulesyncFrontmatter = rulesyncSkill.getFrontmatter(); + + expect(rulesyncFrontmatter.claudecode).toEqual({ + paths: ["src/**/*.ts", "test/**/*.ts"], + }); + }); + it("should preserve other files during conversion", () => { const frontmatter: ClaudecodeSkillFrontmatter = { name: "test-skill", @@ -504,6 +546,40 @@ describe("ClaudecodeSkill", () => { expect(claudecodeSkill.getFrontmatter()["disable-model-invocation"]).toBe(false); }); + it("should convert from RulesyncSkill with paths as string", () => { + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: "paths-string-skill", + description: "Skill with comma-separated paths", + claudecode: { paths: "src/**/*.ts,test/**/*.ts" }, + }; + + const rulesyncSkill = new RulesyncSkill({ + dirName: "paths-string-skill", + frontmatter: rulesyncFrontmatter, + body: "Paths string body", + }); + + const claudecodeSkill = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(claudecodeSkill.getFrontmatter().paths).toBe("src/**/*.ts,test/**/*.ts"); + }); + + it("should convert from RulesyncSkill with paths as array", () => { + const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { + name: "paths-array-skill", + description: "Skill with paths list", + claudecode: { paths: ["src/**/*.ts", "test/**/*.ts"] }, + }; + + const rulesyncSkill = new RulesyncSkill({ + dirName: "paths-array-skill", + frontmatter: rulesyncFrontmatter, + body: "Paths array body", + }); + + const claudecodeSkill = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + expect(claudecodeSkill.getFrontmatter().paths).toEqual(["src/**/*.ts", "test/**/*.ts"]); + }); + it("should set correct relativeDirPath", () => { const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { name: "test-skill", @@ -922,6 +998,42 @@ Global skill content.`; }); expect(result.success).toBe(false); }); + + it("should validate frontmatter with paths as string", () => { + const result = ClaudecodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test", + paths: "src/**/*.ts,test/**/*.ts", + }); + expect(result.success).toBe(true); + }); + + it("should validate frontmatter with paths as array", () => { + const result = ClaudecodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test", + paths: ["src/**/*.ts", "test/**/*.ts"], + }); + expect(result.success).toBe(true); + }); + + it("should reject paths with invalid type", () => { + const result = ClaudecodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test", + paths: 123, + }); + expect(result.success).toBe(false); + }); + + it("should reject paths with array containing non-strings", () => { + const result = ClaudecodeSkillFrontmatterSchema.safeParse({ + name: "test-skill", + description: "Test", + paths: ["src/**/*.ts", 42], + }); + expect(result.success).toBe(false); + }); }); describe("round-trip conversion", () => { @@ -963,6 +1075,44 @@ Global skill content.`; expect(restored.getFrontmatter()["disable-model-invocation"]).toBe(false); }); + it("should preserve paths string through round-trip", () => { + const originalFrontmatter: ClaudecodeSkillFrontmatter = { + name: "round-trip-skill", + description: "Round trip test", + paths: "src/**/*.ts,test/**/*.ts", + }; + + const original = new ClaudecodeSkill({ + dirName: "round-trip-skill", + frontmatter: originalFrontmatter, + body: "Round trip body", + }); + + const rulesyncSkill = original.toRulesyncSkill(); + const restored = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + + expect(restored.getFrontmatter().paths).toBe("src/**/*.ts,test/**/*.ts"); + }); + + it("should preserve paths array through round-trip", () => { + const originalFrontmatter: ClaudecodeSkillFrontmatter = { + name: "round-trip-skill", + description: "Round trip test", + paths: ["src/**/*.ts", "test/**/*.ts"], + }; + + const original = new ClaudecodeSkill({ + dirName: "round-trip-skill", + frontmatter: originalFrontmatter, + body: "Round trip body", + }); + + const rulesyncSkill = original.toRulesyncSkill(); + const restored = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill }); + + expect(restored.getFrontmatter().paths).toEqual(["src/**/*.ts", "test/**/*.ts"]); + }); + it("should preserve model through ClaudecodeSkill -> RulesyncSkill -> ClaudecodeSkill", () => { const originalFrontmatter: ClaudecodeSkillFrontmatter = { name: "round-trip-skill", diff --git a/src/features/skills/claudecode-skill.ts b/src/features/skills/claudecode-skill.ts index b81bc5557..e6ce18fbc 100644 --- a/src/features/skills/claudecode-skill.ts +++ b/src/features/skills/claudecode-skill.ts @@ -24,6 +24,7 @@ export const ClaudecodeSkillFrontmatterSchema = z.looseObject({ "allowed-tools": z.optional(z.array(z.string())), model: z.optional(z.string()), "disable-model-invocation": z.optional(z.boolean()), + paths: z.optional(z.union([z.string(), z.array(z.string())])), }); export type ClaudecodeSkillFrontmatter = z.infer; @@ -124,6 +125,7 @@ export class ClaudecodeSkill extends ToolSkill { ...(frontmatter["disable-model-invocation"] !== undefined && { "disable-model-invocation": frontmatter["disable-model-invocation"], }), + ...(frontmatter.paths !== undefined && { paths: frontmatter.paths }), ...(this.relativeDirPath === CLAUDE_SCHEDULED_TASKS_DIR_PATH && { "scheduled-task": true }), }; const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = { @@ -165,6 +167,9 @@ export class ClaudecodeSkill extends ToolSkill { ...(rulesyncFrontmatter.claudecode?.["disable-model-invocation"] !== undefined && { "disable-model-invocation": rulesyncFrontmatter.claudecode["disable-model-invocation"], }), + ...(rulesyncFrontmatter.claudecode?.paths !== undefined && { + paths: rulesyncFrontmatter.claudecode.paths, + }), }; const settablePaths = ClaudecodeSkill.getSettablePaths({ global }); diff --git a/src/features/skills/rulesync-skill.ts b/src/features/skills/rulesync-skill.ts index 98464e1dc..ff7318d2c 100644 --- a/src/features/skills/rulesync-skill.ts +++ b/src/features/skills/rulesync-skill.ts @@ -20,6 +20,7 @@ const RulesyncSkillFrontmatterSchemaInternal = z.looseObject({ model: z.optional(z.string()), "disable-model-invocation": z.optional(z.boolean()), "scheduled-task": z.optional(z.boolean()), + paths: z.optional(z.union([z.string(), z.array(z.string())])), }), ), codexcli: z.optional( @@ -70,6 +71,7 @@ export type RulesyncSkillFrontmatterInput = { model?: string; "disable-model-invocation"?: boolean; "scheduled-task"?: boolean; + paths?: string | string[]; }; codexcli?: { "short-description"?: string;