refactor: split skill providers

This commit is contained in:
Yichen Jiang
2026-07-08 15:50:38 +08:00
parent 5fd647012e
commit f453ba77a2
77 changed files with 1673 additions and 1334 deletions

View File

@@ -1,54 +1,38 @@
# @deepseek-ai/dsh-skill
Agent skill discovery and model-facing skill guidance.
Agent skill provider registry and model-facing skill guidance.
This package owns the `ctx.skills` interface. It does not know whether skills come from local files, embedded plugin data, HTTP, or another backend; providers register those sources with `ctx.skills.registerProvider(...)`. The shipped local implementation is [`@deepseek-ai/dsh-skill-local`](../skill-local).
## Service: `SkillService` (ctx key: `skills`)
### Public API
- `ctx.skills.list({ cwd? })` Returns model-invocable skill summaries for the current workspace.
- `ctx.skills.get(name, { cwd? })` Returns the full skill, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a runtime skill, disposed with the calling fiber. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
- `ctx.skills.registerProvider(provider): () => void` Registers a provider by unique `provider.name`. Duplicate provider names throw, and `runtime` is reserved for `ctx.skills.register(...)`. The registration is effect-scoped and HMR-safe.
- `ctx.skills.list({ cwd? })` Returns model-invocable skill summaries for the current workspace, merged across providers.
- `ctx.skills.get(name, { cwd? })` Returns the full winning skill, including disabled-for-model skills.
- `ctx.skills.register(skill): () => void` Registers a runtime embedded skill. Same-name runtime registrations are first-wins: a duplicate logs a warning and gets a no-op disposer.
- `ctx.skills.renderModelListing({ cwd? })` Renders the request-time `## Skills` catalog.
### Config
| Field | Default | Meaning |
|---|---|---|
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root; system skills live under `skills/.system`. |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
| `extraRoots` | `[]` | Additional skill roots scanned after user roots and before system skills. |
| `installSystemSkills` | `true` | Whether startup materializes bundled system skills under `dshHome`. |
| `promptFieldMaxLength` | `500` | Maximum rendered `description` / `whenToUse` length in the prompt listing; must be at least `3` because truncated fields reserve `...`. |
| `collectCacheMaxEntries` | `128` | Maximum cwd/root discovery promises kept in memory. |
| `collectCacheMaxEntries` | `128` | Maximum cwd/provider discovery promises kept in memory. |
### Discovery
## Provider Contract
Default roots are resolved in this conflict priority order:
A provider returns `SkillCandidate[]` from `list(options)` and later receives the winning candidate back in `get(candidate, options)`. The candidate's `locator` is opaque to the registry, so a local provider can store a file path while a future HTTP provider can store a URL, id, or version token.
| Source | Path |
|---|---|
| Project DSH | `<projectRoot>/.dsh/skills` |
| Project agents | `<projectRoot>/.agents/skills` |
| Runtime | `ctx.skills.register(...)` |
| User DSH | `~/.dsh/skills` |
| User agents | `~/.agents/skills` |
| Extra | `Config.extraRoots` |
| System | `~/.dsh/skills/.system` |
The registry validates candidate names, descriptions, ranks, and provider ownership. Candidate contract violations fail fast because the provider plugin is malformed; a provider `list()` rejection is treated as a transient source failure, logged, skipped for that request, and not cached. Duplicate skill names are resolved first-wins by `rank`, provider registration order, then the provider's own local order. The final model-visible summary list is sorted by skill `name` for deterministic prompt text and provider prefix-cache friendliness.
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, that ancestor lookup probes `.git` through the filesystem service rather than the host filesystem so remote or sandboxed workspaces keep their own project boundary. The user DSH root skips `.system` during normal user scanning so system skills are read exactly once. Same-name skills keep the highest-priority copy, then model-visible summaries are sorted by skill name for stable prompts and provider prefix-cache friendliness.
## Runtime Skills
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and installs system skills through `ctx.fs.writeText`. Without a filesystem service, the package falls back to Node filesystem I/O for project-root lookup, discovery, reads, and installation so the service can still run in minimal test contexts. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
Discovery is memoized per resolved root set and runtime-skill revision. Runtime `register()` and active disposer calls invalidate the cache; duplicate runtime registrations do not alter the active set. Disk-only changes are picked up on the next invalidation or process restart.
## 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.
`ctx.skills.register(...)` is a convenience for embedded runtime skills. Runtime skills use rank `250`: project providers can override them, while they override the shipped local provider's custom and user roots. Runtime registration is also first-wins within runtime contributions, so a duplicate contribution cannot remove the active one through its disposer.
## Prompt Integration
The service listens on `system-prompt/assemble` and appends a short `## Skills` section to the calling agent's assembled system prompt. The listing contains only stable routing metadata (`name`, `source`, `description`, and optional `whenToUse`), not skill bodies or local absolute paths. `description` and `whenToUse` are whitespace-normalized and capped in the listing so one pathological skill cannot bloat every model request. Models load full instructions through the `skill` tool.
The service listens on `system-prompt/assemble` and appends a short `## Skills` section to the calling agent's assembled system prompt. The listing contains only stable routing metadata (`name`, `source`, `description`, and optional `whenToUse`), not skill bodies or absolute local paths. `description` and `whenToUse` are whitespace-normalized, capped, XML-escaped, and have `{{` / `}}` delimiters split so provider text cannot trip prompt-variable interpolation. Models load full instructions through the `skill` tool.
## System Skills
On startup, the service ensures bundled system skills exist under `~/.dsh/skills/.system` unless `installSystemSkills: false` is configured. Project, runtime, user, and extra-root skills can override system skills by name.
The prompt-injection surface is intentionally separate from provider loading: changing where skills come from means adding or swapping providers, not changing prompt assembly or the `skill` tool.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-skill",
"description": "Agent skill discovery and prompt listing for the DeepSeek Harness",
"description": "Agent skill provider registry and prompt listing for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -23,19 +23,14 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0",
"yaml": "^2.4.2"
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -1,27 +1,25 @@
/**
* Agent skill discovery and prompt listing.
* Agent skill registry and request-time catalog rendering.
*
* Skills are progressive-disclosure instructions: the model sees only a short
* listing in the system prompt, then calls the `skill` tool to load the full
* `SKILL.md` body when a task matches.
* This package is the interface third of the skill capability seam. Concrete
* providers such as `@deepseek-ai/dsh-skill-local` decide where skills come
* from; this service only merges provider catalogs, resolves the winning skill
* for a name, and exposes the model-facing catalog/tool consumers use.
*
* @module @deepseek-ai/dsh-skill
*/
import { access, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { homedir } from 'node:os'
import { Context, Service } 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 type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-agent'
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const DEFAULT_PROMPT_FIELD_LENGTH = 500
const DEFAULT_COLLECT_CACHE_ENTRIES = 128
const RUNTIME_PROVIDER = 'runtime'
const RUNTIME_RANK = 250
const SKILL_PROMPT_SECTION_ORDER = 1000
/** Return whether a string is a valid kebab-case skill name. */
@@ -29,10 +27,16 @@ export function isSkillName(name: string): boolean {
return SKILL_NAME.test(name)
}
/** Origin bucket for a discovered skill. The value is prompt-visible metadata, not part of precedence by itself. */
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'extra' | 'system'
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into the request prompt. */
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
export type SkillResourceBase =
| { kind: 'directory'; path: string }
| { kind: 'url'; url: string }
| { kind: 'opaque'; description: string }
/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */
export interface SkillSummary {
/** Kebab-case identifier used with the `skill` tool. */
name: string
@@ -42,43 +46,68 @@ export interface SkillSummary {
whenToUse?: string
/** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */
disableModelInvocation?: boolean
/** Base directory for resolving skill-relative references. */
directory: string
/** Discovery source that produced this winning skill. */
source: SkillSource
/** Provider that owns this skill body. */
provider: string
/** Provider-specific base for relative resources. */
resourceBase?: SkillResourceBase
}
/** Provider catalog entry used by the registry to merge and later load skills. */
export interface SkillCandidate extends SkillSummary {
/** Lower ranks win duplicate skill names before provider registration order is considered. */
rank: number
/** Opaque provider-owned handle passed back to `provider.get()`. */
locator: unknown
/** Absolute file path when the provider has one. */
path?: string
/** Parsed optional metadata object from provider-specific skill frontmatter. */
metadata?: Record<string, unknown>
}
/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */
export interface SkillDefinition extends SkillSummary {
/** Markdown instruction body after frontmatter removal. */
/** Markdown instruction body after any provider-specific metadata removal. */
content: string
/** Absolute file path when the skill came from disk; runtime skills may omit it. */
/** Absolute file path when the skill came from disk. */
path?: string
/** Parsed optional metadata object from frontmatter. */
metadata?: Record<string, unknown>
}
/** Runtime skill contribution accepted by `ctx.skills.register()`. */
export type SkillRegistration = Omit<SkillDefinition, 'disableModelInvocation'> & { disableModelInvocation?: boolean }
export type SkillRegistration = Omit<SkillDefinition, 'provider'> & { provider?: string }
/** Workspace selector used for cwd-sensitive project-root discovery. */
/** Workspace selector used for cwd-sensitive provider discovery. */
export interface SkillLookupOptions {
cwd?: string | undefined
}
/** Skill plugin configuration. */
/** Provider interface for one source of skills, such as local directories or a remote registry. */
export interface SkillProvider {
/** Unique provider name in the `ctx.skills` registry. */
name: string
/**
* List available skill candidates for the current lookup context.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns provider candidates with precedence ranks and opaque locators.
*/
list(options: SkillLookupOptions): Promise<SkillCandidate[]>
/**
* Load a complete skill body for a previously listed candidate.
* @param candidate - the winning candidate originally returned by this provider.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns the full skill body, or `undefined` if it is no longer loadable.
*/
get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined>
}
/** Skill registry 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
/** Extra skill roots, scanned after user roots and before system skills. */
extraRoots?: string[]
/** Ensure bundled system skills exist under `<dshHome>/skills/.system`. Defaults true. */
installSystemSkills?: boolean
/** Maximum rendered description/whenToUse length in the prompt listing; minimum 3. */
promptFieldMaxLength?: number
/** Maximum number of cwd/root discovery promises kept in the in-memory cache. */
/** Maximum number of cwd/provider discovery promises kept in the in-memory cache. */
collectCacheMaxEntries?: number
}
@@ -86,88 +115,63 @@ declare module 'cordis' {
interface Context {
skills: SkillService
}
interface Events {
/**
* A skill provider became resolvable in the `ctx.skills` registry.
* Consumers can observe this instead of depending on Cordis plugin load
* order, which is concurrent for sibling plugins.
* @param provider - the provider that just registered.
* @mode emit
*/
'skill/provider-added'(provider: SkillProvider): void
/**
* A skill provider left the registry because its plugin fiber was disposed.
* @param name - the registry name that no longer resolves.
* @mode emit
*/
'skill/provider-removed'(name: string): void
}
}
interface SkillRoot {
path: string
source: SkillSource
skipSystem?: boolean
interface IndexedCandidate {
candidate: SkillCandidate
provider: SkillProvider
providerOrder: number
localOrder: number
}
const SYSTEM_SKILLS: SkillDefinition[] = [
{
name: 'dsh-plugin-creator',
description: 'Create or update DeepSeek Harness Cordis plugins and packages.',
directory: 'system://dsh-plugin-creator',
source: 'system',
content: [
'Use this skill to create DeepSeek Harness plugins that fit the repository architecture.',
'',
'Prefer Cordis services, plugin packages, effect-scoped registrations, and existing extension seams over loop changes.',
'When adding a swappable capability, design the interface/implementation/consumer split first.',
'Every registry or registration path needs disposal/HMR coverage.',
'Update package docs, architecture docs, package graph references, and generated catalogs when public surfaces change.',
].join('\n'),
},
{
name: 'dsh-skill-creator',
description: 'Create or update DeepSeek Harness SKILL.md instructions.',
whenToUse: 'Use when writing reusable agent instructions for DeepSeek Harness or adding system/project/user skills.',
directory: 'system://dsh-skill-creator',
source: 'system',
content: [
'Use this skill to write focused DeepSeek Harness skills.',
'',
'A skill is a directory `<name>/SKILL.md` or a flat `<name>.md` file with YAML frontmatter.',
'Frontmatter must include kebab-case `name` and a concise `description` that tells the model when to load it.',
'Use optional `whenToUse` for extra routing signal and `disableModelInvocation: true` for user-only skills.',
'Keep the body procedural, evidence-oriented, and scoped to the workflow the skill owns.',
].join('\n'),
},
]
interface CollectResult {
entries: IndexedCandidate[]
cacheable: boolean
}
/**
* Skill discovery service. It scans project/user/system skill roots, exposes
* model-visible summaries, loads full skill bodies on demand, and injects the
* stable `## Skills` listing into each agent request.
* Registry of skill providers. It merges provider catalogs with stable
* first-wins duplicate handling, exposes sorted model-visible summaries, loads
* full skill bodies on demand, and renders the request-time catalog fragment.
*/
export class SkillService extends Service {
static Config: Schema<Config> = z.object({
dshHome: z.string(),
agentsHome: z.string(),
extraRoots: z.array(z.string()).default([]),
installSystemSkills: z.boolean().default(true),
promptFieldMaxLength: z.number().default(DEFAULT_PROMPT_FIELD_LENGTH),
collectCacheMaxEntries: z.number().default(DEFAULT_COLLECT_CACHE_ENTRIES),
})
private readonly dshHome: string
private readonly agentsHome: string
private readonly extraRoots: string[]
private readonly installSystemSkills: boolean
private readonly promptFieldMaxLength: number
private readonly collectCacheMaxEntries: number
private readonly providers = new Map<string, { provider: SkillProvider; order: number }>()
private readonly runtime = new Map<string, SkillDefinition>()
private readonly collectCache = new Map<string, Promise<SkillDefinition[]>>()
private readonly collectCache = new Map<string, Promise<IndexedCandidate[]>>()
private providerRevision = 0
private nextProviderOrder = 0
private runtimeRevision = 0
private systemReady: Promise<void> | undefined
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'skills')
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.extraRoots = (config.extraRoots ?? []).map(root => resolve(root))
this.installSystemSkills = config.installSystemSkills ?? true
this.promptFieldMaxLength = config.promptFieldMaxLength ?? DEFAULT_PROMPT_FIELD_LENGTH
this.collectCacheMaxEntries = config.collectCacheMaxEntries ?? DEFAULT_COLLECT_CACHE_ENTRIES
assertPositiveInteger('promptFieldMaxLength', this.promptFieldMaxLength, 3)
assertPositiveInteger('collectCacheMaxEntries', this.collectCacheMaxEntries)
if (this.installSystemSkills) {
const systemRoot = join(this.dshHome, 'skills/.system')
this.systemReady = writeSystemSkills(systemRoot, this.ctx).catch((error: unknown) => {
this.ctx.logger.warn(`failed to install bundled system skills under ${systemRoot}: ${errorMessage(error)}`)
})
}
ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
const result = await next()
@@ -186,24 +190,56 @@ export class SkillService extends Service {
}
/**
* Register a runtime skill contribution.
* Same-name runtime registrations are first-wins: a duplicate logs a warning
* and returns a no-op disposer so it cannot remove the active contribution.
* Register a skill provider. Throws if another provider already owns the same
* provider name, including the reserved runtime provider name. Effect-scoped
* and HMR-safe: disposing the caller's fiber unregisters the provider and
* invalidates cached catalogs.
* @param provider - the provider to register by `provider.name`.
* @returns a disposer that unregisters this provider.
*/
registerProvider(provider: SkillProvider): () => void {
const dispose = this.ctx.effect(function* (this: SkillService) {
if (provider.name === RUNTIME_PROVIDER) {
throw new Error(`"${RUNTIME_PROVIDER}" is reserved for runtime skill registrations`)
}
if (this.providers.has(provider.name)) {
throw new Error(`a skill provider named "${provider.name}" is already registered`)
}
this.providers.set(provider.name, { provider, order: this.nextProviderOrder })
this.nextProviderOrder += 1
this.invalidateCache()
yield () => {
this.providers.delete(provider.name)
this.invalidateCache()
this.ctx.emit('skill/provider-removed', provider.name)
}
this.ctx.emit('skill/provider-added', provider)
}.bind(this), 'skills.registerProvider()')
return () => void dispose()
}
/**
* Register a runtime skill contribution. Runtime registrations are treated as
* embedded provider entries with project-over-user priority. Same-name runtime
* registrations are first-wins: a duplicate logs a warning and gets a no-op
* disposer so it cannot remove the active contribution.
* @param skill - the complete skill definition to expose for discovery.
* @returns a disposer that removes this runtime contribution and invalidates caches.
*/
register(skill: SkillRegistration): () => void {
const normalized = normalizeSkill(skill)
const normalized = normalizeRuntimeSkill(skill)
const existing = this.runtime.get(normalized.name)
if (existing !== undefined) {
this.ctx.logger.warn(`runtime skill "${normalized.name}" from ${normalized.source} ignored because it is already registered from ${existing.source}`)
this.ctx.logger.warn(`runtime skill "${normalized.name}" ignored because it is already registered`)
return () => {}
}
const dispose = this.ctx.effect(function* (this: SkillService) {
this.runtime.set(normalized.name, normalized)
this.runtimeRevision += 1
this.invalidateCache()
yield () => {
this.runtime.delete(normalized.name)
this.runtimeRevision += 1
this.invalidateCache()
}
}.bind(this), 'skills.register()')
@@ -217,6 +253,7 @@ export class SkillService extends Service {
*/
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]> {
return (await this.collect(options))
.map(entry => entry.candidate)
.filter(skill => skill.disableModelInvocation !== true)
.map(toSummary)
.sort(compareSummary)
@@ -225,17 +262,19 @@ export class SkillService extends Service {
/**
* Load one full skill definition by name.
* @param name - kebab-case skill name.
* @param options - lookup options; `cwd` selects the project roots to scan.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns the full skill, including body content, or `undefined`.
*/
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined> {
if (!isSkillName(name)) return undefined
return (await this.collect(options)).find(skill => skill.name === name)
const match = (await this.collect(options)).find(entry => entry.candidate.name === name)
if (match === undefined) return undefined
return await match.provider.get(match.candidate, options)
}
/**
* Render the request-time `## Skills` prompt fragment.
* @param options - lookup options; `cwd` selects the project roots to scan.
* @param options - lookup options; `cwd` selects workspace-sensitive skills.
* @returns an empty string when no model-invocable skills are available.
*/
async renderModelListing(options: SkillLookupOptions = {}): Promise<string> {
@@ -259,15 +298,16 @@ export class SkillService extends Service {
].join('\n')
}
private async collect(options: SkillLookupOptions): Promise<SkillDefinition[]> {
await this.ensureSystemSkills()
const roots = await this.roots(options.cwd)
const key = collectCacheKey(roots, this.runtimeRevision)
private async collect(options: SkillLookupOptions): Promise<IndexedCandidate[]> {
const key = collectCacheKey(options, this.providerRevision, this.runtimeRevision)
const cached = this.collectCache.get(key)
if (cached !== undefined) return cached
const collected = this.collectFresh(roots)
const cachedPromise = collected.catch((error: unknown) => {
const collected = this.collectFresh(options)
const cachedPromise = collected.then((result) => {
if (!result.cacheable) this.collectCache.delete(key)
return result.entries
}).catch((error: unknown) => {
this.collectCache.delete(key)
throw error
})
@@ -279,345 +319,116 @@ export class SkillService extends Service {
return cachedPromise
}
private async collectFresh(roots: { project: SkillRoot[]; shared: SkillRoot[] }): Promise<SkillDefinition[]> {
private async collectFresh(options: SkillLookupOptions): Promise<CollectResult> {
const collected = await this.listAllCandidates(options)
collected.entries.sort(compareIndexedCandidates)
const seen = new Set<string>()
const result: SkillDefinition[] = []
const add = (skill: SkillDefinition): void => {
const result: IndexedCandidate[] = []
for (const entry of collected.entries) {
const skill = entry.candidate
if (seen.has(skill.name)) {
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.directory} ignored because a higher-priority skill already exists`)
return
this.ctx.logger.warn(`skill "${skill.name}" from ${skill.source} ignored because a higher-priority skill already exists`)
continue
}
seen.add(skill.name)
result.push(skill)
result.push(entry)
}
for (const root of roots.project) {
for (const skill of await discoverRoot(root, this.ctx)) add(skill)
}
for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) add(skill)
for (const root of roots.shared) {
for (const skill of await discoverRoot(root, this.ctx)) add(skill)
}
return result
return { entries: result, cacheable: collected.cacheable }
}
private async roots(cwd: string | undefined): Promise<{ project: SkillRoot[]; shared: SkillRoot[] }> {
const project: SkillRoot[] = []
if (cwd !== undefined) {
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
project.push(
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh' },
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents' },
)
private async listAllCandidates(options: SkillLookupOptions): Promise<CollectResult> {
const candidates: IndexedCandidate[] = []
let cacheable = true
let runtimeOrder = 0
for (const skill of [...this.runtime.values()].sort((a, b) => a.name.localeCompare(b.name))) {
candidates.push({
candidate: runtimeCandidate(skill),
provider: RUNTIME_SKILL_PROVIDER,
providerOrder: -1,
localOrder: runtimeOrder,
})
runtimeOrder += 1
}
const shared: SkillRoot[] = [
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', skipSystem: true },
{ path: join(this.agentsHome, 'skills'), source: 'user-agents' },
...this.extraRoots.map(path => ({ path, source: 'extra' as const })),
{ path: join(this.dshHome, 'skills/.system'), source: 'system' },
]
return { project, shared }
}
private ensureSystemSkills(): Promise<void> {
return this.systemReady ?? Promise.resolve()
for (const { provider, order } of this.providers.values()) {
let localOrder = 0
const listed = await provider.list(options).catch((error: unknown) => {
cacheable = false
this.ctx.logger.warn(`skill provider "${provider.name}" skipped: ${errorMessage(error)}`)
return undefined
})
if (listed === undefined) continue
for (const candidate of listed) {
validateCandidate(candidate, provider.name)
candidates.push({ candidate, provider, providerOrder: order, localOrder })
localOrder += 1
}
}
return { entries: candidates, cacheable }
}
private invalidateCache(): void {
this.runtimeRevision += 1
this.providerRevision += 1
this.collectCache.clear()
}
}
async function writeSystemSkills(systemRoot: string, ctx: Context): Promise<void> {
await Promise.all(SYSTEM_SKILLS.map(async (skill) => {
const dir = join(systemRoot, skill.name)
const file = join(dir, 'SKILL.md')
if (await skillFileExists(ctx, file)) {
return
}
await writeSkillText(ctx, file, renderSkillFile(skill))
ctx.logger.debug(`installed system skill ${skill.name} at ${file}`)
}))
const RUNTIME_SKILL_PROVIDER: SkillProvider = {
name: RUNTIME_PROVIDER,
/* v8 ignore next -- Runtime skills are injected directly by the registry; this provider only owns `get()`. */
list() {
return Promise.resolve([])
},
get(candidate) {
const skill = candidate.locator as SkillDefinition
return Promise.resolve({ ...skill })
},
}
function renderSkillFile(skill: SkillDefinition): string {
const frontmatter = [
'---',
`name: ${skill.name}`,
`description: ${skill.description}`,
...skill.whenToUse ? [`whenToUse: ${skill.whenToUse}`] : [],
'---',
'',
]
return `${frontmatter.join('\n')}${skill.content}\n`
}
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[]> {
// 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 {
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, directory: string, source: SkillSource, ctx: Context): Promise<SkillDefinition | 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
}
function runtimeCandidate(skill: SkillDefinition): SkillCandidate {
return {
name,
description,
...optionalString(parsed.data, 'whenToUse'),
...optionalBoolean(parsed.data, 'disableModelInvocation'),
...optionalMetadata(parsed.data),
directory,
path,
source,
content: parsed.body.trim(),
...toSummary(skill),
rank: RUNTIME_RANK,
locator: skill,
...skill.path !== undefined ? { path: skill.path } : {},
...skill.metadata !== undefined ? { metadata: skill.metadata } : {},
}
}
function optionalFileSystem(ctx: Context): FileSystem | undefined {
return ctx.get('fs')
}
async function skillFileExists(ctx: Context, path: string): Promise<boolean> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
const target = await fs.resolve(path)
return await fs.stat(target) !== undefined
function validateCandidate(candidate: SkillCandidate, providerName: string): void {
if (!SKILL_NAME.test(candidate.name)) {
throw new Error(`skill provider "${providerName}" returned invalid skill name "${candidate.name}"`)
}
try {
await access(path)
return true
} catch {
// Expected first-run path: the bundled system skill has not been installed.
return false
if (candidate.description.length === 0) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" without a description`)
}
if (!Number.isFinite(candidate.rank)) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" with an invalid rank`)
}
if (candidate.provider !== providerName) {
throw new Error(`skill provider "${providerName}" returned skill "${candidate.name}" for provider "${candidate.provider}"`)
}
}
async function writeSkillText(ctx: Context, path: string, content: string): Promise<void> {
const fs = optionalFileSystem(ctx)
if (fs !== undefined) {
await fs.writeText(await fs.resolve(path), content)
return
}
await mkdir(dirname(path), { recursive: true })
await writeFile(path, content)
}
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 normalizeSkill(skill: SkillRegistration): SkillDefinition {
function normalizeRuntimeSkill(skill: SkillRegistration): SkillDefinition {
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)
return { ...skill }
return {
...skill,
provider: skill.provider ?? RUNTIME_PROVIDER,
source: skill.source,
}
}
function toSummary(skill: SkillDefinition): SkillSummary {
const { name, description, whenToUse, disableModelInvocation, directory, source } = skill
function toSummary(skill: SkillDefinition | SkillCandidate): SkillSummary {
const { name, description, whenToUse, disableModelInvocation, source, provider, resourceBase } = skill
return {
name,
description,
...whenToUse !== undefined ? { whenToUse } : {},
...disableModelInvocation !== undefined ? { disableModelInvocation } : {},
directory,
source,
provider,
...resourceBase !== undefined ? { resourceBase } : {},
}
}
@@ -625,12 +436,22 @@ function compareSummary(left: SkillSummary, right: SkillSummary): number {
return left.name.localeCompare(right.name)
}
function compareIndexedCandidates(left: IndexedCandidate, right: IndexedCandidate): number {
return left.candidate.rank - right.candidate.rank
|| left.providerOrder - right.providerOrder
|| left.localOrder - right.localOrder
}
function promptLine(value: string, maxLength: number): string {
const normalized = value.replaceAll(/\s+/g, ' ').trim()
const truncated = normalized.length <= maxLength
? normalized
: `${normalized.slice(0, maxLength - 3)}...`
return escapeText(truncated)
return escapeText(breakPromptTemplateDelimiters(truncated))
}
function breakPromptTemplateDelimiters(value: string): string {
return value.replaceAll('{{', '{ {').replaceAll('}}', '} }')
}
function assertPositiveInteger(name: string, value: number, minimum = 1): void {
@@ -639,29 +460,6 @@ function assertPositiveInteger(name: string, value: number, minimum = 1): void {
}
}
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 escapeAttr(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
}
@@ -670,8 +468,8 @@ function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
function collectCacheKey(roots: { project: SkillRoot[]; shared: SkillRoot[] }, runtimeRevision: number): string {
return JSON.stringify({ runtimeRevision, roots })
function collectCacheKey(options: SkillLookupOptions, providerRevision: number, runtimeRevision: number): string {
return JSON.stringify({ cwd: options.cwd, providerRevision, runtimeRevision })
}
function errorMessage(error: unknown): string {

View File

@@ -1,748 +1,260 @@
import { describe, expect, it, vi } 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 { describe, expect, it } from 'vitest'
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 SkillService, { type SkillCandidate, type SkillDefinition, type SkillLookupOptions, type SkillProvider } from '@deepseek-ai/dsh-skill'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
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`)
}
function agentForCwd(cwd: string): never {
return { session: { header: { cwd } } } as never
}
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')
function memorySkill(name: string, description: string, rank: number, body = `${name} body.`): SkillCandidate {
return {
name,
description,
provider: 'memory',
source: 'memory',
rank,
locator: { content: body },
}
}
describe('SkillService', () => {
it('discovers project, user, agents, and system skill roots in priority order', async () => {
const home = await tempDir('skill-home')
const agentsHome = await tempDir('agents-home')
const project = await tempDir('skill-project')
await mkdir(join(project, '.git'), { recursive: true })
class MemoryProvider implements SkillProvider {
readonly name = 'memory'
listCalls = 0
await writeSkill(join(home, '.dsh/skills/.system'), 'same', 'system skill')
await writeSkill(join(agentsHome, '.agents/skills'), 'same', 'user agents skill')
await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
await writeSkill(join(home, '.dsh/skills/.system'), 'system-only', 'system only')
constructor(private candidates: SkillCandidate[]) {}
async list(_options: SkillLookupOptions): Promise<SkillCandidate[]> {
this.listCalls += 1
return this.candidates
}
async get(candidate: SkillCandidate): Promise<SkillDefinition | undefined> {
const locator = candidate.locator as { content: string }
return { ...candidate, content: locator.content }
}
replace(candidates: SkillCandidate[]): void {
this.candidates = candidates
}
}
describe('SkillService registry', () => {
it('registers providers, resolves duplicates first-wins, and disposes providers', async () => {
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
['same', 'project dsh skill'],
['system-only', 'system only'],
await ctx.plugin(SkillService)
const provider = new MemoryProvider([
memorySkill('z-skill', 'Z skill', 20),
memorySkill('a-skill', 'A skill', 10),
memorySkill('shadowed', 'Lower priority', 20),
])
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
})
const overrideProvider: SkillProvider = {
name: 'override',
async list() {
return [{
name: 'shadowed',
description: 'Higher priority',
provider: 'override',
source: 'override',
rank: 5,
locator: { content: 'Override body.' },
}]
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
const disposeMemory = ctx.skills.registerProvider(provider)
ctx.skills.registerProvider(overrideProvider)
it('sorts the final model-visible list by skill name after priority conflict resolution', async () => {
const home = await tempDir('skill-sorted-home')
const agentsHome = await tempDir('skill-sorted-agents')
const project = await tempDir('skill-sorted-project')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), 'z-project', 'Project skill')
await writeSkill(join(home, '.dsh/skills'), 'm-user', 'User skill')
await writeSkill(join(home, '.dsh/skills/.system'), 'a-system', 'System skill')
await writeSkill(join(home, '.dsh/skills/.system'), 'm-user', 'Shadowed system skill')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(agentsHome, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list({ cwd: project })).map(skill => [skill.name, skill.description])).toEqual([
['a-system', 'System skill'],
['m-user', 'User skill'],
['z-project', 'Project skill'],
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description, skill.provider])).toEqual([
['a-skill', 'A skill', 'memory'],
['shadowed', 'Higher priority', 'override'],
['z-skill', 'Z skill', 'memory'],
])
expect((await ctx.skills.get('shadowed'))?.content).toBe('Override body.')
const sameRankProvider: SkillProvider = {
name: 'same-rank',
async list() {
return [{
name: 'same-rank-skill',
description: 'Same rank',
provider: 'same-rank',
source: 'same-rank',
rank: 10,
locator: { content: 'Same rank body.' },
}]
},
async get(candidate) {
return { ...candidate, content: (candidate.locator as { content: string }).content }
},
}
ctx.skills.registerProvider(sameRankProvider)
expect((await ctx.skills.list()).find(skill => skill.name === 'same-rank-skill')?.provider).toBe('same-rank')
await expect(ctx.plugin({
name: 'duplicate-memory',
inject: ['skills'],
apply(pluginCtx: Context) {
pluginCtx.skills.registerProvider(new MemoryProvider([]))
},
})).rejects.toThrow('already registered')
expect(() => ctx.skills.registerProvider({
name: 'runtime',
async list() {
return []
},
async get() {
return undefined
},
})).toThrow('reserved')
disposeMemory()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['same-rank-skill', 'shadowed'])
})
it('gives project skills priority over runtime skills while runtime overrides user and system skills', async () => {
const home = await tempDir('skill-runtime-priority')
const project = await tempDir('skill-runtime-project')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
await writeSkill(join(home, '.dsh/skills/.system'), 'runtime-name', 'System loses')
it('validates provider candidates and invalid registry caps', async () => {
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
ctx.skills.register({
name: 'project-name',
description: 'Runtime loses to project',
content: 'Runtime body.',
directory: 'memory://project-name',
source: 'runtime',
})
ctx.skills.register({
name: 'runtime-name',
description: 'Runtime wins',
content: 'Runtime body.',
directory: 'memory://runtime-name',
source: 'runtime',
await ctx.plugin(SkillService)
ctx.skills.registerProvider({
name: 'bad',
async list() {
return [memorySkill('Bad_Name', 'bad', 1)]
},
async get() {
return undefined
},
})
await expect(ctx.skills.list()).rejects.toThrow('invalid skill name')
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')
const invalidCandidates = [
{ ...memorySkill('empty-description', '', 1), provider: 'empty-description' },
{ ...memorySkill('bad-rank', 'Bad rank', Number.NaN), provider: 'bad-rank' },
{ ...memorySkill('wrong-provider', 'Wrong provider', 1), provider: 'different' },
]
for (const candidate of invalidCandidates) {
const invalid = new Context()
await invalid.plugin(SkillService)
invalid.skills.registerProvider({
name: candidate.name,
async list() {
return [candidate]
},
async get() {
return undefined
},
})
await expect(invalid.skills.list()).rejects.toThrow('skill provider')
}
await expect(new Context().plugin(SkillService, { promptFieldMaxLength: 2 })).rejects.toThrow('greater than or equal to 3')
await expect(new Context().plugin(SkillService, { collectCacheMaxEntries: 1.5 })).rejects.toThrow('collectCacheMaxEntries')
})
it('does not scan .system twice through the user dsh root', async () => {
const home = await tempDir('skill-system')
await writeSkill(join(home, '.dsh/skills/.system'), 'builtin', 'builtin skill')
it('caches provider discovery, skips failing providers, and invalidates on runtime skills', async () => {
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
await ctx.plugin(SkillService, { collectCacheMaxEntries: 1 })
const provider = new MemoryProvider([memorySkill('first-skill', 'First', 10)])
ctx.skills.registerProvider(provider)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['builtin'])
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
provider.replace([memorySkill('second-skill', 'Second', 10)])
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['first-skill'])
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
const home = await tempDir('skill-flat')
await writeFlatSkill(join(home, '.dsh/skills'), 'flat-skill', 'flat description', 'Flat instructions.')
await writeFile(join(home, '.dsh/skills/bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/missing-description.md'), '---\nname: missing-description\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/no-frontmatter.md'), 'No frontmatter.')
await writeFile(join(home, '.dsh/skills/plain-markdown.md'), '# Notes\nNot a skill.')
await writeFile(join(home, '.dsh/skills/open-frontmatter.md'), '---\nname: open-frontmatter')
await writeFile(join(home, '.dsh/skills/non-object.md'), '---\n[]\n---\n\nbad')
await writeFile(join(home, '.dsh/skills/no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
await writeFile(join(home, '.dsh/skills/notes.txt'), 'ignored')
await mkdir(join(home, '.dsh/skills/not-a-skill'), { recursive: true })
await writeSkill(join(home, '.dsh/skills'), 'hidden-skill', 'hidden description', 'Hidden.')
await writeFile(join(home, '.dsh/skills/hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body'])
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
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 = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
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 poisoning discovery cache', 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 = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
await writeFile(join(root, 'bad-yaml.md'), '---\nname: fixed-skill\ndescription: Fixed skill\n---\n\nFixed body.\n')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
const dispose = ctx.skills.register({
const disposeRuntime = ctx.skills.register({
name: 'runtime-skill',
description: 'Runtime skill',
content: 'Runtime body.',
directory: 'memory://runtime',
description: 'Runtime',
source: 'runtime',
resourceBase: { kind: 'opaque', description: 'runtime memory' },
path: 'memory://runtime-skill',
metadata: { owner: 'tests' },
content: 'Runtime body.',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fixed-skill', 'good-skill', 'runtime-skill'])
dispose()
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill', 'second-skill'])
expect(await ctx.skills.get('runtime-skill')).toMatchObject({
content: 'Runtime body.',
path: 'memory://runtime-skill',
metadata: { owner: 'tests' },
})
disposeRuntime()
await ctx.skills.list({ cwd: '/tmp/first-cache-key' })
await ctx.skills.list({ cwd: '/tmp/second-cache-key' })
it('does not cache a rejected discovery promise', async () => {
const home = await tempDir('skill-rejected-cache')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const internals = ctx.skills as unknown as {
collectFresh(roots: unknown): Promise<unknown[]>
}
const original = internals.collectFresh.bind(ctx.skills)
let fail = true
internals.collectFresh = async (roots: unknown) => {
if (fail) throw new Error('transient discovery failure')
return await original(roots)
}
await expect(ctx.skills.list()).rejects.toThrow('transient discovery failure')
fail = false
await writeSkill(join(home, '.dsh/skills'), 'late-good', 'Late good')
await expect(ctx.skills.list()).resolves.toMatchObject([{ name: 'late-good' }])
})
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 = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
})
it('honors prompt and cache bounds from config', async () => {
const home = await tempDir('skill-config-bounds')
const firstProject = await tempDir('skill-config-first')
const secondProject = await tempDir('skill-config-second')
await mkdir(join(firstProject, '.git'), { recursive: true })
await mkdir(join(secondProject, '.git'), { recursive: true })
await writeSkill(join(firstProject, '.dsh/skills'), 'first-skill', 'abcdefghij')
await writeSkill(join(secondProject, '.dsh/skills'), 'second-skill', 'Second')
const ctx = new Context()
await ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
promptFieldMaxLength: 6,
collectCacheMaxEntries: 1,
let flakyCalls = 0
ctx.skills.registerProvider({
name: 'flaky',
async list() {
flakyCalls += 1
if (fail) throw new Error('transient discovery failure')
return [{ ...memorySkill('flaky-skill', 'Flaky', 10), provider: 'flaky' }]
},
async get() {
return undefined
},
})
expect(await ctx.skills.renderModelListing({ cwd: firstProject })).toContain('description: abc...')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
await writeSkill(join(firstProject, '.dsh/skills'), 'late-first', 'Late first')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill'])
await ctx.skills.list({ cwd: secondProject })
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['first-skill', 'late-first'])
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
expect(flakyCalls).toBe(1)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['second-skill'])
expect(flakyCalls).toBe(2)
fail = false
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
expect(flakyCalls).toBe(3)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['flaky-skill', 'second-skill'])
expect(flakyCalls).toBe(3)
})
it('rejects invalid positive-integer config caps', async () => {
const home = await tempDir('skill-invalid-config')
const ctx = new Context()
await expect(ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
promptFieldMaxLength: 0,
})).rejects.toThrow('promptFieldMaxLength')
await expect(ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
promptFieldMaxLength: 2,
})).rejects.toThrow('greater than or equal to 3')
await expect(ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
installSystemSkills: false,
collectCacheMaxEntries: 1.5,
})).rejects.toThrow('collectCacheMaxEntries')
})
it('renders no model listing when no model-invocable skills exist', async () => {
const home = await tempDir('skill-empty-listing')
it('renders stable prompt guidance and omits it when no skills exist', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt, { persona: 'base' })
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect(await ctx.skills.renderModelListing()).toBe('')
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd(home) }))).not.toContain('## Skills')
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('## Skills')
})
it('supports default home root resolution without installing system skills', async () => {
const previousDshHome = process.env.DSH_HOME
const envHome = await tempDir('skill-env-home')
try {
process.env.DSH_HOME = join(envHome, '.dsh')
await new Context().plugin(SkillService, { installSystemSkills: false })
delete process.env.DSH_HOME
await new Context().plugin(SkillService, { installSystemSkills: false })
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME
} else {
process.env.DSH_HOME = previousDshHome
}
}
})
it('keeps constructor defaults when schema preprocessing is not involved', async () => {
const home = await tempDir('skill-constructor-defaults')
const service = new SkillService(new Context(), {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
})
expect((await service.list()).map(skill => skill.name)).toEqual(['dsh-plugin-creator', 'dsh-skill-creator'])
})
it('installs system skills into the DSH home without overwriting existing files', async () => {
const home = await tempDir('skill-install')
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Custom system skill\n---\n\nCustom body.\n')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
['dsh-plugin-creator', 'Custom system skill'],
['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
])
expect(await readFile(existing, 'utf8')).toContain('Custom body.')
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
})
it('uses the filesystem service when installing bundled system skills', async () => {
const home = await tempDir('skill-install-fs')
const existing = join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md')
await mkdir(join(home, '.dsh/skills/.system/dsh-plugin-creator'), { recursive: true })
await writeFile(existing, '---\nname: dsh-plugin-creator\ndescription: Existing system skill\n---\n\nExisting body.\n')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => [skill.name, skill.description])).toEqual([
['dsh-plugin-creator', 'Existing system skill'],
['dsh-skill-creator', 'Create or update DeepSeek Harness SKILL.md instructions.'],
])
expect(await readFile(existing, 'utf8')).toContain('Existing body.')
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('dsh-skill-creator')
})
it('renders bundled system skill files with and without routing metadata', async () => {
const home = await tempDir('skill-install-render')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
await ctx.skills.list()
expect(await readFile(join(home, '.dsh/skills/.system/dsh-plugin-creator/SKILL.md'), 'utf8')).not.toContain('whenToUse:')
expect(await readFile(join(home, '.dsh/skills/.system/dsh-skill-creator/SKILL.md'), 'utf8')).toContain('whenToUse:')
})
it('uses the filesystem service for skill file reads when it is available', async () => {
const home = await tempDir('skill-read-fs')
const root = join(home, '.dsh/skills')
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 ctx.plugin(SkillService, { promptFieldMaxLength: 6 })
ctx.skills.registerProvider(new MemoryProvider([
{
...memorySkill('escaped-skill', 'Use </available_skills><oops> safely', 10),
whenToUse: 'Handle <tag> & marker',
},
]))
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'))
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()
})
it('uses the filesystem service when locating a workspace project root', async () => {
const home = await tempDir('skill-project-root-fs')
const project = await tempDir('skill-project-root-backend')
const nestedCwd = join(project, 'packages/app')
await mkdir(nestedCwd, { recursive: true })
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(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, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
['backend-root', 'project-agents'],
])
})
it('degrades when bundled system skill installation fails', async () => {
const home = await tempDir('skill-install-fail')
await writeFile(join(home, '.dsh'), 'not a directory')
await writeSkill(join(home, '.agents/skills'), 'fallback-skill', 'Fallback skill')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents') })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['fallback-skill'])
})
it('memoizes disk discovery until runtime skill registrations change', async () => {
const home = await tempDir('skill-cache')
await writeSkill(join(home, '.dsh/skills'), 'initial-skill', 'Initial skill')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
await writeSkill(join(home, '.dsh/skills'), 'late-skill', 'Late skill')
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill'])
const dispose = ctx.skills.register({
name: 'runtime-skill',
description: 'runtime',
content: 'Runtime body.',
directory: 'memory://runtime',
source: 'runtime',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill', 'runtime-skill'])
dispose()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['initial-skill', 'late-skill'])
})
it('includes extra roots, optional metadata, and explicit false disable flags', async () => {
const home = await tempDir('skill-extra')
const extra = await tempDir('skill-extra-root')
await writeFile(join(extra, 'extra-skill.md'), [
'---',
'name: extra-skill',
'description: Extra skill',
'whenToUse: For extra-root tests',
'disableModelInvocation: false',
'metadata:',
' owner: tests',
'---',
'',
'Extra body.',
].join('\n'))
const ctx = new Context()
await ctx.plugin(SkillService, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
extraRoots: [extra],
installSystemSkills: false,
})
expect(await ctx.skills.list()).toEqual([{
name: 'extra-skill',
description: 'Extra skill',
whenToUse: 'For extra-root tests',
disableModelInvocation: false,
directory: extra,
source: 'extra',
}])
expect((await ctx.skills.get('extra-skill'))?.metadata).toEqual({ owner: 'tests' })
expect(await ctx.skills.renderModelListing()).toContain('whenToUse: For extra-root tests')
})
it('bounds prompt listing fields without changing stored skill content', async () => {
const home = await tempDir('skill-prompt-bounds')
const longDescription = 'a'.repeat(600)
await writeSkill(join(home, '.dsh/skills'), 'long-skill', longDescription, 'Full body.')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const listing = await ctx.skills.renderModelListing()
expect(listing).toContain(`${'a'.repeat(497)}...`)
expect(listing).not.toContain('a'.repeat(600))
expect((await ctx.skills.get('long-skill'))?.description).toBe(longDescription)
expect(listing).toContain('description: Use...')
expect(listing).toContain('whenToUse: Han...')
expect(listing).not.toContain('</available_skills><oops>')
expect(renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))).toContain('## Skills')
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('## Skills')
const empty = new Context()
await empty.plugin(SystemPrompt, { persona: 'base' })
await empty.plugin(SkillService)
expect(await empty.skills.renderModelListing()).toBe('')
expect(renderPrompt(await empty.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))).not.toContain('## Skills')
const direct = new SkillService(new Context(), {})
expect(await direct.renderModelListing()).toBe('')
const short = new Context()
await short.plugin(SkillService)
short.skills.registerProvider(new MemoryProvider([memorySkill('short-skill', 'Short', 10)]))
expect(await short.skills.renderModelListing()).toContain('description: Short')
const templated = new Context()
await templated.plugin(SystemPrompt, { persona: 'base' })
await templated.plugin(SkillService)
templated.skills.registerProvider(new MemoryProvider([memorySkill('templated-skill', 'Use {{placeholder}} safely', 10)]))
const prompt = renderPrompt(await templated.systemPrompt.assemble({ agent: agentForCwd('/tmp') }))
expect(prompt).toContain('description: Use { {placeholder} } safely')
})
it('escapes prompt listing text fields without changing stored skill content', async () => {
const home = await tempDir('skill-prompt-escape')
const root = join(home, '.dsh/skills')
await mkdir(root, { recursive: true })
await writeFile(join(root, 'escaped-skill.md'), [
'---',
'name: escaped-skill',
'description: Use </available_skills><oops> safely',
'whenToUse: Handle <tag> & marker',
'---',
'Full body.',
].join('\n'))
it('rejects invalid runtime skill registrations and ignores duplicates', async () => {
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
await ctx.plugin(SkillService)
expect(() => ctx.skills.register({ name: 'Bad_Name', description: 'Bad', source: 'runtime', content: 'bad' })).toThrow('invalid skill name')
expect(() => ctx.skills.register({ name: 'no-description', description: '', source: 'runtime', content: 'bad' })).toThrow('requires a description')
expect(await ctx.skills.get('missing-skill')).toBeUndefined()
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
const listing = await ctx.skills.renderModelListing()
expect(listing).toContain('description: Use &lt;/available_skills&gt;&lt;oops&gt; safely')
expect(listing).toContain('whenToUse: Handle &lt;tag&gt; &amp; marker')
expect(listing).not.toContain('description: Use </available_skills><oops> safely')
expect((await ctx.skills.get('escaped-skill'))?.description).toBe('Use </available_skills><oops> safely')
})
it('adds skill guidance through system prompt assembly without including bodies', async () => {
const home = await tempDir('skill-guidance')
await writeSkill(join(home, '.dsh/skills'), 'research-helper', 'Research helper', 'Long body that must not be listed.')
const ctx = new Context()
await ctx.plugin(SystemPrompt, { persona: 'base' })
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const prompt = renderPrompt(await ctx.systemPrompt.assemble({ agent: agentForCwd(home) }))
expect(prompt).toContain('base')
expect(prompt).toContain('## Skills\n')
expect(prompt).toContain('research-helper')
expect(prompt).toContain('source="project-dsh"')
expect(prompt).not.toContain(home)
expect(prompt).not.toContain('Long body')
expect(prompt.match(/## Skills/g)).toHaveLength(1)
const copyCtx = new Context()
await copyCtx.plugin(SystemPrompt, { persona: 'base' })
await copyCtx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
copyCtx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const result = await next()
return { ...result, sections: [...result.sections] }
})
const copiedPrompt = renderPrompt(await copyCtx.systemPrompt.assemble({ agent: agentForCwd(home) }))
expect(copiedPrompt.match(/## Skills/g)).toHaveLength(1)
})
it('cleans up runtime registered skills when the contributing fiber is disposed', async () => {
const ctx = new Context()
const home = await tempDir('skill-runtime')
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.skills.register({
name: 'runtime-skill',
description: 'runtime',
content: 'Runtime body.',
directory: 'memory://runtime',
source: 'runtime',
})
}, { inject: ['skills'] }))
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['runtime-skill'])
await fiber.dispose()
expect(await ctx.skills.list()).toEqual([])
})
it('bounds discovery cache entries across many project roots', async () => {
const home = await tempDir('skill-cache-bound-home')
const projects = await Promise.all(Array.from({ length: 129 }, async (_, index) => {
const project = await tempDir(`skill-cache-bound-project-${index}`)
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), `project-${index}`, `Project ${index}`)
return project
}))
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const firstProject = projects[0]
if (firstProject === undefined) throw new Error('expected at least one project')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['project-0'])
await writeSkill(join(firstProject, '.dsh/skills'), 'late-project-0', 'Late project 0')
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['project-0'])
for (const project of projects.slice(1)) {
await ctx.skills.list({ cwd: project })
}
expect((await ctx.skills.list({ cwd: firstProject })).map(skill => skill.name)).toEqual(['late-project-0', 'project-0'])
})
it('removes runtime registered skills when the returned disposer is called', async () => {
const home = await tempDir('skill-runtime-disposer')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const dispose = ctx.skills.register({
name: 'manual-dispose',
description: 'manual',
content: 'Manual body.',
directory: 'memory://manual',
source: 'runtime',
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['manual-dispose'])
dispose()
expect(await ctx.skills.list()).toEqual([])
})
it('keeps the first runtime skill when a duplicate name is registered', async () => {
const home = await tempDir('skill-runtime-duplicate')
const ctx = new Context()
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
const firstDispose = ctx.skills.register({
name: 'same-runtime',
description: 'first',
content: 'First body.',
directory: 'memory://first',
source: 'runtime',
})
const duplicateDispose = ctx.skills.register({
name: 'same-runtime',
description: 'second',
content: 'Second body.',
directory: 'memory://second',
source: 'runtime',
})
await expect(ctx.skills.get('same-runtime')).resolves.toMatchObject({
description: 'first',
content: 'First body.',
directory: 'memory://first',
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining('runtime skill "same-runtime"'))
duplicateDispose()
await expect(ctx.skills.get('same-runtime')).resolves.toMatchObject({
description: 'first',
content: 'First body.',
})
firstDispose()
await expect(ctx.skills.get('same-runtime')).resolves.toBeUndefined()
})
it('rejects invalid runtime skill registrations', async () => {
const home = await tempDir('skill-runtime-invalid')
const ctx = new Context()
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect(() => ctx.skills.register({
name: 'Bad_Name',
description: 'bad',
content: 'bad',
directory: 'memory://bad',
source: 'runtime',
})).toThrow('invalid skill name')
expect(() => ctx.skills.register({
name: 'empty-description',
description: '',
content: 'bad',
directory: 'memory://bad',
source: 'runtime',
})).toThrow('requires a description')
const disposeFirst = ctx.skills.register({ name: 'same-skill', description: 'First', source: 'runtime', content: 'first' })
const disposeSecond = ctx.skills.register({ name: 'same-skill', description: 'Second', source: 'runtime', content: 'second' })
disposeSecond()
expect((await ctx.skills.get('same-skill'))?.description).toBe('First')
disposeFirst()
expect(await ctx.skills.get('same-skill')).toBeUndefined()
})
})

View File

@@ -9,8 +9,7 @@
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../fs/fs" },
{ "path": "../../llm/llm" },
{ "path": "../agent" }
{ "path": "../agent" },
{ "path": "../system-prompt" }
]
}