@batonfx/skills 0.3.7 → 0.4.0

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,5 +1,6 @@
1
1
  import { Effect, FileSystem, Layer, Path, PlatformError, Stream } from "effect"
2
2
  import { SkillSource } from "@batonfx/core"
3
+ import { parseDocument, parseFrontmatter, splitDocument } from "./skill-document"
3
4
 
4
5
  /** @experimental Filesystem skill-loader options. */
5
6
  export interface LoadOptions {
@@ -13,171 +14,12 @@ const DEFAULT_ROOTS = [".agents/skills", ".claude/skills", ".pi/skills"] as cons
13
14
 
14
15
  const decoder = new TextDecoder()
15
16
 
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
17
  const sourceError = (source: string, message: string, cause?: unknown): SkillSource.SkillSourceError =>
35
18
  new SkillSource.SkillSourceError({ source, message, ...(cause === undefined ? {} : { cause }) })
36
19
 
37
20
  const mapPlatformError = (source: string, error: PlatformError.PlatformError): SkillSource.SkillSourceError =>
38
21
  sourceError(source, error.message, error)
39
22
 
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
23
  const readHeader = (
182
24
  fs: FileSystem.FileSystem,
183
25
  source: string,
@@ -202,29 +44,16 @@ const loadSkill = (
202
44
  Effect.gen(function* () {
203
45
  const header = yield* readHeader(fs, file, frontmatterMaxBytes)
204
46
  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
- }
47
+ const directoryName = path.basename(path.dirname(relativeFile))
48
+ const frontmatter = yield* parseFrontmatter(file, headerBlock, directoryName)
222
49
  return {
223
50
  frontmatter,
224
51
  listing: SkillSource.makeListing(frontmatter, descriptionCap),
225
52
  body: fs.readFileString(file).pipe(
226
53
  Effect.mapError((error) => mapPlatformError(file, error)),
227
- Effect.flatMap((content) => parseDocument(file, content, name).pipe(Effect.map((document) => document.body))),
54
+ Effect.flatMap((content) =>
55
+ parseDocument(file, content, directoryName).pipe(Effect.map((document) => document.body)),
56
+ ),
228
57
  ),
229
58
  tools: [],
230
59
  }
@@ -254,29 +83,30 @@ const discoverRoot = (
254
83
  return skills
255
84
  })
256
85
 
257
- /** @experimental Build a SkillSource from filesystem roots. */
86
+ /** @experimental Build a composable SkillSource from filesystem roots. */
87
+ export const make = (options: LoadOptions = {}): SkillSource.Source<FileSystem.FileSystem | Path.Path> =>
88
+ Effect.gen(function* () {
89
+ const fs = yield* FileSystem.FileSystem
90
+ const path = yield* Path.Path
91
+ const cwd = options.cwd === undefined ? "." : path.resolve(options.cwd)
92
+ const roots = options.roots ?? DEFAULT_ROOTS
93
+ const descriptionCap = options.descriptionCap ?? SkillSource.DESCRIPTION_CAP
94
+ const frontmatterMaxBytes = options.frontmatterMaxBytes ?? 64 * 1024
95
+ const byName = new Map<string, SkillSource.Skill>()
96
+ for (const root of roots) {
97
+ for (const skill of yield* discoverRoot(fs, path, cwd, root, descriptionCap, frontmatterMaxBytes)) {
98
+ byName.set(skill.frontmatter.name, skill)
99
+ }
100
+ }
101
+ const skills = [...byName.values()]
102
+ return {
103
+ all: Effect.succeed(skills),
104
+ get: (name) => Effect.succeed(byName.get(name)),
105
+ }
106
+ })
107
+
108
+ /** @experimental Build a SkillSource layer from filesystem roots. */
258
109
  export const layer = (
259
110
  options: LoadOptions = {},
260
111
  ): 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
- )
112
+ Layer.effect(SkillSource.SkillSource, make(options).pipe(Effect.map(SkillSource.SkillSource.of)))
@@ -0,0 +1,398 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { Crypto, Effect, Encoding, Layer } from "effect"
3
+ import { HttpClient, HttpClientResponse } from "effect/unstable/http"
4
+ import { SkillSource } from "@batonfx/core"
5
+ import { GitHubCatalog, HttpCatalog, S3Catalog } from "../src/index"
6
+
7
+ const digestBytes = new Uint8Array(32).fill(1)
8
+ const digest = Encoding.encodeHex(digestBytes)
9
+
10
+ const cryptoLayer = (bytes: Uint8Array = digestBytes) =>
11
+ Layer.succeed(
12
+ Crypto.Crypto,
13
+ Crypto.make({
14
+ randomBytes: (size) => new Uint8Array(size),
15
+ digest: () => Effect.succeed(bytes),
16
+ }),
17
+ )
18
+
19
+ const httpLayer = (
20
+ responses: Readonly<
21
+ Record<string, { readonly body: string | Uint8Array | ReadableStream<Uint8Array>; readonly status?: number }>
22
+ >,
23
+ requests: Array<{ readonly url: string; readonly accept: string | undefined }>,
24
+ ) =>
25
+ Layer.succeed(
26
+ HttpClient.HttpClient,
27
+ HttpClient.make((request, url) => {
28
+ const target = url.toString()
29
+ requests.push({ url: target, accept: request.headers.accept })
30
+ const response = responses[target]
31
+ return Effect.succeed(
32
+ HttpClientResponse.fromWeb(
33
+ request,
34
+ new Response(response?.body ?? "missing", {
35
+ status: response?.status ?? (response === undefined ? 404 : 200),
36
+ }),
37
+ ),
38
+ )
39
+ }),
40
+ )
41
+
42
+ const document = `---
43
+ name: remote
44
+ description: Remote review skill
45
+ allowed-tools: read grep
46
+ ---
47
+ # Remote body
48
+ Review carefully.
49
+ `
50
+
51
+ const manifest = (skillPath: string = "remote/SKILL.md", sha256: string = digest) =>
52
+ JSON.stringify({
53
+ version: 1,
54
+ skills: [
55
+ {
56
+ name: "remote",
57
+ description: "Remote review skill",
58
+ allowedTools: ["read", "grep"],
59
+ skillPath,
60
+ sha256,
61
+ },
62
+ ],
63
+ })
64
+
65
+ describe("hosted skill catalogs", () => {
66
+ it.effect("loads HTTP metadata once and keeps verified SKILL.md bodies lazy", () => {
67
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
68
+ const manifestUrl = "https://skills.example/catalog/skills.json"
69
+ const bodyUrl = "https://skills.example/catalog/remote/SKILL.md"
70
+ return Effect.gen(function* () {
71
+ const source = yield* HttpCatalog.make({ manifestUrl, source: "company-skills" })
72
+ const all = yield* source.all
73
+
74
+ expect(all.map((skill) => skill.frontmatter.name)).toEqual(["remote"])
75
+ expect(requests.map((request) => request.url)).toEqual([manifestUrl])
76
+
77
+ const body = yield* all[0]!.body
78
+
79
+ expect(body).toContain("# Remote body")
80
+ expect(requests.map((request) => request.url)).toEqual([manifestUrl, bodyUrl])
81
+ }).pipe(
82
+ Effect.provide(
83
+ Layer.mergeAll(
84
+ cryptoLayer(),
85
+ httpLayer({ [manifestUrl]: { body: manifest() }, [bodyUrl]: { body: document } }, requests),
86
+ ),
87
+ ),
88
+ )
89
+ })
90
+
91
+ it.effect("rejects unsafe hosted paths during source construction", () => {
92
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
93
+ const manifestUrl = "https://skills.example/catalog/skills.json"
94
+ return Effect.gen(function* () {
95
+ const failure = yield* Effect.flip(HttpCatalog.make({ manifestUrl, source: "unsafe-catalog" }))
96
+
97
+ expect(failure._tag).toBe("@batonfx/core/SkillSourceError")
98
+ expect(failure.source).toBe("unsafe-catalog")
99
+ }).pipe(
100
+ Effect.provide(
101
+ Layer.mergeAll(cryptoLayer(), httpLayer({ [manifestUrl]: { body: manifest("../escape/SKILL.md") } }, requests)),
102
+ ),
103
+ )
104
+ })
105
+
106
+ it.effect("fails digest mismatches on activation and allows a later retry", () => {
107
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
108
+ const manifestUrl = "https://skills.example/catalog/skills.json"
109
+ const bodyUrl = "https://skills.example/catalog/remote/SKILL.md"
110
+ return Effect.gen(function* () {
111
+ const source = yield* HttpCatalog.make({ manifestUrl, source: "retry-catalog" })
112
+ const skill = yield* source.get("remote")
113
+ const first = yield* Effect.exit(skill!.body)
114
+ const second = yield* Effect.exit(skill!.body)
115
+
116
+ expect(first._tag).toBe("Failure")
117
+ expect(second._tag).toBe("Failure")
118
+ expect(requests.filter((request) => request.url === bodyUrl)).toHaveLength(2)
119
+ }).pipe(
120
+ Effect.provide(
121
+ Layer.mergeAll(
122
+ cryptoLayer(new Uint8Array(32).fill(2)),
123
+ httpLayer({ [manifestUrl]: { body: manifest() }, [bodyUrl]: { body: document } }, requests),
124
+ ),
125
+ ),
126
+ )
127
+ })
128
+
129
+ it.effect("rejects frontmatter drift after a verified body fetch", () => {
130
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
131
+ const manifestUrl = "https://skills.example/catalog/skills.json"
132
+ const bodyUrl = "https://skills.example/catalog/remote/SKILL.md"
133
+ const drifted = document.replace("Remote review skill", "Changed after publication")
134
+ return Effect.gen(function* () {
135
+ const source = yield* HttpCatalog.make({ manifestUrl, source: "drift-catalog" })
136
+ const skill = yield* source.get("remote")
137
+ const failure = yield* Effect.flip(skill!.body)
138
+
139
+ expect(failure._tag).toBe("@batonfx/core/SkillSourceError")
140
+ expect(failure.message).toContain("Frontmatter mismatch")
141
+ }).pipe(
142
+ Effect.provide(
143
+ Layer.mergeAll(
144
+ cryptoLayer(),
145
+ httpLayer({ [manifestUrl]: { body: manifest() }, [bodyUrl]: { body: drifted } }, requests),
146
+ ),
147
+ ),
148
+ )
149
+ })
150
+
151
+ it.effect("rejects duplicate names and bounded manifest overflows", () => {
152
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
153
+ const duplicateUrl = "https://skills.example/duplicate.json"
154
+ const limitedUrl = "https://skills.example/limited.json"
155
+ const duplicate = JSON.stringify({
156
+ version: 1,
157
+ skills: [...JSON.parse(manifest()).skills, ...JSON.parse(manifest()).skills],
158
+ })
159
+ return Effect.gen(function* () {
160
+ const duplicateFailure = yield* Effect.flip(HttpCatalog.make({ manifestUrl: duplicateUrl }))
161
+ const limitedFailure = yield* Effect.flip(HttpCatalog.make({ manifestUrl: limitedUrl, manifestMaxBytes: 1 }))
162
+
163
+ expect(duplicateFailure.message).toContain("Duplicate hosted skill name")
164
+ expect(limitedFailure.message).toContain("exceeds 1 bytes")
165
+ }).pipe(
166
+ Effect.provide(
167
+ Layer.mergeAll(
168
+ cryptoLayer(),
169
+ httpLayer({ [duplicateUrl]: { body: duplicate }, [limitedUrl]: { body: manifest() } }, requests),
170
+ ),
171
+ ),
172
+ )
173
+ })
174
+
175
+ it.effect("maps non-success HTTP responses to safe source errors", () => {
176
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
177
+ const manifestUrl = "https://user:password@skills.example/private/skills.json?signature=secret#fragment"
178
+ return Effect.gen(function* () {
179
+ const failure = yield* Effect.flip(HttpCatalog.make({ manifestUrl }))
180
+
181
+ expect(failure.source).toBe("https://skills.example/private/skills.json")
182
+ expect(failure.source).not.toContain("secret")
183
+ expect(failure.source).not.toContain("password")
184
+ }).pipe(
185
+ Effect.provide(
186
+ Layer.mergeAll(cryptoLayer(), httpLayer({ [manifestUrl]: { body: "forbidden", status: 403 } }, requests)),
187
+ ),
188
+ )
189
+ })
190
+
191
+ it.effect("constructs S3 and immutable GitHub catalog endpoints", () => {
192
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
193
+ const commit = "a".repeat(40)
194
+ const s3Manifest = "https://company-skills.s3.us-west-2.amazonaws.com/support/skills.json"
195
+ const s3Body = "https://company-skills.s3.us-west-2.amazonaws.com/support/remote/SKILL.md"
196
+ const githubManifest = `https://api.github.com/repos/acme/agent-skills/contents/skills/skills.json?ref=${commit}`
197
+ const githubBody = `https://api.github.com/repos/acme/agent-skills/contents/skills/remote/SKILL.md?ref=${commit}`
198
+ return Effect.gen(function* () {
199
+ const s3 = yield* S3Catalog.make({ bucket: "company-skills", region: "us-west-2", prefix: "support" })
200
+ const github = yield* GitHubCatalog.make({
201
+ owner: "acme",
202
+ repo: "agent-skills",
203
+ ref: commit,
204
+ root: "skills",
205
+ })
206
+
207
+ yield* (yield* s3.get("remote"))!.body
208
+ yield* (yield* github.get("remote"))!.body
209
+
210
+ expect(requests.map((request) => request.url)).toEqual([s3Manifest, githubManifest, s3Body, githubBody])
211
+ expect(requests.filter((request) => request.url.startsWith("https://api.github.com"))).toSatisfy(
212
+ (items: ReadonlyArray<{ readonly accept: string | undefined }>) =>
213
+ items.every((request) => request.accept === "application/vnd.github.raw+json"),
214
+ )
215
+ }).pipe(
216
+ Effect.provide(
217
+ Layer.mergeAll(
218
+ cryptoLayer(),
219
+ httpLayer(
220
+ {
221
+ [s3Manifest]: { body: manifest() },
222
+ [s3Body]: { body: document },
223
+ [githubManifest]: { body: manifest() },
224
+ [githubBody]: { body: document },
225
+ },
226
+ requests,
227
+ ),
228
+ ),
229
+ ),
230
+ )
231
+ })
232
+
233
+ it.effect("requires GitHub refs to be immutable commit ids", () =>
234
+ Effect.gen(function* () {
235
+ const failure = yield* Effect.flip(
236
+ GitHubCatalog.make({ owner: "acme", repo: "agent-skills", ref: "main", root: "skills" }),
237
+ )
238
+ expect(failure._tag).toBe("@batonfx/core/SkillSourceError")
239
+ }).pipe(Effect.provide(Layer.mergeAll(cryptoLayer(), httpLayer({}, [])))),
240
+ )
241
+
242
+ it.effect("bounds raw streamed bytes before buffering and rejects invalid UTF-8", () => {
243
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
244
+ const limitedUrl = "https://skills.example/streamed.json"
245
+ const invalidUrl = "https://skills.example/invalid.json"
246
+ let pulls = 0
247
+ const streamed = new ReadableStream<Uint8Array>(
248
+ {
249
+ pull(controller) {
250
+ pulls += 1
251
+ controller.enqueue(new Uint8Array([123, 34, 120]))
252
+ if (pulls >= 3) controller.close()
253
+ },
254
+ },
255
+ { highWaterMark: 0 },
256
+ )
257
+ return Effect.gen(function* () {
258
+ const limited = yield* Effect.flip(HttpCatalog.make({ manifestUrl: limitedUrl, manifestMaxBytes: 4 }))
259
+ const invalid = yield* Effect.flip(HttpCatalog.make({ manifestUrl: invalidUrl }))
260
+
261
+ expect(limited.message).toContain("exceeds 4 bytes")
262
+ expect(pulls).toBeLessThan(3)
263
+ expect(invalid.message).toContain("UTF-8")
264
+ }).pipe(
265
+ Effect.provide(
266
+ Layer.mergeAll(
267
+ cryptoLayer(),
268
+ httpLayer(
269
+ {
270
+ [limitedUrl]: { body: streamed },
271
+ [invalidUrl]: { body: new Uint8Array([0xff]) },
272
+ },
273
+ requests,
274
+ ),
275
+ ),
276
+ ),
277
+ )
278
+ })
279
+
280
+ it.effect("hashes exact body bytes and does not inherit tools from record prototypes", () => {
281
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
282
+ const manifestUrl = "https://skills.example/catalog/skills.json"
283
+ const bodyUrl = "https://skills.example/catalog/constructor/SKILL.md"
284
+ const raw = new TextEncoder().encode(`---\nname: constructor\ndescription: Safe own-property lookup\n---\nbody`)
285
+ let hashed: Uint8Array | undefined
286
+ const manifestBody = JSON.stringify({
287
+ version: 1,
288
+ skills: [
289
+ {
290
+ name: "constructor",
291
+ description: "Safe own-property lookup",
292
+ skillPath: "constructor/SKILL.md",
293
+ sha256: digest,
294
+ },
295
+ ],
296
+ })
297
+ const recordingCrypto = Layer.succeed(
298
+ Crypto.Crypto,
299
+ Crypto.make({
300
+ randomBytes: (size) => new Uint8Array(size),
301
+ digest: (_algorithm, bytes) => {
302
+ hashed = bytes
303
+ return Effect.succeed(digestBytes)
304
+ },
305
+ }),
306
+ )
307
+ return Effect.gen(function* () {
308
+ const source = yield* HttpCatalog.make({ manifestUrl, toolsBySkill: {} })
309
+ const skill = yield* source.get("constructor")
310
+ yield* skill!.body
311
+
312
+ expect([...hashed!]).toEqual([...raw])
313
+ expect(skill!.tools).toEqual([])
314
+ }).pipe(
315
+ Effect.provide(
316
+ Layer.mergeAll(
317
+ recordingCrypto,
318
+ httpLayer({ [manifestUrl]: { body: manifestBody }, [bodyUrl]: { body: raw } }, requests),
319
+ ),
320
+ ),
321
+ )
322
+ })
323
+
324
+ it.effect("rejects unsafe provider components and encoded hosted paths", () =>
325
+ Effect.gen(function* () {
326
+ const commit = "a".repeat(40)
327
+ const encodedPathUrl = "https://skills.example/catalog.json"
328
+ const failures = yield* Effect.all([
329
+ Effect.flip(GitHubCatalog.make({ owner: "..", repo: "skills", ref: commit })),
330
+ Effect.flip(GitHubCatalog.make({ owner: "acme", repo: "..", ref: commit })),
331
+ Effect.flip(
332
+ GitHubCatalog.make({
333
+ owner: "acme",
334
+ repo: "skills",
335
+ ref: commit,
336
+ apiBaseUrl: "https://user:secret@git.example/api?token=secret",
337
+ }),
338
+ ),
339
+ Effect.flip(S3Catalog.make({ bucket: "company.skills", region: "us-west-2" })),
340
+ Effect.flip(S3Catalog.make({ bucket: "192.168.1.1", region: "us-west-2" })),
341
+ Effect.flip(HttpCatalog.make({ manifestUrl: encodedPathUrl })),
342
+ ])
343
+ expect(failures).toHaveLength(6)
344
+ }).pipe(
345
+ Effect.provide(
346
+ Layer.mergeAll(
347
+ cryptoLayer(),
348
+ httpLayer(
349
+ {
350
+ "https://skills.example/catalog.json": {
351
+ body: manifest("remote%2fescape/SKILL.md"),
352
+ },
353
+ },
354
+ [],
355
+ ),
356
+ ),
357
+ ),
358
+ ),
359
+ )
360
+
361
+ it.effect("composes public filesystem-independent adapters with later sources winning", () => {
362
+ const requests: Array<{ readonly url: string; readonly accept: string | undefined }> = []
363
+ const httpUrl = "https://skills.example/catalog.json"
364
+ const s3Url = "https://company-skills.s3.us-west-2.amazonaws.com/skills.json"
365
+ const secondManifest = JSON.stringify({
366
+ version: 1,
367
+ skills: [
368
+ {
369
+ name: "remote",
370
+ description: "Later source wins",
371
+ skillPath: "remote/SKILL.md",
372
+ sha256: digest,
373
+ },
374
+ ],
375
+ })
376
+ return Effect.gen(function* () {
377
+ const source = yield* SkillSource.SkillSource
378
+ const all = yield* source.all
379
+ const remote = yield* source.get("remote")
380
+
381
+ expect(all).toHaveLength(1)
382
+ expect(remote?.frontmatter.description).toBe("Later source wins")
383
+ }).pipe(
384
+ Effect.provide(
385
+ SkillSource.layer([
386
+ HttpCatalog.make({ manifestUrl: httpUrl }),
387
+ S3Catalog.make({ bucket: "company-skills", region: "us-west-2" }),
388
+ ]),
389
+ ),
390
+ Effect.provide(
391
+ Layer.mergeAll(
392
+ cryptoLayer(),
393
+ httpLayer({ [httpUrl]: { body: manifest() }, [s3Url]: { body: secondManifest } }, requests),
394
+ ),
395
+ ),
396
+ )
397
+ })
398
+ })