返回源码地图

packages/skill/skill-filesystem/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 09 / 09;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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