fix: list skills through fs seam
This commit is contained in:
@@ -15,7 +15,7 @@ import { Context, Service } from 'cordis'
|
|||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import type Schema from 'schemastery'
|
import type Schema from 'schemastery'
|
||||||
import { parse as parseYaml } from 'yaml'
|
import { parse as parseYaml } from 'yaml'
|
||||||
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
|
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
|
||||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||||
import type {} from '@deepseek-ai/dsh-agent'
|
import type {} from '@deepseek-ai/dsh-agent'
|
||||||
|
|
||||||
@@ -338,6 +338,47 @@ function renderSkillFile(skill: SkillDefinition): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinition[]> {
|
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinition[]> {
|
||||||
|
const skills: SkillDefinition[] = []
|
||||||
|
const entries = await listSkillRootEntries(root, ctx)
|
||||||
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
if (root.skipSystem && entry.name === '.system') continue
|
||||||
|
const parsed = entry.type === 'directory'
|
||||||
|
? await parseSkillFile(join(entry.path, 'SKILL.md'), entry.path, root.source, ctx)
|
||||||
|
: entry.type === 'file' && entry.name.endsWith('.md')
|
||||||
|
? await parseSkillFile(entry.path, root.path, root.source, ctx)
|
||||||
|
: undefined
|
||||||
|
if (parsed) skills.push(parsed)
|
||||||
|
}
|
||||||
|
return skills
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillRootEntry {
|
||||||
|
name: string
|
||||||
|
type: 'directory' | 'file' | 'other'
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||||
|
const fs = optionalFileSystem(ctx)
|
||||||
|
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
|
||||||
|
return await listSkillRootEntriesFromNode(root, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
|
||||||
|
try {
|
||||||
|
const target = await fs.resolve(root.path)
|
||||||
|
const entries = await fs.listDir(target)
|
||||||
|
return entries.map(entryFromFs)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryFromFs(entry: FsDirEntry): SkillRootEntry {
|
||||||
|
return { name: entry.name, type: entry.type, path: entry.target.displayPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||||
let entries
|
let entries
|
||||||
try {
|
try {
|
||||||
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
|
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
|
||||||
@@ -345,19 +386,13 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillDefinit
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const skills: SkillDefinition[] = []
|
const result: SkillRootEntry[] = []
|
||||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
for (const entry of entries) {
|
||||||
if (root.skipSystem && entry.name === '.system') continue
|
const path = join(root.path, entry.name)
|
||||||
const fullPath = join(root.path, entry.name)
|
const type = await nodeEntryKind(path, entry, ctx)
|
||||||
const kind = await entryKind(fullPath, entry, ctx)
|
result.push({ name: entry.name, type: type ?? 'other', path })
|
||||||
const parsed = kind === 'directory'
|
|
||||||
? await parseSkillFile(join(fullPath, 'SKILL.md'), fullPath, root.source, ctx)
|
|
||||||
: kind === 'file' && entry.name.endsWith('.md')
|
|
||||||
? await parseSkillFile(fullPath, root.path, root.source, ctx)
|
|
||||||
: undefined
|
|
||||||
if (parsed) skills.push(parsed)
|
|
||||||
}
|
}
|
||||||
return skills
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | undefined> {
|
async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | undefined> {
|
||||||
@@ -456,7 +491,7 @@ function fsReadErrorMessage(target: FsTarget, error: unknown): string {
|
|||||||
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
|
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function entryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
|
async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
|
||||||
if (entry.isDirectory()) return 'directory'
|
if (entry.isDirectory()) return 'directory'
|
||||||
if (entry.isFile()) return 'file'
|
if (entry.isFile()) return 'file'
|
||||||
/* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
|
/* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises'
|
import { mkdir, readdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import SkillService from '@deepseek-ai/dsh-skill'
|
import SkillService from '@deepseek-ai/dsh-skill'
|
||||||
import { FileSystem, FsVersion, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
import { FileSystem, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
|
||||||
|
|
||||||
async function tempDir(name: string): Promise<string> {
|
async function tempDir(name: string): Promise<string> {
|
||||||
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
|
||||||
@@ -22,6 +22,8 @@ async function writeFlatSkill(root: string, name: string, description: string, b
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TestFileSystem extends FileSystem {
|
class TestFileSystem extends FileSystem {
|
||||||
|
listDirCalls = 0
|
||||||
|
|
||||||
override async resolve(path: string): Promise<FsTarget> {
|
override async resolve(path: string): Promise<FsTarget> {
|
||||||
return { targetKey: path as never, displayPath: path }
|
return { targetKey: path as never, displayPath: path }
|
||||||
}
|
}
|
||||||
@@ -50,8 +52,30 @@ class TestFileSystem extends FileSystem {
|
|||||||
throw new Error('not needed in skill tests')
|
throw new Error('not needed in skill tests')
|
||||||
}
|
}
|
||||||
|
|
||||||
override async listDir(): Promise<never> {
|
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
|
||||||
throw new Error('not needed in skill tests')
|
this.listDirCalls += 1
|
||||||
|
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<FsWriteOutcome> {
|
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
|
||||||
@@ -412,9 +436,11 @@ describe('SkillService', () => {
|
|||||||
|
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(TestFileSystem)
|
await ctx.plugin(TestFileSystem)
|
||||||
|
const fs = ctx.fs as TestFileSystem
|
||||||
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
|
||||||
|
|
||||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])
|
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['text-skill'])
|
||||||
|
expect(fs.listDirCalls).toBeGreaterThan(0)
|
||||||
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user