From 77ab82a94c2f98908a4f46ed2bfb61eb65ecfa9a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 5 Jul 2026 18:38:44 +0800 Subject: [PATCH] fix: list skills through fs seam --- packages/core/skill/src/index.ts | 63 +++++++++++++++++++------ packages/core/skill/tests/skill.spec.ts | 34 +++++++++++-- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/core/skill/src/index.ts b/packages/core/skill/src/index.ts index 39c656eac8..d2a228dc1c 100644 --- a/packages/core/skill/src/index.ts +++ b/packages/core/skill/src/index.ts @@ -15,7 +15,7 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type Schema from 'schemastery' 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 {} from '@deepseek-ai/dsh-agent' @@ -338,6 +338,47 @@ function renderSkillFile(skill: SkillDefinition): string { } async function discoverRoot(root: SkillRoot, ctx: Context): Promise { + 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 { + 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 { + 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 { let entries try { entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' }) @@ -345,19 +386,13 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise a.name.localeCompare(b.name))) { - if (root.skipSystem && entry.name === '.system') continue - const fullPath = join(root.path, entry.name) - const kind = await entryKind(fullPath, entry, ctx) - 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) + const result: SkillRootEntry[] = [] + for (const entry of entries) { + const path = join(root.path, entry.name) + const type = await nodeEntryKind(path, entry, ctx) + result.push({ name: entry.name, type: type ?? 'other', path }) } - return skills + return result } async function parseSkillFile(path: string, directory: string, source: SkillSource, ctx: Context): Promise { @@ -456,7 +491,7 @@ function fsReadErrorMessage(target: FsTarget, error: unknown): string { 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.isFile()) return 'file' /* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */ diff --git a/packages/core/skill/tests/skill.spec.ts b/packages/core/skill/tests/skill.spec.ts index 8fdb6116ed..be16483a1e 100644 --- a/packages/core/skill/tests/skill.spec.ts +++ b/packages/core/skill/tests/skill.spec.ts @@ -1,10 +1,10 @@ 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 { tmpdir } from 'node:os' import { Context } from 'cordis' 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 { 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 { + listDirCalls = 0 + override async resolve(path: string): Promise { return { targetKey: path as never, displayPath: path } } @@ -50,8 +52,30 @@ class TestFileSystem extends FileSystem { throw new Error('not needed in skill tests') } - override async listDir(): Promise { - throw new Error('not needed in skill tests') + override async listDir(target: FsTarget): Promise { + 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 { @@ -412,9 +436,11 @@ describe('SkillService', () => { const ctx = new Context() await ctx.plugin(TestFileSystem) + const fs = ctx.fs as TestFileSystem 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(fs.listDirCalls).toBeGreaterThan(0) expect(await ctx.skills.get('binary-skill')).toBeUndefined() })