/** * 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 { unwatchFile, watchFile, type Stats } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' import type { Context } from 'cordis' import chokidar from 'chokidar' 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 { resolveDshHome } from '@deepseek-ai/dsh-paths' import { isSkillName, type SkillCandidate, type SkillDefinition, type SkillInvocationPolicy, type SkillLookupOptions, type SkillProvider, type SkillProviderControl, type SkillProviderObservation, 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 const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200 const DEFAULT_WATCH_POLL_INTERVAL_MS = 100 const DEFAULT_WATCH_MAX_PROJECTS = 128 const BUNDLED_RANK = 600 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[] /** Whether host-local skill roots are watched for catalog changes. */ watch?: boolean /** Whether Chokidar uses polling instead of native filesystem events. */ watchUsePolling?: boolean /** Milliseconds a changed skill entry must remain stable before it is observed. */ watchStabilityThresholdMs?: number /** Milliseconds between Chokidar stability or polling probes. */ watchPollIntervalMs?: number /** Maximum distinct project roots whose skill directories remain watched. */ watchMaxProjects?: number /** Whether watched symbolic links follow their target files. */ watchFollowSymlinks?: boolean /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */ bundledSkillDir?: string } export const Config: Schema = z.object({ dshHome: z.string(), agentsHome: z.string(), customSkillDirs: z.array(z.string()).default([]), watch: z.boolean().default(true), watchUsePolling: z.boolean().default(false), watchStabilityThresholdMs: z.number().default(DEFAULT_WATCH_STABILITY_THRESHOLD_MS), watchPollIntervalMs: z.number().default(DEFAULT_WATCH_POLL_INTERVAL_MS), watchMaxProjects: z.number().default(DEFAULT_WATCH_MAX_PROJECTS), watchFollowSymlinks: z.boolean().default(true), bundledSkillDir: z.string(), }) interface SkillRoot { path: string source: SkillSource rank: number skipSystem?: boolean projectRoot?: string trustedHost?: boolean } interface SkillRootEntry { name: string type: 'directory' | 'file' | 'other' path: string } interface ParsedSkill { name: string description: string whenToUse?: string invocation: SkillInvocationPolicy metadata?: Record content: string } interface LocalLocator { path: string directory: string } interface ResolvedWatchConfig { enabled: boolean usePolling: boolean stabilityThresholdMs: number pollIntervalMs: number maxProjects: number followSymlinks: boolean } /** Register the local filesystem skill provider on `ctx.skills`. */ export function apply(ctx: Context, config: Config = {}): void { let provider!: LocalSkillProvider ctx.skills.registerProvider((control) => { provider = new LocalSkillProvider(ctx, control, config) return provider }) ctx.effect(function* () { yield async () => { await provider.dispose() } }, 'skill-local watcher') ctx.on('fs/observed', (target, _version, actor) => { if (mutationToolName(actor) === undefined) return provider.observeHostMutation(target.displayPath) }) } /** 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[] private readonly watchManager: SkillWatchManager private readonly bundledSkillDir: string | undefined private disposal: Promise | undefined constructor( private readonly ctx: Context, control: SkillProviderControl, config: Config = {}, ) { this.dshHome = resolveDshHome(config.dshHome) this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents')) this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root)) this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir) } /** * 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; watcher startup * failure returns readable candidates as an incomplete observation. */ async list(options: SkillLookupOptions): Promise { const roots = await this.roots(options.cwd) let complete = true try { await this.watchManager.observeRoots(roots) } catch (error) { if (this.disposal !== undefined) throw error complete = false } const candidates: SkillCandidate[] = [] for (const root of roots) { for (const skill of await discoverRoot(root, this.ctx)) { candidates.push(skill) } } return complete ? candidates : { candidates, complete } } /** * Load a complete local skill body from the candidate's file locator. * @param candidate - the winning candidate returned by this provider. * @param options - lookup options whose signal cancels filesystem reads. * @returns the full local skill, or `undefined` if the file disappeared. */ async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise { const locator = candidate.locator as LocalLocator const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled') if (parsed === undefined) return undefined return { name: parsed.name, description: parsed.description, ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}, invocation: parsed.invocation, source: candidate.source, provider: this.name, resourceBase: { kind: 'directory', path: locator.directory }, path: locator.path, ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}, content: parsed.content, } } /** * Invalidate this provider synchronously after a first-party filesystem mutation. * @param path - host display path observed after a model-facing write or edit. */ observeHostMutation(path: string): void { this.watchManager.observeHostMutation(path) } /** * Close every host watcher and contain late filesystem callbacks. * @returns a shared promise that settles when every watcher reaches quiescence. */ dispose(): Promise { this.disposal ??= this.watchManager.dispose() return this.disposal } private async roots(cwd: string | undefined): Promise { 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, projectRoot }, { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot }, ) } 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 }, ...this.bundledSkillDir === undefined ? [] : [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }], ) return roots } } type SkillWatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir' type RootWatchMode = | { kind: 'root'; anchor: string } | { kind: 'ancestor'; anchor: string; nextPath: string } interface RootWatchState { root: SkillRoot owners: Set watcher: WatchHandle | undefined opening: Promise | undefined unhealthy: boolean } interface WatchHandle { mode: RootWatchMode close(): Promise | void } /** Owns bounded host watchers while discovery and reads remain on the filesystem service. */ class SkillWatchManager { private readonly roots = new Map() private readonly projects = new Map>() private readonly lifecycle = new AbortController() private closing = false private invalidationQueued = false constructor( private readonly ctx: Context, private readonly invalidate: () => void, private readonly config: ResolvedWatchConfig, ) {} async observeRoots(roots: readonly SkillRoot[]): Promise { if (this.closing) return const projectRoots = new Map() const pending: Promise[] = [] for (const root of roots) { if (root.projectRoot === undefined) { pending.push(this.retainRoot(root, `shared:${root.path}`)) continue } const grouped = projectRoots.get(root.projectRoot) ?? [] grouped.push(root) projectRoots.set(root.projectRoot, grouped) } for (const [projectRoot, grouped] of projectRoots) { const owner = `project:${projectRoot}` this.projects.delete(projectRoot) const paths = new Set(grouped.map(root => root.path)) this.projects.set(projectRoot, paths) for (const root of grouped) pending.push(this.retainRoot(root, owner)) } let evictedProject = false while (this.projects.size > this.config.maxProjects) { const oldest = this.projects.entries().next() /* v8 ignore next -- the loop condition proves one project exists. */ if (oldest.done) break const [projectRoot, paths] = oldest.value this.projects.delete(projectRoot) const owner = `project:${projectRoot}` for (const path of paths) pending.push(this.releaseRoot(path, owner)) evictedProject = true } await Promise.all(pending) if (evictedProject) this.invalidate() } observeHostMutation(path: string): void { if (this.closing) return const normalized = resolve(path) if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return this.invalidate() } async dispose(): Promise { this.closing = true this.lifecycle.abort(new Error('skill-local watcher disposed')) const states = [...this.roots.values()] this.roots.clear() this.projects.clear() await Promise.all(states.map(async (state) => { await settleWatcherOpening(state.opening) const watcher = state.watcher state.watcher = undefined if (watcher !== undefined) await this.closeWatcher(watcher) })) } private async retainRoot(root: SkillRoot, owner: string): Promise { let state = this.roots.get(root.path) if (state === undefined) { state = { root, owners: new Set(), watcher: undefined, opening: undefined, unhealthy: true } this.roots.set(root.path, state) } state.owners.add(owner) if (this.config.enabled) await this.ensureWatcher(state) } private async releaseRoot(path: string, owner: string): Promise { const state = this.roots.get(path) /* v8 ignore next -- Concurrent cwd observations can evict the same shared root before this release settles. */ if (state === undefined) return state.owners.delete(owner) if (state.owners.size > 0) return this.roots.delete(path) await settleWatcherOpening(state.opening) const watcher = state.watcher state.watcher = undefined if (watcher !== undefined) await this.closeWatcher(watcher) } private ensureWatcher(state: RootWatchState): Promise { /* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */ if (this.closing || !this.config.enabled) return Promise.resolve() if (state.opening !== undefined) return state.opening const opening = this.ensureCurrentWatcher(state) state.opening = opening void opening.then( () => { state.opening = undefined }, () => { state.opening = undefined }, ) return opening } private async ensureCurrentWatcher(state: RootWatchState): Promise { const watcher = state.watcher if (watcher !== undefined && !state.unhealthy) { const current = await resolveRootWatchMode(state.root.path) // A child unlink can publish an empty catalog before root unlinkDir arrives. // Discovery therefore revalidates the retained handle independently. // oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return } await this.replaceWatcher(state) } private async replaceWatcher(state: RootWatchState): Promise { const previous = state.watcher state.watcher = undefined if (previous !== undefined) await this.closeWatcher(previous) /* v8 ignore next -- Teardown can win while an unhealthy watcher is still closing. */ if (this.closing || state.owners.size === 0) return try { const watcher = await this.openStableWatcher(state) /* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */ if (watcher === undefined) return /* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */ // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (this.closing || state.owners.size === 0) { await this.closeWatcher(watcher) return } /* v8 ignore stop */ state.watcher = watcher state.unhealthy = false } catch (error) { // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup if (!this.closing) { state.unhealthy = true this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`) } throw error } } // TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis // service; keep skill filtering and invalidation here. private async openStableWatcher(state: RootWatchState): Promise { while (!this.closing && state.owners.size > 0) { const mode = await resolveRootWatchMode(state.root.path) const watcher = mode.kind === 'ancestor' ? this.openAncestorWatcher(state, mode) : await this.openRootWatcher(state, mode) const current = await resolveRootWatchMode(state.root.path) /* v8 ignore else -- A host path transition between the two probes is timing-dependent. */ if (sameWatchMode(mode, current)) return watcher /* v8 ignore next -- Covered by the same host path transition guard. */ await this.closeWatcher(watcher) } /* v8 ignore next -- The loop exits only when teardown wins between awaited probes. */ return undefined } private openAncestorWatcher(state: RootWatchState, mode: Extract): WatchHandle { const listener = (_current: Stats, _previous: Stats): void => { void this.handleAncestorWatchEvent(state, mode) } watchFile(mode.nextPath, { persistent: false, interval: this.config.pollIntervalMs, }, listener) return { mode, close() { unwatchFile(mode.nextPath, listener) }, } } private async handleAncestorWatchEvent( state: RootWatchState, mode: Extract, ): Promise { let current: RootWatchMode try { current = await resolveRootWatchMode(state.root.path) } catch (error) { /* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */ if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error) return /* v8 ignore stop */ } if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return this.queueInvalidation() state.unhealthy = true this.scheduleRewatch(state) } private async openRootWatcher(state: RootWatchState, mode: Extract): Promise { const watcher = chokidar.watch(mode.anchor, { persistent: false, ignoreInitial: true, depth: 1, followSymlinks: this.config.followSymlinks, atomic: true, awaitWriteFinish: { stabilityThreshold: this.config.stabilityThresholdMs, pollInterval: this.config.pollIntervalMs, }, usePolling: this.config.usePolling, interval: this.config.pollIntervalMs, }) const handle: WatchHandle = { mode, close: () => watcher.close(), } let ready = false const readiness = Promise.withResolvers() const signal = this.lifecycle.signal if (signal.aborted) { await this.closeWatcher(handle) signal.throwIfAborted() } const onAbort = (): void => { readiness.reject(signal.reason) } signal.addEventListener('abort', onAbort, { once: true }) const onError = (error: unknown): void => { if (!ready) { readiness.reject(error) return } this.handleWatcherError(state, error) } watcher.on('error', onError) watcher.once('ready', () => { ready = true readiness.resolve(undefined) }) for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) { watcher.on(event, (path) => { this.handleWatchEvent(state, event, path) }) } try { await readiness.promise } catch (error) { await this.closeWatcher(handle) throw error } finally { signal.removeEventListener('abort', onAbort) } return handle } private handleWatchEvent( state: RootWatchState, event: SkillWatchEvent, path: string, ): void { if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return this.queueInvalidation() if (resolve(path) === state.root.path && event === 'unlinkDir') { state.unhealthy = true this.scheduleRewatch(state) } } private handleWatcherError(state: RootWatchState, error: unknown): void { if (this.closing) return this.ctx.logger.warn(`skill-local: watcher for ${state.root.path} failed: ${errorMessage(error)}`) state.unhealthy = true this.queueInvalidation() this.scheduleRewatch(state) } private scheduleRewatch(state: RootWatchState): void { const currentOpening = state.opening ?? Promise.resolve() void (async () => { await settleWatcherOpening(currentOpening) try { await this.ensureWatcher(state) } catch { // Watch startup logged the retry failure; the next incomplete discovery retries it again. return } this.queueInvalidation() })() } private queueInvalidation(): void { if (this.closing || this.invalidationQueued) return this.invalidationQueued = true queueMicrotask(() => { this.invalidationQueued = false /* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */ if (this.closing) return this.invalidate() }) } private async closeWatcher(watcher: WatchHandle): Promise { try { await watcher.close() } catch (error) { this.ctx.logger.warn(`skill-local: failed to close watcher: ${errorMessage(error)}`) } } } async function settleWatcherOpening(opening: Promise | undefined): Promise { if (opening === undefined) return try { await opening } catch { // Watch startup already logged the underlying failure; teardown only contains it. } } function resolveWatchConfig(config: Config): ResolvedWatchConfig { const stabilityThresholdMs = config.watchStabilityThresholdMs ?? DEFAULT_WATCH_STABILITY_THRESHOLD_MS const pollIntervalMs = config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS const maxProjects = config.watchMaxProjects ?? DEFAULT_WATCH_MAX_PROJECTS assertPositiveInteger('watchStabilityThresholdMs', stabilityThresholdMs) assertPositiveInteger('watchPollIntervalMs', pollIntervalMs) assertPositiveInteger('watchMaxProjects', maxProjects) return { enabled: config.watch ?? true, usePolling: config.watchUsePolling ?? false, stabilityThresholdMs, pollIntervalMs, maxProjects, followSymlinks: config.watchFollowSymlinks ?? true, } } async function resolveRootWatchMode(root: string): Promise { let candidate = root while (true) { try { const info = await stat(candidate) if (info.isDirectory()) { if (candidate === root) return { kind: 'root', anchor: root } const firstSegment = relative(candidate, root).split(sep)[0] /* v8 ignore next -- candidate is a strict ancestor of root. */ if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root } return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) } } } catch (error) { /* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */ if (!isAbsentPathError(error)) throw error } const parent = dirname(candidate) /* v8 ignore next -- Traversal reaches the existing filesystem root before this fallback. */ if (parent === candidate) return { kind: 'ancestor', anchor: candidate, nextPath: root } candidate = parent } } function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean { return left.kind === right.kind && left.anchor === right.anchor && (left.kind === 'root' || (right.kind === 'ancestor' && left.nextPath === right.nextPath)) } function isRelevantWatchEvent( root: SkillRoot, event: SkillWatchEvent, path: string, ): boolean { const segments = containedSegments(root.path, path) if (segments === undefined) return false if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir' if (root.skipSystem === true && segments[0] === '.system') return false if (segments.length === 1) { if (event === 'addDir' || event === 'unlinkDir') return true return segments[0]?.endsWith('.md') === true } return segments.length === 2 && segments[1] === 'SKILL.md' && event !== 'addDir' && event !== 'unlinkDir' } function isPotentialSkillPath(root: SkillRoot, path: string): boolean { const segments = containedSegments(root.path, path) if (segments === undefined || segments.length === 0 || segments.length > 2) return false if (root.skipSystem === true && segments[0] === '.system') return false return segments.length === 1 ? segments[0]?.endsWith('.md') === true : segments[1] === 'SKILL.md' } function containedSegments(root: string, path: string): string[] | undefined { const child = relative(root, path) if (child.length === 0) return [] if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) return undefined return child.split(sep) } function mutationToolName(actor: object | undefined): 'edit' | 'write' | undefined { if (actor === undefined || !('name' in actor)) return undefined const value = actor.name return value === 'edit' || value === 'write' ? value : undefined } function assertPositiveInteger(field: string, value: number): void { if (!Number.isInteger(value) || value < 1) { throw new TypeError(`skill-local: ${field} must be a positive integer`) } } function isAbsentPathError(error: unknown): boolean { return hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR') } function isAbsentSkillPathError(error: unknown): boolean { return isAbsentPathError(error) || hasErrorCode(error, 'FS_NOT_FOUND') || hasErrorCode(error, 'FS_NOT_DIRECTORY') } function hasErrorCode(error: unknown, code: string): boolean { return typeof error === 'object' && error !== null && 'code' in error && error.code === code } async function discoverRoot(root: SkillRoot, ctx: Context): Promise { 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, undefined, root.trustedHost === true) if (parsed === undefined) continue skills.push({ name: parsed.name, description: parsed.description, ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {}, invocation: parsed.invocation, 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 { const fs = optionalFileSystem(ctx) if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs) return await listSkillRootEntriesFromNode(root, ctx) } async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise { try { return (await fsListDir(fs, root.path)).map(entryFromFs) } catch (error) { if (isAbsentSkillPathError(error)) return [] throw error } } async function fsListDir(fs: FileSystem, path: string): Promise { 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 { let entries try { entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' }) } catch (error) { /* v8 ignore else -- Native non-absence directory failures are provider-dependent; the ctx.fs path pins incomplete discovery. */ if (isAbsentSkillPathError(error)) return [] /* v8 ignore next -- Same native error branch as above. */ throw error } 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, signal?: AbortSignal, trustedHost = false): Promise { const raw = await readSkillText(ctx, path, signal, trustedHost) signal?.throwIfAborted() 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 } let invocation try { invocation = parseInvocationPolicy(parsed.data) } catch (error) { ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`) return undefined } return { name, description, ...optionalString(parsed.data, 'whenToUse'), invocation, ...optionalMetadata(parsed.data), content: parsed.body.trim(), } } function optionalFileSystem(ctx: Context): FileSystem | undefined { return ctx.get('fs') } async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise { signal?.throwIfAborted() const fs = optionalFileSystem(ctx) if (fs !== undefined && !trustedHost) { return await readSkillTextFromFileSystem(ctx, fs, path, signal) } try { return await readFile(path, { encoding: 'utf8', signal }) } catch (error) { signal?.throwIfAborted() if (isAbsentSkillPathError(error)) return undefined throw error } } async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise { // A missing or temporarily inaccessible skill file is not fatal to discovery. signal?.throwIfAborted() let target try { target = await fs.resolve(path) } catch (error) { if (isAbsentSkillPathError(error)) return undefined throw error } signal?.throwIfAborted() let info try { info = await fs.stat(target, signal) } catch (error) { signal?.throwIfAborted() if (isAbsentSkillPathError(error)) return undefined throw error } if (info === undefined || info.type !== 'file') return undefined try { return await fs.readText(target, signal) } catch (error) { signal?.throwIfAborted() if (isAbsentSkillPathError(error)) return undefined if (!hasErrorCode(error, 'FS_NOT_TEXT')) throw 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' /* v8 ignore else -- the special-file symlink branch relies on POSIX /dev/null. */ if (info.isFile()) return 'file' /* v8 ignore next -- The special-file symlink fixture relies on POSIX /dev/null. */ 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; 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, 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 { 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 { if (fs !== undefined) { return await pathExistsInFileSystem(path, fs) } return await pathExistsInNode(path) } async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise { 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 { try { await access(path) return true } catch { // Missing host paths are expected while walking toward the filesystem root. return false } } function stringField(data: Record, key: string): string | undefined { const value = data[key] return typeof value === 'string' && value.length > 0 ? value : undefined } function optionalString(data: Record, key: string): { [K in typeof key]?: string } { const value = data[key] return typeof value === 'string' && value.length > 0 ? { [key]: value } : {} } function parseInvocationPolicy(data: Record): SkillInvocationPolicy { rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation') rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation') rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable') const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation') const userInvocable = frontmatterBoolean(data, 'user-invocable') return { modelInvocable: disableModelInvocation !== true, userInvocable: userInvocable !== false, } } function rejectLegacyInvocationKey(data: Record, legacy: string, canonical: string): void { if (Object.hasOwn(data, legacy)) { throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`) } } function frontmatterBoolean(data: Record, key: string): boolean | undefined { if (!Object.hasOwn(data, key)) return undefined const value = data[key] if (typeof value === 'boolean') return value if (value === 1 || value === '1') return true if (value === 0 || value === '0') return false if (typeof value === 'string') { switch (value.toLowerCase()) { case 'true': case 'yes': case 'on': return true case 'false': case 'no': case 'off': return false } } throw new TypeError(`frontmatter field "${key}" must be a boolean`) } function optionalMetadata(data: Record): { metadata?: Record } { const value = data.metadata if (typeof value === 'object' && value !== null && !Array.isArray(value)) { return { metadata: value as Record } } return {} } function errorMessage(error: unknown): string { return String(error) }