@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.
- package/README.md +5 -0
- package/dist/github-catalog.d.ts +18 -0
- package/dist/github-catalog.js +53 -0
- package/dist/hosted-catalog.d.ts +64 -0
- package/dist/hosted-catalog.js +147 -0
- package/dist/http-catalog.d.ts +13 -0
- package/dist/http-catalog.js +19 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -37363
- package/dist/instructions-files.d.ts +14 -0
- package/dist/instructions-files.js +42 -0
- package/dist/s3-catalog.d.ts +16 -0
- package/dist/s3-catalog.js +33 -0
- package/dist/skill-document.d.ts +10 -0
- package/dist/skill-document.js +144 -0
- package/dist/skill-loader.d.ts +13 -0
- package/dist/skill-loader.js +56 -0
- package/package.json +29 -4
- package/.turbo/turbo-build.log +0 -5
- package/src/github-catalog.ts +0 -80
- package/src/hosted-catalog.ts +0 -270
- package/src/http-catalog.ts +0 -29
- package/src/index.ts +0 -6
- package/src/instructions-files.ts +0 -63
- package/src/s3-catalog.ts +0 -49
- package/src/skill-document.ts +0 -174
- package/src/skill-loader.ts +0 -112
- package/test/hosted-catalog.test.ts +0 -398
- package/test/instructions-files.test.ts +0 -57
- package/test/skill-loader.test.ts +0 -248
- package/tsconfig.json +0 -7
package/src/hosted-catalog.ts
DELETED
|
@@ -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)))
|
package/src/http-catalog.ts
DELETED
|
@@ -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)])
|
package/src/skill-document.ts
DELETED
|
@@ -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
|
-
})
|