@ex-machina/facets 0.1.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/package.json +27 -0
- package/src/__tests__/e2e.test.ts +129 -0
- package/src/cli/init.ts +58 -0
- package/src/cli.ts +206 -0
- package/src/discovery/__tests__/list.test.ts +114 -0
- package/src/discovery/cache.ts +218 -0
- package/src/discovery/clear.ts +15 -0
- package/src/discovery/list.ts +129 -0
- package/src/index.ts +27 -0
- package/src/installation/__tests__/install.test.ts +210 -0
- package/src/installation/install.ts +232 -0
- package/src/installation/status.ts +42 -0
- package/src/installation/uninstall.ts +116 -0
- package/src/registry/__tests__/loader.test.ts +67 -0
- package/src/registry/__tests__/schemas.test.ts +206 -0
- package/src/registry/files.ts +60 -0
- package/src/registry/loader.ts +42 -0
- package/src/registry/schemas.ts +119 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import yaml from "js-yaml"
|
|
3
|
+
import { loadManifest } from "../registry/loader.ts"
|
|
4
|
+
import { localFacetsDir } from "../registry/files.ts"
|
|
5
|
+
import { getCacheDir } from "../discovery/cache.ts"
|
|
6
|
+
import { resolvePromptPath, normalizeRequires, type FacetManifest } from "../registry/schemas.ts"
|
|
7
|
+
|
|
8
|
+
// --- Prerequisite checking ---
|
|
9
|
+
|
|
10
|
+
const PREREQ_CONFIRMED_DIR = `${process.env.XDG_STATE_HOME ?? `${process.env.HOME}/.local/state`}/facets/prereqs`
|
|
11
|
+
|
|
12
|
+
interface PrereqSuccess {
|
|
13
|
+
success: true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PrereqFailure {
|
|
17
|
+
success: false
|
|
18
|
+
command: string
|
|
19
|
+
exitCode: number
|
|
20
|
+
output: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type PrereqResult = PrereqSuccess | PrereqFailure
|
|
24
|
+
|
|
25
|
+
async function isPrereqConfirmed(facetName: string): Promise<boolean> {
|
|
26
|
+
return Bun.file(`${PREREQ_CONFIRMED_DIR}/${facetName}`).exists()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function markPrereqConfirmed(facetName: string): Promise<void> {
|
|
30
|
+
await Bun.$`mkdir -p ${PREREQ_CONFIRMED_DIR}`
|
|
31
|
+
await Bun.write(`${PREREQ_CONFIRMED_DIR}/${facetName}`, new Date().toISOString())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runPrereqChecks(commands: string[]): Promise<PrereqResult> {
|
|
35
|
+
for (const command of commands) {
|
|
36
|
+
const result = await Bun.$`${{ raw: command }}`.nothrow().quiet()
|
|
37
|
+
if (result.exitCode !== 0) {
|
|
38
|
+
return {
|
|
39
|
+
success: false,
|
|
40
|
+
command,
|
|
41
|
+
exitCode: result.exitCode,
|
|
42
|
+
output: (result.stderr.toString() + result.stdout.toString()).trim(),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { success: true }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- Agent/Command frontmatter assembly ---
|
|
50
|
+
|
|
51
|
+
interface AgentFrontmatter {
|
|
52
|
+
description?: string
|
|
53
|
+
tools?: Record<string, boolean> | string[]
|
|
54
|
+
[key: string]: unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function assembleAgentFile(promptBody: string, descriptor: NonNullable<FacetManifest["agents"]>[string]): string {
|
|
58
|
+
const fm: AgentFrontmatter = {}
|
|
59
|
+
if (descriptor.description) fm.description = descriptor.description
|
|
60
|
+
const opencode = descriptor.platforms?.opencode
|
|
61
|
+
if (opencode?.tools) fm.tools = opencode.tools
|
|
62
|
+
|
|
63
|
+
const frontmatter = yaml.dump(fm, { lineWidth: -1, noRefs: true }).trim()
|
|
64
|
+
return `---\n${frontmatter}\n---\n\n${promptBody}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assembleCommandFile(promptBody: string, descriptor: NonNullable<FacetManifest["commands"]>[string]): string {
|
|
68
|
+
const fm: Record<string, unknown> = {}
|
|
69
|
+
if (descriptor.description) fm.description = descriptor.description
|
|
70
|
+
|
|
71
|
+
const frontmatter = yaml.dump(fm, { lineWidth: -1, noRefs: true }).trim()
|
|
72
|
+
return `---\n${frontmatter}\n---\n\n${promptBody}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- Result types ---
|
|
76
|
+
|
|
77
|
+
export interface InstallSuccess {
|
|
78
|
+
success: true
|
|
79
|
+
facet: string
|
|
80
|
+
resources: { name: string; type: string }[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface InstallNotFound {
|
|
84
|
+
success: false
|
|
85
|
+
facet: string
|
|
86
|
+
reason: "not_found"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface InstallPrereqFailure {
|
|
90
|
+
success: false
|
|
91
|
+
facet: string
|
|
92
|
+
reason: "prereq"
|
|
93
|
+
failure: { command: string; exitCode: number; output: string }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface InstallCopyFailure {
|
|
97
|
+
success: false
|
|
98
|
+
facet: string
|
|
99
|
+
reason: "copy"
|
|
100
|
+
error: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type InstallResult = InstallSuccess | InstallNotFound | InstallPrereqFailure | InstallCopyFailure
|
|
104
|
+
|
|
105
|
+
// --- Options ---
|
|
106
|
+
|
|
107
|
+
export interface InstallOptions {
|
|
108
|
+
/** If true, skip interactive prereq approval (for programmatic use) */
|
|
109
|
+
skipPrereqApproval?: boolean
|
|
110
|
+
/** If true, force re-run prereq checks even if already confirmed */
|
|
111
|
+
forcePrereqCheck?: boolean
|
|
112
|
+
/** Callback to ask user for prereq approval. Returns true if approved. */
|
|
113
|
+
onPrereqApproval?: (commands: string[]) => Promise<boolean>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve the facet directory — checks local first, then cache.
|
|
118
|
+
*/
|
|
119
|
+
async function resolveFacetDir(name: string, projectRoot: string): Promise<string | null> {
|
|
120
|
+
const localDir = `${localFacetsDir(projectRoot)}/${name}`
|
|
121
|
+
const localManifest = `${localDir}/facet.yaml`
|
|
122
|
+
if (await Bun.file(localManifest).exists()) return localDir
|
|
123
|
+
|
|
124
|
+
const cacheDir = getCacheDir(name)
|
|
125
|
+
const cacheManifest = `${cacheDir}/facet.yaml`
|
|
126
|
+
if (await Bun.file(cacheManifest).exists()) return cacheDir
|
|
127
|
+
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Install a named facet, copying its resources to the active OpenCode directories.
|
|
133
|
+
*/
|
|
134
|
+
export async function installFacet(
|
|
135
|
+
name: string,
|
|
136
|
+
projectRoot: string,
|
|
137
|
+
options: InstallOptions = {},
|
|
138
|
+
): Promise<InstallResult> {
|
|
139
|
+
const facetDir = await resolveFacetDir(name, projectRoot)
|
|
140
|
+
if (!facetDir) {
|
|
141
|
+
return { success: false, facet: name, reason: "not_found" }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const manifestResult = await loadManifest(`${facetDir}/facet.yaml`)
|
|
145
|
+
if (!manifestResult.success) {
|
|
146
|
+
return { success: false, facet: name, reason: "not_found" }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const manifest = manifestResult.manifest
|
|
150
|
+
const base = `${projectRoot}/.opencode`
|
|
151
|
+
|
|
152
|
+
// Prerequisites
|
|
153
|
+
const requires = normalizeRequires(manifest.requires)
|
|
154
|
+
if (requires.length > 0) {
|
|
155
|
+
const alreadyConfirmed = !options.forcePrereqCheck && (await isPrereqConfirmed(name))
|
|
156
|
+
|
|
157
|
+
if (!alreadyConfirmed) {
|
|
158
|
+
// Ask for approval if handler is provided
|
|
159
|
+
if (options.onPrereqApproval) {
|
|
160
|
+
const approved = await options.onPrereqApproval(requires)
|
|
161
|
+
if (!approved) {
|
|
162
|
+
return {
|
|
163
|
+
success: false,
|
|
164
|
+
facet: name,
|
|
165
|
+
reason: "prereq",
|
|
166
|
+
failure: { command: "(user declined)", exitCode: -1, output: "Prerequisite approval declined" },
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Run checks
|
|
172
|
+
const prereqResult = await runPrereqChecks(requires)
|
|
173
|
+
if (!prereqResult.success) {
|
|
174
|
+
return { success: false, facet: name, reason: "prereq", failure: prereqResult }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Mark confirmed
|
|
178
|
+
await markPrereqConfirmed(name)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Install resources
|
|
183
|
+
const installed: { name: string; type: string }[] = []
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
// Skills — copy entire directory
|
|
187
|
+
for (const skill of manifest.skills ?? []) {
|
|
188
|
+
const src = `${facetDir}/skills/${skill}/SKILL.md`
|
|
189
|
+
const dst = `${base}/skills/${skill}/SKILL.md`
|
|
190
|
+
await Bun.$`mkdir -p ${path.dirname(dst)}`
|
|
191
|
+
await Bun.$`cp ${src} ${dst}`
|
|
192
|
+
installed.push({ name: skill, type: "skill" })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Agents — read prompt body, assemble with frontmatter
|
|
196
|
+
for (const [agentName, descriptor] of Object.entries(manifest.agents ?? {})) {
|
|
197
|
+
const promptPath = resolvePromptPath(descriptor.prompt)
|
|
198
|
+
if (!promptPath) continue
|
|
199
|
+
const promptBody = await Bun.file(`${facetDir}/${promptPath}`).text()
|
|
200
|
+
const assembled = assembleAgentFile(promptBody, descriptor)
|
|
201
|
+
const dst = `${base}/agents/${agentName}.md`
|
|
202
|
+
await Bun.$`mkdir -p ${path.dirname(dst)}`
|
|
203
|
+
await Bun.write(dst, assembled)
|
|
204
|
+
installed.push({ name: agentName, type: "agent" })
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Commands — read prompt body, assemble with frontmatter
|
|
208
|
+
for (const [cmdName, descriptor] of Object.entries(manifest.commands ?? {})) {
|
|
209
|
+
const promptPath = resolvePromptPath(descriptor.prompt)
|
|
210
|
+
if (!promptPath) continue
|
|
211
|
+
const promptBody = await Bun.file(`${facetDir}/${promptPath}`).text()
|
|
212
|
+
const assembled = assembleCommandFile(promptBody, descriptor)
|
|
213
|
+
const dst = `${base}/commands/${cmdName}.md`
|
|
214
|
+
await Bun.$`mkdir -p ${path.dirname(dst)}`
|
|
215
|
+
await Bun.write(dst, assembled)
|
|
216
|
+
installed.push({ name: cmdName, type: "command" })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Platform tools — copy directly
|
|
220
|
+
for (const tool of manifest.platforms?.opencode?.tools ?? []) {
|
|
221
|
+
const src = `${facetDir}/opencode/tools/${tool}.ts`
|
|
222
|
+
const dst = `${base}/tools/${tool}.ts`
|
|
223
|
+
await Bun.$`mkdir -p ${path.dirname(dst)}`
|
|
224
|
+
await Bun.$`cp ${src} ${dst}`
|
|
225
|
+
installed.push({ name: tool, type: "tool" })
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
return { success: false, facet: name, reason: "copy", error: String(err) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { success: true, facet: name, resources: installed }
|
|
232
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { FacetManifest } from "../registry/schemas.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check whether a facet's resources are currently installed by
|
|
5
|
+
* verifying that expected destination files exist.
|
|
6
|
+
*/
|
|
7
|
+
export async function isInstalled(manifest: FacetManifest, projectRoot: string): Promise<boolean> {
|
|
8
|
+
const base = `${projectRoot}/.opencode`
|
|
9
|
+
|
|
10
|
+
// Check skills
|
|
11
|
+
for (const skill of manifest.skills ?? []) {
|
|
12
|
+
const exists = await Bun.file(`${base}/skills/${skill}/SKILL.md`).exists()
|
|
13
|
+
if (!exists) return false
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Check agents
|
|
17
|
+
for (const name of Object.keys(manifest.agents ?? {})) {
|
|
18
|
+
const exists = await Bun.file(`${base}/agents/${name}.md`).exists()
|
|
19
|
+
if (!exists) return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Check commands
|
|
23
|
+
for (const name of Object.keys(manifest.commands ?? {})) {
|
|
24
|
+
const exists = await Bun.file(`${base}/commands/${name}.md`).exists()
|
|
25
|
+
if (!exists) return false
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check platform tools
|
|
29
|
+
for (const tool of manifest.platforms?.opencode?.tools ?? []) {
|
|
30
|
+
const exists = await Bun.file(`${base}/tools/${tool}.ts`).exists()
|
|
31
|
+
if (!exists) return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// At least one resource must exist for a facet to be considered installed
|
|
35
|
+
const hasResources =
|
|
36
|
+
(manifest.skills?.length ?? 0) > 0 ||
|
|
37
|
+
Object.keys(manifest.agents ?? {}).length > 0 ||
|
|
38
|
+
Object.keys(manifest.commands ?? {}).length > 0 ||
|
|
39
|
+
(manifest.platforms?.opencode?.tools?.length ?? 0) > 0
|
|
40
|
+
|
|
41
|
+
return hasResources
|
|
42
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises"
|
|
2
|
+
import { loadManifest } from "../registry/loader.ts"
|
|
3
|
+
import { localFacetsDir, readFacetsYaml, writeFacetsYaml } from "../registry/files.ts"
|
|
4
|
+
import { getCacheDir } from "../discovery/cache.ts"
|
|
5
|
+
|
|
6
|
+
export interface UninstallSuccess {
|
|
7
|
+
success: true
|
|
8
|
+
facet: string
|
|
9
|
+
removed: { name: string; type: string }[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UninstallNotFound {
|
|
13
|
+
success: false
|
|
14
|
+
facet: string
|
|
15
|
+
reason: "not_found"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UninstallError {
|
|
19
|
+
success: false
|
|
20
|
+
facet: string
|
|
21
|
+
reason: "error"
|
|
22
|
+
error: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type UninstallResult = UninstallSuccess | UninstallNotFound | UninstallError
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Uninstall a facet: remove its installed resource files and remove
|
|
29
|
+
* it from facets.yaml.
|
|
30
|
+
*/
|
|
31
|
+
export async function uninstallFacet(name: string, projectRoot: string): Promise<UninstallResult> {
|
|
32
|
+
// Find the manifest (local or cached) to know what to remove
|
|
33
|
+
const localDir = `${localFacetsDir(projectRoot)}/${name}`
|
|
34
|
+
const cacheDir = getCacheDir(name)
|
|
35
|
+
|
|
36
|
+
let manifestPath: string | null = null
|
|
37
|
+
if (await Bun.file(`${localDir}/facet.yaml`).exists()) {
|
|
38
|
+
manifestPath = `${localDir}/facet.yaml`
|
|
39
|
+
} else if (await Bun.file(`${cacheDir}/facet.yaml`).exists()) {
|
|
40
|
+
manifestPath = `${cacheDir}/facet.yaml`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!manifestPath) {
|
|
44
|
+
return { success: false, facet: name, reason: "not_found" }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const manifestResult = await loadManifest(manifestPath)
|
|
48
|
+
if (!manifestResult.success) {
|
|
49
|
+
return { success: false, facet: name, reason: "error", error: manifestResult.error }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const manifest = manifestResult.manifest
|
|
53
|
+
const base = `${projectRoot}/.opencode`
|
|
54
|
+
const removed: { name: string; type: string }[] = []
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Remove skills
|
|
58
|
+
for (const skill of manifest.skills ?? []) {
|
|
59
|
+
const skillDir = `${base}/skills/${skill}`
|
|
60
|
+
try {
|
|
61
|
+
await rm(skillDir, { recursive: true, force: true })
|
|
62
|
+
removed.push({ name: skill, type: "skill" })
|
|
63
|
+
} catch {
|
|
64
|
+
// Already gone
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Remove agents
|
|
69
|
+
for (const agentName of Object.keys(manifest.agents ?? {})) {
|
|
70
|
+
const agentFile = `${base}/agents/${agentName}.md`
|
|
71
|
+
try {
|
|
72
|
+
await rm(agentFile, { force: true })
|
|
73
|
+
removed.push({ name: agentName, type: "agent" })
|
|
74
|
+
} catch {
|
|
75
|
+
// Already gone
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Remove commands
|
|
80
|
+
for (const cmdName of Object.keys(manifest.commands ?? {})) {
|
|
81
|
+
const cmdFile = `${base}/commands/${cmdName}.md`
|
|
82
|
+
try {
|
|
83
|
+
await rm(cmdFile, { force: true })
|
|
84
|
+
removed.push({ name: cmdName, type: "command" })
|
|
85
|
+
} catch {
|
|
86
|
+
// Already gone
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Remove platform tools
|
|
91
|
+
for (const tool of manifest.platforms?.opencode?.tools ?? []) {
|
|
92
|
+
const toolFile = `${base}/tools/${tool}.ts`
|
|
93
|
+
try {
|
|
94
|
+
await rm(toolFile, { force: true })
|
|
95
|
+
removed.push({ name: tool, type: "tool" })
|
|
96
|
+
} catch {
|
|
97
|
+
// Already gone
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return { success: false, facet: name, reason: "error", error: String(err) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Remove from facets.yaml
|
|
105
|
+
const facetsYaml = await readFacetsYaml(projectRoot)
|
|
106
|
+
if (facetsYaml.remote?.[name]) {
|
|
107
|
+
delete facetsYaml.remote[name]
|
|
108
|
+
await writeFacetsYaml(projectRoot, facetsYaml)
|
|
109
|
+
}
|
|
110
|
+
if (facetsYaml.local?.includes(name)) {
|
|
111
|
+
facetsYaml.local = facetsYaml.local.filter((n) => n !== name)
|
|
112
|
+
await writeFacetsYaml(projectRoot, facetsYaml)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { success: true, facet: name, removed }
|
|
116
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { loadManifest } from "../loader.ts"
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import path from "path"
|
|
6
|
+
import yaml from "js-yaml"
|
|
7
|
+
|
|
8
|
+
let tempDir: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await mkdtemp(path.join(tmpdir(), "facets-test-"))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe("loadManifest", () => {
|
|
19
|
+
test("loads valid manifest", async () => {
|
|
20
|
+
const manifest = { name: "test-facet", version: "1.0.0", description: "A test" }
|
|
21
|
+
await Bun.write(`${tempDir}/facet.yaml`, yaml.dump(manifest))
|
|
22
|
+
|
|
23
|
+
const result = await loadManifest(`${tempDir}/facet.yaml`)
|
|
24
|
+
expect(result.success).toBe(true)
|
|
25
|
+
if (result.success) {
|
|
26
|
+
expect(result.manifest.name).toBe("test-facet")
|
|
27
|
+
expect(result.manifest.version).toBe("1.0.0")
|
|
28
|
+
expect(result.manifest.description).toBe("A test")
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("returns error for missing file", async () => {
|
|
33
|
+
const result = await loadManifest(`${tempDir}/nonexistent.yaml`)
|
|
34
|
+
expect(result.success).toBe(false)
|
|
35
|
+
if (!result.success) {
|
|
36
|
+
expect(result.error).toContain("Cannot read manifest")
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("returns error for invalid YAML", async () => {
|
|
41
|
+
await Bun.write(`${tempDir}/facet.yaml`, ":\n :\n - [invalid\n")
|
|
42
|
+
|
|
43
|
+
const result = await loadManifest(`${tempDir}/facet.yaml`)
|
|
44
|
+
expect(result.success).toBe(false)
|
|
45
|
+
if (!result.success) {
|
|
46
|
+
expect(result.error).toMatch(/Invalid (YAML|manifest)/)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("returns error for missing required fields", async () => {
|
|
51
|
+
await Bun.write(`${tempDir}/facet.yaml`, yaml.dump({ description: "no name or version" }))
|
|
52
|
+
|
|
53
|
+
const result = await loadManifest(`${tempDir}/facet.yaml`)
|
|
54
|
+
expect(result.success).toBe(false)
|
|
55
|
+
if (!result.success) {
|
|
56
|
+
expect(result.error).toContain("Invalid manifest")
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("tolerates unrecognized fields", async () => {
|
|
61
|
+
const manifest = { name: "test", version: "1.0.0", customField: "hello" }
|
|
62
|
+
await Bun.write(`${tempDir}/facet.yaml`, yaml.dump(manifest))
|
|
63
|
+
|
|
64
|
+
const result = await loadManifest(`${tempDir}/facet.yaml`)
|
|
65
|
+
expect(result.success).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test"
|
|
2
|
+
import { FacetManifestSchema, FacetsYamlSchema, FacetsLockSchema, normalizeRequires, resolvePromptPath } from "../schemas.ts"
|
|
3
|
+
|
|
4
|
+
describe("FacetManifestSchema", () => {
|
|
5
|
+
test("accepts valid minimal manifest", () => {
|
|
6
|
+
const result = FacetManifestSchema.safeParse({
|
|
7
|
+
name: "my-facet",
|
|
8
|
+
version: "1.0.0",
|
|
9
|
+
})
|
|
10
|
+
expect(result.success).toBe(true)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test("accepts full manifest with all fields", () => {
|
|
14
|
+
const result = FacetManifestSchema.safeParse({
|
|
15
|
+
name: "my-facet",
|
|
16
|
+
version: "1.0.0",
|
|
17
|
+
description: "A test facet",
|
|
18
|
+
author: "Test <test@example.com>",
|
|
19
|
+
requires: ["gh --version", "jq --version"],
|
|
20
|
+
skills: ["my-skill"],
|
|
21
|
+
agents: {
|
|
22
|
+
"my-agent": {
|
|
23
|
+
description: "Does a thing",
|
|
24
|
+
prompt: "prompts/my-agent.md",
|
|
25
|
+
platforms: {
|
|
26
|
+
opencode: { tools: { write: false } },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
commands: {
|
|
31
|
+
"my-command": {
|
|
32
|
+
description: "What it does",
|
|
33
|
+
prompt: { file: "prompts/my-command.md" },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
platforms: {
|
|
37
|
+
opencode: {
|
|
38
|
+
tools: ["my-tool"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
})
|
|
42
|
+
expect(result.success).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("rejects manifest without name", () => {
|
|
46
|
+
const result = FacetManifestSchema.safeParse({ version: "1.0.0" })
|
|
47
|
+
expect(result.success).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("rejects manifest without version", () => {
|
|
51
|
+
const result = FacetManifestSchema.safeParse({ name: "test" })
|
|
52
|
+
expect(result.success).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("tolerates unrecognized fields", () => {
|
|
56
|
+
const result = FacetManifestSchema.safeParse({
|
|
57
|
+
name: "my-facet",
|
|
58
|
+
version: "1.0.0",
|
|
59
|
+
unknownField: "value",
|
|
60
|
+
anotherField: 42,
|
|
61
|
+
})
|
|
62
|
+
expect(result.success).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("accepts requires as string", () => {
|
|
66
|
+
const result = FacetManifestSchema.safeParse({
|
|
67
|
+
name: "my-facet",
|
|
68
|
+
version: "1.0.0",
|
|
69
|
+
requires: "gh --version",
|
|
70
|
+
})
|
|
71
|
+
expect(result.success).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test("accepts requires as array", () => {
|
|
75
|
+
const result = FacetManifestSchema.safeParse({
|
|
76
|
+
name: "my-facet",
|
|
77
|
+
version: "1.0.0",
|
|
78
|
+
requires: ["gh --version", "jq --version"],
|
|
79
|
+
})
|
|
80
|
+
expect(result.success).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("accepts prompt as string", () => {
|
|
84
|
+
const result = FacetManifestSchema.safeParse({
|
|
85
|
+
name: "test",
|
|
86
|
+
version: "1.0.0",
|
|
87
|
+
agents: {
|
|
88
|
+
agent1: { prompt: "prompts/agent1.md" },
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
expect(result.success).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("accepts prompt as object with file", () => {
|
|
95
|
+
const result = FacetManifestSchema.safeParse({
|
|
96
|
+
name: "test",
|
|
97
|
+
version: "1.0.0",
|
|
98
|
+
agents: {
|
|
99
|
+
agent1: { prompt: { file: "prompts/agent1.md" } },
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
expect(result.success).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test("accepts prompt as object with url", () => {
|
|
106
|
+
const result = FacetManifestSchema.safeParse({
|
|
107
|
+
name: "test",
|
|
108
|
+
version: "1.0.0",
|
|
109
|
+
agents: {
|
|
110
|
+
agent1: { prompt: { url: "https://example.com/prompt" } },
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
expect(result.success).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("accepts agent with array-style tools", () => {
|
|
117
|
+
const result = FacetManifestSchema.safeParse({
|
|
118
|
+
name: "test",
|
|
119
|
+
version: "1.0.0",
|
|
120
|
+
agents: {
|
|
121
|
+
agent1: {
|
|
122
|
+
prompt: "prompts/a.md",
|
|
123
|
+
platforms: {
|
|
124
|
+
"claude-code": { tools: ["Read", "Edit", "Bash"] },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
expect(result.success).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("FacetsYamlSchema", () => {
|
|
134
|
+
test("accepts valid dependency file", () => {
|
|
135
|
+
const result = FacetsYamlSchema.safeParse({
|
|
136
|
+
local: ["my-local-facet"],
|
|
137
|
+
remote: {
|
|
138
|
+
viper: {
|
|
139
|
+
url: "https://example.com/facets/viper/facet.yaml",
|
|
140
|
+
version: "1.2.0",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
expect(result.success).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test("accepts empty dependency file", () => {
|
|
148
|
+
const result = FacetsYamlSchema.safeParse({})
|
|
149
|
+
expect(result.success).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test("accepts local-only dependencies", () => {
|
|
153
|
+
const result = FacetsYamlSchema.safeParse({
|
|
154
|
+
local: ["one", "two"],
|
|
155
|
+
})
|
|
156
|
+
expect(result.success).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe("FacetsLockSchema", () => {
|
|
161
|
+
test("accepts valid lockfile", () => {
|
|
162
|
+
const result = FacetsLockSchema.safeParse({
|
|
163
|
+
remote: {
|
|
164
|
+
viper: {
|
|
165
|
+
url: "https://example.com/facets/viper/facet.yaml",
|
|
166
|
+
version: "1.2.0",
|
|
167
|
+
integrity: "sha256-abc123",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
expect(result.success).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("accepts empty lockfile", () => {
|
|
175
|
+
const result = FacetsLockSchema.safeParse({})
|
|
176
|
+
expect(result.success).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe("normalizeRequires", () => {
|
|
181
|
+
test("returns empty array for undefined", () => {
|
|
182
|
+
expect(normalizeRequires(undefined)).toEqual([])
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
test("wraps string in array", () => {
|
|
186
|
+
expect(normalizeRequires("gh --version")).toEqual(["gh --version"])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
test("passes array through", () => {
|
|
190
|
+
expect(normalizeRequires(["a", "b"])).toEqual(["a", "b"])
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe("resolvePromptPath", () => {
|
|
195
|
+
test("returns string prompt as-is", () => {
|
|
196
|
+
expect(resolvePromptPath("prompts/a.md")).toBe("prompts/a.md")
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test("returns file path from object", () => {
|
|
200
|
+
expect(resolvePromptPath({ file: "prompts/a.md" })).toBe("prompts/a.md")
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("returns null for url prompt", () => {
|
|
204
|
+
expect(resolvePromptPath({ url: "https://example.com/prompt" })).toBeNull()
|
|
205
|
+
})
|
|
206
|
+
})
|