import { describe, expect, it } from 'vitest' import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import SkillService from '@deepseek-ai/dsh-skill' import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillLocal from '../src/index.ts' async function tempDir(name: string): Promise { return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`))) } async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise { const dir = join(root, name) await mkdir(dir, { recursive: true }) await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`) } async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise { await mkdir(root, { recursive: true }) await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`) } class TestFileSystem extends FileSystem { listDirCalls = 0 failResolvePaths = new Set() failStatPaths = new Set() failListDirPaths = new Set() errorResolvePaths = new Set() errorStatPaths = new Set() errorReadPaths = new Set() missingReadPaths = new Set() statOverrides = new Map() statSignals: Array = [] readTextSignals: Array = [] readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise override async resolve(path: string): Promise { if (this.failResolvePaths.has(path)) throw new FsError('resolve failed', 'FS_NOT_FOUND') if (this.errorResolvePaths.has(path)) throw new Error('resolve temporarily failed') return { targetKey: path as never, displayPath: path } } override async stat(target: FsTarget, signal?: AbortSignal): Promise { this.statSignals.push(signal) if (this.failStatPaths.has(target.displayPath)) throw new FsError('stat failed', 'FS_NOT_FOUND') if (this.errorStatPaths.has(target.displayPath)) throw new Error('stat temporarily failed') if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath) try { const fs = await import('node:fs/promises') const info = await fs.stat(target.displayPath) return { version: FsVersion(String(info.mtimeMs)), type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other', size: info.size, } } catch { return undefined } } override async lstat(path: string): Promise { try { const fs = await import('node:fs/promises') const info = await fs.lstat(path) return { version: FsVersion(String(info.mtimeMs)), type: info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other', size: info.size, } } catch { return undefined } } override async readText(target: FsTarget, signal?: AbortSignal): Promise { this.readTextSignals.push(signal) if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal) if (this.missingReadPaths.has(target.displayPath)) throw new FsError('read failed', 'FS_NOT_FOUND') if (this.errorReadPaths.has(target.displayPath)) throw new Error('read temporarily failed') const text = await readFile(target.displayPath, 'utf8') if (text.includes('\uFFFD')) throw new FsError('not text', 'FS_NOT_TEXT') return text } override async streamText(_target: FsTarget): Promise> { throw new Error('not needed in skill tests') } override async listDir(target: FsTarget): Promise { this.listDirCalls += 1 if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed') const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' }) const result: FsDirEntry[] = [] for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { const childPath = join(target.displayPath, entry.name) let type: FsInfo['type'] = 'other' let size: number | undefined try { const info = await stat(childPath) type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other' size = info.isFile() ? info.size : undefined } catch { type = 'other' } result.push({ name: entry.name, type, target: { targetKey: childPath as never, displayPath: childPath }, version: FsVersion('test'), ...(size !== undefined ? { size } : {}), }) } return result } override async writeText(target: FsTarget, content: string): Promise { await mkdir(dirname(target.displayPath), { recursive: true }) await writeFile(target.displayPath, content) return { operation: 'create', version: FsVersion('test'), before: null, after: content } } override async editText(_target: FsTarget, _request: FsEditRequest): Promise { throw new Error('not needed in skill tests') } } async function setupLocal(home: string, config: Partial = {}): Promise { const ctx = new Context() await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false, ...config, }) return ctx } async function waitFor(read: () => Promise, accept: (value: T) => boolean): Promise { const deadline = Date.now() + 5000 while (true) { const value = await read() if (accept(value)) return value if (Date.now() >= deadline) throw new Error('timed out waiting for watcher state') await new Promise(resolve => setTimeout(resolve, 20)) } } describe('dsh-skill-local plugin exports', () => { it('declares stable plugin metadata', () => { expect(SkillLocal.name).toBe('skill-local') expect(SkillLocal.inject).toEqual(['skills']) }) }) describe('LocalSkillProvider', () => { it('discovers project, custom, user, and agents skill roots in priority order', async () => { const home = await tempDir('skill-home') const project = await tempDir('skill-project') const custom = await tempDir('skill-custom') await mkdir(join(project, '.git'), { recursive: true }) await writeSkill(join(home, '.agents/skills'), 'same', 'user agents skill') await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill') await writeSkill(custom, 'same', 'custom skill') await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill') await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill') await writeSkill(custom, 'custom-only', 'custom only') await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system') const bundled = await tempDir('skill-bundled') await writeSkill(bundled, 'bundled-only', 'bundled skill') await writeSkill(bundled, 'same', 'bundled skill') const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled }) const skills = await ctx.skills.list({ cwd: join(project, 'src') }) expect(skills.map(skill => skill.name)).toEqual([ 'bundled-only', 'custom-only', 'same', ]) expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only') expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill') expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh') expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined() expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' }) expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.') const noGit = await tempDir('skill-no-git') await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root') expect((await ctx.skills.list({ cwd: noGit })).map(skill => skill.name)).toContain('fallback-root') }) it('lets project skills override runtime while runtime overrides custom and user skills', async () => { const home = await tempDir('skill-runtime-priority') const project = await tempDir('skill-runtime-project') const custom = await tempDir('skill-runtime-custom') await mkdir(join(project, '.git'), { recursive: true }) await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins') await writeSkill(custom, 'runtime-name', 'Custom loses') await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses') const ctx = await setupLocal(home, { customSkillDirs: [custom] }) ctx.skills.register({ name: 'project-name', description: 'Runtime loses to project', content: 'Runtime body.', source: 'runtime', }) ctx.skills.register({ name: 'runtime-name', description: 'Runtime wins', content: 'Runtime body.', source: 'runtime', }) expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins') expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins') }) it('parses flat skills and filters invalid skills from the invocation-neutral listing', async () => { const home = await tempDir('skill-flat') const root = join(home, '.dsh/skills') await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.') await writeFile(join(root, 'rich-skill.md'), [ '---', 'name: rich-skill', 'description: rich description', 'whenToUse: For richer local parsing', 'disable-model-invocation: off', 'user-invocable: YES', 'metadata:', ' owner: tests', '---', '', 'Rich body.', ].join('\n')) await writeFile(join(root, 'bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad') await writeFile(join(root, 'missing-description.md'), '---\nname: missing-description\n---\n\nbad') await writeFile(join(root, 'no-frontmatter.md'), 'No frontmatter.') await writeFile(join(root, 'plain-markdown.md'), '# Notes\nNot a skill.') await writeFile(join(root, 'open-frontmatter.md'), '---\nname: open-frontmatter') await writeFile(join(root, 'non-object.md'), '---\n[]\n---\n\nbad') await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---') await writeFile(join(root, 'notes.txt'), 'ignored') await mkdir(join(root, 'not-a-skill'), { recursive: true }) await writeSkill(root, 'user-only-skill', 'user-only description', 'User-only.') 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') await writeSkill(root, 'model-only-skill', 'model-only description', 'Model-only.') 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') const ctx = await setupLocal(home) const listedBeforeDelete = await ctx.skills.list() const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill') if (flatSummary === undefined) throw new Error('expected flat-skill') await rm(join(root, 'flat-skill.md')) expect(listedBeforeDelete.map(skill => skill.name)).toEqual([ 'flat-skill', 'model-only-skill', 'no-trailing-body', 'rich-skill', 'user-only-skill', ]) expect(flatSummary.invocation).toEqual({ modelInvocable: true, userInvocable: true }) expect(await ctx.skills.get('flat-skill')).toBeUndefined() expect(await ctx.skills.get('no-trailing-body')).toMatchObject({ invocation: { modelInvocable: true, userInvocable: true }, }) expect(await ctx.skills.get('user-only-skill')).toMatchObject({ invocation: { modelInvocable: false, userInvocable: true }, content: 'User-only.', }) expect(await ctx.skills.get('model-only-skill')).toMatchObject({ invocation: { modelInvocable: true, userInvocable: false }, content: 'Model-only.', }) expect(await ctx.skills.get('rich-skill')).toMatchObject({ whenToUse: 'For richer local parsing', invocation: { modelInvocable: true, userInvocable: true }, metadata: { owner: 'tests' }, }) expect(await ctx.skills.get('Bad_Name')).toBeUndefined() }) it('accepts the documented boolean spellings for invocation frontmatter', async () => { const home = await tempDir('skill-invocation-booleans') const root = join(home, '.dsh/skills') await mkdir(root, { recursive: true }) const truthy = ['true', 'TRUE', '"true"', 'yes', 'ON', '1', '"1"'] const falsy = ['false', 'FALSE', '"false"', 'no', 'OFF', '0', '"0"'] for (const [index, value] of truthy.entries()) { await writeFile(join(root, `truthy-${index}.md`), [ '---', `name: truthy-${index}`, `description: Truthy ${index}`, `disable-model-invocation: ${value}`, '---', '', 'Truthy.', ].join('\n')) } for (const [index, value] of falsy.entries()) { await writeFile(join(root, `falsy-${index}.md`), [ '---', `name: falsy-${index}`, `description: Falsy ${index}`, `user-invocable: ${value}`, '---', '', 'Falsy.', ].join('\n')) } const ctx = await setupLocal(home) for (const [index] of truthy.entries()) { expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({ modelInvocable: false, userInvocable: true, }) } for (const [index] of falsy.entries()) { expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({ modelInvocable: true, userInvocable: false, }) } }) it('rejects legacy and invalid invocation frontmatter without hiding valid siblings', async () => { const home = await tempDir('skill-invalid-invocation') const root = join(home, '.dsh/skills') await writeSkill(root, 'good-skill', 'Good skill') const invalid = [ ['legacy-model', 'disableModelInvocation: true'], ['legacy-positive-model', 'modelInvocable: false'], ['legacy-user', 'userInvocable: false'], ['bad-string', 'disable-model-invocation: maybe'], ['bad-value', 'user-invocable: null'], ] as const for (const [name, field] of invalid) { await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`) } const ctx = await setupLocal(home) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill']) }) it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => { const home = await tempDir('skill-frontmatter-crlf') const root = join(home, '.dsh/skills') await mkdir(root, { recursive: true }) await writeFile(join(root, 'crlf-skill.md'), [ '---', 'name: crlf-skill', 'description: CRLF skill', 'metadata:', ' marker: "----"', '---', '', 'CRLF body.', ].join('\r\n')) await writeFile(join(root, 'block-skill.md'), [ '---', 'name: block-skill', 'description: |', ' Includes a ---- marker that is not a delimiter.', '---', '', 'Block body.', ].join('\n')) const ctx = await setupLocal(home) expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.') expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' }) expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n') expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.') }) it('skips invalid YAML skill files without hiding valid siblings', async () => { const home = await tempDir('skill-invalid-yaml') const root = join(home, '.dsh/skills') await writeSkill(root, 'good-skill', 'Good skill') await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n') const ctx = await setupLocal(home) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill']) }) it('discovers symlinked skill directories and flat files', async () => { const home = await tempDir('skill-symlink-home') const external = await tempDir('skill-symlink-external') await writeSkill(external, 'linked-dir', 'Linked directory') await writeFlatSkill(external, 'linked-flat', 'Linked flat') await mkdir(join(home, '.dsh/skills'), { recursive: true }) await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir')) await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md')) await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link')) await symlink('/dev/null', join(home, '.dsh/skills/device-link')) const ctx = await setupLocal(home) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat']) }) it('uses the filesystem service for discovery, reads, and project-root lookup', async () => { const home = await tempDir('skill-read-fs') const project = await tempDir('skill-project-root-backend') const nestedCwd = join(project, 'packages/app') const root = join(home, '.dsh/skills') await mkdir(nestedCwd, { recursive: true }) await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.') await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve body.') await writeFlatSkill(root, 'stat-fail', 'Stat fail', 'Stat body.') await mkdir(join(root, 'empty-dir'), { recursive: true }) await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true }) await writeFile(join(root, 'binary-skill.md'), Buffer.concat([ Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'), Buffer.from([0xff]), Buffer.from('\n'), ])) await writeSkill(join(project, '.agents/skills'), 'backend-root', 'Backend root skill') const ctx = new Context() await ctx.plugin(TestFileSystem) const fs = ctx.fs as TestFileSystem fs.failResolvePaths.add(join(root, 'resolve-fail.md')) fs.failStatPaths.add(join(root, 'stat-fail.md')) fs.failResolvePaths.add(join(nestedCwd, '.git')) fs.failStatPaths.add(join(project, 'packages/.git')) fs.statOverrides.set(join(project, '.git'), { version: FsVersion('virtual-git'), type: 'directory', size: 0, }) await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([ ['backend-root', 'project-agents'], ['text-skill', 'user-dsh'], ]) expect(fs.listDirCalls).toBeGreaterThan(0) expect(await ctx.skills.get('binary-skill')).toBeUndefined() const bundled = await tempDir('skill-backend-bundled') await writeSkill(bundled, 'bundled-host', 'Bundled host skill') const bundledCtx = new Context() await bundledCtx.plugin(TestFileSystem) const bundledFs = bundledCtx.fs as TestFileSystem bundledFs.failResolvePaths.add(bundled) await bundledCtx.plugin(SkillService) await bundledCtx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), bundledSkillDir: bundled, }) expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled') }) it('reports transient root reads as incomplete without caching an empty catalog', async () => { const home = await tempDir('skill-transient-root') const root = join(home, '.agents/skills') await writeSkill(root, 'stable-skill', 'Stable skill') const ctx = new Context() await ctx.plugin(TestFileSystem) const fs = ctx.fs as TestFileSystem await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false, }) expect(await ctx.skills.snapshot()).toMatchObject({ skills: [{ name: 'stable-skill' }], complete: true, }) fs.failListDirPaths.add(root) const path = join(root, 'stable-skill/SKILL.md') ctx.emit( 'fs/observed', { targetKey: path as never, displayPath: path }, FsVersion('failed-read'), { name: 'edit' }, ) expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) fs.failListDirPaths.clear() expect(await ctx.skills.snapshot()).toMatchObject({ skills: [{ name: 'stable-skill' }], complete: true, }) }) it('distinguishes transient filesystem entry failures from confirmed disappearance', async () => { const home = await tempDir('skill-transient-entry') const root = join(home, '.agents/skills') const path = join(root, 'stable-skill/SKILL.md') await writeSkill(root, 'stable-skill', 'Stable skill') const ctx = new Context() await ctx.plugin(TestFileSystem) const fs = ctx.fs as TestFileSystem await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false, }) const invalidate = (): void => { ctx.emit( 'fs/observed', { targetKey: path as never, displayPath: path }, FsVersion('entry-failure'), { name: 'write' }, ) } expect((await ctx.skills.snapshot()).complete).toBe(true) for (const failures of [fs.errorResolvePaths, fs.errorStatPaths, fs.errorReadPaths]) { failures.add(path) invalidate() expect((await ctx.skills.snapshot()).complete).toBe(false) failures.clear() } fs.missingReadPaths.add(path) invalidate() expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true }) fs.missingReadPaths.clear() invalidate() expect(await ctx.skills.snapshot()).toMatchObject({ skills: [{ name: 'stable-skill' }], complete: true, }) }) it('marks an unexpected native skill-file read failure incomplete', async () => { const home = await tempDir('skill-native-read-failure') const root = join(home, '.agents/skills') await mkdir(join(root, 'broken-skill/SKILL.md'), { recursive: true }) const ctx = await setupLocal(home) expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false }) }) it('forwards cancellation to filesystem reads while loading a skill', async () => { const home = await tempDir('skill-read-abort') await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill') const ctx = new Context() await ctx.plugin(TestFileSystem) const fs = ctx.fs as TestFileSystem await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill']) fs.statSignals = [] fs.readTextSignals = [] const started = Promise.withResolvers() fs.readTextOverride = async (_target, signal) => { if (signal === undefined) throw new Error('expected the skill lookup signal') started.resolve(undefined) return await new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { const abortReason = signal.reason as unknown reject(abortReason instanceof Error ? abortReason : new Error(String(abortReason))) }, { once: true }) }) } const controller = new AbortController() const reason = new Error('turn cancelled') const loading = ctx.skills.get('abortable-skill', { signal: controller.signal }) await started.promise controller.abort(reason) await expect(loading).rejects.toBe(reason) expect(fs.statSignals).toEqual([controller.signal]) expect(fs.readTextSignals).toEqual([controller.signal]) }) it('refreshes additions, metadata changes, deletions, and a recreated missing root', { timeout: 20000 }, async () => { const home = await tempDir('skill-watch-home') const agentsRoot = join(home, '.agents/skills') const ctx = new Context() await ctx.plugin(SkillService) const fiber = await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: true, watchStabilityThresholdMs: 20, watchPollIntervalMs: 10, }) try { expect(await ctx.skills.list()).toEqual([]) await writeSkill(agentsRoot, 'watched-skill', 'First description', 'First body.') const added = await waitFor( async () => await ctx.skills.list(), skills => skills.some(skill => skill.name === 'watched-skill'), ) expect(added.find(skill => skill.name === 'watched-skill')?.description).toBe('First description') await writeSkill(agentsRoot, 'watched-skill', 'Second description', 'Second body.') const changed = await waitFor( async () => await ctx.skills.list(), skills => skills.find(skill => skill.name === 'watched-skill')?.description === 'Second description', ) expect(changed).toHaveLength(1) expect((await ctx.skills.get('watched-skill'))?.content).toBe('Second body.') await writeFlatSkill(agentsRoot, 'flat-added', 'Flat added') expect(await waitFor( async () => (await ctx.skills.list()).map(skill => skill.name), names => names.includes('flat-added'), )).toEqual(['flat-added', 'watched-skill']) await rename(join(agentsRoot, 'watched-skill'), join(agentsRoot, 'renamed-skill')) await writeSkill(agentsRoot, 'renamed-skill', 'Renamed skill') expect(await waitFor( async () => (await ctx.skills.list()).map(skill => skill.name), names => names.includes('renamed-skill') && !names.includes('watched-skill'), )).toEqual(['flat-added', 'renamed-skill']) await rm(join(agentsRoot, 'renamed-skill'), { recursive: true }) expect(await waitFor( async () => (await ctx.skills.list()).map(skill => skill.name), names => !names.includes('renamed-skill'), )).toEqual(['flat-added']) await rm(join(home, '.agents'), { recursive: true }) expect(await waitFor( async () => await ctx.skills.list(), skills => skills.length === 0, )).toEqual([]) await writeSkill(agentsRoot, 'recreated-skill', 'Recreated') expect(await waitFor( async () => (await ctx.skills.list()).map(skill => skill.name), names => names.includes('recreated-skill'), )).toEqual(['recreated-skill']) } finally { await fiber.dispose() } }) it('uses fs/observed as a synchronous first-party invalidation path without a watcher', async () => { const home = await tempDir('skill-observed-home') const root = join(home, '.agents/skills') const ctx = await setupLocal(home) expect(await ctx.skills.list()).toEqual([]) let invalidations = 0 ctx.on('skills/change', () => { invalidations += 1 }) await writeSkill(root, 'observed-skill', 'Observed skill') const path = join(root, 'observed-skill/SKILL.md') const emitObserved = (displayPath: string, actor?: object): void => { ctx.emit( 'fs/observed', { targetKey: displayPath as never, displayPath }, FsVersion('observed'), actor, ) } emitObserved(path) emitObserved(path, {}) emitObserved(path, { name: 'read' }) emitObserved(join(home, 'outside.md'), { name: 'write' }) emitObserved(root, { name: 'write' }) emitObserved(join(root, 'observed-skill/references/notes.md'), { name: 'write' }) emitObserved(join(home, '.dsh/skills/.system/SKILL.md'), { name: 'write' }) emitObserved(join(root, 'flat-skill.md'), { name: 'write' }) ctx.emit( 'fs/observed', { targetKey: path as never, displayPath: path }, FsVersion('observed'), { name: 'edit' }, ) expect(invalidations).toBe(2) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['observed-skill']) }) it('bounds project watchers and re-observes an evicted project on its next lookup', async () => { const home = await tempDir('skill-watch-lru-home') const first = await tempDir('skill-watch-lru-first') const second = await tempDir('skill-watch-lru-second') await mkdir(join(first, '.git'), { recursive: true }) await mkdir(join(second, '.git'), { recursive: true }) await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project') await writeSkill(join(second, '.agents/skills'), 'second-project', 'Second project') const ctx = new Context() await ctx.plugin(SkillService) const fiber = await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), customSkillDirs: [join(first, '.agents/skills')], watch: true, watchMaxProjects: 1, watchStabilityThresholdMs: 20, watchPollIntervalMs: 10, }) try { expect((await ctx.skills.list({ cwd: first })).map(skill => skill.name)).toContain('first-project') expect((await ctx.skills.list({ cwd: second })).map(skill => skill.name)).toContain('second-project') await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project refreshed') expect((await ctx.skills.list({ cwd: first })).find(skill => skill.name === 'first-project')?.description) .toBe('First project refreshed') } finally { await fiber.dispose() } const noWatch = new Context() await noWatch.plugin(SkillService) await noWatch.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false, watchMaxProjects: 1, }) await noWatch.skills.list({ cwd: first }) await noWatch.skills.list({ cwd: second }) }) it('contains repeated disposal and late first-party observations', async () => { const home = await tempDir('skill-watch-dispose') const nonDirectoryRoot = join(home, 'not-a-directory') await writeFile(nonDirectoryRoot, 'not a skill root') await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill') const ctx = new Context() await ctx.plugin(SkillService) let provider!: SkillLocal.LocalSkillProvider const disposeProvider = ctx.skills.registerProvider((control) => { provider = new SkillLocal.LocalSkillProvider(ctx, control, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), customSkillDirs: [nonDirectoryRoot], watch: true, watchStabilityThresholdMs: 20, watchPollIntervalMs: 10, }) return provider }) const beforeDisposal = await provider.list({}) expect((Array.isArray(beforeDisposal) ? beforeDisposal : beforeDisposal.candidates).map(skill => skill.name)) .toEqual(['disposed-skill']) await provider.dispose() await provider.dispose() provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md')) const afterDisposal = await provider.list({}) expect((Array.isArray(afterDisposal) ? afterDisposal : afterDisposal.candidates).map(skill => skill.name)) .toEqual(['disposed-skill']) disposeProvider() }) it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => { const home = await tempDir('skill-watch-symlink-home') const external = await tempDir('skill-watch-symlink-external') const root = join(home, '.dsh/skills') await writeSkill(external, 'linked-skill', 'First linked description') await mkdir(root, { recursive: true }) await symlink(join(external, 'linked-skill'), join(root, 'linked-skill')) const ctx = new Context() await ctx.plugin(SkillService) const fiber = await ctx.plugin(SkillLocal, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: true, watchFollowSymlinks: true, watchStabilityThresholdMs: 20, watchPollIntervalMs: 10, }) try { expect((await ctx.skills.list())[0]?.description).toBe('First linked description') await writeSkill(external, 'linked-skill', 'Second linked description') const refreshed = await waitFor( async () => await ctx.skills.list(), skills => skills[0]?.description === 'Second linked description', ) expect(refreshed[0]?.name).toBe('linked-skill') } finally { await fiber.dispose() } }) it('validates watcher tunables at plugin load', async () => { const ctx = new Context() await ctx.plugin(SkillService) await expect(ctx.plugin(SkillLocal, { watchMaxProjects: 0 })).rejects.toThrow('watchMaxProjects') await expect(ctx.plugin(SkillLocal, { watchPollIntervalMs: 1.5 })).rejects.toThrow('watchPollIntervalMs') await expect(ctx.plugin(SkillLocal, { watchStabilityThresholdMs: 0 })).rejects.toThrow('watchStabilityThresholdMs') }) it('uses default home root resolution without exposing builtin skills', async () => { const previousDshHome = process.env.DSH_HOME const previousAgentsHome = process.env.DSH_AGENTS_HOME const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR const envHome = await tempDir('skill-env-home') try { process.env.DSH_HOME = join(envHome, '.dsh') process.env.DSH_AGENTS_HOME = join(envHome, '.agents') const bundled = join(envHome, 'bundled-skills') process.env.DSH_BUNDLED_SKILL_DIR = bundled await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill') await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill') const ctx = new Context() await ctx.plugin(SkillService) await ctx.plugin(SkillLocal, { watch: false }) expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill']) // Isolated providers see only their explicit roots: the environment // bundled root is a default root, so includeDefaultRoots: false must // drop it — repository providers never re-claim the app's builtins. const isolated = new Context() await isolated.plugin(SkillService) const customOnly = join(envHome, 'custom-only') await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill') await isolated.plugin(SkillLocal, { providerName: 'isolated', includeDefaultRoots: false, customSkillDirs: [customOnly], watch: false, }) expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill']) await isolated.fiber.dispose() process.env.DSH_HOME = join(envHome, 'empty-dsh') delete process.env.DSH_BUNDLED_SKILL_DIR process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents') const empty = new Context() await empty.plugin(SkillService) SkillLocal.apply(empty, { watch: false }) expect(await empty.skills.list()).toEqual([]) delete process.env.DSH_AGENTS_HOME expect(new SkillLocal.LocalSkillProvider(empty, { signal: new AbortController().signal, invalidate() {}, }, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local') } finally { if (previousDshHome === undefined) { delete process.env.DSH_HOME } else { process.env.DSH_HOME = previousDshHome } if (previousAgentsHome === undefined) { delete process.env.DSH_AGENTS_HOME } else { process.env.DSH_AGENTS_HOME = previousAgentsHome } if (previousBundledSkillDir === undefined) { delete process.env.DSH_BUNDLED_SKILL_DIR } else { process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir } } }) })