UNPKG

@tanstack/ai-skills

Version:

Portable Agent Skills (SKILL.md) as a first-class chat() middleware for TanStack AI.

282 lines (267 loc) • 9.5 kB
/** * `skillDirectory` — a filesystem-backed {@link SkillSource}. Lives behind the * `/node` subpath because it imports `node:fs`; the root export stays edge-safe * (Workers, browsers), mirroring `@tanstack/ai-code-mode-snippets/storage`. */ import { readFile, readdir, realpath, stat } from 'node:fs/promises' import { basename, join, relative, sep } from 'node:path' import { walkSkillDirs } from '../walk' import { parseSkill, stripFrontmatter } from '../parse' import { assertSafeResourcePath, stableHash } from '../util' import type { Dirent } from 'node:fs' import type { ListDir } from '../walk' import type { GeneratedCatalog, GeneratedSkill } from '../static/index' import type { SkillMetadata, SkillScriptRef, SkillSource } from '../types' const RESOURCE_DIRS = ['references', 'assets'] const SCRIPT_DIR = 'scripts' export interface SkillDirectoryOptions { maxDepth?: number /** default true — promote parse warnings to errors (see spec §7). */ strict?: boolean } const nodeLister: ListDir = async (dir) => { const ents = await readdir(dir, { withFileTypes: true }) return ents.map((e) => ({ name: e.name, path: join(dir, e.name), type: e.isDirectory() ? 'dir' : 'file', })) } /** Recursively collect file paths under `dir`, relative to `root`. */ async function collectFiles(dir: string, root: string): Promise<Array<string>> { let ents: Array<Dirent> try { ents = await readdir(dir, { withFileTypes: true }) } catch { return [] } const out: Array<string> = [] for (const e of ents) { const full = join(dir, e.name) if (e.isDirectory()) out.push(...(await collectFiles(full, root))) else out.push(relative(root, full).replace(/\\/g, '/')) } return out } function posixRel(path: string): string { return path.replace(/\\/g, '/') } function hasPrefix(rel: string, prefixes: Array<string>): boolean { const n = posixRel(rel) return prefixes.some((p) => n === p || n.startsWith(`${p}/`)) } async function resolveInside(dir: string, rel: string): Promise<string> { const full = join(dir, rel) const rootReal = await realpath(dir) const fullReal = await realpath(full) const prefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep if (fullReal !== rootReal && !fullReal.startsWith(prefix)) { throw new Error(`unsafe resource path: "${rel}"`) } return fullReal } export function skillDirectory( root: string | Array<string>, options: SkillDirectoryOptions = {}, ): SkillSource { const roots = Array.isArray(root) ? root : [root] const { maxDepth, strict = true } = options /** Fresh scan of every root → parsed skill name → skill directory. First wins. */ const scan = async (): Promise<Map<string, string>> => { const map = new Map<string, string>() for (const r of roots) { const dirs = await walkSkillDirs(nodeLister, r, { maxDepth }) for (const d of dirs) { const raw = await readFile(join(d.dir, 'SKILL.md'), 'utf8').catch( () => undefined, ) if (raw === undefined) continue try { const parsed = parseSkill(raw, { dirName: basename(d.dir), strict, }) if (!map.has(parsed.metadata.name)) { map.set(parsed.metadata.name, d.dir) } } catch { // Skip unparseable skills (same policy as list()). } } } return map } const dirOf = async (name: string): Promise<string> => { const dir = (await scan()).get(name) if (!dir) throw new Error(`no skill named "${name}" under ${roots.join(', ')}`) return dir } return { revision: async () => { const map = await scan() const parts: Array<string> = [] for (const [name, dir] of [...map].sort()) { const s = await stat(join(dir, 'SKILL.md')).catch(() => undefined) parts.push(`${name}:${s?.mtimeMs ?? 0}:${s?.size ?? 0}`) } return stableHash(parts.join('|')) }, list: async () => { const map = await scan() const out: Array<SkillMetadata> = [] for (const [, dir] of map) { const raw = await readFile(join(dir, 'SKILL.md'), 'utf8').catch( () => undefined, ) if (raw === undefined) continue try { out.push(parseSkill(raw, { dirName: basename(dir), strict }).metadata) } catch { // Lenient: an unparseable skill is skipped, not fatal (spec §7). // strict mode still throws inside parseSkill for warnings, but a // genuinely broken file (no description) is always skipped. } } return out }, load: async (name) => readFile(join(await dirOf(name), 'SKILL.md'), 'utf8'), listResources: async (name) => { const dir = await dirOf(name) const files: Array<string> = [] for (const sub of RESOURCE_DIRS) { files.push(...(await collectFiles(join(dir, sub), dir))) } return files }, readResource: async (name, path) => { assertSafeResourcePath(path) if (!hasPrefix(path, RESOURCE_DIRS)) { throw new Error( `resource path must be under references/ or assets/: "${path}"`, ) } const dir = await dirOf(name) const fullReal = await resolveInside(dir, path) if (hasPrefix(path, ['references'])) { return readFile(fullReal, 'utf8') } return readFile(fullReal) }, listScripts: async (name) => { const dir = await dirOf(name) const files = await collectFiles(join(dir, SCRIPT_DIR), dir) return files.map( (p): SkillScriptRef => ({ path: p, executable: false, reason: 'no-runtime', }), ) }, readScript: async (name, path) => { assertSafeResourcePath(path) if (!hasPrefix(path, [SCRIPT_DIR])) { throw new Error(`script path must be under scripts/: "${path}"`) } const dir = await dirOf(name) const fullReal = await resolveInside(dir, path) const bytes = await readFile(fullReal) return new Uint8Array(bytes) }, } } /** * Read a skill directory tree into a plain {@link GeneratedCatalog} — the shape * `staticSkills` consumes. Used by the Vite plugin and directly available for * custom build scripts. */ export async function generateCatalog( root: string | Array<string>, options: SkillDirectoryOptions = {}, ): Promise<GeneratedCatalog> { const roots = Array.isArray(root) ? root : [root] const skills: Array<GeneratedSkill> = [] const seen = new Set<string>() for (const r of roots) { for (const { dir } of await walkSkillDirs(nodeLister, r, { maxDepth: options.maxDepth, })) { const raw = await readFile(join(dir, 'SKILL.md'), 'utf8').catch( () => undefined, ) if (raw === undefined) continue let meta try { meta = parseSkill(raw, { dirName: basename(dir), strict: options.strict ?? true, }).metadata } catch { continue } if (seen.has(meta.name)) continue seen.add(meta.name) const resources: Record<string, string> = {} for (const sub of RESOURCE_DIRS) { for (const rel of await collectFiles(join(dir, sub), dir)) { resources[rel] = await readFile(join(dir, rel), 'utf8').catch( () => '', ) } } skills.push({ name: meta.name, description: meta.description, body: stripFrontmatter(raw), ...(meta.compatibility && { compatibility: meta.compatibility }), ...(Object.keys(resources).length && { resources }), }) } } skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) const revision = stableHash( skills.map((s) => `${s.name}:${stableHash(s.body)}`).join('|'), ) return { revision, skills } } /** Structural Vite plugin (no `vite` type dependency). */ export interface SkillsCatalogPlugin { name: string resolveId: (id: string) => string | undefined load: ( this: { addWatchFile?: (id: string) => void }, id: string, ) => Promise<string | undefined> } /** * Vite plugin that globs `SKILL.md` under `dir` at build time and serves a * virtual module (default id `virtual:tanstack-skills`) exporting the catalog * `as const`. Consumers then wrap it with `staticSkills` for a literal-union of * skill names. The catalog is embedded as JSON, so the bundle hash tracks it. */ export function skillsCatalogPlugin( options: { dir?: string; virtualId?: string; maxDepth?: number } = {}, ): SkillsCatalogPlugin { const virtualId = options.virtualId ?? 'virtual:tanstack-skills' const resolved = `\0${virtualId}` const dir = options.dir ?? 'skills' return { name: 'tanstack-skills-catalog', resolveId: (id) => (id === virtualId ? resolved : undefined), async load(this: { addWatchFile?: (id: string) => void }, id: string) { if (id !== resolved) return undefined const catalog = await generateCatalog(dir, { maxDepth: options.maxDepth }) if (typeof this.addWatchFile === 'function') { for (const r of Array.isArray(dir) ? dir : [dir]) { for (const d of await walkSkillDirs(nodeLister, r, { maxDepth: options.maxDepth, })) { this.addWatchFile(join(d.dir, 'SKILL.md')) } } } return `export const catalog = ${JSON.stringify(catalog)} as const\n` }, } }