@cyber-dash-tech/revela 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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -0
  3. package/README.zh-CN.md +270 -0
  4. package/designs/default/DESIGN.md +1100 -0
  5. package/designs/editorial-ribbon/DESIGN.md +1092 -0
  6. package/designs/minimal/DESIGN.md +1079 -0
  7. package/domains/consulting/INDUSTRY.md +230 -0
  8. package/domains/deeptech-investment/INDUSTRY.md +160 -0
  9. package/domains/general/INDUSTRY.md +6 -0
  10. package/index.ts +1 -0
  11. package/lib/agents/research-prompt.ts +129 -0
  12. package/lib/commands/designs.ts +59 -0
  13. package/lib/commands/disable.ts +14 -0
  14. package/lib/commands/domains.ts +59 -0
  15. package/lib/commands/enable.ts +48 -0
  16. package/lib/commands/help.ts +35 -0
  17. package/lib/config.ts +65 -0
  18. package/lib/ctx.ts +27 -0
  19. package/lib/design/designs.ts +389 -0
  20. package/lib/domain/domains.ts +258 -0
  21. package/lib/frontmatter.ts +63 -0
  22. package/lib/log.ts +35 -0
  23. package/lib/prompt-builder.ts +194 -0
  24. package/lib/qa/checks.ts +594 -0
  25. package/lib/qa/index.ts +38 -0
  26. package/lib/qa/measure.ts +287 -0
  27. package/lib/read-hooks/extractors/docx.ts +16 -0
  28. package/lib/read-hooks/extractors/pdf.ts +19 -0
  29. package/lib/read-hooks/extractors/pptx.ts +53 -0
  30. package/lib/read-hooks/extractors/xlsx.ts +81 -0
  31. package/lib/read-hooks/image/compress.ts +36 -0
  32. package/lib/read-hooks/index.ts +12 -0
  33. package/lib/read-hooks/post-read.ts +74 -0
  34. package/lib/read-hooks/pre-read.ts +51 -0
  35. package/package.json +65 -0
  36. package/plugin.ts +365 -0
  37. package/skill/SKILL.md +676 -0
  38. package/tools/designs.ts +126 -0
  39. package/tools/domains.ts +73 -0
  40. package/tools/qa.ts +61 -0
  41. package/tools/research-save.ts +96 -0
  42. package/tools/workspace-scan.ts +154 -0
