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: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,8 @@ 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.

> **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.

> **Note:** Codex CLI reads UI metadata, invocation policy, and tool dependencies from an `agents/openai.yaml` sidecar next to `SKILL.md` (Codex's `SKILL.md` frontmatter only carries `name` and `description`). When `codexcli.interface`, `codexcli.policy`, or `codexcli.dependencies` is present, Rulesync emits `.agents/skills/<name>/agents/openai.yaml` and reads it back on import. If the sidecar is emitted and `interface.short_description` is absent, the legacy `codexcli.short-description` is routed there. See the [Codex skills docs](https://developers.openai.com/codex/skills.md).
Expand Down
65 changes: 64 additions & 1 deletion src/features/skills/claudecode-skill.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from "node:path";
import { join, relative } from "node:path";

import { z } from "zod/mini";

Expand All @@ -10,6 +10,15 @@ import { SKILL_FILE_NAME } from "../../constants/general.js";
import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../../constants/rulesync-paths.js";
import { ValidationResult } from "../../types/ai-dir.js";
import { formatError } from "../../utils/error.js";
import {
filterOutPathsInGitIgnoredDirectories,
findFilesByGlobs,
toPosixPath,
} from "../../utils/file.js";
import {
NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH,
NESTED_SCAN_EXCLUDED_ROOT_DIRS,
} from "../rules/nested-scan-exclusions.js";
import {
RulesyncSkill,
RulesyncSkillFrontmatter,
Expand Down Expand Up @@ -184,6 +193,60 @@ export class ClaudecodeSkill extends ToolSkill {
};
}

/**
* 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). Discover those directories so `rulesync import` sees them —
* import-only and lenient, like every configured root; the project-root
* `.claude/skills/` stays the sole generation target. The scan reuses the
* nested-rule-file exclusions (dependency trees at any depth, build/vendor
* dirs at the root, hidden dirs other than the `.claude` being matched) and
* never runs in global mode. On a name clash with a root skill, the root
* one wins the import (Claude Code itself keeps both under a
* directory-qualified name, which rulesync's flat skill namespace cannot
* express).
*
* @see https://code.claude.com/docs/en/skills
*/
static async getConfiguredImportRoots({
outputRoot,
global = false,
}: {
outputRoot: string;
global?: boolean;
}): Promise<Array<{ outputRoot: string; relativeDirPath: string }>> {
if (global) {
return [];
}
const root = toPosixPath(outputRoot);
const dirPaths = await findFilesByGlobs(
[`${root}/*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`],
{
type: "dir",
followSymbolicLinks: false,
ignore: [
`${root}/**/.*/**/${toPosixPath(CLAUDECODE_SKILLS_DIR_PATH)}`,
...NESTED_SCAN_EXCLUDED_DIRS_ANY_DEPTH.map((dir) => `${root}/**/${dir}/**`),
...NESTED_SCAN_EXCLUDED_ROOT_DIRS.map((dir) => `${root}/${dir}/**`),
],
},
);
// Honor the project's .gitignore like the nested AGENTS.md scan does: a
// gitignored scratch checkout's skills are somebody else's project and
// must not be pulled into the shared `.rulesync/skills/` namespace.
// Sorted so a nested-vs-nested name clash resolves deterministically
// (lexicographically first root wins).
const filteredDirPaths = filterOutPathsInGitIgnoredDirectories({
rootDir: outputRoot,
filePaths: dirPaths,
}).toSorted();
return filteredDirPaths.map((dirPath) => ({
outputRoot,
relativeDirPath: relative(outputRoot, dirPath),
}));
}

getFrontmatter(): ClaudecodeSkillFrontmatter {
const result = ClaudecodeSkillFrontmatterSchema.parse(this.requireMainFileFrontmatter());
return result;
Expand Down
42 changes: 42 additions & 0 deletions src/features/skills/skills-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,48 @@ Broken YAML`,
);
});

it("should import skills from nested .claude/skills directories (v2.1.178)", async () => {
const logger = createMockLogger();
const processor = new SkillsProcessor({
logger,
outputRoot: testDir,
toolTarget: "claudecode",
});
const rootDir = join(testDir, ".claude", "skills", "root-skill");
const nestedDir = join(testDir, "apps", "web", ".claude", "skills", "deploy");
const dupDir = join(testDir, "apps", "web", ".claude", "skills", "root-skill");
const nodeModulesDir = join(testDir, "node_modules", "dep", ".claude", "skills", "vendored");
const depthOneDir = join(testDir, "apps", ".claude", "skills", "shallow");
const distDir = join(testDir, "dist", "x", ".claude", "skills", "built");
for (const [dir, name, body] of [
[rootDir, "root-skill", "Root body"],
[nestedDir, "deploy", "Deploy body"],
[dupDir, "root-skill", "Nested duplicate body"],
[nodeModulesDir, "vendored", "Vendored body"],
[depthOneDir, "shallow", "Shallow body"],
[distDir, "built", "Built body"],
] as const) {
await ensureDir(dir);
await writeFileContent(
join(dir, "SKILL.md"),
`---\nname: ${name}\ndescription: ${name} description\n---\n${body}`,
);
}

const toolDirs = await processor.loadToolDirs();
const names = toolDirs.map((dir) => (dir as AgentsSkillsSkill).getDirName());

// The nested deploy skill is discovered; the root skill wins the name
// clash; a dependency-tree skill is never scanned.
expect(names).toContain("root-skill");
expect(names).toContain("deploy");
// Depth-1 nesting is covered by the `*/**` glob; root build dirs are not.
expect(names).toContain("shallow");
expect(names).not.toContain("built");
expect(names).not.toContain("vendored");
expect(names.filter((name) => name === "root-skill")).toHaveLength(1);
});

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

Large diffs are not rendered by default.

Loading