feat(skill): move catalogs into session prefixes

This commit is contained in:
Yichen Jiang
2026-07-10 14:19:06 +08:00
parent b9bf67d0a7
commit 6292d52236
54 changed files with 1019 additions and 691 deletions
+37
View File
@@ -0,0 +1,37 @@
# @deepseek-ai/dsh-skill-local
Local filesystem provider for the `ctx.skills` registry.
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the session-prefix catalog and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
## Plugin
Requires `ctx.skills` (`inject: ['skills']`).
### Config
| Field | Default | Meaning |
|---|---|---|
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; scans `skills` under this directory. |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
## Discovery
Default roots are resolved in this provider's rank order:
| Rank | Source | Path |
|---|---|---|
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
| 300 | `custom` | `Config.customSkillDirs` |
| 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not accidentally treated as normal user skills. DeepSeek Harness no longer ships built-in system skills from this provider; additional built-ins can be supplied later by another provider.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Without a filesystem service, the provider falls back to Node filesystem I/O so minimal local contexts can still load skills. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
## Skill Format
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@deepseek-ai/dsh-skill-local",
"description": "Local filesystem skill provider for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0",
"yaml": "^2.4.2"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+413
View File
@@ -0,0 +1,413 @@
/**
* Local filesystem skill provider.
*
* This package is one implementation of the `ctx.skills` provider registry. It
* discovers directory-bundle and flat Markdown skills from project, custom, and
* user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
* filesystem service is present.
*
* @module @deepseek-ai/dsh-skill-local
*/
import { access, readdir, readFile, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import type { Context } from 'cordis'
import z from 'schemastery'
import type Schema from 'schemastery'
import { parse as parseYaml } from 'yaml'
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
import {
isSkillName,
type SkillCandidate,
type SkillDefinition,
type SkillLookupOptions,
type SkillProvider,
type SkillSource,
} from '@deepseek-ai/dsh-skill'
const PROJECT_DSH_RANK = 100
const PROJECT_AGENTS_RANK = 200
const CUSTOM_RANK = 300
const USER_DSH_RANK = 400
const USER_AGENTS_RANK = 500
export const name = 'skill-local'
export const inject = ['skills']
/** Local filesystem skill provider configuration. */
export interface Config {
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
dshHome?: string
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
agentsHome?: string
/** Additional skill roots scanned after project roots and before user roots. */
customSkillDirs?: string[]
}
export const Config: Schema<Config> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
customSkillDirs: z.array(z.string()).default([]),
})
interface SkillRoot {
path: string
source: SkillSource
rank: number
skipSystem?: boolean
}
interface SkillRootEntry {
name: string
type: 'directory' | 'file' | 'other'
path: string
}
interface ParsedSkill {
name: string
description: string
whenToUse?: string
disableModelInvocation?: boolean
metadata?: Record<string, unknown>
content: string
}
interface LocalLocator {
path: string
directory: string
}
/** Register the local filesystem skill provider on `ctx.skills`. */
export function apply(ctx: Context, config: Config = {}): void {
const provider = new LocalSkillProvider(ctx, config)
ctx.skills.registerProvider(provider)
}
/** Provider that maps local project/user skill roots into `ctx.skills`. */
export class LocalSkillProvider implements SkillProvider {
readonly name = 'local'
private readonly dshHome: string
private readonly agentsHome: string
private readonly customSkillDirs: string[]
constructor(private readonly ctx: Context, config: Config = {}) {
this.dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'))
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
}
/**
* Discover local skill summaries for a cwd-sensitive workspace.
* @param options - lookup options; `cwd` selects the project roots to scan.
* @returns local provider candidates with stable root ranks.
*/
async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
const roots = await this.roots(options.cwd)
const candidates: SkillCandidate[] = []
for (const root of roots) {
for (const skill of await discoverRoot(root, this.ctx)) {
candidates.push(skill)
}
}
return candidates
}
/**
* Load a complete local skill body from the candidate's file locator.
* @param candidate - the winning candidate returned by this provider.
* @returns the full local skill, or `undefined` if the file disappeared.
*/
async get(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
const locator = candidate.locator as LocalLocator
const parsed = await parseSkillFile(locator.path, this.ctx)
if (parsed === undefined) return undefined
return {
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
source: candidate.source,
provider: this.name,
resourceBase: { kind: 'directory', path: locator.directory },
path: locator.path,
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
content: parsed.content,
}
}
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
const roots: SkillRoot[] = []
if (cwd !== undefined) {
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
roots.push(
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK },
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK },
)
}
roots.push(
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
)
return roots
}
}
async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandidate[]> {
const skills: SkillCandidate[] = []
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 locator = entry.type === 'directory'
? { path: join(entry.path, 'SKILL.md'), directory: entry.path }
: entry.type === 'file' && entry.name.endsWith('.md')
? { path: entry.path, directory: root.path }
: undefined
if (locator === undefined) continue
const parsed = await parseSkillFile(locator.path, ctx)
if (parsed === undefined) continue
skills.push({
name: parsed.name,
description: parsed.description,
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
provider: 'local',
source: root.source,
rank: root.rank,
locator,
resourceBase: { kind: 'directory', path: locator.directory },
path: locator.path,
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
})
}
return skills
}
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[]> {
// Skill roots are optional; an absent or unlistable root contributes no skills.
const entries = await fsListDir(fs, root.path).catch(() => undefined)
return entries === undefined ? [] : entries.map(entryFromFs)
}
async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {
const target = await fs.resolve(path)
return await fs.listDir(target)
}
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
try {
entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
} catch {
// Missing or unreadable local skill roots are expected in most deployments.
return []
}
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 result
}
async function parseSkillFile(path: string, ctx: Context): Promise<ParsedSkill | undefined> {
const raw = await readSkillText(ctx, path)
if (raw === undefined) {
return undefined
}
let parsed
try {
parsed = parseFrontmatter(raw)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
return undefined
}
if (!parsed) {
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
return undefined
}
const name = stringField(parsed.data, 'name')
const description = stringField(parsed.data, 'description')
if (name === undefined || description === undefined) {
ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`)
return undefined
}
if (!isSkillName(name)) {
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
return undefined
}
return {
name,
description,
...optionalString(parsed.data, 'whenToUse'),
...optionalBoolean(parsed.data, 'disableModelInvocation'),
...optionalMetadata(parsed.data),
content: parsed.body.trim(),
}
}
function optionalFileSystem(ctx: Context): FileSystem | undefined {
return ctx.get('fs')
}
async function readSkillText(ctx: Context, path: string): Promise<string | undefined> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
return await readSkillTextFromFileSystem(ctx, fs, path)
}
try {
return await readFile(path, 'utf8')
} catch {
return undefined
}
}
async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string): Promise<string | undefined> {
// A missing or temporarily inaccessible skill file is not fatal to discovery.
const target = await fs.resolve(path).catch(() => undefined)
if (target === undefined) return undefined
const info = await fs.stat(target).catch((error: unknown) => {
ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`)
return undefined
})
if (info === undefined || info.type !== 'file') return undefined
try {
return await fs.readText(target)
} catch (error) {
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
return undefined
}
}
function fsReadErrorMessage(target: FsTarget, error: unknown): string {
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
}
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. */
if (!entry.isSymbolicLink()) return undefined
try {
const info = await stat(fullPath)
if (info.isDirectory()) return 'directory'
if (info.isFile()) return 'file'
return undefined
} catch (error) {
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
return undefined
}
}
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
const firstLineEnd = raw.indexOf('\n')
if (firstLineEnd < 0) return undefined
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
if (firstLine !== '---') return undefined
const start = firstLineEnd + 1
const closing = findClosingFrontmatter(raw, start)
if (closing === undefined) return undefined
const yaml = raw.slice(start, closing.start)
const parsed = parseYaml(yaml) as unknown
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
}
function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
let lineStart = start
while (lineStart <= raw.length) {
const nextNewline = raw.indexOf('\n', lineStart)
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
if (line === '---') {
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
}
if (nextNewline < 0) return undefined
lineStart = nextNewline + 1
}
}
async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
let current = cwd
while (true) {
if (await pathExists(join(current, '.git'), fs)) {
return current
}
const parent = dirname(current)
if (parent === current) return cwd
current = parent
}
}
async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
if (fs !== undefined) {
return await pathExistsInFileSystem(path, fs)
}
return await pathExistsInNode(path)
}
async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
let target
try {
target = await fs.resolve(path)
} catch {
// A backend may reject or hide this candidate; continue walking upward.
return false
}
try {
return await fs.stat(target) !== undefined
} catch {
// Transient stat failures make only this git-root candidate unusable.
return false
}
}
async function pathExistsInNode(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
// Missing host paths are expected while walking toward the filesystem root.
return false
}
}
function stringField(data: Record<string, unknown>, key: string): string | undefined {
const value = data[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
const value = data[key]
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
}
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
const value = data[key]
return typeof value === 'boolean' ? { [key]: value } : {}
}
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
const value = data.metadata
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return { metadata: value as Record<string, unknown> }
}
return {}
}
function errorMessage(error: unknown): string {
return String(error)
}
@@ -0,0 +1,352 @@
import { describe, expect, it } from 'vitest'
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 FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import * as SkillLocal from '../src/index.ts'
async function tempDir(name: string): Promise<string> {
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<void> {
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<void> {
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<string>()
failStatPaths = new Set<string>()
statOverrides = new Map<string, FsInfo | undefined>()
override async resolve(path: string): Promise<FsTarget> {
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
return { targetKey: path as never, displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
if (this.failStatPaths.has(target.displayPath)) throw new Error('stat 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 readText(target: FsTarget): Promise<string> {
const text = await readFile(target.displayPath, 'utf8')
if (text.includes('\uFFFD')) throw new Error('not text')
return text
}
override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
throw new Error('not needed in skill tests')
}
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
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> {
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<FsEditOutcome> {
throw new Error('not needed in skill tests')
}
}
async function setupLocal(home: string, config: Partial<SkillLocal.Config> = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
...config,
})
return ctx
}
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 ctx = await setupLocal(home, { customSkillDirs: [custom] })
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
['custom-only', 'custom only'],
['same', 'project dsh skill'],
])
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
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 or model-disabled skills from 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',
'disableModelInvocation: false',
'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, 'hidden-skill', 'hidden description', 'Hidden.')
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\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 writeFile(join(root, 'flat-skill.md'), '')
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
expect(await ctx.skills.get('rich-skill')).toMatchObject({
whenToUse: 'For richer local parsing',
disableModelInvocation: false,
metadata: { owner: 'tests' },
})
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
})
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') })
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()
})
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 envHome = await tempDir('skill-env-home')
try {
process.env.DSH_HOME = join(envHome, '.dsh')
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
const ctx = new Context()
await ctx.plugin(SkillService)
await ctx.plugin(SkillLocal)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
process.env.DSH_HOME = join(envHome, 'empty-dsh')
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
const empty = new Context()
await empty.plugin(SkillService)
SkillLocal.apply(empty, {})
expect(await empty.skills.list()).toEqual([])
} 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
}
}
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../fs/fs" },
{ "path": "../skill" }
]
}