@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 +20 -0
- package/bunfig.toml +2 -0
- package/package.json +32 -0
- package/src/__tests__/build-pipeline.test.ts +427 -0
- package/src/__tests__/content-hash.test.ts +226 -0
- package/src/__tests__/facet-loader.test.ts +264 -0
- package/src/__tests__/facet-manifest.test.ts +208 -0
- package/src/__tests__/lockfile.test.ts +166 -0
- package/src/__tests__/server-loader.test.ts +99 -0
- package/src/__tests__/server-manifest.test.ts +92 -0
- package/src/build/content-hash.ts +102 -0
- package/src/build/detect-collisions.ts +36 -0
- package/src/build/pipeline.ts +120 -0
- package/src/build/validate-facets.ts +34 -0
- package/src/build/validate-platforms.ts +89 -0
- package/src/build/write-output.ts +34 -0
- package/src/index.ts +35 -0
- package/src/loaders/facet.ts +180 -0
- package/src/loaders/server.ts +37 -0
- package/src/loaders/validate.ts +64 -0
- package/src/schemas/build-manifest.ts +15 -0
- package/src/schemas/facet-manifest.ts +113 -0
- package/src/schemas/lockfile.ts +37 -0
- package/src/schemas/server-manifest.ts +17 -0
- package/src/types.ts +20 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type } from 'arktype'
|
|
2
|
+
import type { FacetManifest } from '../schemas/facet-manifest.ts'
|
|
3
|
+
import type { ValidationError } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
// --- Known platform schemas ---
|
|
6
|
+
|
|
7
|
+
/** OpenCode platform config schema */
|
|
8
|
+
const OpenCodePlatformSchema = type({
|
|
9
|
+
'tools?': type.Record('string', 'boolean'),
|
|
10
|
+
'model?': 'string',
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
/** Claude Code platform config schema */
|
|
14
|
+
const ClaudeCodePlatformSchema = type({
|
|
15
|
+
'tools?': type.Record('string', 'boolean'),
|
|
16
|
+
'permissions?': type.Record('string', 'boolean'),
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
/** Map of known platform names to their ArkType validators */
|
|
20
|
+
const KNOWN_PLATFORMS: Record<string, (data: unknown) => unknown> = {
|
|
21
|
+
opencode: (data) => OpenCodePlatformSchema(data),
|
|
22
|
+
'claude-code': (data) => ClaudeCodePlatformSchema(data),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PlatformValidationResult {
|
|
26
|
+
errors: ValidationError[]
|
|
27
|
+
warnings: string[]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validates platform configuration for all assets that declare `platforms`.
|
|
32
|
+
* Known platforms are validated against their schema — invalid config is an error.
|
|
33
|
+
* Unknown platforms produce a warning but do not cause failure.
|
|
34
|
+
*/
|
|
35
|
+
export function validatePlatformConfigs(manifest: FacetManifest): PlatformValidationResult {
|
|
36
|
+
const errors: ValidationError[] = []
|
|
37
|
+
const warnings: string[] = []
|
|
38
|
+
|
|
39
|
+
// Check skills
|
|
40
|
+
if (manifest.skills) {
|
|
41
|
+
for (const [name, skill] of Object.entries(manifest.skills)) {
|
|
42
|
+
if (skill.platforms) {
|
|
43
|
+
validateAssetPlatforms(`skills.${name}`, skill.platforms, errors, warnings)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check agents
|
|
49
|
+
if (manifest.agents) {
|
|
50
|
+
for (const [name, agent] of Object.entries(manifest.agents)) {
|
|
51
|
+
if (agent.platforms) {
|
|
52
|
+
validateAssetPlatforms(`agents.${name}`, agent.platforms, errors, warnings)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Commands don't have platforms in the current schema, but if they ever do,
|
|
58
|
+
// they'd be validated here uniformly.
|
|
59
|
+
|
|
60
|
+
return { errors, warnings }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateAssetPlatforms(
|
|
64
|
+
assetPath: string,
|
|
65
|
+
platforms: Record<string, unknown>,
|
|
66
|
+
errors: ValidationError[],
|
|
67
|
+
warnings: string[],
|
|
68
|
+
): void {
|
|
69
|
+
for (const [platformName, config] of Object.entries(platforms)) {
|
|
70
|
+
const validator = KNOWN_PLATFORMS[platformName]
|
|
71
|
+
|
|
72
|
+
if (!validator) {
|
|
73
|
+
warnings.push(`${assetPath}: unknown platform "${platformName}" — config will not be validated`)
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const result = validator(config)
|
|
78
|
+
if (result instanceof type.errors) {
|
|
79
|
+
for (const err of result) {
|
|
80
|
+
errors.push({
|
|
81
|
+
path: `${assetPath}.platforms.${platformName}.${err.path.join('.')}`,
|
|
82
|
+
message: `Invalid platform config for "${platformName}" on ${assetPath}: ${err.message}`,
|
|
83
|
+
expected: err.expected,
|
|
84
|
+
actual: String(err.actual),
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { mkdir, rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { BuildManifest } from '../schemas/build-manifest.ts'
|
|
4
|
+
import type { BuildResult } from './pipeline.ts'
|
|
5
|
+
|
|
6
|
+
const DIST_DIR = 'dist'
|
|
7
|
+
const BUILD_MANIFEST_FILE = 'build-manifest.json'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Writes the build output to dist/.
|
|
11
|
+
*
|
|
12
|
+
* - Cleans (removes and recreates) the dist/ directory
|
|
13
|
+
* - Writes the .facet archive (gzip-compressed tar)
|
|
14
|
+
* - Writes build-manifest.json with integrity hash and per-asset hashes
|
|
15
|
+
*/
|
|
16
|
+
export async function writeBuildOutput(result: BuildResult, rootDir: string): Promise<void> {
|
|
17
|
+
const distDir = join(rootDir, DIST_DIR)
|
|
18
|
+
|
|
19
|
+
// Clean previous output
|
|
20
|
+
await rm(distDir, { recursive: true, force: true })
|
|
21
|
+
await mkdir(distDir, { recursive: true })
|
|
22
|
+
|
|
23
|
+
// Write the .facet archive
|
|
24
|
+
await Bun.write(join(distDir, result.archiveFilename), result.archiveBytes)
|
|
25
|
+
|
|
26
|
+
// Write build manifest
|
|
27
|
+
const manifest: BuildManifest = {
|
|
28
|
+
facetVersion: 1,
|
|
29
|
+
archive: result.archiveFilename,
|
|
30
|
+
integrity: result.integrity,
|
|
31
|
+
assets: result.assetHashes,
|
|
32
|
+
}
|
|
33
|
+
await Bun.write(join(distDir, BUILD_MANIFEST_FILE), JSON.stringify(manifest, null, 2))
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// types
|
|
2
|
+
|
|
3
|
+
export type { ArchiveEntry } from './build/content-hash.ts'
|
|
4
|
+
export {
|
|
5
|
+
assembleTar,
|
|
6
|
+
collectArchiveEntries,
|
|
7
|
+
compressArchive,
|
|
8
|
+
computeAssetHashes,
|
|
9
|
+
computeContentHash,
|
|
10
|
+
} from './build/content-hash.ts'
|
|
11
|
+
export { detectNamingCollisions } from './build/detect-collisions.ts'
|
|
12
|
+
export type { BuildFailure, BuildProgress, BuildResult } from './build/pipeline.ts'
|
|
13
|
+
// build pipeline
|
|
14
|
+
export { runBuildPipeline } from './build/pipeline.ts'
|
|
15
|
+
export { validateCompactFacets } from './build/validate-facets.ts'
|
|
16
|
+
export type { PlatformValidationResult } from './build/validate-platforms.ts'
|
|
17
|
+
export { validatePlatformConfigs } from './build/validate-platforms.ts'
|
|
18
|
+
export { writeBuildOutput } from './build/write-output.ts'
|
|
19
|
+
export type { ResolvedFacetManifest } from './loaders/facet.ts'
|
|
20
|
+
// loaders
|
|
21
|
+
export { FACET_MANIFEST_FILE, loadManifest, resolvePrompts } from './loaders/facet.ts'
|
|
22
|
+
export { loadServerManifest } from './loaders/server.ts'
|
|
23
|
+
export type { BuildManifest } from './schemas/build-manifest.ts'
|
|
24
|
+
export { BuildManifestSchema } from './schemas/build-manifest.ts'
|
|
25
|
+
export type { FacetManifest } from './schemas/facet-manifest.ts'
|
|
26
|
+
// schemas
|
|
27
|
+
export {
|
|
28
|
+
checkFacetManifestConstraints,
|
|
29
|
+
FacetManifestSchema,
|
|
30
|
+
} from './schemas/facet-manifest.ts'
|
|
31
|
+
export type { Lockfile } from './schemas/lockfile.ts'
|
|
32
|
+
export { LockfileSchema } from './schemas/lockfile.ts'
|
|
33
|
+
export type { ServerManifest } from './schemas/server-manifest.ts'
|
|
34
|
+
export { ServerManifestSchema } from './schemas/server-manifest.ts'
|
|
35
|
+
export type { Result, ValidationError } from './types.ts'
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { type } from 'arktype'
|
|
3
|
+
import { checkFacetManifestConstraints, type FacetManifest, FacetManifestSchema } from '../schemas/facet-manifest.ts'
|
|
4
|
+
import type { Result, ValidationError } from '../types.ts'
|
|
5
|
+
import { mapArkErrors, parseJson, readFile } from './validate.ts'
|
|
6
|
+
|
|
7
|
+
export const FACET_MANIFEST_FILE = 'facet.json'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Loads and validates a facet manifest from the specified directory.
|
|
11
|
+
*
|
|
12
|
+
* Reads the facet manifest, parses JSON, validates against the schema, and checks
|
|
13
|
+
* business-rule constraints. Returns a discriminated result — either the
|
|
14
|
+
* validated manifest or structured errors.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadManifest(dir: string): Promise<Result<FacetManifest>> {
|
|
17
|
+
const filePath = join(dir, FACET_MANIFEST_FILE)
|
|
18
|
+
|
|
19
|
+
// Phase 0: Read the file
|
|
20
|
+
const fileResult = await readFile(filePath)
|
|
21
|
+
if (!fileResult.ok) {
|
|
22
|
+
return fileResult
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Phase 1: Parse JSON
|
|
26
|
+
const jsonResult = parseJson(fileResult.content)
|
|
27
|
+
if (!jsonResult.ok) {
|
|
28
|
+
return jsonResult
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Phase 2: Schema validation
|
|
32
|
+
const validated = FacetManifestSchema(jsonResult.data)
|
|
33
|
+
if (validated instanceof type.errors) {
|
|
34
|
+
return { ok: false, errors: mapArkErrors(validated) }
|
|
35
|
+
}
|
|
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
|
+
return { ok: true, data: validated }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A manifest with all prompts resolved to their string content.
|
|
48
|
+
* File paths are derived from convention: `<type>/<name>.md`.
|
|
49
|
+
*/
|
|
50
|
+
export interface ResolvedFacetManifest {
|
|
51
|
+
name: string
|
|
52
|
+
version: string
|
|
53
|
+
description?: string
|
|
54
|
+
author?: string
|
|
55
|
+
skills?: Record<
|
|
56
|
+
string,
|
|
57
|
+
{
|
|
58
|
+
description: string
|
|
59
|
+
prompt: string
|
|
60
|
+
platforms?: Record<string, unknown>
|
|
61
|
+
}
|
|
62
|
+
>
|
|
63
|
+
agents?: Record<
|
|
64
|
+
string,
|
|
65
|
+
{
|
|
66
|
+
description: string
|
|
67
|
+
prompt: string
|
|
68
|
+
platforms?: Record<string, unknown>
|
|
69
|
+
}
|
|
70
|
+
>
|
|
71
|
+
commands?: Record<
|
|
72
|
+
string,
|
|
73
|
+
{
|
|
74
|
+
description: string
|
|
75
|
+
prompt: string
|
|
76
|
+
}
|
|
77
|
+
>
|
|
78
|
+
facets?: FacetManifest['facets']
|
|
79
|
+
servers?: FacetManifest['servers']
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Resolves prompt content for all skills, agents, and commands by reading
|
|
84
|
+
* files at conventional paths relative to the facet root directory.
|
|
85
|
+
*
|
|
86
|
+
* The convention is `<type>/<name>.md` — for example, a skill named
|
|
87
|
+
* "code-review" resolves to `skills/code-review.md`.
|
|
88
|
+
*
|
|
89
|
+
* This also serves as file existence verification for all three asset types —
|
|
90
|
+
* if an expected file doesn't exist, resolution fails with an error identifying
|
|
91
|
+
* the asset and the expected file path.
|
|
92
|
+
*
|
|
93
|
+
* Returns a new manifest with all prompts resolved to strings, or an error
|
|
94
|
+
* result identifying which prompt failed and why.
|
|
95
|
+
*/
|
|
96
|
+
export async function resolvePrompts(manifest: FacetManifest, rootDir: string): Promise<Result<ResolvedFacetManifest>> {
|
|
97
|
+
const errors: ValidationError[] = []
|
|
98
|
+
|
|
99
|
+
// Resolve skill prompts from skills/<name>.md
|
|
100
|
+
let resolvedSkills: ResolvedFacetManifest['skills'] | undefined
|
|
101
|
+
if (manifest.skills) {
|
|
102
|
+
resolvedSkills = {}
|
|
103
|
+
for (const [name, skill] of Object.entries(manifest.skills)) {
|
|
104
|
+
const resolvedPrompt = await resolveAssetPrompt('skills', name, rootDir)
|
|
105
|
+
if (typeof resolvedPrompt === 'string') {
|
|
106
|
+
resolvedSkills[name] = { ...skill, prompt: resolvedPrompt }
|
|
107
|
+
} else {
|
|
108
|
+
errors.push(resolvedPrompt)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Resolve agent prompts from agents/<name>.md
|
|
114
|
+
let resolvedAgents: ResolvedFacetManifest['agents'] | undefined
|
|
115
|
+
if (manifest.agents) {
|
|
116
|
+
resolvedAgents = {}
|
|
117
|
+
for (const [name, agent] of Object.entries(manifest.agents)) {
|
|
118
|
+
const resolvedPrompt = await resolveAssetPrompt('agents', name, rootDir)
|
|
119
|
+
if (typeof resolvedPrompt === 'string') {
|
|
120
|
+
resolvedAgents[name] = { ...agent, prompt: resolvedPrompt }
|
|
121
|
+
} else {
|
|
122
|
+
errors.push(resolvedPrompt)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Resolve command prompts from commands/<name>.md
|
|
128
|
+
let resolvedCommands: ResolvedFacetManifest['commands'] | undefined
|
|
129
|
+
if (manifest.commands) {
|
|
130
|
+
resolvedCommands = {}
|
|
131
|
+
for (const [name, command] of Object.entries(manifest.commands)) {
|
|
132
|
+
const resolvedPrompt = await resolveAssetPrompt('commands', name, rootDir)
|
|
133
|
+
if (typeof resolvedPrompt === 'string') {
|
|
134
|
+
resolvedCommands[name] = { ...command, prompt: resolvedPrompt }
|
|
135
|
+
} else {
|
|
136
|
+
errors.push(resolvedPrompt)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (errors.length > 0) {
|
|
142
|
+
return { ok: false, errors }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const resolved: ResolvedFacetManifest = {
|
|
146
|
+
name: manifest.name,
|
|
147
|
+
version: manifest.version,
|
|
148
|
+
...(manifest.description !== undefined && { description: manifest.description }),
|
|
149
|
+
...(manifest.author !== undefined && { author: manifest.author }),
|
|
150
|
+
...(resolvedSkills !== undefined && { skills: resolvedSkills }),
|
|
151
|
+
...(resolvedAgents !== undefined && { agents: resolvedAgents }),
|
|
152
|
+
...(resolvedCommands !== undefined && { commands: resolvedCommands }),
|
|
153
|
+
...(manifest.facets !== undefined && { facets: manifest.facets }),
|
|
154
|
+
...(manifest.servers !== undefined && { servers: manifest.servers }),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { ok: true, data: resolved }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolves prompt content for a single asset by reading <type>/<name>.md.
|
|
162
|
+
* Returns the file content as a string, or a ValidationError if the file doesn't exist.
|
|
163
|
+
*/
|
|
164
|
+
async function resolveAssetPrompt(assetType: string, name: string, rootDir: string): Promise<string | ValidationError> {
|
|
165
|
+
const relativePath = `${assetType}/${name}.md`
|
|
166
|
+
const filePath = join(rootDir, relativePath)
|
|
167
|
+
const file = Bun.file(filePath)
|
|
168
|
+
const exists = await file.exists()
|
|
169
|
+
|
|
170
|
+
if (!exists) {
|
|
171
|
+
return {
|
|
172
|
+
path: `${assetType}.${name}`,
|
|
173
|
+
message: `Prompt file not found: ${relativePath} (resolved to ${filePath})`,
|
|
174
|
+
expected: 'file to exist',
|
|
175
|
+
actual: 'file not found',
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return file.text()
|
|
180
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { type } from 'arktype'
|
|
3
|
+
import { type ServerManifest, ServerManifestSchema } from '../schemas/server-manifest.ts'
|
|
4
|
+
import type { Result } from '../types.ts'
|
|
5
|
+
import { mapArkErrors, parseJson, readFile } from './validate.ts'
|
|
6
|
+
|
|
7
|
+
const SERVER_MANIFEST_FILE = 'server.json'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Loads and validates a server manifest from the specified directory.
|
|
11
|
+
*
|
|
12
|
+
* Reads the server manifest, parses JSON, validates against the schema, and returns
|
|
13
|
+
* a discriminated result — either the validated manifest or structured errors.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadServerManifest(dir: string): Promise<Result<ServerManifest>> {
|
|
16
|
+
const filePath = join(dir, SERVER_MANIFEST_FILE)
|
|
17
|
+
|
|
18
|
+
// Phase 0: Read the file
|
|
19
|
+
const fileResult = await readFile(filePath)
|
|
20
|
+
if (!fileResult.ok) {
|
|
21
|
+
return fileResult
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Phase 1: Parse JSON
|
|
25
|
+
const jsonResult = parseJson(fileResult.content)
|
|
26
|
+
if (!jsonResult.ok) {
|
|
27
|
+
return jsonResult
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Phase 2: Schema validation
|
|
31
|
+
const validated = ServerManifestSchema(jsonResult.data)
|
|
32
|
+
if (validated instanceof type.errors) {
|
|
33
|
+
return { ok: false, errors: mapArkErrors(validated) }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { ok: true, data: validated }
|
|
37
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { type } from 'arktype'
|
|
2
|
+
import type { ValidationError } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps ArkType errors to our public ValidationError type.
|
|
6
|
+
* Decouples the public API from ArkType internals.
|
|
7
|
+
*/
|
|
8
|
+
export function mapArkErrors(errors: InstanceType<typeof type.errors>): ValidationError[] {
|
|
9
|
+
return errors.map((err) => ({
|
|
10
|
+
path: err.path.join('.'),
|
|
11
|
+
message: err.message,
|
|
12
|
+
expected: err.expected ?? 'unknown',
|
|
13
|
+
actual: err.actual ?? 'unknown',
|
|
14
|
+
}))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses a JSON string. Returns the parsed data or a ValidationError array.
|
|
19
|
+
*/
|
|
20
|
+
export function parseJson(jsonContent: string): { ok: true; data: unknown } | { ok: false; errors: ValidationError[] } {
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(jsonContent)
|
|
23
|
+
return { ok: true, data: parsed }
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const message = err instanceof SyntaxError ? err.message : 'Unknown JSON parse error'
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
errors: [
|
|
29
|
+
{
|
|
30
|
+
path: '',
|
|
31
|
+
message: `JSON syntax error: ${message}`,
|
|
32
|
+
expected: 'valid JSON',
|
|
33
|
+
actual: 'malformed JSON',
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads a file from disk. Returns the text content or a ValidationError array.
|
|
42
|
+
*/
|
|
43
|
+
export async function readFile(
|
|
44
|
+
filePath: string,
|
|
45
|
+
): Promise<{ ok: true; content: string } | { ok: false; errors: ValidationError[] }> {
|
|
46
|
+
const file = Bun.file(filePath)
|
|
47
|
+
const exists = await file.exists()
|
|
48
|
+
if (!exists) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
errors: [
|
|
52
|
+
{
|
|
53
|
+
path: '',
|
|
54
|
+
message: `File not found: ${filePath}`,
|
|
55
|
+
expected: 'file to exist',
|
|
56
|
+
actual: 'file not found',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const content = await file.text()
|
|
63
|
+
return { ok: true, content }
|
|
64
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type } from 'arktype'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for the build manifest (build-manifest.json).
|
|
5
|
+
* Written by `facet build` alongside the .facet archive.
|
|
6
|
+
*/
|
|
7
|
+
export const BuildManifestSchema = type({
|
|
8
|
+
facetVersion: 'number',
|
|
9
|
+
archive: 'string',
|
|
10
|
+
integrity: /^sha256:[a-f0-9]{64}$/,
|
|
11
|
+
assets: type.Record('string', 'string'),
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
/** Inferred TypeScript type for a validated build manifest */
|
|
15
|
+
export type BuildManifest = typeof BuildManifestSchema.infer
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type } from 'arktype'
|
|
2
|
+
|
|
3
|
+
// --- Sub-schemas ---
|
|
4
|
+
|
|
5
|
+
/** Skill descriptor — description is required, prompt resolved from skills/<name>.md */
|
|
6
|
+
const SkillDescriptor = type({
|
|
7
|
+
description: 'string',
|
|
8
|
+
'platforms?': type.Record('string', 'unknown'),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
/** Agent descriptor — description is required, prompt resolved from agents/<name>.md */
|
|
12
|
+
const AgentDescriptor = type({
|
|
13
|
+
description: 'string',
|
|
14
|
+
'platforms?': type.Record('string', 'unknown'),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Command descriptor — description is required, prompt resolved from commands/<name>.md */
|
|
18
|
+
const CommandDescriptor = type({
|
|
19
|
+
description: 'string',
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** Selective facets entry — cherry-pick specific assets from another facet */
|
|
23
|
+
const SelectiveFacetsEntry = type({
|
|
24
|
+
name: 'string',
|
|
25
|
+
version: 'string',
|
|
26
|
+
'skills?': 'string[]',
|
|
27
|
+
'agents?': 'string[]',
|
|
28
|
+
'commands?': 'string[]',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** Facets entry: compact string ("name@version") or selective object */
|
|
32
|
+
const FacetsEntry = type('string').or(SelectiveFacetsEntry)
|
|
33
|
+
|
|
34
|
+
/** Server reference: source-mode (floor version string) or ref-mode (OCI image object) */
|
|
35
|
+
const ServerReference = type('string').or({ image: 'string' })
|
|
36
|
+
|
|
37
|
+
// --- Main schema ---
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The structural schema for the facet manifest — validates shape only.
|
|
41
|
+
* Custom constraints (at least one text asset, selective entry must select at least one type)
|
|
42
|
+
* are checked post-validation by checkFacetManifestConstraints().
|
|
43
|
+
*/
|
|
44
|
+
export const FacetManifestSchema = type({
|
|
45
|
+
name: 'string',
|
|
46
|
+
version: 'string',
|
|
47
|
+
'description?': 'string',
|
|
48
|
+
'author?': 'string',
|
|
49
|
+
'skills?': type.Record('string', SkillDescriptor),
|
|
50
|
+
'agents?': type.Record('string', AgentDescriptor),
|
|
51
|
+
'commands?': type.Record('string', CommandDescriptor),
|
|
52
|
+
'facets?': FacetsEntry.array(),
|
|
53
|
+
'servers?': type.Record('string', ServerReference),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Inferred TypeScript type for a validated facet manifest */
|
|
57
|
+
export type FacetManifest = typeof FacetManifestSchema.infer
|
|
58
|
+
|
|
59
|
+
// --- Custom validation ---
|
|
60
|
+
|
|
61
|
+
export interface FacetManifestError {
|
|
62
|
+
path: string
|
|
63
|
+
message: string
|
|
64
|
+
expected: string
|
|
65
|
+
actual: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Checks business-rule constraints that ArkType's structural validation cannot express:
|
|
70
|
+
* 1. At least one text asset must be present (skills, agents, commands, or facets)
|
|
71
|
+
* 2. Selective facets entries must include at least one asset type
|
|
72
|
+
*/
|
|
73
|
+
export function checkFacetManifestConstraints(manifest: FacetManifest): FacetManifestError[] {
|
|
74
|
+
const errors: FacetManifestError[] = []
|
|
75
|
+
|
|
76
|
+
// Constraint 1: at least one text asset
|
|
77
|
+
const hasSkills = manifest.skills && Object.keys(manifest.skills).length > 0
|
|
78
|
+
const hasAgents = manifest.agents && Object.keys(manifest.agents).length > 0
|
|
79
|
+
const hasCommands = manifest.commands && Object.keys(manifest.commands).length > 0
|
|
80
|
+
const hasFacets = manifest.facets && manifest.facets.length > 0
|
|
81
|
+
|
|
82
|
+
if (!hasSkills && !hasAgents && !hasCommands && !hasFacets) {
|
|
83
|
+
errors.push({
|
|
84
|
+
path: '',
|
|
85
|
+
message: 'Manifest must include at least one text asset (skills, agents, commands, or facets)',
|
|
86
|
+
expected: 'at least one of: skills, agents, commands, facets',
|
|
87
|
+
actual: 'none present',
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Constraint 2: selective facets entries must select at least one asset type
|
|
92
|
+
if (manifest.facets) {
|
|
93
|
+
for (let i = 0; i < manifest.facets.length; i++) {
|
|
94
|
+
const entry = manifest.facets[i]
|
|
95
|
+
if (typeof entry === 'object') {
|
|
96
|
+
const hasSelectedSkills = entry.skills && entry.skills.length > 0
|
|
97
|
+
const hasSelectedAgents = entry.agents && entry.agents.length > 0
|
|
98
|
+
const hasSelectedCommands = entry.commands && entry.commands.length > 0
|
|
99
|
+
|
|
100
|
+
if (!hasSelectedSkills && !hasSelectedAgents && !hasSelectedCommands) {
|
|
101
|
+
errors.push({
|
|
102
|
+
path: `facets[${i}]`,
|
|
103
|
+
message: 'Selective facets entry must include at least one asset type (skills, agents, or commands)',
|
|
104
|
+
expected: 'at least one of: skills, agents, commands',
|
|
105
|
+
actual: 'none selected',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return errors
|
|
113
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type } from 'arktype'
|
|
2
|
+
|
|
3
|
+
/** Facet identity section of the lockfile */
|
|
4
|
+
const LockfileFacet = type({
|
|
5
|
+
name: 'string',
|
|
6
|
+
version: 'string',
|
|
7
|
+
integrity: 'string',
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/** Source-mode server entry — resolved from the facets registry */
|
|
11
|
+
const SourceModeServerEntry = type({
|
|
12
|
+
version: 'string',
|
|
13
|
+
integrity: 'string',
|
|
14
|
+
api_surface: 'string',
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
/** Ref-mode server entry — resolved from an OCI registry */
|
|
18
|
+
const RefModeServerEntry = type({
|
|
19
|
+
image: 'string',
|
|
20
|
+
digest: 'string',
|
|
21
|
+
api_surface: 'string',
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
/** A lockfile server entry is either source-mode or ref-mode */
|
|
25
|
+
const ServerEntry = SourceModeServerEntry.or(RefModeServerEntry)
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Schema for facets.lock — the lockfile recording resolved installation state.
|
|
29
|
+
* Matches the shape defined in ADR-003.
|
|
30
|
+
*/
|
|
31
|
+
export const LockfileSchema = type({
|
|
32
|
+
facet: LockfileFacet,
|
|
33
|
+
'servers?': type.Record('string', ServerEntry),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** Inferred TypeScript type for a validated lockfile */
|
|
37
|
+
export type Lockfile = typeof LockfileSchema.infer
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type } from 'arktype'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for the server manifest (server.json).
|
|
5
|
+
* Matches the shape defined in ADR-005.
|
|
6
|
+
*/
|
|
7
|
+
export const ServerManifestSchema = type({
|
|
8
|
+
name: 'string',
|
|
9
|
+
version: 'string',
|
|
10
|
+
runtime: 'string',
|
|
11
|
+
entry: 'string',
|
|
12
|
+
'description?': 'string',
|
|
13
|
+
'author?': 'string',
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/** Inferred TypeScript type for a validated server manifest */
|
|
17
|
+
export type ServerManifest = typeof ServerManifestSchema.infer
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A structured validation error decoupled from ArkType internals.
|
|
3
|
+
* Used by all loaders to report schema and parsing failures.
|
|
4
|
+
*/
|
|
5
|
+
export interface ValidationError {
|
|
6
|
+
/** Dot-separated path to the invalid field (e.g., "agents.reviewer.prompt") */
|
|
7
|
+
path: string
|
|
8
|
+
/** Human-readable error message */
|
|
9
|
+
message: string
|
|
10
|
+
/** What was expected at this location */
|
|
11
|
+
expected: string
|
|
12
|
+
/** What was actually found */
|
|
13
|
+
actual: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Discriminated result type returned by all loaders.
|
|
18
|
+
* Callers check `ok` to determine success or failure.
|
|
19
|
+
*/
|
|
20
|
+
export type Result<T> = { ok: true; data: T } | { ok: false; errors: ValidationError[] }
|
package/tsconfig.json
ADDED