skill-local.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870
  1. import { describe, expect, it } from 'vitest'
  2. import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
  3. import { dirname, join } from 'node:path'
  4. import { tmpdir } from 'node:os'
  5. import { Context } from 'cordis'
  6. import SkillService from '@deepseek-ai/dsh-skill'
  7. import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
  8. import * as SkillLocal from '../src/index.ts'
  9. async function tempDir(name: string): Promise<string> {
  10. return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
  11. }
  12. async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
  13. const dir = join(root, name)
  14. await mkdir(dir, { recursive: true })
  15. await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
  16. }
  17. async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise<void> {
  18. await mkdir(root, { recursive: true })
  19. await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
  20. }
  21. class TestFileSystem extends FileSystem {
  22. listDirCalls = 0
  23. failResolvePaths = new Set<string>()
  24. failStatPaths = new Set<string>()
  25. failListDirPaths = new Set<string>()
  26. errorResolvePaths = new Set<string>()
  27. errorStatPaths = new Set<string>()
  28. errorReadPaths = new Set<string>()
  29. missingReadPaths = new Set<string>()
  30. statOverrides = new Map<string, FsInfo | undefined>()
  31. statSignals: Array<AbortSignal | undefined> = []
  32. readTextSignals: Array<AbortSignal | undefined> = []
  33. readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise<string>
  34. override async resolve(path: string): Promise<FsTarget> {
  35. if (this.failResolvePaths.has(path)) throw new FsError('resolve failed', 'FS_NOT_FOUND')
  36. if (this.errorResolvePaths.has(path)) throw new Error('resolve temporarily failed')
  37. return { targetKey: path as never, displayPath: path }
  38. }
  39. override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
  40. this.statSignals.push(signal)
  41. if (this.failStatPaths.has(target.displayPath)) throw new FsError('stat failed', 'FS_NOT_FOUND')
  42. if (this.errorStatPaths.has(target.displayPath)) throw new Error('stat temporarily failed')
  43. if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
  44. try {
  45. const fs = await import('node:fs/promises')
  46. const info = await fs.stat(target.displayPath)
  47. return {
  48. version: FsVersion(String(info.mtimeMs)),
  49. type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
  50. size: info.size,
  51. }
  52. } catch {
  53. return undefined
  54. }
  55. }
  56. override async lstat(path: string): Promise<FsPathInfo | undefined> {
  57. try {
  58. const fs = await import('node:fs/promises')
  59. const info = await fs.lstat(path)
  60. return {
  61. version: FsVersion(String(info.mtimeMs)),
  62. type: info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
  63. size: info.size,
  64. }
  65. } catch {
  66. return undefined
  67. }
  68. }
  69. override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
  70. this.readTextSignals.push(signal)
  71. if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal)
  72. if (this.missingReadPaths.has(target.displayPath)) throw new FsError('read failed', 'FS_NOT_FOUND')
  73. if (this.errorReadPaths.has(target.displayPath)) throw new Error('read temporarily failed')
  74. const text = await readFile(target.displayPath, 'utf8')
  75. if (text.includes('\uFFFD')) throw new FsError('not text', 'FS_NOT_TEXT')
  76. return text
  77. }
  78. override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
  79. throw new Error('not needed in skill tests')
  80. }
  81. override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
  82. this.listDirCalls += 1
  83. if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed')
  84. const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' })
  85. const result: FsDirEntry[] = []
  86. for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
  87. const childPath = join(target.displayPath, entry.name)
  88. let type: FsInfo['type'] = 'other'
  89. let size: number | undefined
  90. try {
  91. const info = await stat(childPath)
  92. type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
  93. size = info.isFile() ? info.size : undefined
  94. } catch {
  95. type = 'other'
  96. }
  97. result.push({
  98. name: entry.name,
  99. type,
  100. target: { targetKey: childPath as never, displayPath: childPath },
  101. version: FsVersion('test'),
  102. ...(size !== undefined ? { size } : {}),
  103. })
  104. }
  105. return result
  106. }
  107. override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
  108. await mkdir(dirname(target.displayPath), { recursive: true })
  109. await writeFile(target.displayPath, content)
  110. return { operation: 'create', version: FsVersion('test'), before: null, after: content }
  111. }
  112. override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
  113. throw new Error('not needed in skill tests')
  114. }
  115. }
  116. async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {}): Promise<Context> {
  117. const ctx = new Context()
  118. await ctx.plugin(SkillService)
  119. await ctx.plugin(SkillLocal, {
  120. dshHome: join(home, '.dsh'),
  121. agentsHome: join(home, '.agents'),
  122. watch: false,
  123. ...config,
  124. })
  125. return ctx
  126. }
  127. async function waitFor<T>(read: () => Promise<T>, accept: (value: T) => boolean): Promise<T> {
  128. const deadline = Date.now() + 5000
  129. while (true) {
  130. const value = await read()
  131. if (accept(value)) return value
  132. if (Date.now() >= deadline) throw new Error('timed out waiting for watcher state')
  133. await new Promise(resolve => setTimeout(resolve, 20))
  134. }
  135. }
  136. describe('dsh-skill-local plugin exports', () => {
  137. it('declares stable plugin metadata', () => {
  138. expect(SkillLocal.name).toBe('skill-local')
  139. expect(SkillLocal.inject).toEqual(['skills'])
  140. })
  141. })
  142. describe('LocalSkillProvider', () => {
  143. it('discovers project, custom, user, and agents skill roots in priority order', async () => {
  144. const home = await tempDir('skill-home')
  145. const project = await tempDir('skill-project')
  146. const custom = await tempDir('skill-custom')
  147. await mkdir(join(project, '.git'), { recursive: true })
  148. await writeSkill(join(home, '.agents/skills'), 'same', 'user agents skill')
  149. await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
  150. await writeSkill(custom, 'same', 'custom skill')
  151. await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
  152. await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
  153. await writeSkill(custom, 'custom-only', 'custom only')
  154. await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
  155. const bundled = await tempDir('skill-bundled')
  156. await writeSkill(bundled, 'bundled-only', 'bundled skill')
  157. await writeSkill(bundled, 'same', 'bundled skill')
  158. const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled })
  159. const skills = await ctx.skills.list({ cwd: join(project, 'src') })
  160. expect(skills.map(skill => skill.name)).toEqual([
  161. 'bundled-only',
  162. 'custom-only',
  163. 'same',
  164. ])
  165. expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only')
  166. expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill')
  167. expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
  168. expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
  169. expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' })
  170. expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.')
  171. const noGit = await tempDir('skill-no-git')
  172. await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
  173. expect((await ctx.skills.list({ cwd: noGit })).map(skill => skill.name)).toContain('fallback-root')
  174. })
  175. it('lets project skills override runtime while runtime overrides custom and user skills', async () => {
  176. const home = await tempDir('skill-runtime-priority')
  177. const project = await tempDir('skill-runtime-project')
  178. const custom = await tempDir('skill-runtime-custom')
  179. await mkdir(join(project, '.git'), { recursive: true })
  180. await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
  181. await writeSkill(custom, 'runtime-name', 'Custom loses')
  182. await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
  183. const ctx = await setupLocal(home, { customSkillDirs: [custom] })
  184. ctx.skills.register({
  185. name: 'project-name',
  186. description: 'Runtime loses to project',
  187. content: 'Runtime body.',
  188. source: 'runtime',
  189. })
  190. ctx.skills.register({
  191. name: 'runtime-name',
  192. description: 'Runtime wins',
  193. content: 'Runtime body.',
  194. source: 'runtime',
  195. })
  196. expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins')
  197. expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
  198. })
  199. it('parses flat skills and filters invalid skills from the invocation-neutral listing', async () => {
  200. const home = await tempDir('skill-flat')
  201. const root = join(home, '.dsh/skills')
  202. await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
  203. await writeFile(join(root, 'rich-skill.md'), [
  204. '---',
  205. 'name: rich-skill',
  206. 'description: rich description',
  207. 'whenToUse: For richer local parsing',
  208. 'disable-model-invocation: off',
  209. 'user-invocable: YES',
  210. 'metadata:',
  211. ' owner: tests',
  212. '---',
  213. '',
  214. 'Rich body.',
  215. ].join('\n'))
  216. await writeFile(join(root, 'bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
  217. await writeFile(join(root, 'missing-description.md'), '---\nname: missing-description\n---\n\nbad')
  218. await writeFile(join(root, 'no-frontmatter.md'), 'No frontmatter.')
  219. await writeFile(join(root, 'plain-markdown.md'), '# Notes\nNot a skill.')
  220. await writeFile(join(root, 'open-frontmatter.md'), '---\nname: open-frontmatter')
  221. await writeFile(join(root, 'non-object.md'), '---\n[]\n---\n\nbad')
  222. await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
  223. await writeFile(join(root, 'notes.txt'), 'ignored')
  224. await mkdir(join(root, 'not-a-skill'), { recursive: true })
  225. await writeSkill(root, 'user-only-skill', 'user-only description', 'User-only.')
  226. await writeFile(join(root, 'user-only-skill/SKILL.md'), '---\nname: user-only-skill\ndescription: user-only description\ndisable-model-invocation: true\n---\n\nUser-only.\n')
  227. await writeSkill(root, 'model-only-skill', 'model-only description', 'Model-only.')
  228. await writeFile(join(root, 'model-only-skill/SKILL.md'), '---\nname: model-only-skill\ndescription: model-only description\nuser-invocable: false\n---\n\nModel-only.\n')
  229. const ctx = await setupLocal(home)
  230. const listedBeforeDelete = await ctx.skills.list()
  231. const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
  232. if (flatSummary === undefined) throw new Error('expected flat-skill')
  233. await rm(join(root, 'flat-skill.md'))
  234. expect(listedBeforeDelete.map(skill => skill.name)).toEqual([
  235. 'flat-skill',
  236. 'model-only-skill',
  237. 'no-trailing-body',
  238. 'rich-skill',
  239. 'user-only-skill',
  240. ])
  241. expect(flatSummary.invocation).toEqual({ modelInvocable: true, userInvocable: true })
  242. expect(await ctx.skills.get('flat-skill')).toBeUndefined()
  243. expect(await ctx.skills.get('no-trailing-body')).toMatchObject({
  244. invocation: { modelInvocable: true, userInvocable: true },
  245. })
  246. expect(await ctx.skills.get('user-only-skill')).toMatchObject({
  247. invocation: { modelInvocable: false, userInvocable: true },
  248. content: 'User-only.',
  249. })
  250. expect(await ctx.skills.get('model-only-skill')).toMatchObject({
  251. invocation: { modelInvocable: true, userInvocable: false },
  252. content: 'Model-only.',
  253. })
  254. expect(await ctx.skills.get('rich-skill')).toMatchObject({
  255. whenToUse: 'For richer local parsing',
  256. invocation: { modelInvocable: true, userInvocable: true },
  257. metadata: { owner: 'tests' },
  258. })
  259. expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
  260. })
  261. it('accepts the documented boolean spellings for invocation frontmatter', async () => {
  262. const home = await tempDir('skill-invocation-booleans')
  263. const root = join(home, '.dsh/skills')
  264. await mkdir(root, { recursive: true })
  265. const truthy = ['true', 'TRUE', '"true"', 'yes', 'ON', '1', '"1"']
  266. const falsy = ['false', 'FALSE', '"false"', 'no', 'OFF', '0', '"0"']
  267. for (const [index, value] of truthy.entries()) {
  268. await writeFile(join(root, `truthy-${index}.md`), [
  269. '---',
  270. `name: truthy-${index}`,
  271. `description: Truthy ${index}`,
  272. `disable-model-invocation: ${value}`,
  273. '---',
  274. '',
  275. 'Truthy.',
  276. ].join('\n'))
  277. }
  278. for (const [index, value] of falsy.entries()) {
  279. await writeFile(join(root, `falsy-${index}.md`), [
  280. '---',
  281. `name: falsy-${index}`,
  282. `description: Falsy ${index}`,
  283. `user-invocable: ${value}`,
  284. '---',
  285. '',
  286. 'Falsy.',
  287. ].join('\n'))
  288. }
  289. const ctx = await setupLocal(home)
  290. for (const [index] of truthy.entries()) {
  291. expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({
  292. modelInvocable: false,
  293. userInvocable: true,
  294. })
  295. }
  296. for (const [index] of falsy.entries()) {
  297. expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({
  298. modelInvocable: true,
  299. userInvocable: false,
  300. })
  301. }
  302. })
  303. it('rejects legacy and invalid invocation frontmatter without hiding valid siblings', async () => {
  304. const home = await tempDir('skill-invalid-invocation')
  305. const root = join(home, '.dsh/skills')
  306. await writeSkill(root, 'good-skill', 'Good skill')
  307. const invalid = [
  308. ['legacy-model', 'disableModelInvocation: true'],
  309. ['legacy-positive-model', 'modelInvocable: false'],
  310. ['legacy-user', 'userInvocable: false'],
  311. ['bad-string', 'disable-model-invocation: maybe'],
  312. ['bad-value', 'user-invocable: null'],
  313. ] as const
  314. for (const [name, field] of invalid) {
  315. await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`)
  316. }
  317. const ctx = await setupLocal(home)
  318. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
  319. })
  320. it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
  321. const home = await tempDir('skill-frontmatter-crlf')
  322. const root = join(home, '.dsh/skills')
  323. await mkdir(root, { recursive: true })
  324. await writeFile(join(root, 'crlf-skill.md'), [
  325. '---',
  326. 'name: crlf-skill',
  327. 'description: CRLF skill',
  328. 'metadata:',
  329. ' marker: "----"',
  330. '---',
  331. '',
  332. 'CRLF body.',
  333. ].join('\r\n'))
  334. await writeFile(join(root, 'block-skill.md'), [
  335. '---',
  336. 'name: block-skill',
  337. 'description: |',
  338. ' Includes a ---- marker that is not a delimiter.',
  339. '---',
  340. '',
  341. 'Block body.',
  342. ].join('\n'))
  343. const ctx = await setupLocal(home)
  344. expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
  345. expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
  346. expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
  347. expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
  348. })
  349. it('skips invalid YAML skill files without hiding valid siblings', async () => {
  350. const home = await tempDir('skill-invalid-yaml')
  351. const root = join(home, '.dsh/skills')
  352. await writeSkill(root, 'good-skill', 'Good skill')
  353. await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
  354. const ctx = await setupLocal(home)
  355. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
  356. })
  357. it('discovers symlinked skill directories and flat files', async () => {
  358. const home = await tempDir('skill-symlink-home')
  359. const external = await tempDir('skill-symlink-external')
  360. await writeSkill(external, 'linked-dir', 'Linked directory')
  361. await writeFlatSkill(external, 'linked-flat', 'Linked flat')
  362. await mkdir(join(home, '.dsh/skills'), { recursive: true })
  363. await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
  364. await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
  365. await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
  366. await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
  367. const ctx = await setupLocal(home)
  368. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
  369. })
  370. it('uses the filesystem service for discovery, reads, and project-root lookup', async () => {
  371. const home = await tempDir('skill-read-fs')
  372. const project = await tempDir('skill-project-root-backend')
  373. const nestedCwd = join(project, 'packages/app')
  374. const root = join(home, '.dsh/skills')
  375. await mkdir(nestedCwd, { recursive: true })
  376. await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
  377. await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve body.')
  378. await writeFlatSkill(root, 'stat-fail', 'Stat fail', 'Stat body.')
  379. await mkdir(join(root, 'empty-dir'), { recursive: true })
  380. await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
  381. await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
  382. Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
  383. Buffer.from([0xff]),
  384. Buffer.from('\n'),
  385. ]))
  386. await writeSkill(join(project, '.agents/skills'), 'backend-root', 'Backend root skill')
  387. const ctx = new Context()
  388. await ctx.plugin(TestFileSystem)
  389. const fs = ctx.fs as TestFileSystem
  390. fs.failResolvePaths.add(join(root, 'resolve-fail.md'))
  391. fs.failStatPaths.add(join(root, 'stat-fail.md'))
  392. fs.failResolvePaths.add(join(nestedCwd, '.git'))
  393. fs.failStatPaths.add(join(project, 'packages/.git'))
  394. fs.statOverrides.set(join(project, '.git'), {
  395. version: FsVersion('virtual-git'),
  396. type: 'directory',
  397. size: 0,
  398. })
  399. await ctx.plugin(SkillService)
  400. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  401. expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
  402. ['backend-root', 'project-agents'],
  403. ['text-skill', 'user-dsh'],
  404. ])
  405. expect(fs.listDirCalls).toBeGreaterThan(0)
  406. expect(await ctx.skills.get('binary-skill')).toBeUndefined()
  407. const bundled = await tempDir('skill-backend-bundled')
  408. await writeSkill(bundled, 'bundled-host', 'Bundled host skill')
  409. const bundledCtx = new Context()
  410. await bundledCtx.plugin(TestFileSystem)
  411. const bundledFs = bundledCtx.fs as TestFileSystem
  412. bundledFs.failResolvePaths.add(bundled)
  413. await bundledCtx.plugin(SkillService)
  414. await bundledCtx.plugin(SkillLocal, {
  415. dshHome: join(home, '.dsh'),
  416. agentsHome: join(home, '.agents'),
  417. bundledSkillDir: bundled,
  418. })
  419. expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
  420. })
  421. it('reports transient root reads as incomplete without caching an empty catalog', async () => {
  422. const home = await tempDir('skill-transient-root')
  423. const root = join(home, '.agents/skills')
  424. await writeSkill(root, 'stable-skill', 'Stable skill')
  425. const ctx = new Context()
  426. await ctx.plugin(TestFileSystem)
  427. const fs = ctx.fs as TestFileSystem
  428. await ctx.plugin(SkillService)
  429. await ctx.plugin(SkillLocal, {
  430. dshHome: join(home, '.dsh'),
  431. agentsHome: join(home, '.agents'),
  432. watch: false,
  433. })
  434. expect(await ctx.skills.snapshot()).toMatchObject({
  435. skills: [{ name: 'stable-skill' }],
  436. complete: true,
  437. })
  438. fs.failListDirPaths.add(root)
  439. const path = join(root, 'stable-skill/SKILL.md')
  440. ctx.emit(
  441. 'fs/observed',
  442. { targetKey: path as never, displayPath: path },
  443. FsVersion('failed-read'),
  444. { name: 'edit' },
  445. )
  446. expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
  447. fs.failListDirPaths.clear()
  448. expect(await ctx.skills.snapshot()).toMatchObject({
  449. skills: [{ name: 'stable-skill' }],
  450. complete: true,
  451. })
  452. })
  453. it('distinguishes transient filesystem entry failures from confirmed disappearance', async () => {
  454. const home = await tempDir('skill-transient-entry')
  455. const root = join(home, '.agents/skills')
  456. const path = join(root, 'stable-skill/SKILL.md')
  457. await writeSkill(root, 'stable-skill', 'Stable skill')
  458. const ctx = new Context()
  459. await ctx.plugin(TestFileSystem)
  460. const fs = ctx.fs as TestFileSystem
  461. await ctx.plugin(SkillService)
  462. await ctx.plugin(SkillLocal, {
  463. dshHome: join(home, '.dsh'),
  464. agentsHome: join(home, '.agents'),
  465. watch: false,
  466. })
  467. const invalidate = (): void => {
  468. ctx.emit(
  469. 'fs/observed',
  470. { targetKey: path as never, displayPath: path },
  471. FsVersion('entry-failure'),
  472. { name: 'write' },
  473. )
  474. }
  475. expect((await ctx.skills.snapshot()).complete).toBe(true)
  476. for (const failures of [fs.errorResolvePaths, fs.errorStatPaths, fs.errorReadPaths]) {
  477. failures.add(path)
  478. invalidate()
  479. expect((await ctx.skills.snapshot()).complete).toBe(false)
  480. failures.clear()
  481. }
  482. fs.missingReadPaths.add(path)
  483. invalidate()
  484. expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
  485. fs.missingReadPaths.clear()
  486. invalidate()
  487. expect(await ctx.skills.snapshot()).toMatchObject({
  488. skills: [{ name: 'stable-skill' }],
  489. complete: true,
  490. })
  491. })
  492. it('marks an unexpected native skill-file read failure incomplete', async () => {
  493. const home = await tempDir('skill-native-read-failure')
  494. const root = join(home, '.agents/skills')
  495. await mkdir(join(root, 'broken-skill/SKILL.md'), { recursive: true })
  496. const ctx = await setupLocal(home)
  497. expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
  498. })
  499. it('forwards cancellation to filesystem reads while loading a skill', async () => {
  500. const home = await tempDir('skill-read-abort')
  501. await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill')
  502. const ctx = new Context()
  503. await ctx.plugin(TestFileSystem)
  504. const fs = ctx.fs as TestFileSystem
  505. await ctx.plugin(SkillService)
  506. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
  507. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill'])
  508. fs.statSignals = []
  509. fs.readTextSignals = []
  510. const started = Promise.withResolvers<undefined>()
  511. fs.readTextOverride = async (_target, signal) => {
  512. if (signal === undefined) throw new Error('expected the skill lookup signal')
  513. started.resolve(undefined)
  514. return await new Promise<string>((_resolve, reject) => {
  515. signal.addEventListener('abort', () => {
  516. const abortReason = signal.reason as unknown
  517. reject(abortReason instanceof Error ? abortReason : new Error(String(abortReason)))
  518. }, { once: true })
  519. })
  520. }
  521. const controller = new AbortController()
  522. const reason = new Error('turn cancelled')
  523. const loading = ctx.skills.get('abortable-skill', { signal: controller.signal })
  524. await started.promise
  525. controller.abort(reason)
  526. await expect(loading).rejects.toBe(reason)
  527. expect(fs.statSignals).toEqual([controller.signal])
  528. expect(fs.readTextSignals).toEqual([controller.signal])
  529. })
  530. it('refreshes additions, metadata changes, deletions, and a recreated missing root', { timeout: 20000 }, async () => {
  531. const home = await tempDir('skill-watch-home')
  532. const agentsRoot = join(home, '.agents/skills')
  533. const ctx = new Context()
  534. await ctx.plugin(SkillService)
  535. const fiber = await ctx.plugin(SkillLocal, {
  536. dshHome: join(home, '.dsh'),
  537. agentsHome: join(home, '.agents'),
  538. watch: true,
  539. watchStabilityThresholdMs: 20,
  540. watchPollIntervalMs: 10,
  541. })
  542. try {
  543. expect(await ctx.skills.list()).toEqual([])
  544. await writeSkill(agentsRoot, 'watched-skill', 'First description', 'First body.')
  545. const added = await waitFor(
  546. async () => await ctx.skills.list(),
  547. skills => skills.some(skill => skill.name === 'watched-skill'),
  548. )
  549. expect(added.find(skill => skill.name === 'watched-skill')?.description).toBe('First description')
  550. await writeSkill(agentsRoot, 'watched-skill', 'Second description', 'Second body.')
  551. const changed = await waitFor(
  552. async () => await ctx.skills.list(),
  553. skills => skills.find(skill => skill.name === 'watched-skill')?.description === 'Second description',
  554. )
  555. expect(changed).toHaveLength(1)
  556. expect((await ctx.skills.get('watched-skill'))?.content).toBe('Second body.')
  557. await writeFlatSkill(agentsRoot, 'flat-added', 'Flat added')
  558. expect(await waitFor(
  559. async () => (await ctx.skills.list()).map(skill => skill.name),
  560. names => names.includes('flat-added'),
  561. )).toEqual(['flat-added', 'watched-skill'])
  562. await rename(join(agentsRoot, 'watched-skill'), join(agentsRoot, 'renamed-skill'))
  563. await writeSkill(agentsRoot, 'renamed-skill', 'Renamed skill')
  564. expect(await waitFor(
  565. async () => (await ctx.skills.list()).map(skill => skill.name),
  566. names => names.includes('renamed-skill') && !names.includes('watched-skill'),
  567. )).toEqual(['flat-added', 'renamed-skill'])
  568. await rm(join(agentsRoot, 'renamed-skill'), { recursive: true })
  569. expect(await waitFor(
  570. async () => (await ctx.skills.list()).map(skill => skill.name),
  571. names => !names.includes('renamed-skill'),
  572. )).toEqual(['flat-added'])
  573. await rm(join(home, '.agents'), { recursive: true })
  574. expect(await waitFor(
  575. async () => await ctx.skills.list(),
  576. skills => skills.length === 0,
  577. )).toEqual([])
  578. await writeSkill(agentsRoot, 'recreated-skill', 'Recreated')
  579. expect(await waitFor(
  580. async () => (await ctx.skills.list()).map(skill => skill.name),
  581. names => names.includes('recreated-skill'),
  582. )).toEqual(['recreated-skill'])
  583. } finally {
  584. await fiber.dispose()
  585. }
  586. })
  587. it('uses fs/observed as a synchronous first-party invalidation path without a watcher', async () => {
  588. const home = await tempDir('skill-observed-home')
  589. const root = join(home, '.agents/skills')
  590. const ctx = await setupLocal(home)
  591. expect(await ctx.skills.list()).toEqual([])
  592. let invalidations = 0
  593. ctx.on('skills/change', () => { invalidations += 1 })
  594. await writeSkill(root, 'observed-skill', 'Observed skill')
  595. const path = join(root, 'observed-skill/SKILL.md')
  596. const emitObserved = (displayPath: string, actor?: object): void => {
  597. ctx.emit(
  598. 'fs/observed',
  599. { targetKey: displayPath as never, displayPath },
  600. FsVersion('observed'),
  601. actor,
  602. )
  603. }
  604. emitObserved(path)
  605. emitObserved(path, {})
  606. emitObserved(path, { name: 'read' })
  607. emitObserved(join(home, 'outside.md'), { name: 'write' })
  608. emitObserved(root, { name: 'write' })
  609. emitObserved(join(root, 'observed-skill/references/notes.md'), { name: 'write' })
  610. emitObserved(join(home, '.dsh/skills/.system/SKILL.md'), { name: 'write' })
  611. emitObserved(join(root, 'flat-skill.md'), { name: 'write' })
  612. ctx.emit(
  613. 'fs/observed',
  614. { targetKey: path as never, displayPath: path },
  615. FsVersion('observed'),
  616. { name: 'edit' },
  617. )
  618. expect(invalidations).toBe(2)
  619. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['observed-skill'])
  620. })
  621. it('bounds project watchers and re-observes an evicted project on its next lookup', async () => {
  622. const home = await tempDir('skill-watch-lru-home')
  623. const first = await tempDir('skill-watch-lru-first')
  624. const second = await tempDir('skill-watch-lru-second')
  625. await mkdir(join(first, '.git'), { recursive: true })
  626. await mkdir(join(second, '.git'), { recursive: true })
  627. await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project')
  628. await writeSkill(join(second, '.agents/skills'), 'second-project', 'Second project')
  629. const ctx = new Context()
  630. await ctx.plugin(SkillService)
  631. const fiber = await ctx.plugin(SkillLocal, {
  632. dshHome: join(home, '.dsh'),
  633. agentsHome: join(home, '.agents'),
  634. customSkillDirs: [join(first, '.agents/skills')],
  635. watch: true,
  636. watchMaxProjects: 1,
  637. watchStabilityThresholdMs: 20,
  638. watchPollIntervalMs: 10,
  639. })
  640. try {
  641. expect((await ctx.skills.list({ cwd: first })).map(skill => skill.name)).toContain('first-project')
  642. expect((await ctx.skills.list({ cwd: second })).map(skill => skill.name)).toContain('second-project')
  643. await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project refreshed')
  644. expect((await ctx.skills.list({ cwd: first })).find(skill => skill.name === 'first-project')?.description)
  645. .toBe('First project refreshed')
  646. } finally {
  647. await fiber.dispose()
  648. }
  649. const noWatch = new Context()
  650. await noWatch.plugin(SkillService)
  651. await noWatch.plugin(SkillLocal, {
  652. dshHome: join(home, '.dsh'),
  653. agentsHome: join(home, '.agents'),
  654. watch: false,
  655. watchMaxProjects: 1,
  656. })
  657. await noWatch.skills.list({ cwd: first })
  658. await noWatch.skills.list({ cwd: second })
  659. })
  660. it('contains repeated disposal and late first-party observations', async () => {
  661. const home = await tempDir('skill-watch-dispose')
  662. const nonDirectoryRoot = join(home, 'not-a-directory')
  663. await writeFile(nonDirectoryRoot, 'not a skill root')
  664. await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill')
  665. const ctx = new Context()
  666. await ctx.plugin(SkillService)
  667. let provider!: SkillLocal.LocalSkillProvider
  668. const disposeProvider = ctx.skills.registerProvider((control) => {
  669. provider = new SkillLocal.LocalSkillProvider(ctx, control, {
  670. dshHome: join(home, '.dsh'),
  671. agentsHome: join(home, '.agents'),
  672. customSkillDirs: [nonDirectoryRoot],
  673. watch: true,
  674. watchStabilityThresholdMs: 20,
  675. watchPollIntervalMs: 10,
  676. })
  677. return provider
  678. })
  679. const beforeDisposal = await provider.list({})
  680. expect((Array.isArray(beforeDisposal) ? beforeDisposal : beforeDisposal.candidates).map(skill => skill.name))
  681. .toEqual(['disposed-skill'])
  682. await provider.dispose()
  683. await provider.dispose()
  684. provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
  685. const afterDisposal = await provider.list({})
  686. expect((Array.isArray(afterDisposal) ? afterDisposal : afterDisposal.candidates).map(skill => skill.name))
  687. .toEqual(['disposed-skill'])
  688. disposeProvider()
  689. })
  690. it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => {
  691. const home = await tempDir('skill-watch-symlink-home')
  692. const external = await tempDir('skill-watch-symlink-external')
  693. const root = join(home, '.dsh/skills')
  694. await writeSkill(external, 'linked-skill', 'First linked description')
  695. await mkdir(root, { recursive: true })
  696. await symlink(join(external, 'linked-skill'), join(root, 'linked-skill'))
  697. const ctx = new Context()
  698. await ctx.plugin(SkillService)
  699. const fiber = await ctx.plugin(SkillLocal, {
  700. dshHome: join(home, '.dsh'),
  701. agentsHome: join(home, '.agents'),
  702. watch: true,
  703. watchFollowSymlinks: true,
  704. watchStabilityThresholdMs: 20,
  705. watchPollIntervalMs: 10,
  706. })
  707. try {
  708. expect((await ctx.skills.list())[0]?.description).toBe('First linked description')
  709. await writeSkill(external, 'linked-skill', 'Second linked description')
  710. const refreshed = await waitFor(
  711. async () => await ctx.skills.list(),
  712. skills => skills[0]?.description === 'Second linked description',
  713. )
  714. expect(refreshed[0]?.name).toBe('linked-skill')
  715. } finally {
  716. await fiber.dispose()
  717. }
  718. })
  719. it('validates watcher tunables at plugin load', async () => {
  720. const ctx = new Context()
  721. await ctx.plugin(SkillService)
  722. await expect(ctx.plugin(SkillLocal, { watchMaxProjects: 0 })).rejects.toThrow('watchMaxProjects')
  723. await expect(ctx.plugin(SkillLocal, { watchPollIntervalMs: 1.5 })).rejects.toThrow('watchPollIntervalMs')
  724. await expect(ctx.plugin(SkillLocal, { watchStabilityThresholdMs: 0 })).rejects.toThrow('watchStabilityThresholdMs')
  725. })
  726. it('uses default home root resolution without exposing builtin skills', async () => {
  727. const previousDshHome = process.env.DSH_HOME
  728. const previousAgentsHome = process.env.DSH_AGENTS_HOME
  729. const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR
  730. const envHome = await tempDir('skill-env-home')
  731. try {
  732. process.env.DSH_HOME = join(envHome, '.dsh')
  733. process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
  734. const bundled = join(envHome, 'bundled-skills')
  735. process.env.DSH_BUNDLED_SKILL_DIR = bundled
  736. await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
  737. await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill')
  738. const ctx = new Context()
  739. await ctx.plugin(SkillService)
  740. await ctx.plugin(SkillLocal, { watch: false })
  741. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
  742. // Isolated providers see only their explicit roots: the environment
  743. // bundled root is a default root, so includeDefaultRoots: false must
  744. // drop it — repository providers never re-claim the app's builtins.
  745. const isolated = new Context()
  746. await isolated.plugin(SkillService)
  747. const customOnly = join(envHome, 'custom-only')
  748. await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill')
  749. await isolated.plugin(SkillLocal, {
  750. providerName: 'isolated',
  751. includeDefaultRoots: false,
  752. customSkillDirs: [customOnly],
  753. watch: false,
  754. })
  755. expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill'])
  756. await isolated.fiber.dispose()
  757. process.env.DSH_HOME = join(envHome, 'empty-dsh')
  758. delete process.env.DSH_BUNDLED_SKILL_DIR
  759. process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
  760. const empty = new Context()
  761. await empty.plugin(SkillService)
  762. SkillLocal.apply(empty, { watch: false })
  763. expect(await empty.skills.list()).toEqual([])
  764. delete process.env.DSH_AGENTS_HOME
  765. expect(new SkillLocal.LocalSkillProvider(empty, {
  766. signal: new AbortController().signal,
  767. invalidate() {},
  768. }, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
  769. } finally {
  770. if (previousDshHome === undefined) {
  771. delete process.env.DSH_HOME
  772. } else {
  773. process.env.DSH_HOME = previousDshHome
  774. }
  775. if (previousAgentsHome === undefined) {
  776. delete process.env.DSH_AGENTS_HOME
  777. } else {
  778. process.env.DSH_AGENTS_HOME = previousAgentsHome
  779. }
  780. if (previousBundledSkillDir === undefined) {
  781. delete process.env.DSH_BUNDLED_SKILL_DIR
  782. } else {
  783. process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir
  784. }
  785. }
  786. })
  787. })