clanka 0.5.0 → 0.5.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.
@@ -40,6 +40,7 @@ const withRoots = <A, E, R>(
40
40
  f: (roots: {
41
41
  readonly project: string
42
42
  readonly home: string
43
+ readonly hermes: string
43
44
  }) => Effect.Effect<A, E, R>,
44
45
  ) =>
45
46
  Effect.scoped(
@@ -49,9 +50,11 @@ const withRoots = <A, E, R>(
49
50
  const base = yield* fs.makeTempDirectoryScoped()
50
51
  const project = path.join(base, "project")
51
52
  const home = path.join(base, "home")
53
+ const hermes = path.join(base, "hermes-home")
54
+ yield* fs.makeDirectory(hermes)
52
55
  yield* fs.makeDirectory(project)
53
56
  yield* fs.makeDirectory(home)
54
- return yield* f({ project, home })
57
+ return yield* f({ project, home, hermes })
55
58
  }),
56
59
  )
57
60
 
@@ -61,7 +64,291 @@ const discover = (roots: { readonly project: string; readonly home: string }) =>
61
64
  homeDirectory: Option.some(roots.home),
62
65
  })
63
66
 
67
+ const writeHermesSkill = Effect.fnUntraced(function* (
68
+ root: string,
69
+ dir: string,
70
+ content: string,
71
+ ) {
72
+ const fs = yield* FileSystem.FileSystem
73
+ const path = yield* Path.Path
74
+ const skillDir = path.join(root, "skills", dir)
75
+ yield* fs.makeDirectory(skillDir, { recursive: true })
76
+ const location = path.join(skillDir, "SKILL.md")
77
+ yield* fs.writeFileString(location, content)
78
+ return location
79
+ })
80
+
81
+ const discoverWithHermes = (
82
+ roots: {
83
+ readonly project: string
84
+ readonly home: string
85
+ readonly hermes: string
86
+ },
87
+ hermesHome: Option.Option<string> = Option.some(roots.hermes),
88
+ ) =>
89
+ AgentSkills.discover({
90
+ directory: roots.project,
91
+ homeDirectory: Option.some(roots.home),
92
+ hermesHome,
93
+ })
94
+
95
+ const withLocalExecutor = <A, E, R>(
96
+ roots: { readonly project: string; readonly home: string },
97
+ hermesHome: string | undefined,
98
+ effect: Effect.Effect<A, E, R>,
99
+ ) =>
100
+ Effect.scoped(
101
+ Effect.gen(function* () {
102
+ yield* Effect.acquireRelease(
103
+ Effect.sync(() => {
104
+ const previous = {
105
+ HOME: process.env.HOME,
106
+ HERMES_HOME: process.env.HERMES_HOME,
107
+ }
108
+ process.env.HOME = roots.home
109
+ if (hermesHome === undefined) delete process.env.HERMES_HOME
110
+ else process.env.HERMES_HOME = hermesHome
111
+ return previous
112
+ }),
113
+ (previous) =>
114
+ Effect.sync(() => {
115
+ for (const key of ["HOME", "HERMES_HOME"] as const) {
116
+ if (previous[key] === undefined) delete process.env[key]
117
+ else process.env[key] = previous[key]
118
+ }
119
+ }),
120
+ )
121
+ return yield* effect.pipe(
122
+ Effect.provide(
123
+ AgentExecutor.layerLocal({ directory: roots.project }).pipe(
124
+ Layer.provide(NodeHttpClient.layerUndici),
125
+ ),
126
+ ),
127
+ )
128
+ }),
129
+ )
130
+
64
131
  describe("AgentSkills", () => {
132
+ it.effect("catalogs a flat HERMES_HOME skill with an absolute location", () =>
133
+ withRoots(
134
+ Effect.fnUntraced(function* (roots) {
135
+ const path = yield* Path.Path
136
+ const location = yield* writeHermesSkill(
137
+ roots.hermes,
138
+ "bound",
139
+ skillFile("bound", "Workspace skill"),
140
+ )
141
+ const skills = yield* discoverWithHermes(roots)
142
+ assert.deepStrictEqual(
143
+ skills.map(({ name, description, location }) => [
144
+ name,
145
+ description,
146
+ location,
147
+ ]),
148
+ [["bound", "Workspace skill", location]],
149
+ )
150
+ assert.isTrue(path.isAbsolute(skills[0]!.location))
151
+ }),
152
+ ).pipe(Effect.provide(NodeServices.layer)),
153
+ )
154
+
155
+ for (const projectWins of [true, false]) {
156
+ it.effect(
157
+ projectWins
158
+ ? "project skill overrides Hermes and user skills"
159
+ : "Hermes skill overrides a user skill",
160
+ () =>
161
+ withRoots(
162
+ Effect.fnUntraced(function* (roots) {
163
+ yield* writeSkill(roots.home, "shared", skillFile("shared", "User"))
164
+ const hermesLocation = yield* writeHermesSkill(
165
+ roots.hermes,
166
+ "shared",
167
+ skillFile("shared", "Hermes"),
168
+ )
169
+ const location = projectWins
170
+ ? yield* writeSkill(
171
+ roots.project,
172
+ "shared",
173
+ skillFile("shared", "Project"),
174
+ )
175
+ : hermesLocation
176
+ const skills = yield* discoverWithHermes(roots)
177
+ assert.deepStrictEqual(
178
+ skills.map((skill) => [skill.description, skill.location]),
179
+ [[projectWins ? "Project" : "Hermes", location]],
180
+ )
181
+ }),
182
+ ).pipe(Effect.provide(NodeServices.layer)),
183
+ )
184
+ }
185
+
186
+ for (const mode of ["unset", "empty", "missing skills"] as const) {
187
+ it.effect(`preserves existing discovery with ${mode} HERMES_HOME`, () =>
188
+ withRoots(
189
+ Effect.fnUntraced(function* (roots) {
190
+ const fs = yield* FileSystem.FileSystem
191
+ const path = yield* Path.Path
192
+ yield* writeSkill(roots.project, "proj", skillFile("proj", "Project"))
193
+ yield* writeSkill(roots.home, "usr", skillFile("usr", "User"))
194
+ // An unset input must not fall back to ~/.hermes.
195
+ yield* writeHermesSkill(
196
+ path.join(roots.home, ".hermes"),
197
+ "ignored",
198
+ skillFile("ignored", "Ignored"),
199
+ )
200
+ const reads: Array<string> = []
201
+ const skills = yield* discoverWithHermes(
202
+ roots,
203
+ mode === "unset"
204
+ ? Option.none()
205
+ : Option.some(mode === "empty" ? "" : roots.hermes),
206
+ ).pipe(
207
+ Effect.provideService(
208
+ FileSystem.FileSystem,
209
+ FileSystem.FileSystem.of({
210
+ ...fs,
211
+ readDirectory: (directory) => {
212
+ reads.push(directory)
213
+ return fs.readDirectory(directory)
214
+ },
215
+ }),
216
+ ),
217
+ )
218
+ assert.deepStrictEqual(skills, yield* discover(roots))
219
+ if (mode !== "missing skills")
220
+ assert.deepStrictEqual(reads, [
221
+ path.join(roots.project, ".agents", "skills"),
222
+ path.join(roots.home, ".agents", "skills"),
223
+ ])
224
+ }),
225
+ ).pipe(Effect.provide(NodeServices.layer)),
226
+ )
227
+ }
228
+
229
+ it.effect("only reads HERMES_HOME/skills/<dir>/SKILL.md", () =>
230
+ withRoots(
231
+ Effect.fnUntraced(function* (roots) {
232
+ yield* writeHermesSkill(
233
+ roots.hermes,
234
+ "outer/inner",
235
+ skillFile("nested", "Ignored"),
236
+ )
237
+ yield* writeHermesSkill(roots.hermes, "", skillFile("bare", "Ignored"))
238
+ yield* writeSkill(
239
+ roots.hermes,
240
+ "agents",
241
+ skillFile("agents", "Ignored"),
242
+ )
243
+ const location = yield* writeHermesSkill(
244
+ roots.hermes,
245
+ "real",
246
+ skillFile("real", "Found"),
247
+ )
248
+ const skills = yield* discoverWithHermes(roots)
249
+ assert.deepStrictEqual(
250
+ skills.map((skill) => [skill.name, skill.location]),
251
+ [["real", location]],
252
+ )
253
+ }),
254
+ ).pipe(Effect.provide(NodeServices.layer)),
255
+ )
256
+
257
+ it.effect(
258
+ "makeLocal exposes HERMES_HOME skills and reads their relative references",
259
+ () =>
260
+ withRoots(
261
+ Effect.fnUntraced(function* (roots) {
262
+ const fs = yield* FileSystem.FileSystem
263
+ const path = yield* Path.Path
264
+ const content =
265
+ skillFile("bound", "Workspace skill") +
266
+ "Read references/guide.md.\n"
267
+ const location = yield* writeHermesSkill(
268
+ roots.hermes,
269
+ "bound",
270
+ content,
271
+ )
272
+ const reference = path.join(
273
+ path.dirname(location),
274
+ "references",
275
+ "guide.md",
276
+ )
277
+ yield* fs.makeDirectory(path.dirname(reference))
278
+ yield* fs.writeFileString(
279
+ reference,
280
+ "Workspace reference instructions.",
281
+ )
282
+ yield* withLocalExecutor(
283
+ roots,
284
+ roots.hermes,
285
+ Effect.gen(function* () {
286
+ const executor = yield* AgentExecutor.AgentExecutor
287
+ const capabilities = yield* executor.capabilities
288
+ assert.deepStrictEqual(
289
+ capabilities.skills.map((skill) => [
290
+ skill.name,
291
+ skill.location,
292
+ ]),
293
+ [["bound", location]],
294
+ )
295
+ const discovered = capabilities.skills[0]!.location
296
+ const body = yield* executor.executeUnsafe({
297
+ tool: "readFile",
298
+ params: { path: discovered },
299
+ })
300
+ assert.include(String(body), "Read references/guide.md.")
301
+ const guide = yield* executor.executeUnsafe({
302
+ tool: "readFile",
303
+ params: {
304
+ path: path.resolve(
305
+ path.dirname(discovered),
306
+ "references/guide.md",
307
+ ),
308
+ },
309
+ })
310
+ assert.include(String(guide), "Workspace reference instructions.")
311
+ }),
312
+ )
313
+ }),
314
+ ).pipe(Effect.provide(NodeServices.layer)),
315
+ )
316
+
317
+ for (const hermesHome of [undefined, ""]) {
318
+ it.effect(
319
+ hermesHome === undefined
320
+ ? "makeLocal leaves unset HERMES_HOME disabled"
321
+ : "makeLocal treats empty HERMES_HOME as unset",
322
+ () =>
323
+ withRoots(
324
+ Effect.fnUntraced(function* (roots) {
325
+ const path = yield* Path.Path
326
+ yield* writeHermesSkill(
327
+ path.join(roots.home, ".hermes"),
328
+ "ignored",
329
+ skillFile("ignored", "Ignored"),
330
+ )
331
+ const location = yield* writeSkill(
332
+ roots.project,
333
+ "proj",
334
+ skillFile("proj", "Project"),
335
+ )
336
+ const capabilities = yield* withLocalExecutor(
337
+ roots,
338
+ hermesHome,
339
+ AgentExecutor.AgentExecutor.pipe(
340
+ Effect.flatMap((executor) => executor.capabilities),
341
+ ),
342
+ )
343
+ assert.deepStrictEqual(
344
+ capabilities.skills.map((skill) => skill.location),
345
+ [location],
346
+ )
347
+ }),
348
+ ).pipe(Effect.provide(NodeServices.layer)),
349
+ )
350
+ }
351
+
65
352
  for (const type of ["FIFO", "Socket", "Directory"] as const) {
66
353
  it.effect(`skips a ${type} SKILL.md without attempting to read it`, () =>
67
354
  withRoots(
@@ -456,7 +743,7 @@ body`,
456
743
  it.effect("returns an empty catalog when nothing exists", () =>
457
744
  withRoots(
458
745
  Effect.fnUntraced(function* (roots) {
459
- const skills = yield* discover(roots)
746
+ const skills = yield* discoverWithHermes(roots, Option.none())
460
747
  assert.deepStrictEqual(skills, [])
461
748
  assert.isTrue(Option.isNone(AgentSkills.renderCatalog(skills)))
462
749
  }),
@@ -478,19 +765,11 @@ body`,
478
765
  )
479
766
  yield* writeSkill(roots.project, "broken", "---\nnope\n---")
480
767
 
481
- const previousHome = process.env.HOME
482
- process.env.HOME = roots.home
483
- const capabilities = yield* AgentExecutor.AgentExecutor.pipe(
484
- Effect.flatMap((executor) => executor.capabilities),
485
- Effect.provide(
486
- AgentExecutor.layerLocal({ directory: roots.project }).pipe(
487
- Layer.provide(NodeHttpClient.layerUndici),
488
- ),
489
- ),
490
- Effect.ensuring(
491
- Effect.sync(() => {
492
- process.env.HOME = previousHome
493
- }),
768
+ const capabilities = yield* withLocalExecutor(
769
+ roots,
770
+ undefined,
771
+ AgentExecutor.AgentExecutor.pipe(
772
+ Effect.flatMap((executor) => executor.capabilities),
494
773
  ),
495
774
  )
496
775
  assert.deepStrictEqual(
@@ -567,6 +846,48 @@ const systemPromptFor = (skills: ReadonlyArray<AgentSkills.Skill>) =>
567
846
  )
568
847
 
569
848
  describe("Agent skills catalog", () => {
849
+ it.effect(
850
+ "renders a Hermes skill in the system prompt with its absolute location",
851
+ () =>
852
+ withRoots(
853
+ Effect.fnUntraced(function* (roots) {
854
+ const location = yield* writeHermesSkill(
855
+ roots.hermes,
856
+ "bound",
857
+ skillFile("bound", "Workspace skill"),
858
+ )
859
+ const system = yield* systemPromptFor(
860
+ yield* discoverWithHermes(roots),
861
+ )
862
+ assert.include(system, "# Skills")
863
+ assert.include(system, "- bound: Workspace skill")
864
+ assert.include(system, "location: " + location)
865
+ assert.notInclude(system, "Instructions for bound.")
866
+ }),
867
+ ).pipe(Effect.provide(NodeServices.layer)),
868
+ )
869
+
870
+ it.effect(
871
+ "omits the catalog with HERMES_HOME pointing at an empty directory",
872
+ () =>
873
+ withRoots(
874
+ Effect.fnUntraced(function* (roots) {
875
+ const capabilities = yield* withLocalExecutor(
876
+ roots,
877
+ roots.hermes,
878
+ AgentExecutor.AgentExecutor.pipe(
879
+ Effect.flatMap((executor) => executor.capabilities),
880
+ ),
881
+ )
882
+ assert.deepStrictEqual(capabilities.skills, [])
883
+ assert.notInclude(
884
+ yield* systemPromptFor(capabilities.skills),
885
+ "# Skills",
886
+ )
887
+ }),
888
+ ).pipe(Effect.provide(NodeServices.layer)),
889
+ )
890
+
570
891
  it.effect(
571
892
  "escapes a newline in a catalog location without changing the filesystem path",
572
893
  () =>
@@ -37,9 +37,11 @@ export class Skill extends Schema.Class<Skill>("Skill")({
37
37
  }) {}
38
38
 
39
39
  /**
40
- * Scan project and user `.agents/skills/<dir>/SKILL.md` files.
40
+ * Scan project and user `.agents/skills/<dir>/SKILL.md` files, plus
41
+ * `<hermesHome>/skills/<dir>/SKILL.md` when explicitly configured.
41
42
  *
42
- * Project names override user names; the first name wins within each scope.
43
+ * Project names override Hermes names, which override user names.
44
+ * The first name wins within each scope; an empty Hermes home is ignored.
43
45
  * Skip unreadable files, invalid frontmatter and missing or empty descriptions.
44
46
  *
45
47
  * @since 1.0.0
@@ -48,6 +50,7 @@ export class Skill extends Schema.Class<Skill>("Skill")({
48
50
  export const discover: (options: {
49
51
  readonly directory: string
50
52
  readonly homeDirectory: Option.Option<string>
53
+ readonly hermesHome?: Option.Option<string>
51
54
  }) => Effect.Effect<
52
55
  ReadonlyArray<Skill>,
53
56
  never,
@@ -57,14 +60,31 @@ export const discover: (options: {
57
60
  const path = yield* Path.Path
58
61
 
59
62
  const roots: Array<{ readonly root: string; readonly source: SkillSource }> =
60
- [{ root: options.directory, source: "project" }]
63
+ [
64
+ {
65
+ root: path.resolve(options.directory, ".agents", "skills"),
66
+ source: "project",
67
+ },
68
+ ]
69
+ if (
70
+ options.hermesHome &&
71
+ Option.isSome(options.hermesHome) &&
72
+ options.hermesHome.value !== ""
73
+ ) {
74
+ roots.push({
75
+ root: path.resolve(options.hermesHome.value, "skills"),
76
+ source: "user",
77
+ })
78
+ }
61
79
  if (Option.isSome(options.homeDirectory)) {
62
- roots.push({ root: options.homeDirectory.value, source: "user" })
80
+ roots.push({
81
+ root: path.resolve(options.homeDirectory.value, ".agents", "skills"),
82
+ source: "user",
83
+ })
63
84
  }
64
85
 
65
86
  const byName = new Map<string, Skill>()
66
- for (const { root, source } of roots) {
67
- const skillsDir = path.resolve(root, ".agents", "skills")
87
+ for (const { root: skillsDir, source } of roots) {
68
88
  const entries = yield* fs
69
89
  .readDirectory(skillsDir)
70
90
  .pipe(Effect.orElseSucceed(() => []))