skill-local.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. import { describe, expect, it } from 'vitest'
  2. import { mkdir, readdir, readFile, 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, 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. statOverrides = new Map<string, FsInfo | undefined>()
  26. statSignals: Array<AbortSignal | undefined> = []
  27. readTextSignals: Array<AbortSignal | undefined> = []
  28. readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise<string>
  29. override async resolve(path: string): Promise<FsTarget> {
  30. if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
  31. return { targetKey: path as never, displayPath: path }
  32. }
  33. override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
  34. this.statSignals.push(signal)
  35. if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed')
  36. if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
  37. try {
  38. const fs = await import('node:fs/promises')
  39. const info = await fs.stat(target.displayPath)
  40. return {
  41. version: FsVersion(String(info.mtimeMs)),
  42. type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
  43. size: info.size,
  44. }
  45. } catch {
  46. return undefined
  47. }
  48. }
  49. override async lstat(path: string): Promise<FsPathInfo | undefined> {
  50. try {
  51. const fs = await import('node:fs/promises')
  52. const info = await fs.lstat(path)
  53. return {
  54. version: FsVersion(String(info.mtimeMs)),
  55. type: info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
  56. size: info.size,
  57. }
  58. } catch {
  59. return undefined
  60. }
  61. }
  62. override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
  63. this.readTextSignals.push(signal)
  64. if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal)
  65. const text = await readFile(target.displayPath, 'utf8')
  66. if (text.includes('\uFFFD')) throw new Error('not text')
  67. return text
  68. }
  69. override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
  70. throw new Error('not needed in skill tests')
  71. }
  72. override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
  73. this.listDirCalls += 1
  74. const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' })
  75. const result: FsDirEntry[] = []
  76. for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
  77. const childPath = join(target.displayPath, entry.name)
  78. let type: FsInfo['type'] = 'other'
  79. let size: number | undefined
  80. try {
  81. const info = await stat(childPath)
  82. type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
  83. size = info.isFile() ? info.size : undefined
  84. } catch {
  85. type = 'other'
  86. }
  87. result.push({
  88. name: entry.name,
  89. type,
  90. target: { targetKey: childPath as never, displayPath: childPath },
  91. version: FsVersion('test'),
  92. ...(size !== undefined ? { size } : {}),
  93. })
  94. }
  95. return result
  96. }
  97. override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
  98. await mkdir(dirname(target.displayPath), { recursive: true })
  99. await writeFile(target.displayPath, content)
  100. return { operation: 'create', version: FsVersion('test'), before: null, after: content }
  101. }
  102. override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
  103. throw new Error('not needed in skill tests')
  104. }
  105. }
  106. async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {}): Promise<Context> {
  107. const ctx = new Context()
  108. await ctx.plugin(SkillService)
  109. await ctx.plugin(SkillLocal, {
  110. dshHome: join(home, '.dsh'),
  111. agentsHome: join(home, '.agents'),
  112. ...config,
  113. })
  114. return ctx
  115. }
  116. describe('dsh-skill-local plugin exports', () => {
  117. it('declares stable plugin metadata', () => {
  118. expect(SkillLocal.name).toBe('skill-local')
  119. expect(SkillLocal.inject).toEqual(['skills'])
  120. })
  121. })
  122. describe('LocalSkillProvider', () => {
  123. it('discovers project, custom, user, and agents skill roots in priority order', async () => {
  124. const home = await tempDir('skill-home')
  125. const project = await tempDir('skill-project')
  126. const custom = await tempDir('skill-custom')
  127. await mkdir(join(project, '.git'), { recursive: true })
  128. await writeSkill(join(home, '.agents/skills'), 'same', 'user agents skill')
  129. await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
  130. await writeSkill(custom, 'same', 'custom skill')
  131. await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
  132. await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
  133. await writeSkill(custom, 'custom-only', 'custom only')
  134. await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
  135. const ctx = await setupLocal(home, { customSkillDirs: [custom] })
  136. const skills = await ctx.skills.list({ cwd: join(project, 'src') })
  137. expect(skills.map(skill => [skill.name, skill.description])).toEqual([
  138. ['custom-only', 'custom only'],
  139. ['same', 'project dsh skill'],
  140. ])
  141. expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
  142. expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
  143. const noGit = await tempDir('skill-no-git')
  144. await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
  145. expect((await ctx.skills.list({ cwd: noGit })).map(skill => skill.name)).toContain('fallback-root')
  146. })
  147. it('lets project skills override runtime while runtime overrides custom and user skills', async () => {
  148. const home = await tempDir('skill-runtime-priority')
  149. const project = await tempDir('skill-runtime-project')
  150. const custom = await tempDir('skill-runtime-custom')
  151. await mkdir(join(project, '.git'), { recursive: true })
  152. await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
  153. await writeSkill(custom, 'runtime-name', 'Custom loses')
  154. await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
  155. const ctx = await setupLocal(home, { customSkillDirs: [custom] })
  156. ctx.skills.register({
  157. name: 'project-name',
  158. description: 'Runtime loses to project',
  159. content: 'Runtime body.',
  160. source: 'runtime',
  161. })
  162. ctx.skills.register({
  163. name: 'runtime-name',
  164. description: 'Runtime wins',
  165. content: 'Runtime body.',
  166. source: 'runtime',
  167. })
  168. expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins')
  169. expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
  170. })
  171. it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
  172. const home = await tempDir('skill-flat')
  173. const root = join(home, '.dsh/skills')
  174. await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
  175. await writeFile(join(root, 'rich-skill.md'), [
  176. '---',
  177. 'name: rich-skill',
  178. 'description: rich description',
  179. 'whenToUse: For richer local parsing',
  180. 'disableModelInvocation: false',
  181. 'metadata:',
  182. ' owner: tests',
  183. '---',
  184. '',
  185. 'Rich body.',
  186. ].join('\n'))
  187. await writeFile(join(root, 'bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
  188. await writeFile(join(root, 'missing-description.md'), '---\nname: missing-description\n---\n\nbad')
  189. await writeFile(join(root, 'no-frontmatter.md'), 'No frontmatter.')
  190. await writeFile(join(root, 'plain-markdown.md'), '# Notes\nNot a skill.')
  191. await writeFile(join(root, 'open-frontmatter.md'), '---\nname: open-frontmatter')
  192. await writeFile(join(root, 'non-object.md'), '---\n[]\n---\n\nbad')
  193. await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
  194. await writeFile(join(root, 'notes.txt'), 'ignored')
  195. await mkdir(join(root, 'not-a-skill'), { recursive: true })
  196. await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
  197. await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
  198. const ctx = await setupLocal(home)
  199. const listedBeforeDelete = await ctx.skills.list()
  200. const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
  201. if (flatSummary === undefined) throw new Error('expected flat-skill')
  202. await writeFile(join(root, 'flat-skill.md'), '')
  203. expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
  204. expect(await ctx.skills.get('flat-skill')).toBeUndefined()
  205. expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
  206. expect(await ctx.skills.get('rich-skill')).toMatchObject({
  207. whenToUse: 'For richer local parsing',
  208. disableModelInvocation: false,
  209. metadata: { owner: 'tests' },
  210. })
  211. expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
  212. })
  213. it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
  214. const home = await tempDir('skill-frontmatter-crlf')
  215. const root = join(home, '.dsh/skills')
  216. await mkdir(root, { recursive: true })
  217. await writeFile(join(root, 'crlf-skill.md'), [
  218. '---',
  219. 'name: crlf-skill',
  220. 'description: CRLF skill',
  221. 'metadata:',
  222. ' marker: "----"',
  223. '---',
  224. '',
  225. 'CRLF body.',
  226. ].join('\r\n'))
  227. await writeFile(join(root, 'block-skill.md'), [
  228. '---',
  229. 'name: block-skill',
  230. 'description: |',
  231. ' Includes a ---- marker that is not a delimiter.',
  232. '---',
  233. '',
  234. 'Block body.',
  235. ].join('\n'))
  236. const ctx = await setupLocal(home)
  237. expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
  238. expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
  239. expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
  240. expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
  241. })
  242. it('skips invalid YAML skill files without hiding valid siblings', async () => {
  243. const home = await tempDir('skill-invalid-yaml')
  244. const root = join(home, '.dsh/skills')
  245. await writeSkill(root, 'good-skill', 'Good skill')
  246. await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
  247. const ctx = await setupLocal(home)
  248. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
  249. })
  250. it('discovers symlinked skill directories and flat files', async () => {
  251. const home = await tempDir('skill-symlink-home')
  252. const external = await tempDir('skill-symlink-external')
  253. await writeSkill(external, 'linked-dir', 'Linked directory')
  254. await writeFlatSkill(external, 'linked-flat', 'Linked flat')
  255. await mkdir(join(home, '.dsh/skills'), { recursive: true })
  256. await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
  257. await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
  258. await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
  259. await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
  260. const ctx = await setupLocal(home)
  261. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
  262. })
  263. it('uses the filesystem service for discovery, reads, and project-root lookup', async () => {
  264. const home = await tempDir('skill-read-fs')
  265. const project = await tempDir('skill-project-root-backend')
  266. const nestedCwd = join(project, 'packages/app')
  267. const root = join(home, '.dsh/skills')
  268. await mkdir(nestedCwd, { recursive: true })
  269. await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
  270. await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve body.')
  271. await writeFlatSkill(root, 'stat-fail', 'Stat fail', 'Stat body.')
  272. await mkdir(join(root, 'empty-dir'), { recursive: true })
  273. await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
  274. await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
  275. Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
  276. Buffer.from([0xff]),
  277. Buffer.from('\n'),
  278. ]))
  279. await writeSkill(join(project, '.agents/skills'), 'backend-root', 'Backend root skill')
  280. const ctx = new Context()
  281. await ctx.plugin(TestFileSystem)
  282. const fs = ctx.fs as TestFileSystem
  283. fs.failResolvePaths.add(join(root, 'resolve-fail.md'))
  284. fs.failStatPaths.add(join(root, 'stat-fail.md'))
  285. fs.failResolvePaths.add(join(nestedCwd, '.git'))
  286. fs.failStatPaths.add(join(project, 'packages/.git'))
  287. fs.statOverrides.set(join(project, '.git'), {
  288. version: FsVersion('virtual-git'),
  289. type: 'directory',
  290. size: 0,
  291. })
  292. await ctx.plugin(SkillService)
  293. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
  294. expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
  295. ['backend-root', 'project-agents'],
  296. ['text-skill', 'user-dsh'],
  297. ])
  298. expect(fs.listDirCalls).toBeGreaterThan(0)
  299. expect(await ctx.skills.get('binary-skill')).toBeUndefined()
  300. })
  301. it('forwards cancellation to filesystem reads while loading a skill', async () => {
  302. const home = await tempDir('skill-read-abort')
  303. await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill')
  304. const ctx = new Context()
  305. await ctx.plugin(TestFileSystem)
  306. const fs = ctx.fs as TestFileSystem
  307. await ctx.plugin(SkillService)
  308. await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
  309. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill'])
  310. fs.statSignals = []
  311. fs.readTextSignals = []
  312. const started = Promise.withResolvers<undefined>()
  313. fs.readTextOverride = async (_target, signal) => {
  314. if (signal === undefined) throw new Error('expected the skill lookup signal')
  315. started.resolve(undefined)
  316. return await new Promise<string>((_resolve, reject) => {
  317. signal.addEventListener('abort', () => {
  318. const abortReason = signal.reason as unknown
  319. reject(abortReason instanceof Error ? abortReason : new Error(String(abortReason)))
  320. }, { once: true })
  321. })
  322. }
  323. const controller = new AbortController()
  324. const reason = new Error('turn cancelled')
  325. const loading = ctx.skills.get('abortable-skill', { signal: controller.signal })
  326. await started.promise
  327. controller.abort(reason)
  328. await expect(loading).rejects.toBe(reason)
  329. expect(fs.statSignals).toEqual([controller.signal])
  330. expect(fs.readTextSignals).toEqual([controller.signal])
  331. })
  332. it('uses default home root resolution without exposing builtin skills', async () => {
  333. const previousDshHome = process.env.DSH_HOME
  334. const previousAgentsHome = process.env.DSH_AGENTS_HOME
  335. const envHome = await tempDir('skill-env-home')
  336. try {
  337. process.env.DSH_HOME = join(envHome, '.dsh')
  338. process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
  339. await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
  340. const ctx = new Context()
  341. await ctx.plugin(SkillService)
  342. await ctx.plugin(SkillLocal)
  343. expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
  344. process.env.DSH_HOME = join(envHome, 'empty-dsh')
  345. process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
  346. const empty = new Context()
  347. await empty.plugin(SkillService)
  348. SkillLocal.apply(empty, {})
  349. expect(await empty.skills.list()).toEqual([])
  350. delete process.env.DSH_AGENTS_HOME
  351. expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
  352. } finally {
  353. if (previousDshHome === undefined) {
  354. delete process.env.DSH_HOME
  355. } else {
  356. process.env.DSH_HOME = previousDshHome
  357. }
  358. if (previousAgentsHome === undefined) {
  359. delete process.env.DSH_AGENTS_HOME
  360. } else {
  361. process.env.DSH_AGENTS_HOME = previousAgentsHome
  362. }
  363. }
  364. })
  365. })