skill.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { Skill } from "../../src/skill"
  4. import { Discovery } from "../../src/skill/discovery"
  5. import { RuntimeFlags } from "../../src/effect/runtime-flags"
  6. import { Bus } from "../../src/bus"
  7. import { Config } from "../../src/config/config"
  8. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  9. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  10. import { Global } from "@opencode-ai/core/global"
  11. import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
  12. import { testEffect } from "../lib/effect"
  13. import path from "path"
  14. import fs from "fs/promises"
  15. const node = CrossSpawnSpawner.defaultLayer
  16. const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
  17. const itWithoutClaudeCodeSkills = testEffect(
  18. Layer.mergeAll(
  19. Skill.layer.pipe(
  20. Layer.provide(Discovery.defaultLayer),
  21. Layer.provide(Config.defaultLayer),
  22. Layer.provide(Bus.layer),
  23. Layer.provide(AppFileSystem.defaultLayer),
  24. Layer.provide(Global.layer),
  25. Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })),
  26. ),
  27. node,
  28. ),
  29. )
  30. const itWithoutExternalSkills = testEffect(
  31. Layer.mergeAll(
  32. Skill.layer.pipe(
  33. Layer.provide(Discovery.defaultLayer),
  34. Layer.provide(Config.defaultLayer),
  35. Layer.provide(Bus.layer),
  36. Layer.provide(AppFileSystem.defaultLayer),
  37. Layer.provide(Global.layer),
  38. Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })),
  39. ),
  40. node,
  41. ),
  42. )
  43. async function createGlobalSkill(homeDir: string) {
  44. const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
  45. await fs.mkdir(skillDir, { recursive: true })
  46. await Bun.write(
  47. path.join(skillDir, "SKILL.md"),
  48. `---
  49. name: global-test-skill
  50. description: A global skill from ~/.claude/skills for testing.
  51. ---
  52. # Global Test Skill
  53. This skill is loaded from the global home directory.
  54. `,
  55. )
  56. }
  57. const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
  58. Effect.acquireUseRelease(
  59. Effect.sync(() => {
  60. const prev = process.env.OPENCODE_TEST_HOME
  61. process.env.OPENCODE_TEST_HOME = home
  62. return prev
  63. }),
  64. () => self,
  65. (prev) =>
  66. Effect.sync(() => {
  67. process.env.OPENCODE_TEST_HOME = prev
  68. }),
  69. )
  70. describe("skill", () => {
  71. it.live("discovers skills from .opencode/skill/ directory", () =>
  72. provideTmpdirInstance(
  73. (dir) =>
  74. Effect.gen(function* () {
  75. yield* Effect.promise(() =>
  76. Bun.write(
  77. path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"),
  78. `---
  79. name: test-skill
  80. description: A test skill for verification.
  81. ---
  82. # Test Skill
  83. Instructions here.
  84. `,
  85. ),
  86. )
  87. const skill = yield* Skill.Service
  88. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  89. expect(list.length).toBe(1)
  90. const item = list.find((x) => x.name === "test-skill")
  91. expect(item).toBeDefined()
  92. expect(item!.description).toBe("A test skill for verification.")
  93. expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
  94. }),
  95. { git: true },
  96. ),
  97. )
  98. it.live("returns skill directories from Skill.dirs", () =>
  99. provideTmpdirInstance(
  100. (dir) =>
  101. withHome(
  102. dir,
  103. Effect.gen(function* () {
  104. yield* Effect.promise(() =>
  105. Bun.write(
  106. path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"),
  107. `---
  108. name: dir-skill
  109. description: Skill for dirs test.
  110. ---
  111. # Dir Skill
  112. `,
  113. ),
  114. )
  115. const skill = yield* Skill.Service
  116. const dirs = yield* skill.dirs()
  117. expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill"))
  118. expect(dirs.length).toBe(1)
  119. }),
  120. ),
  121. { git: true },
  122. ),
  123. )
  124. it.live("discovers multiple skills from .opencode/skill/ directory", () =>
  125. provideTmpdirInstance(
  126. (dir) =>
  127. Effect.gen(function* () {
  128. yield* Effect.promise(() =>
  129. Promise.all([
  130. Bun.write(
  131. path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"),
  132. `---
  133. name: skill-one
  134. description: First test skill.
  135. ---
  136. # Skill One
  137. `,
  138. ),
  139. Bun.write(
  140. path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
  141. `---
  142. name: skill-two
  143. description: Second test skill.
  144. ---
  145. # Skill Two
  146. `,
  147. ),
  148. ]),
  149. )
  150. const skill = yield* Skill.Service
  151. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  152. expect(list.length).toBe(2)
  153. expect(list.find((x) => x.name === "skill-one")).toBeDefined()
  154. expect(list.find((x) => x.name === "skill-two")).toBeDefined()
  155. }),
  156. { git: true },
  157. ),
  158. )
  159. it.live("skips skills with missing frontmatter", () =>
  160. provideTmpdirInstance(
  161. (dir) =>
  162. Effect.gen(function* () {
  163. yield* Effect.promise(() =>
  164. Bun.write(
  165. path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"),
  166. `# No Frontmatter
  167. Just some content without YAML frontmatter.
  168. `,
  169. ),
  170. )
  171. const skill = yield* Skill.Service
  172. expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
  173. }),
  174. { git: true },
  175. ),
  176. )
  177. it.live("discovers skills without descriptions", () =>
  178. provideTmpdirInstance(
  179. (dir) =>
  180. Effect.gen(function* () {
  181. yield* Effect.promise(() =>
  182. Bun.write(
  183. path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"),
  184. `---
  185. name: manual-skill
  186. ---
  187. # Manual Skill
  188. Instructions here.
  189. `,
  190. ),
  191. )
  192. const skill = yield* Skill.Service
  193. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  194. expect(list.length).toBe(1)
  195. const item = list.find((x) => x.name === "manual-skill")
  196. expect(item).toBeDefined()
  197. expect(item!.description).toBeUndefined()
  198. expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
  199. expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
  200. }),
  201. { git: true },
  202. ),
  203. )
  204. it.live("discovers skills from .claude/skills/ directory", () =>
  205. provideTmpdirInstance(
  206. (dir) =>
  207. Effect.gen(function* () {
  208. yield* Effect.promise(() =>
  209. Bun.write(
  210. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  211. `---
  212. name: claude-skill
  213. description: A skill in the .claude/skills directory.
  214. ---
  215. # Claude Skill
  216. `,
  217. ),
  218. )
  219. const skill = yield* Skill.Service
  220. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  221. expect(list.length).toBe(1)
  222. const item = list.find((x) => x.name === "claude-skill")
  223. expect(item).toBeDefined()
  224. expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
  225. }),
  226. { git: true },
  227. ),
  228. )
  229. it.live("discovers global skills from ~/.claude/skills/ directory", () =>
  230. Effect.gen(function* () {
  231. const tmp = yield* Effect.acquireRelease(
  232. Effect.promise(() => tmpdir({ git: true })),
  233. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  234. )
  235. yield* withHome(
  236. tmp.path,
  237. Effect.gen(function* () {
  238. yield* Effect.promise(() => createGlobalSkill(tmp.path))
  239. yield* Effect.gen(function* () {
  240. const skill = yield* Skill.Service
  241. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  242. expect(list.length).toBe(1)
  243. expect(list[0].name).toBe("global-test-skill")
  244. expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
  245. expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
  246. }).pipe(provideInstance(tmp.path))
  247. }),
  248. )
  249. }),
  250. )
  251. it.live("returns empty array when no skills exist", () =>
  252. provideTmpdirInstance(
  253. () =>
  254. Effect.gen(function* () {
  255. const skill = yield* Skill.Service
  256. expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
  257. }),
  258. { git: true },
  259. ),
  260. )
  261. it.live("discovers skills from .agents/skills/ directory", () =>
  262. provideTmpdirInstance(
  263. (dir) =>
  264. Effect.gen(function* () {
  265. yield* Effect.promise(() =>
  266. Bun.write(
  267. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  268. `---
  269. name: agent-skill
  270. description: A skill in the .agents/skills directory.
  271. ---
  272. # Agent Skill
  273. `,
  274. ),
  275. )
  276. const skill = yield* Skill.Service
  277. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  278. expect(list.length).toBe(1)
  279. const item = list.find((x) => x.name === "agent-skill")
  280. expect(item).toBeDefined()
  281. expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
  282. }),
  283. { git: true },
  284. ),
  285. )
  286. it.live("discovers global skills from ~/.agents/skills/ directory", () =>
  287. Effect.gen(function* () {
  288. const tmp = yield* Effect.acquireRelease(
  289. Effect.promise(() => tmpdir({ git: true })),
  290. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  291. )
  292. yield* withHome(
  293. tmp.path,
  294. Effect.gen(function* () {
  295. const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
  296. yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
  297. yield* Effect.promise(() =>
  298. Bun.write(
  299. path.join(skillDir, "SKILL.md"),
  300. `---
  301. name: global-agent-skill
  302. description: A global skill from ~/.agents/skills for testing.
  303. ---
  304. # Global Agent Skill
  305. This skill is loaded from the global home directory.
  306. `,
  307. ),
  308. )
  309. yield* Effect.gen(function* () {
  310. const skill = yield* Skill.Service
  311. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  312. expect(list.length).toBe(1)
  313. expect(list[0].name).toBe("global-agent-skill")
  314. expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
  315. expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
  316. }).pipe(provideInstance(tmp.path))
  317. }),
  318. )
  319. }),
  320. )
  321. it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
  322. provideTmpdirInstance(
  323. (dir) =>
  324. Effect.gen(function* () {
  325. yield* Effect.promise(() =>
  326. Promise.all([
  327. Bun.write(
  328. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  329. `---
  330. name: claude-skill
  331. description: A skill in the .claude/skills directory.
  332. ---
  333. # Claude Skill
  334. `,
  335. ),
  336. Bun.write(
  337. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  338. `---
  339. name: agent-skill
  340. description: A skill in the .agents/skills directory.
  341. ---
  342. # Agent Skill
  343. `,
  344. ),
  345. ]),
  346. )
  347. const skill = yield* Skill.Service
  348. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  349. expect(list.length).toBe(2)
  350. expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
  351. expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
  352. }),
  353. { git: true },
  354. ),
  355. )
  356. itWithoutClaudeCodeSkills.live("skips Claude Code skills when disabled", () =>
  357. provideTmpdirInstance(
  358. (dir) =>
  359. Effect.gen(function* () {
  360. yield* Effect.promise(() =>
  361. Promise.all([
  362. Bun.write(
  363. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  364. `---
  365. name: claude-skill
  366. description: A skill in the .claude/skills directory.
  367. ---
  368. # Claude Skill
  369. `,
  370. ),
  371. Bun.write(
  372. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  373. `---
  374. name: agent-skill
  375. description: A skill in the .agents/skills directory.
  376. ---
  377. # Agent Skill
  378. `,
  379. ),
  380. ]),
  381. )
  382. const skill = yield* Skill.Service
  383. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  384. expect(list.map((s) => s.name)).toEqual(["agent-skill"])
  385. }),
  386. { git: true },
  387. ),
  388. )
  389. itWithoutExternalSkills.live("skips external skill directories when disabled", () =>
  390. provideTmpdirInstance(
  391. (dir) =>
  392. Effect.gen(function* () {
  393. yield* Effect.promise(() =>
  394. Promise.all([
  395. Bun.write(
  396. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  397. `---
  398. name: claude-skill
  399. description: A skill in the .claude/skills directory.
  400. ---
  401. # Claude Skill
  402. `,
  403. ),
  404. Bun.write(
  405. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  406. `---
  407. name: agent-skill
  408. description: A skill in the .agents/skills directory.
  409. ---
  410. # Agent Skill
  411. `,
  412. ),
  413. Bun.write(
  414. path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"),
  415. `---
  416. name: opencode-skill
  417. description: A skill in the .opencode/skill directory.
  418. ---
  419. # OpenCode Skill
  420. `,
  421. ),
  422. ]),
  423. )
  424. const skill = yield* Skill.Service
  425. const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
  426. expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
  427. }),
  428. { git: true },
  429. ),
  430. )
  431. it.live("properly resolves directories that skills live in", () =>
  432. provideTmpdirInstance(
  433. (dir) =>
  434. Effect.gen(function* () {
  435. yield* Effect.promise(() =>
  436. Promise.all([
  437. Bun.write(
  438. path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
  439. `---
  440. name: claude-skill
  441. description: A skill in the .claude/skills directory.
  442. ---
  443. # Claude Skill
  444. `,
  445. ),
  446. Bun.write(
  447. path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
  448. `---
  449. name: agent-skill
  450. description: A skill in the .agents/skills directory.
  451. ---
  452. # Agent Skill
  453. `,
  454. ),
  455. Bun.write(
  456. path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
  457. `---
  458. name: opencode-skill
  459. description: A skill in the .opencode/skill directory.
  460. ---
  461. # OpenCode Skill
  462. `,
  463. ),
  464. Bun.write(
  465. path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
  466. `---
  467. name: opencode-skill
  468. description: A skill in the .opencode/skills directory.
  469. ---
  470. # OpenCode Skill
  471. `,
  472. ),
  473. ]),
  474. )
  475. const skill = yield* Skill.Service
  476. expect((yield* skill.dirs()).length).toBe(4)
  477. }),
  478. { git: true },
  479. ),
  480. )
  481. })