@batonfx/skills 0.4.0 → 0.4.2

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.
@@ -1,270 +0,0 @@
1
- import { Crypto, Effect, Encoding, Layer, Schema, Stream } from "effect"
2
- import { HttpClient, HttpClientRequest, HttpClientResponse, Url } from "effect/unstable/http"
3
- import { SkillSource } from "@batonfx/core"
4
- import { parseDocument, validateName } from "./skill-document"
5
-
6
- /** @experimental Baton hosted skill manifest entry. */
7
- export const ManifestSkill = Schema.Struct({
8
- name: Schema.String,
9
- description: Schema.String,
10
- skillPath: Schema.String,
11
- sha256: Schema.String,
12
- whenToUse: Schema.optionalKey(Schema.String),
13
- allowedTools: Schema.optionalKey(Schema.Array(Schema.String)),
14
- disableModelInvocation: Schema.optionalKey(Schema.Boolean),
15
- userInvocable: Schema.optionalKey(Schema.Boolean),
16
- contextFork: Schema.optionalKey(Schema.Boolean),
17
- agent: Schema.optionalKey(Schema.String),
18
- model: Schema.optionalKey(Schema.String),
19
- paths: Schema.optionalKey(Schema.Array(Schema.String)),
20
- })
21
-
22
- /** @experimental */
23
- export type ManifestSkill = typeof ManifestSkill.Type
24
-
25
- /** @experimental Baton hosted skill manifest. */
26
- export const Manifest = Schema.Struct({
27
- version: Schema.Literal(1),
28
- skills: Schema.Array(ManifestSkill),
29
- })
30
-
31
- /** @experimental */
32
- export type Manifest = typeof Manifest.Type
33
-
34
- /** @experimental Shared hosted-catalog limits and trusted tools. */
35
- export interface Limits {
36
- readonly manifestMaxBytes?: number
37
- readonly bodyMaxBytes?: number
38
- readonly maxSkills?: number
39
- readonly descriptionCap?: number
40
- readonly toolsBySkill?: Readonly<Record<string, ReadonlyArray<SkillSource.Skill["tools"][number]>>>
41
- }
42
-
43
- /** @experimental Internal hosted-catalog construction options. */
44
- export interface MakeOptions extends Limits {
45
- readonly source: string
46
- readonly manifestUrl: string
47
- readonly resolveSkillUrl: (skillPath: string) => Effect.Effect<string, SkillSource.SkillSourceError>
48
- readonly manifestHeaders?: Readonly<Record<string, string>>
49
- readonly bodyHeaders?: Readonly<Record<string, string>>
50
- }
51
-
52
- const decoder = new TextDecoder("utf-8", { fatal: true })
53
-
54
- const sourceError = (source: string, message: string, cause?: unknown): SkillSource.SkillSourceError =>
55
- new SkillSource.SkillSourceError({ source, message, ...(cause === undefined ? {} : { cause }) })
56
-
57
- const safeInteger = (
58
- source: string,
59
- name: string,
60
- value: number,
61
- minimum: number,
62
- ): Effect.Effect<number, SkillSource.SkillSourceError> =>
63
- Number.isSafeInteger(value) && value >= minimum
64
- ? Effect.succeed(value)
65
- : Effect.fail(sourceError(source, `${name} must be a safe integer >= ${minimum}`))
66
-
67
- const request = (url: string, headers: Readonly<Record<string, string>> | undefined) => {
68
- let value = HttpClientRequest.get(url)
69
- for (const [name, header] of Object.entries(headers ?? {})) value = HttpClientRequest.setHeader(value, name, header)
70
- return value
71
- }
72
-
73
- const fetchBytes = (
74
- client: HttpClient.HttpClient,
75
- source: string,
76
- url: string,
77
- headers: Readonly<Record<string, string>> | undefined,
78
- maxBytes: number,
79
- ): Effect.Effect<Uint8Array, SkillSource.SkillSourceError> =>
80
- client.execute(request(url, headers)).pipe(
81
- Effect.flatMap(HttpClientResponse.filterStatusOk),
82
- Effect.mapError((error) => sourceError(source, "Hosted skill request failed", error)),
83
- Effect.flatMap((response) =>
84
- response.stream.pipe(
85
- Stream.mapError((error) => sourceError(source, "Hosted skill request failed", error)),
86
- Stream.runFoldEffect(
87
- () => ({ size: 0, chunks: [] as Array<Uint8Array> }),
88
- (state, chunk) => {
89
- const size = state.size + chunk.byteLength
90
- if (size > maxBytes) {
91
- return Effect.fail(sourceError(source, `Hosted skill response exceeds ${maxBytes} bytes`))
92
- }
93
- state.chunks.push(chunk)
94
- return Effect.succeed({ size, chunks: state.chunks })
95
- },
96
- ),
97
- Effect.map(({ chunks, size }) => {
98
- const bytes = new Uint8Array(size)
99
- let offset = 0
100
- for (const chunk of chunks) {
101
- bytes.set(chunk, offset)
102
- offset += chunk.byteLength
103
- }
104
- return bytes
105
- }),
106
- ),
107
- ),
108
- )
109
-
110
- const decodeText = (source: string, bytes: Uint8Array): Effect.Effect<string, SkillSource.SkillSourceError> =>
111
- Effect.try({
112
- try: () => decoder.decode(bytes),
113
- catch: (cause) => sourceError(source, "Hosted skill response is not valid UTF-8", cause),
114
- })
115
-
116
- const frontmatter = (entry: ManifestSkill): SkillSource.Frontmatter => ({
117
- name: entry.name,
118
- description: entry.description,
119
- ...(entry.whenToUse === undefined ? {} : { whenToUse: entry.whenToUse }),
120
- ...(entry.allowedTools === undefined ? {} : { allowedTools: entry.allowedTools }),
121
- ...(entry.disableModelInvocation === undefined ? {} : { disableModelInvocation: entry.disableModelInvocation }),
122
- ...(entry.userInvocable === undefined ? {} : { userInvocable: entry.userInvocable }),
123
- ...(entry.contextFork === undefined ? {} : { contextFork: entry.contextFork }),
124
- ...(entry.agent === undefined ? {} : { agent: entry.agent }),
125
- ...(entry.model === undefined ? {} : { model: entry.model }),
126
- ...(entry.paths === undefined ? {} : { paths: entry.paths }),
127
- })
128
-
129
- const sameFrontmatter = (left: SkillSource.Frontmatter, right: SkillSource.Frontmatter): boolean =>
130
- JSON.stringify(left) === JSON.stringify(right)
131
-
132
- /** @experimental Validate one safe relative SKILL.md path. */
133
- export const validateSkillPath = (
134
- source: string,
135
- skillPath: string,
136
- ): Effect.Effect<string, SkillSource.SkillSourceError> => {
137
- const segments = skillPath.split("/")
138
- return skillPath.length > 0 &&
139
- !skillPath.startsWith("/") &&
140
- !skillPath.includes("\\") &&
141
- !skillPath.includes("%") &&
142
- !skillPath.includes("?") &&
143
- !skillPath.includes("#") &&
144
- segments.every((segment) => segment.length > 0 && segment !== "." && segment !== "..")
145
- ? Effect.succeed(skillPath)
146
- : Effect.fail(sourceError(source, `Unsafe hosted skill path: ${skillPath}`))
147
- }
148
-
149
- /** @experimental Resolve a same-origin path beneath a manifest directory. */
150
- export const resolveRelative = (
151
- source: string,
152
- manifestUrl: string,
153
- skillPath: string,
154
- ): Effect.Effect<string, SkillSource.SkillSourceError> =>
155
- Effect.gen(function* () {
156
- yield* validateSkillPath(source, skillPath)
157
- const manifest = yield* Effect.fromResult(Url.fromString(manifestUrl)).pipe(
158
- Effect.mapError((error) => sourceError(source, "Invalid manifest URL", error)),
159
- )
160
- const directory = yield* Effect.fromResult(Url.fromString(".", manifest)).pipe(
161
- Effect.mapError((error) => sourceError(source, "Invalid manifest directory URL", error)),
162
- )
163
- const resolved = yield* Effect.fromResult(Url.fromString(skillPath, directory)).pipe(
164
- Effect.mapError((error) => sourceError(source, "Invalid hosted skill URL", error)),
165
- )
166
- if (resolved.origin !== manifest.origin || !resolved.pathname.startsWith(directory.pathname)) {
167
- return yield* Effect.fail(sourceError(source, `Hosted skill path escapes manifest directory: ${skillPath}`))
168
- }
169
- return resolved.toString()
170
- })
171
-
172
- /** @experimental Build a hosted manifest source over Effect HTTP and Crypto services. */
173
- export const make = (options: MakeOptions): SkillSource.Source<HttpClient.HttpClient | Crypto.Crypto> =>
174
- Effect.gen(function* () {
175
- const client = yield* HttpClient.HttpClient
176
- const crypto = yield* Crypto.Crypto
177
- const manifestMaxBytes = yield* safeInteger(
178
- options.source,
179
- "manifestMaxBytes",
180
- options.manifestMaxBytes ?? 1024 * 1024,
181
- 1,
182
- )
183
- const bodyMaxBytes = yield* safeInteger(options.source, "bodyMaxBytes", options.bodyMaxBytes ?? 1024 * 1024, 1)
184
- const maxSkills = yield* safeInteger(options.source, "maxSkills", options.maxSkills ?? 1_000, 1)
185
- const descriptionCap = yield* safeInteger(
186
- options.source,
187
- "descriptionCap",
188
- options.descriptionCap ?? SkillSource.DESCRIPTION_CAP,
189
- 0,
190
- )
191
- const manifestBytes = yield* fetchBytes(
192
- client,
193
- options.source,
194
- options.manifestUrl,
195
- options.manifestHeaders,
196
- manifestMaxBytes,
197
- )
198
- const manifestText = yield* decodeText(options.source, manifestBytes)
199
- const manifest = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest))(manifestText).pipe(
200
- Effect.mapError((error) => sourceError(options.source, "Invalid hosted skill manifest", error)),
201
- )
202
- if (manifest.skills.length > maxSkills) {
203
- return yield* Effect.fail(sourceError(options.source, `Hosted skill manifest exceeds ${maxSkills} skills`))
204
- }
205
- const byName = new Map<string, SkillSource.Skill>()
206
- for (const entry of manifest.skills) {
207
- yield* validateName(options.source, entry.name)
208
- if (entry.description.length === 0 || entry.description.length > 1_024) {
209
- return yield* Effect.fail(
210
- sourceError(options.source, "Hosted skill description must contain 1-1024 characters"),
211
- )
212
- }
213
- if (!/^[0-9a-f]{64}$/.test(entry.sha256)) {
214
- return yield* Effect.fail(sourceError(options.source, `Invalid SHA-256 for hosted skill ${entry.name}`))
215
- }
216
- if (byName.has(entry.name)) {
217
- return yield* Effect.fail(sourceError(options.source, `Duplicate hosted skill name: ${entry.name}`))
218
- }
219
- const pathSegments = entry.skillPath.split("/")
220
- if (pathSegments.at(-1) !== "SKILL.md" || pathSegments.at(-2) !== entry.name) {
221
- return yield* Effect.fail(
222
- sourceError(options.source, `Hosted skill path directory must match skill name: ${entry.name}`),
223
- )
224
- }
225
- const skillUrl = yield* options.resolveSkillUrl(entry.skillPath)
226
- const metadata = frontmatter(entry)
227
- const body = fetchBytes(client, options.source, skillUrl, options.bodyHeaders, bodyMaxBytes).pipe(
228
- Effect.flatMap((bytes) =>
229
- crypto.digest("SHA-256", bytes).pipe(
230
- Effect.mapError((error) => sourceError(options.source, `Unable to hash hosted skill ${entry.name}`, error)),
231
- Effect.flatMap((actual) =>
232
- Encoding.encodeHex(actual) === entry.sha256
233
- ? decodeText(options.source, bytes)
234
- : Effect.fail(sourceError(options.source, `SHA-256 mismatch for hosted skill ${entry.name}`)),
235
- ),
236
- ),
237
- ),
238
- Effect.flatMap((content) =>
239
- parseDocument(options.source, content, entry.name).pipe(
240
- Effect.flatMap((document) =>
241
- sameFrontmatter(document.frontmatter, metadata)
242
- ? Effect.succeed(document.body)
243
- : Effect.fail(sourceError(options.source, `Frontmatter mismatch for hosted skill ${entry.name}`)),
244
- ),
245
- ),
246
- ),
247
- )
248
- const tools =
249
- options.toolsBySkill !== undefined && Object.hasOwn(options.toolsBySkill, entry.name)
250
- ? (options.toolsBySkill[entry.name] ?? [])
251
- : []
252
- byName.set(entry.name, {
253
- frontmatter: metadata,
254
- listing: SkillSource.makeListing(metadata, descriptionCap),
255
- body,
256
- tools,
257
- })
258
- }
259
- const skills = [...byName.values()]
260
- return {
261
- all: Effect.succeed(skills),
262
- get: (name) => Effect.succeed(byName.get(name)),
263
- }
264
- })
265
-
266
- /** @experimental One-source hosted catalog layer. */
267
- export const layer = (
268
- options: MakeOptions,
269
- ): Layer.Layer<SkillSource.SkillSource, SkillSource.SkillSourceError, HttpClient.HttpClient | Crypto.Crypto> =>
270
- Layer.effect(SkillSource.SkillSource, make(options).pipe(Effect.map(SkillSource.SkillSource.of)))
@@ -1,29 +0,0 @@
1
- import { Crypto, Effect } from "effect"
2
- import { HttpClient, Url } from "effect/unstable/http"
3
- import { SkillSource } from "@batonfx/core"
4
- import { type Limits, make as makeHostedCatalog, resolveRelative } from "./hosted-catalog"
5
-
6
- /** @experimental Generic HTTP skill catalog options. */
7
- export interface Options extends Limits {
8
- readonly manifestUrl: string
9
- readonly source?: string
10
- }
11
-
12
- const invalidUrl = (cause: unknown) =>
13
- new SkillSource.SkillSourceError({ source: "http-skill-catalog", message: "Invalid manifest URL", cause })
14
-
15
- /** @experimental Build a generic HTTP catalog source. */
16
- export const make = (options: Options): SkillSource.Source<HttpClient.HttpClient | Crypto.Crypto> => {
17
- return Effect.gen(function* () {
18
- const parsed = yield* Effect.fromResult(Url.fromString(options.manifestUrl)).pipe(Effect.mapError(invalidUrl))
19
- const source = options.source ?? `${parsed.origin}${parsed.pathname}`
20
- return yield* makeHostedCatalog({
21
- ...options,
22
- source,
23
- resolveSkillUrl: (skillPath) => resolveRelative(source, options.manifestUrl, skillPath),
24
- })
25
- })
26
- }
27
-
28
- /** @experimental Build a generic HTTP catalog layer. */
29
- export const layer = (options: Options) => SkillSource.layer([make(options)])
package/src/index.ts DELETED
@@ -1,6 +0,0 @@
1
- export * as GitHubCatalog from "./github-catalog"
2
- export * as HostedCatalog from "./hosted-catalog"
3
- export * as HttpCatalog from "./http-catalog"
4
- export * as InstructionFiles from "./instructions-files"
5
- export * as S3Catalog from "./s3-catalog"
6
- export * as SkillLoader from "./skill-loader"
@@ -1,63 +0,0 @@
1
- import { Effect, FileSystem, Path, PlatformError } from "effect"
2
-
3
- /** @experimental Loaded instruction-file content. */
4
- export interface InstructionFile {
5
- readonly path: string
6
- readonly content: string
7
- }
8
-
9
- /** @experimental Instruction-file discovery options. */
10
- export interface LoadInstructionFilesOptions {
11
- readonly filenames?: ReadonlyArray<string>
12
- readonly cwd?: string
13
- readonly globalFiles?: ReadonlyArray<string>
14
- }
15
-
16
- const DEFAULT_FILENAMES = ["AGENTS.md", "CLAUDE.md"] as const
17
-
18
- const readIfExists = (
19
- fs: FileSystem.FileSystem,
20
- file: string,
21
- ): Effect.Effect<InstructionFile | undefined, PlatformError.PlatformError> =>
22
- Effect.gen(function* () {
23
- if (!(yield* fs.exists(file))) return undefined
24
- const content = yield* fs.readFileString(file)
25
- return { path: file, content }
26
- })
27
-
28
- const ancestors = (path: Path.Path, cwd: string): ReadonlyArray<string> => {
29
- const directories: Array<string> = []
30
- let cursor = path.resolve(cwd)
31
- while (true) {
32
- directories.push(cursor)
33
- const parent = path.dirname(cursor)
34
- if (parent === cursor) break
35
- cursor = parent
36
- }
37
- return directories.toReversed()
38
- }
39
-
40
- /** @experimental Load AGENTS.md / CLAUDE.md instruction files. */
41
- export const loadInstructionFiles = (
42
- options: LoadInstructionFilesOptions = {},
43
- ): Effect.Effect<ReadonlyArray<InstructionFile>, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> =>
44
- Effect.gen(function* () {
45
- const fs = yield* FileSystem.FileSystem
46
- const path = yield* Path.Path
47
- const filenames = options.filenames ?? DEFAULT_FILENAMES
48
- const files: Array<InstructionFile> = []
49
- for (const globalFile of options.globalFiles ?? []) {
50
- const loaded = yield* readIfExists(fs, globalFile)
51
- if (loaded !== undefined) files.push(loaded)
52
- }
53
- for (const directory of ancestors(path, options.cwd ?? ".")) {
54
- for (const filename of filenames) {
55
- const loaded = yield* readIfExists(fs, path.join(directory, filename))
56
- if (loaded !== undefined) {
57
- files.push(loaded)
58
- break
59
- }
60
- }
61
- }
62
- return files
63
- })
package/src/s3-catalog.ts DELETED
@@ -1,49 +0,0 @@
1
- import { Crypto, Effect } from "effect"
2
- import { HttpClient } from "effect/unstable/http"
3
- import { SkillSource } from "@batonfx/core"
4
- import { type Limits, make as makeHostedCatalog, resolveRelative, validateSkillPath } from "./hosted-catalog"
5
-
6
- /** @experimental Manifest-backed S3 catalog options. */
7
- export interface Options extends Limits {
8
- readonly bucket: string
9
- readonly region: string
10
- readonly prefix?: string
11
- readonly manifestName?: string
12
- readonly source?: string
13
- }
14
-
15
- const segments = (value: string): string =>
16
- value
17
- .split("/")
18
- .filter((segment) => segment.length > 0)
19
- .map(encodeURIComponent)
20
- .join("/")
21
-
22
- /** @experimental Build a manifest-backed S3 catalog source. */
23
- export const make = (options: Options): SkillSource.Source<HttpClient.HttpClient | Crypto.Crypto> => {
24
- const source = options.source ?? `s3://${options.bucket}/${options.prefix ?? ""}`
25
- if (
26
- !/^[a-z0-9](?:[a-z0-9-]{1,61})[a-z0-9]$/.test(options.bucket) ||
27
- !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])$/.test(options.region)
28
- ) {
29
- return Effect.fail(
30
- new SkillSource.SkillSourceError({ source, message: "Invalid S3 bucket or region for hosted skill catalog" }),
31
- )
32
- }
33
- return Effect.gen(function* () {
34
- if ((options.prefix?.length ?? 0) > 0) yield* validateSkillPath(source, options.prefix ?? "")
35
- yield* validateSkillPath(source, options.manifestName ?? "skills.json")
36
- const prefix = segments(options.prefix ?? "")
37
- const manifestName = segments(options.manifestName ?? "skills.json")
38
- const manifestUrl = `https://${options.bucket}.s3.${options.region}.amazonaws.com/${prefix.length === 0 ? "" : `${prefix}/`}${manifestName}`
39
- return yield* makeHostedCatalog({
40
- ...options,
41
- source,
42
- manifestUrl,
43
- resolveSkillUrl: (skillPath) => resolveRelative(source, manifestUrl, skillPath),
44
- })
45
- })
46
- }
47
-
48
- /** @experimental Build a manifest-backed S3 catalog layer. */
49
- export const layer = (options: Options) => SkillSource.layer([make(options)])
@@ -1,174 +0,0 @@
1
- import { Effect } from "effect"
2
- import { SkillSource } from "@batonfx/core"
3
-
4
- export interface ParsedDocument {
5
- readonly frontmatter: SkillSource.Frontmatter
6
- readonly body: string
7
- }
8
-
9
- interface ParsedHeader {
10
- name?: string
11
- description?: string
12
- whenToUse?: string
13
- allowedTools?: ReadonlyArray<string>
14
- disableModelInvocation?: boolean
15
- userInvocable?: boolean
16
- contextFork?: boolean
17
- agent?: string
18
- model?: string
19
- paths?: ReadonlyArray<string>
20
- }
21
-
22
- const sourceError = (source: string, message: string, cause?: unknown): SkillSource.SkillSourceError =>
23
- new SkillSource.SkillSourceError({ source, message, ...(cause === undefined ? {} : { cause }) })
24
-
25
- const normalizeKey = (key: string): string => key.replace(/[-_]/g, "").toLowerCase()
26
-
27
- const stripQuotes = (value: string): string => {
28
- const trimmed = value.trim()
29
- return (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))
30
- ? trimmed.slice(1, -1)
31
- : trimmed
32
- }
33
-
34
- const parseInlineArray = (value: string): ReadonlyArray<string> => {
35
- const trimmed = value.trim()
36
- if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return []
37
- const inner = trimmed.slice(1, -1).trim()
38
- return inner.length === 0 ? [] : inner.split(",").map((item) => stripQuotes(item).trim())
39
- }
40
-
41
- const parseBoolean = (value: string): boolean | undefined => {
42
- const lowered = value.trim().toLowerCase()
43
- if (lowered === "true") return true
44
- if (lowered === "false") return false
45
- return undefined
46
- }
47
-
48
- const setValue = (target: Partial<ParsedHeader>, key: string, value: string | boolean | ReadonlyArray<string>) => {
49
- switch (normalizeKey(key)) {
50
- case "name":
51
- if (typeof value === "string") target.name = value
52
- break
53
- case "description":
54
- if (typeof value === "string") target.description = value
55
- break
56
- case "whentouse":
57
- if (typeof value === "string") target.whenToUse = value
58
- break
59
- case "allowedtools":
60
- if (typeof value === "string") target.allowedTools = value.split(/\s+/).filter((item) => item.length > 0)
61
- else if (Array.isArray(value)) target.allowedTools = value
62
- break
63
- case "disablemodelinvocation":
64
- if (typeof value === "boolean") target.disableModelInvocation = value
65
- break
66
- case "userinvocable":
67
- if (typeof value === "boolean") target.userInvocable = value
68
- break
69
- case "contextfork":
70
- if (typeof value === "boolean") target.contextFork = value
71
- break
72
- case "agent":
73
- if (typeof value === "string") target.agent = value
74
- break
75
- case "model":
76
- if (typeof value === "string") target.model = value
77
- break
78
- case "paths":
79
- if (Array.isArray(value)) target.paths = value
80
- break
81
- }
82
- }
83
-
84
- const parseHeader = (source: string, block: string): Effect.Effect<ParsedHeader, SkillSource.SkillSourceError> =>
85
- Effect.sync(() => {
86
- const parsed: Partial<ParsedHeader> = {}
87
- const lines = block.split("\n")
88
- for (let index = 0; index < lines.length; index += 1) {
89
- const line = lines[index]?.trimEnd() ?? ""
90
- if (line.trim().length === 0) continue
91
- const separator = line.indexOf(":")
92
- if (separator === -1) continue
93
- const key = line.slice(0, separator).trim()
94
- const raw = line.slice(separator + 1).trim()
95
- if (raw.length === 0) {
96
- const values: Array<string> = []
97
- while ((lines[index + 1]?.trimStart().startsWith("- ") ?? false) === true) {
98
- index += 1
99
- values.push(stripQuotes((lines[index] ?? "").trimStart().slice(2)))
100
- }
101
- setValue(parsed, key, values)
102
- } else if (raw.startsWith("[") && raw.endsWith("]")) {
103
- setValue(parsed, key, parseInlineArray(raw))
104
- } else {
105
- setValue(parsed, key, parseBoolean(raw) ?? stripQuotes(raw))
106
- }
107
- }
108
- return parsed
109
- }).pipe(Effect.catchCause((cause) => Effect.fail(sourceError(source, "Invalid SKILL.md frontmatter", cause))))
110
-
111
- export const splitDocument = (
112
- source: string,
113
- content: string,
114
- ): Effect.Effect<readonly [string, string], SkillSource.SkillSourceError> =>
115
- Effect.gen(function* () {
116
- const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")
117
- const lines = normalized.split("\n")
118
- if (lines[0] !== "---") {
119
- return yield* Effect.fail(sourceError(source, "Invalid SKILL.md document: missing opening frontmatter fence"))
120
- }
121
- const close = lines.findIndex((line, index) => index > 0 && line === "---")
122
- if (close === -1) {
123
- return yield* Effect.fail(sourceError(source, "Invalid SKILL.md document: missing closing frontmatter fence"))
124
- }
125
- return [lines.slice(1, close).join("\n"), lines.slice(close + 1).join("\n")] as const
126
- })
127
-
128
- export const validateName = (source: string, name: string): Effect.Effect<string, SkillSource.SkillSourceError> =>
129
- /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(name) && !name.includes("--")
130
- ? Effect.succeed(name)
131
- : Effect.fail(
132
- sourceError(source, "SKILL.md name must be 1-64 lowercase alphanumeric or single-hyphen-separated characters"),
133
- )
134
-
135
- export const parseFrontmatter = (
136
- source: string,
137
- block: string,
138
- directoryName: string,
139
- ): Effect.Effect<SkillSource.Frontmatter, SkillSource.SkillSourceError> =>
140
- Effect.gen(function* () {
141
- const parsed = yield* parseHeader(source, block)
142
- if (parsed.name === undefined) {
143
- return yield* Effect.fail(sourceError(source, "SKILL.md frontmatter requires name"))
144
- }
145
- yield* validateName(source, parsed.name)
146
- if (parsed.name !== directoryName) {
147
- return yield* Effect.fail(sourceError(source, `SKILL.md name must match directory ${directoryName}`))
148
- }
149
- if (parsed.description === undefined || parsed.description.length === 0 || parsed.description.length > 1_024) {
150
- return yield* Effect.fail(sourceError(source, "SKILL.md description must contain 1-1024 characters"))
151
- }
152
- return {
153
- name: parsed.name,
154
- description: parsed.description,
155
- ...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
156
- ...(parsed.allowedTools === undefined ? {} : { allowedTools: parsed.allowedTools }),
157
- ...(parsed.disableModelInvocation === undefined ? {} : { disableModelInvocation: parsed.disableModelInvocation }),
158
- ...(parsed.userInvocable === undefined ? {} : { userInvocable: parsed.userInvocable }),
159
- ...(parsed.contextFork === undefined ? {} : { contextFork: parsed.contextFork }),
160
- ...(parsed.agent === undefined ? {} : { agent: parsed.agent }),
161
- ...(parsed.model === undefined ? {} : { model: parsed.model }),
162
- ...(parsed.paths === undefined ? {} : { paths: parsed.paths }),
163
- }
164
- })
165
-
166
- export const parseDocument = (
167
- source: string,
168
- content: string,
169
- directoryName: string,
170
- ): Effect.Effect<ParsedDocument, SkillSource.SkillSourceError> =>
171
- Effect.gen(function* () {
172
- const [header, body] = yield* splitDocument(source, content)
173
- return { frontmatter: yield* parseFrontmatter(source, header, directoryName), body }
174
- })