@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.
@@ -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
+ })