@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/skill-loader.ts
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { Effect, FileSystem, Layer, Path, PlatformError, Stream } from "effect"
|
|
2
|
-
import { SkillSource } from "@batonfx/core"
|
|
3
|
-
import { parseDocument, parseFrontmatter, splitDocument } from "./skill-document"
|
|
4
|
-
|
|
5
|
-
/** @experimental Filesystem skill-loader options. */
|
|
6
|
-
export interface LoadOptions {
|
|
7
|
-
readonly roots?: ReadonlyArray<string>
|
|
8
|
-
readonly cwd?: string
|
|
9
|
-
readonly descriptionCap?: number
|
|
10
|
-
readonly frontmatterMaxBytes?: number
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const DEFAULT_ROOTS = [".agents/skills", ".claude/skills", ".pi/skills"] as const
|
|
14
|
-
|
|
15
|
-
const decoder = new TextDecoder()
|
|
16
|
-
|
|
17
|
-
const sourceError = (source: string, message: string, cause?: unknown): SkillSource.SkillSourceError =>
|
|
18
|
-
new SkillSource.SkillSourceError({ source, message, ...(cause === undefined ? {} : { cause }) })
|
|
19
|
-
|
|
20
|
-
const mapPlatformError = (source: string, error: PlatformError.PlatformError): SkillSource.SkillSourceError =>
|
|
21
|
-
sourceError(source, error.message, error)
|
|
22
|
-
|
|
23
|
-
const readHeader = (
|
|
24
|
-
fs: FileSystem.FileSystem,
|
|
25
|
-
source: string,
|
|
26
|
-
bytes: number,
|
|
27
|
-
): Effect.Effect<string, SkillSource.SkillSourceError> =>
|
|
28
|
-
fs.stream(source, { bytesToRead: bytes, chunkSize: bytes }).pipe(
|
|
29
|
-
Stream.runFold(
|
|
30
|
-
() => "",
|
|
31
|
-
(content, chunk) => `${content}${decoder.decode(chunk)}`,
|
|
32
|
-
),
|
|
33
|
-
Effect.mapError((error) => mapPlatformError(source, error)),
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
const loadSkill = (
|
|
37
|
-
fs: FileSystem.FileSystem,
|
|
38
|
-
path: Path.Path,
|
|
39
|
-
file: string,
|
|
40
|
-
relativeFile: string,
|
|
41
|
-
descriptionCap: number,
|
|
42
|
-
frontmatterMaxBytes: number,
|
|
43
|
-
): Effect.Effect<SkillSource.Skill, SkillSource.SkillSourceError> =>
|
|
44
|
-
Effect.gen(function* () {
|
|
45
|
-
const header = yield* readHeader(fs, file, frontmatterMaxBytes)
|
|
46
|
-
const [headerBlock] = yield* splitDocument(file, header)
|
|
47
|
-
const directoryName = path.basename(path.dirname(relativeFile))
|
|
48
|
-
const frontmatter = yield* parseFrontmatter(file, headerBlock, directoryName)
|
|
49
|
-
return {
|
|
50
|
-
frontmatter,
|
|
51
|
-
listing: SkillSource.makeListing(frontmatter, descriptionCap),
|
|
52
|
-
body: fs.readFileString(file).pipe(
|
|
53
|
-
Effect.mapError((error) => mapPlatformError(file, error)),
|
|
54
|
-
Effect.flatMap((content) =>
|
|
55
|
-
parseDocument(file, content, directoryName).pipe(Effect.map((document) => document.body)),
|
|
56
|
-
),
|
|
57
|
-
),
|
|
58
|
-
tools: [],
|
|
59
|
-
}
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
const discoverRoot = (
|
|
63
|
-
fs: FileSystem.FileSystem,
|
|
64
|
-
path: Path.Path,
|
|
65
|
-
cwd: string,
|
|
66
|
-
root: string,
|
|
67
|
-
descriptionCap: number,
|
|
68
|
-
frontmatterMaxBytes: number,
|
|
69
|
-
): Effect.Effect<ReadonlyArray<SkillSource.Skill>, SkillSource.SkillSourceError> =>
|
|
70
|
-
Effect.gen(function* () {
|
|
71
|
-
const rootPath = path.isAbsolute(root) ? path.normalize(root) : path.join(cwd, root)
|
|
72
|
-
const exists = yield* fs.exists(rootPath).pipe(Effect.mapError((error) => mapPlatformError(rootPath, error)))
|
|
73
|
-
if (!exists) return []
|
|
74
|
-
const entries = yield* fs
|
|
75
|
-
.readDirectory(rootPath, { recursive: true })
|
|
76
|
-
.pipe(Effect.mapError((error) => mapPlatformError(rootPath, error)))
|
|
77
|
-
const skills: Array<SkillSource.Skill> = []
|
|
78
|
-
for (const skillFile of entries.filter((entry) => path.basename(entry) === "SKILL.md").toSorted()) {
|
|
79
|
-
skills.push(
|
|
80
|
-
yield* loadSkill(fs, path, path.join(rootPath, skillFile), skillFile, descriptionCap, frontmatterMaxBytes),
|
|
81
|
-
)
|
|
82
|
-
}
|
|
83
|
-
return skills
|
|
84
|
-
})
|
|
85
|
-
|
|
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. */
|
|
109
|
-
export const layer = (
|
|
110
|
-
options: LoadOptions = {},
|
|
111
|
-
): Layer.Layer<SkillSource.SkillSource, SkillSource.SkillSourceError, FileSystem.FileSystem | Path.Path> =>
|
|
112
|
-
Layer.effect(SkillSource.SkillSource, make(options).pipe(Effect.map(SkillSource.SkillSource.of)))
|
|
@@ -1,398 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -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
|
-
})
|