@tanstack/ai-skills 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/dist/esm/catalog.d.ts +5 -0
  3. package/dist/esm/catalog.js +18 -0
  4. package/dist/esm/catalog.js.map +1 -0
  5. package/dist/esm/combinators.d.ts +24 -0
  6. package/dist/esm/combinators.js +158 -0
  7. package/dist/esm/combinators.js.map +1 -0
  8. package/dist/esm/errors.d.ts +7 -0
  9. package/dist/esm/errors.js +2 -0
  10. package/dist/esm/index.d.ts +26 -0
  11. package/dist/esm/index.js +13 -0
  12. package/dist/esm/middleware.d.ts +44 -0
  13. package/dist/esm/middleware.js +133 -0
  14. package/dist/esm/middleware.js.map +1 -0
  15. package/dist/esm/node/index.d.ts +33 -0
  16. package/dist/esm/node/index.js +198 -0
  17. package/dist/esm/node/index.js.map +1 -0
  18. package/dist/esm/parse.d.ts +25 -0
  19. package/dist/esm/parse.js +155 -0
  20. package/dist/esm/parse.js.map +1 -0
  21. package/dist/esm/sources/inline.d.ts +9 -0
  22. package/dist/esm/sources/inline.js +48 -0
  23. package/dist/esm/sources/inline.js.map +1 -0
  24. package/dist/esm/static/index.d.ts +17 -0
  25. package/dist/esm/static/index.js +29 -0
  26. package/dist/esm/static/index.js.map +1 -0
  27. package/dist/esm/testing/index.d.ts +2 -0
  28. package/dist/esm/testing/index.js +78 -0
  29. package/dist/esm/testing/index.js.map +1 -0
  30. package/dist/esm/tools/load-skill.d.ts +11 -0
  31. package/dist/esm/tools/load-skill.js +67 -0
  32. package/dist/esm/tools/load-skill.js.map +1 -0
  33. package/dist/esm/tools/read-resource.d.ts +4 -0
  34. package/dist/esm/tools/read-resource.js +55 -0
  35. package/dist/esm/tools/read-resource.js.map +1 -0
  36. package/dist/esm/types.d.ts +70 -0
  37. package/dist/esm/types.js +13 -0
  38. package/dist/esm/types.js.map +1 -0
  39. package/dist/esm/util.d.ts +9 -0
  40. package/dist/esm/util.js +24 -0
  41. package/dist/esm/util.js.map +1 -0
  42. package/dist/esm/validate.d.ts +14 -0
  43. package/dist/esm/validate.js +33 -0
  44. package/dist/esm/validate.js.map +1 -0
  45. package/dist/esm/walk.d.ts +35 -0
  46. package/dist/esm/walk.js +49 -0
  47. package/dist/esm/walk.js.map +1 -0
  48. package/package.json +90 -1
  49. package/skills/ai-skills/SKILL.md +138 -0
  50. package/src/catalog.ts +44 -0
  51. package/src/combinators.ts +224 -0
  52. package/src/errors.ts +7 -0
  53. package/src/index.ts +49 -0
  54. package/src/middleware.ts +256 -0
  55. package/src/node/index.ts +281 -0
  56. package/src/parse.ts +247 -0
  57. package/src/sources/inline.ts +66 -0
  58. package/src/static/index.ts +64 -0
  59. package/src/testing/index.ts +92 -0
  60. package/src/tools/load-skill.ts +96 -0
  61. package/src/tools/read-resource.ts +64 -0
  62. package/src/types.ts +86 -0
  63. package/src/util.ts +28 -0
  64. package/src/validate.ts +63 -0
  65. package/src/walk.ts +84 -0
