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