@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/types.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for portable Agent Skills.
|
|
3
|
+
*
|
|
4
|
+
* `SkillSource` is the central abstraction: bytes only, no filesystem
|
|
5
|
+
* assumption. A source that exposed a `path` would couple future script
|
|
6
|
+
* execution to fs-backed sources and permanently exclude S3/DB/registry
|
|
7
|
+
* sources — so there is deliberately no `path` field, ever. Path resolution
|
|
8
|
+
* is a `skillDirectory`-only concern.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Parsed `SKILL.md` frontmatter. */
|
|
12
|
+
export interface SkillMetadata {
|
|
13
|
+
/** spec-validated: ≤64 chars, `[a-z0-9-]`. */
|
|
14
|
+
name: string
|
|
15
|
+
/** ≤1024 chars. */
|
|
16
|
+
description: string
|
|
17
|
+
license?: string
|
|
18
|
+
/** ≤500 chars, free text. */
|
|
19
|
+
compatibility?: string
|
|
20
|
+
metadata?: Record<string, string>
|
|
21
|
+
/** experimental in spec; parsed, not enforced. */
|
|
22
|
+
allowedTools?: Array<string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A script referenced by a skill. Inventoried in phase 1, executed in phase 2.
|
|
27
|
+
* `executable` flips to `true` and `reason` is dropped in phase 2.
|
|
28
|
+
*/
|
|
29
|
+
export interface SkillScriptRef {
|
|
30
|
+
/** relative to skill root, e.g. `scripts/extract.py`. */
|
|
31
|
+
path: string
|
|
32
|
+
executable: false
|
|
33
|
+
reason?: 'no-runtime'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A source of skills. Bytes only — no filesystem assumption. */
|
|
37
|
+
export interface SkillSource {
|
|
38
|
+
/**
|
|
39
|
+
* Stable identity for the current content. `cache()` does not read this
|
|
40
|
+
* (it is time-based, and opt-in). Callers and custom combinators can use it
|
|
41
|
+
* as a catalog cache key. `withSkills` lists once per `chat()` call.
|
|
42
|
+
*/
|
|
43
|
+
revision?: () => Promise<string>
|
|
44
|
+
|
|
45
|
+
/** Tier 1. Called once per `chat()` by `withSkills` setup. */
|
|
46
|
+
list: () => Promise<Array<SkillMetadata>>
|
|
47
|
+
|
|
48
|
+
/** Tier 2. Raw SKILL.md including frontmatter. Core strips it. */
|
|
49
|
+
load: (name: string) => Promise<string>
|
|
50
|
+
|
|
51
|
+
/** Tier 3a. references/ and assets/ paths, relative to skill root. */
|
|
52
|
+
listResources?: (name: string) => Promise<Array<string>>
|
|
53
|
+
readResource?: (name: string, path: string) => Promise<string | Uint8Array>
|
|
54
|
+
|
|
55
|
+
/** Tier 3b. Inventoried in phase 1, executed in phase 2. */
|
|
56
|
+
listScripts?: (name: string) => Promise<Array<SkillScriptRef>>
|
|
57
|
+
readScript?: (name: string, path: string) => Promise<Uint8Array>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Model family, derived from the middleware context's `provider` string. The
|
|
62
|
+
* codebase has no `ModelFamily` type of its own — `ctx.provider` is a plain
|
|
63
|
+
* string sourced from `adapter.name`. This is the single seam the catalog
|
|
64
|
+
* renderer keys on.
|
|
65
|
+
*/
|
|
66
|
+
export type ModelFamily = 'anthropic' | 'openai' | 'gemini' | 'other'
|
|
67
|
+
|
|
68
|
+
/** Map a provider name (`ctx.provider`) to its {@link ModelFamily}. */
|
|
69
|
+
export function modelFamilyOf(provider: string): ModelFamily {
|
|
70
|
+
const p = provider.toLowerCase()
|
|
71
|
+
if (p.includes('anthropic') || p.includes('claude')) return 'anthropic'
|
|
72
|
+
if (p.includes('openai') || p.includes('gpt')) return 'openai'
|
|
73
|
+
if (p.includes('gemini') || p.includes('google')) return 'gemini'
|
|
74
|
+
return 'other'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Result of activating a skill via `load_skill`. Shape frozen in phase 1. */
|
|
78
|
+
export interface LoadSkillResult {
|
|
79
|
+
skill: string
|
|
80
|
+
/** frontmatter stripped. */
|
|
81
|
+
content: string
|
|
82
|
+
resources: Array<string>
|
|
83
|
+
/** `[]` or `executable:false` entries in phase 1. */
|
|
84
|
+
scripts: Array<SkillScriptRef>
|
|
85
|
+
compatibility?: string
|
|
86
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reject a resource/script path that escapes its skill root. Pure string check
|
|
3
|
+
* (edge-safe, no `node:path`): no absolute paths, no `..` segments, no
|
|
4
|
+
* backslashes. Enforced here so both the resource tool and `skillDirectory`
|
|
5
|
+
* share one guard and the conformance suite can pin it.
|
|
6
|
+
*/
|
|
7
|
+
export function assertSafeResourcePath(path: string): void {
|
|
8
|
+
const normalized = path.replace(/\\/g, '/')
|
|
9
|
+
const bad =
|
|
10
|
+
normalized.startsWith('/') ||
|
|
11
|
+
/^[a-zA-Z]:/.test(normalized) ||
|
|
12
|
+
normalized.split('/').some((seg) => seg === '..' || seg === '~')
|
|
13
|
+
if (bad) {
|
|
14
|
+
throw new Error(`unsafe resource path: "${path}"`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Small, edge-safe (no `node:crypto`) stable string hash for `revision()`. */
|
|
19
|
+
export function stableHash(input: string): string {
|
|
20
|
+
// ponytail: FNV-1a; collisions don't matter here — revision only needs to
|
|
21
|
+
// change when content changes, not be cryptographically unique.
|
|
22
|
+
let h = 0x811c9dc5
|
|
23
|
+
for (let i = 0; i < input.length; i++) {
|
|
24
|
+
h ^= input.charCodeAt(i)
|
|
25
|
+
h = Math.imul(h, 0x01000193)
|
|
26
|
+
}
|
|
27
|
+
return (h >>> 0).toString(16).padStart(8, '0')
|
|
28
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validateSkill` — author-time linting against native-delivery constraints, so
|
|
3
|
+
* a skill authored today can be promoted to a hosted (Anthropic/OpenAI) skill
|
|
4
|
+
* later without surprises. Phase 1 never uploads; this only warns.
|
|
5
|
+
*/
|
|
6
|
+
import type { SkillMetadata } from './types'
|
|
7
|
+
|
|
8
|
+
export type SkillTarget = 'portable' | 'anthropic' | 'openai'
|
|
9
|
+
|
|
10
|
+
export interface SkillValidationIssue {
|
|
11
|
+
target: SkillTarget
|
|
12
|
+
message: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SkillValidationResult {
|
|
16
|
+
ok: boolean
|
|
17
|
+
issues: Array<SkillValidationIssue>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const XML_TAG = /<[^>]+>/
|
|
21
|
+
const RESERVED_ANTHROPIC = ['anthropic', 'claude']
|
|
22
|
+
|
|
23
|
+
/** Lint a skill against the given delivery targets (default `['portable']`). */
|
|
24
|
+
export function validateSkill(
|
|
25
|
+
skill: SkillMetadata,
|
|
26
|
+
options: { targets?: Array<SkillTarget> } = {},
|
|
27
|
+
): SkillValidationResult {
|
|
28
|
+
const targets = options.targets ?? ['portable']
|
|
29
|
+
const issues: Array<SkillValidationIssue> = []
|
|
30
|
+
|
|
31
|
+
const add = (target: SkillTarget, message: string) =>
|
|
32
|
+
issues.push({ target, message })
|
|
33
|
+
|
|
34
|
+
// Portable: the spec's own name/description bounds.
|
|
35
|
+
if (targets.includes('portable')) {
|
|
36
|
+
if (!/^[a-z0-9-]+$/.test(skill.name)) {
|
|
37
|
+
add('portable', 'name must match [a-z0-9-]')
|
|
38
|
+
}
|
|
39
|
+
if (skill.name.length > 64) add('portable', 'name exceeds 64 characters')
|
|
40
|
+
if (skill.description.length > 1024) {
|
|
41
|
+
add('portable', 'description exceeds 1024 characters')
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (targets.includes('anthropic')) {
|
|
46
|
+
const lower = skill.name.toLowerCase()
|
|
47
|
+
if (RESERVED_ANTHROPIC.some((r) => lower.includes(r))) {
|
|
48
|
+
add('anthropic', 'name may not contain "anthropic" or "claude"')
|
|
49
|
+
}
|
|
50
|
+
if (XML_TAG.test(skill.name) || XML_TAG.test(skill.description)) {
|
|
51
|
+
add('anthropic', 'name/description may not contain XML tags')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (targets.includes('openai')) {
|
|
56
|
+
// OpenAI requires exactly one case-insensitive SKILL.md per bundle — a
|
|
57
|
+
// bundle-shape constraint not visible from metadata alone. Only the
|
|
58
|
+
// metadata-checkable rule is enforced here.
|
|
59
|
+
if (skill.name.trim() === '') add('openai', 'name must not be empty')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { ok: issues.length === 0, issues }
|
|
63
|
+
}
|
package/src/walk.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic skill-directory walk, shared with `@tanstack/ai-sandbox`.
|
|
3
|
+
*
|
|
4
|
+
* The algorithm is identical to the one in `ai-sandbox/src/agents-file.ts`, but
|
|
5
|
+
* parameterized over an injected `list` function so it works over any backing
|
|
6
|
+
* store (`node:fs`, a `SandboxHandle.fs`, an in-memory tree). Taking only an
|
|
7
|
+
* injected function keeps this edge-safe, so it lives in the root barrel.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** A directory that contains `SKILL.md`. */
|
|
11
|
+
export interface DiscoveredSkillDir {
|
|
12
|
+
name: string
|
|
13
|
+
dir: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One entry as reported by an injected {@link ListDir}. */
|
|
17
|
+
export interface WalkEntry {
|
|
18
|
+
name: string
|
|
19
|
+
path: string
|
|
20
|
+
type: 'file' | 'dir'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ListDir = (dir: string) => Promise<Array<WalkEntry>>
|
|
24
|
+
|
|
25
|
+
export const SKILL_FILE = 'SKILL.md'
|
|
26
|
+
export const MAX_SKILL_WALK_DEPTH = 6
|
|
27
|
+
const SKIP_DIR_NAMES = new Set(['.git', 'node_modules'])
|
|
28
|
+
|
|
29
|
+
function basenameOf(path: string): string {
|
|
30
|
+
const normalized = path.replace(/\\/g, '/')
|
|
31
|
+
const segments = normalized.split('/').filter((segment) => segment !== '')
|
|
32
|
+
return segments[segments.length - 1] ?? path
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find every skill folder under `root`. A skill folder is a directory that
|
|
37
|
+
* directly contains `SKILL.md`; the walk stops descending once found. Skips
|
|
38
|
+
* dot-directories, `.git`, and `node_modules`. Bounded by `maxDepth`. Errors
|
|
39
|
+
* from `list` are swallowed (an unreadable directory yields nothing).
|
|
40
|
+
*
|
|
41
|
+
* Unlike `ai-sandbox`'s `discoverSkillDirs`, this returns `[]` when nothing is
|
|
42
|
+
* found — the "fall back to the clone dir" behavior is a harness-projection
|
|
43
|
+
* concern and stays at that call site (it is wrong for a catalog).
|
|
44
|
+
*/
|
|
45
|
+
export async function walkSkillDirs(
|
|
46
|
+
list: ListDir,
|
|
47
|
+
root: string,
|
|
48
|
+
opts: { maxDepth?: number } = {},
|
|
49
|
+
): Promise<Array<DiscoveredSkillDir>> {
|
|
50
|
+
const maxDepth = opts.maxDepth ?? MAX_SKILL_WALK_DEPTH
|
|
51
|
+
const found: Array<DiscoveredSkillDir> = []
|
|
52
|
+
await walk(list, root, found, 0, maxDepth)
|
|
53
|
+
return found
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function walk(
|
|
57
|
+
list: ListDir,
|
|
58
|
+
dir: string,
|
|
59
|
+
found: Array<DiscoveredSkillDir>,
|
|
60
|
+
depth: number,
|
|
61
|
+
maxDepth: number,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
if (depth > maxDepth) return
|
|
64
|
+
let entries: Array<WalkEntry>
|
|
65
|
+
try {
|
|
66
|
+
entries = await list(dir)
|
|
67
|
+
} catch {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const hasSkill = entries.some(
|
|
71
|
+
(entry) =>
|
|
72
|
+
entry.type === 'file' &&
|
|
73
|
+
entry.name.toLowerCase() === SKILL_FILE.toLowerCase(),
|
|
74
|
+
)
|
|
75
|
+
if (hasSkill) {
|
|
76
|
+
found.push({ name: basenameOf(dir), dir })
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.type !== 'dir') continue
|
|
81
|
+
if (entry.name.startsWith('.') || SKIP_DIR_NAMES.has(entry.name)) continue
|
|
82
|
+
await walk(list, entry.path, found, depth + 1, maxDepth)
|
|
83
|
+
}
|
|
84
|
+
}
|