@agent-facets/core 0.1.1

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 ADDED
@@ -0,0 +1,20 @@
1
+ # @agent-facets/core
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 5813b90: Small test for change set management in CI
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 2243bbf: Added basic create command to CLI
14
+
15
+ ## 0.0.1
16
+
17
+ ### Patch Changes
18
+
19
+ - 74e3d25: Should be 0.0.1 now
20
+ - 74e3d25: Initial publishing
package/bunfig.toml ADDED
@@ -0,0 +1,2 @@
1
+ [test.reporter]
2
+ junit = "../../test-results/core-junit.xml"
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@agent-facets/core",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "https://github.com/agent-facets/facets",
6
+ "directory": "packages/core"
7
+ },
8
+ "version": "0.1.1",
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "scripts": {
14
+ "types": "tsc --noEmit",
15
+ "test": "bun test"
16
+ },
17
+ "dependencies": {
18
+ "arktype": "2.1.29",
19
+ "comment-json": "^4.2.5",
20
+ "nanotar": "0.3.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/bun": "1.3.10"
24
+ },
25
+ "peerDependencies": {
26
+ "typescript": "^5"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": false
31
+ }
32
+ }
@@ -0,0 +1,427 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { detectNamingCollisions } from '../build/detect-collisions.ts'
6
+ import { runBuildPipeline } from '../build/pipeline.ts'
7
+ import { validateCompactFacets } from '../build/validate-facets.ts'
8
+ import { validatePlatformConfigs } from '../build/validate-platforms.ts'
9
+ import { writeBuildOutput } from '../build/write-output.ts'
10
+ import type { FacetManifest } from '../schemas/facet-manifest.ts'
11
+
12
+ let testDir: string
13
+
14
+ beforeAll(async () => {
15
+ testDir = await mkdtemp(join(tmpdir(), 'build-pipeline-test-'))
16
+ })
17
+
18
+ afterAll(async () => {
19
+ await rm(testDir, { recursive: true, force: true })
20
+ })
21
+
22
+ async function createFixtureDir(name: string): Promise<string> {
23
+ const dir = join(testDir, name)
24
+ await Bun.write(join(dir, '.keep'), '')
25
+ return dir
26
+ }
27
+
28
+ // --- Compact facets validation ---
29
+
30
+ describe('validateCompactFacets', () => {
31
+ test('valid compact entry passes', () => {
32
+ const manifest = {
33
+ name: 'test',
34
+ version: '1.0.0',
35
+ facets: ['base@1.0.0'],
36
+ } as FacetManifest
37
+ const errors = validateCompactFacets(manifest)
38
+ expect(errors).toHaveLength(0)
39
+ })
40
+
41
+ test('scoped compact entry passes', () => {
42
+ const manifest = {
43
+ name: 'test',
44
+ version: '1.0.0',
45
+ facets: ['@acme/base@2.0.0'],
46
+ } as FacetManifest
47
+ const errors = validateCompactFacets(manifest)
48
+ expect(errors).toHaveLength(0)
49
+ })
50
+
51
+ test('malformed compact entry fails', () => {
52
+ const manifest = {
53
+ name: 'test',
54
+ version: '1.0.0',
55
+ facets: ['no-version-here'],
56
+ } as FacetManifest
57
+ const errors = validateCompactFacets(manifest)
58
+ expect(errors).toHaveLength(1)
59
+ expect(errors[0]?.path).toBe('facets[0]')
60
+ expect(errors[0]?.message).toContain('name@version')
61
+ })
62
+
63
+ test('selective entries are skipped', () => {
64
+ const manifest = {
65
+ name: 'test',
66
+ version: '1.0.0',
67
+ facets: [{ name: 'other', version: '1.0.0', skills: ['x'] }],
68
+ } as FacetManifest
69
+ const errors = validateCompactFacets(manifest)
70
+ expect(errors).toHaveLength(0)
71
+ })
72
+
73
+ test('no facets section passes', () => {
74
+ const manifest = {
75
+ name: 'test',
76
+ version: '1.0.0',
77
+ skills: { x: { description: 'A skill' } },
78
+ } as FacetManifest
79
+ const errors = validateCompactFacets(manifest)
80
+ expect(errors).toHaveLength(0)
81
+ })
82
+ })
83
+
84
+ // --- Naming collision detection ---
85
+
86
+ describe('detectNamingCollisions', () => {
87
+ test('no collisions with distinct names', () => {
88
+ const manifest = {
89
+ name: 'test',
90
+ version: '1.0.0',
91
+ skills: { review: { description: 'Review skill' } },
92
+ agents: { helper: { description: 'Helper agent' } },
93
+ commands: { deploy: { description: 'Deploy command' } },
94
+ } as FacetManifest
95
+ const errors = detectNamingCollisions(manifest)
96
+ expect(errors).toHaveLength(0)
97
+ })
98
+
99
+ test('skill and command sharing a name is allowed (cross-type)', () => {
100
+ const manifest = {
101
+ name: 'test',
102
+ version: '1.0.0',
103
+ skills: { review: { description: 'Review skill' } },
104
+ commands: { review: { description: 'Run review' } },
105
+ } as FacetManifest
106
+ const errors = detectNamingCollisions(manifest)
107
+ expect(errors).toHaveLength(0)
108
+ })
109
+
110
+ test('agent and skill sharing a name is allowed (cross-type)', () => {
111
+ const manifest = {
112
+ name: 'test',
113
+ version: '1.0.0',
114
+ skills: { helper: { description: 'Helper skill' } },
115
+ agents: { helper: { description: 'Helper agent' } },
116
+ } as FacetManifest
117
+ const errors = detectNamingCollisions(manifest)
118
+ expect(errors).toHaveLength(0)
119
+ })
120
+
121
+ test('same name across all three types is allowed (cross-type)', () => {
122
+ const manifest = {
123
+ name: 'test',
124
+ version: '1.0.0',
125
+ skills: { deploy: { description: 'Deploy skill' } },
126
+ agents: { deploy: { description: 'Deploy agent' } },
127
+ commands: { deploy: { description: 'Deploy command' } },
128
+ } as FacetManifest
129
+ const errors = detectNamingCollisions(manifest)
130
+ expect(errors).toHaveLength(0)
131
+ })
132
+ })
133
+
134
+ // --- Platform config validation ---
135
+
136
+ describe('validatePlatformConfigs', () => {
137
+ test('valid opencode config passes', () => {
138
+ const manifest = {
139
+ name: 'test',
140
+ version: '1.0.0',
141
+ agents: {
142
+ reviewer: {
143
+ description: 'Reviewer agent',
144
+ platforms: {
145
+ opencode: { tools: { grep: true, bash: true } },
146
+ },
147
+ },
148
+ },
149
+ } as FacetManifest
150
+ const result = validatePlatformConfigs(manifest)
151
+ expect(result.errors).toHaveLength(0)
152
+ expect(result.warnings).toHaveLength(0)
153
+ })
154
+
155
+ test('unknown platform produces warning', () => {
156
+ const manifest = {
157
+ name: 'test',
158
+ version: '1.0.0',
159
+ skills: {
160
+ review: {
161
+ description: 'Review skill',
162
+ platforms: {
163
+ 'unknown-platform': { foo: 'bar' },
164
+ },
165
+ },
166
+ },
167
+ } as FacetManifest
168
+ const result = validatePlatformConfigs(manifest)
169
+ expect(result.errors).toHaveLength(0)
170
+ expect(result.warnings).toHaveLength(1)
171
+ expect(result.warnings[0]).toContain('unknown-platform')
172
+ })
173
+
174
+ test('invalid opencode config fails', () => {
175
+ const manifest = {
176
+ name: 'test',
177
+ version: '1.0.0',
178
+ agents: {
179
+ reviewer: {
180
+ description: 'Reviewer agent',
181
+ platforms: {
182
+ opencode: { tools: 'not-a-record' },
183
+ },
184
+ },
185
+ },
186
+ } as FacetManifest
187
+ const result = validatePlatformConfigs(manifest)
188
+ expect(result.errors.length).toBeGreaterThan(0)
189
+ expect(result.errors[0]?.message).toContain('opencode')
190
+ })
191
+
192
+ test('no platforms on any asset passes', () => {
193
+ const manifest = {
194
+ name: 'test',
195
+ version: '1.0.0',
196
+ skills: { x: { description: 'A skill' } },
197
+ } as FacetManifest
198
+ const result = validatePlatformConfigs(manifest)
199
+ expect(result.errors).toHaveLength(0)
200
+ expect(result.warnings).toHaveLength(0)
201
+ })
202
+ })
203
+
204
+ // --- Build pipeline (end-to-end) ---
205
+
206
+ describe('runBuildPipeline', () => {
207
+ test('successful build with valid facet', async () => {
208
+ const dir = await createFixtureDir('valid-build')
209
+ await Bun.write(join(dir, 'skills/example.md'), '# Example skill')
210
+ await Bun.write(
211
+ join(dir, 'facet.json'),
212
+ JSON.stringify({
213
+ name: 'test-facet',
214
+ version: '1.0.0',
215
+ skills: {
216
+ example: {
217
+ description: 'An example skill',
218
+ },
219
+ },
220
+ }),
221
+ )
222
+
223
+ const result = await runBuildPipeline(dir)
224
+ expect(result.ok).toBe(true)
225
+ if (result.ok) {
226
+ expect(result.data.name).toBe('test-facet')
227
+ expect(result.data.skills?.example?.prompt).toBe('# Example skill')
228
+
229
+ // Content hashing fields
230
+ expect(result.archiveFilename).toBe('test-facet-1.0.0.facet')
231
+ expect(result.archiveBytes.length).toBeGreaterThan(0)
232
+ 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(
235
+ `"sha256:ded8057927e03783371d0d929e4a6e92da66eb9dd164377ad6845a5a1c0cb5ba"`,
236
+ )
237
+ expect(result.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
238
+ }
239
+ })
240
+
241
+ test('build fails on missing manifest', async () => {
242
+ const dir = await createFixtureDir('no-manifest')
243
+ const result = await runBuildPipeline(dir)
244
+ expect(result.ok).toBe(false)
245
+ })
246
+
247
+ test('build fails on missing asset file', async () => {
248
+ const dir = await createFixtureDir('missing-file')
249
+ await Bun.write(
250
+ join(dir, 'facet.json'),
251
+ JSON.stringify({
252
+ name: 'test-facet',
253
+ version: '1.0.0',
254
+ skills: {
255
+ example: {
256
+ description: 'An example skill',
257
+ },
258
+ },
259
+ }),
260
+ )
261
+
262
+ const result = await runBuildPipeline(dir)
263
+ expect(result.ok).toBe(false)
264
+ if (!result.ok) {
265
+ expect(result.errors[0]?.path).toBe('skills.example')
266
+ expect(result.errors[0]?.message).toContain('skills/example.md')
267
+ }
268
+ })
269
+
270
+ test('build succeeds with cross-type name sharing', async () => {
271
+ const dir = await createFixtureDir('cross-type')
272
+ await Bun.write(join(dir, 'skills/review.md'), '# Review skill')
273
+ await Bun.write(join(dir, 'commands/review.md'), '# Review command')
274
+ await Bun.write(
275
+ join(dir, 'facet.json'),
276
+ JSON.stringify({
277
+ name: 'test-facet',
278
+ version: '1.0.0',
279
+ skills: {
280
+ review: { description: 'A review skill' },
281
+ },
282
+ commands: {
283
+ review: { description: 'A review command' },
284
+ },
285
+ }),
286
+ )
287
+
288
+ const result = await runBuildPipeline(dir)
289
+ expect(result.ok).toBe(true)
290
+ })
291
+
292
+ test('build with all asset types includes all hashes', async () => {
293
+ 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')
296
+ await Bun.write(join(dir, 'agents/helper.md'), '# Helper agent')
297
+ await Bun.write(join(dir, 'commands/deploy.md'), '# Deploy command')
298
+ await Bun.write(
299
+ join(dir, 'facet.json'),
300
+ JSON.stringify({
301
+ name: 'multi-facet',
302
+ version: '2.0.0',
303
+ skills: {
304
+ alpha: { description: 'Alpha skill' },
305
+ beta: { description: 'Beta skill' },
306
+ },
307
+ agents: {
308
+ helper: { description: 'Helper agent' },
309
+ },
310
+ commands: {
311
+ deploy: { description: 'Deploy command' },
312
+ },
313
+ }),
314
+ )
315
+
316
+ const result = await runBuildPipeline(dir)
317
+ expect(result.ok).toBe(true)
318
+ if (result.ok) {
319
+ expect(result.archiveFilename).toBe('multi-facet-2.0.0.facet')
320
+ const assetPaths = Object.keys(result.assetHashes).sort()
321
+ expect(assetPaths).toEqual([
322
+ 'agents/helper.md',
323
+ 'commands/deploy.md',
324
+ 'facet.json',
325
+ 'skills/alpha.md',
326
+ 'skills/beta.md',
327
+ ])
328
+ }
329
+ })
330
+
331
+ test('build fails on malformed compact facets entry', async () => {
332
+ const dir = await createFixtureDir('bad-facets')
333
+ await Bun.write(join(dir, 'skills/x.md'), '# Skill')
334
+ await Bun.write(
335
+ join(dir, 'facet.json'),
336
+ JSON.stringify({
337
+ name: 'test-facet',
338
+ version: '1.0.0',
339
+ skills: {
340
+ x: { description: 'A skill' },
341
+ },
342
+ facets: ['no-version-here'],
343
+ }),
344
+ )
345
+
346
+ const result = await runBuildPipeline(dir)
347
+ expect(result.ok).toBe(false)
348
+ if (!result.ok) {
349
+ expect(result.errors[0]?.message).toContain('name@version')
350
+ }
351
+ })
352
+ })
353
+
354
+ // --- Build output generation ---
355
+
356
+ describe('writeBuildOutput', () => {
357
+ test('writes archive and build manifest to dist/', async () => {
358
+ const dir = await createFixtureDir('write-output')
359
+ await Bun.write(join(dir, 'skills/example.md'), '# Resolved content')
360
+ await Bun.write(
361
+ join(dir, 'facet.json'),
362
+ JSON.stringify({
363
+ name: 'test-facet',
364
+ version: '1.0.0',
365
+ skills: {
366
+ example: { description: 'A skill' },
367
+ },
368
+ }),
369
+ )
370
+
371
+ const result = await runBuildPipeline(dir)
372
+ expect(result.ok).toBe(true)
373
+ if (!result.ok) return
374
+
375
+ await writeBuildOutput(result, dir)
376
+
377
+ // Archive exists
378
+ const archiveExists = await Bun.file(join(dir, 'dist/test-facet-1.0.0.facet')).exists()
379
+ expect(archiveExists).toBe(true)
380
+
381
+ // Build manifest exists and has correct structure
382
+ const manifestText = await Bun.file(join(dir, 'dist/build-manifest.json')).text()
383
+ const manifest = JSON.parse(manifestText)
384
+ expect(manifest.facetVersion).toBe(1)
385
+ expect(manifest.archive).toBe('test-facet-1.0.0.facet')
386
+ expect(manifest.integrity).toMatch(/^sha256:[a-f0-9]{64}$/)
387
+ expect(manifest.assets['facet.json']).toMatch(/^sha256:[a-f0-9]{64}$/)
388
+ expect(manifest.assets['skills/example.md']).toMatch(/^sha256:[a-f0-9]{64}$/)
389
+
390
+ // No loose files
391
+ const looseManifest = await Bun.file(join(dir, 'dist/facet.json')).exists()
392
+ expect(looseManifest).toBe(false)
393
+ })
394
+
395
+ test('cleans previous dist/ before writing', async () => {
396
+ const dir = await createFixtureDir('clean-dist')
397
+ await Bun.write(join(dir, 'skills/x.md'), '# Skill')
398
+ await Bun.write(
399
+ join(dir, 'facet.json'),
400
+ JSON.stringify({
401
+ name: 'test',
402
+ version: '1.0.0',
403
+ skills: {
404
+ x: { description: 'A skill' },
405
+ },
406
+ }),
407
+ )
408
+ // Write a stale file in dist/
409
+ await Bun.write(join(dir, 'dist/stale.txt'), 'stale')
410
+
411
+ const result = await runBuildPipeline(dir)
412
+ expect(result.ok).toBe(true)
413
+ if (!result.ok) return
414
+
415
+ await writeBuildOutput(result, dir)
416
+
417
+ // Stale file should be gone
418
+ const staleExists = await Bun.file(join(dir, 'dist/stale.txt')).exists()
419
+ expect(staleExists).toBe(false)
420
+
421
+ // Archive and manifest should exist
422
+ const archiveExists = await Bun.file(join(dir, 'dist/test-1.0.0.facet')).exists()
423
+ expect(archiveExists).toBe(true)
424
+ const manifestExists = await Bun.file(join(dir, 'dist/build-manifest.json')).exists()
425
+ expect(manifestExists).toBe(true)
426
+ })
427
+ })