@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
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ex-machina/facets",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"bin": {
|
|
9
|
+
"facets": "./src/cli.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"types": "tsc --noEmit",
|
|
13
|
+
"test": "bun test"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"comment-json": "^4.2.5",
|
|
17
|
+
"js-yaml": "^4.1.0",
|
|
18
|
+
"zod": "^4.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/bun": "latest",
|
|
22
|
+
"@types/js-yaml": "^4.0.9"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"typescript": "^5"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import path from "path"
|
|
5
|
+
import yaml from "js-yaml"
|
|
6
|
+
import { listFacets } from "../discovery/list.ts"
|
|
7
|
+
import { installFacet } from "../installation/install.ts"
|
|
8
|
+
import { uninstallFacet } from "../installation/uninstall.ts"
|
|
9
|
+
import { initProject } from "../cli/init.ts"
|
|
10
|
+
|
|
11
|
+
let projectRoot: string
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
projectRoot = await mkdtemp(path.join(tmpdir(), "facets-e2e-"))
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
await rm(projectRoot, { recursive: true, force: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
async function writeLocalFacet(
|
|
22
|
+
name: string,
|
|
23
|
+
manifest: Record<string, unknown>,
|
|
24
|
+
files?: Record<string, string>,
|
|
25
|
+
) {
|
|
26
|
+
const dir = `${projectRoot}/.opencode/facets/${name}`
|
|
27
|
+
await Bun.$`mkdir -p ${dir}`
|
|
28
|
+
await Bun.write(`${dir}/facet.yaml`, yaml.dump(manifest))
|
|
29
|
+
for (const [relPath, content] of Object.entries(files ?? {})) {
|
|
30
|
+
const fullPath = `${dir}/${relPath}`
|
|
31
|
+
await Bun.$`mkdir -p ${path.dirname(fullPath)}`
|
|
32
|
+
await Bun.write(fullPath, content)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("End-to-end", () => {
|
|
37
|
+
test("init → list → install → verify resources → uninstall", async () => {
|
|
38
|
+
// 1. Init the project
|
|
39
|
+
await initProject(projectRoot)
|
|
40
|
+
|
|
41
|
+
// Verify opencode.jsonc was created with MCP server registered
|
|
42
|
+
const configText = await Bun.file(`${projectRoot}/.opencode/opencode.jsonc`).text()
|
|
43
|
+
expect(configText).toContain("facets-mcp")
|
|
44
|
+
|
|
45
|
+
// Verify facets.yaml was created
|
|
46
|
+
expect(await Bun.file(`${projectRoot}/.opencode/facets.yaml`).exists()).toBe(true)
|
|
47
|
+
|
|
48
|
+
// 2. Create a local facet
|
|
49
|
+
await writeLocalFacet(
|
|
50
|
+
"test-facet",
|
|
51
|
+
{
|
|
52
|
+
name: "test-facet",
|
|
53
|
+
version: "1.0.0",
|
|
54
|
+
description: "End-to-end test facet",
|
|
55
|
+
skills: ["e2e-skill"],
|
|
56
|
+
agents: {
|
|
57
|
+
"e2e-agent": {
|
|
58
|
+
description: "E2E test agent",
|
|
59
|
+
prompt: "prompts/e2e-agent.md",
|
|
60
|
+
platforms: {
|
|
61
|
+
opencode: { tools: { write: false } },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
commands: {
|
|
66
|
+
"e2e-cmd": {
|
|
67
|
+
description: "E2E test command",
|
|
68
|
+
prompt: "prompts/e2e-cmd.md",
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"skills/e2e-skill/SKILL.md": "# E2E Skill\nThis is a test skill.",
|
|
74
|
+
"prompts/e2e-agent.md": "You are an end-to-end test agent.",
|
|
75
|
+
"prompts/e2e-cmd.md": "Run the e2e test command.",
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// 3. List — should show the facet as not installed
|
|
80
|
+
let list = await listFacets(projectRoot)
|
|
81
|
+
expect(list.facets).toHaveLength(1)
|
|
82
|
+
expect(list.facets[0]!.name).toBe("test-facet")
|
|
83
|
+
expect(list.facets[0]!.installed).toBe(false)
|
|
84
|
+
|
|
85
|
+
// 4. Install
|
|
86
|
+
const installResult = await installFacet("test-facet", projectRoot)
|
|
87
|
+
expect(installResult.success).toBe(true)
|
|
88
|
+
if (installResult.success) {
|
|
89
|
+
expect(installResult.resources).toHaveLength(3)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 5. Verify resource files are present
|
|
93
|
+
expect(await Bun.file(`${projectRoot}/.opencode/skills/e2e-skill/SKILL.md`).exists()).toBe(true)
|
|
94
|
+
expect(await Bun.file(`${projectRoot}/.opencode/agents/e2e-agent.md`).exists()).toBe(true)
|
|
95
|
+
expect(await Bun.file(`${projectRoot}/.opencode/commands/e2e-cmd.md`).exists()).toBe(true)
|
|
96
|
+
|
|
97
|
+
// Verify agent file has assembled frontmatter
|
|
98
|
+
const agentContent = await Bun.file(`${projectRoot}/.opencode/agents/e2e-agent.md`).text()
|
|
99
|
+
expect(agentContent).toContain("description: E2E test agent")
|
|
100
|
+
expect(agentContent).toContain("You are an end-to-end test agent.")
|
|
101
|
+
|
|
102
|
+
// 6. List — should now show as installed
|
|
103
|
+
list = await listFacets(projectRoot)
|
|
104
|
+
expect(list.facets[0]!.installed).toBe(true)
|
|
105
|
+
|
|
106
|
+
// 7. Uninstall
|
|
107
|
+
const uninstallResult = await uninstallFacet("test-facet", projectRoot)
|
|
108
|
+
expect(uninstallResult.success).toBe(true)
|
|
109
|
+
|
|
110
|
+
// 8. Verify resources removed
|
|
111
|
+
expect(await Bun.file(`${projectRoot}/.opencode/skills/e2e-skill/SKILL.md`).exists()).toBe(false)
|
|
112
|
+
expect(await Bun.file(`${projectRoot}/.opencode/agents/e2e-agent.md`).exists()).toBe(false)
|
|
113
|
+
expect(await Bun.file(`${projectRoot}/.opencode/commands/e2e-cmd.md`).exists()).toBe(false)
|
|
114
|
+
|
|
115
|
+
// 9. List — should be back to not installed
|
|
116
|
+
list = await listFacets(projectRoot)
|
|
117
|
+
expect(list.facets[0]!.installed).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test("init is idempotent", async () => {
|
|
121
|
+
await initProject(projectRoot)
|
|
122
|
+
await initProject(projectRoot)
|
|
123
|
+
|
|
124
|
+
// MCP server should only be registered once
|
|
125
|
+
const configText = await Bun.file(`${projectRoot}/.opencode/opencode.jsonc`).text()
|
|
126
|
+
const matches = configText.match(/facets-mcp/g)
|
|
127
|
+
expect(matches).toHaveLength(1)
|
|
128
|
+
})
|
|
129
|
+
})
|
package/src/cli/init.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parse, stringify } from "comment-json"
|
|
2
|
+
import { facetsYamlPath } from "../registry/files.ts"
|
|
3
|
+
|
|
4
|
+
const OPENCODE_CONFIG_PATH = ".opencode/opencode.jsonc"
|
|
5
|
+
|
|
6
|
+
const MCP_SERVER_CONFIG = {
|
|
7
|
+
type: "local",
|
|
8
|
+
command: ["bunx", "facets-mcp"],
|
|
9
|
+
enabled: true,
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Initialize a project for facets:
|
|
14
|
+
* 1. Register the facets MCP server in .opencode/opencode.jsonc
|
|
15
|
+
* 2. Create facets.yaml if absent
|
|
16
|
+
*/
|
|
17
|
+
export async function initProject(projectRoot: string): Promise<void> {
|
|
18
|
+
const configPath = `${projectRoot}/${OPENCODE_CONFIG_PATH}`
|
|
19
|
+
|
|
20
|
+
// Ensure .opencode/ directory exists
|
|
21
|
+
await Bun.$`mkdir -p ${projectRoot}/.opencode`
|
|
22
|
+
|
|
23
|
+
// Read or create opencode.jsonc
|
|
24
|
+
let config: Record<string, unknown>
|
|
25
|
+
let configText: string
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
configText = await Bun.file(configPath).text()
|
|
29
|
+
config = parse(configText) as Record<string, unknown>
|
|
30
|
+
} catch {
|
|
31
|
+
// Config doesn't exist — create a new one
|
|
32
|
+
config = {}
|
|
33
|
+
configText = "{}"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if MCP server is already registered
|
|
37
|
+
const mcp = (config.mcp ?? {}) as Record<string, unknown>
|
|
38
|
+
if (mcp.facets) {
|
|
39
|
+
console.log("Project already configured for facets.")
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Register the facets MCP server
|
|
44
|
+
mcp.facets = MCP_SERVER_CONFIG
|
|
45
|
+
config.mcp = mcp
|
|
46
|
+
|
|
47
|
+
// Write back preserving comments
|
|
48
|
+
const newConfig = stringify(config, null, 2)
|
|
49
|
+
await Bun.write(configPath, newConfig + "\n")
|
|
50
|
+
console.log(`Registered facets MCP server in ${OPENCODE_CONFIG_PATH}`)
|
|
51
|
+
|
|
52
|
+
// Create facets.yaml if absent
|
|
53
|
+
const yamlPath = facetsYamlPath(projectRoot)
|
|
54
|
+
if (!(await Bun.file(yamlPath).exists())) {
|
|
55
|
+
await Bun.write(yamlPath, "# Facet dependencies for this project\nlocal: []\nremote: {}\n")
|
|
56
|
+
console.log("Created facets.yaml")
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parseArgs } from "util"
|
|
3
|
+
import { listFacets } from "./discovery/list.ts"
|
|
4
|
+
import { cacheFacet } from "./discovery/cache.ts"
|
|
5
|
+
import { clearCache } from "./discovery/clear.ts"
|
|
6
|
+
import { installFacet } from "./installation/install.ts"
|
|
7
|
+
import { uninstallFacet } from "./installation/uninstall.ts"
|
|
8
|
+
|
|
9
|
+
const HELP = `Usage: facets <command> [options]
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
init Set up project for facets
|
|
13
|
+
list List all facets and their status
|
|
14
|
+
add <url> Cache a remote facet by URL
|
|
15
|
+
install [name] Install a facet's resources
|
|
16
|
+
remove <name> Remove a facet
|
|
17
|
+
update [name] Update cached remote facets
|
|
18
|
+
cache clear Clear the global facet cache
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
--help, -h Show this help message
|
|
22
|
+
--version, -v Show version`
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
const args = process.argv.slice(2)
|
|
26
|
+
|
|
27
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
28
|
+
console.log(HELP)
|
|
29
|
+
process.exit(0)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
33
|
+
console.log("0.1.0")
|
|
34
|
+
process.exit(0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const command = args[0]
|
|
38
|
+
|
|
39
|
+
switch (command) {
|
|
40
|
+
case "init":
|
|
41
|
+
await cmdInit()
|
|
42
|
+
break
|
|
43
|
+
case "list":
|
|
44
|
+
await cmdList()
|
|
45
|
+
break
|
|
46
|
+
case "add":
|
|
47
|
+
await cmdAdd(args[1])
|
|
48
|
+
break
|
|
49
|
+
case "install":
|
|
50
|
+
await cmdInstall(args[1])
|
|
51
|
+
break
|
|
52
|
+
case "remove":
|
|
53
|
+
await cmdRemove(args[1])
|
|
54
|
+
break
|
|
55
|
+
case "update":
|
|
56
|
+
await cmdUpdate(args[1])
|
|
57
|
+
break
|
|
58
|
+
case "cache":
|
|
59
|
+
if (args[1] === "clear") {
|
|
60
|
+
await cmdCacheClear()
|
|
61
|
+
} else {
|
|
62
|
+
console.error(`Unknown cache subcommand: ${args[1]}`)
|
|
63
|
+
console.error('Usage: facets cache clear')
|
|
64
|
+
process.exit(1)
|
|
65
|
+
}
|
|
66
|
+
break
|
|
67
|
+
default:
|
|
68
|
+
console.error(`Unknown command: ${command}`)
|
|
69
|
+
console.log(HELP)
|
|
70
|
+
process.exit(1)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function cmdInit() {
|
|
75
|
+
const { initProject } = await import("./cli/init.ts")
|
|
76
|
+
await initProject(process.cwd())
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function cmdList() {
|
|
80
|
+
const projectRoot = process.cwd()
|
|
81
|
+
const result = await listFacets(projectRoot)
|
|
82
|
+
|
|
83
|
+
if (result.facets.length === 0) {
|
|
84
|
+
console.log("No facets declared.")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const facet of result.facets) {
|
|
89
|
+
const status = facet.installed ? "installed" : "not installed"
|
|
90
|
+
const version = facet.version ? `v${facet.version}` : ""
|
|
91
|
+
const source = facet.source === "local" ? "local" : "remote"
|
|
92
|
+
console.log(` ${facet.name} ${version} (${source}) [${status}]`)
|
|
93
|
+
if (facet.description) {
|
|
94
|
+
console.log(` ${facet.description}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function cmdAdd(url: string | undefined) {
|
|
100
|
+
if (!url) {
|
|
101
|
+
console.error("Usage: facets add <url>")
|
|
102
|
+
process.exit(1)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const projectRoot = process.cwd()
|
|
106
|
+
const result = await cacheFacet(url, projectRoot)
|
|
107
|
+
|
|
108
|
+
if (result.success) {
|
|
109
|
+
console.log(`Cached: ${result.name} v${result.version}`)
|
|
110
|
+
} else {
|
|
111
|
+
console.error(`Failed to cache facet: ${result.error}`)
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function cmdInstall(name: string | undefined) {
|
|
117
|
+
const projectRoot = process.cwd()
|
|
118
|
+
|
|
119
|
+
if (!name) {
|
|
120
|
+
// Install all declared facets
|
|
121
|
+
const list = await listFacets(projectRoot)
|
|
122
|
+
for (const facet of list.facets) {
|
|
123
|
+
if (!facet.installed) {
|
|
124
|
+
const result = await installFacet(facet.name, projectRoot)
|
|
125
|
+
if (result.success) {
|
|
126
|
+
console.log(`Installed: ${facet.name}`)
|
|
127
|
+
} else {
|
|
128
|
+
console.error(`Failed to install ${facet.name}: ${result.reason}`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = await installFacet(name, projectRoot)
|
|
136
|
+
if (result.success) {
|
|
137
|
+
console.log(`Installed: ${name}`)
|
|
138
|
+
for (const r of result.resources) {
|
|
139
|
+
console.log(` ${r.type}: ${r.name}`)
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
console.error(`Failed to install ${name}: ${result.reason}`)
|
|
143
|
+
if (result.reason === "prereq" && "failure" in result) {
|
|
144
|
+
console.error(` Command failed: ${result.failure.command}`)
|
|
145
|
+
}
|
|
146
|
+
process.exit(1)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function cmdRemove(name: string | undefined) {
|
|
151
|
+
if (!name) {
|
|
152
|
+
console.error("Usage: facets remove <name>")
|
|
153
|
+
process.exit(1)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const projectRoot = process.cwd()
|
|
157
|
+
const result = await uninstallFacet(name, projectRoot)
|
|
158
|
+
|
|
159
|
+
if (result.success) {
|
|
160
|
+
console.log(`Removed: ${name}`)
|
|
161
|
+
} else {
|
|
162
|
+
console.error(`Failed to remove ${name}: ${result.reason}`)
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function cmdUpdate(name: string | undefined) {
|
|
168
|
+
const { updateFacet, updateAllFacets } = await import("./discovery/cache.ts")
|
|
169
|
+
|
|
170
|
+
if (name) {
|
|
171
|
+
const result = await updateFacet(name, process.cwd())
|
|
172
|
+
if (result.success) {
|
|
173
|
+
if (result.updated) {
|
|
174
|
+
console.log(`Updated: ${name} → v${result.version}`)
|
|
175
|
+
} else {
|
|
176
|
+
console.log(`${name}: already current (v${result.version})`)
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
console.error(`Failed to update ${name}: ${result.error}`)
|
|
180
|
+
process.exit(1)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
const results = await updateAllFacets(process.cwd())
|
|
184
|
+
for (const result of results) {
|
|
185
|
+
if (result.success) {
|
|
186
|
+
if (result.updated) {
|
|
187
|
+
console.log(`Updated: ${result.name} → v${result.version}`)
|
|
188
|
+
} else {
|
|
189
|
+
console.log(`${result.name}: already current`)
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
console.error(`Failed to update ${result.name}: ${result.error}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function cmdCacheClear() {
|
|
199
|
+
await clearCache()
|
|
200
|
+
console.log("Cache cleared.")
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
main().catch((err) => {
|
|
204
|
+
console.error(err)
|
|
205
|
+
process.exit(1)
|
|
206
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { listFacets } from "../list.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 projectRoot: string
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
projectRoot = await mkdtemp(path.join(tmpdir(), "facets-test-"))
|
|
12
|
+
await Bun.$`mkdir -p ${projectRoot}/.opencode/facets`
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(projectRoot, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
async function writeLocalFacet(name: string, manifest: Record<string, unknown>) {
|
|
20
|
+
const dir = `${projectRoot}/.opencode/facets/${name}`
|
|
21
|
+
await Bun.$`mkdir -p ${dir}`
|
|
22
|
+
await Bun.write(`${dir}/facet.yaml`, yaml.dump(manifest))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("listFacets", () => {
|
|
26
|
+
test("returns empty list when no facets exist", async () => {
|
|
27
|
+
const result = await listFacets(projectRoot)
|
|
28
|
+
expect(result.facets).toEqual([])
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("includes local facets", async () => {
|
|
32
|
+
await writeLocalFacet("test-facet", {
|
|
33
|
+
name: "test-facet",
|
|
34
|
+
version: "1.0.0",
|
|
35
|
+
description: "A test facet",
|
|
36
|
+
skills: ["my-skill"],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const result = await listFacets(projectRoot)
|
|
40
|
+
expect(result.facets).toHaveLength(1)
|
|
41
|
+
expect(result.facets[0]!.name).toBe("test-facet")
|
|
42
|
+
expect(result.facets[0]!.version).toBe("1.0.0")
|
|
43
|
+
expect(result.facets[0]!.source).toBe("local")
|
|
44
|
+
expect(result.facets[0]!.installed).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("reports installed status correctly", async () => {
|
|
48
|
+
await writeLocalFacet("test-facet", {
|
|
49
|
+
name: "test-facet",
|
|
50
|
+
version: "1.0.0",
|
|
51
|
+
skills: ["my-skill"],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// Not installed yet
|
|
55
|
+
let result = await listFacets(projectRoot)
|
|
56
|
+
expect(result.facets[0]!.installed).toBe(false)
|
|
57
|
+
|
|
58
|
+
// Create the expected installed file
|
|
59
|
+
await Bun.$`mkdir -p ${projectRoot}/.opencode/skills/my-skill`
|
|
60
|
+
await Bun.write(`${projectRoot}/.opencode/skills/my-skill/SKILL.md`, "# Skill")
|
|
61
|
+
|
|
62
|
+
result = await listFacets(projectRoot)
|
|
63
|
+
expect(result.facets[0]!.installed).toBe(true)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("includes requires as metadata", async () => {
|
|
67
|
+
await writeLocalFacet("test-facet", {
|
|
68
|
+
name: "test-facet",
|
|
69
|
+
version: "1.0.0",
|
|
70
|
+
requires: ["gh --version"],
|
|
71
|
+
skills: ["s"],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const result = await listFacets(projectRoot)
|
|
75
|
+
expect(result.facets[0]!.requires).toEqual(["gh --version"])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("lists resource summaries", async () => {
|
|
79
|
+
await writeLocalFacet("test-facet", {
|
|
80
|
+
name: "test-facet",
|
|
81
|
+
version: "1.0.0",
|
|
82
|
+
skills: ["my-skill"],
|
|
83
|
+
agents: { "my-agent": { prompt: "prompts/a.md" } },
|
|
84
|
+
commands: { "my-cmd": { prompt: "prompts/c.md" } },
|
|
85
|
+
platforms: { opencode: { tools: ["my-tool"] } },
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const result = await listFacets(projectRoot)
|
|
89
|
+
const resources = result.facets[0]!.resources
|
|
90
|
+
expect(resources).toContainEqual({ type: "skill", name: "my-skill" })
|
|
91
|
+
expect(resources).toContainEqual({ type: "agent", name: "my-agent" })
|
|
92
|
+
expect(resources).toContainEqual({ type: "command", name: "my-cmd" })
|
|
93
|
+
expect(resources).toContainEqual({ type: "tool", name: "my-tool" })
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("includes remote facets from facets.yaml", async () => {
|
|
97
|
+
const facetsYaml = {
|
|
98
|
+
remote: {
|
|
99
|
+
"remote-facet": {
|
|
100
|
+
url: "https://example.com/facet.yaml",
|
|
101
|
+
version: "2.0.0",
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
await Bun.write(`${projectRoot}/.opencode/facets.yaml`, yaml.dump(facetsYaml))
|
|
106
|
+
|
|
107
|
+
const result = await listFacets(projectRoot)
|
|
108
|
+
expect(result.facets).toHaveLength(1)
|
|
109
|
+
expect(result.facets[0]!.name).toBe("remote-facet")
|
|
110
|
+
expect(result.facets[0]!.source).toBe("remote")
|
|
111
|
+
// Not cached, so shows minimal info
|
|
112
|
+
expect(result.facets[0]!.version).toBe("2.0.0")
|
|
113
|
+
})
|
|
114
|
+
})
|