@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
2
|
import { type } from 'arktype'
|
|
3
|
-
import {
|
|
3
|
+
import { type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
|
|
4
4
|
|
|
5
5
|
// --- Valid manifests ---
|
|
6
6
|
|
|
@@ -88,9 +88,6 @@ describe('FacetManifestSchema — valid manifests', () => {
|
|
|
88
88
|
}
|
|
89
89
|
const result = FacetManifestSchema(input)
|
|
90
90
|
expect(result).not.toBeInstanceOf(type.errors)
|
|
91
|
-
const data = result as FacetManifest
|
|
92
|
-
const errors = checkFacetManifestConstraints(data)
|
|
93
|
-
expect(errors).toHaveLength(0)
|
|
94
91
|
})
|
|
95
92
|
})
|
|
96
93
|
|
|
@@ -137,38 +134,29 @@ describe('FacetManifestSchema — invalid manifests', () => {
|
|
|
137
134
|
const errors = result as InstanceType<typeof type.errors>
|
|
138
135
|
expect(errors.some((e) => e.path.includes('bad'))).toBe(true)
|
|
139
136
|
})
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
// --- Business-rule constraints ---
|
|
143
137
|
|
|
144
|
-
|
|
145
|
-
test('no text assets → error', () => {
|
|
138
|
+
test('no text assets → schema error', () => {
|
|
146
139
|
const input = {
|
|
147
140
|
name: 'empty',
|
|
148
141
|
version: '1.0.0',
|
|
149
142
|
servers: { jira: '1.0.0' },
|
|
150
143
|
}
|
|
151
144
|
const result = FacetManifestSchema(input)
|
|
152
|
-
expect(result).
|
|
153
|
-
const errors =
|
|
154
|
-
expect(errors).
|
|
155
|
-
const firstError = errors[0]
|
|
156
|
-
expect(firstError).toBeDefined()
|
|
157
|
-
expect(firstError?.message).toContain('at least one text asset')
|
|
145
|
+
expect(result).toBeInstanceOf(type.errors)
|
|
146
|
+
const errors = result as InstanceType<typeof type.errors>
|
|
147
|
+
expect(errors.some((e) => e.message.includes('at least one text asset'))).toBe(true)
|
|
158
148
|
})
|
|
159
149
|
|
|
160
|
-
test('selective facets entry with no asset selection → error', () => {
|
|
150
|
+
test('selective facets entry with no asset selection → schema error', () => {
|
|
161
151
|
const input = {
|
|
162
152
|
name: 'bad-selective',
|
|
163
153
|
version: '1.0.0',
|
|
164
154
|
facets: [{ name: 'other', version: '1.0.0' }],
|
|
165
155
|
}
|
|
166
156
|
const result = FacetManifestSchema(input)
|
|
167
|
-
expect(result).
|
|
168
|
-
const errors =
|
|
169
|
-
|
|
170
|
-
expect(selectiveError).toBeDefined()
|
|
171
|
-
expect(selectiveError?.path).toBe('facets[0]')
|
|
157
|
+
expect(result).toBeInstanceOf(type.errors)
|
|
158
|
+
const errors = result as InstanceType<typeof type.errors>
|
|
159
|
+
expect(errors.some((e) => e.message.includes('at least one asset type'))).toBe(true)
|
|
172
160
|
})
|
|
173
161
|
})
|
|
174
162
|
|
|
@@ -27,7 +27,7 @@ export function collectArchiveEntries(resolved: ResolvedFacetManifest, manifestC
|
|
|
27
27
|
|
|
28
28
|
if (resolved.skills) {
|
|
29
29
|
for (const [name, skill] of Object.entries(resolved.skills)) {
|
|
30
|
-
entries.push({ path: `skills/${name}.md`, content: skill.prompt })
|
|
30
|
+
entries.push({ path: `skills/${name}/SKILL.md`, content: skill.prompt })
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
package/src/build/pipeline.ts
CHANGED
|
@@ -9,11 +9,12 @@ import {
|
|
|
9
9
|
computeContentHash,
|
|
10
10
|
} from './content-hash.ts'
|
|
11
11
|
import { detectNamingCollisions } from './detect-collisions.ts'
|
|
12
|
+
import { validateContentFiles } from './validate-content.ts'
|
|
12
13
|
import { validateCompactFacets } from './validate-facets.ts'
|
|
13
14
|
import { validatePlatformConfigs } from './validate-platforms.ts'
|
|
14
15
|
|
|
15
16
|
export interface BuildProgress {
|
|
16
|
-
stage:
|
|
17
|
+
stage: BuildStage
|
|
17
18
|
status: 'running' | 'done' | 'failed'
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -33,19 +34,33 @@ export interface BuildFailure {
|
|
|
33
34
|
warnings: string[]
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** Stage names emitted by the build pipeline via the onProgress callback. */
|
|
38
|
+
export const BUILD_STAGES = [
|
|
39
|
+
'Parsing manifest',
|
|
40
|
+
'Resolving prompts',
|
|
41
|
+
'Validating assets',
|
|
42
|
+
'Checking collisions',
|
|
43
|
+
'Validating platforms',
|
|
44
|
+
'Assembling archive',
|
|
45
|
+
'Writing output',
|
|
46
|
+
] as const
|
|
47
|
+
|
|
48
|
+
export type BuildStage = (typeof BUILD_STAGES)[number]
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
51
|
* Runs the full build pipeline:
|
|
38
|
-
* 1.
|
|
39
|
-
* 2. Resolve prompts — read prompt files at conventional paths
|
|
40
|
-
* 3. Validate
|
|
41
|
-
* 4.
|
|
42
|
-
* 5. Validate
|
|
43
|
-
* 6. Assemble archive — collect entries, compute
|
|
52
|
+
* 1. Parse manifest — read facet.json, parse JSON, validate schema, check constraints
|
|
53
|
+
* 2. Resolve prompts — read prompt files at conventional paths (also verifies files exist)
|
|
54
|
+
* 3. Validate content — no front matter, no empty files
|
|
55
|
+
* 4. Check collisions — fail if same name used within an asset type
|
|
56
|
+
* 5. Validate platforms — check known platform schemas, warn on unknown
|
|
57
|
+
* 6. Assemble archive — collect entries, compute hashes, build tar, compress
|
|
44
58
|
*
|
|
45
59
|
* Returns the resolved manifest and archive data on success, or collected errors on failure.
|
|
46
60
|
* Warnings are returned in both cases.
|
|
47
61
|
*
|
|
48
62
|
* An optional `onProgress` callback receives stage updates for UI display.
|
|
63
|
+
* The 'Writing output' stage is emitted by name but handled by the caller (BuildView).
|
|
49
64
|
*/
|
|
50
65
|
export async function runBuildPipeline(
|
|
51
66
|
rootDir: string,
|
|
@@ -53,46 +68,64 @@ export async function runBuildPipeline(
|
|
|
53
68
|
): Promise<BuildResult | BuildFailure> {
|
|
54
69
|
const warnings: string[] = []
|
|
55
70
|
|
|
56
|
-
// Stage 1:
|
|
57
|
-
onProgress?.({ stage: '
|
|
71
|
+
// Stage 1: Parse manifest
|
|
72
|
+
onProgress?.({ stage: 'Parsing manifest', status: 'running' })
|
|
58
73
|
|
|
59
74
|
const loadResult = await loadManifest(rootDir)
|
|
60
75
|
if (!loadResult.ok) {
|
|
61
|
-
onProgress?.({ stage: '
|
|
76
|
+
onProgress?.({ stage: 'Parsing manifest', status: 'failed' })
|
|
62
77
|
return { ok: false, errors: loadResult.errors, warnings }
|
|
63
78
|
}
|
|
64
79
|
const manifest = loadResult.data
|
|
65
80
|
|
|
81
|
+
onProgress?.({ stage: 'Parsing manifest', status: 'done' })
|
|
82
|
+
|
|
66
83
|
// Stage 2: Resolve prompts (also serves as file existence verification)
|
|
84
|
+
onProgress?.({ stage: 'Resolving prompts', status: 'running' })
|
|
85
|
+
|
|
67
86
|
const resolveResult = await resolvePrompts(manifest, rootDir)
|
|
68
87
|
if (!resolveResult.ok) {
|
|
69
|
-
onProgress?.({ stage: '
|
|
88
|
+
onProgress?.({ stage: 'Resolving prompts', status: 'failed' })
|
|
70
89
|
return { ok: false, errors: resolveResult.errors, warnings }
|
|
71
90
|
}
|
|
72
91
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
92
|
+
onProgress?.({ stage: 'Resolving prompts', status: 'done' })
|
|
93
|
+
|
|
94
|
+
// Stage 3: Validate assets (no front matter, no empty files)
|
|
95
|
+
onProgress?.({ stage: 'Validating assets', status: 'running' })
|
|
96
|
+
|
|
97
|
+
const contentErrors = validateContentFiles(resolveResult.data)
|
|
98
|
+
if (contentErrors.length > 0) {
|
|
99
|
+
onProgress?.({ stage: 'Validating assets', status: 'failed' })
|
|
100
|
+
return { ok: false, errors: contentErrors, warnings }
|
|
78
101
|
}
|
|
79
102
|
|
|
80
|
-
|
|
103
|
+
onProgress?.({ stage: 'Validating assets', status: 'done' })
|
|
104
|
+
|
|
105
|
+
// Stage 4: Check naming collisions
|
|
106
|
+
onProgress?.({ stage: 'Checking collisions', status: 'running' })
|
|
107
|
+
|
|
81
108
|
const collisionErrors = detectNamingCollisions(manifest)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
const facetsErrors = validateCompactFacets(manifest)
|
|
110
|
+
const checkErrors = [...collisionErrors, ...facetsErrors]
|
|
111
|
+
if (checkErrors.length > 0) {
|
|
112
|
+
onProgress?.({ stage: 'Checking collisions', status: 'failed' })
|
|
113
|
+
return { ok: false, errors: checkErrors, warnings }
|
|
85
114
|
}
|
|
86
115
|
|
|
116
|
+
onProgress?.({ stage: 'Checking collisions', status: 'done' })
|
|
117
|
+
|
|
87
118
|
// Stage 5: Validate platform config
|
|
119
|
+
onProgress?.({ stage: 'Validating platforms', status: 'running' })
|
|
120
|
+
|
|
88
121
|
const platformResult = validatePlatformConfigs(manifest)
|
|
89
122
|
if (platformResult.errors.length > 0) {
|
|
90
|
-
onProgress?.({ stage: 'Validating
|
|
123
|
+
onProgress?.({ stage: 'Validating platforms', status: 'failed' })
|
|
91
124
|
return { ok: false, errors: platformResult.errors, warnings: [...warnings, ...platformResult.warnings] }
|
|
92
125
|
}
|
|
93
126
|
warnings.push(...platformResult.warnings)
|
|
94
127
|
|
|
95
|
-
onProgress?.({ stage: 'Validating
|
|
128
|
+
onProgress?.({ stage: 'Validating platforms', status: 'done' })
|
|
96
129
|
|
|
97
130
|
// Stage 6: Assemble archive, compute content hashes, and compress for delivery
|
|
98
131
|
onProgress?.({ stage: 'Assembling archive', status: 'running' })
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { hasFrontMatter } from '../front-matter.ts'
|
|
2
|
+
import type { ResolvedFacetManifest } from '../loaders/facet.ts'
|
|
3
|
+
import type { ValidationError } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validates resolved prompt content for all assets:
|
|
7
|
+
* - No YAML front matter (manifest is the single source of truth for metadata)
|
|
8
|
+
* - No empty files (zero bytes or whitespace only)
|
|
9
|
+
*
|
|
10
|
+
* Returns an array of validation errors, one per offending file.
|
|
11
|
+
*/
|
|
12
|
+
export function validateContentFiles(resolved: ResolvedFacetManifest): ValidationError[] {
|
|
13
|
+
const errors: ValidationError[] = []
|
|
14
|
+
|
|
15
|
+
const assetTypes = [
|
|
16
|
+
{ type: 'skills', assets: resolved.skills },
|
|
17
|
+
{ type: 'agents', assets: resolved.agents },
|
|
18
|
+
{ type: 'commands', assets: resolved.commands },
|
|
19
|
+
] as const
|
|
20
|
+
|
|
21
|
+
for (const { type, assets } of assetTypes) {
|
|
22
|
+
if (!assets) continue
|
|
23
|
+
for (const [name, asset] of Object.entries(assets)) {
|
|
24
|
+
const relativePath = type === 'skills' ? `skills/${name}/SKILL.md` : `${type}/${name}.md`
|
|
25
|
+
|
|
26
|
+
// Check for empty content
|
|
27
|
+
if (asset.prompt.trim().length === 0) {
|
|
28
|
+
errors.push({
|
|
29
|
+
path: `${type}.${name}`,
|
|
30
|
+
message: `File is empty: ${relativePath}. Content files must contain prompt content.`,
|
|
31
|
+
expected: 'non-empty content',
|
|
32
|
+
actual: 'empty or whitespace only',
|
|
33
|
+
})
|
|
34
|
+
continue // Skip front matter check on empty files
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check for YAML front matter
|
|
38
|
+
if (hasFrontMatter(asset.prompt)) {
|
|
39
|
+
errors.push({
|
|
40
|
+
path: `${type}.${name}`,
|
|
41
|
+
message: `File contains YAML front matter: ${relativePath}. The manifest is the source of truth for metadata — use \`facet edit\` to reconcile.`,
|
|
42
|
+
expected: 'no front matter',
|
|
43
|
+
actual: 'front matter detected',
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return errors
|
|
50
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { FACET_MANIFEST_FILE } from '../loaders/facet.ts'
|
|
3
|
+
import type { FacetManifest } from '../schemas/facet-manifest.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Writes a facet manifest to disk as `facet.json`.
|
|
7
|
+
* Uses `JSON.stringify(data, null, 2)` per ADR-006.
|
|
8
|
+
*/
|
|
9
|
+
export async function writeManifest(manifest: FacetManifest, rootDir: string): Promise<void> {
|
|
10
|
+
const path = join(rootDir, FACET_MANIFEST_FILE)
|
|
11
|
+
await Bun.write(path, JSON.stringify(manifest, null, 2))
|
|
12
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FacetManifest } from '../schemas/facet-manifest.ts'
|
|
2
|
+
import type { AssetType, DiscoveredAsset } from './scanner.ts'
|
|
3
|
+
|
|
4
|
+
export interface MissingAsset {
|
|
5
|
+
type: AssetType
|
|
6
|
+
name: string
|
|
7
|
+
/** The path where the file was expected (e.g., 'skills/review/SKILL.md') */
|
|
8
|
+
expectedPath: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MatchedAsset {
|
|
12
|
+
type: AssetType
|
|
13
|
+
name: string
|
|
14
|
+
/** Relative path from the facet root */
|
|
15
|
+
path: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReconciliationResult {
|
|
19
|
+
/** Assets found on disk but not in the manifest */
|
|
20
|
+
additions: DiscoveredAsset[]
|
|
21
|
+
/** Assets in the manifest but missing from disk */
|
|
22
|
+
missing: MissingAsset[]
|
|
23
|
+
/** Assets that exist in both the manifest and on disk */
|
|
24
|
+
matched: MatchedAsset[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compares discovered assets on disk against manifest entries.
|
|
29
|
+
* Produces lists of additions (on disk, not in manifest),
|
|
30
|
+
* missing files (in manifest, not on disk), and matched assets.
|
|
31
|
+
*/
|
|
32
|
+
export function reconcile(manifest: FacetManifest, discovered: DiscoveredAsset[]): ReconciliationResult {
|
|
33
|
+
const additions: DiscoveredAsset[] = []
|
|
34
|
+
const missing: MissingAsset[] = []
|
|
35
|
+
const matched: MatchedAsset[] = []
|
|
36
|
+
|
|
37
|
+
// Build a set of discovered assets keyed by type:name
|
|
38
|
+
const discoveredMap = new Map<string, DiscoveredAsset>()
|
|
39
|
+
for (const asset of discovered) {
|
|
40
|
+
discoveredMap.set(`${asset.type}:${asset.name}`, asset)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Check manifest entries against discovered assets
|
|
44
|
+
const assetSections: Array<{ type: AssetType; entries: Record<string, unknown> | undefined }> = [
|
|
45
|
+
{ type: 'skills', entries: manifest.skills },
|
|
46
|
+
{ type: 'agents', entries: manifest.agents },
|
|
47
|
+
{ type: 'commands', entries: manifest.commands },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const manifestKeys = new Set<string>()
|
|
51
|
+
|
|
52
|
+
for (const { type, entries } of assetSections) {
|
|
53
|
+
if (!entries) continue
|
|
54
|
+
for (const name of Object.keys(entries)) {
|
|
55
|
+
const key = `${type}:${name}`
|
|
56
|
+
manifestKeys.add(key)
|
|
57
|
+
|
|
58
|
+
const onDisk = discoveredMap.get(key)
|
|
59
|
+
if (onDisk) {
|
|
60
|
+
matched.push({ type, name, path: onDisk.path })
|
|
61
|
+
} else {
|
|
62
|
+
const expectedPath = type === 'skills' ? `skills/${name}/SKILL.md` : `${type}/${name}.md`
|
|
63
|
+
missing.push({ type, name, expectedPath })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check discovered assets not in manifest
|
|
69
|
+
for (const asset of discovered) {
|
|
70
|
+
const key = `${asset.type}:${asset.name}`
|
|
71
|
+
if (!manifestKeys.has(key)) {
|
|
72
|
+
additions.push(asset)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { additions, missing, matched }
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Glob } from 'bun'
|
|
2
|
+
|
|
3
|
+
/** Kebab-case pattern for valid asset names. */
|
|
4
|
+
export const KEBAB_CASE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
|
|
5
|
+
|
|
6
|
+
export type AssetType = 'skills' | 'agents' | 'commands'
|
|
7
|
+
|
|
8
|
+
export interface DiscoveredAsset {
|
|
9
|
+
type: AssetType
|
|
10
|
+
name: string
|
|
11
|
+
/** Relative path from the facet root (e.g., 'skills/review/SKILL.md') */
|
|
12
|
+
path: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scans a facet directory for content files and returns discovered assets.
|
|
17
|
+
*
|
|
18
|
+
* Skills use the directory convention: `skills/<name>/SKILL.md`
|
|
19
|
+
* Agents use the flat convention: `agents/<name>.md`
|
|
20
|
+
* Commands use the flat convention: `commands/<name>.md`
|
|
21
|
+
*
|
|
22
|
+
* Only assets with valid kebab-case names are returned.
|
|
23
|
+
*/
|
|
24
|
+
export async function scanAssets(rootDir: string): Promise<DiscoveredAsset[]> {
|
|
25
|
+
const assets: DiscoveredAsset[] = []
|
|
26
|
+
|
|
27
|
+
// Scan skills: skills/*/SKILL.md
|
|
28
|
+
const skillGlob = new Glob('skills/*/SKILL.md')
|
|
29
|
+
for await (const match of skillGlob.scan({ cwd: rootDir, onlyFiles: true })) {
|
|
30
|
+
// match is e.g. 'skills/review/SKILL.md'
|
|
31
|
+
const parts = match.split('/')
|
|
32
|
+
const name = parts[1]
|
|
33
|
+
if (name && KEBAB_CASE.test(name)) {
|
|
34
|
+
assets.push({ type: 'skills', name, path: match })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Scan agents: agents/*.md
|
|
39
|
+
const agentGlob = new Glob('agents/*.md')
|
|
40
|
+
for await (const match of agentGlob.scan({ cwd: rootDir, onlyFiles: true })) {
|
|
41
|
+
const name = match.replace('agents/', '').replace('.md', '')
|
|
42
|
+
if (KEBAB_CASE.test(name)) {
|
|
43
|
+
assets.push({ type: 'agents', name, path: match })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Scan commands: commands/*.md
|
|
48
|
+
const commandGlob = new Glob('commands/*.md')
|
|
49
|
+
for await (const match of commandGlob.scan({ cwd: rootDir, onlyFiles: true })) {
|
|
50
|
+
const name = match.replace('commands/', '').replace('.md', '')
|
|
51
|
+
if (KEBAB_CASE.test(name)) {
|
|
52
|
+
assets.push({ type: 'commands', name, path: match })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return assets.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name))
|
|
57
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parse as parseYaml } from 'yaml'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Matches YAML front matter: opening `---`, YAML content, closing `---`.
|
|
5
|
+
* Anchored to start of string. Handles optional trailing content.
|
|
6
|
+
*/
|
|
7
|
+
const FRONT_MATTER_RE = /^---\n([\s\S]*?)\n---(?:\n([\s\S]*))?$/
|
|
8
|
+
|
|
9
|
+
/** Matches empty front matter: `---\n---` with optional trailing content. */
|
|
10
|
+
const EMPTY_FRONT_MATTER_RE = /^---\n---(?:\n([\s\S]*))?$/
|
|
11
|
+
|
|
12
|
+
/** Normalize BOM and line endings to LF. */
|
|
13
|
+
function normalize(raw: string): string {
|
|
14
|
+
return raw
|
|
15
|
+
.replace(/^\uFEFF/, '')
|
|
16
|
+
.replace(/\r\n/g, '\n')
|
|
17
|
+
.replace(/\r/g, '\n')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns true if the string contains YAML front matter. */
|
|
21
|
+
export function hasFrontMatter(raw: string): boolean {
|
|
22
|
+
const input = normalize(raw)
|
|
23
|
+
return FRONT_MATTER_RE.test(input) || EMPTY_FRONT_MATTER_RE.test(input)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FrontMatterResult<T = Record<string, unknown>> {
|
|
27
|
+
/** Parsed YAML attributes. Empty object if no front matter or parse failure. */
|
|
28
|
+
data: T
|
|
29
|
+
/** Markdown body with front matter stripped. Original content if no front matter. */
|
|
30
|
+
content: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extracts YAML front matter attributes and clean body from a string.
|
|
35
|
+
* Returns the original content unchanged if no front matter is found.
|
|
36
|
+
* Treats YAML parse failures as "no front matter" (returns empty data + original content).
|
|
37
|
+
*/
|
|
38
|
+
export function extractFrontMatter<T = Record<string, unknown>>(raw: string): FrontMatterResult<T> {
|
|
39
|
+
const input = normalize(raw)
|
|
40
|
+
|
|
41
|
+
const emptyMatch = input.match(EMPTY_FRONT_MATTER_RE)
|
|
42
|
+
if (emptyMatch) {
|
|
43
|
+
return { data: {} as T, content: emptyMatch[1] ?? '' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const match = input.match(FRONT_MATTER_RE)
|
|
47
|
+
if (!match) {
|
|
48
|
+
return { data: {} as T, content: raw }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const yaml = match[1] ?? ''
|
|
53
|
+
return { data: (parseYaml(yaml) ?? {}) as T, content: match[2] ?? '' }
|
|
54
|
+
} catch {
|
|
55
|
+
// Malformed YAML — treat as no front matter
|
|
56
|
+
return { data: {} as T, content: raw }
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// types
|
|
2
|
-
|
|
3
2
|
export type { ArchiveEntry } from './build/content-hash.ts'
|
|
4
3
|
export {
|
|
5
4
|
assembleTar,
|
|
@@ -9,13 +8,22 @@ export {
|
|
|
9
8
|
computeContentHash,
|
|
10
9
|
} from './build/content-hash.ts'
|
|
11
10
|
export { detectNamingCollisions } from './build/detect-collisions.ts'
|
|
12
|
-
export type { BuildFailure, BuildProgress, BuildResult } from './build/pipeline.ts'
|
|
11
|
+
export type { BuildFailure, BuildProgress, BuildResult, BuildStage } from './build/pipeline.ts'
|
|
13
12
|
// build pipeline
|
|
14
|
-
export { runBuildPipeline } from './build/pipeline.ts'
|
|
13
|
+
export { BUILD_STAGES, runBuildPipeline } from './build/pipeline.ts'
|
|
15
14
|
export { validateCompactFacets } from './build/validate-facets.ts'
|
|
16
15
|
export type { PlatformValidationResult } from './build/validate-platforms.ts'
|
|
17
16
|
export { validatePlatformConfigs } from './build/validate-platforms.ts'
|
|
18
17
|
export { writeBuildOutput } from './build/write-output.ts'
|
|
18
|
+
export { writeManifest } from './edit/manifest-writer.ts'
|
|
19
|
+
export type { MatchedAsset, MissingAsset, ReconciliationResult } from './edit/reconcile.ts'
|
|
20
|
+
export { reconcile } from './edit/reconcile.ts'
|
|
21
|
+
// edit
|
|
22
|
+
export type { AssetType, DiscoveredAsset } from './edit/scanner.ts'
|
|
23
|
+
export { KEBAB_CASE, scanAssets } from './edit/scanner.ts'
|
|
24
|
+
// front matter
|
|
25
|
+
export type { FrontMatterResult } from './front-matter.ts'
|
|
26
|
+
export { extractFrontMatter, hasFrontMatter } from './front-matter.ts'
|
|
19
27
|
export type { ResolvedFacetManifest } from './loaders/facet.ts'
|
|
20
28
|
// loaders
|
|
21
29
|
export { FACET_MANIFEST_FILE, loadManifest, resolvePrompts } from './loaders/facet.ts'
|
|
@@ -24,10 +32,7 @@ export type { BuildManifest } from './schemas/build-manifest.ts'
|
|
|
24
32
|
export { BuildManifestSchema } from './schemas/build-manifest.ts'
|
|
25
33
|
export type { FacetManifest } from './schemas/facet-manifest.ts'
|
|
26
34
|
// schemas
|
|
27
|
-
export {
|
|
28
|
-
checkFacetManifestConstraints,
|
|
29
|
-
FacetManifestSchema,
|
|
30
|
-
} from './schemas/facet-manifest.ts'
|
|
35
|
+
export { FacetManifestSchema } from './schemas/facet-manifest.ts'
|
|
31
36
|
export type { Lockfile } from './schemas/lockfile.ts'
|
|
32
37
|
export { LockfileSchema } from './schemas/lockfile.ts'
|
|
33
38
|
export type { ServerManifest } from './schemas/server-manifest.ts'
|
package/src/loaders/facet.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { type } from 'arktype'
|
|
3
|
-
import {
|
|
3
|
+
import { type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
|
|
4
4
|
import type { Result, ValidationError } from '../types.ts'
|
|
5
5
|
import { mapArkErrors, parseJson, readFile } from './validate.ts'
|
|
6
6
|
|
|
@@ -34,18 +34,13 @@ export async function loadManifest(dir: string): Promise<Result<FacetManifest>>
|
|
|
34
34
|
return { ok: false, errors: mapArkErrors(validated) }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// Phase 3: Business-rule constraints
|
|
38
|
-
const constraintErrors = checkFacetManifestConstraints(validated)
|
|
39
|
-
if (constraintErrors.length > 0) {
|
|
40
|
-
return { ok: false, errors: constraintErrors }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
37
|
return { ok: true, data: validated }
|
|
44
38
|
}
|
|
45
39
|
|
|
46
40
|
/**
|
|
47
41
|
* A manifest with all prompts resolved to their string content.
|
|
48
|
-
* File paths are derived from convention:
|
|
42
|
+
* File paths are derived from convention: skills use `skills/<name>/SKILL.md`,
|
|
43
|
+
* agents use `agents/<name>.md`, commands use `commands/<name>.md`.
|
|
49
44
|
*/
|
|
50
45
|
export interface ResolvedFacetManifest {
|
|
51
46
|
name: string
|
|
@@ -83,8 +78,9 @@ export interface ResolvedFacetManifest {
|
|
|
83
78
|
* Resolves prompt content for all skills, agents, and commands by reading
|
|
84
79
|
* files at conventional paths relative to the facet root directory.
|
|
85
80
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
81
|
+
* Skills use the Agent Skills directory convention: a skill named "code-review"
|
|
82
|
+
* resolves to `skills/code-review/SKILL.md`. Agents and commands use the flat
|
|
83
|
+
* file convention: `agents/<name>.md` and `commands/<name>.md`.
|
|
88
84
|
*
|
|
89
85
|
* This also serves as file existence verification for all three asset types —
|
|
90
86
|
* if an expected file doesn't exist, resolution fails with an error identifying
|
|
@@ -96,7 +92,7 @@ export interface ResolvedFacetManifest {
|
|
|
96
92
|
export async function resolvePrompts(manifest: FacetManifest, rootDir: string): Promise<Result<ResolvedFacetManifest>> {
|
|
97
93
|
const errors: ValidationError[] = []
|
|
98
94
|
|
|
99
|
-
// Resolve skill prompts from skills/<name
|
|
95
|
+
// Resolve skill prompts from skills/<name>/SKILL.md
|
|
100
96
|
let resolvedSkills: ResolvedFacetManifest['skills'] | undefined
|
|
101
97
|
if (manifest.skills) {
|
|
102
98
|
resolvedSkills = {}
|
|
@@ -158,11 +154,13 @@ export async function resolvePrompts(manifest: FacetManifest, rootDir: string):
|
|
|
158
154
|
}
|
|
159
155
|
|
|
160
156
|
/**
|
|
161
|
-
* Resolves prompt content for a single asset by reading
|
|
157
|
+
* Resolves prompt content for a single asset by reading the file at its
|
|
158
|
+
* conventional path. Skills use `skills/<name>/SKILL.md` (Agent Skills
|
|
159
|
+
* directory convention). Agents and commands use `<type>/<name>.md`.
|
|
162
160
|
* Returns the file content as a string, or a ValidationError if the file doesn't exist.
|
|
163
161
|
*/
|
|
164
162
|
async function resolveAssetPrompt(assetType: string, name: string, rootDir: string): Promise<string | ValidationError> {
|
|
165
|
-
const relativePath = `${assetType}/${name}.md`
|
|
163
|
+
const relativePath = assetType === 'skills' ? `${assetType}/${name}/SKILL.md` : `${assetType}/${name}.md`
|
|
166
164
|
const filePath = join(rootDir, relativePath)
|
|
167
165
|
const file = Bun.file(filePath)
|
|
168
166
|
const exists = await file.exists()
|
package/src/loaders/validate.ts
CHANGED
|
@@ -8,9 +8,11 @@ import type { ValidationError } from '../types.ts'
|
|
|
8
8
|
export function mapArkErrors(errors: InstanceType<typeof type.errors>): ValidationError[] {
|
|
9
9
|
return errors.map((err) => ({
|
|
10
10
|
path: err.path.join('.'),
|
|
11
|
-
|
|
11
|
+
// For predicate errors (.narrow()), err.message includes the full data object.
|
|
12
|
+
// Use err.expected directly — it's our clean sentence from ctx.mustBe().
|
|
13
|
+
message: err.code === 'predicate' ? (err.expected ?? err.message) : err.message,
|
|
12
14
|
expected: err.expected ?? 'unknown',
|
|
13
|
-
actual: err.actual ?? 'unknown',
|
|
15
|
+
actual: err.code === 'predicate' ? 'constraint not met' : (err.actual ?? 'unknown'),
|
|
14
16
|
}))
|
|
15
17
|
}
|
|
16
18
|
|