import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { discoverClaudeSkills } from "./ClaudeSkills.ts"; const writeSkill = Effect.fn(function* ( skillsDir: string, directoryName: string, contents: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const skillDir = path.join(skillsDir, directoryName); yield* fs.makeDirectory(skillDir, { recursive: true }); yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents); }); it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { it.effect("discovers user and project skills with frontmatter metadata", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const configDir = path.join(tempDir, "claude-home"); const workspace = path.join(tempDir, "workspace"); yield* writeSkill( path.join(configDir, "skills"), "codex-review", [ "---", "name: codex-review", "description: Ask Codex for a review.", "---", "", "# Body", ].join("\n"), ); yield* writeSkill( path.join(workspace, ".claude", "skills"), "deploy", ["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"), ); const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.deepEqual(skills, [ { name: "codex-review", path: path.join(configDir, "skills", "codex-review", "SKILL.md"), enabled: true, scope: "user", description: "Ask Codex for a review.", }, { name: "deploy", path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), enabled: true, scope: "project", description: "Deploy the app.", }, ]); }), ); it.effect("discovers project skills from the workspace .agents directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const configDir = path.join(tempDir, "claude-home"); const workspace = path.join(tempDir, "workspace"); yield* writeSkill( path.join(workspace, ".agents", "skills"), "review", ["---", "name: review", "description: Review the changes.", "---"].join("\n"), ); const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.deepEqual(skills, [ { name: "review", path: path.join(workspace, ".agents", "skills", "review", "SKILL.md"), enabled: true, scope: "project", description: "Review the changes.", }, ]); }), ); it.effect("prefers workspace .claude skills on three-way name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const configDir = path.join(tempDir, "claude-home"); const workspace = path.join(tempDir, "workspace"); yield* writeSkill( path.join(configDir, "skills"), "deploy", ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), ); yield* writeSkill( path.join(workspace, ".agents", "skills"), "deploy", ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), ); yield* writeSkill( path.join(workspace, ".claude", "skills"), "deploy", ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"), ); const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.deepEqual(skills, [ { name: "deploy", path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"), enabled: true, scope: "project", description: "Claude deploy.", }, ]); }), ); it.effect("prefers workspace .agents skills over user skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const configDir = path.join(tempDir, "claude-home"); const workspace = path.join(tempDir, "workspace"); yield* writeSkill( path.join(configDir, "skills"), "deploy", ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), ); yield* writeSkill( path.join(workspace, ".agents", "skills"), "deploy", ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"), ); const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.deepEqual(skills, [ { name: "deploy", path: path.join(workspace, ".agents", "skills", "deploy", "SKILL.md"), enabled: true, scope: "project", description: "Agents deploy.", }, ]); }), ); it.effect("prefers project skills over user skills on name collisions", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const configDir = path.join(tempDir, "claude-home"); const workspace = path.join(tempDir, "workspace"); yield* writeSkill( path.join(configDir, "skills"), "deploy", ["---", "name: deploy", "description: User deploy.", "---"].join("\n"), ); yield* writeSkill( path.join(workspace, ".claude", "skills"), "deploy", ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"), ); const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace); assert.equal(skills.length, 1); assert.equal(skills[0]?.scope, "project"); assert.equal(skills[0]?.description, "Project deploy."); }), ); it.effect("falls back to the directory name and skips malformed frontmatter", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const configDir = path.join(tempDir, "claude-home"); const skillsDir = path.join(configDir, "skills"); yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n"); yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n"); // A stray file (not a directory with SKILL.md) must be skipped. yield* fs.makeDirectory(skillsDir, { recursive: true }); yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); // A skill with no frontmatter falls back to its directory name; a skill // whose frontmatter fails to parse is skipped entirely (Claude Code // won't load it either). assert.deepEqual( skills.map((skill) => skill.name), ["no-frontmatter"], ); assert.equal(skills[0]?.description, undefined); }), ); it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const environmentConfigDir = path.join(tempDir, "env-config"); yield* writeSkill( path.join(environmentConfigDir, "skills"), "env-skill", ["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"), ); const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, { CLAUDE_CONFIG_DIR: environmentConfigDir, }); assert.deepEqual( skills.map((skill) => skill.name), ["env-skill"], ); // An explicit homePath wins over the environment variable, matching // makeClaudeEnvironment which overwrites CLAUDE_CONFIG_DIR for the CLI. const explicitHome = path.join(tempDir, "explicit-home"); yield* writeSkill( path.join(explicitHome, "skills"), "explicit-skill", ["---", "name: explicit-skill", "---"].join("\n"), ); const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, { CLAUDE_CONFIG_DIR: environmentConfigDir, }); assert.deepEqual( explicitSkills.map((skill) => skill.name), ["explicit-skill"], ); }), ); it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const workspace = path.join(tempDir, "workspace"); yield* fs.makeDirectory(workspace, { recursive: true }); // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own // cwd (the workspace), so discovery must do the same. yield* writeSkill( path.join(workspace, "relative-config", "skills"), "relative-skill", ["---", "name: relative-skill", "---"].join("\n"), ); const skills = yield* discoverClaudeSkills({ homePath: "" }, workspace, { CLAUDE_CONFIG_DIR: "relative-config", }); assert.deepEqual( skills.map((skill) => skill.name), ["relative-skill"], ); assert.equal(skills[0]?.scope, "user"); }), ); it.effect("returns an empty list when no skill roots exist", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); const skills = yield* discoverClaudeSkills( { homePath: path.join(tempDir, "missing-home") }, path.join(tempDir, "missing-workspace"), ); assert.deepEqual(skills, []); }), ); });