Skip to content

Commit e65d881

Browse files
committed
feat(kit): enhance FsSkillStorage with entry existence check and resource summary listing
1 parent 14b4e54 commit e65d881

3 files changed

Lines changed: 142 additions & 41 deletions

File tree

packages/kit/src/skills/storage/fs.ts

Lines changed: 82 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '../loader/utils'
1414
import type { SkillDefinition, SkillResourceDescriptor } from '../types'
1515
import { createImportSkill } from './importSkill'
16-
import type { SkillStorage } from './types'
16+
import type { SkillStorage, SkillSummary } from './types'
1717

1818
/** 一个标准 skill 目录集合的文件系统 storage。 */
1919
export interface FsSkillStorageOptions {
@@ -97,7 +97,17 @@ export class FsSkillStorage implements SkillStorage<SkillLoadOptions> {
9797
}
9898

9999
async has(name: string) {
100-
return Boolean(await this.get(name))
100+
const entryPath = join(this.getSkillDirectory(name), entryFile)
101+
102+
try {
103+
return (await stat(entryPath)).isFile()
104+
} catch (error) {
105+
if (isFileNotFoundError(error)) {
106+
return false
107+
}
108+
109+
throw error
110+
}
101111
}
102112

103113
async delete(name: string) {
@@ -130,17 +140,33 @@ export class FsSkillStorage implements SkillStorage<SkillLoadOptions> {
130140
const summaries = await Promise.all(
131141
entries
132142
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
133-
.map(async (entry) => this.get(entry.name)),
143+
.map(async (entry): Promise<SkillSummary | undefined> => {
144+
const directory = this.getSkillDirectory(entry.name)
145+
const skill = await this.readSkillEntry(directory).catch((error: unknown) => {
146+
if (isFileNotFoundError(error)) {
147+
return undefined
148+
}
149+
150+
throw error
151+
})
152+
153+
if (!skill) {
154+
return undefined
155+
}
156+
157+
const resourceCount = (await this.readResourceFiles(directory)).length
158+
159+
return {
160+
name: skill.name,
161+
description: skill.description,
162+
resourceCount,
163+
metadata: skill.metadata,
164+
}
165+
}),
134166
)
135167

136168
return summaries
137-
.filter((skill): skill is SkillDefinition => Boolean(skill))
138-
.map((skill) => ({
139-
name: skill.name,
140-
description: skill.description,
141-
resourceCount: skill.resources?.length ?? 0,
142-
metadata: skill.metadata,
143-
}))
169+
.filter((summary): summary is SkillSummary => Boolean(summary))
144170
.sort((a, b) => a.name.localeCompare(b.name))
145171
}
146172

@@ -162,6 +188,16 @@ export class FsSkillStorage implements SkillStorage<SkillLoadOptions> {
162188
}
163189

164190
private async readSkillDirectory(directory: string): Promise<SkillDefinition> {
191+
const skill = await this.readSkillEntry(directory)
192+
const resources = await this.readResourceDescriptors(directory)
193+
194+
return {
195+
...skill,
196+
resources: resources.length ? resources : undefined,
197+
}
198+
}
199+
200+
private async readSkillEntry(directory: string) {
165201
const entryPath = join(directory, entryFile)
166202
const entryContent = await readFile(entryPath, 'utf8')
167203
const { frontmatter, body } = parseMarkdownFrontmatter(entryContent)
@@ -171,13 +207,10 @@ export class FsSkillStorage implements SkillStorage<SkillLoadOptions> {
171207
throw new Error(`Skill entry file "${entryFile}" must contain instructions.`)
172208
}
173209

174-
const resources = await this.readResourceDescriptors(directory)
175-
176210
return {
177211
name: getString(frontmatter.name) || directory.split(/[\\/]/).at(-1) || '',
178212
description: getString(frontmatter.description) || '',
179213
instructions,
180-
resources: resources.length ? resources : undefined,
181214
metadata: {
182215
...getRecord(frontmatter.metadata),
183216
...(getString(frontmatter.homepage) ? { homepage: getString(frontmatter.homepage) } : {}),
@@ -188,6 +221,40 @@ export class FsSkillStorage implements SkillStorage<SkillLoadOptions> {
188221
private async readResourceDescriptors(directory: string) {
189222
const resources: SkillResourceDescriptor[] = []
190223

224+
for (const { fullPath, path } of await this.readResourceFiles(directory)) {
225+
const fileStat = await stat(fullPath)
226+
const kind = isTextSkillFilePath(path) ? 'text' : 'binary'
227+
const base = {
228+
path,
229+
kind,
230+
resourceId: path,
231+
size: fileStat.size,
232+
lastModified: fileStat.mtimeMs,
233+
}
234+
235+
resources.push(
236+
kind === 'text'
237+
? {
238+
...base,
239+
kind,
240+
readText: async () => readFile(fullPath, 'utf8'),
241+
readBinary: async () => new Uint8Array(await readFile(fullPath)),
242+
}
243+
: {
244+
...base,
245+
kind,
246+
readBinary: async () => new Uint8Array(await readFile(fullPath)),
247+
readText: async () => new TextDecoder().decode(await readFile(fullPath)),
248+
},
249+
)
250+
}
251+
252+
return resources
253+
}
254+
255+
private async readResourceFiles(directory: string) {
256+
const files: Array<{ fullPath: string; path: string }> = []
257+
191258
const walk = async (currentDirectory: string) => {
192259
const entries = await readdir(currentDirectory, {
193260
withFileTypes: true,
@@ -214,36 +281,12 @@ export class FsSkillStorage implements SkillStorage<SkillLoadOptions> {
214281
continue
215282
}
216283

217-
const fileStat = await stat(fullPath)
218-
const kind = isTextSkillFilePath(path) ? 'text' : 'binary'
219-
const base = {
220-
path,
221-
kind,
222-
resourceId: path,
223-
size: fileStat.size,
224-
lastModified: fileStat.mtimeMs,
225-
}
226-
227-
resources.push(
228-
kind === 'text'
229-
? {
230-
...base,
231-
kind,
232-
readText: async () => readFile(fullPath, 'utf8'),
233-
readBinary: async () => new Uint8Array(await readFile(fullPath)),
234-
}
235-
: {
236-
...base,
237-
kind,
238-
readBinary: async () => new Uint8Array(await readFile(fullPath)),
239-
readText: async () => new TextDecoder().decode(await readFile(fullPath)),
240-
},
241-
)
284+
files.push({ fullPath, path })
242285
}
243286
}
244287

245288
await walk(directory)
246-
return resources.sort((a, b) => a.path.localeCompare(b.path))
289+
return files.sort((a, b) => a.path.localeCompare(b.path))
247290
}
248291

249292
private async writeResource(directory: string, resource: SkillResourceDescriptor) {

packages/kit/src/skills/storage/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface SkillSummary {
2020
export interface SkillStorage<TImportOptions> {
2121
add(skill: SkillDefinition): Promise<SkillDefinition>
2222
get(name: string): Promise<SkillDefinition | undefined>
23+
/** 判断 storage entry 是否存在,不验证内容是否可被完整读取。 */
2324
has(name: string): Promise<boolean>
2425
delete(name: string): Promise<boolean>
2526
list(): Promise<SkillSummary[]>

packages/kit/src/skills/test/fsStorage.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cp, mkdtemp, readFile, rename, writeFile } from 'node:fs/promises'
1+
import { cp, mkdir, mkdtemp, readFile, rename, stat, writeFile } from 'node:fs/promises'
22
import { basename, join } from 'node:path'
33
import { tmpdir } from 'node:os'
44
import { fileURLToPath } from 'node:url'
@@ -11,15 +11,18 @@ vi.mock('node:fs/promises', async (importOriginal) => {
1111
return {
1212
...fs,
1313
rename: vi.fn(fs.rename),
14+
stat: vi.fn(fs.stat),
1415
}
1516
})
1617

17-
const { rename: actualRename } = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
18+
const { rename: actualRename, stat: actualStat } =
19+
await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
1820
const createTempRoot = () => mkdtemp(join(tmpdir(), 'tiny-robot-skill-storage-'))
1921

2022
describe('FsSkillStorage', () => {
2123
afterEach(() => {
2224
vi.mocked(rename).mockReset().mockImplementation(actualRename)
25+
vi.mocked(stat).mockReset().mockImplementation(actualStat)
2326
})
2427

2528
it.each(['.', '.hidden'])('rejects invalid skill name %s', async (name) => {
@@ -242,6 +245,60 @@ describe('FsSkillStorage', () => {
242245
).toThrow(readonlyError)
243246
})
244247

248+
it('checks entry existence without parsing and deletes a corrupt skill', async () => {
249+
const root = await createTempRoot()
250+
const directory = join(root, 'broken')
251+
const storage = createFsSkillStorage({ root })
252+
await mkdir(directory)
253+
await writeFile(
254+
join(directory, 'SKILL.md'),
255+
['---', 'name: broken', 'description: Broken skill', '---', ''].join('\n'),
256+
'utf8',
257+
)
258+
259+
await expect(storage.has('broken')).resolves.toBe(true)
260+
await expect(storage.get('broken')).rejects.toThrow('must contain instructions')
261+
await expect(storage.list()).rejects.toThrow('must contain instructions')
262+
await expect(storage.delete('broken')).resolves.toBe(true)
263+
await expect(storage.has('broken')).resolves.toBe(false)
264+
})
265+
266+
it('lists summaries with exact resource counts without statting each resource', async () => {
267+
const root = await createTempRoot()
268+
const storage = createFsSkillStorage({ root })
269+
await storage.add({
270+
name: 'demo',
271+
description: 'Demo skill',
272+
instructions: '# Demo',
273+
resources: [
274+
{
275+
path: 'guide.md',
276+
kind: 'text',
277+
resourceId: 'guide.md',
278+
text: '# Guide',
279+
},
280+
{
281+
path: 'references/nested.md',
282+
kind: 'text',
283+
resourceId: 'references/nested.md',
284+
text: '# Nested',
285+
},
286+
],
287+
})
288+
await mkdir(join(root, 'unrelated'))
289+
vi.mocked(stat).mockClear()
290+
291+
await expect(storage.list()).resolves.toEqual([
292+
{
293+
name: 'demo',
294+
description: 'Demo skill',
295+
resourceCount: 2,
296+
metadata: {},
297+
},
298+
])
299+
expect(stat).not.toHaveBeenCalled()
300+
})
301+
245302
it('lists existing skill directories, imports another skill, and deletes skills', async () => {
246303
const root = await createTempRoot()
247304
const weatherRoot = fileURLToPath(new URL('./.cache/weather', import.meta.url))

0 commit comments

Comments
 (0)