1
/**2
* Local filesystem skill provider.3
*4
* This package is one implementation of the `ctx.skills` provider registry. It5
* discovers directory-bundle and flat Markdown skills from project, custom, and6
* user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a7
* filesystem service is present.8
*9
* @module @deepseek-ai/dsh-skill-filesystem10
*/12
import { access, lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'13
import { unwatchFile, watchFile, type Stats } from 'node:fs'14
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'15
import { homedir } from 'node:os'16
import type { Context } from '@deepseek-ai/cordis'17
import chokidar from 'chokidar'18
import z from '@deepseek-ai/schemastery'19
import type Schema from '@deepseek-ai/schemastery'20
import { parse as parseYaml } from 'yaml'21
import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'22
import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'23
import {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'36
const PROJECT_DSH_RANK = 10037
const PROJECT_AGENTS_RANK = 20038
const CUSTOM_RANK = 30039
const USER_DSH_RANK = 40040
const USER_AGENTS_RANK = 50041
const DEFAULT_WATCH_STABILITY_THRESHOLD_MS = 20042
const DEFAULT_WATCH_POLL_INTERVAL_MS = 10043
const DEFAULT_WATCH_MAX_PROJECTS = 12845
export const name = 'skill-filesystem'46
export const inject = ['skills']48
/** Local filesystem skill provider configuration. */49
export interface Config {50
/** Unique provider name. Defaults to `filesystem`. */51
providerName?: string52
/** Whether project and user roots are included around custom roots. */53
includeDefaultRoots?: boolean54
/** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */55
dshHome?: string56
/** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */57
agentsHome?: string58
/** 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?: boolean62
/** Whether Chokidar uses polling instead of native filesystem events. */63
watchUsePolling?: boolean64
/** Milliseconds a changed skill entry must remain stable before it is observed. */65
watchStabilityThresholdMs?: number66
/** Milliseconds between Chokidar stability or polling probes. */67
watchPollIntervalMs?: number68
/** Maximum distinct project roots whose skill directories remain watched. */69
watchMaxProjects?: number70
/** Whether watched symbolic links follow their target files. */71
watchFollowSymlinks?: boolean72
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR` when default roots are included, otherwise mounts none. */73
bundledSkillDir?: string74
}76
export 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
})91
interface SkillRoot {92
path: string93
source: SkillSource94
rank: number95
skipSystem?: boolean96
projectRoot?: string97
trustedHost?: boolean98
}100
interface SkillRootEntry {101
name: string102
type: 'directory' | 'file' | 'other'103
path: string104
}106
interface SkillText {107
path: string108
content: string109
}111
interface ParsedSkill extends SkillText {112
name: string113
description: string114
whenToUse?: string115
invocation: SkillInvocationPolicy116
metadata?: Record<string, unknown>117
}119
interface LocalLocator {120
path: string121
directory: string122
}124
interface ResolvedWatchConfig {125
enabled: boolean126
usePolling: boolean127
stabilityThresholdMs: number128
pollIntervalMs: number129
maxProjects: number130
followSymlinks: boolean131
}133
/** Register the local filesystem skill provider on `ctx.skills`. */134
export function apply(ctx: Context, config: Config = {}): void {135
let provider!: FileSystemSkillProvider136
ctx.skills.registerProvider((control) => {137
provider = new FileSystemSkillProvider(ctx, control, config)138
return provider139
})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) return145
provider.observeHostMutation(target.displayPath)146
})147
}149
/** Provider that maps local project/user skill roots into `ctx.skills`. */150
export class FileSystemSkillProvider implements SkillProvider {151
readonly name: string152
private readonly includeDefaultRoots: boolean153
private readonly dshHome: string154
private readonly agentsHome: string155
private readonly customSkillDirs: string[]156
private readonly watchManager: SkillWatchManager157
private readonly bundledSkillDir: string | undefined158
private disposal: Promise<void> | undefined160
constructor(161
private readonly ctx: Context,162
control: SkillProviderControl,163
config: Config = {},164
) {165
this.name = config.providerName ?? 'filesystem'166
this.includeDefaultRoots = config.includeDefaultRoots ?? true167
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 provider173
// must see only its explicit roots, or every such provider would174
// re-discover the app's bundled skills under its own provider name.175
const bundledSkillDir = config.bundledSkillDir176
?? (this.includeDefaultRoots ? process.env.DSH_BUNDLED_SKILL_DIR : undefined)177
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)178
}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 startup184
* 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 = true189
try {190
await this.watchManager.observeRoots(roots)191
} catch (error) {192
if (this.disposal !== undefined) throw error193
complete = false194
}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
}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 LocalLocator212
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled')213
if (parsed === undefined) return undefined214
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
}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
}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.disposal243
}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 roots265
}266
}268
type SkillWatchEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'270
type RootWatchMode =271
| { kind: 'root'; anchor: string }272
| { kind: 'ancestor'; anchor: string; nextPath: string }274
interface RootWatchState {275
root: SkillRoot276
owners: Set<string>277
watcher: WatchHandle | undefined278
opening: Promise<void> | undefined279
unhealthy: boolean280
}282
interface WatchHandle {283
mode: RootWatchMode284
close(): Promise<void> | void285
}287
/** Owns bounded host watchers while discovery and reads remain on the filesystem service. */288
class 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 = false293
private invalidationQueued = false295
constructor(296
private readonly ctx: Context,297
private readonly invalidate: () => void,298
private readonly config: ResolvedWatchConfig,299
) {}301
async observeRoots(roots: readonly SkillRoot[]): Promise<void> {302
if (this.closing) return303
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
continue309
}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 = false322
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) break326
const [projectRoot, paths] = oldest.value327
this.projects.delete(projectRoot)328
const owner = `project:${projectRoot}`329
for (const path of paths) pending.push(this.releaseRoot(path, owner))330
evictedProject = true331
}332
await Promise.all(pending)333
if (evictedProject) this.invalidate()334
}336
observeHostMutation(path: string): void {337
if (this.closing) return338
const normalized = resolve(path)339
if (![...this.roots.values()].some(state => isPotentialSkillPath(state.root, normalized))) return340
this.invalidate()341
}343
async dispose(): Promise<void> {344
this.closing = true345
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.watcher352
state.watcher = undefined353
if (watcher !== undefined) await this.closeWatcher(watcher)354
}))355
}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
}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) return371
state.owners.delete(owner)372
if (state.owners.size > 0) return373
this.roots.delete(path)374
await settleWatcherOpening(state.opening)375
const watcher = state.watcher376
state.watcher = undefined377
if (watcher !== undefined) await this.closeWatcher(watcher)378
}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.opening384
const opening = this.ensureCurrentWatcher(state)385
state.opening = opening386
void opening.then(387
() => {388
state.opening = undefined389
},390
() => {391
state.opening = undefined392
},393
)394
return opening395
}397
private async ensureCurrentWatcher(state: RootWatchState): Promise<void> {398
const watcher = state.watcher399
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 awaits404
if (!state.unhealthy && sameWatchMode(watcher.mode, current)) return405
}406
await this.replaceWatcher(state)407
}409
private async replaceWatcher(state: RootWatchState): Promise<void> {410
const previous = state.watcher411
state.watcher = undefined412
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) return415
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) return419
/* 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 startup421
if (this.closing || state.owners.size === 0) {422
await this.closeWatcher(watcher)423
return424
}425
/* v8 ignore stop */426
state.watcher = watcher427
state.unhealthy = false428
} catch (error) {429
// oxlint-disable-next-line typescript/no-unnecessary-condition -- teardown can race awaited watcher startup430
if (!this.closing) {431
state.unhealthy = true432
this.ctx.logger.warn(`skill-filesystem: failed to watch ${state.root.path}: ${errorMessage(error)}`)433
}434
throw error435
}436
}438
// TODO(file-watch-service): Extract Chokidar and missing-root observation below into a Cordis439
// 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 watcher449
/* 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 undefined454
}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
}472
private async handleAncestorWatchEvent(473
state: RootWatchState,474
mode: Extract<RootWatchMode, { kind: 'ancestor' }>,475
): Promise<void> {476
let current: RootWatchMode477
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
return483
/* v8 ignore stop */484
}485
if (this.closing || state.owners.size === 0 || sameWatchMode(mode, current)) return486
this.queueInvalidation()487
state.unhealthy = true488
this.scheduleRewatch(state)489
}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 = false512
const readiness = Promise.withResolvers<undefined>()513
const signal = this.lifecycle.signal514
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
return524
}525
this.handleWatcherError(state, error)526
}527
watcher.on('error', onError)528
watcher.once('ready', () => {529
ready = true530
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.promise537
} catch (error) {538
await this.closeWatcher(handle)539
throw error540
} finally {541
signal.removeEventListener('abort', onAbort)542
}543
return handle544
}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)) return554
this.queueInvalidation()555
if (target === mode.anchor && event === 'unlinkDir') {556
state.unhealthy = true557
this.scheduleRewatch(state)558
}559
}561
private handleWatcherError(state: RootWatchState, error: unknown): void {562
if (this.closing) return563
this.ctx.logger.warn(`skill-filesystem: watcher for ${state.root.path} failed: ${errorMessage(error)}`)564
state.unhealthy = true565
this.queueInvalidation()566
this.scheduleRewatch(state)567
}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
return578
}579
this.queueInvalidation()580
})()581
}583
private queueInvalidation(): void {584
if (this.closing || this.invalidationQueued) return585
this.invalidationQueued = true586
queueMicrotask(() => {587
this.invalidationQueued = false588
/* v8 ignore next -- Effect teardown can win this queued microtask before provider disposal emits. */589
if (this.closing) return590
this.invalidate()591
})592
}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
}603
async function settleWatcherOpening(opening: Promise<void> | undefined): Promise<void> {604
if (opening === undefined) return605
try {606
await opening607
} catch {608
// Watch startup already logged the underlying failure; teardown only contains it.609
}610
}612
function resolveWatchConfig(config: Config): ResolvedWatchConfig {613
const stabilityThresholdMs = config.watchStabilityThresholdMs ?? DEFAULT_WATCH_STABILITY_THRESHOLD_MS614
const pollIntervalMs = config.watchPollIntervalMs ?? DEFAULT_WATCH_POLL_INTERVAL_MS615
const maxProjects = config.watchMaxProjects ?? DEFAULT_WATCH_MAX_PROJECTS616
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
}629
async function resolveRootWatchMode(root: string, followSymlinks: boolean): Promise<RootWatchMode> {630
let candidate = root631
while (true) {632
try {633
const info = await stat(candidate)634
if (info.isDirectory()) {635
const preserveRootLink = candidate === root636
&& !followSymlinks637
&& (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 error648
}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 = parent653
}654
}656
function sameWatchMode(left: RootWatchMode, right: RootWatchMode): boolean {657
return left.kind === right.kind658
&& left.anchor === right.anchor659
&& (left.kind === 'root' || (right.kind === 'ancestor' && left.nextPath === right.nextPath))660
}662
function isRelevantWatchEvent(663
root: SkillRoot,664
event: SkillWatchEvent,665
path: string,666
): boolean {667
const segments = containedSegments(root.path, path)668
if (segments === undefined) return false669
if (segments.length === 0) return event === 'addDir' || event === 'unlinkDir'670
if (root.skipSystem === true && segments[0] === '.system') return false671
if (segments.length === 1) {672
if (event === 'addDir' || event === 'unlinkDir') return true673
return segments[0]?.endsWith('.md') === true674
}675
return segments.length === 2676
&& segments[1] === 'SKILL.md'677
&& event !== 'addDir'678
&& event !== 'unlinkDir'679
}681
function isPotentialSkillPath(root: SkillRoot, path: string): boolean {682
const segments = containedSegments(root.path, path)683
if (segments === undefined || segments.length === 0 || segments.length > 2) return false684
if (root.skipSystem === true && segments[0] === '.system') return false685
return segments.length === 1686
? segments[0]?.endsWith('.md') === true687
: segments[1] === 'SKILL.md'688
}690
function 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 undefined694
return child.split(sep)695
}697
function mutationToolName(actor: object | undefined): 'edit' | 'write' | undefined {698
if (actor === undefined || !('name' in actor)) return undefined699
const value = actor.name700
return value === 'edit' || value === 'write' ? value : undefined701
}703
function 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
}709
function isAbsentPathError(error: unknown): boolean {710
return hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTDIR')711
}713
function isAbsentSkillPathError(error: unknown): boolean {714
return isAbsentPathError(error)715
|| hasErrorCode(error, 'FS_NOT_FOUND')716
|| hasErrorCode(error, 'FS_NOT_DIRECTORY')717
}719
function hasErrorCode(error: unknown, code: string): boolean {720
return typeof error === 'object' && error !== null && 'code' in error && error.code === code721
}723
async 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') continue728
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
: undefined733
if (locator === undefined) continue734
const parsed = await parseSkillFile(locator.path, ctx, undefined, root.trustedHost === true)735
if (parsed === undefined) continue736
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 skills751
}753
async 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
}759
async 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 error765
}766
}768
async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {769
const target = await fs.resolve(path)770
return await fs.listDir(target)771
}773
function entryFromFs(entry: FsDirEntry): SkillRootEntry {774
return { name: entry.name, type: entry.type, path: entry.target.displayPath }775
}777
async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {778
let entries779
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 error786
}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 result795
}797
async 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 undefined802
}803
let parsed804
try {805
parsed = parseFrontmatter(raw.content)806
} catch (error) {807
ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)808
return undefined809
}810
if (!parsed) {811
ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)812
return undefined813
}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 undefined819
}820
if (!isSkillName(name)) {821
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)822
return undefined823
}824
let invocation825
try {826
invocation = parseInvocationPolicy(parsed.data)827
} catch (error) {828
ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`)829
return undefined830
}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
}842
function optionalFileSystem(ctx: Context): FileSystem | undefined {843
return ctx.get('fs')844
}846
async 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 undefined858
throw error859
}860
}862
async 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 target868
try {869
target = await fs.resolve(path)870
} catch (error) {871
if (isAbsentSkillPathError(error)) return undefined872
throw error873
}874
signal?.throwIfAborted()875
let info876
try {877
info = await fs.stat(target, signal)878
} catch (error) {879
signal?.throwIfAborted()880
if (isAbsentSkillPathError(error)) return undefined881
throw error882
}883
if (info === undefined || info.type !== 'file') return undefined884
try {885
return { path: fs.processPath(target), content: await fs.readText(target, signal) }886
} catch (error) {887
signal?.throwIfAborted()888
if (isAbsentSkillPathError(error)) return undefined889
if (!hasErrorCode(error, 'FS_NOT_TEXT')) throw error890
ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)891
return undefined892
}893
}895
function fsReadErrorMessage(target: FsTarget, error: unknown): string {896
return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`897
}899
async 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 undefined904
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 undefined911
} catch (error) {912
ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)913
return undefined914
}915
}917
function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {918
const firstLineEnd = raw.indexOf('\n')919
if (firstLineEnd < 0) return undefined920
const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')921
if (firstLine !== '---') return undefined922
const start = firstLineEnd + 1923
const closing = findClosingFrontmatter(raw, start)924
if (closing === undefined) return undefined925
const yaml = raw.slice(start, closing.start)926
const parsed: unknown = parseYaml(yaml)927
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined928
return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }929
}931
function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {932
let lineStart = start933
while (lineStart <= raw.length) {934
const nextNewline = raw.indexOf('\n', lineStart)935
const lineEnd = nextNewline < 0 ? raw.length : nextNewline936
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 undefined941
lineStart = nextNewline + 1942
}943
}945
async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {946
let current = cwd947
while (true) {948
if (await pathExists(join(current, '.git'), fs)) {949
return current950
}951
const parent = dirname(current)952
if (parent === current) return cwd953
current = parent954
}955
}957
async 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
}964
async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {965
let target966
try {967
target = await fs.resolve(path)968
} catch {969
// A backend may reject or hide this candidate; continue walking upward.970
return false971
}972
try {973
return await fs.stat(target) !== undefined974
} catch {975
// Transient stat failures make only this git-root candidate unusable.976
return false977
}978
}980
async function pathExistsInNode(path: string): Promise<boolean> {981
try {982
await access(path)983
return true984
} catch {985
// Missing host paths are expected while walking toward the filesystem root.986
return false987
}988
}990
function stringField(data: Record<string, unknown>, key: string): string | undefined {991
const value = data[key]992
return typeof value === 'string' && value.length > 0 ? value : undefined993
}995
function 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
}1000
function 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
}1012
function 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
}1018
function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {1019
if (!Object.hasOwn(data, key)) return undefined1020
const value = data[key]1021
if (typeof value === 'boolean') return value1022
if (value === 1 || value === '1') return true1023
if (value === 0 || value === '0') return false1024
if (typeof value === 'string') {1025
switch (value.toLowerCase()) {1026
case 'true':1027
case 'yes':1028
case 'on':1029
return true1030
case 'false':1031
case 'no':1032
case 'off':1033
return false1034
}1035
}1036
throw new TypeError(`frontmatter field "${key}" must be a boolean`)1037
}1039
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {1040
const value = data.metadata1041
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {1042
return { metadata: value as Record<string, unknown> }1043
}1044
return {}1045
}1047
function errorMessage(error: unknown): string {1048
return String(error)1049
}