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
2 changes: 1 addition & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,7 +788,7 @@ When `claudecode.scheduled-task: true` is set, that skill is emitted only as a C

> The **simulated** `agentsmd` writer is the exception that is fixed: it has no frontmatter model of its own (the AGENTS.md standard defines no skills at all), so it used to overwrite the native output with a bare `name`/`description` pair and silently drop `license`, `compatibility`, `metadata` and `allowed-tools`. It now emits exactly what `agentsskills` emits, so a simulated writer can never degrade the file a native target owns.

> **Claude Code nested skills note:** Claude Code v2.1.178+ also loads skills from **nested** `.claude/skills/` directories below the working directory (a skill in `apps/web/.claude/skills/` becomes available when working on files there, and a name clash with a root skill keeps both under a directory-qualified name like `apps/web:deploy`). `rulesync import --targets claudecode --features skills` discovers those nested directories (import-only, lenient, same dependency/build-directory exclusions as the nested `AGENTS.md` scan; symlinks not followed) so an existing nested skill is no longer invisible. On a name clash the root skill wins the import — rulesync's flat skill namespace cannot express the qualified variant. Generation stays targeted at the project-root `.claude/skills/`; to scope a skill's _activation_ to a subtree, use the `paths` frontmatter, or run a separate generate with `--output-roots <subdir>` for physical co-location.
> **Claude Code nested skills note:** Claude Code v2.1.178+ also loads skills from **nested** `.claude/skills/` directories below the working directory (a skill in `apps/web/.claude/skills/` becomes available when working on files there, and a name clash with a root skill keeps both under a directory-qualified name like `apps/web:deploy`). `rulesync import --targets claudecode --features skills` discovers those nested directories (import-only, lenient, same dependency/build-directory exclusions as the nested `AGENTS.md` scan; symlinks not followed) so an existing nested skill is no longer invisible. On a name clash the root skill wins the import — rulesync's flat skill namespace cannot express the qualified variant. Because generation stays targeted at the project-root `.claude/skills/`, a nested skill's location-based scoping would otherwise be lost, so the import derives it: a skill found in `apps/web/.claude/skills/` gets `claudecode.paths: ["apps/web/**"]` written for it. Glob metacharacters in the directory names are escaped, so a Next.js-style `app/[slug]/.claude/skills/` still derives a literal match. A `paths` value the skill already declares is kept as-is — Claude Code does not document whether a nested skill's `paths` resolves against the project root or its own directory, so rulesync does not rewrite the author's glob, which means a declared value narrower than the subtree (`src/**`) is re-anchored at the project root once the skill moves there; write it subtree-qualified (`apps/web/src/**`) if that matters. Root-discovered skills get nothing added, and the derived value lands in the `claudecode:` block only — other targets with their own `paths` field (`cursor:`, `qwencode:`) are untouched. To scope a skill's _activation_ to a subtree yourself, write the `paths` frontmatter, or run a separate generate with `--output-roots <subdir>` for physical co-location.

> **Note:** `claudecode.disallowed-tools` (a space/comma-separated string or a YAML list) removes the listed tools from the model while the skill is active. The same field is available on Claude Code slash commands. Both round-trip through the `claudecode` frontmatter section.

Expand Down
114 changes: 114 additions & 0 deletions src/features/skills/claudecode-skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ClaudecodeSkill,
type ClaudecodeSkillFrontmatter,
ClaudecodeSkillFrontmatterSchema,
deriveNestedSkillPaths,
} from "./claudecode-skill.js";
import { RulesyncSkill, type RulesyncSkillFrontmatterInput } from "./rulesync-skill.js";

Expand Down Expand Up @@ -585,6 +586,119 @@ describe("ClaudecodeSkill", () => {
"scheduled-task": true,
});
});

