wicked-interactive 0.4.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,124 @@
1
+ // export.js — export a version to a self-contained interactive HTML or a PDF (ADR-0009).
2
+ //
3
+ // HTML: inline local stylesheets, scripts, images (data-URI), and url() refs inside inlined
4
+ // CSS, so the file renders + stays interactive opened straight from disk (no server).
5
+ // PDF: render the self-contained HTML via headless Chrome (the primitive wicked-prezzie
6
+ // wraps; wicked-prezzie itself is a plugin/skill, not an importable library).
7
+
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { join, dirname, resolve } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import * as cheerio from "cheerio";
12
+ import { readVersionHtml } from "./fsstore.js";
13
+
14
+ const MIME = {
15
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
16
+ ".svg": "image/svg+xml", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2",
17
+ ".ttf": "font/ttf", ".otf": "font/otf", ".css": "text/css", ".js": "application/javascript",
18
+ };
19
+ const ext = (p) => { const i = p.lastIndexOf("."); return i < 0 ? "" : p.slice(i).toLowerCase(); };
20
+ const isLocal = (url) => url && !/^(https?:)?\/\//.test(url) && !url.startsWith("data:") && !url.startsWith("#");
21
+
22
+ function dataUri(absPath) {
23
+ const mime = MIME[ext(absPath)] || "application/octet-stream";
24
+ return `data:${mime};base64,${readFileSync(absPath).toString("base64")}`;
25
+ }
26
+
27
+ // Rewrite url(local) inside CSS to data-URIs, resolved relative to the CSS file's dir.
28
+ function inlineCssUrls(css, cssDir) {
29
+ return css.replace(/url\(\s*['"]?([^'")]+)['"]?\s*\)/g, (m, url) => {
30
+ if (!isLocal(url)) return m;
31
+ const abs = resolve(cssDir, url);
32
+ return existsSync(abs) ? `url(${dataUri(abs)})` : m;
33
+ });
34
+ }
35
+
36
+ /**
37
+ * Produce a self-contained version of `html`. Local assets are resolved against `baseDir`.
38
+ * @returns {string}
39
+ */
40
+ export function inlineHtml(html, { baseDir }) {
41
+ const $ = cheerio.load(html);
42
+
43
+ $('link[rel="stylesheet"]').each((_, el) => {
44
+ const href = $(el).attr("href");
45
+ if (!isLocal(href)) return;
46
+ const abs = resolve(baseDir, href);
47
+ if (!existsSync(abs)) return;
48
+ const css = inlineCssUrls(readFileSync(abs, "utf-8"), dirname(abs));
49
+ $(el).replaceWith(`<style>${css}</style>`);
50
+ });
51
+
52
+ $("script[src]").each((_, el) => {
53
+ const src = $(el).attr("src");
54
+ if (!isLocal(src)) return;
55
+ const abs = resolve(baseDir, src);
56
+ if (!existsSync(abs)) return;
57
+ $(el).removeAttr("src").text(readFileSync(abs, "utf-8"));
58
+ });
59
+
60
+ $("img[src]").each((_, el) => {
61
+ const src = $(el).attr("src");
62
+ if (!isLocal(src)) return;
63
+ const abs = resolve(baseDir, src);
64
+ if (existsSync(abs)) $(el).attr("src", dataUri(abs));
65
+ });
66
+
67
+ // Inline any remaining <style> blocks' url() refs (baseDir-relative).
68
+ $("style").each((_, el) => {
69
+ const css = $(el).html();
70
+ if (css && css.includes("url(")) $(el).text(inlineCssUrls(css, baseDir));
71
+ });
72
+
73
+ return $.html();
74
+ }
75
+
76
+ function exportsDir(dir) {
77
+ const out = join(dir, "exports");
78
+ mkdirSync(out, { recursive: true });
79
+ return out;
80
+ }
81
+
82
+ /** Export version → self-contained HTML. @returns {{ path: string, bytes: number }} */
83
+ export function exportHtml(dir, version, outPath) {
84
+ const html = inlineHtml(readVersionHtml(dir, version), { baseDir: dir });
85
+ const path = outPath || join(exportsDir(dir), `export_v${version}.html`);
86
+ writeFileSync(path, html);
87
+ return { path, bytes: Buffer.byteLength(html) };
88
+ }
89
+
90
+ /** Locate a Chrome/Chromium binary (env override wins). */
91
+ export function findChrome(override) {
92
+ const candidates = [
93
+ override, process.env.WI_CHROME,
94
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
95
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
96
+ "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser",
97
+ ].filter(Boolean);
98
+ return candidates.find((c) => existsSync(c)) || null;
99
+ }
100
+
101
+ /** Default PDF renderer: headless Chrome --print-to-pdf over the self-contained HTML. */
102
+ export function chromeRenderer(htmlPath, pdfPath, { chromePath } = {}) {
103
+ const chrome = findChrome(chromePath);
104
+ if (!chrome) throw new Error("no Chrome/Chromium found for PDF render (set WI_CHROME)");
105
+ const r = spawnSync(chrome, [
106
+ "--headless=new", "--disable-gpu", "--no-sandbox",
107
+ "--no-pdf-header-footer", `--print-to-pdf=${pdfPath}`, `file://${htmlPath}`,
108
+ ], { timeout: 60000 });
109
+ if (r.status !== 0 || !existsSync(pdfPath)) {
110
+ throw new Error(`chrome PDF render failed (status ${r.status}): ${r.stderr?.toString().slice(0, 300)}`);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Export version → PDF. First builds the self-contained HTML, then renders it.
116
+ * `renderer(htmlPath, pdfPath, opts)` is injectable (tests pass a fake).
117
+ * @returns {{ path: string }}
118
+ */
119
+ export function exportPdf(dir, version, outPath, { renderer = chromeRenderer, chromePath } = {}) {
120
+ const { path: htmlPath } = exportHtml(dir, version, join(exportsDir(dir), `export_v${version}.pdf.html`));
121
+ const path = outPath || join(exportsDir(dir), `export_v${version}.pdf`);
122
+ renderer(htmlPath, path, { chromePath });
123
+ return { path };
124
+ }
@@ -0,0 +1,26 @@
1
+ // fsstore.js — shared on-disk primitives for a document workspace. Kept separate so both
2
+ // workspace.js and structural.js can use them without importing each other (no cycle).
3
+
4
+ import { readFileSync, writeFileSync, renameSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ const MANIFEST = "versions.json";
8
+
9
+ /** Atomic write: temp file + rename (so a watcher never reads a half-written file). */
10
+ export function atomicWrite(path, content) {
11
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
12
+ writeFileSync(tmp, content);
13
+ renameSync(tmp, path);
14
+ }
15
+
16
+ export function loadManifest(dir) {
17
+ return JSON.parse(readFileSync(join(dir, MANIFEST), "utf-8"));
18
+ }
19
+
20
+ export function saveManifest(dir, manifest) {
21
+ atomicWrite(join(dir, MANIFEST), JSON.stringify(manifest, null, 2));
22
+ }
23
+
24
+ export function readVersionHtml(dir, version) {
25
+ return readFileSync(join(dir, `_v${version}.html`), "utf-8");
26
+ }
@@ -0,0 +1,105 @@
1
+ // generation.js — delegate "build a document from my content" to the supervising agent
2
+ // (ADR-0010). The service is model-free: it cannot index files or generate HTML. So when a
3
+ // doc is created with kind:"source", the service seeds a placeholder v0 and writes
4
+ // requests/_gen.request.json; the agent reads the source materials, drives wicked-prezzie /
5
+ // wicked-brain, and writes requests/_gen.response.json with the full first draft. The
6
+ // service instruments + themes it and lands it as a follow-on version.
7
+ //
8
+ // Unlike a structural edit, the generated draft is a whole new document — there are no
9
+ // pre-existing data-wid anchors to preserve (the placeholder v0's anchors are throwaway),
10
+ // so instrument() simply assigns fresh ones.
11
+
12
+ import { readFileSync, mkdirSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { instrument } from "../core/instrument.js";
15
+ import { themed } from "./theme-source.js";
16
+ import { recordVersion, nextVersionNumber, getVersion } from "../core/versions.js";
17
+ import { atomicWrite, loadManifest, saveManifest } from "./fsstore.js";
18
+
19
+ export const REQUESTS_DIR = "requests";
20
+ export const GEN_REQUEST = "_gen.request.json";
21
+ export const GEN_RESPONSE = "_gen.response.json";
22
+
23
+ /** Placeholder shown at v0 while the agent builds the real draft from the user's content. */
24
+ export function generationPlaceholder(name, sourcePaths, brief = "") {
25
+ const safe = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
26
+ const paths = (Array.isArray(sourcePaths) ? sourcePaths : [sourcePaths]).filter(Boolean);
27
+ const title = safe((name || "your document").replace(/-/g, " "));
28
+ // Brief-only generation is first-class: with no source files the agent drafts from the
29
+ // brief alone, so the placeholder describes that instead of "0 locations".
30
+ const sources = paths.length === 0
31
+ ? "your brief"
32
+ : paths.length === 1
33
+ ? `<code>${safe(paths[0])}</code>`
34
+ : `${paths.length} locations`;
35
+ const list = paths.length > 1
36
+ ? `<ul>${paths.map((p) => `<li><code>${safe(p)}</code></li>`).join("")}</ul>`
37
+ : "";
38
+ const briefBlock = paths.length === 0 && brief
39
+ ? `<blockquote>${safe(brief)}</blockquote>`
40
+ : "";
41
+ return (
42
+ `<section>` +
43
+ `<h1>Building ${title}…</h1>` +
44
+ `<p class="lead">Reading ${sources} and drafting your document. ` +
45
+ `This view updates automatically the moment the first draft is ready.</p>` +
46
+ briefBlock +
47
+ list +
48
+ `</section>`
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Write the generation work request for a freshly-created source doc. The agent watches for
54
+ * this file (or the `generation` SSE event) and fulfills it.
55
+ * @returns {{ requestFile: string }}
56
+ */
57
+ export function writeGenerationRequest(dir, { sourcePaths, brief = "", documentId = dir, baseHtmlFile = "_v0.html" }) {
58
+ mkdirSync(join(dir, REQUESTS_DIR), { recursive: true });
59
+ const paths = (Array.isArray(sourcePaths) ? sourcePaths : [sourcePaths]).map((s) => String(s).trim()).filter(Boolean);
60
+ const body = {
61
+ document_id: documentId,
62
+ source_paths: paths,
63
+ brief,
64
+ base_html: baseHtmlFile,
65
+ ts: new Date().toISOString(),
66
+ };
67
+ atomicWrite(join(dir, REQUESTS_DIR, GEN_REQUEST), JSON.stringify(body, null, 2));
68
+ return { requestFile: GEN_REQUEST };
69
+ }
70
+
71
+ /**
72
+ * Apply the agent's generated draft as a follow-on version. Response shape: { html }.
73
+ * Instruments (fresh data-wids) and themes the draft, then records it write-once (INV-4)
74
+ * with the current head as parent.
75
+ * @returns {Promise<{version:number, parent:number}>}
76
+ */
77
+ export async function applyGeneratedDraft(dir, responseFile, opts = {}) {
78
+ const resp = JSON.parse(readFileSync(join(dir, REQUESTS_DIR, responseFile), "utf-8"));
79
+ const html = String(resp.html ?? "");
80
+ if (!html.trim()) throw new Error("generation response missing html");
81
+
82
+ let manifest = loadManifest(dir);
83
+ const parent = manifest.head;
84
+ const version = nextVersionNumber(manifest);
85
+ const prepared = themed(instrument(html).html, opts);
86
+ atomicWrite(join(dir, `_v${version}.html`), prepared);
87
+ ({ manifest } = recordVersion(manifest, { version, parent, feedbackFile: responseFile }));
88
+ saveManifest(dir, manifest);
89
+
90
+ if (typeof opts.emit === "function") {
91
+ opts.emit("HTML_UPDATED", {
92
+ document_id: opts.documentId ?? dir,
93
+ version, html_file: `_v${version}.html`, prev_version: parent, ts: new Date().toISOString(),
94
+ });
95
+ }
96
+ return { version, parent };
97
+ }
98
+
99
+ /** Whether a doc still has a pending (unfulfilled) generation request. */
100
+ export function hasPendingGeneration(dir) {
101
+ try {
102
+ return getVersion(loadManifest(dir), 1) == null
103
+ && readFileSync(join(dir, REQUESTS_DIR, GEN_REQUEST), "utf-8").length > 0;
104
+ } catch { return false; }
105
+ }
@@ -0,0 +1,84 @@
1
+ // preflight.js — detect required sibling plugins (ADR-0016).
2
+ //
3
+ // Each plugin has its own detection rule because they install differently:
4
+ // wicked-prezzie / wicked-garden → Claude Code plugin caches (a directory per plugin)
5
+ // wicked-brain → npm package; the on-disk signal is the brain dir
6
+ // under ~/.wicked-brain (or its Windows equivalent).
7
+ //
8
+ // The plugin-cache list is overrideable via env (`WI_PLUGIN_PATHS`, colon-separated) so
9
+ // non-default installations and tests can be picked up.
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { createRequire } from "node:module";
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ // Exported so service-level plugin-cache lookups (e.g. theme-source) reuse the same paths.
19
+ export function pluginSearchPaths() {
20
+ const env = (process.env.WI_PLUGIN_PATHS || "").split(":").filter(Boolean);
21
+ const home = homedir();
22
+ return [
23
+ ...env,
24
+ join(home, ".claude/plugins/cache"),
25
+ join(home, "alt-configs/.claude/plugins/cache"),
26
+ join(home, ".claude-code/plugins/cache"),
27
+ ];
28
+ }
29
+
30
+ function inPluginCache(name) {
31
+ for (const base of pluginSearchPaths()) {
32
+ if (existsSync(join(base, name))) return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function brainInstalled() {
38
+ // npm package: the durable signal is the brain directory (created by `wicked-brain:init`).
39
+ // The env override doubles as a test seam — WI_PLUGIN_PATHS sets HOME in tests, so the
40
+ // joined path lands inside the temp dir and detection is deterministic.
41
+ return existsSync(join(homedir(), ".wicked-brain")) || inPluginCache("wicked-brain");
42
+ }
43
+
44
+ const DETECTORS = {
45
+ "wicked-prezzie": () => inPluginCache("wicked-prezzie"),
46
+ "wicked-garden": () => inPluginCache("wicked-garden"),
47
+ "wicked-brain": brainInstalled,
48
+ };
49
+
50
+ // Each sibling installs differently, so a single command can't cover them. The hint shown
51
+ // in the install gate maps each MISSING plugin to its real install step (prezzie/garden are
52
+ // Claude Code plugins; brain is an npm package run via npx).
53
+ const INSTALL_CMD = {
54
+ "wicked-prezzie": "/plugin marketplace add mikeparcewski/wicked-prezzie\n/plugin install wicked-prezzie",
55
+ "wicked-garden": "/plugin marketplace add mikeparcewski/wicked-garden\n/plugin install wicked-garden",
56
+ "wicked-brain": "npx wicked-brain",
57
+ };
58
+
59
+ // Playwright (ADR-0018) is the demo recorder. Unlike the sibling plugins it's an npm
60
+ // dependency, so the durable signal is whether the package resolves from this project.
61
+ // (Browser binaries are a second gate surfaced at record time — Playwright throws a clear
62
+ // "Executable doesn't exist, run npx playwright install" we already wrap in recordDemo.)
63
+ // Kept OUT of `required`/`missing` so it gates only demo creation, not ordinary documents.
64
+ export const PLAYWRIGHT_INSTALL = "npx playwright install\nplaywright-cli install --skills";
65
+ export function playwrightInstalled() {
66
+ try { require.resolve("playwright"); return true; } catch { return false; }
67
+ }
68
+
69
+ /** Snapshot the install state of every required plugin. */
70
+ export function preflight() {
71
+ const required = {};
72
+ for (const [name, detect] of Object.entries(DETECTORS)) {
73
+ required[name] = { detected: detect() };
74
+ }
75
+ const missing = Object.keys(DETECTORS).filter((n) => !required[n].detected);
76
+ return {
77
+ ok: missing.length === 0,
78
+ required,
79
+ missing,
80
+ install_hint: missing.length ? missing.map((n) => INSTALL_CMD[n]).join("\n\n") : null,
81
+ // Demo-only dependency, reported alongside (not folded into `ok`/`missing`).
82
+ playwright: { detected: playwrightInstalled(), install_hint: PLAYWRIGHT_INSTALL },
83
+ };
84
+ }