Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/SKILL.md instead of .claude/skills/<name>/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)
Expand Down
3 changes: 3 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>/SKILL.md instead of .claude/skills/<name>/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)
Expand Down
150 changes: 150 additions & 0 deletions src/features/skills/claudecode-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/features/skills/claudecode-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())])),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In claudecode-rule.ts, paths accepts only string[], while here it accepts string | string[]. A brief comment explaining this difference would help future maintainers understand it is intentional, not an oversight.

});

export type ClaudecodeSkillFrontmatter = z.infer<typeof ClaudecodeSkillFrontmatterSchema>;
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 });
Expand Down
2 changes: 2 additions & 0 deletions src/features/skills/rulesync-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -70,6 +71,7 @@ export type RulesyncSkillFrontmatterInput = {
model?: string;
"disable-model-invocation"?: boolean;
"scheduled-task"?: boolean;
paths?: string | string[];
};
codexcli?: {
"short-description"?: string;
Expand Down
Loading