it("should derive paths from a nested discovery root", () => {
const skill = new ClaudecodeSkill({
dirName: "deploy",
relativeDirPath: join("apps", "web", ".claude", "skills"),
frontmatter: {
name: "deploy",
description: "Deploy the web app",
},
body: "Deploy body",
});

const rulesyncSkill = skill.toRulesyncSkill();
expect(rulesyncSkill.getFrontmatter().claudecode).toEqual({
paths: ["apps/web/**"],
});

const roundTripped = ClaudecodeSkill.fromRulesyncSkill({ rulesyncSkill });
expect(roundTripped.getFrontmatter().paths).toEqual(["apps/web/**"]);
});

it("should keep an author-declared paths value on a nested skill", () => {
const skill = new ClaudecodeSkill({
dirName: "deploy",
relativeDirPath: join("apps", "web", ".claude", "skills"),
frontmatter: {
name: "deploy",
description: "Deploy the web app",
paths: ["apps/web/src/**"],
},
body: "Deploy body",
});

expect(skill.toRulesyncSkill().getFrontmatter().claudecode).toEqual({
paths: ["apps/web/src/**"],
});
});

it("should not re-anchor a declared paths value that is narrower than the subtree", () => {
const skill = new ClaudecodeSkill({
dirName: "deploy",
relativeDirPath: join("apps", "web", ".claude", "skills"),
frontmatter: {
name: "deploy",
description: "Deploy the web app",
paths: ["src/**"],
},
body: "Deploy body",
});

expect(skill.toRulesyncSkill().getFrontmatter().claudecode).toEqual({
paths: ["src/**"],
});
});

it("should not derive paths for a root skill", () => {
const skill = new ClaudecodeSkill({
dirName: "deploy",
frontmatter: {
name: "deploy",
description: "Deploy the app",
},
body: "Deploy body",
});

expect(skill.toRulesyncSkill().getFrontmatter().claudecode).toBeUndefined();
});

it("should not derive paths for a nested scheduled-tasks root", () => {
const skill = new ClaudecodeSkill({
dirName: "weekly-review",
relativeDirPath: join("apps", "web", ".claude", "scheduled-tasks"),
frontmatter: {
name: "weekly-review",
description: "Weekly review task",
},
body: "Run weekly review",
});

expect(skill.toRulesyncSkill().getFrontmatter().claudecode).toBeUndefined();
});
});

describe("deriveNestedSkillPaths", () => {
it("should derive a subtree glob from a nested root", () => {
expect(deriveNestedSkillPaths(join("apps", "web", ".claude", "skills"))).toEqual([
"apps/web/**",
]);
});

it("should return undefined for the project-root skills directory", () => {
expect(deriveNestedSkillPaths(join(".claude", "skills"))).toBeUndefined();
});

it("should return undefined for a non-skills directory", () => {
expect(deriveNestedSkillPaths(join("apps", "web", ".claude", "agents"))).toBeUndefined();
});

it("should accept Windows-style separators", () => {
expect(deriveNestedSkillPaths("apps\\web\\.claude\\skills")).toEqual(["apps/web/**"]);
});

it("should escape glob metacharacters in directory names", () => {
expect(deriveNestedSkillPaths("app/[slug]/.claude/skills")).toEqual(["app/\\[slug\\]/**"]);
expect(deriveNestedSkillPaths("packages/a(1)/.claude/skills")).toEqual([
"packages/a\\(1\\)/**",
]);
});

it("should return undefined when there is no subtree above the skills directory", () => {
expect(deriveNestedSkillPaths("/.claude/skills")).toBeUndefined();
expect(deriveNestedSkillPaths("./.claude/skills")).toBeUndefined();
});
});

