@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
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
+ }