@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.
@@ -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
- })
package/tsconfig.json DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "extends": "../../tsconfig.json",
3
- "compilerOptions": {
4
- "rootDir": "."
5
- },
6
- "include": ["src/**/*.ts", "test/**/*.ts"]
7
- }