skill.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import fs from "fs/promises"
  2. import path from "path"
  3. import { describe, expect } from "bun:test"
  4. import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect"
  5. import { Config } from "@opencode-ai/core/config"
  6. import {
  7. AgentsDirectory,
  8. ClaudeDirectory,
  9. Directory as ConfigDirectory,
  10. Document,
  11. type Entry,
  12. Info,
  13. } from "@opencode-ai/schema/config"
  14. import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
  15. import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file"
  16. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  17. import { Watcher } from "@opencode-ai/core/filesystem/watcher"
  18. import { Bus } from "@opencode-ai/core/bus"
  19. import { Credential } from "@opencode-ai/core/credential"
  20. import { FSUtil } from "@opencode-ai/util/fs-util"
  21. import { Global } from "@opencode-ai/util/global"
  22. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  23. import { Location } from "@opencode-ai/core/location"
  24. import { AbsolutePath } from "@opencode-ai/core/schema"
  25. import { Skill } from "@opencode-ai/core/skill"
  26. import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
  27. import { WellKnown } from "@opencode-ai/core/wellknown"
  28. import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
  29. import { tmpdir } from "../fixture/tmpdir"
  30. import { location } from "../fixture/location"
  31. import { testEffect } from "../lib/effect"
  32. import { host } from "../plugin/host"
  33. const urls = new Map<string, AbsolutePath[]>()
  34. const failedUrls = new Set<string>()
  35. let pulls = 0
  36. const discoveryLayer = Layer.succeed(
  37. SkillDiscovery.Service,
  38. SkillDiscovery.Service.of({
  39. pull: (url) => {
  40. pulls++
  41. if (failedUrls.has(url)) return Effect.die(`failed to pull ${url}`)
  42. return Effect.succeed(urls.get(url) ?? [])
  43. },
  44. }),
  45. )
  46. const watcherLayer = Watcher.testLayer
  47. const it = testEffect(
  48. Layer.mergeAll(
  49. AppNodeBuilder.build(LayerNode.group([Skill.node, Bus.node, FSUtil.node])),
  50. discoveryLayer,
  51. watcherLayer,
  52. ),
  53. )
  54. const decode = Schema.decodeUnknownSync(Info)
  55. function write(directory: string, name: string, description: string) {
  56. return fs.writeFile(
  57. path.join(directory, name, "SKILL.md"),
  58. `---
  59. name: ${name}
  60. description: ${description}
  61. ---
  62. # ${name}`,
  63. )
  64. }
  65. const startEntries = Effect.fnUntraced(function* (entries: Entry[], directory: string, home = directory) {
  66. const service = yield* Skill.Service
  67. yield* ConfigSkillPlugin.Plugin.effect(
  68. host({
  69. skill: {
  70. list: () => Effect.die("unused skill.list"),
  71. transform: service.transform,
  72. reload: service.reload,
  73. },
  74. }),
  75. ).pipe(
  76. Effect.provide(Config.testLayer(entries)),
  77. Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
  78. Effect.provideService(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  79. )
  80. return service
  81. })
  82. const start = (skills: string[], directory: string) =>
  83. startEntries(
  84. [
  85. new Document({
  86. type: "document",
  87. info: decode({ skills }),
  88. }),
  89. ],
  90. directory,
  91. )
  92. const discover = (directory: string, global: string) =>
  93. Effect.gen(function* () {
  94. const config = yield* Config.Service
  95. return yield* config.entries()
  96. }).pipe(
  97. Effect.provide(
  98. AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
  99. [
  100. Location.node,
  101. Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
  102. ],
  103. [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
  104. [Credential.node, emptyCredentialNode],
  105. [WellKnown.node, emptyWellknownNode],
  106. [Watcher.node, Watcher.testLayer],
  107. ]),
  108. ),
  109. )
  110. function emitAndWait(update: Watcher.Update) {
  111. return Effect.gen(function* () {
  112. const watcher = yield* Watcher.Test
  113. const bus = yield* Bus.Service
  114. const deferred = yield* Deferred.make<void>()
  115. const fiber = yield* bus.subscribe(Skill.Event.Updated).pipe(
  116. Stream.runForEach(() => Deferred.succeed(deferred, undefined).pipe(Effect.asVoid)),
  117. Effect.forkScoped,
  118. )
  119. yield* Effect.yieldNow
  120. yield* watcher.emit(update)
  121. yield* Deferred.await(deferred).pipe(Effect.timeout("2 seconds"))
  122. yield* Fiber.interrupt(fiber)
  123. })
  124. }
  125. describe("SkillFile.parse", () => {
  126. it.effect("parses root and nested skill ids and metadata flags", () =>
  127. Effect.sync(() => {
  128. const directory = "/repo/skills"
  129. expect(
  130. SkillFile.parse(
  131. directory,
  132. "/repo/skills/manual/SKILL.md",
  133. `---
  134. name: Manual
  135. description: Manual only
  136. metadata:
  137. opencode/slash: "true"
  138. opencode/autoinvoke: false
  139. ---
  140. # manual`,
  141. ),
  142. ).toEqual({
  143. _tag: "Parsed",
  144. skill: {
  145. id: Skill.ID.make("manual"),
  146. name: Skill.Name.make("Manual"),
  147. description: "Manual only",
  148. slash: true,
  149. autoinvoke: false,
  150. location: AbsolutePath.make("/repo/skills/manual/SKILL.md"),
  151. content: "# manual",
  152. },
  153. })
  154. expect(SkillFile.parse(directory, "/repo/skills/foo.md", "---\nslash: true\n---\n# foo")).toMatchObject({
  155. _tag: "Parsed",
  156. skill: { id: Skill.ID.make("foo") },
  157. })
  158. expect(
  159. SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"),
  160. ).toEqual({ _tag: "Skipped", reason: "markdown" })
  161. expect(SkillFile.parse(directory, "/repo/skills/broken.md", "---\nslash: nope\n---\n# broken")).toMatchObject({
  162. _tag: "Skipped",
  163. reason: "frontmatter",
  164. issue: expect.anything(),
  165. })
  166. }),
  167. )
  168. })
  169. describe("ConfigSkillPlugin.Plugin", () => {
  170. it.live("maps config entry types to skill directories", () =>
  171. Effect.acquireRelease(
  172. Effect.promise(() => tmpdir()),
  173. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  174. ).pipe(
  175. Effect.flatMap((tmp) =>
  176. Effect.gen(function* () {
  177. const claude = path.join(tmp.path, "claude")
  178. const agents = path.join(tmp.path, "agents")
  179. const opencode = path.join(tmp.path, "opencode")
  180. const home = path.join(tmp.path, "home")
  181. const directory = path.join(tmp.path, "project")
  182. const expected = [
  183. path.join(claude, "skills"),
  184. path.join(agents, "skills"),
  185. path.join(opencode, "skill"),
  186. path.join(opencode, "skills"),
  187. path.join(home, "shared"),
  188. path.join(directory, "relative"),
  189. ]
  190. yield* Effect.promise(() => Promise.all(expected.map((item) => fs.mkdir(item, { recursive: true }))))
  191. yield* startEntries(
  192. [
  193. new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(claude) }),
  194. new AgentsDirectory({ type: "agents", path: AbsolutePath.make(agents) }),
  195. new ConfigDirectory({ type: "directory", path: AbsolutePath.make(opencode) }),
  196. new Document({ type: "document", info: decode({ skills: ["~/shared", "./relative"] }) }),
  197. ],
  198. directory,
  199. home,
  200. )
  201. const watcher = yield* Watcher.Test
  202. expect(yield* watcher.subscriptions()).toEqual(expected.map((item) => ({ path: item, type: "directory" })))
  203. }),
  204. ),
  205. ),
  206. )
  207. it.live("loads directory and URL sources with later-source precedence", () =>
  208. Effect.acquireRelease(
  209. Effect.promise(() => tmpdir()),
  210. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  211. ).pipe(
  212. Effect.flatMap((tmp) =>
  213. Effect.gen(function* () {
  214. const first = path.join(tmp.path, "first")
  215. const second = path.join(tmp.path, "second")
  216. yield* Effect.promise(async () => {
  217. await fs.mkdir(path.join(first, "review"), { recursive: true })
  218. await fs.mkdir(path.join(second, "review"), { recursive: true })
  219. await write(first, "review", "First")
  220. await write(second, "review", "Second")
  221. })
  222. pulls = 0
  223. urls.set("https://example.test/skills/", [AbsolutePath.make(second)])
  224. const skill = yield* start([first, "https://example.test/skills/"], tmp.path)
  225. expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second")
  226. expect(pulls).toBe(1)
  227. }),
  228. ),
  229. ),
  230. )
  231. it.live("prefers a worktree skill over the parent checkout copy", () =>
  232. Effect.acquireRelease(
  233. Effect.promise(() => tmpdir()),
  234. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  235. ).pipe(
  236. Effect.flatMap((tmp) =>
  237. Effect.gen(function* () {
  238. const checkout = path.join(tmp.path, "repo")
  239. const worktree = path.join(checkout, ".worktrees", "feature")
  240. const parentSkills = path.join(checkout, ".agents", "skills")
  241. const worktreeSkills = path.join(worktree, ".agents", "skills")
  242. yield* Effect.promise(async () => {
  243. await fs.mkdir(path.join(checkout, ".git"), { recursive: true })
  244. await fs.mkdir(path.join(parentSkills, "review"), { recursive: true })
  245. await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true })
  246. await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n")
  247. await write(parentSkills, "review", "Parent checkout")
  248. await write(worktreeSkills, "review", "Worktree")
  249. })
  250. const entries = yield* discover(worktree, path.join(tmp.path, "global"))
  251. const skill = yield* startEntries(entries, worktree)
  252. const review = (yield* skill.list()).find((item) => item.id === "review")
  253. expect(review?.description).toBe("Worktree")
  254. expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md")))
  255. }),
  256. ),
  257. ),
  258. )
  259. it.live("keeps directory skills when a URL source fails", () =>
  260. Effect.acquireRelease(
  261. Effect.promise(() => tmpdir()),
  262. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  263. ).pipe(
  264. Effect.flatMap((tmp) =>
  265. Effect.gen(function* () {
  266. yield* Effect.promise(async () => {
  267. await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
  268. await write(tmp.path, "review", "Available")
  269. })
  270. const url = "https://unreachable.example.test/skills/"
  271. failedUrls.add(url)
  272. const skill = yield* start([tmp.path, url], tmp.path)
  273. expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Available")
  274. failedUrls.delete(url)
  275. }),
  276. ),
  277. ),
  278. )
  279. it.live("rescans directory sources when watched files change", () =>
  280. Effect.acquireRelease(
  281. Effect.promise(() => tmpdir()),
  282. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  283. ).pipe(
  284. Effect.flatMap((tmp) =>
  285. Effect.gen(function* () {
  286. yield* Effect.promise(async () => {
  287. await fs.mkdir(path.join(tmp.path, "deploy"), { recursive: true })
  288. await write(tmp.path, "deploy", "Initial")
  289. })
  290. const skill = yield* start([tmp.path], tmp.path)
  291. expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Initial")
  292. const deploy = path.join(tmp.path, "deploy", "SKILL.md")
  293. yield* Effect.promise(() => write(tmp.path, "deploy", "Updated"))
  294. yield* emitAndWait({ type: "update", path: deploy })
  295. expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Updated")
  296. yield* Effect.promise(async () => {
  297. await fs.mkdir(path.join(tmp.path, "review"), { recursive: true })
  298. await write(tmp.path, "review", "Review")
  299. })
  300. yield* emitAndWait({ type: "create", path: path.join(tmp.path, "review", "SKILL.md") })
  301. expect((yield* skill.list()).map((item) => item.id)).toEqual([
  302. Skill.ID.make("deploy"),
  303. Skill.ID.make("review"),
  304. ])
  305. yield* Effect.promise(() => fs.rm(path.join(tmp.path, "review"), { recursive: true }))
  306. yield* emitAndWait({ type: "delete", path: path.join(tmp.path, "review", "SKILL.md") })
  307. expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
  308. }),
  309. ),
  310. ),
  311. )
  312. it.live("watches canonical directories behind symlinked skills", () =>
  313. Effect.acquireRelease(
  314. Effect.promise(() => tmpdir()),
  315. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  316. ).pipe(
  317. Effect.flatMap((tmp) =>
  318. Effect.gen(function* () {
  319. const source = path.join(tmp.path, "source")
  320. const target = path.join(tmp.path, "target", "bro")
  321. const file = path.join(target, "SKILL.md")
  322. yield* Effect.promise(async () => {
  323. await fs.mkdir(source, { recursive: true })
  324. await fs.mkdir(target, { recursive: true })
  325. await fs.writeFile(file, "---\nname: bro\ndescription: Initial\n---\n# bro")
  326. await fs.symlink(target, path.join(source, "bro"), process.platform === "win32" ? "junction" : undefined)
  327. })
  328. const skill = yield* start([source], tmp.path)
  329. const watcher = yield* Watcher.Test
  330. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Initial")
  331. expect(yield* watcher.subscriptions()).toContainEqual({ path: target, type: "directory" })
  332. yield* Effect.promise(() => fs.writeFile(file, "---\nname: bro\ndescription: Updated\n---\n# bro"))
  333. yield* emitAndWait({ type: "update", path: file })
  334. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Updated")
  335. }),
  336. ),
  337. ),
  338. )
  339. it.live("reloads symlinked sources when their target changes", () =>
  340. Effect.acquireRelease(
  341. Effect.promise(() => tmpdir()),
  342. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  343. ).pipe(
  344. Effect.flatMap((tmp) =>
  345. Effect.gen(function* () {
  346. const source = path.join(tmp.path, "source")
  347. const first = path.join(tmp.path, "first")
  348. const second = path.join(tmp.path, "second")
  349. yield* Effect.promise(async () => {
  350. await fs.mkdir(path.join(first, "bro"), { recursive: true })
  351. await fs.mkdir(path.join(second, "bro"), { recursive: true })
  352. await write(first, "bro", "First")
  353. await write(second, "bro", "Second")
  354. await fs.symlink(first, source, process.platform === "win32" ? "junction" : undefined)
  355. })
  356. const skill = yield* start([source], tmp.path)
  357. const watcher = yield* Watcher.Test
  358. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("First")
  359. expect(yield* watcher.subscriptions()).toEqual([
  360. { path: first, type: "directory" },
  361. { path: source, type: "file" },
  362. ])
  363. yield* Effect.promise(async () => {
  364. await fs.unlink(source)
  365. await fs.symlink(second, source, process.platform === "win32" ? "junction" : undefined)
  366. })
  367. yield* emitAndWait({ type: "update", path: source })
  368. expect((yield* skill.list()).find((item) => item.id === "bro")?.description).toBe("Second")
  369. expect(yield* watcher.subscriptions()).toEqual([
  370. { path: first, type: "directory" },
  371. { path: source, type: "file" },
  372. { path: second, type: "directory" },
  373. { path: source, type: "file" },
  374. ])
  375. }),
  376. ),
  377. ),
  378. )
  379. it.live("follows missing source directories as their parents appear", () =>
  380. Effect.acquireRelease(
  381. Effect.promise(() => tmpdir()),
  382. (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
  383. ).pipe(
  384. Effect.flatMap((tmp) =>
  385. Effect.gen(function* () {
  386. const source = path.join(tmp.path, "generated", "skills")
  387. const skill = yield* start([source], tmp.path)
  388. const watcher = yield* Watcher.Test
  389. expect(yield* skill.list()).toEqual([])
  390. expect(yield* watcher.subscriptions()).toEqual([{ path: path.join(tmp.path, "generated"), type: "file" }])
  391. yield* Effect.promise(() => fs.mkdir(path.join(tmp.path, "generated")))
  392. yield* emitAndWait({ type: "create", path: path.join(tmp.path, "generated") })
  393. yield* Effect.promise(async () => {
  394. await fs.mkdir(path.join(source, "deploy"), { recursive: true })
  395. await write(source, "deploy", "Deploy")
  396. })
  397. yield* emitAndWait({ type: "create", path: source })
  398. expect((yield* skill.list()).map((item) => item.id)).toEqual([Skill.ID.make("deploy")])
  399. }),
  400. ),
  401. ),
  402. )
  403. })