1042 lines
38 KiB
TypeScript
1042 lines
38 KiB
TypeScript
/**
|
|
* 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, lstat, 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 { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths'
|
|
import {
|
|
BUNDLED_SKILL_RANK,
|
|
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
|
|
|
|
export const name = 'skill-local'
|
|
export const inject = ['skills']
|
|
|
|
/** Local filesystem skill provider configuration. */
|
|
export interface Config {
|
|
/** Unique provider name. Defaults to `local`. */
|
|
providerName?: string
|
|
/** Whether project and user roots are included around custom roots. */
|
|
includeDefaultRoots?: boolean
|
|
/** 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` when default roots are included, otherwise mounts none. */
|
|
bundledSkillDir?: string
|
|
}
|
|
|
|
export const Config: Schema<Config> = z.object({
|
|
providerName: z.string().min(1).default('local'),
|
|
includeDefaultRoots: z.boolean().default(true),
|
|
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<string, unknown>
|
|
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, _observation, 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: string
|
|
private readonly includeDefaultRoots: boolean
|
|
private readonly dshHome: string
|
|
private readonly agentsHome: string
|
|
private readonly customSkillDirs: string[]
|
|
private readonly watchManager: SkillWatchManager
|
|
private readonly bundledSkillDir: string | undefined
|
|
private disposal: Promise<void> | undefined
|
|
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
control: SkillProviderControl,
|
|
config: Config = {},
|
|
) {
|
|
this.name = config.providerName ?? 'local'
|
|
this.includeDefaultRoots = config.includeDefaultRoots ?? true
|
|
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 })
|
|
// The environment bundled root is a default root: an isolated provider
|
|
// must see only its explicit roots, or every such provider would
|
|
// re-discover the app's bundled skills under its own provider name.
|
|
const bundledSkillDir = config.bundledSkillDir
|
|
?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined)
|
|
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<SkillCandidate[] | SkillProviderObservation> {
|
|
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, this.name)) {
|
|
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<SkillDefinition | undefined> {
|
|
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<void> {
|
|
this.disposal ??= this.watchManager.dispose()
|
|
return this.disposal
|
|
}
|
|
|
|
private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
|
|
const roots: SkillRoot[] = []
|
|
if (this.includeDefaultRoots && 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 })))
|
|
if (this.includeDefaultRoots) {
|
|
roots.push(
|
|
{ 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 },
|
|
)
|
|
}
|
|
if (this.bundledSkillDir !== undefined) {
|
|
roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_SKILL_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<string>
|
|
watcher: WatchHandle | undefined
|
|
opening: Promise<void> | undefined
|
|
unhealthy: boolean
|
|
}
|
|
|
|
interface WatchHandle {
|
|
mode: RootWatchMode
|
|
close(): Promise<void> | void
|
|
}
|
|
|
|
/** Owns bounded host watchers while discovery and reads remain on the filesystem service. */
|
|
class SkillWatchManager {
|
|
private readonly roots = new Map<string, RootWatchState>()
|
|
private readonly projects = new Map<string, Set<string>>()
|
|
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<void> {
|
|
if (this.closing) return
|
|
const projectRoots = new Map<string, SkillRoot[]>()
|
|
const pending: Promise<void>[] = []
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
/* 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<void> {
|
|
const watcher = state.watcher
|
|
if (watcher !== undefined && !state.unhealthy) {
|
|
const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
|
|
// 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<void> {
|
|
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<WatchHandle | undefined> {
|
|
while (!this.closing && state.owners.size > 0) {
|
|
const mode = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
|
|
const watcher = mode.kind === 'ancestor'
|
|
? this.openAncestorWatcher(state, mode)
|
|
: await this.openRootWatcher(state, mode)
|
|
const current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
|
|
/* 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<RootWatchMode, { kind: 'ancestor' }>): 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<RootWatchMode, { kind: 'ancestor' }>,
|
|
): Promise<void> {
|
|
let current: RootWatchMode
|
|
try {
|
|
current = await resolveRootWatchMode(state.root.path, this.config.followSymlinks)
|
|
} 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<RootWatchMode, { kind: 'root' }>): Promise<WatchHandle> {
|
|
const watcher = chokidar.watch(mode.anchor, {
|
|
// Chokidar owns late native fs.watch errors only for persistent watchers;
|
|
// this provider's effect explicitly closes every handle at teardown.
|
|
persistent: true,
|
|
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<undefined>()
|
|
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, mode, 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,
|
|
mode: Extract<RootWatchMode, { kind: 'root' }>,
|
|
event: SkillWatchEvent,
|
|
path: string,
|
|
): void {
|
|
const target = resolve(path)
|
|
if (this.closing || !isRelevantWatchEvent({ ...state.root, path: mode.anchor }, event, target)) return
|
|
this.queueInvalidation()
|
|
if (target === mode.anchor && 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<void> {
|
|
try {
|
|
await watcher.close()
|
|
} catch (error) {
|
|
this.ctx.logger.warn(`skill-local: failed to close watcher: ${errorMessage(error)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function settleWatcherOpening(opening: Promise<void> | undefined): Promise<void> {
|
|
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, followSymlinks: boolean): Promise<RootWatchMode> {
|
|
let candidate = root
|
|
while (true) {
|
|
try {
|
|
const info = await stat(candidate)
|
|
if (info.isDirectory()) {
|
|
const preserveRootLink = candidate === root
|
|
&& !followSymlinks
|
|
&& (await lstat(candidate)).isSymbolicLink()
|
|
const anchor = preserveRootLink ? resolve(candidate) : await canonicalizeWatchPath(candidate)
|
|
if (candidate === root) return { kind: 'root', anchor }
|
|
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 }
|
|
return { kind: 'ancestor', anchor, nextPath: join(anchor, 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, provider: string): Promise<SkillCandidate[]> {
|
|
const skills: SkillCandidate[] = []
|
|
const entries = await listSkillRootEntries(root, ctx)
|
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
if (root.skipSystem && entry.name === '.system') continue
|
|
const locator = entry.type === 'directory'
|
|
? { path: join(entry.path, 'SKILL.md'), directory: entry.path }
|
|
: entry.type === 'file' && entry.name.endsWith('.md')
|
|
? { path: entry.path, directory: root.path }
|
|
: undefined
|
|
if (locator === undefined) continue
|
|
const parsed = await parseSkillFile(locator.path, ctx, 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,
|
|
source: root.source,
|
|
rank: root.rank,
|
|
locator,
|
|
resourceBase: { kind: 'directory', path: locator.directory },
|
|
path: locator.path,
|
|
...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
|
|
})
|
|
}
|
|
return skills
|
|
}
|
|
|
|
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
|
const fs = optionalFileSystem(ctx)
|
|
if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs)
|
|
return await listSkillRootEntriesFromNode(root, ctx)
|
|
}
|
|
|
|
async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
|
|
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<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 (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<ParsedSkill | undefined> {
|
|
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<string | undefined> {
|
|
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<string | undefined> {
|
|
// 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<string, unknown>; body: string } | undefined {
|
|
const firstLineEnd = raw.indexOf('\n')
|
|
if (firstLineEnd < 0) return undefined
|
|
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
|
|
if (firstLine !== '---') return undefined
|
|
const start = firstLineEnd + 1
|
|
const closing = findClosingFrontmatter(raw, start)
|
|
if (closing === undefined) return undefined
|
|
const yaml = raw.slice(start, closing.start)
|
|
const parsed = parseYaml(yaml) as unknown
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
|
|
return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
|
|
}
|
|
|
|
function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
|
|
let lineStart = start
|
|
while (lineStart <= raw.length) {
|
|
const nextNewline = raw.indexOf('\n', lineStart)
|
|
const lineEnd = nextNewline < 0 ? raw.length : nextNewline
|
|
const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
|
|
if (line === '---') {
|
|
return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
|
|
}
|
|
if (nextNewline < 0) return undefined
|
|
lineStart = nextNewline + 1
|
|
}
|
|
}
|
|
|
|
async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
|
|
let current = cwd
|
|
while (true) {
|
|
if (await pathExists(join(current, '.git'), fs)) {
|
|
return current
|
|
}
|
|
const parent = dirname(current)
|
|
if (parent === current) return cwd
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
|
|
if (fs !== undefined) {
|
|
return await pathExistsInFileSystem(path, fs)
|
|
}
|
|
return await pathExistsInNode(path)
|
|
}
|
|
|
|
async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
|
|
let target
|
|
try {
|
|
target = await fs.resolve(path)
|
|
} catch {
|
|
// A backend may reject or hide this candidate; continue walking upward.
|
|
return false
|
|
}
|
|
try {
|
|
return await fs.stat(target) !== undefined
|
|
} catch {
|
|
// Transient stat failures make only this git-root candidate unusable.
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function pathExistsInNode(path: string): Promise<boolean> {
|
|
try {
|
|
await access(path)
|
|
return true
|
|
} catch {
|
|
// Missing host paths are expected while walking toward the filesystem root.
|
|
return false
|
|
}
|
|
}
|
|
|
|
function stringField(data: Record<string, unknown>, key: string): string | undefined {
|
|
const value = data[key]
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
}
|
|
|
|
function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
|
|
const value = data[key]
|
|
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
|
|
}
|
|
|
|
function parseInvocationPolicy(data: Record<string, unknown>): 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<string, unknown>, legacy: string, canonical: string): void {
|
|
if (Object.hasOwn(data, legacy)) {
|
|
throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`)
|
|
}
|
|
}
|
|
|
|
function frontmatterBoolean(data: Record<string, unknown>, 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<string, unknown>): { metadata?: Record<string, unknown> } {
|
|
const value = data.metadata
|
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
return { metadata: value as Record<string, unknown> }
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function errorMessage(error: unknown): string {
|
|
return String(error)
|
|
}
|