skill.test.ts 11 KB

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