@@ -0,0 +1,256 @@
1
+ /**
2
+ * `withSkills` — portable Agent Skills as a chat middleware.
3
+ *
4
+ * All source resolution and catalog rendering happen once in `setup`; `onConfig`
5
+ * (which fires every agent iteration) only returns memoized values. Otherwise an
6
+ * S3-backed source would hit the network per loop turn and catalog reordering
7
+ * would break Anthropic's cache prefix mid-run.
8
+ */
9
+ import {
10
+ createCapability,
11
+ defineChatMiddleware,
12
+ SkillLimitError,
13
+ } from '@tanstack/ai'
14
+ import { combineSources } from './combinators'
15
+ import { renderCatalog } from './catalog'
16
+ import { modelFamilyOf } from './types'
17
+ import { createLoadSkillTool } from './tools/load-skill'
18
+ import { READ_RESOURCE_TOOL_NAME } from './tools/read-resource'
19
+ import type { DefinedChatMiddleware, StreamChunk, Tool } from '@tanstack/ai'
20
+ import type { ModelFamily, SkillMetadata, SkillSource } from './types'
21
+
22
+ /** CUSTOM stream-event name carrying the catalog to the browser DevTools. */
23
+ export const SKILLS_STATE_EVENT = 'skills:state'
24
+
25
+ export interface SkillsStateEventValue {
26
+ catalog: Array<{ name: string; description: string }>
27
+ activated: Array<string>
28
+ }
29
+
30
+ export interface SkillsOptions {
31
+ /** Override catalog rendering. Receives resolved metadata + the model family. */
32
+ renderCatalog?: (skills: Array<SkillMetadata>, family: ModelFamily) => string
33
+ /** Template with a required `{skills}` placeholder. Literal braces escape as `{{`/`}}`. */
34
+ instructionTemplate?: string
35
+ /** Hard cap on tier-1 catalog token spend. Default 4000. */
36
+ maxCatalogTokens?: number
37
+ /** `'error'` (default) or a reducer invoked when the cap is exceeded. */
38
+ onLimitExceeded?:
39
+ | 'error'
40
+ | ((skills: Array<SkillMetadata>, limit: number) => Array<SkillMetadata>)
41
+ /** Where the catalog goes. Default `'system'`. */
42
+ catalogPlacement?: 'system' | 'tool-description'
43
+ /** Require approval before load_skill / read_skill_resource. Default false. */
44
+ requireApproval?: boolean
45
+ }
46
+
47
+ interface SkillsRuntime {
48
+ skills: Array<SkillMetadata>
49
+ activated: Set<string>
50
+ source: SkillSource
51
+ family: ModelFamily
52
+ catalog: string
53
+ options: SkillsOptions
54
+ /** Built on first onConfig (needs config.tools to detect the resource tool). */
55
+ memo?: { prompt: { content: string } | undefined; tools: Array<Tool> }
56
+ stateChunkEmitted?: boolean
57
+ }
58
+
59
+ const SkillsCapability = createCapability<SkillsRuntime>()('skills')
60
+
61
+ /** ~4 chars/token — good enough to guard a runaway catalog. */
62
+ const estimateTokens = (s: string) => Math.ceil(s.length / 4)
63
+
64
+ function fillTemplate(template: string, catalog: string): string {
65
+ // Escape `{{`/`}}` to sentinels, substitute `{skills}`, then restore braces.
66
+ const OPEN = '\u0000OPEN\u0000'
67
+ const CLOSE = '\u0000CLOSE\u0000'
68
+ return template
69
+ .split('{{')
70
+ .join(OPEN)
71
+ .split('}}')
72
+ .join(CLOSE)
73
+ .split('{skills}')
74
+ .join(catalog)
75
+ .split(OPEN)
76
+ .join('{')
77
+ .split(CLOSE)
78
+ .join('}')
79
+ }
80
+
81
+ /** True when a code_execution/shell tool in `tools` carries hosted skills. */
82
+ function findNativeSkillTool(tools: Array<Tool>): string | undefined {
83
+ for (const tool of tools) {
84
+ const meta = tool.metadata as
85
+ | { skills?: Array<unknown>; environment?: { skills?: Array<unknown> } }
86
+ | undefined
87
+ if (tool.name === 'code_execution' && (meta?.skills?.length ?? 0) > 0) {
88
+ return 'code_execution'
89
+ }
90
+ if (tool.name === 'shell' && (meta?.environment?.skills?.length ?? 0) > 0) {
91
+ return 'shell'
92
+ }
93
+ }
94
+ return undefined
95
+ }
96
+
97
+ function activationInstructions(
98
+ catalog: string,
99
+ hasResourceTool: boolean,
100
+ ): string {
101
+ const resourceLine = hasResourceTool
102
+ ? 'To read a skill’s bundled resource files, call `read_skill_resource` with the skill name and the resource path.'
103
+ : 'Some skills may list resource files; they are not loadable in this configuration.'
104
+ return [
105
+ 'You have access to a library of skills. When a task matches one, call the `load_skill` tool with its name to load its full instructions before proceeding.',
106
+ catalog,
107
+ resourceLine,
108
+ ].join('\n\n')
109
+ }
110
+
111
+ export function withSkills(
112
+ sources: SkillSource | Array<SkillSource>,
113
+ options: SkillsOptions = {},
114
+ ): DefinedChatMiddleware<
115
+ unknown,
116
+ readonly [],
117
+ readonly [typeof SkillsCapability]
118
+ > {
119
+ if (options.instructionTemplate && options.renderCatalog) {
120
+ throw new Error(
121
+ '`instructionTemplate` and `renderCatalog` are mutually exclusive',
122
+ )
123
+ }
124
+ if (
125
+ options.instructionTemplate &&
126
+ !options.instructionTemplate.includes('{skills}')
127
+ ) {
128
+ throw new Error(
129
+ '`instructionTemplate` must contain a `{skills}` placeholder',
130
+ )
131
+ }
132
+
133
+ return defineChatMiddleware({
134
+ name: 'skills',
135
+ provides: [SkillsCapability],
136
+
137
+ async setup(ctx) {
138
+ const source = combineSources(sources)
139
+ let skills = await source.list()
140
+ const family = modelFamilyOf(ctx.provider)
141
+
142
+ // Catalog token cap (spec §4.2).
143
+ const limit = options.maxCatalogTokens ?? 4000
144
+ const render = options.renderCatalog ?? renderCatalog
145
+ let catalog = render(skills, family)
146
+ if (estimateTokens(catalog) > limit) {
147
+ if (options.onLimitExceeded && options.onLimitExceeded !== 'error') {
148
+ skills = options.onLimitExceeded(skills, limit)
149
+ catalog = render(skills, family)
150
+ }
151
+ if (estimateTokens(catalog) > limit) {
152
+ throw new SkillLimitError({
153
+ provider: family,
154
+ path: 'portable',
155
+ limit: `maxCatalogTokens (${limit})`,
156
+ allowed: limit,
157
+ actual: estimateTokens(catalog),
158
+ offending: skills.map((s) => s.name),
159
+ })
160
+ }
161
+ }
162
+
163
+ ctx.provide(SkillsCapability, {
164
+ skills,
165
+ activated: new Set<string>(),
166
+ source,
167
+ family,
168
+ catalog,
169
+ options,
170
+ })
171
+ },
172
+
173
+ onConfig(ctx, config) {
174
+ const rt = ctx.get(SkillsCapability)
175
+ if (rt.skills.length === 0) return // empty catalog → no tools, no prompt
176
+
177
+ // Native co-existence: portable + hosted skills don't compose (spec §6.1).
178
+ const native = findNativeSkillTool(config.tools)
179
+ if (native) {
180
+ throw new Error(
181
+ `withSkills (portable skills) cannot be combined with a "${native}" tool that carries hosted/native skills. ` +
182
+ 'Use one delivery mode: remove the hosted skills, or drop withSkills.',
183
+ )
184
+ }
185
+
186
+ if (!rt.memo) {
187
+ const hasResourceTool = config.tools.some(
188
+ (t) => t.name === READ_RESOURCE_TOOL_NAME,
189
+ )
190
+ const body =
191
+ options.instructionTemplate !== undefined
192
+ ? fillTemplate(options.instructionTemplate, rt.catalog)
193
+ : activationInstructions(rt.catalog, hasResourceTool)
194
+
195
+ const loadTool = createLoadSkillTool({
196
+ source: rt.source,
197
+ skills: rt.skills,
198
+ activated: rt.activated,
199
+ requireApproval: options.requireApproval,
200
+ })
201
+
202
+ const placement = options.catalogPlacement ?? 'system'
203
+ if (placement === 'tool-description') {
204
+ loadTool.description = `${loadTool.description}\n\n${body}`
205
+ rt.memo = { prompt: undefined, tools: [loadTool] }
206
+ } else {
207
+ rt.memo = { prompt: { content: body }, tools: [loadTool] }
208
+ }
209
+ }
210
+
211
+ // onConfig fires every iteration and the engine feeds the merged config
212
+ // back in — so appending must be idempotent (add our prompt/tools only
213
+ // when not already present) or a second iteration duplicates them.
214
+ const prompt = rt.memo.prompt
215
+ const promptPresent =
216
+ !prompt ||
217
+ config.systemPrompts.some((p) =>
218
+ typeof p === 'string'
219
+ ? p === prompt.content
220
+ : p.content === prompt.content,
221
+ )
222
+ const existingNames = new Set(config.tools.map((t) => t.name))
223
+ const toolsToAdd = rt.memo.tools.filter((t) => !existingNames.has(t.name))
224
+
225
+ return {
226
+ systemPrompts:
227
+ prompt && !promptPresent
228
+ ? [...config.systemPrompts, prompt]
229
+ : config.systemPrompts,
230
+ tools:
231
+ toolsToAdd.length > 0
232
+ ? [...config.tools, ...toolsToAdd]
233
+ : config.tools,
234
+ }
235
+ },
236
+
237
+ onChunk(ctx, chunk) {
238
+ const rt = ctx.getOptional(SkillsCapability)
239
+ if (!rt || rt.stateChunkEmitted) return
240
+ rt.stateChunkEmitted = true
241
+ const custom: StreamChunk = {
242
+ type: 'CUSTOM',
243
+ name: SKILLS_STATE_EVENT,
244
+ value: {
245
+ catalog: rt.skills.map((s) => ({
246
+ name: s.name,
247
+ description: s.description,
248
+ })),
249
+ activated: [...rt.activated],
250
+ } satisfies SkillsStateEventValue,
251
+ timestamp: Date.now(),
252
+ }
253
+ return [chunk, custom]
254
+ },
255
+ })
256
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * `skillDirectory` — a filesystem-backed {@link SkillSource}. Lives behind the
3
+ * `/node` subpath because it imports `node:fs`; the root export stays edge-safe
4
+ * (Workers, browsers), mirroring `@tanstack/ai-code-mode-snippets/storage`.
5
+ */
6
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises'
7
+ import { basename, join, relative, sep } from 'node:path'
8
+ import { walkSkillDirs } from '../walk'
9
+ import { parseSkill, stripFrontmatter } from '../parse'
10
+ import { assertSafeResourcePath, stableHash } from '../util'
11
+ import type { Dirent } from 'node:fs'
12
+ import type { ListDir } from '../walk'
13
+ import type { GeneratedCatalog, GeneratedSkill } from '../static/index'
14
+ import type { SkillMetadata, SkillScriptRef, SkillSource } from '../types'
15
+
16
+ const RESOURCE_DIRS = ['references', 'assets']
17
+ const SCRIPT_DIR = 'scripts'
18
+
19
+ export interface SkillDirectoryOptions {
20
+ maxDepth?: number
21
+ /** default true — promote parse warnings to errors (see spec §7). */
22
+ strict?: boolean
23
+ }
24
+
25
+ const nodeLister: ListDir = async (dir) => {
26
+ const ents = await readdir(dir, { withFileTypes: true })
27
+ return ents.map((e) => ({
28
+ name: e.name,
29
+ path: join(dir, e.name),
30
+ type: e.isDirectory() ? 'dir' : 'file',
31
+ }))
32
+ }
33
+
34
+ /** Recursively collect file paths under `dir`, relative to `root`. */
35
+ async function collectFiles(dir: string, root: string): Promise<Array<string>> {
36
+ let ents: Array<Dirent>
37
+ try {
38
+ ents = await readdir(dir, { withFileTypes: true })
39
+ } catch {
40
+ return []
41
+ }
42
+ const out: Array<string> = []
43
+ for (const e of ents) {
44
+ const full = join(dir, e.name)
45
+ if (e.isDirectory()) out.push(...(await collectFiles(full, root)))
46
+ else out.push(relative(root, full).replace(/\\/g, '/'))
47
+ }
48
+ return out
49
+ }
50
+
51
+ function posixRel(path: string): string {
52
+ return path.replace(/\\/g, '/')
53
+ }
54
+
55
+ function hasPrefix(rel: string, prefixes: Array<string>): boolean {
56
+ const n = posixRel(rel)
57
+ return prefixes.some((p) => n === p || n.startsWith(`${p}/`))
58
+ }
59
+
60
+ async function resolveInside(dir: string, rel: string): Promise<string> {
61
+ const full = join(dir, rel)
62
+ const rootReal = await realpath(dir)
63
+ const fullReal = await realpath(full)
64
+ const prefix = rootReal.endsWith(sep) ? rootReal : rootReal + sep
65
+ if (fullReal !== rootReal && !fullReal.startsWith(prefix)) {
66
+ throw new Error(`unsafe resource path: "${rel}"`)
67
+ }
68
+ return fullReal
69
+ }
70
+
71
+ export function skillDirectory(
72
+ root: string | Array<string>,
73
+ options: SkillDirectoryOptions = {},
74
+ ): SkillSource {
75
+ const roots = Array.isArray(root) ? root : [root]
76
+ const { maxDepth, strict = true } = options
77
+
78
+ /** Fresh scan of every root → parsed skill name → skill directory. First wins. */
79
+ const scan = async (): Promise<Map<string, string>> => {
80
+ const map = new Map<string, string>()
81
+ for (const r of roots) {
82
+ const dirs = await walkSkillDirs(nodeLister, r, { maxDepth })
83
+ for (const d of dirs) {
84
+ const raw = await readFile(join(d.dir, 'SKILL.md'), 'utf8').catch(
85
+ () => undefined,
86
+ )
87
+ if (raw === undefined) continue
88
+ try {
89
+ const parsed = parseSkill(raw, {
90
+ dirName: basename(d.dir),
91
+ strict,
92
+ })
93
+ if (!map.has(parsed.metadata.name)) {
94
+ map.set(parsed.metadata.name, d.dir)
95
+ }
96
+ } catch {
97
+ // Skip unparseable skills (same policy as list()).
98
+ }
99
+ }
100
+ }
101
+ return map
102
+ }
103
+
104
+ const dirOf = async (name: string): Promise<string> => {
105
+ const dir = (await scan()).get(name)
106
+ if (!dir)
107
+ throw new Error(`no skill named "${name}" under ${roots.join(', ')}`)
108
+ return dir
109
+ }
110
+
111
+ return {
112
+ revision: async () => {
113
+ const map = await scan()
114
+ const parts: Array<string> = []
115
+ for (const [name, dir] of [...map].sort()) {
116
+ const s = await stat(join(dir, 'SKILL.md')).catch(() => undefined)
117
+ parts.push(`${name}:${s?.mtimeMs ?? 0}:${s?.size ?? 0}`)
118
+ }
119
+ return stableHash(parts.join('|'))
120
+ },
121
+ list: async () => {
122
+ const map = await scan()
123
+ const out: Array<SkillMetadata> = []
124
+ for (const [, dir] of map) {
125
+ const raw = await readFile(join(dir, 'SKILL.md'), 'utf8').catch(
126
+ () => undefined,
127
+ )
128
+ if (raw === undefined) continue
129
+ try {
130
+ out.push(parseSkill(raw, { dirName: basename(dir), strict }).metadata)
131
+ } catch {
132
+ // Lenient: an unparseable skill is skipped, not fatal (spec §7).
133
+ // strict mode still throws inside parseSkill for warnings, but a
134
+ // genuinely broken file (no description) is always skipped.
135
+ }
136
+ }
137
+ return out
138
+ },
139
+ load: async (name) => readFile(join(await dirOf(name), 'SKILL.md'), 'utf8'),
140
+ listResources: async (name) => {
141
+ const dir = await dirOf(name)
142
+ const files: Array<string> = []
143
+ for (const sub of RESOURCE_DIRS) {
144
+ files.push(...(await collectFiles(join(dir, sub), dir)))
145
+ }
146
+ return files
147
+ },
148
+ readResource: async (name, path) => {
149
+ assertSafeResourcePath(path)
150
+ if (!hasPrefix(path, RESOURCE_DIRS)) {
151
+ throw new Error(
152
+ `resource path must be under references/ or assets/: "${path}"`,
153
+ )
154
+ }
155
+ const dir = await dirOf(name)
156
+ const fullReal = await resolveInside(dir, path)
157
+ if (hasPrefix(path, ['references'])) {
158
+ return readFile(fullReal, 'utf8')
159
+ }
160
+ return readFile(fullReal)
161
+ },
162
+ listScripts: async (name) => {
163
+ const dir = await dirOf(name)
164
+ const files = await collectFiles(join(dir, SCRIPT_DIR), dir)
165
+ return files.map(
166
+ (p): SkillScriptRef => ({
167
+ path: p,
168
+ executable: false,
169
+ reason: 'no-runtime',
170
+ }),
171
+ )
172
+ },
173
+ readScript: async (name, path) => {
174
+ assertSafeResourcePath(path)
175
+ if (!hasPrefix(path, [SCRIPT_DIR])) {
176
+ throw new Error(`script path must be under scripts/: "${path}"`)
177
+ }
178
+ const dir = await dirOf(name)
179
+ const fullReal = await resolveInside(dir, path)
180
+ const bytes = await readFile(fullReal)
181
+ return new Uint8Array(bytes)
182
+ },
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Read a skill directory tree into a plain {@link GeneratedCatalog} — the shape
188
+ * `staticSkills` consumes. Used by the Vite plugin and directly available for
189
+ * custom build scripts.
190
+ */
191
+ export async function generateCatalog(
192
+ root: string | Array<string>,
193
+ options: SkillDirectoryOptions = {},
194
+ ): Promise<GeneratedCatalog> {
195
+ const roots = Array.isArray(root) ? root : [root]
196
+ const skills: Array<GeneratedSkill> = []
197
+ const seen = new Set<string>()
198
+ for (const r of roots) {
199
+ for (const { dir } of await walkSkillDirs(nodeLister, r, {
200
+ maxDepth: options.maxDepth,
201
+ })) {
202
+ const raw = await readFile(join(dir, 'SKILL.md'), 'utf8').catch(
203
+ () => undefined,
204
+ )
205
+ if (raw === undefined) continue
206
+ let meta
207
+ try {
208
+ meta = parseSkill(raw, {
209
+ dirName: basename(dir),
210
+ strict: options.strict ?? true,
211
+ }).metadata
212
+ } catch {
213
+ continue
214
+ }
215
+ if (seen.has(meta.name)) continue
216
+ seen.add(meta.name)
217
+ const resources: Record<string, string> = {}
218
+ for (const sub of RESOURCE_DIRS) {
219
+ for (const rel of await collectFiles(join(dir, sub), dir)) {
220
+ resources[rel] = await readFile(join(dir, rel), 'utf8').catch(
221
+ () => '',
222
+ )
223
+ }
224
+ }
225
+ skills.push({
226
+ name: meta.name,
227
+ description: meta.description,
228
+ body: stripFrontmatter(raw),
229
+ ...(meta.compatibility && { compatibility: meta.compatibility }),
230
+ ...(Object.keys(resources).length && { resources }),
231
+ })
232
+ }
233
+ }
234
+ skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
235
+ const revision = stableHash(
236
+ skills.map((s) => `${s.name}:${stableHash(s.body)}`).join('|'),
237
+ )
238
+ return { revision, skills }
239
+ }
240
+
241
+ /** Structural Vite plugin (no `vite` type dependency). */
242
+ export interface SkillsCatalogPlugin {
243
+ name: string
244
+ resolveId: (id: string) => string | undefined
245
+ load: (
246
+ this: { addWatchFile?: (id: string) => void },
247
+ id: string,
248
+ ) => Promise<string | undefined>
249
+ }
250
+
251
+ /**
252
+ * Vite plugin that globs `SKILL.md` under `dir` at build time and serves a
253
+ * virtual module (default id `virtual:tanstack-skills`) exporting the catalog
254
+ * `as const`. Consumers then wrap it with `staticSkills` for a literal-union of
255
+ * skill names. The catalog is embedded as JSON, so the bundle hash tracks it.
256
+ */
257
+ export function skillsCatalogPlugin(
258
+ options: { dir?: string; virtualId?: string; maxDepth?: number } = {},
259
+ ): SkillsCatalogPlugin {
260
+ const virtualId = options.virtualId ?? 'virtual:tanstack-skills'
261
+ const resolved = `\0${virtualId}`
262
+ const dir = options.dir ?? 'skills'
263
+ return {
264
+ name: 'tanstack-skills-catalog',
265
+ resolveId: (id) => (id === virtualId ? resolved : undefined),
266
+ async load(this: { addWatchFile?: (id: string) => void }, id: string) {
267
+ if (id !== resolved) return undefined
268
+ const catalog = await generateCatalog(dir, { maxDepth: options.maxDepth })
269
+ if (typeof this.addWatchFile === 'function') {
270
+ for (const r of Array.isArray(dir) ? dir : [dir]) {
271
+ for (const d of await walkSkillDirs(nodeLister, r, {
272
+ maxDepth: options.maxDepth,
273
+ })) {
274
+ this.addWatchFile(join(d.dir, 'SKILL.md'))
275
+ }
276
+ }
277
+ }
278
+ return `export const catalog = ${JSON.stringify(catalog)} as const\n`
279
+ },
280
+ }
281
+ }