@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,218 @@
|
|
|
1
|
+
import path from "path"
|
|
2
|
+
import yaml from "js-yaml"
|
|
3
|
+
import { loadManifest } from "../registry/loader.ts"
|
|
4
|
+
import { readFacetsYaml, writeFacetsYaml, readFacetsLock, writeFacetsLock } from "../registry/files.ts"
|
|
5
|
+
import { resolvePromptPath, type FacetManifest } from "../registry/schemas.ts"
|
|
6
|
+
|
|
7
|
+
const DEFAULT_CACHE_DIR = `${process.env.XDG_CACHE_HOME ?? `${process.env.HOME}/.cache`}/facets`
|
|
8
|
+
|
|
9
|
+
/** Get the cache directory for a facet by name */
|
|
10
|
+
export function getCacheDir(name: string): string {
|
|
11
|
+
return `${DEFAULT_CACHE_DIR}/${name}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Get the root cache directory */
|
|
15
|
+
export function getCacheRoot(): string {
|
|
16
|
+
return DEFAULT_CACHE_DIR
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CacheSuccess {
|
|
20
|
+
success: true
|
|
21
|
+
name: string
|
|
22
|
+
version: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CacheError {
|
|
26
|
+
success: false
|
|
27
|
+
error: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type CacheResult = CacheSuccess | CacheError
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compute a SHA-256 integrity hash for a string.
|
|
34
|
+
*/
|
|
35
|
+
async function computeIntegrity(content: string): Promise<string> {
|
|
36
|
+
const hash = new Bun.CryptoHasher("sha256").update(content).digest("hex")
|
|
37
|
+
return `sha256-${hash}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch a remote URL and return its text content.
|
|
42
|
+
*/
|
|
43
|
+
async function fetchText(url: string): Promise<string> {
|
|
44
|
+
const response = await fetch(url)
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
47
|
+
}
|
|
48
|
+
return response.text()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a relative URL against a base URL.
|
|
53
|
+
*/
|
|
54
|
+
function resolveUrl(base: string, relative: string): string {
|
|
55
|
+
const baseUrl = new URL(base)
|
|
56
|
+
// Navigate to parent directory of the base file
|
|
57
|
+
const basePath = baseUrl.pathname.replace(/\/[^/]*$/, "/")
|
|
58
|
+
const resolved = new URL(relative, `${baseUrl.origin}${basePath}`)
|
|
59
|
+
return resolved.href
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Collect all resource file paths referenced by a manifest.
|
|
64
|
+
* Returns paths relative to the facet root directory.
|
|
65
|
+
*/
|
|
66
|
+
function collectResourcePaths(manifest: FacetManifest): string[] {
|
|
67
|
+
const paths: string[] = []
|
|
68
|
+
|
|
69
|
+
// Skills
|
|
70
|
+
for (const skill of manifest.skills ?? []) {
|
|
71
|
+
paths.push(`skills/${skill}/SKILL.md`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Agent prompts
|
|
75
|
+
for (const agent of Object.values(manifest.agents ?? {})) {
|
|
76
|
+
const promptPath = resolvePromptPath(agent.prompt)
|
|
77
|
+
if (promptPath) paths.push(promptPath)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Command prompts
|
|
81
|
+
for (const command of Object.values(manifest.commands ?? {})) {
|
|
82
|
+
const promptPath = resolvePromptPath(command.prompt)
|
|
83
|
+
if (promptPath) paths.push(promptPath)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Platform-specific tools
|
|
87
|
+
for (const tool of manifest.platforms?.opencode?.tools ?? []) {
|
|
88
|
+
paths.push(`opencode/tools/${tool}.ts`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return paths
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Cache a remote facet by URL. Fetches the manifest and all referenced
|
|
96
|
+
* resources, stores them in the global cache, and records the dependency
|
|
97
|
+
* in the project's facets.yaml and facets.lock.
|
|
98
|
+
*/
|
|
99
|
+
export async function cacheFacet(url: string, projectRoot: string): Promise<CacheResult> {
|
|
100
|
+
// Fetch the manifest
|
|
101
|
+
let manifestText: string
|
|
102
|
+
try {
|
|
103
|
+
manifestText = await fetchText(url)
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { success: false, error: `Failed to fetch manifest: ${err}` }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Parse and validate
|
|
109
|
+
let parsed: unknown
|
|
110
|
+
try {
|
|
111
|
+
parsed = yaml.load(manifestText)
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return { success: false, error: `Invalid YAML at ${url}: ${err}` }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { FacetManifestSchema } = await import("../registry/schemas.ts")
|
|
117
|
+
const validation = FacetManifestSchema.safeParse(parsed)
|
|
118
|
+
if (!validation.success) {
|
|
119
|
+
const issues = validation.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")
|
|
120
|
+
return { success: false, error: `Invalid manifest: ${issues}` }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const manifest = validation.data
|
|
124
|
+
const cacheDir = getCacheDir(manifest.name)
|
|
125
|
+
|
|
126
|
+
// Write the manifest
|
|
127
|
+
await Bun.$`mkdir -p ${cacheDir}`
|
|
128
|
+
await Bun.write(`${cacheDir}/facet.yaml`, manifestText)
|
|
129
|
+
|
|
130
|
+
// Fetch and cache all referenced resource files
|
|
131
|
+
const resourcePaths = collectResourcePaths(manifest)
|
|
132
|
+
for (const relPath of resourcePaths) {
|
|
133
|
+
const resourceUrl = resolveUrl(url, relPath)
|
|
134
|
+
try {
|
|
135
|
+
const content = await fetchText(resourceUrl)
|
|
136
|
+
const destPath = `${cacheDir}/${relPath}`
|
|
137
|
+
await Bun.$`mkdir -p ${path.dirname(destPath)}`
|
|
138
|
+
await Bun.write(destPath, content)
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { success: false, error: `Failed to fetch resource ${relPath}: ${err}` }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Update facets.yaml
|
|
145
|
+
const facetsYaml = await readFacetsYaml(projectRoot)
|
|
146
|
+
if (!facetsYaml.remote) facetsYaml.remote = {}
|
|
147
|
+
facetsYaml.remote[manifest.name] = {
|
|
148
|
+
url,
|
|
149
|
+
version: manifest.version,
|
|
150
|
+
}
|
|
151
|
+
await writeFacetsYaml(projectRoot, facetsYaml)
|
|
152
|
+
|
|
153
|
+
// Update facets.lock
|
|
154
|
+
const lock = await readFacetsLock(projectRoot)
|
|
155
|
+
if (!lock.remote) lock.remote = {}
|
|
156
|
+
lock.remote[manifest.name] = {
|
|
157
|
+
url,
|
|
158
|
+
version: manifest.version,
|
|
159
|
+
integrity: await computeIntegrity(manifestText),
|
|
160
|
+
}
|
|
161
|
+
await writeFacetsLock(projectRoot, lock)
|
|
162
|
+
|
|
163
|
+
return { success: true, name: manifest.name, version: manifest.version }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface UpdateSuccess {
|
|
167
|
+
success: true
|
|
168
|
+
name: string
|
|
169
|
+
version: string
|
|
170
|
+
updated: boolean
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface UpdateError {
|
|
174
|
+
success: false
|
|
175
|
+
name: string
|
|
176
|
+
error: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type UpdateResult = UpdateSuccess | UpdateError
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Re-fetch a single cached remote facet and update if newer.
|
|
183
|
+
*/
|
|
184
|
+
export async function updateFacet(name: string, projectRoot: string): Promise<UpdateResult> {
|
|
185
|
+
const facetsYaml = await readFacetsYaml(projectRoot)
|
|
186
|
+
const entry = facetsYaml.remote?.[name]
|
|
187
|
+
if (!entry) {
|
|
188
|
+
return { success: false, name, error: `No remote facet named "${name}" declared` }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const lock = await readFacetsLock(projectRoot)
|
|
192
|
+
const lockEntry = lock.remote?.[name]
|
|
193
|
+
|
|
194
|
+
// Re-fetch
|
|
195
|
+
const result = await cacheFacet(entry.url, projectRoot)
|
|
196
|
+
if (!result.success) {
|
|
197
|
+
return { success: false, name, error: result.error }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check if the version changed
|
|
201
|
+
const updated = lockEntry ? lockEntry.version !== result.version : true
|
|
202
|
+
|
|
203
|
+
return { success: true, name, version: result.version, updated }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Update all remote facets declared in facets.yaml.
|
|
208
|
+
*/
|
|
209
|
+
export async function updateAllFacets(projectRoot: string): Promise<UpdateResult[]> {
|
|
210
|
+
const facetsYaml = await readFacetsYaml(projectRoot)
|
|
211
|
+
const results: UpdateResult[] = []
|
|
212
|
+
|
|
213
|
+
for (const name of Object.keys(facetsYaml.remote ?? {})) {
|
|
214
|
+
results.push(await updateFacet(name, projectRoot))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return results
|
|
218
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getCacheRoot } from "./cache.ts"
|
|
2
|
+
import { rm } from "node:fs/promises"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Remove the entire global facet cache directory.
|
|
6
|
+
* Does not affect local facets or installed resources.
|
|
7
|
+
*/
|
|
8
|
+
export async function clearCache(): Promise<void> {
|
|
9
|
+
const cacheRoot = getCacheRoot()
|
|
10
|
+
try {
|
|
11
|
+
await rm(cacheRoot, { recursive: true, force: true })
|
|
12
|
+
} catch {
|
|
13
|
+
// Cache dir doesn't exist — that's fine
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { loadManifest } from "../registry/loader.ts"
|
|
2
|
+
import { readFacetsYaml, localFacetsDir } from "../registry/files.ts"
|
|
3
|
+
import { normalizeRequires, type FacetManifest } from "../registry/schemas.ts"
|
|
4
|
+
import { getCacheDir } from "./cache.ts"
|
|
5
|
+
import { isInstalled } from "../installation/status.ts"
|
|
6
|
+
|
|
7
|
+
export interface FacetEntry {
|
|
8
|
+
name: string
|
|
9
|
+
version: string
|
|
10
|
+
description?: string
|
|
11
|
+
source: "local" | "remote"
|
|
12
|
+
installed: boolean
|
|
13
|
+
requires: string[]
|
|
14
|
+
resources: ResourceSummary[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResourceSummary {
|
|
18
|
+
type: "skill" | "agent" | "command" | "tool"
|
|
19
|
+
name: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ListResult {
|
|
23
|
+
facets: FacetEntry[]
|
|
24
|
+
errors?: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractResources(manifest: FacetManifest): ResourceSummary[] {
|
|
28
|
+
const resources: ResourceSummary[] = []
|
|
29
|
+
|
|
30
|
+
for (const skill of manifest.skills ?? []) {
|
|
31
|
+
resources.push({ type: "skill", name: skill })
|
|
32
|
+
}
|
|
33
|
+
for (const name of Object.keys(manifest.agents ?? {})) {
|
|
34
|
+
resources.push({ type: "agent", name })
|
|
35
|
+
}
|
|
36
|
+
for (const name of Object.keys(manifest.commands ?? {})) {
|
|
37
|
+
resources.push({ type: "command", name })
|
|
38
|
+
}
|
|
39
|
+
for (const tool of manifest.platforms?.opencode?.tools ?? []) {
|
|
40
|
+
resources.push({ type: "tool", name: tool })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return resources
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* List all facets declared by the project — local facets and remote facets
|
|
48
|
+
* from facets.yaml. Read-only: no network, no command execution.
|
|
49
|
+
*/
|
|
50
|
+
export async function listFacets(projectRoot: string): Promise<ListResult> {
|
|
51
|
+
const facets: FacetEntry[] = []
|
|
52
|
+
const errors: string[] = []
|
|
53
|
+
const facetsYaml = await readFacetsYaml(projectRoot)
|
|
54
|
+
|
|
55
|
+
// Scan local facets
|
|
56
|
+
const localDir = localFacetsDir(projectRoot)
|
|
57
|
+
try {
|
|
58
|
+
const entries: string[] = []
|
|
59
|
+
for await (const entry of new Bun.Glob("*/facet.yaml").scan(localDir)) {
|
|
60
|
+
entries.push(entry)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const facetDir = `${localDir}/${entry.replace(/\/facet\.yaml$/, "")}`
|
|
65
|
+
const manifestPath = `${localDir}/${entry}`
|
|
66
|
+
const result = await loadManifest(manifestPath)
|
|
67
|
+
if (!result.success) {
|
|
68
|
+
errors.push(`Local facet at ${facetDir}: ${result.error}`)
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const manifest = result.manifest
|
|
72
|
+
facets.push({
|
|
73
|
+
name: manifest.name,
|
|
74
|
+
version: manifest.version,
|
|
75
|
+
description: manifest.description,
|
|
76
|
+
source: "local",
|
|
77
|
+
installed: await isInstalled(manifest, projectRoot),
|
|
78
|
+
requires: normalizeRequires(manifest.requires),
|
|
79
|
+
resources: extractResources(manifest),
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// No local facets dir — fine
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Scan remote facets declared in facets.yaml
|
|
87
|
+
for (const [name, entry] of Object.entries(facetsYaml.remote ?? {})) {
|
|
88
|
+
const cacheDir = getCacheDir(name)
|
|
89
|
+
const manifestPath = `${cacheDir}/facet.yaml`
|
|
90
|
+
const result = await loadManifest(manifestPath)
|
|
91
|
+
if (!result.success) {
|
|
92
|
+
// Cached manifest not available — show minimal info
|
|
93
|
+
facets.push({
|
|
94
|
+
name,
|
|
95
|
+
version: entry.version ?? "unknown",
|
|
96
|
+
source: "remote",
|
|
97
|
+
installed: false,
|
|
98
|
+
requires: [],
|
|
99
|
+
resources: [],
|
|
100
|
+
})
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
const manifest = result.manifest
|
|
104
|
+
facets.push({
|
|
105
|
+
name: manifest.name,
|
|
106
|
+
version: manifest.version,
|
|
107
|
+
description: manifest.description,
|
|
108
|
+
source: "remote",
|
|
109
|
+
installed: await isInstalled(manifest, projectRoot),
|
|
110
|
+
requires: normalizeRequires(manifest.requires),
|
|
111
|
+
resources: extractResources(manifest),
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Include local facets declared in facets.yaml that weren't already found
|
|
116
|
+
for (const name of facetsYaml.local ?? []) {
|
|
117
|
+
if (facets.some((f) => f.name === name)) continue
|
|
118
|
+
facets.push({
|
|
119
|
+
name,
|
|
120
|
+
version: "unknown",
|
|
121
|
+
source: "local",
|
|
122
|
+
installed: false,
|
|
123
|
+
requires: [],
|
|
124
|
+
resources: [],
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { facets, ...(errors.length > 0 && { errors }) }
|
|
129
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Registry — schemas and loaders
|
|
2
|
+
export {
|
|
3
|
+
FacetManifestSchema,
|
|
4
|
+
FacetsYamlSchema,
|
|
5
|
+
FacetsLockSchema,
|
|
6
|
+
type FacetManifest,
|
|
7
|
+
type FacetsYaml,
|
|
8
|
+
type FacetsLock,
|
|
9
|
+
} from "./registry/schemas.ts"
|
|
10
|
+
export { loadManifest } from "./registry/loader.ts"
|
|
11
|
+
export { readFacetsYaml, writeFacetsYaml, readFacetsLock, writeFacetsLock } from "./registry/files.ts"
|
|
12
|
+
|
|
13
|
+
// Discovery — list, cache, clear
|
|
14
|
+
export { listFacets, type FacetEntry, type ListResult } from "./discovery/list.ts"
|
|
15
|
+
export { cacheFacet, updateFacet, updateAllFacets, type CacheResult, type UpdateResult } from "./discovery/cache.ts"
|
|
16
|
+
export { clearCache } from "./discovery/clear.ts"
|
|
17
|
+
|
|
18
|
+
// Installation — install, uninstall, prerequisites
|
|
19
|
+
export {
|
|
20
|
+
installFacet,
|
|
21
|
+
type InstallResult,
|
|
22
|
+
type InstallSuccess,
|
|
23
|
+
type InstallNotFound,
|
|
24
|
+
type InstallPrereqFailure,
|
|
25
|
+
type InstallCopyFailure,
|
|
26
|
+
} from "./installation/install.ts"
|
|
27
|
+
export { uninstallFacet, type UninstallResult } from "./installation/uninstall.ts"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { installFacet } from "../install.ts"
|
|
3
|
+
import { uninstallFacet } from "../uninstall.ts"
|
|
4
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
5
|
+
import { tmpdir } from "node:os"
|
|
6
|
+
import path from "path"
|
|
7
|
+
import yaml from "js-yaml"
|
|
8
|
+
|
|
9
|
+
let projectRoot: string
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
projectRoot = await mkdtemp(path.join(tmpdir(), "facets-test-"))
|
|
13
|
+
await Bun.$`mkdir -p ${projectRoot}/.opencode/facets`
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(projectRoot, { recursive: true, force: true })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
async function writeLocalFacet(
|
|
21
|
+
name: string,
|
|
22
|
+
manifest: Record<string, unknown>,
|
|
23
|
+
files?: Record<string, string>,
|
|
24
|
+
) {
|
|
25
|
+
const dir = `${projectRoot}/.opencode/facets/${name}`
|
|
26
|
+
await Bun.$`mkdir -p ${dir}`
|
|
27
|
+
await Bun.write(`${dir}/facet.yaml`, yaml.dump(manifest))
|
|
28
|
+
for (const [relPath, content] of Object.entries(files ?? {})) {
|
|
29
|
+
const fullPath = `${dir}/${relPath}`
|
|
30
|
+
await Bun.$`mkdir -p ${path.dirname(fullPath)}`
|
|
31
|
+
await Bun.write(fullPath, content)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("installFacet", () => {
|
|
36
|
+
test("installs local facet with skill", async () => {
|
|
37
|
+
await writeLocalFacet(
|
|
38
|
+
"test-facet",
|
|
39
|
+
{
|
|
40
|
+
name: "test-facet",
|
|
41
|
+
version: "1.0.0",
|
|
42
|
+
skills: ["my-skill"],
|
|
43
|
+
},
|
|
44
|
+
{ "skills/my-skill/SKILL.md": "# My Skill\nDoes things." },
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const result = await installFacet("test-facet", projectRoot)
|
|
48
|
+
expect(result.success).toBe(true)
|
|
49
|
+
if (result.success) {
|
|
50
|
+
expect(result.resources).toContainEqual({ name: "my-skill", type: "skill" })
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Verify file exists
|
|
54
|
+
const installed = await Bun.file(`${projectRoot}/.opencode/skills/my-skill/SKILL.md`).exists()
|
|
55
|
+
expect(installed).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("installs agent with assembled frontmatter", async () => {
|
|
59
|
+
await writeLocalFacet(
|
|
60
|
+
"test-facet",
|
|
61
|
+
{
|
|
62
|
+
name: "test-facet",
|
|
63
|
+
version: "1.0.0",
|
|
64
|
+
agents: {
|
|
65
|
+
"my-agent": {
|
|
66
|
+
description: "Does a thing",
|
|
67
|
+
prompt: "prompts/my-agent.md",
|
|
68
|
+
platforms: {
|
|
69
|
+
opencode: { tools: { write: false } },
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{ "prompts/my-agent.md": "You are a helpful agent." },
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const result = await installFacet("test-facet", projectRoot)
|
|
78
|
+
expect(result.success).toBe(true)
|
|
79
|
+
|
|
80
|
+
const content = await Bun.file(`${projectRoot}/.opencode/agents/my-agent.md`).text()
|
|
81
|
+
expect(content).toContain("---")
|
|
82
|
+
expect(content).toContain("description: Does a thing")
|
|
83
|
+
expect(content).toContain("You are a helpful agent.")
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test("installs command with assembled frontmatter", async () => {
|
|
87
|
+
await writeLocalFacet(
|
|
88
|
+
"test-facet",
|
|
89
|
+
{
|
|
90
|
+
name: "test-facet",
|
|
91
|
+
version: "1.0.0",
|
|
92
|
+
commands: {
|
|
93
|
+
"my-command": {
|
|
94
|
+
description: "What it does",
|
|
95
|
+
prompt: "prompts/my-command.md",
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{ "prompts/my-command.md": "Run this command." },
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const result = await installFacet("test-facet", projectRoot)
|
|
103
|
+
expect(result.success).toBe(true)
|
|
104
|
+
|
|
105
|
+
const content = await Bun.file(`${projectRoot}/.opencode/commands/my-command.md`).text()
|
|
106
|
+
expect(content).toContain("description: What it does")
|
|
107
|
+
expect(content).toContain("Run this command.")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("installs platform tools", async () => {
|
|
111
|
+
await writeLocalFacet(
|
|
112
|
+
"test-facet",
|
|
113
|
+
{
|
|
114
|
+
name: "test-facet",
|
|
115
|
+
version: "1.0.0",
|
|
116
|
+
platforms: {
|
|
117
|
+
opencode: { tools: ["my-tool"] },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{ "opencode/tools/my-tool.ts": 'export default "tool"' },
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const result = await installFacet("test-facet", projectRoot)
|
|
124
|
+
expect(result.success).toBe(true)
|
|
125
|
+
|
|
126
|
+
const installed = await Bun.file(`${projectRoot}/.opencode/tools/my-tool.ts`).exists()
|
|
127
|
+
expect(installed).toBe(true)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test("returns not_found for unknown facet", async () => {
|
|
131
|
+
const result = await installFacet("nonexistent", projectRoot)
|
|
132
|
+
expect(result.success).toBe(false)
|
|
133
|
+
if (!result.success) {
|
|
134
|
+
expect(result.reason).toBe("not_found")
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("runs prereq checks and fails on bad command", async () => {
|
|
139
|
+
await writeLocalFacet(
|
|
140
|
+
"test-facet",
|
|
141
|
+
{
|
|
142
|
+
name: "test-facet",
|
|
143
|
+
version: "1.0.0",
|
|
144
|
+
requires: ["false"],
|
|
145
|
+
skills: ["s"],
|
|
146
|
+
},
|
|
147
|
+
{ "skills/s/SKILL.md": "# S" },
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const result = await installFacet("test-facet", projectRoot, {
|
|
151
|
+
skipPrereqApproval: true,
|
|
152
|
+
})
|
|
153
|
+
expect(result.success).toBe(false)
|
|
154
|
+
if (!result.success) {
|
|
155
|
+
expect(result.reason).toBe("prereq")
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("user declining prereqs cancels install", async () => {
|
|
160
|
+
await writeLocalFacet(
|
|
161
|
+
"test-facet",
|
|
162
|
+
{
|
|
163
|
+
name: "test-facet",
|
|
164
|
+
version: "1.0.0",
|
|
165
|
+
requires: ["true"],
|
|
166
|
+
skills: ["s"],
|
|
167
|
+
},
|
|
168
|
+
{ "skills/s/SKILL.md": "# S" },
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const result = await installFacet("test-facet", projectRoot, {
|
|
172
|
+
onPrereqApproval: async () => false,
|
|
173
|
+
})
|
|
174
|
+
expect(result.success).toBe(false)
|
|
175
|
+
if (!result.success) {
|
|
176
|
+
expect(result.reason).toBe("prereq")
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
describe("uninstallFacet", () => {
|
|
182
|
+
test("removes installed resources", async () => {
|
|
183
|
+
await writeLocalFacet(
|
|
184
|
+
"test-facet",
|
|
185
|
+
{
|
|
186
|
+
name: "test-facet",
|
|
187
|
+
version: "1.0.0",
|
|
188
|
+
skills: ["my-skill"],
|
|
189
|
+
},
|
|
190
|
+
{ "skills/my-skill/SKILL.md": "# My Skill" },
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
// Install first
|
|
194
|
+
await installFacet("test-facet", projectRoot)
|
|
195
|
+
expect(await Bun.file(`${projectRoot}/.opencode/skills/my-skill/SKILL.md`).exists()).toBe(true)
|
|
196
|
+
|
|
197
|
+
// Uninstall
|
|
198
|
+
const result = await uninstallFacet("test-facet", projectRoot)
|
|
199
|
+
expect(result.success).toBe(true)
|
|
200
|
+
expect(await Bun.file(`${projectRoot}/.opencode/skills/my-skill/SKILL.md`).exists()).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("returns not_found for unknown facet", async () => {
|
|
204
|
+
const result = await uninstallFacet("nonexistent", projectRoot)
|
|
205
|
+
expect(result.success).toBe(false)
|
|
206
|
+
if (!result.success) {
|
|
207
|
+
expect(result.reason).toBe("not_found")
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
})
|