@@ -0,0 +1,48 @@
1
+ /**
2
+ * lib/commands/enable.ts
3
+ *
4
+ * Handler for `/revela enable` — activates slide generation mode.
5
+ */
6
+
7
+ import { existsSync } from "fs"
8
+ import { activeDesign } from "../design/designs"
9
+ import { activeDomain } from "../domain/domains"
10
+ import { ctx } from "../ctx"
11
+ import { ACTIVE_PROMPT_FILE } from "../config"
12
+ import { buildPrompt } from "../prompt-builder"
13
+ import { log } from "../log"
14
+
15
+ export async function handleEnable(
16
+ send: (text: string) => Promise<void>,
17
+ ): Promise<void> {
18
+ ctx.enabled = true
19
+ const design = activeDesign()
20
+ const domain = activeDomain()
21
+
22
+ // Health check: ensure the active prompt file exists.
23
+ // If startup failed (e.g. SKILL.md missing), rebuild now so the user gets a working session.
24
+ if (!existsSync(ACTIVE_PROMPT_FILE)) {
25
+ log.warn("active prompt file missing on enable — rebuilding", { promptFile: ACTIVE_PROMPT_FILE })
26
+ try {
27
+ buildPrompt()
28
+ log.info("prompt rebuilt on enable", { design, domain, promptFile: ACTIVE_PROMPT_FILE })
29
+ } catch (e) {
30
+ log.error("prompt rebuild failed on enable", { error: e instanceof Error ? e.message : String(e) })
31
+ await send(
32
+ `**Revela enabled (with warnings).** Slide generation mode is active, ` +
33
+ `but the prompt file could not be built. ` +
34
+ `Try \`/revela disable\` then \`/revela enable\` again, or check that the package is correctly installed.\n\n` +
35
+ `Design: \`${design}\` · Domain: \`${domain}\``
36
+ )
37
+ return
38
+ }
39
+ }
40
+
41
+ log.info("revela enabled", { design, domain })
42
+ await send(
43
+ `**Revela enabled.** Slide generation mode is now active.\n` +
44
+ `Design: \`${design}\` · Domain: \`${domain}\`\n\n` +
45
+ `The three-layer slide generation prompt will be injected into every message. ` +
46
+ `Describe your presentation to get started.`
47
+ )
48
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * lib/commands/help.ts
3
+ *
4
+ * Handler for `/revela` (no sub-command) — shows status and command reference.
5
+ */
6
+
7
+ import { activeDesign } from "../design/designs"
8
+ import { activeDomain } from "../domain/domains"
9
+ import { ctx } from "../ctx"
10
+
11
+ export async function handleHelp(
12
+ send: (text: string) => Promise<void>,
13
+ ): Promise<void> {
14
+ const design = activeDesign()
15
+ const domain = activeDomain()
16
+ const status = ctx.enabled ? "enabled ✓" : "disabled"
17
+ await send(
18
+ `\`\`\`\n` +
19
+ ` R E V E L A\n` +
20
+ `\`\`\`\n` +
21
+ `**Status:** ${status}\n` +
22
+ `🟠 **Design:** \`${design}\`\n` +
23
+ `🟠 **Domain:** \`${domain}\`\n\n` +
24
+ `---\n\n` +
25
+ `**Commands**\n\n` +
26
+ `\`/revela enable\` — enable slide generation mode\n` +
27
+ `\`/revela disable\` — disable slide generation mode\n` +
28
+ `\`/revela designs\` — list installed designs\n` +
29
+ `\`/revela designs <name>\` — activate a design\n` +
30
+ `\`/revela domains\` — list installed domains\n` +
31
+ `\`/revela domains <name>\` — activate a domain\n` +
32
+ `\`/revela designs-add <url>\` — install a design from URL / github:user/repo\n` +
33
+ `\`/revela domains-add <url>\` — install a domain from URL / github:user/repo`
34
+ )
35
+ }
package/lib/config.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Config file management for revela.
3
+ *
4
+ * Reads/writes `~/.config/revela/config.json`.
5
+ * All paths are derived from CONFIG_DIR.
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
9
+ import { homedir } from "os"
10
+ import { join } from "path"
11
+ import { childLog } from "./log"
12
+
13
+ const configLog = childLog("config")
14
+
15
+ /** Root config directory for revela runtime data. */
16
+ export const CONFIG_DIR = join(homedir(), ".config", "revela")
17
+
18
+ /** Directory where installed designs are stored at runtime. */
19
+ export const DESIGNS_DIR = join(CONFIG_DIR, "designs")
20
+
21
+ /** Directory where installed domains are stored at runtime. */
22
+ export const DOMAINS_DIR = join(CONFIG_DIR, "domains")
23
+
24
+ /** Path to the main config file. */
25
+ export const CONFIG_FILE = join(CONFIG_DIR, "config.json")
26
+
27
+ /** Path to the dynamically generated system prompt. */
28
+ export const ACTIVE_PROMPT_FILE = join(CONFIG_DIR, "_active-prompt.md")
29
+
30
+ /** Default design name. */
31
+ export const DEFAULT_DESIGN = "default"
32
+
33
+ /** Default domain name. */
34
+ export const DEFAULT_DOMAIN = "general"
35
+
36
+ export interface SlidesConfig {
37
+ activeDesign?: string
38
+ activeDomain?: string
39
+ /** Legacy key — fallback for activeDesign. */
40
+ activeTemplate?: string
41
+ /** Legacy key — fallback for activeDomain. */
42
+ activeIndustry?: string
43
+ [key: string]: string | undefined
44
+ }
45
+
46
+ /** Load config.json, returning empty object on any error. */
47
+ export function loadConfig(): SlidesConfig {
48
+ try {
49
+ if (existsSync(CONFIG_FILE)) {
50
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"))
51
+ }
52
+ } catch (e) {
53
+ configLog.warn("config.json is corrupt or unreadable — using defaults", {
54
+ configFile: CONFIG_FILE,
55
+ error: e instanceof Error ? e.message : String(e),
56
+ })
57
+ }
58
+ return {}
59
+ }
60
+
61
+ /** Write config.json atomically. Creates parent dirs if needed. */
62
+ export function saveConfig(config: SlidesConfig): void {
63
+ mkdirSync(CONFIG_DIR, { recursive: true })
64
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8")
65
+ }
package/lib/ctx.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * lib/ctx.ts — Revela Global Runtime Context
3
+ *
4
+ * A session-level singleton shared across all modules: plugin hooks,
5
+ * read-hooks, future subagents, and any other feature modules.
6
+ *
7
+ * Lifecycle: resets on OpenCode restart. NOT persisted to config.json.
8
+ * For persistent user preferences (activeDesign, activeDomain), see lib/config.ts.
9
+ */
10
+
11
+ export interface RevelaCtx {
12
+ /** Master switch — controls prompt injection, read hooks, subagents, etc. */
13
+ enabled: boolean
14
+
15
+ /**
16
+ * True when the current LLM request originates from the revela-research subagent.
17
+ * Set in experimental.chat.system.transform by detecting RESEARCH_AGENT_SIGNATURE.
18
+ * Used by tool.execute.before to allow websearch for research agents only.
19
+ */
20
+ isResearchAgent: boolean
21
+ }
22
+
23
+ /** Global singleton. Import and use directly from any module. */
24
+ export const ctx: RevelaCtx = {
25
+ enabled: false,
26
+ isResearchAgent: false,
27
+ }
@@ -0,0 +1,389 @@
1
+ /**
2
+ * DesignManager — manage revela visual design templates.
3
+ *
4
+ * Designs are stored in ~/.config/revela/designs/<name>/.
5
+ * Each design directory contains DESIGN.md (required) and optionally preview.html.
6
+ *
7
+ * Built-in designs are shipped with the npm package under designs/ and seeded
8
+ * to the config directory on first run.
9
+ */
10
+
11
+ import {
12
+ cpSync,
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from "fs"
21
+ import { join, resolve, basename } from "path"
22
+ import { tmpdir } from "os"
23
+ import { parseFrontmatter } from "../frontmatter"
24
+ import {
25
+ DESIGNS_DIR,
26
+ DEFAULT_DESIGN,
27
+ loadConfig,
28
+ saveConfig,
29
+ } from "../config"
30
+ import { childLog } from "../log"
31
+
32
+ const designLog = childLog("designs")
33
+
34
+ // Seed directory: built-in designs shipped with this package.
35
+ const SEED_DIR = resolve(__dirname, "../..", "designs")
36
+
37
+ export interface DesignInfo {
38
+ name: string
39
+ description: string
40
+ author: string
41
+ version: string
42
+ skillText: string
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Seed
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Copy built-in designs from the package to ~/.config/revela/designs/.
51
+ * Always overwrites to keep bundled designs up to date.
52
+ * User-created designs (not in the seed directory) are never touched.
53
+ */
54
+ export function seedBuiltinDesigns(): void {
55
+ if (!existsSync(SEED_DIR)) return
56
+ mkdirSync(DESIGNS_DIR, { recursive: true })
57
+
58
+ for (const entry of readdirSync(SEED_DIR)) {
59
+ const src = join(SEED_DIR, entry)
60
+ if (!statSync(src).isDirectory()) continue
61
+ if (!existsSync(join(src, "DESIGN.md"))) continue
62
+
63
+ const dst = join(DESIGNS_DIR, entry)
64
+ mkdirSync(dst, { recursive: true })
65
+ cpSync(src, dst, { recursive: true, force: true })
66
+ }
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Parse
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /** Parse a DESIGN.md file into DesignInfo. Returns null on any error. */
74
+ export function parseDesignFile(filePath: string): DesignInfo | null {
75
+ try {
76
+ const text = readFileSync(filePath, "utf-8")
77
+ const { meta, body } = parseFrontmatter(text)
78
+ return {
79
+ name: meta.name || basename(join(filePath, "..")),
80
+ description: meta.description || "",
81
+ author: meta.author || "unknown",
82
+ version: meta.version || "0.0.0",
83
+ skillText: body,
84
+ }
85
+ } catch (e) {
86
+ designLog.warn("failed to parse design file — skipping", {
87
+ filePath,
88
+ error: e instanceof Error ? e.message : String(e),
89
+ })
90
+ return null
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Public API
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /** List all installed designs, sorted by name. */
99
+ export function listDesigns(): DesignInfo[] {
100
+ if (!existsSync(DESIGNS_DIR)) return []
101
+ const results: DesignInfo[] = []
102
+
103
+ for (const entry of readdirSync(DESIGNS_DIR).sort()) {
104
+ const dir = join(DESIGNS_DIR, entry)
105
+ if (!statSync(dir).isDirectory()) continue
106
+ const mdPath = join(dir, "DESIGN.md")
107
+ if (!existsSync(mdPath)) continue
108
+ const info = parseDesignFile(mdPath)
109
+ if (info) results.push(info)
110
+ }
111
+ return results
112
+ }
113
+
114
+ /** Get the name of the currently active design. */
115
+ export function activeDesign(): string {
116
+ const cfg = loadConfig()
117
+ return cfg.activeDesign || cfg.activeTemplate || DEFAULT_DESIGN
118
+ }
119
+
120
+ /** Set the active design. Throws if design is not installed. */
121
+ export function activateDesign(name: string): void {
122
+ if (!designExists(name)) {
123
+ throw new Error(`Design '${name}' is not installed`)
124
+ }
125
+ const cfg = loadConfig()
126
+ cfg.activeDesign = name
127
+ saveConfig(cfg)
128
+ }
129
+
130
+ /** Get the skill text body from a design's DESIGN.md. */
131
+ export function getDesignSkillMd(name?: string): string {
132
+ const designName = name || activeDesign()
133
+ const mdPath = join(DESIGNS_DIR, designName, "DESIGN.md")
134
+ if (!existsSync(mdPath)) {
135
+ throw new Error(`Design '${designName}' is not installed`)
136
+ }
137
+ const info = parseDesignFile(mdPath)
138
+ if (!info) {
139
+ throw new Error(`Failed to parse DESIGN.md for '${designName}'`)
140
+ }
141
+ return info.skillText
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Marker-based section / component parsing
146
+ // ---------------------------------------------------------------------------
147
+
148
+ export interface DesignSections {
149
+ /** Map of section name → extracted content (without marker lines). */
150
+ sections: Record<string, string>
151
+ /** Map of component name → extracted content (without marker lines). */
152
+ components: Record<string, string>
153
+ /** Whether the DESIGN.md has any markers at all. */
154
+ hasMarkers: boolean
155
+ }
156
+
157
+ /**
158
+ * Parse a DESIGN.md body (no frontmatter) into sections and components
159
+ * using the two-layer HTML comment marker convention:
160
+ * <!-- @section:<name>:start --> … <!-- @section:<name>:end -->
161
+ * <!-- @component:<name>:start --> … <!-- @component:<name>:end -->
162
+ *
163
+ * Returns an object with empty maps and hasMarkers=false when no markers found.
164
+ */
165
+ export function parseDesignSections(body: string): DesignSections {
166
+ const sections: Record<string, string> = {}
167
+ const components: Record<string, string> = {}
168
+
169
+ const sectionRe = /<!--\s*@section:(\w[\w-]*):start\s*-->([\s\S]*?)<!--\s*@section:\1:end\s*-->/g
170
+ const componentRe = /<!--\s*@component:(\w[\w-]*):start\s*-->([\s\S]*?)<!--\s*@component:\1:end\s*-->/g
171
+
172
+ let hasMarkers = false
173
+ let match: RegExpExecArray | null
174
+
175
+ while ((match = sectionRe.exec(body)) !== null) {
176
+ hasMarkers = true
177
+ sections[match[1]] = match[2].trim()
178
+ }
179
+
180
+ while ((match = componentRe.exec(body)) !== null) {
181
+ hasMarkers = true
182
+ components[match[1]] = match[2].trim()
183
+ }
184
+
185
+ return { sections, components, hasMarkers }
186
+ }
187
+
188
+ /**
189
+ * Generate a compact Component Index table from parsed components.
190
+ * Lists each component name with a one-line description (first non-empty
191
+ * text line of the component block, stripped of markdown heading markers).
192
+ */
193
+ export function generateComponentIndex(components: Record<string, string>): string {
194
+ const names = Object.keys(components)
195
+ if (names.length === 0) return ""
196
+
197
+ const rows = names.map((name) => {
198
+ const body = components[name]
199
+ // Extract first non-empty non-marker line as a short description
200
+ const firstLine = body
201
+ .split("\n")
202
+ .map((l) => l.trim())
203
+ .find((l) => l && !l.startsWith("<!--") && !l.startsWith("```"))
204
+ // Strip markdown heading markers
205
+ const desc = firstLine
206
+ ? firstLine.replace(/^#+\s*/, "").replace(/\(.*?\)/, "").trim()
207
+ : ""
208
+ return `| \`${name}\` | ${desc} |`
209
+ })
210
+
211
+ return [
212
+ "### Component Index",
213
+ "",
214
+ "| Component | Description |",
215
+ "|---|---|",
216
+ ...rows,
217
+ "",
218
+ "_Use `revela-designs` tool with `action: \"read\"` and `component: \"<name>\"` to get full CSS/HTML for any component._",
219
+ ].join("\n")
220
+ }
221
+
222
+ /**
223
+ * Get the raw text of a named section from a DESIGN.md.
224
+ * Throws if the design is not installed or the section doesn't exist.
225
+ */
226
+ export function getDesignSection(sectionName: string, designName?: string): string {
227
+ const name = designName || activeDesign()
228
+ const mdPath = join(DESIGNS_DIR, name, "DESIGN.md")
229
+ if (!existsSync(mdPath)) {
230
+ throw new Error(`Design '${name}' is not installed`)
231
+ }
232
+ const text = readFileSync(mdPath, "utf-8")
233
+ const { body } = parseFrontmatter(text)
234
+ const { sections, hasMarkers } = parseDesignSections(body)
235
+
236
+ if (!hasMarkers) {
237
+ throw new Error(`Design '${name}' has no markers — use getDesignSkillMd() for full text`)
238
+ }
239
+ if (!(sectionName in sections)) {
240
+ throw new Error(`Section '${sectionName}' not found in design '${name}'`)
241
+ }
242
+ return sections[sectionName]
243
+ }
244
+
245
+ /**
246
+ * Get the raw text of one or more named components from a DESIGN.md.
247
+ * @param componentNames - Comma-separated component names or an array.
248
+ * @param designName - Design to read from (defaults to active).
249
+ */
250
+ export function getDesignComponent(
251
+ componentNames: string | string[],
252
+ designName?: string,
253
+ ): string {
254
+ const name = designName || activeDesign()
255
+ const mdPath = join(DESIGNS_DIR, name, "DESIGN.md")
256
+ if (!existsSync(mdPath)) {
257
+ throw new Error(`Design '${name}' is not installed`)
258
+ }
259
+ const text = readFileSync(mdPath, "utf-8")
260
+ const { body } = parseFrontmatter(text)
261
+ const { components, hasMarkers } = parseDesignSections(body)
262
+
263
+ if (!hasMarkers) {
264
+ throw new Error(`Design '${name}' has no markers — use getDesignSkillMd() for full text`)
265
+ }
266
+
267
+ const names = Array.isArray(componentNames)
268
+ ? componentNames
269
+ : componentNames.split(",").map((s) => s.trim())
270
+
271
+ const parts: string[] = []
272
+ for (const compName of names) {
273
+ if (!(compName in components)) {
274
+ throw new Error(`Component '${compName}' not found in design '${name}'`)
275
+ }
276
+ parts.push(`### Component: ${compName}\n\n${components[compName]}`)
277
+ }
278
+ return parts.join("\n\n---\n\n")
279
+ }
280
+
281
+ /** Remove an installed design. Throws if not found. */
282
+ export function removeDesign(name: string): void {
283
+ const dir = join(DESIGNS_DIR, name)
284
+ if (!existsSync(dir)) {
285
+ throw new Error(`Design '${name}' is not installed`)
286
+ }
287
+ rmSync(dir, { recursive: true, force: true })
288
+ // Reset active design if it was the removed one
289
+ if (activeDesign() === name) {
290
+ activateDesign(DEFAULT_DESIGN)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Install a design from a source.
296
+ *
297
+ * Supported sources:
298
+ * - Local path (starts with `./ ` or `/` or exists on disk)
299
+ * - URL (starts with `http://` or `https://`) — downloads zip
300
+ * - GitHub shorthand `github:user/repo` — converted to zip URL
301
+ *
302
+ * Returns the installed design name.
303
+ */
304
+ export async function installDesign(
305
+ source: string,
306
+ name?: string,
307
+ ): Promise<string> {
308
+ if (source.startsWith("http://") || source.startsWith("https://")) {
309
+ return installFromUrl(source, name)
310
+ }
311
+ if (source.startsWith("github:")) {
312
+ const repo = source.slice("github:".length)
313
+ const url = `https://github.com/${repo}/archive/refs/heads/main.zip`
314
+ return installFromUrl(url, name)
315
+ }
316
+ // Local path
317
+ return installFromPath(source, name)
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // Private helpers
322
+ // ---------------------------------------------------------------------------
323
+
324
+ function designExists(name: string): boolean {
325
+ const dir = join(DESIGNS_DIR, name)
326
+ return existsSync(dir) && existsSync(join(dir, "DESIGN.md"))
327
+ }
328
+
329
+ function installFromPath(srcPath: string, name?: string): string {
330
+ const resolved = resolve(srcPath)
331
+ if (!existsSync(resolved)) {
332
+ throw new Error(`Path does not exist: ${resolved}`)
333
+ }
334
+ if (!existsSync(join(resolved, "DESIGN.md"))) {
335
+ throw new Error(`No DESIGN.md found in ${resolved}`)
336
+ }
337
+ const info = parseDesignFile(join(resolved, "DESIGN.md"))
338
+ const designName = name || info?.name || basename(resolved)
339
+ const target = join(DESIGNS_DIR, designName)
340
+
341
+ mkdirSync(DESIGNS_DIR, { recursive: true })
342
+ if (existsSync(target)) {
343
+ rmSync(target, { recursive: true, force: true })
344
+ }
345
+ cpSync(resolved, target, { recursive: true })
346
+ return designName
347
+ }
348
+
349
+ async function installFromUrl(url: string, name?: string): Promise<string> {
350
+ // Download zip to temp dir
351
+ const tmp = join(tmpdir(), `revela-design-${Date.now()}`)
352
+ mkdirSync(tmp, { recursive: true })
353
+
354
+ try {
355
+ const zipPath = join(tmp, "design.zip")
356
+ const response = await fetch(url)
357
+ if (!response.ok) {
358
+ throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
359
+ }
360
+ const buffer = new Uint8Array(await response.arrayBuffer())
361
+ writeFileSync(zipPath, buffer)
362
+
363
+ // Extract using Bun's built-in or system unzip
364
+ const extractDir = join(tmp, "extracted")
365
+ mkdirSync(extractDir)
366
+
367
+ // Use system unzip (available on macOS/Linux)
368
+ const proc = Bun.spawnSync(["unzip", "-q", "-o", zipPath, "-d", extractDir])
369
+ if (proc.exitCode !== 0) {
370
+ throw new Error(`Failed to extract zip: ${proc.stderr.toString()}`)
371
+ }
372
+
373
+ // Find DESIGN.md in extracted contents (GitHub zips wrap in a subdirectory)
374
+ const candidates = [extractDir]
375
+ for (const entry of readdirSync(extractDir)) {
376
+ const p = join(extractDir, entry)
377
+ if (statSync(p).isDirectory()) candidates.push(p)
378
+ }
379
+
380
+ for (const candidate of candidates) {
381
+ if (existsSync(join(candidate, "DESIGN.md"))) {
382
+ return installFromPath(candidate, name)
383
+ }
384
+ }
385
+ throw new Error("No DESIGN.md found inside the downloaded zip")
386
+ } finally {
387
+ rmSync(tmp, { recursive: true, force: true })
388
+ }
389
+ }