index.ts 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034
  1. /**
  2. * Local filesystem skill provider.
  3. *
  4. * This package is one implementation of the `ctx.skills` provider registry. It
  5. * discovers directory-bundle and flat Markdown skills from project, custom, and
  6. * user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
  7. * filesystem service is present.
  8. *
  9. * @module @deepseek-ai/dsh-skill-local
  10. */
  11. import { access, readdir, readFile, stat } from 'node:fs/promises'
  12. import { unwatchFile, watchFile, type Stats } from 'node:fs'
  13. import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
  14. import { homedir } from 'node:os'
  15. import type { Context } from 'cordis'
  16. import chokidar from 'chokidar'
  17. import z from 'schemastery'
  18. import type Schema from 'schemastery'
  19. import { parse as parseYaml } from 'yaml'
  20. import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
  21. import { resolveDshHome } from '@deepseek-ai/dsh-paths'
  22. import {
  23. isSkillName,
  24. type SkillCandidate,
  25. type SkillDefinition,
  26. type SkillInvocationPolicy,
  27. type SkillLookupOptions,
  28. type SkillProvider,
  29. type SkillProviderControl,
  30. type SkillProviderObservation,
  31. type SkillSource,
  32. } from '@deepseek-ai/dsh-skill'
  33. const PROJECT_DSH_RANK = 100
  34. const PROJECT_AGENTS_RANK = 200
  35. const CUSTOM_RANK = 300
  36. const USER_DSH_RANK = 400
  37. const USER_AGENTS_RANK = 500
  38. const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 200
  39. const DEFAULT_WATCH_POLL_INTERVAL_MS = 100
  40. const DEFAULT_WATCH_MAX_PROJECTS = 128
  41. const BUNDLED_RANK = 600
  42. export const name = 'skill-local'
  43. export const inject = ['skills']
  44. /** Local filesystem skill provider configuration. */
  45. export interface Config {
  46. /** Unique provider name. Defaults to `local`. */
  47. providerName?: string
  48. /** Whether project and user roots are included around custom roots. */
  49. includeDefaultRoots?: boolean
  50. /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
  51. dshHome?: string
  52. /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
  53. agentsHome?: string
  54. /** Additional skill roots scanned after project roots and before user roots. */
  55. customSkillDirs?: string[]
  56. /** Whether host-local skill roots are watched for catalog changes. */
  57. watch?: boolean
  58. /** Whether Chokidar uses polling instead of native filesystem events. */
  59. watchUsePolling?: boolean
  60. /** Milliseconds a changed skill entry must remain stable before it is observed. */
  61. watchStabilityThresholdMs?: number
  62. /** Milliseconds between Chokidar stability or polling probes. */
  63. watchPollIntervalMs?: number
  64. /** Maximum distinct project roots whose skill directories remain watched. */
  65. watchMaxProjects?: number
  66. /** Whether watched symbolic links follow their target files. */
  67. watchFollowSymlinks?: boolean
  68. /** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */
  69. bundledSkillDir?: string
  70. }
  71. export const Config: Schema<Config> = z.object({
  72. providerName: z.string().min(1).default('local'),
  73. includeDefaultRoots: z.boolean().default(true),
  74. dshHome: z.string(),
  75. agentsHome: z.string(),
  76. customSkillDirs: z.array(z.string()).default([]),
  77. watch: z.boolean().default(true),
  78. watchUsePolling: z.boolean().default(false),
  79. watchStabilityThresholdMs: z.number().default(DEFAULT_WATCH_STABILITY_THRESHOLD_MS),
  80. watchPollIntervalMs: z.number().default(DEFAULT_WATCH_POLL_INTERVAL_MS),
  81. watchMaxProjects: z.number().default(DEFAULT_WATCH_MAX_PROJECTS),
  82. watchFollowSymlinks: z.boolean().default(true),
  83. bundledSkillDir: z.string(),
  84. })
  85. interface SkillRoot {
  86. path: string
  87. source: SkillSource
  88. rank: number
  89. skipSystem?: boolean
  90. projectRoot?: string
  91. trustedHost?: boolean
  92. }
  93. interface SkillRootEntry {
  94. name: string
  95. type: 'directory' | 'file' | 'other'
  96. path: string
  97. }
  98. interface ParsedSkill {
  99. name: string
  100. description: string
  101. whenToUse?: string
  102. invocation: SkillInvocationPolicy
  103. metadata?: Record<string, unknown>
  104. content: string
  105. }
  106. interface LocalLocator {
  107. path: string
  108. directory: string
  109. }
  110. interface ResolvedWatchConfig {
  111. enabled: boolean
  112. usePolling: boolean
  113. stabilityThresholdMs: number
  114. pollIntervalMs: number
  115. maxProjects: number
  116. followSymlinks: boolean
  117. }
  118. /** Register the local filesystem skill provider on `ctx.skills`. */
  119. export function apply(ctx: Context, config: Config = {}): void {
  120. let provider!: LocalSkillProvider
  121. ctx.skills.registerProvider((control) => {
  122. provider = new LocalSkillProvider(ctx, control, config)
  123. return provider
  124. })
  125. ctx.effect(function* () {
  126. yield async () => { await provider.dispose() }
  127. }, 'skill-local watcher')
  128. ctx.on('fs/observed', (target, _version, actor) => {
  129. if (mutationToolName(actor) === undefined) return
  130. provider.observeHostMutation(target.displayPath)
  131. })
  132. }
  133. /** Provider that maps local project/user skill roots into `ctx.skills`. */
  134. export class LocalSkillProvider implements SkillProvider {
  135. readonly name: string
  136. private readonly includeDefaultRoots: boolean
  137. private readonly dshHome: string
  138. private readonly agentsHome: string
  139. private readonly customSkillDirs: string[]
  140. private readonly watchManager: SkillWatchManager
  141. private readonly bundledSkillDir: string | undefined
  142. private disposal: Promise<void> | undefined
  143. constructor(
  144. private readonly ctx: Context,
  145. control: SkillProviderControl,
  146. config: Config = {},
  147. ) {
  148. this.name = config.providerName ?? 'local'
  149. this.includeDefaultRoots = config.includeDefaultRoots ?? true
  150. this.dshHome = resolveDshHome(config.dshHome)
  151. this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
  152. this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
  153. this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config))
  154. control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true })
  155. // The environment bundled root is a default root: an isolated provider
  156. // (includeDefaultRoots: false — repository plugins) must see only its
  157. // explicit custom roots, or every such provider would re-discover the
  158. // app's bundled skills and claim them under its own provider name.
  159. const bundledSkillDir = config.bundledSkillDir
  160. ?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined)
  161. this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
  162. }
  163. /**
  164. * Discover local skill summaries for a cwd-sensitive workspace.
  165. * @param options - lookup options; `cwd` selects the project roots to scan.
  166. * @returns local provider candidates with stable root ranks; watcher startup
  167. * failure returns readable candidates as an incomplete observation.
  168. */
  169. async list(options: SkillLookupOptions): Promise<SkillCandidate[] | SkillProviderObservation> {
  170. const roots = await this.roots(options.cwd)
  171. let complete = true
  172. try {
  173. await this.watchManager.observeRoots(roots)
  174. } catch (error) {
  175. if (this.disposal !== undefined) throw error
  176. complete = false
  177. }
  178. const candidates: SkillCandidate[] = []
  179. for (const root of roots) {
  180. for (const skill of await discoverRoot(root, this.ctx, this.name)) {
  181. candidates.push(skill)
  182. }
  183. }
  184. return complete ? candidates : { candidates, complete }
  185. }
  186. /**
  187. * Load a complete local skill body from the candidate's file locator.
  188. * @param candidate - the winning candidate returned by this provider.
  189. * @param options - lookup options whose signal cancels filesystem reads.
  190. * @returns the full local skill, or `undefined` if the file disappeared.
  191. */
  192. async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
  193. const locator = candidate.locator as LocalLocator
  194. const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled')
  195. if (parsed === undefined) return undefined
  196. return {
  197. name: parsed.name,
  198. description: parsed.description,
  199. ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
  200. invocation: parsed.invocation,
  201. source: candidate.source,
  202. provider: this.name,
  203. resourceBase: { kind: 'directory', path: locator.directory },
  204. path: locator.path,
  205. ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
  206. content: parsed.content,
  207. }
  208. }
  209. /**
  210. * Invalidate this provider synchronously after a first-party filesystem mutation.
  211. * @param path - host display path observed after a model-facing write or edit.
  212. */
  213. observeHostMutation(path: string): void {
  214. this.watchManager.observeHostMutation(path)
  215. }
  216. /**
  217. * Close every host watcher and contain late filesystem callbacks.
  218. * @returns a shared promise that settles when every watcher reaches quiescence.
  219. */
  220. dispose(): Promise<void> {
  221. this.disposal ??= this.watchManager.dispose()
  222. return this.disposal
  223. }
  224. private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
  225. const roots: SkillRoot[] = []
  226. if (this.includeDefaultRoots && cwd !== undefined) {
  227. const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
  228. roots.push(
  229. { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot },
  230. { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot },
  231. )
  232. }
  233. roots.push(...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })))
  234. if (this.includeDefaultRoots) {
  235. roots.push(
  236. { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
  237. { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
  238. )
  239. }
  240. if (this.bundledSkillDir !== undefined) {
  241. roots.push({ path: this.bundledSkillDir, source: 'bundled', rank: BUNDLED_RANK, trustedHost: true })
  242. }
  243. return roots
  244. }
  245. }
  246. type SkillWatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'
  247. type RootWatchMode =
  248. | { kind: 'root'; anchor: string }
  249. | { kind: 'ancestor'; anchor: string; nextPath: string }
  250. interface RootWatchState {
  251. root: SkillRoot
  252. owners: Set<string>
  253. watcher: WatchHandle | undefined
  254. opening: Promise<void> | undefined
  255. unhealthy: boolean
  256. }
  257. interface WatchHandle {
  258. mode: RootWatchMode
  259. close(): Promise<void> | void
  260. }
  261. /** Owns bounded host watchers while discovery and reads remain on the filesystem service. */
  262. class SkillWatchManager {
  263. private readonly roots = new Map<string, RootWatchState>()
  264. private readonly projects = new Map<string, Set<string>>()
  265. private readonly lifecycle = new AbortController()
  266. private closing = false
  267. private invalidationQueued = false
  268. constructor(
  269. private readonly ctx: Context,
  270. private readonly invalidate: () => void,
  271. private readonly config: ResolvedWatchConfig,
  272. ) {}
  273. async observeRoots(roots: readonly SkillRoot[]): Promise<void> {
  274. if (this.closing) return
  275. const projectRoots = new Map<string, SkillRoot[]>()
  276. const pending: Promise<void>[] = []
  277. for (const root of roots) {
  278. if (root.projectRoot === undefined) {
  279. pending.push(this.retainRoot(root, `shared:${root.path}`))
  280. continue
  281. }
  282. const grouped = projectRoots.get(root.projectRoot) ?? []
  283. grouped.push(root)
  284. projectRoots.set(root.projectRoot, grouped)
  285. }
  286. for (const [projectRoot, grouped] of projectRoots) {
  287. const owner = `project:${projectRoot}`
  288. this.projects.delete(projectRoot)
  289. const paths = new Set(grouped.map(root => root.path))
  290. this.projects.set(projectRoot, paths)
  291. for (const root of grouped) pending.push(this.retainRoot(root, owner))
  292. }
  293. let evictedProject = false
  294. while (this.projects.size > this.config.maxProjects) {
  295. const oldest = this.projects.entries().next()
  296. /* v8 ignore next -- the loop condition proves one project exists. */
  297. if (oldest.done) break
  298. const [projectRoot, paths] = oldest.value
  299. this.projects.delete(projectRoot)
  300. const owner = `project:${projectRoot}`
  301. for (const path of paths) pending.push(this.releaseRoot(path, owner))
  302. evictedProject = true
  303. }
  304. await Promise.all(pending)
  305. if (evictedProject) this.invalidate()
  306. }
  307. observeHostMutation(path: string): void {
  308. if (this.closing) return
  309. const normalized = resolve(path)
  310. if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return
  311. this.invalidate()
  312. }
  313. async dispose(): Promise<void> {
  314. this.closing = true
  315. this.lifecycle.abort(new Error('skill-local watcher disposed'))
  316. const states = [...this.roots.values()]
  317. this.roots.clear()
  318. this.projects.clear()
  319. await Promise.all(states.map(async (state) => {
  320. await settleWatcherOpening(state.opening)
  321. const watcher = state.watcher
  322. state.watcher = undefined
  323. if (watcher !== undefined) await this.closeWatcher(watcher)
  324. }))
  325. }
  326. private async retainRoot(root: SkillRoot, owner: string): Promise<void> {
  327. let state = this.roots.get(root.path)
  328. if (state === undefined) {
  329. state = { root, owners: new Set(), watcher: undefined, opening: undefined, unhealthy: true }
  330. this.roots.set(root.path, state)
  331. }
  332. state.owners.add(owner)
  333. if (this.config.enabled) await this.ensureWatcher(state)
  334. }
  335. private async releaseRoot(path: string, owner: string): Promise<void> {
  336. const state = this.roots.get(path)
  337. /* v8 ignore next -- Concurrent cwd observations can evict the same shared root before this release settles. */
  338. if (state === undefined) return
  339. state.owners.delete(owner)
  340. if (state.owners.size > 0) return
  341. this.roots.delete(path)
  342. await settleWatcherOpening(state.opening)
  343. const watcher = state.watcher
  344. state.watcher = undefined
  345. if (watcher !== undefined) await this.closeWatcher(watcher)
  346. }
  347. private ensureWatcher(state: RootWatchState): Promise<void> {
  348. /* v8 ignore next -- A scheduled rewatch can reach this guard only when teardown wins its await. */
  349. if (this.closing || !this.config.enabled) return Promise.resolve()
  350. if (state.opening !== undefined) return state.opening
  351. const opening = this.ensureCurrentWatcher(state)
  352. state.opening = opening
  353. void opening.then(
  354. () => {
  355. state.opening = undefined
  356. },
  357. () => {
  358. state.opening = undefined
  359. },
  360. )
  361. return opening
  362. }
  363. private async ensureCurrentWatcher(state: RootWatchState): Promise<void> {
  364. const watcher = state.watcher
  365. if (watcher !== undefined && !state.unhealthy) {
  366. const current = await resolveRootWatchMode(state.root.path)
  367. // A child unlink can publish an empty catalog before root unlinkDir arrives.
  368. // Discovery therefore revalidates the retained handle independently.
  369. // oxlint-disable-next-line typescript/no-unnecessary-condition -- watcher callbacks can mark unhealthy while the probe awaits
  370. if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return
  371. }
  372. await this.replaceWatcher(state)
  373. }
  374. private async replaceWatcher(state: RootWatchState): Promise<void> {
  375. const previous = state.watcher
  376. state.watcher = undefined
  377. if (previous !== undefined) await this.closeWatcher(previous)
  378. /* v8 ignore next -- Teardown can win while an unhealthy watcher is still closing. */
  379. if (this.closing || state.owners.size === 0) return
  380. try {
  381. const watcher = await this.openStableWatcher(state)
  382. /* v8 ignore next -- The loop returns no handle only when teardown wins between awaited probes. */
  383. if (watcher === undefined) return
  384. /* v8 ignore start -- Post-open teardown is timing-dependent; the disposal race has an explicit integration test. */
  385. // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup
  386. if (this.closing || state.owners.size === 0) {
  387. await this.closeWatcher(watcher)
  388. return
  389. }
  390. /* v8 ignore stop */
  391. state.watcher = watcher
  392. state.unhealthy = false
  393. } catch (error) {
  394. // oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup
  395. if (!this.closing) {
  396. state.unhealthy = true
  397. this.ctx.logger.warn(`skill-local: failed to watch ${state.root.path}: ${errorMessage(error)}`)
  398. }
  399. throw error
  400. }
  401. }
  402. // TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis
  403. // service; keep skill filtering and invalidation here.
  404. private async openStableWatcher(state: RootWatchState): Promise<WatchHandle | undefined> {
  405. while (!this.closing && state.owners.size > 0) {
  406. const mode = await resolveRootWatchMode(state.root.path)
  407. const watcher = mode.kind === 'ancestor'
  408. ? this.openAncestorWatcher(state, mode)
  409. : await this.openRootWatcher(state, mode)
  410. const current = await resolveRootWatchMode(state.root.path)
  411. /* v8 ignore else -- A host path transition between the two probes is timing-dependent. */
  412. if (sameWatchMode(mode, current)) return watcher
  413. /* v8 ignore next -- Covered by the same host path transition guard. */
  414. await this.closeWatcher(watcher)
  415. }
  416. /* v8 ignore next -- The loop exits only when teardown wins between awaited probes. */
  417. return undefined
  418. }
  419. private openAncestorWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'ancestor' }>): WatchHandle {
  420. const listener = (_current: Stats, _previous: Stats): void => {
  421. void this.handleAncestorWatchEvent(state, mode)
  422. }
  423. watchFile(mode.nextPath, {
  424. persistent: false,
  425. interval: this.config.pollIntervalMs,
  426. }, listener)
  427. return {
  428. mode,
  429. close() {
  430. unwatchFile(mode.nextPath, listener)
  431. },
  432. }
  433. }
  434. private async handleAncestorWatchEvent(
  435. state: RootWatchState,
  436. mode: Extract<RootWatchMode, { kind: 'ancestor' }>,
  437. ): Promise<void> {
  438. let current: RootWatchMode
  439. try {
  440. current = await resolveRootWatchMode(state.root.path)
  441. } catch (error) {
  442. /* v8 ignore start -- Non-absence stat failures need a platform permission or I/O fault. */
  443. if (!this.closing && state.owners.size > 0) this.handleWatcherError(state, error)
  444. return
  445. /* v8 ignore stop */
  446. }
  447. if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return
  448. this.queueInvalidation()
  449. state.unhealthy = true
  450. this.scheduleRewatch(state)
  451. }
  452. private async openRootWatcher(state: RootWatchState, mode: Extract<RootWatchMode, { kind: 'root' }>): Promise<WatchHandle> {
  453. const watcher = chokidar.watch(mode.anchor, {
  454. persistent: false,
  455. ignoreInitial: true,
  456. depth: 1,
  457. followSymlinks: this.config.followSymlinks,
  458. atomic: true,
  459. awaitWriteFinish: {
  460. stabilityThreshold: this.config.stabilityThresholdMs,
  461. pollInterval: this.config.pollIntervalMs,
  462. },
  463. usePolling: this.config.usePolling,
  464. interval: this.config.pollIntervalMs,
  465. })
  466. const handle: WatchHandle = {
  467. mode,
  468. close: () => watcher.close(),
  469. }
  470. let ready = false
  471. const readiness = Promise.withResolvers<undefined>()
  472. const signal = this.lifecycle.signal
  473. if (signal.aborted) {
  474. await this.closeWatcher(handle)
  475. signal.throwIfAborted()
  476. }
  477. const onAbort = (): void => { readiness.reject(signal.reason) }
  478. signal.addEventListener('abort', onAbort, { once: true })
  479. const onError = (error: unknown): void => {
  480. if (!ready) {
  481. readiness.reject(error)
  482. return
  483. }
  484. this.handleWatcherError(state, error)
  485. }
  486. watcher.on('error', onError)
  487. watcher.once('ready', () => {
  488. ready = true
  489. readiness.resolve(undefined)
  490. })
  491. for (const event of ['add', 'addDir', 'change', 'unlink', 'unlinkDir'] as const) {
  492. watcher.on(event, (path) => { this.handleWatchEvent(state, event, path) })
  493. }
  494. try {
  495. await readiness.promise
  496. } catch (error) {
  497. await this.closeWatcher(handle)
  498. throw error
  499. } finally {
  500. signal.removeEventListener('abort', onAbort)
  501. }
  502. return handle
  503. }
  504. private handleWatchEvent(
  505. state: RootWatchState,
  506. event: SkillWatchEvent,
  507. path: string,
  508. ): void {
  509. if (this.closing || !isRelevantWatchEvent(state.root, event, resolve(path))) return
  510. this.queueInvalidation()
  511. if (resolve(path) === state.root.path && event === 'unlinkDir') {
  512. state.unhealthy = true
  513. this.scheduleRewatch(state)
  514. }
  515. }
  516. private handleWatcherError(state: RootWatchState, error: unknown): void {
  517. if (this.closing) return
  518. this.ctx.logger.warn(`skill-local: watcher for ${state.root.path} failed: ${errorMessage(error)}`)
  519. state.unhealthy = true
  520. this.queueInvalidation()
  521. this.scheduleRewatch(state)
  522. }
  523. private scheduleRewatch(state: RootWatchState): void {
  524. const currentOpening = state.opening ?? Promise.resolve()
  525. void (async () => {
  526. await settleWatcherOpening(currentOpening)
  527. try {
  528. await this.ensureWatcher(state)
  529. } catch {
  530. // Watch startup logged the retry failure; the next incomplete discovery retries it again.
  531. return
  532. }
  533. this.queueInvalidation()
  534. })()
  535. }
  536. private queueInvalidation(): void {
  537. if (this.closing || this.invalidationQueued) return
  538. this.invalidationQueued = true
  539. queueMicrotask(() => {
  540. this.invalidationQueued = false
  541. /* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */
  542. if (this.closing) return
  543. this.invalidate()
  544. })
  545. }
  546. private async closeWatcher(watcher: WatchHandle): Promise<void> {
  547. try {
  548. await watcher.close()
  549. } catch (error) {
  550. this.ctx.logger.warn(`skill-local: failed to close watcher: ${errorMessage(error)}`)
  551. }
  552. }
  553. }
  554. async function settleWatcherOpening(opening: Promise<void> | undefined): Promise<void> {
  555. if (opening === undefined) return
  556. try {
  557. await opening
  558. } catch {
  559. // Watch startup already logged the underlying failure; teardown only contains it.
  560. }
  561. }
  562. function resolveWatchConfig(config: Config): ResolvedWatchConfig {
  563. const stabilityThresholdMs = config.watchStabilityThresholdMs ?? DEFAULT_WATCH_STABILITY_THRESHOLD_MS
  564. const pollIntervalMs = config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS
  565. const maxProjects = config.watchMaxProjects ?? DEFAULT_WATCH_MAX_PROJECTS
  566. assertPositiveInteger('watchStabilityThresholdMs', stabilityThresholdMs)
  567. assertPositiveInteger('watchPollIntervalMs', pollIntervalMs)
  568. assertPositiveInteger('watchMaxProjects', maxProjects)
  569. return {
  570. enabled: config.watch ?? true,
  571. usePolling: config.watchUsePolling ?? false,
  572. stabilityThresholdMs,
  573. pollIntervalMs,
  574. maxProjects,
  575. followSymlinks: config.watchFollowSymlinks ?? true,
  576. }
  577. }
  578. async function resolveRootWatchMode(root: string): Promise<RootWatchMode> {
  579. let candidate = root
  580. while (true) {
  581. try {
  582. const info = await stat(candidate)
  583. if (info.isDirectory()) {
  584. if (candidate === root) return { kind: 'root', anchor: root }
  585. const firstSegment = relative(candidate, root).split(sep)[0]
  586. /* v8 ignore next -- candidate is a strict ancestor of root. */
  587. if (firstSegment === undefined || firstSegment.length === 0) return { kind: 'root', anchor: root }
  588. return { kind: 'ancestor', anchor: candidate, nextPath: join(candidate, firstSegment) }
  589. }
  590. } catch (error) {
  591. /* v8 ignore next -- Non-absence stat failures are platform/permission-specific and propagate as incomplete discovery. */
  592. if (!isAbsentPathError(error)) throw error
  593. }
  594. const parent = dirname(candidate)
  595. /* v8 ignore next -- Traversal reaches the existing filesystem root before this fallback. */
  596. if (parent === candidate) return { kind: 'ancestor', anchor: candidate, nextPath: root }
  597. candidate = parent
  598. }
  599. }
  600. function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean {
  601. return left.kind === right.kind
  602. && left.anchor === right.anchor
  603. && (left.kind === 'root' || (right.kind === 'ancestor' && left.nextPath === right.nextPath))
  604. }
  605. function isRelevantWatchEvent(
  606. root: SkillRoot,
  607. event: SkillWatchEvent,
  608. path: string,
  609. ): boolean {
  610. const segments = containedSegments(root.path, path)
  611. if (segments === undefined) return false
  612. if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir'
  613. if (root.skipSystem === true && segments[0] === '.system') return false
  614. if (segments.length === 1) {
  615. if (event === 'addDir' || event === 'unlinkDir') return true
  616. return segments[0]?.endsWith('.md') === true
  617. }
  618. return segments.length === 2
  619. && segments[1] === 'SKILL.md'
  620. && event !== 'addDir'
  621. && event !== 'unlinkDir'
  622. }
  623. function isPotentialSkillPath(root: SkillRoot, path: string): boolean {
  624. const segments = containedSegments(root.path, path)
  625. if (segments === undefined || segments.length === 0 || segments.length > 2) return false
  626. if (root.skipSystem === true && segments[0] === '.system') return false
  627. return segments.length === 1
  628. ? segments[0]?.endsWith('.md') === true
  629. : segments[1] === 'SKILL.md'
  630. }
  631. function containedSegments(root: string, path: string): string[] | undefined {
  632. const child = relative(root, path)
  633. if (child.length === 0) return []
  634. if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) return undefined
  635. return child.split(sep)
  636. }
  637. function mutationToolName(actor: object | undefined): 'edit' | 'write' | undefined {
  638. if (actor === undefined || !('name' in actor)) return undefined
  639. const value = actor.name
  640. return value === 'edit' || value === 'write' ? value : undefined
  641. }
  642. function assertPositiveInteger(field: string, value: number): void {
  643. if (!Number.isInteger(value) || value < 1) {
  644. throw new TypeError(`skill-local: ${field} must be a positive integer`)
  645. }
  646. }
  647. function isAbsentPathError(error: unknown): boolean {
  648. return hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')
  649. }
  650. function isAbsentSkillPathError(error: unknown): boolean {
  651. return isAbsentPathError(error)
  652. || hasErrorCode(error, 'FS_NOT_FOUND')
  653. || hasErrorCode(error, 'FS_NOT_DIRECTORY')
  654. }
  655. function hasErrorCode(error: unknown, code: string): boolean {
  656. return typeof error === 'object' && error !== null && 'code' in error && error.code === code
  657. }
  658. async function discoverRoot(root: SkillRoot, ctx: Context, provider: string): Promise<SkillCandidate[]> {
  659. const skills: SkillCandidate[] = []
  660. const entries = await listSkillRootEntries(root, ctx)
  661. for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
  662. if (root.skipSystem && entry.name === '.system') continue
  663. const locator = entry.type === 'directory'
  664. ? { path: join(entry.path, 'SKILL.md'), directory: entry.path }
  665. : entry.type === 'file' && entry.name.endsWith('.md')
  666. ? { path: entry.path, directory: root.path }
  667. : undefined
  668. if (locator === undefined) continue
  669. const parsed = await parseSkillFile(locator.path, ctx, undefined, root.trustedHost === true)
  670. if (parsed === undefined) continue
  671. skills.push({
  672. name: parsed.name,
  673. description: parsed.description,
  674. ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
  675. invocation: parsed.invocation,
  676. provider,
  677. source: root.source,
  678. rank: root.rank,
  679. locator,
  680. resourceBase: { kind: 'directory', path: locator.directory },
  681. path: locator.path,
  682. ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
  683. })
  684. }
  685. return skills
  686. }
  687. async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
  688. const fs = optionalFileSystem(ctx)
  689. if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs)
  690. return await listSkillRootEntriesFromNode(root, ctx)
  691. }
  692. async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
  693. try {
  694. return (await fsListDir(fs, root.path)).map(entryFromFs)
  695. } catch (error) {
  696. if (isAbsentSkillPathError(error)) return []
  697. throw error
  698. }
  699. }
  700. async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {
  701. const target = await fs.resolve(path)
  702. return await fs.listDir(target)
  703. }
  704. function entryFromFs(entry: FsDirEntry): SkillRootEntry {
  705. return { name: entry.name, type: entry.type, path: entry.target.displayPath }
  706. }
  707. async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
  708. let entries
  709. try {
  710. entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
  711. } catch (error) {
  712. /* v8 ignore else -- Native non-absence directory failures are provider-dependent; the ctx.fs path pins incomplete discovery. */
  713. if (isAbsentSkillPathError(error)) return []
  714. /* v8 ignore next -- Same native error branch as above. */
  715. throw error
  716. }
  717. const result: SkillRootEntry[] = []
  718. for (const entry of entries) {
  719. const path = join(root.path, entry.name)
  720. const type = await nodeEntryKind(path, entry, ctx)
  721. result.push({ name: entry.name, type: type ?? 'other', path })
  722. }
  723. return result
  724. }
  725. async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise<ParsedSkill | undefined> {
  726. const raw = await readSkillText(ctx, path, signal, trustedHost)
  727. signal?.throwIfAborted()
  728. if (raw === undefined) {
  729. return undefined
  730. }
  731. let parsed
  732. try {
  733. parsed = parseFrontmatter(raw)
  734. } catch (error) {
  735. ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
  736. return undefined
  737. }
  738. if (!parsed) {
  739. ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
  740. return undefined
  741. }
  742. const name = stringField(parsed.data, 'name')
  743. const description = stringField(parsed.data, 'description')
  744. if (name === undefined || description === undefined) {
  745. ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`)
  746. return undefined
  747. }
  748. if (!isSkillName(name)) {
  749. ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
  750. return undefined
  751. }
  752. let invocation
  753. try {
  754. invocation = parseInvocationPolicy(parsed.data)
  755. } catch (error) {
  756. ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`)
  757. return undefined
  758. }
  759. return {
  760. name,
  761. description,
  762. ...optionalString(parsed.data, 'whenToUse'),
  763. invocation,
  764. ...optionalMetadata(parsed.data),
  765. content: parsed.body.trim(),
  766. }
  767. }
  768. function optionalFileSystem(ctx: Context): FileSystem | undefined {
  769. return ctx.get('fs')
  770. }
  771. async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise<string | undefined> {
  772. signal?.throwIfAborted()
  773. const fs = optionalFileSystem(ctx)
  774. if (fs !== undefined && !trustedHost) {
  775. return await readSkillTextFromFileSystem(ctx, fs, path, signal)
  776. }
  777. try {
  778. return await readFile(path, { encoding: 'utf8', signal })
  779. } catch (error) {
  780. signal?.throwIfAborted()
  781. if (isAbsentSkillPathError(error)) return undefined
  782. throw error
  783. }
  784. }
  785. async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise<string | undefined> {
  786. // A missing or temporarily inaccessible skill file is not fatal to discovery.
  787. signal?.throwIfAborted()
  788. let target
  789. try {
  790. target = await fs.resolve(path)
  791. } catch (error) {
  792. if (isAbsentSkillPathError(error)) return undefined
  793. throw error
  794. }
  795. signal?.throwIfAborted()
  796. let info
  797. try {
  798. info = await fs.stat(target, signal)
  799. } catch (error) {
  800. signal?.throwIfAborted()
  801. if (isAbsentSkillPathError(error)) return undefined
  802. throw error
  803. }
  804. if (info === undefined || info.type !== 'file') return undefined
  805. try {
  806. return await fs.readText(target, signal)
  807. } catch (error) {
  808. signal?.throwIfAborted()
  809. if (isAbsentSkillPathError(error)) return undefined
  810. if (!hasErrorCode(error, 'FS_NOT_TEXT')) throw error
  811. ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
  812. return undefined
  813. }
  814. }
  815. function fsReadErrorMessage(target: FsTarget, error: unknown): string {
  816. return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
  817. }
  818. async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
  819. if (entry.isDirectory()) return 'directory'
  820. if (entry.isFile()) return 'file'
  821. /* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
  822. if (!entry.isSymbolicLink()) return undefined
  823. try {
  824. const info = await stat(fullPath)
  825. if (info.isDirectory()) return 'directory'
  826. /* v8 ignore else -- the special-file symlink branch relies on POSIX /dev/null. */
  827. if (info.isFile()) return 'file'
  828. /* v8 ignore next -- The special-file symlink fixture relies on POSIX /dev/null. */
  829. return undefined
  830. } catch (error) {
  831. ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
  832. return undefined
  833. }
  834. }
  835. function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
  836. const firstLineEnd = raw.indexOf('\n')
  837. if (firstLineEnd < 0) return undefined
  838. const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
  839. if (firstLine !== '---') return undefined
  840. const start = firstLineEnd + 1
  841. const closing = findClosingFrontmatter(raw, start)
  842. if (closing === undefined) return undefined
  843. const yaml = raw.slice(start, closing.start)
  844. const parsed = parseYaml(yaml) as unknown
  845. if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
  846. return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
  847. }
  848. function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
  849. let lineStart = start
  850. while (lineStart <= raw.length) {
  851. const nextNewline = raw.indexOf('\n', lineStart)
  852. const lineEnd = nextNewline < 0 ? raw.length : nextNewline
  853. const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
  854. if (line === '---') {
  855. return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
  856. }
  857. if (nextNewline < 0) return undefined
  858. lineStart = nextNewline + 1
  859. }
  860. }
  861. async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
  862. let current = cwd
  863. while (true) {
  864. if (await pathExists(join(current, '.git'), fs)) {
  865. return current
  866. }
  867. const parent = dirname(current)
  868. if (parent === current) return cwd
  869. current = parent
  870. }
  871. }
  872. async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
  873. if (fs !== undefined) {
  874. return await pathExistsInFileSystem(path, fs)
  875. }
  876. return await pathExistsInNode(path)
  877. }
  878. async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
  879. let target
  880. try {
  881. target = await fs.resolve(path)
  882. } catch {
  883. // A backend may reject or hide this candidate; continue walking upward.
  884. return false
  885. }
  886. try {
  887. return await fs.stat(target) !== undefined
  888. } catch {
  889. // Transient stat failures make only this git-root candidate unusable.
  890. return false
  891. }
  892. }
  893. async function pathExistsInNode(path: string): Promise<boolean> {
  894. try {
  895. await access(path)
  896. return true
  897. } catch {
  898. // Missing host paths are expected while walking toward the filesystem root.
  899. return false
  900. }
  901. }
  902. function stringField(data: Record<string, unknown>, key: string): string | undefined {
  903. const value = data[key]
  904. return typeof value === 'string' && value.length > 0 ? value : undefined
  905. }
  906. function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
  907. const value = data[key]
  908. return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
  909. }
  910. function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy {
  911. rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation')
  912. rejectLegacyInvocationKey(data, 'modelInvocable', 'disable-model-invocation')
  913. rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable')
  914. const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation')
  915. const userInvocable = frontmatterBoolean(data, 'user-invocable')
  916. return {
  917. modelInvocable: disableModelInvocation !== true,
  918. userInvocable: userInvocable !== false,
  919. }
  920. }
  921. function rejectLegacyInvocationKey(data: Record<string, unknown>, legacy: string, canonical: string): void {
  922. if (Object.hasOwn(data, legacy)) {
  923. throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`)
  924. }
  925. }
  926. function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
  927. if (!Object.hasOwn(data, key)) return undefined
  928. const value = data[key]
  929. if (typeof value === 'boolean') return value
  930. if (value === 1 || value === '1') return true
  931. if (value === 0 || value === '0') return false
  932. if (typeof value === 'string') {
  933. switch (value.toLowerCase()) {
  934. case 'true':
  935. case 'yes':
  936. case 'on':
  937. return true
  938. case 'false':
  939. case 'no':
  940. case 'off':
  941. return false
  942. }
  943. }
  944. throw new TypeError(`frontmatter field "${key}" must be a boolean`)
  945. }
  946. function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
  947. const value = data.metadata
  948. if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
  949. return { metadata: value as Record<string, unknown> }
  950. }
  951. return {}
  952. }
  953. function errorMessage(error: unknown): string {
  954. return String(error)
  955. }