@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.
- package/LICENSE +21 -0
- package/dist/esm/catalog.d.ts +5 -0
- package/dist/esm/catalog.js +18 -0
- package/dist/esm/catalog.js.map +1 -0
- package/dist/esm/combinators.d.ts +24 -0
- package/dist/esm/combinators.js +158 -0
- package/dist/esm/combinators.js.map +1 -0
- package/dist/esm/errors.d.ts +7 -0
- package/dist/esm/errors.js +2 -0
- package/dist/esm/index.d.ts +26 -0
- package/dist/esm/index.js +13 -0
- package/dist/esm/middleware.d.ts +44 -0
- package/dist/esm/middleware.js +133 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/node/index.d.ts +33 -0
- package/dist/esm/node/index.js +198 -0
- package/dist/esm/node/index.js.map +1 -0
- package/dist/esm/parse.d.ts +25 -0
- package/dist/esm/parse.js +155 -0
- package/dist/esm/parse.js.map +1 -0
- package/dist/esm/sources/inline.d.ts +9 -0
- package/dist/esm/sources/inline.js +48 -0
- package/dist/esm/sources/inline.js.map +1 -0
- package/dist/esm/static/index.d.ts +17 -0
- package/dist/esm/static/index.js +29 -0
- package/dist/esm/static/index.js.map +1 -0
- package/dist/esm/testing/index.d.ts +2 -0
- package/dist/esm/testing/index.js +78 -0
- package/dist/esm/testing/index.js.map +1 -0
- package/dist/esm/tools/load-skill.d.ts +11 -0
- package/dist/esm/tools/load-skill.js +67 -0
- package/dist/esm/tools/load-skill.js.map +1 -0
- package/dist/esm/tools/read-resource.d.ts +4 -0
- package/dist/esm/tools/read-resource.js +55 -0
- package/dist/esm/tools/read-resource.js.map +1 -0
- package/dist/esm/types.d.ts +70 -0
- package/dist/esm/types.js +13 -0
- package/dist/esm/types.js.map +1 -0
- package/dist/esm/util.d.ts +9 -0
- package/dist/esm/util.js +24 -0
- package/dist/esm/util.js.map +1 -0
- package/dist/esm/validate.d.ts +14 -0
- package/dist/esm/validate.js +33 -0
- package/dist/esm/validate.js.map +1 -0
- package/dist/esm/walk.d.ts +35 -0
- package/dist/esm/walk.js +49 -0
- package/dist/esm/walk.js.map +1 -0
- package/package.json +90 -1
- package/skills/ai-skills/SKILL.md +138 -0
- package/src/catalog.ts +44 -0
- package/src/combinators.ts +224 -0
- package/src/errors.ts +7 -0
- package/src/index.ts +49 -0
- package/src/middleware.ts +256 -0
- package/src/node/index.ts +281 -0
- package/src/parse.ts +247 -0
- package/src/sources/inline.ts +66 -0
- package/src/static/index.ts +64 -0
- package/src/testing/index.ts +92 -0
- package/src/tools/load-skill.ts +96 -0
- package/src/tools/read-resource.ts +64 -0
- package/src/types.ts +86 -0
- package/src/util.ts +28 -0
- package/src/validate.ts +63 -0
- 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
|
+
}
|