@batonfx/skills 0.3.7 → 0.4.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/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 -35192
- 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/index.ts +0 -2
- package/src/instructions-files.ts +0 -63
- package/src/skill-loader.ts +0 -282
- package/test/instructions-files.test.ts +0 -57
- package/test/skill-loader.test.ts +0 -187
- package/tsconfig.json +0 -7
package/src/skill-loader.ts
DELETED
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
import { Effect, FileSystem, Layer, Path, PlatformError, Stream } from "effect"
|
|
2
|
-
import { SkillSource } from "@batonfx/core"
|
|
3
|
-
|
|
4
|
-
/** @experimental Filesystem skill-loader options. */
|
|
5
|
-
export interface LoadOptions {
|
|
6
|
-
readonly roots?: ReadonlyArray<string>
|
|
7
|
-
readonly cwd?: string
|
|
8
|
-
readonly descriptionCap?: number
|
|
9
|
-
readonly frontmatterMaxBytes?: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const DEFAULT_ROOTS = [".agents/skills", ".claude/skills", ".pi/skills"] as const
|
|
13
|
-
|
|
14
|
-
const decoder = new TextDecoder()
|
|
15
|
-
|
|
16
|
-
interface ParsedDocument {
|
|
17
|
-
readonly frontmatter: SkillSource.Frontmatter
|
|
18
|
-
readonly body: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface ParsedHeader {
|
|
22
|
-
name?: string
|
|
23
|
-
description?: string
|
|
24
|
-
whenToUse?: string
|
|
25
|
-
allowedTools?: ReadonlyArray<string>
|
|
26
|
-
disableModelInvocation?: boolean
|
|
27
|
-
userInvocable?: boolean
|
|
28
|
-
contextFork?: boolean
|
|
29
|
-
agent?: string
|
|
30
|
-
model?: string
|
|
31
|
-
paths?: ReadonlyArray<string>
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const sourceError = (source: string, message: string, cause?: unknown): SkillSource.SkillSourceError =>
|
|
35
|
-
new SkillSource.SkillSourceError({ source, message, ...(cause === undefined ? {} : { cause }) })
|
|
36
|
-
|
|
37
|
-
const mapPlatformError = (source: string, error: PlatformError.PlatformError): SkillSource.SkillSourceError =>
|
|
38
|
-
sourceError(source, error.message, error)
|
|
39
|
-
|
|
40
|
-
const normalizeKey = (key: string): string => key.replace(/[-_]/g, "").toLowerCase()
|
|
41
|
-
|
|
42
|
-
const stripQuotes = (value: string): string => {
|
|
43
|
-
const trimmed = value.trim()
|
|
44
|
-
return (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
45
|
-
? trimmed.slice(1, -1)
|
|
46
|
-
: trimmed
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const parseInlineArray = (value: string): ReadonlyArray<string> => {
|
|
50
|
-
const trimmed = value.trim()
|
|
51
|
-
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return []
|
|
52
|
-
const inner = trimmed.slice(1, -1).trim()
|
|
53
|
-
return inner.length === 0 ? [] : inner.split(",").map((item) => stripQuotes(item).trim())
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const parseBoolean = (value: string): boolean | undefined => {
|
|
57
|
-
const lowered = value.trim().toLowerCase()
|
|
58
|
-
if (lowered === "true") return true
|
|
59
|
-
if (lowered === "false") return false
|
|
60
|
-
return undefined
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const setValue = (target: Partial<ParsedHeader>, key: string, value: string | boolean | ReadonlyArray<string>) => {
|
|
64
|
-
switch (normalizeKey(key)) {
|
|
65
|
-
case "name":
|
|
66
|
-
if (typeof value === "string") target.name = value
|
|
67
|
-
break
|
|
68
|
-
case "description":
|
|
69
|
-
if (typeof value === "string") target.description = value
|
|
70
|
-
break
|
|
71
|
-
case "whentouse":
|
|
72
|
-
if (typeof value === "string") target.whenToUse = value
|
|
73
|
-
break
|
|
74
|
-
case "allowedtools":
|
|
75
|
-
if (Array.isArray(value)) target.allowedTools = value
|
|
76
|
-
break
|
|
77
|
-
case "disablemodelinvocation":
|
|
78
|
-
if (typeof value === "boolean") target.disableModelInvocation = value
|
|
79
|
-
break
|
|
80
|
-
case "userinvocable":
|
|
81
|
-
if (typeof value === "boolean") target.userInvocable = value
|
|
82
|
-
break
|
|
83
|
-
case "contextfork":
|
|
84
|
-
if (typeof value === "boolean") target.contextFork = value
|
|
85
|
-
break
|
|
86
|
-
case "agent":
|
|
87
|
-
if (typeof value === "string") target.agent = value
|
|
88
|
-
break
|
|
89
|
-
case "model":
|
|
90
|
-
if (typeof value === "string") target.model = value
|
|
91
|
-
break
|
|
92
|
-
case "paths":
|
|
93
|
-
if (Array.isArray(value)) target.paths = value
|
|
94
|
-
break
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const parseHeader = (source: string, block: string): Effect.Effect<ParsedHeader, SkillSource.SkillSourceError> =>
|
|
99
|
-
Effect.sync(() => {
|
|
100
|
-
const parsed: Partial<ParsedHeader> = {}
|
|
101
|
-
const lines = block.split("\n")
|
|
102
|
-
for (let index = 0; index < lines.length; index += 1) {
|
|
103
|
-
const line = lines[index]?.trimEnd() ?? ""
|
|
104
|
-
if (line.trim().length === 0) continue
|
|
105
|
-
const separator = line.indexOf(":")
|
|
106
|
-
if (separator === -1) continue
|
|
107
|
-
const key = line.slice(0, separator).trim()
|
|
108
|
-
const raw = line.slice(separator + 1).trim()
|
|
109
|
-
if (raw.length === 0) {
|
|
110
|
-
const values: Array<string> = []
|
|
111
|
-
while ((lines[index + 1]?.trimStart().startsWith("- ") ?? false) === true) {
|
|
112
|
-
index += 1
|
|
113
|
-
values.push(stripQuotes((lines[index] ?? "").trimStart().slice(2)))
|
|
114
|
-
}
|
|
115
|
-
setValue(parsed, key, values)
|
|
116
|
-
} else if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
117
|
-
setValue(parsed, key, parseInlineArray(raw))
|
|
118
|
-
} else {
|
|
119
|
-
setValue(parsed, key, parseBoolean(raw) ?? stripQuotes(raw))
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return parsed
|
|
123
|
-
}).pipe(Effect.catchCause((cause) => Effect.fail(sourceError(source, "Invalid SKILL.md frontmatter", cause))))
|
|
124
|
-
|
|
125
|
-
const splitDocument = (
|
|
126
|
-
source: string,
|
|
127
|
-
content: string,
|
|
128
|
-
): Effect.Effect<readonly [string, string], SkillSource.SkillSourceError> =>
|
|
129
|
-
Effect.gen(function* () {
|
|
130
|
-
const normalized = content.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")
|
|
131
|
-
const lines = normalized.split("\n")
|
|
132
|
-
if (lines[0] !== "---") {
|
|
133
|
-
return yield* Effect.fail(sourceError(source, "Invalid SKILL.md document: missing opening frontmatter fence"))
|
|
134
|
-
}
|
|
135
|
-
const close = lines.findIndex((line, index) => index > 0 && line === "---")
|
|
136
|
-
if (close === -1) {
|
|
137
|
-
return yield* Effect.fail(sourceError(source, "Invalid SKILL.md document: missing closing frontmatter fence"))
|
|
138
|
-
}
|
|
139
|
-
return [lines.slice(1, close).join("\n"), lines.slice(close + 1).join("\n")] as const
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
const namespacedName = (path: Path.Path, relativeFile: string, explicitName: string | undefined): string => {
|
|
143
|
-
const directory = path.dirname(relativeFile)
|
|
144
|
-
const segments = directory === "." ? [] : directory.split("/").filter((part) => part.length > 0)
|
|
145
|
-
const directoryName = segments.at(-1) ?? path.basename(path.dirname(relativeFile))
|
|
146
|
-
const baseName = explicitName ?? directoryName
|
|
147
|
-
const namespace = segments.slice(0, -1).join(":")
|
|
148
|
-
return baseName.includes(":") || namespace.length === 0 ? baseName : `${namespace}:${baseName}`
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const parseDocument = (
|
|
152
|
-
source: string,
|
|
153
|
-
content: string,
|
|
154
|
-
name: string,
|
|
155
|
-
): Effect.Effect<ParsedDocument, SkillSource.SkillSourceError> =>
|
|
156
|
-
Effect.gen(function* () {
|
|
157
|
-
const [header, body] = yield* splitDocument(source, content)
|
|
158
|
-
const parsed = yield* parseHeader(source, header)
|
|
159
|
-
if (parsed.description === undefined || parsed.description.length === 0) {
|
|
160
|
-
return yield* Effect.fail(sourceError(source, "SKILL.md frontmatter requires description"))
|
|
161
|
-
}
|
|
162
|
-
return {
|
|
163
|
-
body,
|
|
164
|
-
frontmatter: {
|
|
165
|
-
name,
|
|
166
|
-
description: parsed.description,
|
|
167
|
-
...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
|
|
168
|
-
...(parsed.allowedTools === undefined ? {} : { allowedTools: parsed.allowedTools }),
|
|
169
|
-
...(parsed.disableModelInvocation === undefined
|
|
170
|
-
? {}
|
|
171
|
-
: { disableModelInvocation: parsed.disableModelInvocation }),
|
|
172
|
-
...(parsed.userInvocable === undefined ? {} : { userInvocable: parsed.userInvocable }),
|
|
173
|
-
...(parsed.contextFork === undefined ? {} : { contextFork: parsed.contextFork }),
|
|
174
|
-
...(parsed.agent === undefined ? {} : { agent: parsed.agent }),
|
|
175
|
-
...(parsed.model === undefined ? {} : { model: parsed.model }),
|
|
176
|
-
...(parsed.paths === undefined ? {} : { paths: parsed.paths }),
|
|
177
|
-
},
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
const readHeader = (
|
|
182
|
-
fs: FileSystem.FileSystem,
|
|
183
|
-
source: string,
|
|
184
|
-
bytes: number,
|
|
185
|
-
): Effect.Effect<string, SkillSource.SkillSourceError> =>
|
|
186
|
-
fs.stream(source, { bytesToRead: bytes, chunkSize: bytes }).pipe(
|
|
187
|
-
Stream.runFold(
|
|
188
|
-
() => "",
|
|
189
|
-
(content, chunk) => `${content}${decoder.decode(chunk)}`,
|
|
190
|
-
),
|
|
191
|
-
Effect.mapError((error) => mapPlatformError(source, error)),
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
const loadSkill = (
|
|
195
|
-
fs: FileSystem.FileSystem,
|
|
196
|
-
path: Path.Path,
|
|
197
|
-
file: string,
|
|
198
|
-
relativeFile: string,
|
|
199
|
-
descriptionCap: number,
|
|
200
|
-
frontmatterMaxBytes: number,
|
|
201
|
-
): Effect.Effect<SkillSource.Skill, SkillSource.SkillSourceError> =>
|
|
202
|
-
Effect.gen(function* () {
|
|
203
|
-
const header = yield* readHeader(fs, file, frontmatterMaxBytes)
|
|
204
|
-
const [headerBlock] = yield* splitDocument(file, header)
|
|
205
|
-
const parsed = yield* parseHeader(file, headerBlock)
|
|
206
|
-
const name = namespacedName(path, relativeFile, parsed.name)
|
|
207
|
-
if (parsed.description === undefined || parsed.description.length === 0) {
|
|
208
|
-
return yield* Effect.fail(sourceError(file, "SKILL.md frontmatter requires description"))
|
|
209
|
-
}
|
|
210
|
-
const frontmatter: SkillSource.Frontmatter = {
|
|
211
|
-
name,
|
|
212
|
-
description: parsed.description,
|
|
213
|
-
...(parsed.whenToUse === undefined ? {} : { whenToUse: parsed.whenToUse }),
|
|
214
|
-
...(parsed.allowedTools === undefined ? {} : { allowedTools: parsed.allowedTools }),
|
|
215
|
-
...(parsed.disableModelInvocation === undefined ? {} : { disableModelInvocation: parsed.disableModelInvocation }),
|
|
216
|
-
...(parsed.userInvocable === undefined ? {} : { userInvocable: parsed.userInvocable }),
|
|
217
|
-
...(parsed.contextFork === undefined ? {} : { contextFork: parsed.contextFork }),
|
|
218
|
-
...(parsed.agent === undefined ? {} : { agent: parsed.agent }),
|
|
219
|
-
...(parsed.model === undefined ? {} : { model: parsed.model }),
|
|
220
|
-
...(parsed.paths === undefined ? {} : { paths: parsed.paths }),
|
|
221
|
-
}
|
|
222
|
-
return {
|
|
223
|
-
frontmatter,
|
|
224
|
-
listing: SkillSource.makeListing(frontmatter, descriptionCap),
|
|
225
|
-
body: fs.readFileString(file).pipe(
|
|
226
|
-
Effect.mapError((error) => mapPlatformError(file, error)),
|
|
227
|
-
Effect.flatMap((content) => parseDocument(file, content, name).pipe(Effect.map((document) => document.body))),
|
|
228
|
-
),
|
|
229
|
-
tools: [],
|
|
230
|
-
}
|
|
231
|
-
})
|
|
232
|
-
|
|
233
|
-
const discoverRoot = (
|
|
234
|
-
fs: FileSystem.FileSystem,
|
|
235
|
-
path: Path.Path,
|
|
236
|
-
cwd: string,
|
|
237
|
-
root: string,
|
|
238
|
-
descriptionCap: number,
|
|
239
|
-
frontmatterMaxBytes: number,
|
|
240
|
-
): Effect.Effect<ReadonlyArray<SkillSource.Skill>, SkillSource.SkillSourceError> =>
|
|
241
|
-
Effect.gen(function* () {
|
|
242
|
-
const rootPath = path.isAbsolute(root) ? path.normalize(root) : path.join(cwd, root)
|
|
243
|
-
const exists = yield* fs.exists(rootPath).pipe(Effect.mapError((error) => mapPlatformError(rootPath, error)))
|
|
244
|
-
if (!exists) return []
|
|
245
|
-
const entries = yield* fs
|
|
246
|
-
.readDirectory(rootPath, { recursive: true })
|
|
247
|
-
.pipe(Effect.mapError((error) => mapPlatformError(rootPath, error)))
|
|
248
|
-
const skills: Array<SkillSource.Skill> = []
|
|
249
|
-
for (const skillFile of entries.filter((entry) => path.basename(entry) === "SKILL.md").toSorted()) {
|
|
250
|
-
skills.push(
|
|
251
|
-
yield* loadSkill(fs, path, path.join(rootPath, skillFile), skillFile, descriptionCap, frontmatterMaxBytes),
|
|
252
|
-
)
|
|
253
|
-
}
|
|
254
|
-
return skills
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
/** @experimental Build a SkillSource from filesystem roots. */
|
|
258
|
-
export const layer = (
|
|
259
|
-
options: LoadOptions = {},
|
|
260
|
-
): Layer.Layer<SkillSource.SkillSource, SkillSource.SkillSourceError, FileSystem.FileSystem | Path.Path> =>
|
|
261
|
-
Layer.effect(
|
|
262
|
-
SkillSource.SkillSource,
|
|
263
|
-
Effect.gen(function* () {
|
|
264
|
-
const fs = yield* FileSystem.FileSystem
|
|
265
|
-
const path = yield* Path.Path
|
|
266
|
-
const cwd = options.cwd === undefined ? "." : path.resolve(options.cwd)
|
|
267
|
-
const roots = options.roots ?? DEFAULT_ROOTS
|
|
268
|
-
const descriptionCap = options.descriptionCap ?? SkillSource.DESCRIPTION_CAP
|
|
269
|
-
const frontmatterMaxBytes = options.frontmatterMaxBytes ?? 64 * 1024
|
|
270
|
-
const byName = new Map<string, SkillSource.Skill>()
|
|
271
|
-
for (const root of roots) {
|
|
272
|
-
for (const skill of yield* discoverRoot(fs, path, cwd, root, descriptionCap, frontmatterMaxBytes)) {
|
|
273
|
-
byName.set(skill.frontmatter.name, skill)
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
const skills = [...byName.values()]
|
|
277
|
-
return SkillSource.SkillSource.of({
|
|
278
|
-
all: Effect.succeed(skills),
|
|
279
|
-
get: (name) => Effect.succeed(byName.get(name)),
|
|
280
|
-
})
|
|
281
|
-
}),
|
|
282
|
-
)
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"
|
|
3
|
-
import { InstructionFiles } from "../src/index"
|
|
4
|
-
|
|
5
|
-
const notFound = (method: string, path: string) =>
|
|
6
|
-
PlatformError.systemError({
|
|
7
|
-
_tag: "NotFound",
|
|
8
|
-
module: "InstructionFilesTest",
|
|
9
|
-
method,
|
|
10
|
-
description: "not found",
|
|
11
|
-
pathOrDescriptor: path,
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
const testFsLayer = (files: Readonly<Record<string, string>>) =>
|
|
15
|
-
FileSystem.layerNoop({
|
|
16
|
-
exists: (path) => Effect.succeed(path in files),
|
|
17
|
-
readFileString: (path) => {
|
|
18
|
-
const content = files[path]
|
|
19
|
-
return content === undefined ? Effect.fail(notFound("readFileString", path)) : Effect.succeed(content)
|
|
20
|
-
},
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
describe("InstructionFiles", () => {
|
|
24
|
-
it.effect("loads global files before root-to-cwd ancestors with AGENTS.md preferred", () => {
|
|
25
|
-
const files = {
|
|
26
|
-
"/global/AGENTS.md": "global",
|
|
27
|
-
"/repo/AGENTS.md": "root agents",
|
|
28
|
-
"/repo/a/CLAUDE.md": "a claude",
|
|
29
|
-
"/repo/a/b/AGENTS.md": "b agents",
|
|
30
|
-
"/repo/a/b/CLAUDE.md": "b claude ignored",
|
|
31
|
-
}
|
|
32
|
-
return Effect.gen(function* () {
|
|
33
|
-
const loaded = yield* InstructionFiles.loadInstructionFiles({
|
|
34
|
-
cwd: "/repo/a/b",
|
|
35
|
-
globalFiles: ["/global/AGENTS.md"],
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
expect(loaded).toEqual([
|
|
39
|
-
{ path: "/global/AGENTS.md", content: "global" },
|
|
40
|
-
{ path: "/repo/AGENTS.md", content: "root agents" },
|
|
41
|
-
{ path: "/repo/a/CLAUDE.md", content: "a claude" },
|
|
42
|
-
{ path: "/repo/a/b/AGENTS.md", content: "b agents" },
|
|
43
|
-
])
|
|
44
|
-
}).pipe(Effect.provide(Layer.mergeAll(testFsLayer(files), Path.layer)))
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it.effect("supports custom filenames and skips missing files", () => {
|
|
48
|
-
const files = {
|
|
49
|
-
"/repo/a/NOTES.md": "notes",
|
|
50
|
-
}
|
|
51
|
-
return Effect.gen(function* () {
|
|
52
|
-
const loaded = yield* InstructionFiles.loadInstructionFiles({ cwd: "/repo/a", filenames: ["NOTES.md"] })
|
|
53
|
-
|
|
54
|
-
expect(loaded).toEqual([{ path: "/repo/a/NOTES.md", content: "notes" }])
|
|
55
|
-
}).pipe(Effect.provide(Layer.mergeAll(testFsLayer(files), Path.layer)))
|
|
56
|
-
})
|
|
57
|
-
})
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "@effect/vitest"
|
|
2
|
-
import { Effect, FileSystem, Layer, Path, PlatformError, Stream } from "effect"
|
|
3
|
-
import { SkillSource } from "@batonfx/core"
|
|
4
|
-
import { SkillLoader } from "../src/index"
|
|
5
|
-
|
|
6
|
-
const encoder = new TextEncoder()
|
|
7
|
-
|
|
8
|
-
interface ReadCounts {
|
|
9
|
-
readonly full: Record<string, number>
|
|
10
|
-
readonly streamed: Record<string, number>
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const notFound = (method: string, path: string) =>
|
|
14
|
-
PlatformError.systemError({
|
|
15
|
-
_tag: "NotFound",
|
|
16
|
-
module: "SkillLoaderTest",
|
|
17
|
-
method,
|
|
18
|
-
description: "not found",
|
|
19
|
-
pathOrDescriptor: path,
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
const testFsLayer = (
|
|
23
|
-
files: Readonly<Record<string, string>>,
|
|
24
|
-
directories: Readonly<Record<string, ReadonlyArray<string>>>,
|
|
25
|
-
reads: ReadCounts = { full: {}, streamed: {} },
|
|
26
|
-
) =>
|
|
27
|
-
FileSystem.layerNoop({
|
|
28
|
-
exists: (path) => Effect.succeed(path in files || path in directories),
|
|
29
|
-
readDirectory: (path) => {
|
|
30
|
-
const entries = directories[path]
|
|
31
|
-
return entries === undefined ? Effect.fail(notFound("readDirectory", path)) : Effect.succeed([...entries])
|
|
32
|
-
},
|
|
33
|
-
readFileString: (path) => {
|
|
34
|
-
const content = files[path]
|
|
35
|
-
return content === undefined
|
|
36
|
-
? Effect.fail(notFound("readFileString", path))
|
|
37
|
-
: Effect.sync(() => {
|
|
38
|
-
reads.full[path] = (reads.full[path] ?? 0) + 1
|
|
39
|
-
return content
|
|
40
|
-
})
|
|
41
|
-
},
|
|
42
|
-
stream: (path, options) => {
|
|
43
|
-
const content = files[path]
|
|
44
|
-
return content === undefined
|
|
45
|
-
? Stream.fail(notFound("stream", path))
|
|
46
|
-
: Stream.sync(() => {
|
|
47
|
-
reads.streamed[path] = (reads.streamed[path] ?? 0) + 1
|
|
48
|
-
const bytesToRead = Number(options?.bytesToRead ?? content.length)
|
|
49
|
-
return encoder.encode(content.slice(0, bytesToRead))
|
|
50
|
-
})
|
|
51
|
-
},
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const loaderTestLayer = (
|
|
55
|
-
options: Parameters<typeof SkillLoader.layer>[0],
|
|
56
|
-
files: Readonly<Record<string, string>>,
|
|
57
|
-
directories: Readonly<Record<string, ReadonlyArray<string>>>,
|
|
58
|
-
reads?: ReadCounts,
|
|
59
|
-
) => SkillLoader.layer(options).pipe(Layer.provide(Layer.mergeAll(testFsLayer(files, directories, reads), Path.layer)))
|
|
60
|
-
|
|
61
|
-
describe("SkillLoader", () => {
|
|
62
|
-
it.effect("parses frontmatter and leaves body lazy", () => {
|
|
63
|
-
const reads: ReadCounts = { full: {}, streamed: {} }
|
|
64
|
-
const path = "/repo/.agents/skills/review/SKILL.md"
|
|
65
|
-
const files = {
|
|
66
|
-
[path]: `---
|
|
67
|
-
name: review
|
|
68
|
-
description: Review code carefully
|
|
69
|
-
when-to-use: before merging
|
|
70
|
-
allowedTools:
|
|
71
|
-
- read
|
|
72
|
-
- grep
|
|
73
|
-
disableModelInvocation: false
|
|
74
|
-
userInvocable: true
|
|
75
|
-
contextFork: true
|
|
76
|
-
agent: reviewer
|
|
77
|
-
model: fast
|
|
78
|
-
paths: ["packages/core/**", "docs/spec/**"]
|
|
79
|
-
---
|
|
80
|
-
# Review body
|
|
81
|
-
Use the checklist.
|
|
82
|
-
`,
|
|
83
|
-
}
|
|
84
|
-
const directories = { "/repo/.agents/skills": ["review/SKILL.md"] }
|
|
85
|
-
return Effect.gen(function* () {
|
|
86
|
-
const source = yield* SkillSource.SkillSource
|
|
87
|
-
const all = yield* source.all
|
|
88
|
-
const found = yield* source.get("review")
|
|
89
|
-
|
|
90
|
-
expect(all).toHaveLength(1)
|
|
91
|
-
expect(found).toBe(all[0])
|
|
92
|
-
expect(all[0]?.frontmatter).toMatchObject({
|
|
93
|
-
name: "review",
|
|
94
|
-
description: "Review code carefully",
|
|
95
|
-
whenToUse: "before merging",
|
|
96
|
-
allowedTools: ["read", "grep"],
|
|
97
|
-
disableModelInvocation: false,
|
|
98
|
-
userInvocable: true,
|
|
99
|
-
contextFork: true,
|
|
100
|
-
agent: "reviewer",
|
|
101
|
-
model: "fast",
|
|
102
|
-
paths: ["packages/core/**", "docs/spec/**"],
|
|
103
|
-
})
|
|
104
|
-
expect(all[0]?.listing).toBe("- review: Review code carefully")
|
|
105
|
-
expect(reads.streamed[path]).toBe(1)
|
|
106
|
-
expect(reads.full[path]).toBeUndefined()
|
|
107
|
-
|
|
108
|
-
const body = yield* all[0]!.body
|
|
109
|
-
|
|
110
|
-
expect(body).toContain("# Review body")
|
|
111
|
-
expect(reads.full[path]).toBe(1)
|
|
112
|
-
}).pipe(Effect.provide(loaderTestLayer({ cwd: "/repo", roots: [".agents/skills"] }, files, directories, reads)))
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it.effect("defaults names, namespaces nested skills, and lets later roots win collisions", () => {
|
|
116
|
-
const files = {
|
|
117
|
-
"/repo/a/frontend/lint/SKILL.md": `---
|
|
118
|
-
description: Lint frontend
|
|
119
|
-
---
|
|
120
|
-
body a`,
|
|
121
|
-
"/repo/a/dup/SKILL.md": `---
|
|
122
|
-
name: dup
|
|
123
|
-
description: First duplicate
|
|
124
|
-
---
|
|
125
|
-
body first`,
|
|
126
|
-
"/repo/b/dup/SKILL.md": `---
|
|
127
|
-
name: dup
|
|
128
|
-
description: Second duplicate
|
|
129
|
-
---
|
|
130
|
-
body second`,
|
|
131
|
-
}
|
|
132
|
-
const directories = {
|
|
133
|
-
"/repo/a": ["frontend/lint/SKILL.md", "dup/SKILL.md"],
|
|
134
|
-
"/repo/b": ["dup/SKILL.md"],
|
|
135
|
-
}
|
|
136
|
-
return Effect.gen(function* () {
|
|
137
|
-
const source = yield* SkillSource.SkillSource
|
|
138
|
-
const all = yield* source.all
|
|
139
|
-
const nested = yield* source.get("frontend:lint")
|
|
140
|
-
const duplicate = yield* source.get("dup")
|
|
141
|
-
|
|
142
|
-
expect(all.map((skill) => skill.frontmatter.name)).toEqual(["dup", "frontend:lint"])
|
|
143
|
-
expect(nested?.frontmatter.description).toBe("Lint frontend")
|
|
144
|
-
expect(duplicate?.frontmatter.description).toBe("Second duplicate")
|
|
145
|
-
}).pipe(Effect.provide(loaderTestLayer({ cwd: "/repo", roots: ["a", "b"] }, files, directories)))
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it.effect("fails typed for invalid frontmatter and keeps user-only skills addressable", () => {
|
|
149
|
-
const files = {
|
|
150
|
-
"/repo/skills/user-only/SKILL.md": `---
|
|
151
|
-
description: User only
|
|
152
|
-
disableModelInvocation: true
|
|
153
|
-
---
|
|
154
|
-
body`,
|
|
155
|
-
"/repo/skills/bad/SKILL.md": `---
|
|
156
|
-
name: bad
|
|
157
|
-
---
|
|
158
|
-
body`,
|
|
159
|
-
}
|
|
160
|
-
const directories = { "/repo/skills": ["user-only/SKILL.md", "bad/SKILL.md"] }
|
|
161
|
-
return Effect.gen(function* () {
|
|
162
|
-
const failure = yield* Effect.flip(
|
|
163
|
-
SkillSource.SkillSource.pipe(
|
|
164
|
-
Effect.provide(loaderTestLayer({ cwd: "/repo", roots: ["skills"] }, files, directories)),
|
|
165
|
-
),
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
expect(failure._tag).toBe("@batonfx/core/SkillSourceError")
|
|
169
|
-
|
|
170
|
-
const goodSource = yield* SkillSource.SkillSource.pipe(
|
|
171
|
-
Effect.provide(
|
|
172
|
-
loaderTestLayer(
|
|
173
|
-
{ cwd: "/repo", roots: ["skills"] },
|
|
174
|
-
{
|
|
175
|
-
"/repo/skills/user-only/SKILL.md": files["/repo/skills/user-only/SKILL.md"] ?? "",
|
|
176
|
-
},
|
|
177
|
-
{ "/repo/skills": ["user-only/SKILL.md"] },
|
|
178
|
-
),
|
|
179
|
-
),
|
|
180
|
-
)
|
|
181
|
-
const userOnly = yield* goodSource.get("user-only")
|
|
182
|
-
|
|
183
|
-
expect(userOnly).toBeDefined()
|
|
184
|
-
expect(SkillSource.selectListings(userOnly === undefined ? [] : [userOnly], 1_000, [])).toEqual([])
|
|
185
|
-
})
|
|
186
|
-
})
|
|
187
|
-
})
|