| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034 |
- /**
- * 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 {
- /** 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, _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: 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
- // (includeDefaultRoots: false — repository plugins) must see only its
- // explicit custom roots, or every such provider would re-discover the
- // app's bundled skills and claim them 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_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)
- // 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)
- 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<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)
- } 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, {
- 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<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, 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<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): Promise<RootWatchMode> {
- 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, 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)
- }
|