describe("fromRulesyncSkill", () => {
Expand Down
52 changes: 51 additions & 1 deletion src/features/skills/claudecode-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,45 @@ function buildClaudecodeSkillFrontmatter({
return frontmatter as ClaudecodeSkillFrontmatter;
}

/**
* Escapes the glob metacharacters in a directory path so it matches literally.
* A real directory name may contain them — `app/[slug]` in a Next.js tree is
* the common case, and unescaped `[slug]` reads as a bracket expression that
* matches a different subtree (or nothing at all).
*
* @see https://code.claude.com/docs/en/memory
*/
function escapeGlobLiteral(dirPath: string): string {
return dirPath.replaceAll(/[\\*?[\]{}()!]/g, "\\$&");
}

/**
* Claude Code scopes a nested skill by its location: a skill living in
* `apps/web/.claude/skills/deploy` only activates while working under
* `apps/web`. rulesync generates every imported skill into the project-root
* `.claude/skills/`, so on import that location-based scoping has to be
* re-expressed as an explicit `paths` glob — otherwise the round-trip silently
* promotes a subtree skill to global activation.
*
* Returns the derived glob for a nested discovery root, or `undefined` for the
* project-root `.claude/skills` (and for any root whose subtree cannot be
* determined), where no scoping is implied.
*
* @see https://code.claude.com/docs/en/skills
*/
export function deriveNestedSkillPaths(relativeDirPath: string): string[] | undefined {
const posixDirPath = toPosixPath(relativeDirPath);
const skillsDirSuffix = `/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`;
if (!posixDirPath.endsWith(skillsDirSuffix)) {
return undefined;
}
const subtree = posixDirPath.slice(0, -skillsDirSuffix.length);
if (subtree === "" || subtree === ".") {
return undefined;
}
return [`${escapeGlobLiteral(subtree)}/**`];
}

export type ClaudecodeSkillParams = {
outputRoot?: string;
relativeDirPath?: string;
Expand Down Expand Up @@ -278,6 +317,17 @@ export class ClaudecodeSkill extends ToolSkill {

toRulesyncSkill(): RulesyncSkill {
const frontmatter = this.getFrontmatter();
// An author-declared `paths` always wins; only a skill that says nothing
// about scoping inherits the glob derived from its nested location. A
// declared value is carried through verbatim rather than intersected with
// the subtree: Claude Code does not document whether a nested skill's
// `paths` resolves against the project root or its own directory, so
// rewriting the author's glob would be guessing. The caveat is documented
// in docs/reference/file-formats.md.
const resolvedPaths =
frontmatter.paths !== undefined
? frontmatter.paths
: deriveNestedSkillPaths(this.relativeDirPath);
const claudecodeSection = {
...(frontmatter.when_to_use && { when_to_use: frontmatter.when_to_use }),
...(frontmatter["allowed-tools"] && { "allowed-tools": frontmatter["allowed-tools"] }),
Expand All @@ -302,7 +352,7 @@ export class ClaudecodeSkill extends ToolSkill {
...(this.relativeDirPath === CLAUDECODE_SCHEDULED_TASKS_DIR_PATH && {
"scheduled-task": true,
}),
...(frontmatter.paths !== undefined && { paths: frontmatter.paths }),
...(resolvedPaths !== undefined && { paths: resolvedPaths }),
};
const rulesyncFrontmatter: RulesyncSkillFrontmatterInput = {
name: frontmatter.name,
Expand Down
12 changes: 12 additions & 0 deletions src/features/skills/skills-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,18 @@ Broken YAML`,
expect(names).not.toContain("built");
expect(names).not.toContain("vendored");
expect(names.filter((name) => name === "root-skill")).toHaveLength(1);

// The nested skill's location-based scoping survives the import as an
// explicit glob, while the root skill stays unscoped.
const byName = new Map(
toolDirs.map((dir) => [(dir as ClaudecodeSkill).getDirName(), dir as ClaudecodeSkill]),
);
expect(byName.get("deploy")?.toRulesyncSkill().getFrontmatter().claudecode).toEqual({
paths: ["apps/web/**"],
});
expect(
byName.get("root-skill")?.toRulesyncSkill().getFrontmatter().claudecode,
).toBeUndefined();
});

it("should still abort import for non-lenient tools when a declared-root skill is invalid", async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/generated/docs-content.ts

Large diffs are not rendered by default.

Loading