skill.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { Skill } from "../../src/skill"
  4. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  5. import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
  6. import { testEffect } from "../lib/effect"
  7. import path from "path"
  8. import fs from "fs/promises"
  9. const node = CrossSpawnSpawner.defaultLayer
  10. const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
  11. async function createGlobalSkill(homeDir: string) {
  12. const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
  13. await fs.mkdir(skillDir, { recursive: true })
  14. await Bun.write(
  15. path.join(skillDir, "SKILL.md"),
  16. `---
  17. name: global-test-skill
  18. description: A global skill from ~/.claude/skills for testing.
  19. ---
  20. # Global Test Skill
  21. This skill is loaded from the global home directory.
  22. `,
  23. )
  24. }
  25. const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
  26. Effect.acquireUseRelease(
  27. Effect.sync(() => {
  28. const prev = process.env.KILO_TEST_HOME
  29. process.env.KILO_TEST_HOME = home
  30. return prev
  31. }),
  32. () => self,
  33. (prev) =>
  34. Effect.sync(() => {
  35. process.env.KILO_TEST_HOME = prev
  36. }),
  37. )
  38. const discovered = <T extends { location: string }>(list: readonly T[]) =>
  39. list.filter((s) => s.location !== Skill.BUILTIN_LOCATION) // kilocode_change
  40. describe("skill", () => {
  41. // kilocode_change start
  42. it.live("discovers skills from .kilo/skill/ directory", () =>
  43. // kilocode_change end
  44. provideTmpdirInstance(
  45. (dir) =>
  46. Effect.gen(function* () {
  47. yield* Effect.promise(() =>
  48. Bun.write(
  49. path.join(dir, ".kilo", "skill", "test-skill", "SKILL.md"),
  50. `---
  51. name: test-skill
  52. description: A test skill for verification.
  53. ---
  54. # Test Skill
  55. Instructions here.
  56. `,
  57. ),
  58. )
  59. const skill = yield* Skill.Service
  60. const list = discovered(yield* skill.all()) // kilocode_change
  61. expect(list.length).toBe(1)
  62. const item = list.find((x) => x.name === "test-skill")
  63. expect(item).toBeDefined()
  64. expect(item!.description).toBe("A test skill for verification.")
  65. expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
  66. }),
  67. { git: true },
  68. ),
  69. )
  70. it.live("returns skill directories from Skill.dirs", () =>
  71. provideTmpdirInstance(
  72. (dir) =>
  73. withHome(
  74. dir,
  75. Effect.gen(function* () {
  76. yield* Effect.promise(() =>
  77. Bun.write(
  78. path.join(dir, ".kilo", "skill", "dir-skill", "SKILL.md"), // kilocode_change: .kilo is primary
  79. `---
  80. name: dir-skill
  81. description: Skill for dirs test.
  82. ---
  83. # Dir Skill
  84. `,
  85. ),
  86. )
  87. const skill = yield* Skill.Service
  88. const dirs = yield* skill.dirs()
  89. expect(dirs).toContain(path.join(dir, ".kilo", "skill", "dir-skill")) // kilocode_change: .kilo is primary
  90. expect(dirs.length).toBe(1)
  91. }),
  92. ),
  93. { git: true },
  94. ),
  95. )
  96. // kilocode_change start
  97. it.live("discovers multiple skills from .kilo/skill/ directory", () =>
  98. // kilocode_change end
  99. provideTmpdirInstance(
  100. (dir) =>
  101. Effect.gen(function* () {
  102. yield* Effect.promise(() =>
  103. Promise.all([
  104. Bun.write(
  105. path.join(dir, ".kilo", "skill", "skill-one", "SKILL.md"),
  106. `---
  107. name: skill-one
  108. description: First test skill.
  109. ---
  110. # Skill One
  111. `,
  112. ),
  113. Bun.write(
  114. path.join(dir, ".kilo", "skill", "skill-two", "SKILL.md"),
  115. `---
  116. name: skill-two
  117. description: Second test skill.
  118. ---
  119. # Skill Two
  120. `,
  121. ),
  122. ]),
  123. )
  124. const skill = yield* Skill.Service
  125. const list = discovered(yield* skill.all()) // kilocode_change
  126. expect(list.length).toBe(2)
  127. expect(list.find((x) => x.name === "skill-one")).toBeDefined()
  128. expect(list.find((x) => x.name === "skill-two")).toBeDefined()
  129. }),
  130. { git: true },
  131. ),
  132. )
  133. it.live("skips skills with missing frontmatter", () =>
  134. provideTmpdirInstance(
  135. (dir) =>
  136. Effect.gen(function* () {
  137. yield* Effect.promise(() =>
  138. Bun.write(
  139. path.join(dir, ".kilo", "skill", "no-frontmatter", "SKILL.md"), // kilocode_change: .kilo is primary
  140. `# No Frontmatter
  141. Just some content without YAML frontmatter.
  142. `,
  143. ),
  144. )
  145. const skill = yield* Skill.Service
  146. expect(discovered(yield* skill.all())).toEqual([]) // kilocode_change
  147. }),
  148. { git: true },
  149. ),
  150. )
  151. it.live("discovers skills from .claude/skills/ directory", () =>
  152. provideTmpdirInstance(
  153. (dir) =>
  154. Effect.gen(function* () {
  155. yield* Effect.promise(() =>
  156. Bun.write(
  157. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  158. `---
  159. name: claude-skill
  160. description: A skill in the .claude/skills directory.
  161. ---
  162. # Claude Skill
  163. `,
  164. ),
  165. )
  166. const skill = yield* Skill.Service
  167. const list = discovered(yield* skill.all()) // kilocode_change
  168. expect(list.length).toBe(1)
  169. const item = list.find((x) => x.name === "claude-skill")
  170. expect(item).toBeDefined()
  171. expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
  172. }),
  173. { git: true },
  174. ),
  175. )
  176. it.live("discovers global skills from ~/.claude/skills/ directory", () =>
  177. Effect.gen(function* () {
  178. const tmp = yield* Effect.acquireRelease(
  179. Effect.promise(() => tmpdir({ git: true })),
  180. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  181. )
  182. yield* withHome(
  183. tmp.path,
  184. Effect.gen(function* () {
  185. yield* Effect.promise(() => createGlobalSkill(tmp.path))
  186. yield* Effect.gen(function* () {
  187. const skill = yield* Skill.Service
  188. const list = discovered(yield* skill.all()) // kilocode_change
  189. expect(list.length).toBe(1)
  190. expect(list[0].name).toBe("global-test-skill")
  191. expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
  192. expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
  193. }).pipe(provideInstance(tmp.path))
  194. }),
  195. )
  196. }),
  197. )
  198. it.live("returns empty array when no skills exist", () =>
  199. provideTmpdirInstance(
  200. () =>
  201. Effect.gen(function* () {
  202. const skill = yield* Skill.Service
  203. expect(discovered(yield* skill.all())).toEqual([]) // kilocode_change
  204. }),
  205. { git: true },
  206. ),
  207. )
  208. it.live("discovers skills from .agents/skills/ directory", () =>
  209. provideTmpdirInstance(
  210. (dir) =>
  211. Effect.gen(function* () {
  212. yield* Effect.promise(() =>
  213. Bun.write(
  214. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  215. `---
  216. name: agent-skill
  217. description: A skill in the .agents/skills directory.
  218. ---
  219. # Agent Skill
  220. `,
  221. ),
  222. )
  223. const skill = yield* Skill.Service
  224. const list = discovered(yield* skill.all()) // kilocode_change
  225. expect(list.length).toBe(1)
  226. const item = list.find((x) => x.name === "agent-skill")
  227. expect(item).toBeDefined()
  228. expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
  229. }),
  230. { git: true },
  231. ),
  232. )
  233. it.live("discovers global skills from ~/.agents/skills/ directory", () =>
  234. Effect.gen(function* () {
  235. const tmp = yield* Effect.acquireRelease(
  236. Effect.promise(() => tmpdir({ git: true })),
  237. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  238. )
  239. yield* withHome(
  240. tmp.path,
  241. Effect.gen(function* () {
  242. const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
  243. yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
  244. yield* Effect.promise(() =>
  245. Bun.write(
  246. path.join(skillDir, "SKILL.md"),
  247. `---
  248. name: global-agent-skill
  249. description: A global skill from ~/.agents/skills for testing.
  250. ---
  251. # Global Agent Skill
  252. This skill is loaded from the global home directory.
  253. `,
  254. ),
  255. )
  256. yield* Effect.gen(function* () {
  257. const skill = yield* Skill.Service
  258. const list = discovered(yield* skill.all()) // kilocode_change
  259. expect(list.length).toBe(1)
  260. expect(list[0].name).toBe("global-agent-skill")
  261. expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
  262. expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
  263. }).pipe(provideInstance(tmp.path))
  264. }),
  265. )
  266. }),
  267. )
  268. it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
  269. provideTmpdirInstance(
  270. (dir) =>
  271. Effect.gen(function* () {
  272. yield* Effect.promise(() =>
  273. Promise.all([
  274. Bun.write(
  275. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  276. `---
  277. name: claude-skill
  278. description: A skill in the .claude/skills directory.
  279. ---
  280. # Claude Skill
  281. `,
  282. ),
  283. Bun.write(
  284. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  285. `---
  286. name: agent-skill
  287. description: A skill in the .agents/skills directory.
  288. ---
  289. # Agent Skill
  290. `,
  291. ),
  292. ]),
  293. )
  294. const skill = yield* Skill.Service
  295. const list = discovered(yield* skill.all()) // kilocode_change
  296. expect(list.length).toBe(2)
  297. expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
  298. expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
  299. }),
  300. { git: true },
  301. ),
  302. )
  303. it.live("properly resolves directories that skills live in", () =>
  304. provideTmpdirInstance(
  305. (dir) =>
  306. Effect.gen(function* () {
  307. yield* Effect.promise(() =>
  308. Promise.all([
  309. Bun.write(
  310. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  311. `---
  312. name: claude-skill
  313. description: A skill in the .claude/skills directory.
  314. ---
  315. # Claude Skill
  316. `,
  317. ),
  318. Bun.write(
  319. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  320. `---
  321. name: agent-skill
  322. description: A skill in the .agents/skills directory.
  323. ---
  324. # Agent Skill
  325. `,
  326. ),
  327. Bun.write(
  328. path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
  329. `---
  330. name: opencode-skill
  331. description: A skill in the .opencode/skill directory.
  332. ---
  333. # OpenCode Skill
  334. `,
  335. ),
  336. Bun.write(
  337. path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
  338. `---
  339. name: opencode-skill
  340. description: A skill in the .opencode/skills directory.
  341. ---
  342. # OpenCode Skill
  343. `,
  344. ),
  345. ]),
  346. )
  347. const skill = yield* Skill.Service
  348. expect((yield* skill.dirs()).length).toBe(4)
  349. }),
  350. { git: true },
  351. ),
  352. )
  353. })