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