skill.test.ts 17 KB

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