index.ts 38 KB

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