1import * as NodePath from "@effect/platform-node/NodePath";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7
8import { discoverClaudeSkills, skillOverrideSettingsPaths } from "./ClaudeSkills.ts";
9
10const writeSkill = Effect.fn(function* (
11 skillsDir: string,
12 directoryName: string,
13 contents: string,
14) {
15 const fs = yield* FileSystem.FileSystem;
16 const path = yield* Path.Path;
17 const skillDir = path.join(skillsDir, directoryName);
18 yield* fs.makeDirectory(skillDir, { recursive: true });
19 yield* fs.writeFileString(path.join(skillDir, "SKILL.md"), contents);
20});
21
22it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => {
23 it.effect("discovers user and project skills with frontmatter metadata", () =>
24 Effect.gen(function* () {
25 const fs = yield* FileSystem.FileSystem;
26 const path = yield* Path.Path;
27 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
28 const configDir = path.join(tempDir, "claude-home");
29 const workspace = path.join(tempDir, "workspace");
30
31 yield* writeSkill(
32 path.join(configDir, "skills"),
33 "codex-review",
34 [
35 "---",
36 "name: codex-review",
37 "description: Ask Codex for a review.",
38 "---",
39 "",
40 "# Body",
41 ].join("\n"),
42 );
43 yield* writeSkill(
44 path.join(workspace, ".claude", "skills"),
45 "deploy",
46 ["---", "name: deploy", "description: Deploy the app.", "---", "", "# Deploy"].join("\n"),
47 );
48
49 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
50
51 assert.deepEqual(skills, [
52 {
53 name: "codex-review",
54 path: path.join(configDir, "skills", "codex-review", "SKILL.md"),
55 enabled: true,
56 scope: "user",
57 description: "Ask Codex for a review.",
58 },
59 {
60 name: "deploy",
61 path: path.join(workspace, ".claude", "skills", "deploy", "SKILL.md"),
62 enabled: true,
63 scope: "project",
64 description: "Deploy the app.",
65 },
66 ]);
67 }),
68 );
69
70 it.effect("ignores .agents/skills, which Claude Code does not load", () =>
71 Effect.gen(function* () {
72 const fs = yield* FileSystem.FileSystem;
73 const path = yield* Path.Path;
74 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
75 const configDir = path.join(tempDir, "claude-home");
76 const workspace = path.join(tempDir, "workspace");
77
78 // Verified against the CLI: `/review` here is answered with
79 // `Unknown command`, so offering it would dispatch a dead command.
80 yield* writeSkill(
81 path.join(workspace, ".agents", "skills"),
82 "review",
83 ["---", "name: review", "description: Review the changes.", "---"].join("\n"),
84 );
85
86 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
87
88 assert.deepEqual(skills, []);
89 }),
90 );
91
92 it.effect("prefers user skills on name collisions even with a stray .agents copy", () =>
93 Effect.gen(function* () {
94 const fs = yield* FileSystem.FileSystem;
95 const path = yield* Path.Path;
96 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
97 const configDir = path.join(tempDir, "claude-home");
98 const workspace = path.join(tempDir, "workspace");
99
100 yield* writeSkill(
101 path.join(configDir, "skills"),
102 "deploy",
103 ["---", "name: deploy", "description: User deploy.", "---"].join("\n"),
104 );
105 yield* writeSkill(
106 path.join(workspace, ".agents", "skills"),
107 "deploy",
108 ["---", "name: deploy", "description: Agents deploy.", "---"].join("\n"),
109 );
110 yield* writeSkill(
111 path.join(workspace, ".claude", "skills"),
112 "deploy",
113 ["---", "name: deploy", "description: Claude deploy.", "---"].join("\n"),
114 );
115
116 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
117
118 assert.deepEqual(skills, [
119 {
120 name: "deploy",
121 path: path.join(configDir, "skills", "deploy", "SKILL.md"),
122 enabled: true,
123 scope: "user",
124 description: "User deploy.",
125 },
126 ]);
127 }),
128 );
129
130 it.effect("prefers user skills over project skills on name collisions", () =>
131 Effect.gen(function* () {
132 const fs = yield* FileSystem.FileSystem;
133 const path = yield* Path.Path;
134 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
135 const configDir = path.join(tempDir, "claude-home");
136 const workspace = path.join(tempDir, "workspace");
137
138 yield* writeSkill(
139 path.join(configDir, "skills"),
140 "deploy",
141 ["---", "name: deploy", "description: User deploy.", "---"].join("\n"),
142 );
143 yield* writeSkill(
144 path.join(workspace, ".claude", "skills"),
145 "deploy",
146 ["---", "name: deploy", "description: Project deploy.", "---"].join("\n"),
147 );
148
149 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
150
151 assert.equal(skills.length, 1);
152 assert.equal(skills[0]?.scope, "user");
153 assert.equal(skills[0]?.description, "User deploy.");
154 }),
155 );
156
157 it.effect("recovers colon-bearing descriptions with invocation metadata intact", () =>
158 Effect.gen(function* () {
159 const fs = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;
161 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
162 const configDir = path.join(tempDir, "claude-home");
163
164 for (const [description, comment] of [
165 ["Browser automation + AI test authoring via kane-cli: run browser objectives, ...", ""],
166 ['Read C:\\skills\\guide#tag: continue with "quoted".', " # trailing: comment"],
167 ] as const) {
168 yield* writeSkill(
169 path.join(configDir, "skills"),
170 "kane-cli",
171 [
172 "---",
173 "name: frontmatter-alias",
174 `description: ${description}${comment}`,
175 "allowed-tools: [Read, Write]",
176 "disable-model-invocation: yes",
177 "user-invocable: no",
178 "---",
179 ].join("\n"),
180 );
181
182 const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined);
183
184 assert.deepEqual(skills, [
185 {
186 name: "kane-cli",
187 path: path.join(configDir, "skills", "kane-cli", "SKILL.md"),
188 enabled: true,
189 scope: "user",
190 description,
191 userInvocationOnly: true,
192 userInvocable: false,
193 },
194 ]);
195 }
196 }),
197 );
198
199 it.effect("falls back to the directory name and skips malformed frontmatter", () =>
200 Effect.gen(function* () {
201 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;
203 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
204 const configDir = path.join(tempDir, "claude-home");
205 const skillsDir = path.join(configDir, "skills");
206
207 yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n");
208 for (const [directoryName, field] of [
209 ["broken-yaml", "name: [unclosed"],
210 ["broken-tools", "allowed-tools: [Read, Write"],
211 ["broken-quoted", 'name: "unclosed: text'],
212 ] as const) {
213 yield* writeSkill(
214 skillsDir,
215 directoryName,
216 ["---", "description: Run: browser objectives.", field, "---"].join("\n"),
217 );
218 }
219 // A stray file (not a directory with SKILL.md) must be skipped.
220 yield* fs.makeDirectory(skillsDir, { recursive: true });
221 yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill");
222
223 const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined);
224
225 // A skill with no frontmatter falls back to its directory name; a skill
226 // whose frontmatter fails to parse is skipped entirely (Claude Code
227 // won't load it either).
228 assert.deepEqual(
229 skills.map((skill) => skill.name),
230 ["no-frontmatter"],
231 );
232 assert.equal(skills[0]?.description, undefined);
233 }),
234 );
235
236 it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () =>
237 Effect.gen(function* () {
238 const fs = yield* FileSystem.FileSystem;
239 const path = yield* Path.Path;
240 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
241 const environmentConfigDir = path.join(tempDir, "env-config");
242
243 yield* writeSkill(
244 path.join(environmentConfigDir, "skills"),
245 "env-skill",
246 ["---", "name: env-skill", "description: From env config dir.", "---"].join("\n"),
247 );
248
249 const skills = yield* discoverClaudeSkills({ homePath: "" }, undefined, {
250 CLAUDE_CONFIG_DIR: environmentConfigDir,
251 });
252
253 assert.deepEqual(
254 skills.map((skill) => skill.name),
255 ["env-skill"],
256 );
257
258 // An explicit homePath wins over the environment variable, matching
259 // makeClaudeEnvironment which overwrites CLAUDE_CONFIG_DIR for the CLI.
260 const explicitHome = path.join(tempDir, "explicit-home");
261 yield* writeSkill(
262 path.join(explicitHome, "skills"),
263 "explicit-skill",
264 ["---", "name: explicit-skill", "---"].join("\n"),
265 );
266 const explicitSkills = yield* discoverClaudeSkills({ homePath: explicitHome }, undefined, {
267 CLAUDE_CONFIG_DIR: environmentConfigDir,
268 });
269 assert.deepEqual(
270 explicitSkills.map((skill) => skill.name),
271 ["explicit-skill"],
272 );
273 }),
274 );
275
276 it.effect("resolves a relative CLAUDE_CONFIG_DIR against the workspace cwd", () =>
277 Effect.gen(function* () {
278 const fs = yield* FileSystem.FileSystem;
279 const path = yield* Path.Path;
280 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
281 const workspace = path.join(tempDir, "workspace");
282 yield* fs.makeDirectory(workspace, { recursive: true });
283
284 // The spawned CLI resolves a relative CLAUDE_CONFIG_DIR against its own
285 // cwd (the workspace), so discovery must do the same.
286 yield* writeSkill(
287 path.join(workspace, "relative-config", "skills"),
288 "relative-skill",
289 ["---", "name: relative-skill", "---"].join("\n"),
290 );
291
292 const skills = yield* discoverClaudeSkills({ homePath: "" }, workspace, {
293 CLAUDE_CONFIG_DIR: "relative-config",
294 });
295
296 assert.deepEqual(
297 skills.map((skill) => skill.name),
298 ["relative-skill"],
299 );
300 assert.equal(skills[0]?.scope, "user");
301 }),
302 );
303
304 it.effect("marks skills that only the user can invoke", () =>
305 Effect.gen(function* () {
306 const fs = yield* FileSystem.FileSystem;
307 const path = yield* Path.Path;
308 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
309 const configDir = path.join(tempDir, "claude-home");
310 const workspace = path.join(tempDir, "workspace");
311
312 yield* writeSkill(
313 path.join(workspace, ".claude", "skills"),
314 "re-release-version",
315 [
316 "---",
317 "name: re-release-version",
318 "description: Move the current tag forward.",
319 "disable-model-invocation: true",
320 "---",
321 "",
322 "# Body",
323 ].join("\n"),
324 );
325 yield* writeSkill(
326 path.join(workspace, ".claude", "skills"),
327 "release-version",
328 ["---", "name: release-version", "description: Cut a release.", "---", "", "# Body"].join(
329 "\n",
330 ),
331 );
332
333 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
334
335 assert.equal(
336 skills.find((skill) => skill.name === "re-release-version")?.userInvocationOnly,
337 true,
338 );
339 assert.equal(
340 skills.find((skill) => skill.name === "release-version")?.userInvocationOnly,
341 undefined,
342 );
343 }),
344 );
345
346 it.effect("disables skills switched off by skillOverrides", () =>
347 Effect.gen(function* () {
348 const fs = yield* FileSystem.FileSystem;
349 const path = yield* Path.Path;
350 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
351 const configDir = path.join(tempDir, "claude-home");
352 const workspace = path.join(tempDir, "workspace");
353
354 for (const name of ["kept", "off-by-user", "off-by-project"]) {
355 yield* writeSkill(
356 path.join(configDir, "skills"),
357 name,
358 ["---", `name: ${name}`, "---", "", "# Body"].join("\n"),
359 );
360 }
361
362 yield* fs.makeDirectory(configDir, { recursive: true });
363 yield* fs.writeFileString(
364 path.join(configDir, "settings.json"),
365 '{ "skillOverrides": { "off-by-user": "off", "kept": "on" } }',
366 );
367 yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true });
368 yield* fs.writeFileString(
369 path.join(workspace, ".claude", "settings.json"),
370 '{ "skillOverrides": { "off-by-project": "off" } }',
371 );
372
373 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
374
375 assert.deepEqual(
376 skills.map((skill) => [skill.name, skill.enabled]),
377 [
378 ["kept", true],
379 ["off-by-project", false],
380 ["off-by-user", false],
381 ],
382 );
383 }),
384 );
385
386 it.effect("ignores unreadable settings when resolving skillOverrides", () =>
387 Effect.gen(function* () {
388 const fs = yield* FileSystem.FileSystem;
389 const path = yield* Path.Path;
390 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
391 const configDir = path.join(tempDir, "claude-home");
392
393 yield* writeSkill(
394 path.join(configDir, "skills"),
395 "kept",
396 ["---", "name: kept", "---", "", "# Body"].join("\n"),
397 );
398 yield* fs.writeFileString(path.join(configDir, "settings.json"), "{ not json");
399
400 const skills = yield* discoverClaudeSkills({ homePath: configDir });
401
402 assert.deepEqual(
403 skills.map((skill) => [skill.name, skill.enabled]),
404 [["kept", true]],
405 );
406 }),
407 );
408
409 it.effect("treats a user-invocable-only override like disable-model-invocation", () =>
410 Effect.gen(function* () {
411 const fs = yield* FileSystem.FileSystem;
412 const path = yield* Path.Path;
413 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
414 const configDir = path.join(tempDir, "claude-home");
415
416 yield* writeSkill(
417 path.join(configDir, "skills"),
418 "ask-matt",
419 ["---", "name: ask-matt", "---", "", "# Body"].join("\n"),
420 );
421 yield* fs.writeFileString(
422 path.join(configDir, "settings.json"),
423 '{ "skillOverrides": { "ask-matt": "user-invocable-only" } }',
424 );
425
426 const skills = yield* discoverClaudeSkills({ homePath: configDir });
427
428 assert.deepEqual(
429 skills.map((skill) => [skill.name, skill.enabled, skill.userInvocationOnly === true]),
430 [["ask-matt", true, true]],
431 );
432 }),
433 );
434
435 it.effect("drops every override in a file when one value is invalid, as Claude Code does", () =>
436 Effect.gen(function* () {
437 const fs = yield* FileSystem.FileSystem;
438 const path = yield* Path.Path;
439 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
440 const configDir = path.join(tempDir, "claude-home");
441
442 for (const name of ["unknown-mode", "boolean-false", "sibling-off"]) {
443 yield* writeSkill(
444 path.join(configDir, "skills"),
445 name,
446 ["---", `name: ${name}`, "---", "", "# Body"].join("\n"),
447 );
448 }
449 // Verified against the CLI: with an unknown string or a boolean in the
450 // map, the valid "off" sibling is ignored too and every skill runs.
451 yield* fs.writeFileString(
452 path.join(configDir, "settings.json"),
453 '{ "skillOverrides": { "unknown-mode": "some-future-mode", "boolean-false": false, "sibling-off": "off" } }',
454 );
455
456 const skills = yield* discoverClaudeSkills({ homePath: configDir });
457
458 assert.deepEqual(
459 skills.map((skill) => [skill.name, skill.enabled]),
460 [
461 ["boolean-false", true],
462 ["sibling-off", true],
463 ["unknown-mode", true],
464 ],
465 );
466 }),
467 );
468
469 it.effect("reads repository root settings from a nested workspace", () =>
470 Effect.gen(function* () {
471 const fs = yield* FileSystem.FileSystem;
472 const path = yield* Path.Path;
473 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
474 const configDir = path.join(tempDir, "claude-home");
475 const repo = path.join(tempDir, "repo");
476 const workspace = path.join(repo, "packages", "app");
477
478 for (const name of ["root-off", "root-off-cwd-on", "cwd-off-root-on"]) {
479 yield* writeSkill(
480 path.join(configDir, "skills"),
481 name,
482 ["---", `name: ${name}`, "---", "", "# Body"].join("\n"),
483 );
484 }
485 yield* fs.makeDirectory(path.join(repo, ".git"), { recursive: true });
486 yield* fs.makeDirectory(path.join(repo, ".claude"), { recursive: true });
487 yield* fs.makeDirectory(path.join(workspace, ".claude"), { recursive: true });
488 // The CLI ignores the root's plain settings.json from a nested cwd.
489 yield* fs.writeFileString(
490 path.join(repo, ".claude", "settings.json"),
491 '{ "skillOverrides": { "cwd-off-root-on": "off" } }',
492 );
493 // The root local file outranks the workspace local file, as in the CLI.
494 yield* fs.writeFileString(
495 path.join(repo, ".claude", "settings.local.json"),
496 '{ "skillOverrides": { "root-off": "off", "root-off-cwd-on": "off", "cwd-off-root-on": "on" } }',
497 );
498 yield* fs.writeFileString(
499 path.join(workspace, ".claude", "settings.local.json"),
500 '{ "skillOverrides": { "root-off-cwd-on": "on", "cwd-off-root-on": "off" } }',
501 );
502
503 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
504
505 assert.deepEqual(
506 skills.map((skill) => [skill.name, skill.enabled]),
507 [
508 ["cwd-off-root-on", true],
509 ["root-off", false],
510 ["root-off-cwd-on", false],
511 ],
512 );
513 }),
514 );
515
516 it.effect("ignores ancestor settings outside a repository", () =>
517 Effect.gen(function* () {
518 const fs = yield* FileSystem.FileSystem;
519 const path = yield* Path.Path;
520 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
521 const configDir = path.join(tempDir, "claude-home");
522 const parent = path.join(tempDir, "not-a-repo");
523 const workspace = path.join(parent, "workspace");
524
525 yield* writeSkill(
526 path.join(configDir, "skills"),
527 "kept",
528 ["---", "name: kept", "---", "", "# Body"].join("\n"),
529 );
530 yield* fs.makeDirectory(path.join(parent, ".claude"), { recursive: true });
531 yield* fs.makeDirectory(workspace, { recursive: true });
532 yield* fs.writeFileString(
533 path.join(parent, ".claude", "settings.local.json"),
534 '{ "skillOverrides": { "kept": "off" } }',
535 );
536
537 const skills = yield* discoverClaudeSkills({ homePath: configDir }, workspace);
538
539 assert.deepEqual(
540 skills.map((skill) => [skill.name, skill.enabled]),
541 [["kept", true]],
542 );
543 }),
544 );
545
546 it.effect("lets the administrator's managed policy outrank every other settings file", () =>
547 Effect.gen(function* () {
548 // The function is pure on its `path` argument, so hand it the
549 // implementation matching each platform under test rather than the
550 // host's.
551 const path = yield* Path.Path.pipe(Effect.provide(NodePath.layerPosix));
552 const win32Path = yield* Path.Path.pipe(Effect.provide(NodePath.layerWin32));
553
554 for (const [platform, expected] of [
555 ["darwin", "/Library/Application Support/ClaudeCode/managed-settings.json"],
556 ["linux", "/etc/claude-code/managed-settings.json"],
557 ] as const) {
558 const paths = skillOverrideSettingsPaths(path, "/home/.claude", "/workspace", platform, {});
559 assert.deepEqual(paths, [
560 "/home/.claude/settings.json",
561 "/workspace/.claude/settings.json",
562 "/workspace/.claude/settings.local.json",
563 expected,
564 ]);
565 }
566
567 assert.deepEqual(
568 skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", {
569 PROGRAMDATA: "C:\\ProgramData",
570 }).at(-1),
571 "C:\\ProgramData\\ClaudeCode\\managed-settings.json",
572 );
573 assert.deepEqual(
574 skillOverrideSettingsPaths(win32Path, "C:\\Users\\me\\.claude", undefined, "win32", {}),
575 ["C:\\Users\\me\\.claude\\settings.json"],
576 );
577
578 // Only the repository root's local file joins in, after the
579 // workspace's own local file so it wins.
580 assert.deepEqual(
581 skillOverrideSettingsPaths(
582 path,
583 "/home/.claude",
584 "/repo/packages/app",
585 "linux",
586 {},
587 "/repo",
588 ),
589 [
590 "/home/.claude/settings.json",
591 "/repo/packages/app/.claude/settings.json",
592 "/repo/packages/app/.claude/settings.local.json",
593 "/repo/.claude/settings.local.json",
594 "/etc/claude-code/managed-settings.json",
595 ],
596 );
597 // A workspace that is the root itself is not read twice.
598 assert.deepEqual(
599 skillOverrideSettingsPaths(path, "/home/.claude", "/repo", "linux", {}, "/repo"),
600 [
601 "/home/.claude/settings.json",
602 "/repo/.claude/settings.json",
603 "/repo/.claude/settings.local.json",
604 "/etc/claude-code/managed-settings.json",
605 ],
606 );
607 }),
608 );
609
610 it.effect("records a skill Claude Code keeps out of its own slash commands", () =>
611 Effect.gen(function* () {
612 const fs = yield* FileSystem.FileSystem;
613 const path = yield* Path.Path;
614 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
615 const configDir = path.join(tempDir, "claude-home");
616
617 yield* writeSkill(
618 path.join(configDir, "skills"),
619 "agent-only",
620 ["---", "name: agent-only", "user-invocable: false", "---", "", "# Body"].join("\n"),
621 );
622
623 const skills = yield* discoverClaudeSkills({ homePath: configDir });
624
625 assert.deepEqual(
626 skills.map((skill) => [skill.name, skill.userInvocable]),
627 [["agent-only", false]],
628 );
629 }),
630 );
631
632 it.effect("identifies a skill by its directory, as Claude Code does", () =>
633 Effect.gen(function* () {
634 const fs = yield* FileSystem.FileSystem;
635 const path = yield* Path.Path;
636 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
637 const configDir = path.join(tempDir, "claude-home");
638
639 yield* writeSkill(
640 path.join(configDir, "skills"),
641 "probe-alias",
642 ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"),
643 );
644 yield* fs.writeFileString(
645 path.join(configDir, "settings.json"),
646 '{ "skillOverrides": { "probe-alias-frontmatter": "off" } }',
647 );
648
649 const skills = yield* discoverClaudeSkills({ homePath: configDir });
650
651 // The frontmatter name is not the command, so an override naming it is
652 // not the override Claude Code would apply either.
653 assert.deepEqual(
654 skills.map((skill) => [skill.name, skill.enabled]),
655 [["probe-alias", true]],
656 );
657 }),
658 );
659
660 it.effect("switches a skill off by its directory name", () =>
661 Effect.gen(function* () {
662 const fs = yield* FileSystem.FileSystem;
663 const path = yield* Path.Path;
664 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
665 const configDir = path.join(tempDir, "claude-home");
666
667 yield* writeSkill(
668 path.join(configDir, "skills"),
669 "probe-alias",
670 ["---", "name: probe-alias-frontmatter", "---", "", "# Body"].join("\n"),
671 );
672 yield* fs.writeFileString(
673 path.join(configDir, "settings.json"),
674 '{ "skillOverrides": { "probe-alias": "off" } }',
675 );
676
677 const skills = yield* discoverClaudeSkills({ homePath: configDir });
678
679 assert.deepEqual(
680 skills.map((skill) => [skill.name, skill.enabled]),
681 [["probe-alias", false]],
682 );
683 }),
684 );
685
686 it.effect("accepts the YAML 1.1 boolean spellings Claude Code allows", () =>
687 Effect.gen(function* () {
688 const fs = yield* FileSystem.FileSystem;
689 const path = yield* Path.Path;
690 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
691 const configDir = path.join(tempDir, "claude-home");
692 const skillsDir = path.join(configDir, "skills");
693
694 yield* writeSkill(
695 skillsDir,
696 "user-only-yes",
697 ["---", "disable-model-invocation: yes", "---", "", "# Body"].join("\n"),
698 );
699 yield* writeSkill(
700 skillsDir,
701 "agent-only-no",
702 ["---", "user-invocable: no", "---", "", "# Body"].join("\n"),
703 );
704 yield* writeSkill(
705 skillsDir,
706 "plain-off",
707 ["---", "disable-model-invocation: off", "---", "", "# Body"].join("\n"),
708 );
709
710 const skills = yield* discoverClaudeSkills({ homePath: configDir });
711
712 assert.deepEqual(
713 skills.map((skill) => [
714 skill.name,
715 skill.userInvocationOnly === true,
716 skill.userInvocable === false,
717 ]),
718 [
719 ["agent-only-no", false, true],
720 ["plain-off", false, false],
721 ["user-only-yes", true, false],
722 ],
723 );
724 }),
725 );
726
727 it.effect("returns an empty list when no skill roots exist", () =>
728 Effect.gen(function* () {
729 const fs = yield* FileSystem.FileSystem;
730 const path = yield* Path.Path;
731 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" });
732
733 const skills = yield* discoverClaudeSkills(
734 { homePath: path.join(tempDir, "missing-home") },
735 path.join(tempDir, "missing-workspace"),
736 );
737
738 assert.deepEqual(skills, []);
739 }),
740 );
741});