index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. /**
  2. * Local filesystem skill provider.
  3. *
  4. * This package is one implementation of the `ctx.skills` provider registry. It
  5. * discovers directory-bundle and flat Markdown skills from project, custom, and
  6. * user roots, parses YAML frontmatter, and loads bodies through `ctx.fs` when a
  7. * filesystem service is present.
  8. *
  9. * @module @deepseek-ai/dsh-skill-local
  10. */
  11. import { access, readdir, readFile, stat } from 'node:fs/promises'
  12. import { dirname, join, resolve } from 'node:path'
  13. import { homedir } from 'node:os'
  14. import type { Context } from 'cordis'
  15. import z from 'schemastery'
  16. import type Schema from 'schemastery'
  17. import { parse as parseYaml } from 'yaml'
  18. import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs'
  19. import { resolveDshHome } from '@deepseek-ai/dsh-paths'
  20. import {
  21. isSkillName,
  22. type SkillCandidate,
  23. type SkillDefinition,
  24. type SkillLookupOptions,
  25. type SkillProvider,
  26. type SkillSource,
  27. } from '@deepseek-ai/dsh-skill'
  28. const PROJECT_DSH_RANK = 100
  29. const PROJECT_AGENTS_RANK = 200
  30. const CUSTOM_RANK = 300
  31. const USER_DSH_RANK = 400
  32. const USER_AGENTS_RANK = 500
  33. export const name = 'skill-local'
  34. export const inject = ['skills']
  35. /** Local filesystem skill provider configuration. */
  36. export interface Config {
  37. /** DeepSeek Harness config root. Defaults to `$DSH_HOME` or `~/.dsh`. */
  38. dshHome?: string
  39. /** Shared agent config root. Defaults to `$DSH_AGENTS_HOME` or `~/.agents`. */
  40. agentsHome?: string
  41. /** Additional skill roots scanned after project roots and before user roots. */
  42. customSkillDirs?: string[]
  43. }
  44. export const Config: Schema<Config> = z.object({
  45. dshHome: z.string(),
  46. agentsHome: z.string(),
  47. customSkillDirs: z.array(z.string()).default([]),
  48. })
  49. interface SkillRoot {
  50. path: string
  51. source: SkillSource
  52. rank: number
  53. skipSystem?: boolean
  54. }
  55. interface SkillRootEntry {
  56. name: string
  57. type: 'directory' | 'file' | 'other'
  58. path: string
  59. }
  60. interface ParsedSkill {
  61. name: string
  62. description: string
  63. whenToUse?: string
  64. disableModelInvocation?: boolean
  65. metadata?: Record<string, unknown>
  66. content: string
  67. }
  68. interface LocalLocator {
  69. path: string
  70. directory: string
  71. }
  72. /** Register the local filesystem skill provider on `ctx.skills`. */
  73. export function apply(ctx: Context, config: Config = {}): void {
  74. const provider = new LocalSkillProvider(ctx, config)
  75. ctx.skills.registerProvider(provider)
  76. }
  77. /** Provider that maps local project/user skill roots into `ctx.skills`. */
  78. export class LocalSkillProvider implements SkillProvider {
  79. readonly name = 'local'
  80. private readonly dshHome: string
  81. private readonly agentsHome: string
  82. private readonly customSkillDirs: string[]
  83. constructor(private readonly ctx: Context, config: Config = {}) {
  84. this.dshHome = resolveDshHome(config.dshHome)
  85. this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
  86. this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
  87. }
  88. /**
  89. * Discover local skill summaries for a cwd-sensitive workspace.
  90. * @param options - lookup options; `cwd` selects the project roots to scan.
  91. * @returns local provider candidates with stable root ranks.
  92. */
  93. async list(options: SkillLookupOptions): Promise<SkillCandidate[]> {
  94. const roots = await this.roots(options.cwd)
  95. const candidates: SkillCandidate[] = []
  96. for (const root of roots) {
  97. for (const skill of await discoverRoot(root, this.ctx)) {
  98. candidates.push(skill)
  99. }
  100. }
  101. return candidates
  102. }
  103. /**
  104. * Load a complete local skill body from the candidate's file locator.
  105. * @param candidate - the winning candidate returned by this provider.
  106. * @param options - lookup options whose signal cancels filesystem reads.
  107. * @returns the full local skill, or `undefined` if the file disappeared.
  108. */
  109. async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
  110. const locator = candidate.locator as LocalLocator
  111. const parsed = await parseSkillFile(locator.path, this.ctx, options.signal)
  112. if (parsed === undefined) return undefined
  113. return {
  114. name: parsed.name,
  115. description: parsed.description,
  116. ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
  117. ...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
  118. source: candidate.source,
  119. provider: this.name,
  120. resourceBase: { kind: 'directory', path: locator.directory },
  121. path: locator.path,
  122. ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
  123. content: parsed.content,
  124. }
  125. }
  126. private async roots(cwd: string | undefined): Promise<SkillRoot[]> {
  127. const roots: SkillRoot[] = []
  128. if (cwd !== undefined) {
  129. const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
  130. roots.push(
  131. { path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK },
  132. { path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK },
  133. )
  134. }
  135. roots.push(
  136. ...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
  137. { path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
  138. { path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
  139. )
  140. return roots
  141. }
  142. }
  143. async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandidate[]> {
  144. const skills: SkillCandidate[] = []
  145. const entries = await listSkillRootEntries(root, ctx)
  146. for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
  147. if (root.skipSystem && entry.name === '.system') continue
  148. const locator = entry.type === 'directory'
  149. ? { path: join(entry.path, 'SKILL.md'), directory: entry.path }
  150. : entry.type === 'file' && entry.name.endsWith('.md')
  151. ? { path: entry.path, directory: root.path }
  152. : undefined
  153. if (locator === undefined) continue
  154. const parsed = await parseSkillFile(locator.path, ctx)
  155. if (parsed === undefined) continue
  156. skills.push({
  157. name: parsed.name,
  158. description: parsed.description,
  159. ...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
  160. ...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
  161. provider: 'local',
  162. source: root.source,
  163. rank: root.rank,
  164. locator,
  165. resourceBase: { kind: 'directory', path: locator.directory },
  166. path: locator.path,
  167. ...parsed.metadata !== undefined ? { metadata: parsed.metadata } : {},
  168. })
  169. }
  170. return skills
  171. }
  172. async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
  173. const fs = optionalFileSystem(ctx)
  174. if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
  175. return await listSkillRootEntriesFromNode(root, ctx)
  176. }
  177. async function listSkillRootEntriesFromFileSystem(root: SkillRoot, fs: FileSystem): Promise<SkillRootEntry[]> {
  178. // Skill roots are optional; an absent or unlistable root contributes no skills.
  179. const entries = await fsListDir(fs, root.path).catch(() => undefined)
  180. return entries === undefined ? [] : entries.map(entryFromFs)
  181. }
  182. async function fsListDir(fs: FileSystem, path: string): Promise<FsDirEntry[]> {
  183. const target = await fs.resolve(path)
  184. return await fs.listDir(target)
  185. }
  186. function entryFromFs(entry: FsDirEntry): SkillRootEntry {
  187. return { name: entry.name, type: entry.type, path: entry.target.displayPath }
  188. }
  189. async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
  190. let entries
  191. try {
  192. entries = await readdir(root.path, { withFileTypes: true, encoding: 'utf8' })
  193. } catch {
  194. // Missing or unreadable local skill roots are expected in most deployments.
  195. return []
  196. }
  197. const result: SkillRootEntry[] = []
  198. for (const entry of entries) {
  199. const path = join(root.path, entry.name)
  200. const type = await nodeEntryKind(path, entry, ctx)
  201. result.push({ name: entry.name, type: type ?? 'other', path })
  202. }
  203. return result
  204. }
  205. async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise<ParsedSkill | undefined> {
  206. const raw = await readSkillText(ctx, path, signal)
  207. signal?.throwIfAborted()
  208. if (raw === undefined) {
  209. return undefined
  210. }
  211. let parsed
  212. try {
  213. parsed = parseFrontmatter(raw)
  214. } catch (error) {
  215. ctx.logger.warn(`skill file ${path} ignored: invalid YAML frontmatter: ${errorMessage(error)}`)
  216. return undefined
  217. }
  218. if (!parsed) {
  219. ctx.logger.warn(`skill file ${path} ignored: missing YAML frontmatter`)
  220. return undefined
  221. }
  222. const name = stringField(parsed.data, 'name')
  223. const description = stringField(parsed.data, 'description')
  224. if (name === undefined || description === undefined) {
  225. ctx.logger.warn(`skill file ${path} ignored: frontmatter requires name and description`)
  226. return undefined
  227. }
  228. if (!isSkillName(name)) {
  229. ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
  230. return undefined
  231. }
  232. return {
  233. name,
  234. description,
  235. ...optionalString(parsed.data, 'whenToUse'),
  236. ...optionalBoolean(parsed.data, 'disableModelInvocation'),
  237. ...optionalMetadata(parsed.data),
  238. content: parsed.body.trim(),
  239. }
  240. }
  241. function optionalFileSystem(ctx: Context): FileSystem | undefined {
  242. return ctx.get('fs')
  243. }
  244. async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise<string | undefined> {
  245. signal?.throwIfAborted()
  246. const fs = optionalFileSystem(ctx)
  247. if (fs !== undefined) {
  248. return await readSkillTextFromFileSystem(ctx, fs, path, signal)
  249. }
  250. try {
  251. return await readFile(path, { encoding: 'utf8', signal })
  252. } catch {
  253. signal?.throwIfAborted()
  254. return undefined
  255. }
  256. }
  257. async function readSkillTextFromFileSystem(ctx: Context, fs: FileSystem, path: string, signal?: AbortSignal): Promise<string | undefined> {
  258. // A missing or temporarily inaccessible skill file is not fatal to discovery.
  259. signal?.throwIfAborted()
  260. const target = await fs.resolve(path).catch(() => undefined)
  261. signal?.throwIfAborted()
  262. if (target === undefined) return undefined
  263. let info
  264. try {
  265. info = await fs.stat(target, signal)
  266. } catch (error) {
  267. signal?.throwIfAborted()
  268. ctx.logger.warn(`skill file ${path} ignored: failed to stat through filesystem service: ${errorMessage(error)}`)
  269. return undefined
  270. }
  271. if (info === undefined || info.type !== 'file') return undefined
  272. try {
  273. return await fs.readText(target, signal)
  274. } catch (error) {
  275. signal?.throwIfAborted()
  276. ctx.logger.warn(`skill file ${path} ignored: ${fsReadErrorMessage(target, error)}`)
  277. return undefined
  278. }
  279. }
  280. function fsReadErrorMessage(target: FsTarget, error: unknown): string {
  281. return `failed to read text file at ${target.displayPath}: ${errorMessage(error)}`
  282. }
  283. async function nodeEntryKind(fullPath: string, entry: { isDirectory(): boolean; isFile(): boolean; isSymbolicLink(): boolean }, ctx: Context): Promise<'directory' | 'file' | undefined> {
  284. if (entry.isDirectory()) return 'directory'
  285. if (entry.isFile()) return 'file'
  286. /* v8 ignore next -- Non-file directory entries such as FIFOs are platform-specific and intentionally skipped. */
  287. if (!entry.isSymbolicLink()) return undefined
  288. try {
  289. const info = await stat(fullPath)
  290. if (info.isDirectory()) return 'directory'
  291. /* v8 ignore else -- the special-file symlink branch relies on POSIX /dev/null. */
  292. if (info.isFile()) return 'file'
  293. /* v8 ignore next -- The special-file symlink fixture relies on POSIX /dev/null. */
  294. return undefined
  295. } catch (error) {
  296. ctx.logger.warn(`skill entry ${fullPath} ignored: failed to follow symbolic link: ${errorMessage(error)}`)
  297. return undefined
  298. }
  299. }
  300. function parseFrontmatter(raw: string): { data: Record<string, unknown>; body: string } | undefined {
  301. const firstLineEnd = raw.indexOf('\n')
  302. if (firstLineEnd < 0) return undefined
  303. const firstLine = raw.slice(0, firstLineEnd).replace(/\r$/, '')
  304. if (firstLine !== '---') return undefined
  305. const start = firstLineEnd + 1
  306. const closing = findClosingFrontmatter(raw, start)
  307. if (closing === undefined) return undefined
  308. const yaml = raw.slice(start, closing.start)
  309. const parsed = parseYaml(yaml) as unknown
  310. if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined
  311. return { data: parsed as Record<string, unknown>, body: raw.slice(closing.bodyStart) }
  312. }
  313. function findClosingFrontmatter(raw: string, start: number): { start: number; bodyStart: number } | undefined {
  314. let lineStart = start
  315. while (lineStart <= raw.length) {
  316. const nextNewline = raw.indexOf('\n', lineStart)
  317. const lineEnd = nextNewline < 0 ? raw.length : nextNewline
  318. const line = raw.slice(lineStart, lineEnd).replace(/\r$/, '')
  319. if (line === '---') {
  320. return { start: lineStart, bodyStart: nextNewline < 0 ? raw.length : nextNewline + 1 }
  321. }
  322. if (nextNewline < 0) return undefined
  323. lineStart = nextNewline + 1
  324. }
  325. }
  326. async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
  327. let current = cwd
  328. while (true) {
  329. if (await pathExists(join(current, '.git'), fs)) {
  330. return current
  331. }
  332. const parent = dirname(current)
  333. if (parent === current) return cwd
  334. current = parent
  335. }
  336. }
  337. async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
  338. if (fs !== undefined) {
  339. return await pathExistsInFileSystem(path, fs)
  340. }
  341. return await pathExistsInNode(path)
  342. }
  343. async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
  344. let target
  345. try {
  346. target = await fs.resolve(path)
  347. } catch {
  348. // A backend may reject or hide this candidate; continue walking upward.
  349. return false
  350. }
  351. try {
  352. return await fs.stat(target) !== undefined
  353. } catch {
  354. // Transient stat failures make only this git-root candidate unusable.
  355. return false
  356. }
  357. }
  358. async function pathExistsInNode(path: string): Promise<boolean> {
  359. try {
  360. await access(path)
  361. return true
  362. } catch {
  363. // Missing host paths are expected while walking toward the filesystem root.
  364. return false
  365. }
  366. }
  367. function stringField(data: Record<string, unknown>, key: string): string | undefined {
  368. const value = data[key]
  369. return typeof value === 'string' && value.length > 0 ? value : undefined
  370. }
  371. function optionalString(data: Record<string, unknown>, key: string): { [K in typeof key]?: string } {
  372. const value = data[key]
  373. return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
  374. }
  375. function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
  376. const value = data[key]
  377. return typeof value === 'boolean' ? { [key]: value } : {}
  378. }
  379. function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
  380. const value = data.metadata
  381. if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
  382. return { metadata: value as Record<string, unknown> }
  383. }
  384. return {}
  385. }
  386. function errorMessage(error: unknown): string {
  387. return String(error)
  388. }