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