@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 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.1.2",
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(dir, 'skills/review.md', '# Code Review\nReview all code.')
178
- await writeFixture(dir, 'agents/reviewer.md', '# Reviewer\nReview this code.')
179
- await writeFixture(dir, 'commands/deploy.md', '# Deploy\nDeploy the code.')
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('# Code Review\nReview all code.')
199
- expect(result.data.agents?.reviewer?.prompt).toBe('# Reviewer\nReview this code.')
200
- expect(result.data.commands?.deploy?.prompt).toBe('# Deploy\nDeploy the code.')
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(dir, 'agents/reviewer.md', '# Review\nCheck all code.')
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('# Review\nCheck all code.')
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(dir, 'skills/x.md', '# Skill X\nDo x.')
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('# Skill X\nDo x.')
309
+ expect(result.data.skills?.x?.prompt).toBe(dedent`
310
+ # Skill X
311
+ Do x.
312
+ `)
262
313
  }
263
314
  })
264
315
  })