@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,138 @@
1
+ ---
2
+ name: ai-skills
3
+ description: >
4
+ Portable Agent Skills (SKILL.md) for TanStack AI with @tanstack/ai-skills.
5
+ Renders a skill catalog and a load_skill tool via the withSkills middleware so
6
+ any tool-calling model loads skills on demand, on any provider. Covers the
7
+ SkillSource interface, inlineSkill/skillDirectory/staticSkills, the aggregate/
8
+ dedupe/filter/cache combinators, read_skill_resource, and the conformance
9
+ suite. Use for provider-agnostic runtime skills — NOT hosted provider skills
10
+ (codeExecutionTool/shellTool), which run in a provider sandbox.
11
+ type: core
12
+ library: tanstack-ai
13
+ library_version: '0.0.0'
14
+ sources:
15
+ - 'TanStack/ai:docs/skills/agent-skills.md'
16
+ - 'TanStack/ai:docs/skills/skill-sources.md'
17
+ - 'TanStack/ai:docs/skills/writing-adapters.md'
18
+ - 'TanStack/ai:docs/tools/provider-skills.md'
19
+ ---
20
+
21
+ # TanStack AI Skills
22
+
23
+ > Builds on the `ai-core` skill in `@tanstack/ai`. Package: `@tanstack/ai-skills`.
24
+
25
+ Portable Agent Skills give a tool-calling model a library of `SKILL.md` skills it
26
+ can load on demand, on any provider, with no server sandbox. This is separate
27
+ from hosted **Provider Skills** (`codeExecutionTool` / `shellTool`), which run in
28
+ the provider's sandbox and are referenced by ID.
29
+
30
+ ## Two skill features, do not confuse them
31
+
32
+ | Need | Use |
33
+ | ----------------------------------------------- | ------------------------------------- |
34
+ | Model loads SKILL.md at runtime, any provider | `withSkills` (this package) |
35
+ | Hosted skill runs in a provider sandbox by ID | `codeExecutionTool` / `shellTool` |
36
+ | Teach a coding assistant how to use TanStack AI | Ship a `SKILL.md`, install via Intent |
37
+
38
+ The portable and hosted paths do not mix in one `chat()` call: `withSkills`
39
+ throws if a `code_execution`/`shell` tool in the same call carries skills.
40
+
41
+ ## Add skills to a chat
42
+
43
+ ```typescript
44
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai'
45
+ import { anthropicText } from '@tanstack/ai-anthropic'
46
+ import { inlineSkill, withSkills } from '@tanstack/ai-skills'
47
+
48
+ const pptx = inlineSkill({
49
+ name: 'pptx-builder',
50
+ description: 'Build and edit PowerPoint decks with python-pptx.',
51
+ instructions: '# Building a deck\nUse python-pptx. Edit slides, then save.',
52
+ })
53
+
54
+ const stream = chat({
55
+ adapter: anthropicText('claude-sonnet-4-5'),
56
+ messages,
57
+ middleware: [withSkills(pptx)],
58
+ })
59
+ ```
60
+
61
+ `withSkills` adds a catalog to the system prompt and a `load_skill` tool whose
62
+ `name` is constrained to your skill names. It renders `<available_skills>` XML
63
+ for Anthropic models and markdown for the rest. Re-loading a skill in the same
64
+ conversation returns a short "already loaded" marker.
65
+
66
+ ## Sources
67
+
68
+ A `SkillSource` is bytes only (no filesystem assumption), so the middleware runs
69
+ on the edge too.
70
+
71
+ - `inlineSkill({ name, description, instructions, resources? })` — one skill in
72
+ code or a DB row. Edge-safe.
73
+ - `skillDirectory(root, { strict? })` — walk a folder for `SKILL.md`. Import from
74
+ `@tanstack/ai-skills/node` (uses `node:fs`). Strict by default.
75
+ - `staticSkills(catalog)` — build-time bundle via `skillsCatalogPlugin` (Vite).
76
+ Edge-safe, and `.names` is a typed union.
77
+
78
+ Combine with `aggregate`, `dedupe`, `filter`, `cache`. `withSkills([a, b])` is
79
+ sugar for `dedupe(aggregate([a, b]))`. A single source is never auto-wrapped, so
80
+ a tenant-scoped source is never cached into a shared bucket.
81
+
82
+ ## Resources
83
+
84
+ To let the model read a skill's bundled files, pass `createResourceTool(source)`
85
+ in `tools`. `withSkills` detects it and advertises `read_skill_resource`. Paths
86
+ that escape the skill root are rejected.
87
+
88
+ ## Skills that carry code
89
+
90
+ `withSkills` inventories a skill's `scripts/` in the `load_skill` result but does
91
+ NOT run them (script execution is a later phase). To let a skill run code, pass
92
+ your own execution tool to `chat({ tools })` alongside `withSkills` and write the
93
+ skill so it tells the model to call that tool. `withSkills` composes with any
94
+ tools you provide.
95
+
96
+ ```ts ignore
97
+ import { toolDefinition } from '@tanstack/ai'
98
+ import { z } from 'zod'
99
+
100
+ const executeShell = toolDefinition({
101
+ name: 'execute_shell',
102
+ description: 'Run a shell command and return its stdout.',
103
+ inputSchema: z.object({ command: z.string() }),
104
+ outputSchema: z.object({ stdout: z.string() }),
105
+ }).server(async ({ command }) => ({ stdout: await runSomewhere(command) }))
106
+
107
+ // chat({ tools: [executeShell], middleware: [withSkills(source)] })
108
+ ```
109
+
110
+ The skill supplies the command; your tool supplies the ability to run it. Swap in
111
+ a provider sandbox, a Code Mode isolate, or a remote worker without changing the
112
+ skill. For hosted skills that run in the provider's own sandbox, use
113
+ `codeExecutionTool` / `shellTool` instead (see provider-skills).
114
+
115
+ ## Write a custom source
116
+
117
+ Implement `SkillSource` (`list` + `load`, optional `revision`/`listResources`/
118
+ `readResource`), then validate it with the shipped conformance suite:
119
+
120
+ ```typescript
121
+ import { runSkillSourceConformance } from '@tanstack/ai-skills/testing'
122
+
123
+ runSkillSourceConformance(() => myS3Source(fixtures), 's3')
124
+ ```
125
+
126
+ ## Entry points
127
+
128
+ - `@tanstack/ai-skills` — types, `inlineSkill`, combinators, `withSkills`,
129
+ `createResourceTool`, `validateSkill`, `staticSkills`, `SkillLimitError`.
130
+ - `@tanstack/ai-skills/node` — `skillDirectory`, `skillsCatalogPlugin` (`node:fs`).
131
+ - `@tanstack/ai-skills/testing` — `runSkillSourceConformance`.
132
+
133
+ ## Docs
134
+
135
+ - Portable Agent Skills: `docs/skills/agent-skills.md`
136
+ - Skill sources: `docs/skills/skill-sources.md`
137
+ - Write a skill source: `docs/skills/writing-adapters.md`
138
+ - Provider (hosted) skills: `docs/tools/provider-skills.md`
package/src/catalog.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Catalog rendering (spec §4.3). Deterministic order (sort by name) and a fixed
3
+ * position in the system prompt are prompt-cache requirements, not style. The
4
+ * shape is per model family because `skills-ref` documents `<available_skills>`
5
+ * XML as recommended specifically for Anthropic models.
6
+ */
7
+ import type { ModelFamily, SkillMetadata } from './types'
8
+
9
+ /** Sort skills into a stable, cache-friendly order. */
10
+ export function sortSkills(skills: Array<SkillMetadata>): Array<SkillMetadata> {
11
+ return [...skills].sort((a, b) =>
12
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
13
+ )
14
+ }
15
+
16
+ function escapeXml(s: string): string {
17
+ return s
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+ .replace(/"/g, '&quot;')
22
+ .replace(/'/g, '&apos;')
23
+ }
24
+
25
+ /** Render the skill catalog for a model family. Skills are sorted by name. */
26
+ export function renderCatalog(
27
+ skills: Array<SkillMetadata>,
28
+ family: ModelFamily,
29
+ ): string {
30
+ const sorted = sortSkills(skills)
31
+ if (family === 'anthropic') {
32
+ const entries = sorted
33
+ .map(
34
+ (s) =>
35
+ ` <skill name="${escapeXml(s.name)}">${escapeXml(s.description)}</skill>`,
36
+ )
37
+ .join('\n')
38
+ return `<available_skills>\n${entries}\n</available_skills>`
39
+ }
40
+ const entries = sorted
41
+ .map((s) => `- **${s.name}**: ${s.description}`)
42
+ .join('\n')
43
+ return `## Available skills\n\n${entries}`
44
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Source combinators (spec §3.3). Sources compose in practice: org skills +
3
+ * project skills + tenant skills. `aggregate` concatenates, `dedupe` resolves
4
+ * collisions, `filter` hides, `cache` memoizes.
5
+ */
6
+ import { stableHash } from './util'
7
+ import type { SkillMetadata, SkillScriptRef, SkillSource } from './types'
8
+
9
+ export type FilterContext = Record<string, unknown>
10
+ export type FilterPredicate = (
11
+ skill: SkillMetadata,
12
+ ctx?: FilterContext,
13
+ ) => boolean
14
+
15
+ /** Forward a source's optional `revision`, preserving `undefined` when absent. */
16
+ function forwardRevision(
17
+ source: SkillSource,
18
+ ): (() => Promise<string>) | undefined {
19
+ const rev = source.revision
20
+ return rev ? () => rev() : undefined
21
+ }
22
+
23
+ /** Route a delegating method to the first source that lists `name`. */
24
+ async function ownerOf(
25
+ sources: Array<SkillSource>,
26
+ name: string,
27
+ ): Promise<SkillSource> {
28
+ for (const source of sources) {
29
+ const list = await source.list()
30
+ if (list.some((s) => s.name === name)) return source
31
+ }
32
+ throw new Error(`no source provides a skill named "${name}"`)
33
+ }
34
+
35
+ async function combinedRevision(
36
+ sources: Array<SkillSource>,
37
+ ): Promise<string | undefined> {
38
+ const revs = await Promise.all(
39
+ sources.map((s) => s.revision?.() ?? Promise.resolve(undefined)),
40
+ )
41
+ if (revs.some((r) => r === undefined)) return undefined
42
+ return stableHash(revs.join('|'))
43
+ }
44
+
45
+ /** Concatenate sources in registration order. No dedupe. */
46
+ export function aggregate(sources: Array<SkillSource>): SkillSource {
47
+ // Only expose revision() when every child does — a partial revision would
48
+ // report "unchanged" while an unversioned child mutated underneath.
49
+ const allVersioned = sources.every((s) => s.revision)
50
+ return {
51
+ ...(allVersioned && {
52
+ revision: async () => (await combinedRevision(sources)) ?? '',
53
+ }),
54
+ list: async () => {
55
+ const lists = await Promise.all(sources.map((s) => s.list()))
56
+ return lists.flat()
57
+ },
58
+ load: async (name) => (await ownerOf(sources, name)).load(name),
59
+ listResources: async (name) => {
60
+ const owner = await ownerOf(sources, name)
61
+ return owner.listResources?.(name) ?? []
62
+ },
63
+ readResource: async (name, path) => {
64
+ const owner = await ownerOf(sources, name)
65
+ if (!owner.readResource) {
66
+ throw new Error(`skill "${name}" does not support resources`)
67
+ }
68
+ return owner.readResource(name, path)
69
+ },
70
+ listScripts: async (name) => {
71
+ const owner = await ownerOf(sources, name)
72
+ return (owner.listScripts?.(name) ?? []) as Array<SkillScriptRef>
73
+ },
74
+ readScript: async (name, path) => {
75
+ const owner = await ownerOf(sources, name)
76
+ if (!owner.readScript) {
77
+ throw new Error(`skill "${name}" does not support scripts`)
78
+ }
79
+ return owner.readScript(name, path)
80
+ },
81
+ }
82
+ }
83
+
84
+ /** First occurrence of a name wins; warns on collision. */
85
+ export function dedupe(
86
+ source: SkillSource,
87
+ onCollision: (name: string) => void = (name) =>
88
+ console.warn(`[ai-skills] duplicate skill "${name}" — first one wins`),
89
+ ): SkillSource {
90
+ return {
91
+ ...source,
92
+ revision: forwardRevision(source),
93
+ list: async () => {
94
+ const seen = new Set<string>()
95
+ const out: Array<SkillMetadata> = []
96
+ for (const skill of await source.list()) {
97
+ if (seen.has(skill.name)) {
98
+ onCollision(skill.name)
99
+ continue
100
+ }
101
+ seen.add(skill.name)
102
+ out.push(skill)
103
+ }
104
+ return out
105
+ },
106
+ }
107
+ }
108
+
109
+ /** Hide skills the predicate rejects. Filtered skills never reach the catalog. */
110
+ export function filter(
111
+ source: SkillSource,
112
+ predicate: FilterPredicate,
113
+ ctx?: FilterContext,
114
+ ): SkillSource {
115
+ const list = async () =>
116
+ (await source.list()).filter((s) => predicate(s, ctx))
117
+ const assertVisible = async (name: string) => {
118
+ const skills = await list()
119
+ if (!skills.some((s) => s.name === name)) {
120
+ throw new Error(`no skill named "${name}"`)
121
+ }
122
+ }
123
+ return {
124
+ ...source,
125
+ revision: forwardRevision(source),
126
+ list,
127
+ load: async (name) => {
128
+ await assertVisible(name)
129
+ return source.load(name)
130
+ },
131
+ listResources: source.listResources
132
+ ? async (name) => {
133
+ await assertVisible(name)
134
+ return source.listResources?.(name) ?? []
135
+ }
136
+ : undefined,
137
+ readResource: source.readResource
138
+ ? async (name, path) => {
139
+ await assertVisible(name)
140
+ const read = source.readResource
141
+ if (!read) {
142
+ throw new Error(`skill "${name}" does not support resources`)
143
+ }
144
+ return read(name, path)
145
+ }
146
+ : undefined,
147
+ listScripts: source.listScripts
148
+ ? async (name) => {
149
+ await assertVisible(name)
150
+ return (source.listScripts?.(name) ?? []) as Array<SkillScriptRef>
151
+ }
152
+ : undefined,
153
+ readScript: source.readScript
154
+ ? async (name, path) => {
155
+ await assertVisible(name)
156
+ const read = source.readScript
157
+ if (!read) {
158
+ throw new Error(`skill "${name}" does not support scripts`)
159
+ }
160
+ return read(name, path)
161
+ }
162
+ : undefined,
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Memoize `list()`/`load()`. Concurrent `list()` calls share one underlying
168
+ * fetch. `refreshInterval` (ms) expires the memo; omit for forever.
169
+ *
170
+ * Never auto-applied by the middleware — caching a tenant-scoped source in a
171
+ * shared bucket would replay one tenant's skills for another. Opt in explicitly.
172
+ */
173
+ export function cache(
174
+ source: SkillSource,
175
+ opts: { refreshInterval?: number } = {},
176
+ ): SkillSource {
177
+ let listPromise: Promise<Array<SkillMetadata>> | undefined
178
+ let listAt = 0
179
+ const loads = new Map<string, Promise<string>>()
180
+
181
+ const now = () => (opts.refreshInterval ? Date.now() : 0)
182
+ const fresh = () =>
183
+ opts.refreshInterval === undefined || now() - listAt < opts.refreshInterval
184
+
185
+ return {
186
+ ...source,
187
+ revision: forwardRevision(source),
188
+ list: () => {
189
+ if (!listPromise || !fresh()) {
190
+ listAt = now()
191
+ loads.clear()
192
+ listPromise = source.list().catch((err) => {
193
+ listPromise = undefined // don't cache failures
194
+ throw err
195
+ })
196
+ }
197
+ return listPromise
198
+ },
199
+ load: (name) => {
200
+ let p = loads.get(name)
201
+ if (!p) {
202
+ p = source.load(name).catch((err) => {
203
+ loads.delete(name)
204
+ throw err
205
+ })
206
+ loads.set(name, p)
207
+ }
208
+ return p
209
+ },
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Combine the sources handed to the middleware. An array is deduped and
215
+ * aggregated; a single bare source is used as-is (never auto-wrapped).
216
+ */
217
+ export function combineSources(
218
+ sources: SkillSource | Array<SkillSource>,
219
+ ): SkillSource {
220
+ if (!Array.isArray(sources)) return sources
221
+ const [first] = sources
222
+ if (sources.length === 1 && first) return first
223
+ return dedupe(aggregate(sources))
224
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `SkillLimitError` is defined in core `@tanstack/ai` (so the native tool
3
+ * factories can throw it without depending on this package) and re-exported
4
+ * here for the portable path.
5
+ */
6
+ export { SkillLimitError } from '@tanstack/ai'
7
+ export type { SkillLimitErrorInit } from '@tanstack/ai'
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `@tanstack/ai-skills` — portable Agent Skills (`SKILL.md`) as a first-class
3
+ * `chat()` middleware. Edge-safe root export; `skillDirectory` (node:fs) lives
4
+ * behind the `/node` subpath, the Vite plugin behind `/static`, and the
5
+ * conformance suite behind `/testing`.
6
+ */
7
+ export type {
8
+ SkillSource,
9
+ SkillMetadata,
10
+ SkillScriptRef,
11
+ LoadSkillResult,
12
+ ModelFamily,
13
+ } from './types'
14
+ export { modelFamilyOf } from './types'
15
+
16
+ export { parseSkill, stripFrontmatter, SkillParseError } from './parse'
17
+ export type { ParsedSkill, ParseWarning } from './parse'
18
+
19
+ export { walkSkillDirs, SKILL_FILE, MAX_SKILL_WALK_DEPTH } from './walk'
20
+ export type { DiscoveredSkillDir, WalkEntry, ListDir } from './walk'
21
+
22
+ export { inlineSkill } from './sources/inline'
23
+ export type { InlineSkillConfig } from './sources/inline'
24
+
25
+ export { aggregate, dedupe, filter, cache, combineSources } from './combinators'
26
+ export type { FilterContext, FilterPredicate } from './combinators'
27
+
28
+ export { renderCatalog, sortSkills } from './catalog'
29
+
30
+ export { withSkills, SKILLS_STATE_EVENT } from './middleware'
31
+ export type { SkillsOptions, SkillsStateEventValue } from './middleware'
32
+
33
+ export { createLoadSkillTool, ALREADY_LOADED } from './tools/load-skill'
34
+ export {
35
+ createResourceTool,
36
+ READ_RESOURCE_TOOL_NAME,
37
+ } from './tools/read-resource'
38
+
39
+ export { validateSkill } from './validate'
40
+ export type {
41
+ SkillTarget,
42
+ SkillValidationIssue,
43
+ SkillValidationResult,
44
+ } from './validate'
45
+
46
+ export { SkillLimitError } from './errors'
47
+ export type { SkillLimitErrorInit } from './errors'
48
+
49
+ export { assertSafeResourcePath, stableHash } from './util'