1
0

skill-filesystem.spec.ts 39 KB

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