@agent-facets/core 0.1.2 → 0.2.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.
- package/CHANGELOG.md +20 -0
- package/package.json +6 -3
- package/src/__tests__/build-pipeline.test.ts +98 -13
- package/src/__tests__/content-hash.test.ts +9 -9
- package/src/__tests__/edit.test.ts +190 -0
- package/src/__tests__/facet-loader.test.ts +61 -10
- package/src/__tests__/facet-manifest.test.ts +9 -21
- package/src/build/content-hash.ts +1 -1
- package/src/build/pipeline.ts +55 -22
- package/src/build/validate-content.ts +50 -0
- package/src/edit/manifest-writer.ts +12 -0
- package/src/edit/reconcile.ts +77 -0
- package/src/edit/scanner.ts +57 -0
- package/src/front-matter.ts +58 -0
- package/src/index.ts +12 -7
- package/src/loaders/facet.ts +11 -13
- package/src/loaders/validate.ts +4 -2
- package/src/schemas/facet-manifest.ts +21 -47
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @agent-facets/core
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#35](https://github.com/agent-facets/facets/pull/35) [`6350718`](https://github.com/agent-facets/facets/commit/63507188f1bb3a7276cd4812f69f7d16d1778fd6) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Ensure proper release isolation
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- [#33](https://github.com/agent-facets/facets/pull/33) [`540e126`](https://github.com/agent-facets/facets/commit/540e126e677de98a9b3d4e39542df37de8756b73) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Ensure CI runs tests before release and notify Slack when failures occur.
|
|
12
|
+
|
|
13
|
+
## 0.1.3
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- [`098fd08`](https://github.com/agent-facets/facets/commit/098fd08bf5d9970babc5c57bee6a155bffcecd97) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Better CLI parameter validation
|
|
18
|
+
|
|
19
|
+
- [`5262cbe`](https://github.com/agent-facets/facets/commit/5262cbe66df02c625430309878e6061ccde183de) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Fix publishing by properly categorizing dev dependencies
|
|
20
|
+
|
|
21
|
+
- [`d3b9439`](https://github.com/agent-facets/facets/commit/d3b9439466e0eb65687901426e2ebd6c5a333c60) Thanks [@eXamadeus](https://github.com/eXamadeus)! - Use better github attribution for changesets
|
|
22
|
+
|
|
3
23
|
## 0.1.2
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -5,25 +5,28 @@
|
|
|
5
5
|
"url": "https://github.com/agent-facets/facets",
|
|
6
6
|
"directory": "packages/core"
|
|
7
7
|
},
|
|
8
|
-
"version": "0.
|
|
8
|
+
"version": "0.2.0",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": "./src/index.ts"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
+
"prepack": "bun ../../scripts/prepack.ts",
|
|
15
|
+
"postpack": "bun ../../scripts/postpack.ts",
|
|
14
16
|
"types": "tsc --noEmit",
|
|
15
17
|
"test": "bun test"
|
|
16
18
|
},
|
|
17
19
|
"dependencies": {
|
|
18
20
|
"arktype": "2.1.29",
|
|
19
21
|
"comment-json": "^4.2.5",
|
|
20
|
-
"nanotar": "0.3.0"
|
|
22
|
+
"nanotar": "0.3.0",
|
|
23
|
+
"yaml": "2.8.3"
|
|
21
24
|
},
|
|
22
25
|
"devDependencies": {
|
|
23
26
|
"@types/bun": "1.3.10"
|
|
24
27
|
},
|
|
25
28
|
"peerDependencies": {
|
|
26
|
-
"typescript": "^5"
|
|
29
|
+
"typescript": "^5 || ^6"
|
|
27
30
|
},
|
|
28
31
|
"publishConfig": {
|
|
29
32
|
"access": "public",
|
|
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
+
import dedent from 'dedent'
|
|
5
6
|
import { detectNamingCollisions } from '../build/detect-collisions.ts'
|
|
6
7
|
import { runBuildPipeline } from '../build/pipeline.ts'
|
|
7
8
|
import { validateCompactFacets } from '../build/validate-facets.ts'
|
|
@@ -206,7 +207,7 @@ describe('validatePlatformConfigs', () => {
|
|
|
206
207
|
describe('runBuildPipeline', () => {
|
|
207
208
|
test('successful build with valid facet', async () => {
|
|
208
209
|
const dir = await createFixtureDir('valid-build')
|
|
209
|
-
await Bun.write(join(dir, 'skills/example.md'), '# Example skill')
|
|
210
|
+
await Bun.write(join(dir, 'skills/example/SKILL.md'), '# Example skill')
|
|
210
211
|
await Bun.write(
|
|
211
212
|
join(dir, 'facet.json'),
|
|
212
213
|
JSON.stringify({
|
|
@@ -230,8 +231,8 @@ describe('runBuildPipeline', () => {
|
|
|
230
231
|
expect(result.archiveFilename).toBe('test-facet-1.0.0.facet')
|
|
231
232
|
expect(result.archiveBytes.length).toBeGreaterThan(0)
|
|
232
233
|
expect(Object.keys(result.assetHashes)).toContain('facet.json')
|
|
233
|
-
expect(Object.keys(result.assetHashes)).toContain('skills/example.md')
|
|
234
|
-
expect(result.assetHashes['skills/example.md']).toMatchInlineSnapshot(
|
|
234
|
+
expect(Object.keys(result.assetHashes)).toContain('skills/example/SKILL.md')
|
|
235
|
+
expect(result.assetHashes['skills/example/SKILL.md']).toMatchInlineSnapshot(
|
|
235
236
|
`"sha256:ded8057927e03783371d0d929e4a6e92da66eb9dd164377ad6845a5a1c0cb5ba"`,
|
|
236
237
|
)
|
|
237
238
|
expect(result.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
@@ -263,13 +264,13 @@ describe('runBuildPipeline', () => {
|
|
|
263
264
|
expect(result.ok).toBe(false)
|
|
264
265
|
if (!result.ok) {
|
|
265
266
|
expect(result.errors[0]?.path).toBe('skills.example')
|
|
266
|
-
expect(result.errors[0]?.message).toContain('skills/example.md')
|
|
267
|
+
expect(result.errors[0]?.message).toContain('skills/example/SKILL.md')
|
|
267
268
|
}
|
|
268
269
|
})
|
|
269
270
|
|
|
270
271
|
test('build succeeds with cross-type name sharing', async () => {
|
|
271
272
|
const dir = await createFixtureDir('cross-type')
|
|
272
|
-
await Bun.write(join(dir, 'skills/review.md'), '# Review skill')
|
|
273
|
+
await Bun.write(join(dir, 'skills/review/SKILL.md'), '# Review skill')
|
|
273
274
|
await Bun.write(join(dir, 'commands/review.md'), '# Review command')
|
|
274
275
|
await Bun.write(
|
|
275
276
|
join(dir, 'facet.json'),
|
|
@@ -291,8 +292,8 @@ describe('runBuildPipeline', () => {
|
|
|
291
292
|
|
|
292
293
|
test('build with all asset types includes all hashes', async () => {
|
|
293
294
|
const dir = await createFixtureDir('all-types')
|
|
294
|
-
await Bun.write(join(dir, 'skills/alpha.md'), '# Alpha skill')
|
|
295
|
-
await Bun.write(join(dir, 'skills/beta.md'), '# Beta skill')
|
|
295
|
+
await Bun.write(join(dir, 'skills/alpha/SKILL.md'), '# Alpha skill')
|
|
296
|
+
await Bun.write(join(dir, 'skills/beta/SKILL.md'), '# Beta skill')
|
|
296
297
|
await Bun.write(join(dir, 'agents/helper.md'), '# Helper agent')
|
|
297
298
|
await Bun.write(join(dir, 'commands/deploy.md'), '# Deploy command')
|
|
298
299
|
await Bun.write(
|
|
@@ -322,15 +323,15 @@ describe('runBuildPipeline', () => {
|
|
|
322
323
|
'agents/helper.md',
|
|
323
324
|
'commands/deploy.md',
|
|
324
325
|
'facet.json',
|
|
325
|
-
'skills/alpha.md',
|
|
326
|
-
'skills/beta.md',
|
|
326
|
+
'skills/alpha/SKILL.md',
|
|
327
|
+
'skills/beta/SKILL.md',
|
|
327
328
|
])
|
|
328
329
|
}
|
|
329
330
|
})
|
|
330
331
|
|
|
331
332
|
test('build fails on malformed compact facets entry', async () => {
|
|
332
333
|
const dir = await createFixtureDir('bad-facets')
|
|
333
|
-
await Bun.write(join(dir, 'skills/x.md'), '# Skill')
|
|
334
|
+
await Bun.write(join(dir, 'skills/x/SKILL.md'), '# Skill')
|
|
334
335
|
await Bun.write(
|
|
335
336
|
join(dir, 'facet.json'),
|
|
336
337
|
JSON.stringify({
|
|
@@ -351,12 +352,96 @@ describe('runBuildPipeline', () => {
|
|
|
351
352
|
})
|
|
352
353
|
})
|
|
353
354
|
|
|
355
|
+
// --- Content validation ---
|
|
356
|
+
|
|
357
|
+
describe('content validation', () => {
|
|
358
|
+
test('build fails on file with YAML front matter', async () => {
|
|
359
|
+
const dir = await createFixtureDir('front-matter')
|
|
360
|
+
await Bun.write(
|
|
361
|
+
join(dir, 'skills/review/SKILL.md'),
|
|
362
|
+
dedent`
|
|
363
|
+
---
|
|
364
|
+
name: Review
|
|
365
|
+
description: A review skill
|
|
366
|
+
---
|
|
367
|
+
# Review
|
|
368
|
+
Review all code.
|
|
369
|
+
`,
|
|
370
|
+
)
|
|
371
|
+
await Bun.write(
|
|
372
|
+
join(dir, 'facet.json'),
|
|
373
|
+
JSON.stringify({
|
|
374
|
+
name: 'test-facet',
|
|
375
|
+
version: '1.0.0',
|
|
376
|
+
skills: {
|
|
377
|
+
review: { description: 'A review skill' },
|
|
378
|
+
},
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
const result = await runBuildPipeline(dir)
|
|
383
|
+
expect(result.ok).toBe(false)
|
|
384
|
+
if (!result.ok) {
|
|
385
|
+
expect(result.errors).toHaveLength(1)
|
|
386
|
+
expect(result.errors[0]?.path).toBe('skills.review')
|
|
387
|
+
expect(result.errors[0]?.message).toContain('front matter')
|
|
388
|
+
expect(result.errors[0]?.message).toContain('skills/review/SKILL.md')
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('build fails on empty content file', async () => {
|
|
393
|
+
const dir = await createFixtureDir('empty-file')
|
|
394
|
+
await Bun.write(join(dir, 'skills/empty/SKILL.md'), '')
|
|
395
|
+
await Bun.write(
|
|
396
|
+
join(dir, 'facet.json'),
|
|
397
|
+
JSON.stringify({
|
|
398
|
+
name: 'test-facet',
|
|
399
|
+
version: '1.0.0',
|
|
400
|
+
skills: {
|
|
401
|
+
empty: { description: 'An empty skill' },
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
const result = await runBuildPipeline(dir)
|
|
407
|
+
expect(result.ok).toBe(false)
|
|
408
|
+
if (!result.ok) {
|
|
409
|
+
expect(result.errors).toHaveLength(1)
|
|
410
|
+
expect(result.errors[0]?.path).toBe('skills.empty')
|
|
411
|
+
expect(result.errors[0]?.message).toContain('empty')
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('build fails on whitespace-only content file', async () => {
|
|
416
|
+
const dir = await createFixtureDir('whitespace-file')
|
|
417
|
+
await Bun.write(join(dir, 'agents/blank.md'), ' \n\n \n')
|
|
418
|
+
await Bun.write(
|
|
419
|
+
join(dir, 'facet.json'),
|
|
420
|
+
JSON.stringify({
|
|
421
|
+
name: 'test-facet',
|
|
422
|
+
version: '1.0.0',
|
|
423
|
+
agents: {
|
|
424
|
+
blank: { description: 'A blank agent' },
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const result = await runBuildPipeline(dir)
|
|
430
|
+
expect(result.ok).toBe(false)
|
|
431
|
+
if (!result.ok) {
|
|
432
|
+
expect(result.errors).toHaveLength(1)
|
|
433
|
+
expect(result.errors[0]?.path).toBe('agents.blank')
|
|
434
|
+
expect(result.errors[0]?.message).toContain('empty')
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
})
|
|
438
|
+
|
|
354
439
|
// --- Build output generation ---
|
|
355
440
|
|
|
356
441
|
describe('writeBuildOutput', () => {
|
|
357
442
|
test('writes archive and build manifest to dist/', async () => {
|
|
358
443
|
const dir = await createFixtureDir('write-output')
|
|
359
|
-
await Bun.write(join(dir, 'skills/example.md'), '# Resolved content')
|
|
444
|
+
await Bun.write(join(dir, 'skills/example/SKILL.md'), '# Resolved content')
|
|
360
445
|
await Bun.write(
|
|
361
446
|
join(dir, 'facet.json'),
|
|
362
447
|
JSON.stringify({
|
|
@@ -385,7 +470,7 @@ describe('writeBuildOutput', () => {
|
|
|
385
470
|
expect(manifest.archive).toBe('test-facet-1.0.0.facet')
|
|
386
471
|
expect(manifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
387
472
|
expect(manifest.assets['facet.json']).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
388
|
-
expect(manifest.assets['skills/example.md']).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
473
|
+
expect(manifest.assets['skills/example/SKILL.md']).toMatch(/^sha256:[a-f0-9]{64}$/)
|
|
389
474
|
|
|
390
475
|
// No loose files
|
|
391
476
|
const looseManifest = await Bun.file(join(dir, 'dist/facet.json')).exists()
|
|
@@ -394,7 +479,7 @@ describe('writeBuildOutput', () => {
|
|
|
394
479
|
|
|
395
480
|
test('cleans previous dist/ before writing', async () => {
|
|
396
481
|
const dir = await createFixtureDir('clean-dist')
|
|
397
|
-
await Bun.write(join(dir, 'skills/x.md'), '# Skill')
|
|
482
|
+
await Bun.write(join(dir, 'skills/x/SKILL.md'), '# Skill')
|
|
398
483
|
await Bun.write(
|
|
399
484
|
join(dir, 'facet.json'),
|
|
400
485
|
JSON.stringify({
|
|
@@ -63,7 +63,7 @@ describe('collectArchiveEntries', () => {
|
|
|
63
63
|
|
|
64
64
|
expect(entries).toHaveLength(4)
|
|
65
65
|
expect(entries.map((e) => e.path)).toContain('facet.json')
|
|
66
|
-
expect(entries.map((e) => e.path)).toContain('skills/review.md')
|
|
66
|
+
expect(entries.map((e) => e.path)).toContain('skills/review/SKILL.md')
|
|
67
67
|
expect(entries.map((e) => e.path)).toContain('agents/helper.md')
|
|
68
68
|
expect(entries.map((e) => e.path)).toContain('commands/deploy.md')
|
|
69
69
|
})
|
|
@@ -84,7 +84,7 @@ describe('collectArchiveEntries', () => {
|
|
|
84
84
|
const entries = collectArchiveEntries(resolved, 'manifest content')
|
|
85
85
|
const paths = entries.map((e) => e.path)
|
|
86
86
|
|
|
87
|
-
expect(paths).toEqual(['agents/b-agent.md', 'facet.json', 'skills/a-skill.md', 'skills/z-skill.md'])
|
|
87
|
+
expect(paths).toEqual(['agents/b-agent.md', 'facet.json', 'skills/a-skill/SKILL.md', 'skills/z-skill/SKILL.md'])
|
|
88
88
|
})
|
|
89
89
|
|
|
90
90
|
test('handles manifest with no optional asset types', () => {
|
|
@@ -105,7 +105,7 @@ describe('computeAssetHashes', () => {
|
|
|
105
105
|
test('returns correct hash for each entry', () => {
|
|
106
106
|
const entries = [
|
|
107
107
|
{ path: 'facet.json', content: '{"name":"test"}' },
|
|
108
|
-
{ path: 'skills/review.md', content: '# Review' },
|
|
108
|
+
{ path: 'skills/review/SKILL.md', content: '# Review' },
|
|
109
109
|
]
|
|
110
110
|
|
|
111
111
|
const hashes = computeAssetHashes(entries)
|
|
@@ -114,7 +114,7 @@ describe('computeAssetHashes', () => {
|
|
|
114
114
|
expect(hashes['facet.json']).toMatchInlineSnapshot(
|
|
115
115
|
`"sha256:7d9fd2051fc32b32feab10946fab6bb91426ab7e39aa5439289ed892864aa91d"`,
|
|
116
116
|
)
|
|
117
|
-
expect(hashes['skills/review.md']).toMatchInlineSnapshot(
|
|
117
|
+
expect(hashes['skills/review/SKILL.md']).toMatchInlineSnapshot(
|
|
118
118
|
`"sha256:f1a9d9d60fba2e67d82d788760d147d95461a58456411e205bf33a6dbdc3497f"`,
|
|
119
119
|
)
|
|
120
120
|
})
|
|
@@ -134,7 +134,7 @@ describe('assembleTar', () => {
|
|
|
134
134
|
test('produces a valid tar archive', () => {
|
|
135
135
|
const entries = [
|
|
136
136
|
{ path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
|
|
137
|
-
{ path: 'skills/review.md', content: '# Review skill' },
|
|
137
|
+
{ path: 'skills/review/SKILL.md', content: '# Review skill' },
|
|
138
138
|
]
|
|
139
139
|
|
|
140
140
|
const tar = assembleTar(entries)
|
|
@@ -147,7 +147,7 @@ describe('assembleTar', () => {
|
|
|
147
147
|
|
|
148
148
|
const names = parsed.map((f) => f.name)
|
|
149
149
|
expect(names).toContain('facet.json')
|
|
150
|
-
expect(names).toContain('skills/review.md')
|
|
150
|
+
expect(names).toContain('skills/review/SKILL.md')
|
|
151
151
|
})
|
|
152
152
|
|
|
153
153
|
test('tar contains correct file contents', () => {
|
|
@@ -205,7 +205,7 @@ describe('compressArchive', () => {
|
|
|
205
205
|
test('compressed archive can be decompressed to recover original tar', async () => {
|
|
206
206
|
const entries = [
|
|
207
207
|
{ path: 'facet.json', content: '{"name":"test","version":"1.0.0"}' },
|
|
208
|
-
{ path: 'skills/review.md', content: '# Review skill' },
|
|
208
|
+
{ path: 'skills/review/SKILL.md', content: '# Review skill' },
|
|
209
209
|
]
|
|
210
210
|
|
|
211
211
|
const tar = assembleTar(entries)
|
|
@@ -220,7 +220,7 @@ describe('compressArchive', () => {
|
|
|
220
220
|
|
|
221
221
|
const names = parsed.map((f) => f.name)
|
|
222
222
|
expect(names).toContain('facet.json')
|
|
223
|
-
expect(names).toContain('skills/review.md')
|
|
224
|
-
expect(parsed.find((f) => f.name === 'skills/review.md')?.text).toBe('# Review skill')
|
|
223
|
+
expect(names).toContain('skills/review/SKILL.md')
|
|
224
|
+
expect(parsed.find((f) => f.name === 'skills/review/SKILL.md')?.text).toBe('# Review skill')
|
|
225
225
|
})
|
|
226
226
|
})
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { writeManifest } from '../edit/manifest-writer.ts'
|
|
6
|
+
import { reconcile } from '../edit/reconcile.ts'
|
|
7
|
+
import type { DiscoveredAsset } from '../edit/scanner.ts'
|
|
8
|
+
import { scanAssets } from '../edit/scanner.ts'
|
|
9
|
+
import type { FacetManifest } from '../schemas/facet-manifest.ts'
|
|
10
|
+
|
|
11
|
+
async function createFixtureDir(name: string): Promise<string> {
|
|
12
|
+
const dir = join(tmpdir(), `facets-edit-test-${name}-${Date.now()}`)
|
|
13
|
+
await mkdir(dir, { recursive: true })
|
|
14
|
+
return dir
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// --- Scanner ---
|
|
18
|
+
|
|
19
|
+
describe('scanAssets', () => {
|
|
20
|
+
test('discovers skills in directory convention', async () => {
|
|
21
|
+
const dir = await createFixtureDir('scan-skills')
|
|
22
|
+
await mkdir(join(dir, 'skills/review'), { recursive: true })
|
|
23
|
+
await mkdir(join(dir, 'skills/code-fix'), { recursive: true })
|
|
24
|
+
await Bun.write(join(dir, 'skills/review/SKILL.md'), '# Review')
|
|
25
|
+
await Bun.write(join(dir, 'skills/code-fix/SKILL.md'), '# Code Fix')
|
|
26
|
+
|
|
27
|
+
const assets = await scanAssets(dir)
|
|
28
|
+
expect(assets.filter((a) => a.type === 'skills')).toHaveLength(2)
|
|
29
|
+
expect(assets.find((a) => a.name === 'review')).toBeDefined()
|
|
30
|
+
expect(assets.find((a) => a.name === 'code-fix')).toBeDefined()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('discovers agents and commands in flat convention', async () => {
|
|
34
|
+
const dir = await createFixtureDir('scan-flat')
|
|
35
|
+
await mkdir(join(dir, 'agents'), { recursive: true })
|
|
36
|
+
await mkdir(join(dir, 'commands'), { recursive: true })
|
|
37
|
+
await Bun.write(join(dir, 'agents/reviewer.md'), '# Reviewer')
|
|
38
|
+
await Bun.write(join(dir, 'commands/deploy.md'), '# Deploy')
|
|
39
|
+
|
|
40
|
+
const assets = await scanAssets(dir)
|
|
41
|
+
expect(assets.find((a) => a.type === 'agents' && a.name === 'reviewer')).toBeDefined()
|
|
42
|
+
expect(assets.find((a) => a.type === 'commands' && a.name === 'deploy')).toBeDefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('skips non-kebab-case names', async () => {
|
|
46
|
+
const dir = await createFixtureDir('scan-skip')
|
|
47
|
+
await mkdir(join(dir, 'skills/InvalidName'), { recursive: true })
|
|
48
|
+
await mkdir(join(dir, 'agents'), { recursive: true })
|
|
49
|
+
await Bun.write(join(dir, 'skills/InvalidName/SKILL.md'), '# Bad')
|
|
50
|
+
await Bun.write(join(dir, 'agents/NOT_VALID.md'), '# Bad')
|
|
51
|
+
|
|
52
|
+
const assets = await scanAssets(dir)
|
|
53
|
+
expect(assets).toHaveLength(0)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('returns empty for directory with no assets', async () => {
|
|
57
|
+
const dir = await createFixtureDir('scan-empty')
|
|
58
|
+
const assets = await scanAssets(dir)
|
|
59
|
+
expect(assets).toHaveLength(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('returns sorted results', async () => {
|
|
63
|
+
const dir = await createFixtureDir('scan-sorted')
|
|
64
|
+
await mkdir(join(dir, 'skills/zebra'), { recursive: true })
|
|
65
|
+
await mkdir(join(dir, 'agents'), { recursive: true })
|
|
66
|
+
await mkdir(join(dir, 'skills/alpha'), { recursive: true })
|
|
67
|
+
await Bun.write(join(dir, 'skills/zebra/SKILL.md'), '# Z')
|
|
68
|
+
await Bun.write(join(dir, 'skills/alpha/SKILL.md'), '# A')
|
|
69
|
+
await Bun.write(join(dir, 'agents/beta.md'), '# B')
|
|
70
|
+
|
|
71
|
+
const assets = await scanAssets(dir)
|
|
72
|
+
const names = assets.map((a) => `${a.type}:${a.name}`)
|
|
73
|
+
expect(names).toEqual(['agents:beta', 'skills:alpha', 'skills:zebra'])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// --- Reconciliation ---
|
|
78
|
+
|
|
79
|
+
describe('reconcile', () => {
|
|
80
|
+
test('identifies additions (on disk, not in manifest)', () => {
|
|
81
|
+
const manifest: FacetManifest = {
|
|
82
|
+
name: 'test',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
skills: { review: { description: 'Review skill' } },
|
|
85
|
+
}
|
|
86
|
+
const discovered: DiscoveredAsset[] = [
|
|
87
|
+
{ type: 'skills', name: 'review', path: 'skills/review/SKILL.md' },
|
|
88
|
+
{ type: 'skills', name: 'new-skill', path: 'skills/new-skill/SKILL.md' },
|
|
89
|
+
{ type: 'agents', name: 'helper', path: 'agents/helper.md' },
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
const result = reconcile(manifest, discovered)
|
|
93
|
+
expect(result.additions).toHaveLength(2)
|
|
94
|
+
expect(result.additions.find((a) => a.name === 'new-skill')).toBeDefined()
|
|
95
|
+
expect(result.additions.find((a) => a.name === 'helper')).toBeDefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('identifies missing files (in manifest, not on disk)', () => {
|
|
99
|
+
const manifest: FacetManifest = {
|
|
100
|
+
name: 'test',
|
|
101
|
+
version: '1.0.0',
|
|
102
|
+
skills: {
|
|
103
|
+
review: { description: 'Review' },
|
|
104
|
+
gone: { description: 'Gone' },
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
const discovered: DiscoveredAsset[] = [{ type: 'skills', name: 'review', path: 'skills/review/SKILL.md' }]
|
|
108
|
+
|
|
109
|
+
const result = reconcile(manifest, discovered)
|
|
110
|
+
expect(result.missing).toHaveLength(1)
|
|
111
|
+
expect(result.missing[0]?.name).toBe('gone')
|
|
112
|
+
expect(result.missing[0]?.expectedPath).toBe('skills/gone/SKILL.md')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('identifies matched assets', () => {
|
|
116
|
+
const manifest: FacetManifest = {
|
|
117
|
+
name: 'test',
|
|
118
|
+
version: '1.0.0',
|
|
119
|
+
agents: { reviewer: { description: 'Review agent' } },
|
|
120
|
+
}
|
|
121
|
+
const discovered: DiscoveredAsset[] = [{ type: 'agents', name: 'reviewer', path: 'agents/reviewer.md' }]
|
|
122
|
+
|
|
123
|
+
const result = reconcile(manifest, discovered)
|
|
124
|
+
expect(result.matched).toHaveLength(1)
|
|
125
|
+
expect(result.matched[0]?.name).toBe('reviewer')
|
|
126
|
+
expect(result.additions).toHaveLength(0)
|
|
127
|
+
expect(result.missing).toHaveLength(0)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('handles manifest with no assets', () => {
|
|
131
|
+
const manifest: FacetManifest = {
|
|
132
|
+
name: 'test',
|
|
133
|
+
version: '1.0.0',
|
|
134
|
+
facets: ['base@1.0.0'],
|
|
135
|
+
}
|
|
136
|
+
const discovered: DiscoveredAsset[] = [{ type: 'skills', name: 'extra', path: 'skills/extra/SKILL.md' }]
|
|
137
|
+
|
|
138
|
+
const result = reconcile(manifest, discovered)
|
|
139
|
+
expect(result.additions).toHaveLength(1)
|
|
140
|
+
expect(result.missing).toHaveLength(0)
|
|
141
|
+
expect(result.matched).toHaveLength(0)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('missing agent has flat file expected path', () => {
|
|
145
|
+
const manifest: FacetManifest = {
|
|
146
|
+
name: 'test',
|
|
147
|
+
version: '1.0.0',
|
|
148
|
+
agents: { gone: { description: 'Gone' } },
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = reconcile(manifest, [])
|
|
152
|
+
expect(result.missing[0]?.expectedPath).toBe('agents/gone.md')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// --- Manifest Writer ---
|
|
157
|
+
|
|
158
|
+
describe('writeManifest', () => {
|
|
159
|
+
test('writes valid JSON to facet.json', async () => {
|
|
160
|
+
const dir = await createFixtureDir('write-manifest')
|
|
161
|
+
const manifest: FacetManifest = {
|
|
162
|
+
name: 'test-facet',
|
|
163
|
+
version: '1.0.0',
|
|
164
|
+
skills: { review: { description: 'A review skill' } },
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await writeManifest(manifest, dir)
|
|
168
|
+
|
|
169
|
+
const content = await Bun.file(join(dir, 'facet.json')).text()
|
|
170
|
+
const parsed = JSON.parse(content)
|
|
171
|
+
expect(parsed.name).toBe('test-facet')
|
|
172
|
+
expect(parsed.version).toBe('1.0.0')
|
|
173
|
+
expect(parsed.skills.review.description).toBe('A review skill')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('output is 2-space indented', async () => {
|
|
177
|
+
const dir = await createFixtureDir('write-indent')
|
|
178
|
+
const manifest: FacetManifest = {
|
|
179
|
+
name: 'test',
|
|
180
|
+
version: '1.0.0',
|
|
181
|
+
skills: { x: { description: 'X' } },
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await writeManifest(manifest, dir)
|
|
185
|
+
|
|
186
|
+
const content = await Bun.file(join(dir, 'facet.json')).text()
|
|
187
|
+
// 2-space indent means lines like ' "name": "test"'
|
|
188
|
+
expect(content).toContain(' "name"')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
|
+
import dedent from 'dedent'
|
|
5
6
|
import { loadManifest, resolvePrompts } from '../loaders/facet.ts'
|
|
6
7
|
|
|
7
8
|
let testDir: string
|
|
@@ -174,9 +175,30 @@ describe('loadManifest', () => {
|
|
|
174
175
|
describe('resolvePrompts', () => {
|
|
175
176
|
test('prompt content is resolved from conventional file paths', async () => {
|
|
176
177
|
const dir = await createFixtureDir('resolve-convention')
|
|
177
|
-
await writeFixture(
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
await writeFixture(
|
|
179
|
+
dir,
|
|
180
|
+
'skills/review/SKILL.md',
|
|
181
|
+
dedent`
|
|
182
|
+
# Code Review
|
|
183
|
+
Review all code.
|
|
184
|
+
`,
|
|
185
|
+
)
|
|
186
|
+
await writeFixture(
|
|
187
|
+
dir,
|
|
188
|
+
'agents/reviewer.md',
|
|
189
|
+
dedent`
|
|
190
|
+
# Reviewer
|
|
191
|
+
Review this code.
|
|
192
|
+
`,
|
|
193
|
+
)
|
|
194
|
+
await writeFixture(
|
|
195
|
+
dir,
|
|
196
|
+
'commands/deploy.md',
|
|
197
|
+
dedent`
|
|
198
|
+
# Deploy
|
|
199
|
+
Deploy the code.
|
|
200
|
+
`,
|
|
201
|
+
)
|
|
180
202
|
|
|
181
203
|
const manifest = {
|
|
182
204
|
name: 'test',
|
|
@@ -195,15 +217,31 @@ describe('resolvePrompts', () => {
|
|
|
195
217
|
const result = await resolvePrompts(manifest, dir)
|
|
196
218
|
expect(result.ok).toBe(true)
|
|
197
219
|
if (result.ok) {
|
|
198
|
-
expect(result.data.skills?.review?.prompt).toBe(
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
expect(result.data.skills?.review?.prompt).toBe(dedent`
|
|
221
|
+
# Code Review
|
|
222
|
+
Review all code.
|
|
223
|
+
`)
|
|
224
|
+
expect(result.data.agents?.reviewer?.prompt).toBe(dedent`
|
|
225
|
+
# Reviewer
|
|
226
|
+
Review this code.
|
|
227
|
+
`)
|
|
228
|
+
expect(result.data.commands?.deploy?.prompt).toBe(dedent`
|
|
229
|
+
# Deploy
|
|
230
|
+
Deploy the code.
|
|
231
|
+
`)
|
|
201
232
|
}
|
|
202
233
|
})
|
|
203
234
|
|
|
204
235
|
test('file-based prompt is resolved from agents/<name>.md', async () => {
|
|
205
236
|
const dir = await createFixtureDir('resolve-file')
|
|
206
|
-
await writeFixture(
|
|
237
|
+
await writeFixture(
|
|
238
|
+
dir,
|
|
239
|
+
'agents/reviewer.md',
|
|
240
|
+
dedent`
|
|
241
|
+
# Review
|
|
242
|
+
Check all code.
|
|
243
|
+
`,
|
|
244
|
+
)
|
|
207
245
|
|
|
208
246
|
const manifest = {
|
|
209
247
|
name: 'test',
|
|
@@ -216,7 +254,10 @@ describe('resolvePrompts', () => {
|
|
|
216
254
|
const result = await resolvePrompts(manifest, dir)
|
|
217
255
|
expect(result.ok).toBe(true)
|
|
218
256
|
if (result.ok) {
|
|
219
|
-
expect(result.data.agents?.reviewer?.prompt).toBe(
|
|
257
|
+
expect(result.data.agents?.reviewer?.prompt).toBe(dedent`
|
|
258
|
+
# Review
|
|
259
|
+
Check all code.
|
|
260
|
+
`)
|
|
220
261
|
}
|
|
221
262
|
})
|
|
222
263
|
|
|
@@ -242,7 +283,14 @@ describe('resolvePrompts', () => {
|
|
|
242
283
|
|
|
243
284
|
test('manifest without agents or commands resolves successfully', async () => {
|
|
244
285
|
const dir = await createFixtureDir('resolve-skills-only')
|
|
245
|
-
await writeFixture(
|
|
286
|
+
await writeFixture(
|
|
287
|
+
dir,
|
|
288
|
+
'skills/x/SKILL.md',
|
|
289
|
+
dedent`
|
|
290
|
+
# Skill X
|
|
291
|
+
Do x.
|
|
292
|
+
`,
|
|
293
|
+
)
|
|
246
294
|
|
|
247
295
|
const manifest = {
|
|
248
296
|
name: 'test',
|
|
@@ -258,7 +306,10 @@ describe('resolvePrompts', () => {
|
|
|
258
306
|
expect(result.ok).toBe(true)
|
|
259
307
|
if (result.ok) {
|
|
260
308
|
expect(result.data.name).toBe('test')
|
|
261
|
-
expect(result.data.skills?.x?.prompt).toBe(
|
|
309
|
+
expect(result.data.skills?.x?.prompt).toBe(dedent`
|
|
310
|
+
# Skill X
|
|
311
|
+
Do x.
|
|
312
|
+
`)
|
|
262
313
|
}
|
|
263
314
|
})
|
|
264
315
|
})
|