@tanstack/ai-skills 0.0.0 → 0.1.1
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
package/src/parse.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lenient `SKILL.md` frontmatter parsing (spec §7).
|
|
3
|
+
*
|
|
4
|
+
* Other clients emit malformed frontmatter — most commonly unquoted colons in
|
|
5
|
+
* descriptions — so the default is lenient. We hand-roll a tiny parser rather
|
|
6
|
+
* than pull in a YAML dependency: SKILL.md frontmatter is a flat key/value
|
|
7
|
+
* block (plus block scalars for `description` and simple lists for
|
|
8
|
+
* `allowedTools`), and taking `value = rest-of-line-after-first-colon` handles
|
|
9
|
+
* the unquoted-colon case in a single pass — no quote-and-retry needed.
|
|
10
|
+
*
|
|
11
|
+
* | Condition | Behavior |
|
|
12
|
+
* |----------------------|---------------------------------|
|
|
13
|
+
* | name/dir mismatch | warn, load |
|
|
14
|
+
* | name over 64 chars | warn, load |
|
|
15
|
+
* | invalid name chars | warn, load |
|
|
16
|
+
* | missing `description`| throw (caller skips) |
|
|
17
|
+
* | no frontmatter | throw (caller skips) |
|
|
18
|
+
*
|
|
19
|
+
* `strict: true` promotes every warning to a thrown error.
|
|
20
|
+
*/
|
|
21
|
+
import type { SkillMetadata } from './types'
|
|
22
|
+
|
|
23
|
+
export interface ParseWarning {
|
|
24
|
+
code: 'name-dir-mismatch' | 'name-too-long' | 'name-invalid-chars'
|
|
25
|
+
message: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ParsedSkill {
|
|
29
|
+
metadata: SkillMetadata
|
|
30
|
+
/** frontmatter stripped. */
|
|
31
|
+
body: string
|
|
32
|
+
warnings: Array<ParseWarning>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class SkillParseError extends Error {
|
|
36
|
+
override name = 'SkillParseError'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const NAME_RE = /^[a-z0-9-]+$/
|
|
40
|
+
|
|
41
|
+
/** Split leading `---` frontmatter from the body. Returns null if absent. */
|
|
42
|
+
function splitFrontmatter(
|
|
43
|
+
raw: string,
|
|
44
|
+
): { frontmatter: string; body: string } | null {
|
|
45
|
+
// Tolerate a BOM and leading blank lines before the opening fence.
|
|
46
|
+
const text = raw.replace(/^/, '')
|
|
47
|
+
const match = /^\s*---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/.exec(
|
|
48
|
+
text,
|
|
49
|
+
)
|
|
50
|
+
if (!match) return null
|
|
51
|
+
return { frontmatter: match[1] ?? '', body: match[2] ?? '' }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse the flat frontmatter block into raw string/list/map values. */
|
|
55
|
+
function parseBlock(frontmatter: string): Record<string, unknown> {
|
|
56
|
+
const lines = frontmatter.split(/\r?\n/)
|
|
57
|
+
const out: Record<string, unknown> = {}
|
|
58
|
+
let i = 0
|
|
59
|
+
|
|
60
|
+
const indentOf = (s: string) => s.length - s.trimStart().length
|
|
61
|
+
|
|
62
|
+
while (i < lines.length) {
|
|
63
|
+
const line = lines[i]
|
|
64
|
+
if (line === undefined) break
|
|
65
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) {
|
|
66
|
+
i++
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
// Top-level keys are unindented.
|
|
70
|
+
if (indentOf(line) > 0) {
|
|
71
|
+
i++
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
const colon = line.indexOf(':')
|
|
75
|
+
if (colon === -1) {
|
|
76
|
+
i++
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const key = line.slice(0, colon).trim()
|
|
80
|
+
const value = line.slice(colon + 1).trim()
|
|
81
|
+
|
|
82
|
+
// Block scalar: `>` (folded) or `|` (literal), with optional chomp/indent.
|
|
83
|
+
if (value === '>' || value === '|' || /^[>|][+-]?\d*$/.test(value)) {
|
|
84
|
+
const folded = value.startsWith('>')
|
|
85
|
+
const collected: Array<string> = []
|
|
86
|
+
i++
|
|
87
|
+
while (i < lines.length) {
|
|
88
|
+
const next = lines[i]
|
|
89
|
+
if (next === undefined) break
|
|
90
|
+
if (next.trim() !== '' && indentOf(next) === 0) break
|
|
91
|
+
collected.push(next)
|
|
92
|
+
i++
|
|
93
|
+
}
|
|
94
|
+
// Strip the common leading indentation of the block.
|
|
95
|
+
const nonEmpty = collected.filter((l) => l.trim() !== '')
|
|
96
|
+
const minIndent = nonEmpty.length
|
|
97
|
+
? Math.min(...nonEmpty.map(indentOf))
|
|
98
|
+
: 0
|
|
99
|
+
const stripped = collected.map((l) => l.slice(minIndent))
|
|
100
|
+
out[key] = folded
|
|
101
|
+
? stripped.join(' ').replace(/\s+/g, ' ').trim()
|
|
102
|
+
: stripped.join('\n').trim()
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Empty value → indented children: a block list (`- x`) or a nested map
|
|
107
|
+
// (`k: v`), one level deep.
|
|
108
|
+
if (value === '') {
|
|
109
|
+
const items: Array<string> = []
|
|
110
|
+
const map: Record<string, string> = {}
|
|
111
|
+
let j = i + 1
|
|
112
|
+
while (j < lines.length) {
|
|
113
|
+
const next = lines[j]
|
|
114
|
+
if (next === undefined) break
|
|
115
|
+
if (next.trim() === '') {
|
|
116
|
+
j++
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
if (indentOf(next) === 0) break
|
|
120
|
+
const t = next.trim()
|
|
121
|
+
if (t.startsWith('- ')) {
|
|
122
|
+
items.push(unquote(t.slice(2).trim()))
|
|
123
|
+
} else {
|
|
124
|
+
const c = t.indexOf(':')
|
|
125
|
+
if (c === -1) break
|
|
126
|
+
map[t.slice(0, c).trim()] = unquote(t.slice(c + 1).trim())
|
|
127
|
+
}
|
|
128
|
+
j++
|
|
129
|
+
}
|
|
130
|
+
if (items.length) out[key] = items
|
|
131
|
+
else if (Object.keys(map).length) out[key] = map
|
|
132
|
+
else out[key] = ''
|
|
133
|
+
i = Math.max(j, i + 1)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Inline list `[a, b]`.
|
|
138
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
139
|
+
out[key] = value
|
|
140
|
+
.slice(1, -1)
|
|
141
|
+
.split(',')
|
|
142
|
+
.map((s) => unquote(s.trim()))
|
|
143
|
+
.filter((s) => s !== '')
|
|
144
|
+
i++
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
out[key] = unquote(value)
|
|
149
|
+
i++
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function unquote(s: string): string {
|
|
155
|
+
if (
|
|
156
|
+
(s.startsWith('"') && s.endsWith('"')) ||
|
|
157
|
+
(s.startsWith("'") && s.endsWith("'"))
|
|
158
|
+
) {
|
|
159
|
+
return s.slice(1, -1)
|
|
160
|
+
}
|
|
161
|
+
return s
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function asStringMap(value: unknown): Record<string, string> | undefined {
|
|
165
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
166
|
+
const out: Record<string, string> = {}
|
|
167
|
+
for (const [k, v] of Object.entries(value)) {
|
|
168
|
+
if (typeof v === 'string') out[k] = v
|
|
169
|
+
}
|
|
170
|
+
return Object.keys(out).length ? out : undefined
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Parse a `SKILL.md`. Throws {@link SkillParseError} for cases the spec says to
|
|
175
|
+
* skip (no frontmatter, missing description). Non-fatal issues are returned as
|
|
176
|
+
* `warnings`; `strict` turns them into throws.
|
|
177
|
+
*/
|
|
178
|
+
export function parseSkill(
|
|
179
|
+
raw: string,
|
|
180
|
+
opts: { dirName?: string; strict?: boolean } = {},
|
|
181
|
+
): ParsedSkill {
|
|
182
|
+
const split = splitFrontmatter(raw)
|
|
183
|
+
if (!split) {
|
|
184
|
+
throw new SkillParseError('SKILL.md has no frontmatter block')
|
|
185
|
+
}
|
|
186
|
+
const block = parseBlock(split.frontmatter)
|
|
187
|
+
|
|
188
|
+
const name = typeof block.name === 'string' ? block.name : undefined
|
|
189
|
+
const description =
|
|
190
|
+
typeof block.description === 'string' ? block.description : undefined
|
|
191
|
+
|
|
192
|
+
if (!description) {
|
|
193
|
+
throw new SkillParseError('SKILL.md is missing a `description`')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const warnings: Array<ParseWarning> = []
|
|
197
|
+
const effectiveName = name ?? opts.dirName ?? ''
|
|
198
|
+
|
|
199
|
+
if (name && opts.dirName && name !== opts.dirName) {
|
|
200
|
+
warnings.push({
|
|
201
|
+
code: 'name-dir-mismatch',
|
|
202
|
+
message: `skill name "${name}" does not match directory "${opts.dirName}"`,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
if (effectiveName.length > 64) {
|
|
206
|
+
warnings.push({
|
|
207
|
+
code: 'name-too-long',
|
|
208
|
+
message: `skill name "${effectiveName}" exceeds 64 characters`,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
if (effectiveName && !NAME_RE.test(effectiveName)) {
|
|
212
|
+
warnings.push({
|
|
213
|
+
code: 'name-invalid-chars',
|
|
214
|
+
message: `skill name "${effectiveName}" contains characters outside [a-z0-9-]`,
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (opts.strict && warnings.length) {
|
|
219
|
+
throw new SkillParseError(warnings.map((w) => w.message).join('; '))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const metadata: SkillMetadata = {
|
|
223
|
+
name: effectiveName,
|
|
224
|
+
description,
|
|
225
|
+
...(typeof block.license === 'string' && { license: block.license }),
|
|
226
|
+
...(typeof block.compatibility === 'string' && {
|
|
227
|
+
compatibility: block.compatibility,
|
|
228
|
+
}),
|
|
229
|
+
...(Array.isArray(block.allowedTools) && {
|
|
230
|
+
allowedTools: block.allowedTools.filter(
|
|
231
|
+
(t): t is string => typeof t === 'string',
|
|
232
|
+
),
|
|
233
|
+
}),
|
|
234
|
+
...((): { metadata?: Record<string, string> } => {
|
|
235
|
+
const m = asStringMap(block.metadata)
|
|
236
|
+
return m ? { metadata: m } : {}
|
|
237
|
+
})(),
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { metadata, body: split.body.trim(), warnings }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Strip the frontmatter block, returning just the body. */
|
|
244
|
+
export function stripFrontmatter(raw: string): string {
|
|
245
|
+
const split = splitFrontmatter(raw)
|
|
246
|
+
return split ? split.body.trim() : raw.trim()
|
|
247
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `inlineSkill` — an edge-safe {@link SkillSource} for a single skill defined
|
|
3
|
+
* next to app code, in a DB row, or per-session/per-tenant. Resource values may
|
|
4
|
+
* be thunks, evaluated at read time.
|
|
5
|
+
*/
|
|
6
|
+
import { stableHash } from '../util'
|
|
7
|
+
import { validateSkill } from '../validate'
|
|
8
|
+
import type { SkillMetadata, SkillSource } from '../types'
|
|
9
|
+
|
|
10
|
+
export interface InlineSkillConfig {
|
|
11
|
+
name: string
|
|
12
|
+
description: string
|
|
13
|
+
instructions: string
|
|
14
|
+
resources?: Record<string, string | (() => string | Promise<string>)>
|
|
15
|
+
compatibility?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function inlineSkill(config: InlineSkillConfig): SkillSource {
|
|
19
|
+
const metadata: SkillMetadata = {
|
|
20
|
+
name: config.name,
|
|
21
|
+
description: config.description,
|
|
22
|
+
...(config.compatibility && { compatibility: config.compatibility }),
|
|
23
|
+
}
|
|
24
|
+
const lint = validateSkill(metadata)
|
|
25
|
+
if (!lint.ok) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`inlineSkill "${config.name}": ${lint.issues.map((i) => i.message).join('; ')}`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
const resourcePaths = Object.keys(config.resources ?? {})
|
|
31
|
+
const revision = stableHash(
|
|
32
|
+
JSON.stringify({
|
|
33
|
+
metadata,
|
|
34
|
+
instructions: config.instructions,
|
|
35
|
+
resources: resourcePaths,
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const assertName = (name: string) => {
|
|
40
|
+
if (name !== config.name) {
|
|
41
|
+
throw new Error(`inlineSkill has no skill named "${name}"`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
revision: () => Promise.resolve(revision),
|
|
47
|
+
list: () => Promise.resolve([metadata]),
|
|
48
|
+
// Async so an unknown-name throw surfaces as a rejected promise.
|
|
49
|
+
load: async (name) => {
|
|
50
|
+
assertName(name)
|
|
51
|
+
return config.instructions
|
|
52
|
+
},
|
|
53
|
+
listResources: async (name) => {
|
|
54
|
+
assertName(name)
|
|
55
|
+
return resourcePaths
|
|
56
|
+
},
|
|
57
|
+
readResource: async (name, path) => {
|
|
58
|
+
assertName(name)
|
|
59
|
+
const value = config.resources?.[path]
|
|
60
|
+
if (value === undefined) {
|
|
61
|
+
throw new Error(`skill "${name}" has no resource "${path}"`)
|
|
62
|
+
}
|
|
63
|
+
return typeof value === 'function' ? await value() : value
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staticSkills` — wrap a build-time-generated catalog as a {@link SkillSource}.
|
|
3
|
+
*
|
|
4
|
+
* Edge-safe: this file imports no `node:*` modules. The Vite plugin that GLOBS
|
|
5
|
+
* `skills/**/SKILL.md` and emits the catalog lives in `@tanstack/ai-skills/node`
|
|
6
|
+
* (it needs `node:fs`); the emitted catalog is plain data consumed here.
|
|
7
|
+
*
|
|
8
|
+
* The generated catalog is `as const`, so `T` is the literal union of skill
|
|
9
|
+
* names — which flows into `load_skill`'s enum, the constraint the spec
|
|
10
|
+
* recommends, obtained at build time rather than runtime.
|
|
11
|
+
*/
|
|
12
|
+
import type { SkillMetadata, SkillSource } from '../types'
|
|
13
|
+
|
|
14
|
+
export interface GeneratedSkill<T extends string = string> {
|
|
15
|
+
name: T
|
|
16
|
+
description: string
|
|
17
|
+
/** raw SKILL.md body, frontmatter stripped at generation time. */
|
|
18
|
+
body: string
|
|
19
|
+
compatibility?: string
|
|
20
|
+
/** embedded resource files, path → utf8 contents. */
|
|
21
|
+
resources?: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface GeneratedCatalog<T extends string = string> {
|
|
25
|
+
revision: string
|
|
26
|
+
skills: ReadonlyArray<GeneratedSkill<T>>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function staticSkills<T extends string>(
|
|
30
|
+
catalog: GeneratedCatalog<T>,
|
|
31
|
+
): SkillSource & { names: ReadonlyArray<T> } {
|
|
32
|
+
const byName = new Map(catalog.skills.map((s) => [s.name, s]))
|
|
33
|
+
const get = (name: string): GeneratedSkill<T> => {
|
|
34
|
+
const s = byName.get(name as T)
|
|
35
|
+
if (!s) throw new Error(`static catalog has no skill named "${name}"`)
|
|
36
|
+
return s
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
names: catalog.skills.map((s) => s.name),
|
|
41
|
+
revision: () => Promise.resolve(catalog.revision),
|
|
42
|
+
list: () =>
|
|
43
|
+
Promise.resolve(
|
|
44
|
+
catalog.skills.map(
|
|
45
|
+
(s): SkillMetadata => ({
|
|
46
|
+
name: s.name,
|
|
47
|
+
description: s.description,
|
|
48
|
+
...(s.compatibility && { compatibility: s.compatibility }),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
// Methods are async so a missing-skill throw surfaces as a rejected
|
|
53
|
+
// promise (what callers `await`), not a synchronous throw.
|
|
54
|
+
load: async (name) => get(name).body,
|
|
55
|
+
listResources: async (name) => Object.keys(get(name).resources ?? {}),
|
|
56
|
+
readResource: async (name, path) => {
|
|
57
|
+
const value = get(name).resources?.[path]
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
throw new Error(`skill "${name}" has no resource "${path}"`)
|
|
60
|
+
}
|
|
61
|
+
return value
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `runSkillSourceConformance` — the real deliverable for third-party adapters.
|
|
3
|
+
* Since adapter code is frequently LLM-generated, this suite (not prose) is what
|
|
4
|
+
* makes a new `SkillSource` safe to ship.
|
|
5
|
+
*
|
|
6
|
+
* The factory must return a source seeded with this fixed fixture contract:
|
|
7
|
+
*
|
|
8
|
+
* - skill `alpha`: description non-empty; resource `references/note.md` whose
|
|
9
|
+
* contents are exactly `hello`; script `scripts/run.py` whose bytes decode
|
|
10
|
+
* to `print(1)` (only if the source supports scripts).
|
|
11
|
+
* - skill `beta`: description non-empty; no resources required.
|
|
12
|
+
*
|
|
13
|
+
* Sources that cannot represent a tier (resources/scripts) simply omit the
|
|
14
|
+
* corresponding methods — those cases are skipped, not failed.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import type { SkillSource } from '../types'
|
|
18
|
+
|
|
19
|
+
const dec = (v: string | Uint8Array) =>
|
|
20
|
+
typeof v === 'string' ? v : new TextDecoder().decode(v)
|
|
21
|
+
|
|
22
|
+
export function runSkillSourceConformance(
|
|
23
|
+
factory: () => SkillSource | Promise<SkillSource>,
|
|
24
|
+
label = 'SkillSource',
|
|
25
|
+
): void {
|
|
26
|
+
describe(`conformance: ${label}`, () => {
|
|
27
|
+
it('lists skills with a name and description', async () => {
|
|
28
|
+
const source = await factory()
|
|
29
|
+
const skills = await source.list()
|
|
30
|
+
const names = skills.map((s) => s.name)
|
|
31
|
+
expect(names).toContain('alpha')
|
|
32
|
+
expect(names).toContain('beta')
|
|
33
|
+
for (const s of skills) {
|
|
34
|
+
expect(s.name).toBeTruthy()
|
|
35
|
+
expect(s.description).toBeTruthy()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('loads a known skill body', async () => {
|
|
40
|
+
const source = await factory()
|
|
41
|
+
const body = await source.load('alpha')
|
|
42
|
+
expect(typeof body).toBe('string')
|
|
43
|
+
expect(body.length).toBeGreaterThan(0)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('throws (not returns empty) for a missing skill name', async () => {
|
|
47
|
+
const source = await factory()
|
|
48
|
+
await expect(source.load('does-not-exist')).rejects.toThrow()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('has a stable revision across identical content', async () => {
|
|
52
|
+
const source = await factory()
|
|
53
|
+
const rev = source.revision
|
|
54
|
+
if (!rev) return
|
|
55
|
+
const a = await rev()
|
|
56
|
+
const b = await rev()
|
|
57
|
+
expect(a).toBe(b)
|
|
58
|
+
const other = await factory()
|
|
59
|
+
if (other.revision) expect(await other.revision()).toBe(a)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('serves concurrent list() consistently', async () => {
|
|
63
|
+
const source = await factory()
|
|
64
|
+
const [a, b] = await Promise.all([source.list(), source.list()])
|
|
65
|
+
expect(a.map((s) => s.name).sort()).toEqual(b.map((s) => s.name).sort())
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('reads a bundled resource and rejects path traversal', async () => {
|
|
69
|
+
const source = await factory()
|
|
70
|
+
const { listResources, readResource } = source
|
|
71
|
+
if (!listResources || !readResource) return
|
|
72
|
+
const resources = await listResources('alpha')
|
|
73
|
+
expect(resources).toContain('references/note.md')
|
|
74
|
+
const value = await readResource('alpha', 'references/note.md')
|
|
75
|
+
// trimEnd: a file-backed source keeps the fixture's trailing newline (formatters add one); the payload is what matters.
|
|
76
|
+
expect(dec(value).trimEnd()).toBe('hello')
|
|
77
|
+
await expect(readResource('alpha', '../../etc/passwd')).rejects.toThrow()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('returns script bytes correctly', async () => {
|
|
81
|
+
const source = await factory()
|
|
82
|
+
if (!source.listScripts || !source.readScript) return
|
|
83
|
+
const scripts = await source.listScripts('alpha')
|
|
84
|
+
const ref = scripts.find((s) => s.path === 'scripts/run.py')
|
|
85
|
+
if (!ref) return
|
|
86
|
+
expect(ref.executable).toBe(false)
|
|
87
|
+
const bytes = await source.readScript('alpha', 'scripts/run.py')
|
|
88
|
+
expect(bytes).toBeInstanceOf(Uint8Array)
|
|
89
|
+
expect(dec(bytes)).toContain('print(1)')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `load_skill` — activates a skill by name and returns its (frontmatter-stripped)
|
|
3
|
+
* body plus a resource/script inventory. Result shape is frozen in phase 1;
|
|
4
|
+
* changing it later would churn every eval, snapshot, and devtools panel.
|
|
5
|
+
*/
|
|
6
|
+
import { toolDefinition } from '@tanstack/ai'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { stripFrontmatter } from '../parse'
|
|
9
|
+
import type { Tool } from '@tanstack/ai'
|
|
10
|
+
import type {
|
|
11
|
+
LoadSkillResult,
|
|
12
|
+
SkillMetadata,
|
|
13
|
+
SkillScriptRef,
|
|
14
|
+
SkillSource,
|
|
15
|
+
} from '../types'
|
|
16
|
+
|
|
17
|
+
export const ALREADY_LOADED =
|
|
18
|
+
'(already loaded earlier in this conversation — reuse the prior content)'
|
|
19
|
+
|
|
20
|
+
const scriptSchema = z.object({
|
|
21
|
+
path: z.string(),
|
|
22
|
+
executable: z.literal(false),
|
|
23
|
+
reason: z.string().optional(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const resultSchema = z.object({
|
|
27
|
+
skill: z.string(),
|
|
28
|
+
content: z.string(),
|
|
29
|
+
resources: z.array(z.string()),
|
|
30
|
+
scripts: z.array(scriptSchema),
|
|
31
|
+
compatibility: z.string().optional(),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export interface LoadSkillDeps {
|
|
35
|
+
source: SkillSource
|
|
36
|
+
skills: Array<SkillMetadata>
|
|
37
|
+
/** per-conversation activation set (dedupe). */
|
|
38
|
+
activated: Set<string>
|
|
39
|
+
requireApproval?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createLoadSkillTool(deps: LoadSkillDeps): Tool {
|
|
43
|
+
const names = deps.skills.map((s) => s.name)
|
|
44
|
+
const nameEnum = z.enum(names as [string, ...Array<string>])
|
|
45
|
+
const byName = new Map(deps.skills.map((s) => [s.name, s]))
|
|
46
|
+
|
|
47
|
+
const handler = async ({
|
|
48
|
+
name,
|
|
49
|
+
}: {
|
|
50
|
+
name: string
|
|
51
|
+
}): Promise<LoadSkillResult> => {
|
|
52
|
+
if (deps.activated.has(name)) {
|
|
53
|
+
return {
|
|
54
|
+
skill: name,
|
|
55
|
+
content: ALREADY_LOADED,
|
|
56
|
+
resources: [],
|
|
57
|
+
scripts: [],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const raw = await deps.source.load(name)
|
|
61
|
+
const resources = (await deps.source.listResources?.(name)) ?? []
|
|
62
|
+
const scripts = ((await deps.source.listScripts?.(name)) ??
|
|
63
|
+
[]) as Array<SkillScriptRef>
|
|
64
|
+
deps.activated.add(name)
|
|
65
|
+
const compatibility = byName.get(name)?.compatibility
|
|
66
|
+
return {
|
|
67
|
+
skill: name,
|
|
68
|
+
content: stripFrontmatter(raw),
|
|
69
|
+
resources,
|
|
70
|
+
scripts,
|
|
71
|
+
...(compatibility && { compatibility }),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const description =
|
|
76
|
+
'Activate an available skill by name. Returns its full instructions plus ' +
|
|
77
|
+
'a list of any bundled resources and scripts.'
|
|
78
|
+
const inputSchema = z.object({ name: nameEnum })
|
|
79
|
+
|
|
80
|
+
if (deps.requireApproval) {
|
|
81
|
+
return toolDefinition({
|
|
82
|
+
name: 'load_skill',
|
|
83
|
+
description,
|
|
84
|
+
inputSchema,
|
|
85
|
+
outputSchema: resultSchema,
|
|
86
|
+
needsApproval: true,
|
|
87
|
+
approvalSchema: z.object({ approve: z.boolean() }),
|
|
88
|
+
}).server(handler)
|
|
89
|
+
}
|
|
90
|
+
return toolDefinition({
|
|
91
|
+
name: 'load_skill',
|
|
92
|
+
description,
|
|
93
|
+
inputSchema,
|
|
94
|
+
outputSchema: resultSchema,
|
|
95
|
+
}).server(handler)
|
|
96
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `read_skill_resource` — reads a bundled resource (references/ or assets/) of a
|
|
3
|
+
* skill. Shipped but NOT auto-registered: pass it explicitly in `tools`, and
|
|
4
|
+
* `withSkills` phrases activation instructions based on its presence. This keeps
|
|
5
|
+
* DB/S3/inline sources' resources reachable — they have bytes but no file, so a
|
|
6
|
+
* caller-supplied file-read tool would never see them.
|
|
7
|
+
*/
|
|
8
|
+
import { toolDefinition } from '@tanstack/ai'
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import { assertSafeResourcePath } from '../util'
|
|
11
|
+
import type { Tool } from '@tanstack/ai'
|
|
12
|
+
import type { SkillSource } from '../types'
|
|
13
|
+
|
|
14
|
+
export const READ_RESOURCE_TOOL_NAME = 'read_skill_resource'
|
|
15
|
+
|
|
16
|
+
function toBase64(bytes: Uint8Array): string {
|
|
17
|
+
// ponytail: Buffer exists on node + workers; avoids a browser-only path we
|
|
18
|
+
// don't need for a server-side resource read.
|
|
19
|
+
return Buffer.from(bytes).toString('base64')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createResourceTool(source: SkillSource): Tool {
|
|
23
|
+
return toolDefinition({
|
|
24
|
+
name: READ_RESOURCE_TOOL_NAME,
|
|
25
|
+
description:
|
|
26
|
+
'Read a bundled resource file (from references/ or assets/) of an ' +
|
|
27
|
+
'activated skill, by its path relative to the skill root.',
|
|
28
|
+
inputSchema: z.object({
|
|
29
|
+
skill: z.string(),
|
|
30
|
+
path: z.string(),
|
|
31
|
+
}),
|
|
32
|
+
outputSchema: z.object({
|
|
33
|
+
skill: z.string(),
|
|
34
|
+
path: z.string(),
|
|
35
|
+
content: z.string(),
|
|
36
|
+
encoding: z.enum(['utf8', 'base64']),
|
|
37
|
+
}),
|
|
38
|
+
}).server(async ({ skill, path }) => {
|
|
39
|
+
assertSafeResourcePath(path)
|
|
40
|
+
const listed = await source.list()
|
|
41
|
+
if (!listed.some((s) => s.name === skill)) {
|
|
42
|
+
throw new Error(`no skill named "${skill}"`)
|
|
43
|
+
}
|
|
44
|
+
if (source.listResources) {
|
|
45
|
+
const allowed = await source.listResources(skill)
|
|
46
|
+
if (!allowed.includes(path)) {
|
|
47
|
+
throw new Error(`skill "${skill}" has no resource "${path}"`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (!source.readResource) {
|
|
51
|
+
throw new Error('this skill source does not support resources')
|
|
52
|
+
}
|
|
53
|
+
const value = await source.readResource(skill, path)
|
|
54
|
+
if (typeof value === 'string') {
|
|
55
|
+
return { skill, path, content: value, encoding: 'utf8' as const }
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
skill,
|
|
59
|
+
path,
|
|
60
|
+
content: toBase64(value),
|
|
61
|
+
encoding: 'base64' as const,
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
}
|