@tandryukha/aidemo 0.8.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,713 @@
1
+ /** Distribution kit: install / update / check the record-demo skill in a
2
+ * consumer repo, bootstrap a repo (`repo-init`), file feedback upstream, and a
3
+ * `doctor` preflight. The engine itself stays centralized — a consumer repo
4
+ * only ever holds a version-stamped COPY of the skill plus a SessionStart hook
5
+ * that runs `aidemo skill check`.
6
+ *
7
+ * Design notes:
8
+ * - The engine is meant to be invoked as `npx -y github:tandryukha/aidemo#stable`.
9
+ * When run that way the engine's OWN version (engineVersion(), read from its
10
+ * bundled package.json) IS the latest stable — so `skill check` just compares
11
+ * that against the local installed.json, no extra network needed.
12
+ * - Installable file bodies are kept as template strings here (like starter.ts)
13
+ * so the commands have no file deps when run from an npx temp dir.
14
+ */
15
+ import { promises as fs } from "node:fs";
16
+ import { resolve, dirname, basename } from "node:path";
17
+ import { execFile } from "node:child_process";
18
+ import { promisify } from "node:util";
19
+ import { platform, release } from "node:os";
20
+ import { ENGINE_ROOT, engineVersion, openAiBaseUrl, ttsProvider } from "./config.js";
21
+ import { localTtsStatus } from "./voice-local.js";
22
+ import { Project } from "./project.js";
23
+ import { STARTER_BRIEF, STARTER_STORYBOARD } from "./starter.js";
24
+ import { ensureDir, exists, ok, step, fail, log } from "./util.js";
25
+
26
+ const execFileAsync = promisify(execFile);
27
+
28
+ export const UPSTREAM_REPO = "tandryukha/aidemo";
29
+ export const UPSTREAM_SPEC = `github:${UPSTREAM_REPO}#stable`;
30
+ const SKILL_REL = ".claude/skills/record-demo/SKILL.md";
31
+ const MANIFEST_REL = ".claude/skills/record-demo/installed.json";
32
+ const SETTINGS_REL = ".claude/settings.json";
33
+
34
+ interface Manifest {
35
+ name: string;
36
+ version: string;
37
+ source: string;
38
+ installedAt: string;
39
+ }
40
+
41
+ const engineSkillPath = () => resolve(ENGINE_ROOT, SKILL_REL);
42
+ const targetOf = (dir?: string) => resolve(process.cwd(), dir ?? ".");
43
+
44
+ /** Compare dotted numeric versions. Returns >0 if a>b, <0 if a<b, 0 if equal. */
45
+ function cmpVersion(a: string, b: string): number {
46
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
47
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
48
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
49
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
50
+ if (d !== 0) return d > 0 ? 1 : -1;
51
+ }
52
+ return 0;
53
+ }
54
+
55
+ async function readManifest(target: string): Promise<Manifest | null> {
56
+ try {
57
+ return JSON.parse(await fs.readFile(resolve(target, MANIFEST_REL), "utf8")) as Manifest;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /** Copy the engine's SKILL.md into the target repo and stamp installed.json. */
64
+ export async function installSkill(
65
+ dir: string | undefined,
66
+ opts: { force?: boolean; quiet?: boolean } = {}
67
+ ): Promise<void> {
68
+ const target = targetOf(dir);
69
+ const skillSrc = engineSkillPath();
70
+ if (!(await exists(skillSrc))) {
71
+ throw new Error(`engine skill not found at ${skillSrc} (is this a full engine checkout?)`);
72
+ }
73
+ const skillDest = resolve(target, SKILL_REL);
74
+ if ((await exists(skillDest)) && !opts.force) {
75
+ const m = await readManifest(target);
76
+ if (m) {
77
+ if (!opts.quiet) ok(`record-demo skill already installed (v${m.version}); use --force to overwrite`);
78
+ return;
79
+ }
80
+ }
81
+ await ensureDir(dirname(skillDest));
82
+ await fs.copyFile(skillSrc, skillDest);
83
+ const manifest: Manifest = {
84
+ name: "record-demo",
85
+ version: engineVersion(),
86
+ source: UPSTREAM_SPEC,
87
+ installedAt: new Date().toISOString(),
88
+ };
89
+ await fs.writeFile(resolve(target, MANIFEST_REL), JSON.stringify(manifest, null, 2) + "\n");
90
+ if (!opts.quiet) ok(`installed record-demo skill v${manifest.version} → ${SKILL_REL}`);
91
+ }
92
+
93
+ /**
94
+ * Compare the installed skill version against this (stable) engine's version.
95
+ * Prints a one-line update notice when behind; silent + exit 0 otherwise so it
96
+ * is safe to run from a SessionStart hook. Never mutates the repo.
97
+ */
98
+ export async function checkSkill(
99
+ dir: string | undefined,
100
+ opts: { verbose?: boolean } = {}
101
+ ): Promise<void> {
102
+ const target = targetOf(dir);
103
+ const manifest = await readManifest(target);
104
+ if (!manifest) {
105
+ if (opts.verbose) log(`no record-demo skill installed here (nothing to check)`);
106
+ return; // not a consumer repo / not initialized — stay silent
107
+ }
108
+ const latest = engineVersion();
109
+ if (cmpVersion(latest, manifest.version) > 0) {
110
+ step(`record-demo skill update available`);
111
+ ok(`installed v${manifest.version} → stable v${latest}`);
112
+ ok(`apply with: npx -y ${UPSTREAM_SPEC} skill update --dir "${target}"`);
113
+ } else if (opts.verbose) {
114
+ ok(`record-demo skill is up to date (v${manifest.version})`);
115
+ }
116
+ }
117
+
118
+ const SETTINGS_TEMPLATE = (): string =>
119
+ JSON.stringify(
120
+ {
121
+ hooks: {
122
+ SessionStart: [
123
+ {
124
+ hooks: [
125
+ {
126
+ type: "command",
127
+ // Non-blocking: surfaces "update available" at session start.
128
+ // `|| true` so a network hiccup never blocks the session.
129
+ command: `npx -y ${UPSTREAM_SPEC} skill check --dir "$CLAUDE_PROJECT_DIR" 2>/dev/null || true`,
130
+ },
131
+ ],
132
+ },
133
+ ],
134
+ },
135
+ },
136
+ null,
137
+ 2
138
+ ) + "\n";
139
+
140
+ const POINTER_DOC = (): string => `# record-demo skill — how this got here & how to keep it fresh
141
+
142
+ This repo carries a **copy** of the \`record-demo\` skill (a thin adapter) from
143
+ [aidemo](https://github.com/${UPSTREAM_REPO}). The engine itself is
144
+ **not** vendored — it runs on demand via npx from a pinned \`stable\` tag, and
145
+ agents drive it through the **aidemo MCP server**, registered by \`repo-init\`
146
+ in \`.mcp.json\` (Claude Code) and \`.gemini/settings.json\` (Gemini CLI).
147
+ Codex CLI keeps MCP config globally — register once with:
148
+
149
+ codex mcp add aidemo -- npx -y ${UPSTREAM_SPEC} mcp
150
+
151
+ Everywhere below, \`aidemo\` means:
152
+
153
+ npx -y ${UPSTREAM_SPEC}
154
+
155
+ ## The authoring guide (never goes stale)
156
+ The canonical guide is served by the engine itself, always version-matched:
157
+ the MCP \`get_authoring_guide\` tool, or \`aidemo guide\` on the CLI.
158
+
159
+ ## Stay up to date
160
+ A \`SessionStart\` hook in \`.claude/settings.json\` runs \`aidemo skill check\` and
161
+ prints a notice when a newer skill is available. Nothing is overwritten silently —
162
+ apply an update when you're ready:
163
+
164
+ npx -y ${UPSTREAM_SPEC} skill update --dir .
165
+
166
+ ## Record a demo
167
+ Preferred: ask your agent — it uses the MCP tools (pipeline tools return job
168
+ ids; \`job_status\` reports progress and failures). By hand:
169
+
170
+ npx -y ${UPSTREAM_SPEC} init <name> # scaffold demos/<name>/
171
+ npx -y ${UPSTREAM_SPEC} render demos/<name> # voice → record → captions → compose
172
+
173
+ (Needs Chrome, ffmpeg and OPENAI_API_KEY — run \`aidemo doctor\` to check.)
174
+
175
+ ## Send feedback upstream
176
+ Hit a broken selector, bad timing, or have an idea? File it on the engine:
177
+
178
+ npx -y ${UPSTREAM_SPEC} feedback demos/<name>
179
+ `;
180
+
181
+ /** The one MCP server entry every client config gets. */
182
+ const MCP_SERVER_ENTRY = () => ({
183
+ command: "npx",
184
+ args: ["-y", UPSTREAM_SPEC, "mcp"],
185
+ });
186
+
187
+ const AGENTS_SNIPPET = (): string => `## Recording product demos (aidemo)
188
+
189
+ This repo is set up for [aidemo](https://github.com/${UPSTREAM_REPO}) — agents
190
+ record narrated, captioned product-demo videos from a storyboard.
191
+
192
+ - Preferred: the \`aidemo\` **MCP server** (registered in \`.mcp.json\` and
193
+ \`.gemini/settings.json\`; Codex CLI:
194
+ \`codex mcp add aidemo -- npx -y ${UPSTREAM_SPEC} mcp\`). Call the
195
+ \`get_authoring_guide\` tool first and follow it. Pipeline tools return a
196
+ jobId immediately — poll \`job_status\`. Pass absolute demo directories.
197
+ - CLI fallback: \`npx -y ${UPSTREAM_SPEC} guide\` prints the same guide;
198
+ \`npx -y ${UPSTREAM_SPEC} render demos/<name>\` renders.
199
+ `;
200
+
201
+ /**
202
+ * Merge the aidemo entry into a JSON config holding an mcpServers map
203
+ * (.mcp.json for Claude Code, .gemini/settings.json for Gemini CLI). Never
204
+ * clobbers: absent → created; parseable → only the aidemo key is added
205
+ * (replaced with --force); unparseable → snippet printed for manual merge.
206
+ */
207
+ async function registerMcpServer(
208
+ target: string,
209
+ rel: string,
210
+ force: boolean
211
+ ): Promise<void> {
212
+ const path = resolve(target, rel);
213
+ const entry = MCP_SERVER_ENTRY();
214
+ let config: Record<string, unknown> = {};
215
+ if (await exists(path)) {
216
+ try {
217
+ config = JSON.parse(await fs.readFile(path, "utf8")) as Record<string, unknown>;
218
+ } catch {
219
+ log(`${rel} exists but isn't valid JSON — merge this in manually:`);
220
+ log(JSON.stringify({ mcpServers: { aidemo: entry } }, null, 2));
221
+ return;
222
+ }
223
+ }
224
+ if (typeof config.mcpServers !== "object" || config.mcpServers == null) {
225
+ config.mcpServers = {};
226
+ }
227
+ const servers = config.mcpServers as Record<string, unknown>;
228
+ if (servers.aidemo && !force) {
229
+ ok(`${rel} already registers aidemo — leaving it`);
230
+ return;
231
+ }
232
+ servers.aidemo = entry;
233
+ await ensureDir(dirname(path));
234
+ await fs.writeFile(path, JSON.stringify(config, null, 2) + "\n");
235
+ ok(`registered aidemo MCP server → ${rel}`);
236
+ }
237
+
238
+ /** Scaffold demos/<name>/ under baseDir from the built-in starter templates. */
239
+ export async function scaffoldDemo(
240
+ baseDir: string,
241
+ name: string,
242
+ opts: { force?: boolean } = {}
243
+ ): Promise<string> {
244
+ const dir = resolve(baseDir, "demos", name);
245
+ if ((await exists(dir)) && !opts.force) {
246
+ ok(`demos/${name} already exists — skipping scaffold`);
247
+ return dir;
248
+ }
249
+ const project = new Project(dir);
250
+ await project.ensureDirs();
251
+ await fs.writeFile(project.p("input", "brief.md"), STARTER_BRIEF(name));
252
+ await fs.writeFile(project.storyboardPath, STARTER_STORYBOARD(name));
253
+ ok(`scaffolded demos/${name}/`);
254
+ return dir;
255
+ }
256
+
257
+ /**
258
+ * One-time bootstrap of a consumer repo: install the skill, add the update-check
259
+ * hook, drop a pointer doc, and scaffold an example demo. Idempotent — existing
260
+ * files are left in place unless --force.
261
+ */
262
+ export async function repoInit(
263
+ dir: string | undefined,
264
+ opts: { force?: boolean } = {}
265
+ ): Promise<void> {
266
+ const target = targetOf(dir);
267
+ step(`Bootstrapping ${basename(target)} for record-demo`);
268
+
269
+ await installSkill(dir, { force: opts.force });
270
+
271
+ // MCP registration — the agent interface. Claude Code reads .mcp.json at
272
+ // the project root; Gemini CLI reads .gemini/settings.json. Codex CLI keeps
273
+ // MCP config globally, so that one is an instruction, not a file.
274
+ await registerMcpServer(target, ".mcp.json", opts.force ?? false);
275
+ await registerMcpServer(target, ".gemini/settings.json", opts.force ?? false);
276
+ log(`Codex CLI (global config): codex mcp add aidemo -- npx -y ${UPSTREAM_SPEC} mcp`);
277
+
278
+ // AGENTS.md so Codex/other AGENTS.md-reading agents discover the setup.
279
+ const agentsPath = resolve(target, "AGENTS.md");
280
+ if (!(await exists(agentsPath))) {
281
+ await fs.writeFile(agentsPath, `# Agent guide\n\n${AGENTS_SNIPPET()}`);
282
+ ok(`wrote AGENTS.md (demo-recording section)`);
283
+ } else if (!(await fs.readFile(agentsPath, "utf8")).includes("aidemo")) {
284
+ log(`AGENTS.md exists — add this section so agents discover aidemo:`);
285
+ log(AGENTS_SNIPPET().trim());
286
+ }
287
+
288
+ // SessionStart update hook — never clobber an existing settings.json.
289
+ const settingsPath = resolve(target, SETTINGS_REL);
290
+ if ((await exists(settingsPath)) && !opts.force) {
291
+ log(`.claude/settings.json exists — leaving it. Merge this hook in manually:`);
292
+ log(SETTINGS_TEMPLATE().trim());
293
+ } else {
294
+ await ensureDir(dirname(settingsPath));
295
+ await fs.writeFile(settingsPath, SETTINGS_TEMPLATE());
296
+ ok(`wrote ${SETTINGS_REL} (SessionStart update-check hook)`);
297
+ }
298
+
299
+ // Pointer doc so a human (or Claude) in this repo knows how to invoke/update.
300
+ const pointerPath = resolve(target, ".claude/skills/record-demo/README.md");
301
+ if (!(await exists(pointerPath)) || opts.force) {
302
+ await fs.writeFile(pointerPath, POINTER_DOC());
303
+ ok(`wrote .claude/skills/record-demo/README.md`);
304
+ }
305
+
306
+ await scaffoldDemo(target, "example", { force: false });
307
+
308
+ step(`Done — commit .claude/, .mcp.json, .gemini/, AGENTS.md and demos/example/`);
309
+ ok(`next: npx -y ${UPSTREAM_SPEC} render demos/example (needs Chrome + ffmpeg + OPENAI_API_KEY)`);
310
+ ok(`feedback anytime: npx -y ${UPSTREAM_SPEC} feedback demos/example`);
311
+ }
312
+
313
+ /** Best-effort `cmd --version`-style probe; returns first output line or null. */
314
+ async function probeCmd(cmd: string, args: string[]): Promise<string | null> {
315
+ try {
316
+ const { stdout, stderr } = await execFileAsync(cmd, args, { timeout: 5000 });
317
+ return (stdout || stderr).split("\n")[0]?.trim() || null;
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
323
+ /** Locate Chrome across platforms; returns a version string or install path. */
324
+ async function probeChrome(): Promise<string | null> {
325
+ const version =
326
+ (await probeCmd("google-chrome", ["--version"])) ??
327
+ (await probeCmd("google-chrome-stable", ["--version"])) ??
328
+ (await probeCmd(
329
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
330
+ ["--version"]
331
+ ));
332
+ if (version) return version;
333
+ if (process.platform !== "win32") return null;
334
+ // chrome.exe --version prints nothing on Windows; check the usual install paths.
335
+ const winPaths = [
336
+ `${process.env["ProgramFiles"] ?? "C:\\Program Files"}\\Google\\Chrome\\Application\\chrome.exe`,
337
+ `${process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"}\\Google\\Chrome\\Application\\chrome.exe`,
338
+ process.env.LOCALAPPDATA
339
+ ? `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`
340
+ : "",
341
+ ];
342
+ for (const p of winPaths) if (p && (await exists(p))) return p;
343
+ return null;
344
+ }
345
+
346
+ /** Tail the most recent logs/*.log under a demo dir (best-effort). */
347
+ async function recentLogTail(demoDir: string, lines = 40): Promise<string | null> {
348
+ const logsDir = resolve(demoDir, "logs");
349
+ let entries: string[];
350
+ try {
351
+ entries = (await fs.readdir(logsDir)).filter((f) => f.endsWith(".log"));
352
+ } catch {
353
+ return null;
354
+ }
355
+ if (!entries.length) return null;
356
+ let newest: { path: string; mtime: number } | null = null;
357
+ for (const f of entries) {
358
+ const p = resolve(logsDir, f);
359
+ const st = await fs.stat(p);
360
+ if (!newest || st.mtimeMs > newest.mtime) newest = { path: p, mtime: st.mtimeMs };
361
+ }
362
+ if (!newest) return null;
363
+ const raw = await fs.readFile(newest.path, "utf8");
364
+ const tail = raw.split("\n").slice(-lines).join("\n").trim();
365
+ return `${basename(newest.path)} (last ${lines} lines):\n${tail}`;
366
+ }
367
+
368
+ async function assembleFeedbackBody(
369
+ demoDir: string | null,
370
+ description?: string
371
+ ): Promise<string> {
372
+ const ffmpeg = (await probeCmd("ffmpeg", ["-version"])) ?? "not found";
373
+ let originUrl: string | null = null;
374
+ try {
375
+ const { stdout } = await execFileAsync("git", ["config", "--get", "remote.origin.url"], {
376
+ cwd: process.cwd(),
377
+ timeout: 5000,
378
+ });
379
+ originUrl = stdout.trim() || null;
380
+ } catch {
381
+ /* not a git repo */
382
+ }
383
+ const storyboard = demoDir ? resolve(demoDir, "generated", "storyboard.json") : null;
384
+ const logTail = demoDir ? await recentLogTail(demoDir) : null;
385
+
386
+ return [
387
+ `**What happened / suggestion**`,
388
+ ``,
389
+ description?.trim() || `<!-- describe the broken selector / bad timing / idea here -->`,
390
+ ``,
391
+ `---`,
392
+ `**Environment**`,
393
+ `- aidemo engine: v${engineVersion()} (${UPSTREAM_SPEC})`,
394
+ `- consuming repo: ${originUrl ?? basename(process.cwd())}`,
395
+ `- demo dir: ${demoDir ?? "(none given)"}`,
396
+ `- storyboard: ${storyboard && (await exists(storyboard)) ? storyboard : "n/a"}`,
397
+ `- OS: ${platform()} ${release()}`,
398
+ `- node: ${process.version}`,
399
+ `- ffmpeg: ${ffmpeg}`,
400
+ ``,
401
+ logTail ? `**Recent log**\n\n\`\`\`\n${logTail}\n\`\`\`` : `**Recent log:** none found`,
402
+ ].join("\n");
403
+ }
404
+
405
+ /** Title + full body (environment/log context auto-attached) of a feedback issue. */
406
+ export interface FeedbackContext {
407
+ title: string;
408
+ body: string;
409
+ }
410
+
411
+ /**
412
+ * Build the title + body for an aidemo feedback issue — environment (engine
413
+ * version, OS, node, ffmpeg, consuming repo) and a recent-log tail are
414
+ * auto-attached the same way for every caller. Shared by the CLI `aidemo
415
+ * feedback` command and the MCP `feedback` tool. `description`, when given
416
+ * (the MCP tool's `body` param — an agent's actual finding), replaces the
417
+ * CLI's "describe here" placeholder comment.
418
+ */
419
+ export async function buildFeedback(
420
+ demoDir: string | null,
421
+ opts: { title?: string; description?: string } = {}
422
+ ): Promise<FeedbackContext> {
423
+ const body = await assembleFeedbackBody(demoDir, opts.description);
424
+ const title =
425
+ opts.title?.trim() ||
426
+ `Demo feedback: ${demoDir ? basename(demoDir) : basename(process.cwd())}`;
427
+ return { title, body };
428
+ }
429
+
430
+ export interface FileFeedbackResult {
431
+ filed: boolean;
432
+ url?: string;
433
+ localPath?: string;
434
+ message: string;
435
+ }
436
+
437
+ /**
438
+ * File a feedback issue on the engine repo, or preview it (dryRun). Uses `gh
439
+ * issue create` when available; `web` opens a prefilled New Issue page
440
+ * instead of filing directly; falls back to a local docs/feedback-*.md + a
441
+ * prefilled `github.com/.../issues/new` URL when `gh` is missing or offline.
442
+ * Shared by the CLI `aidemo feedback` command and the MCP `feedback` tool —
443
+ * intentionally no other network path (repo security policy: no new
444
+ * endpoints; `gh` or a plain github.com URL only).
445
+ */
446
+ export async function fileFeedback(
447
+ ctx: FeedbackContext,
448
+ opts: { web?: boolean; dryRun?: boolean; cwd?: string } = {}
449
+ ): Promise<FileFeedbackResult> {
450
+ const { title, body } = ctx;
451
+ if (opts.dryRun) {
452
+ return { filed: false, message: "dry run — not filed" };
453
+ }
454
+
455
+ const ghPath = await probeCmd("gh", ["--version"]);
456
+ if (ghPath) {
457
+ const args = ["issue", "create", "--repo", UPSTREAM_REPO, "--title", title, "--body", body];
458
+ if (opts.web) args.push("--web");
459
+ try {
460
+ const { stdout } = await execFileAsync("gh", args, { timeout: 30000 });
461
+ const out = stdout.trim();
462
+ return {
463
+ filed: true,
464
+ ...(out.startsWith("http") ? { url: out } : {}),
465
+ message: out || `filed feedback on ${UPSTREAM_REPO}`,
466
+ };
467
+ } catch (err) {
468
+ fail(`gh issue create failed: ${(err as Error).message}`);
469
+ // fall through to local fallback
470
+ }
471
+ } else {
472
+ log(`gh CLI not found — saving feedback locally instead`);
473
+ }
474
+
475
+ // Local fallback: write docs/feedback-<date>.md and print the prefilled URL.
476
+ const base = opts.cwd ?? process.cwd();
477
+ const stamp = new Date().toISOString().slice(0, 10);
478
+ const outDir = resolve(base, "docs");
479
+ await ensureDir(outDir);
480
+ const outPath = resolve(outDir, `feedback-${stamp}.md`);
481
+ await fs.writeFile(outPath, `# ${title}\n\n${body}\n`);
482
+ const url = `https://github.com/${UPSTREAM_REPO}/issues/new?title=${encodeURIComponent(
483
+ title
484
+ )}&body=${encodeURIComponent(body)}`;
485
+ return { filed: false, url, localPath: outPath, message: `saved feedback → ${outPath}` };
486
+ }
487
+
488
+ /**
489
+ * File demo-recording feedback as a GitHub issue on the engine repo (CLI
490
+ * `aidemo feedback`). `--dry-run` prints the assembled title/body without
491
+ * filing. Thin wrapper over buildFeedback/fileFeedback — see the MCP
492
+ * `feedback` tool for the agent-facing equivalent (takes a real title/body).
493
+ */
494
+ export async function feedback(
495
+ dir: string | undefined,
496
+ opts: { web?: boolean; dryRun?: boolean } = {}
497
+ ): Promise<void> {
498
+ const demoDir = dir ? targetOf(dir) : null;
499
+ const ctx = await buildFeedback(demoDir);
500
+
501
+ if (opts.dryRun) {
502
+ step(`feedback (dry run) → ${UPSTREAM_REPO}`);
503
+ log(`title: ${ctx.title}`);
504
+ log(`body:\n${ctx.body}`);
505
+ return;
506
+ }
507
+
508
+ const result = await fileFeedback(ctx, {
509
+ web: opts.web,
510
+ cwd: demoDir ?? process.cwd(),
511
+ });
512
+ if (result.filed) {
513
+ step(`filed feedback on ${UPSTREAM_REPO}`);
514
+ ok(result.message);
515
+ } else if (result.localPath) {
516
+ step(`saved feedback → ${result.localPath}`);
517
+ ok(`file it upstream: ${result.url}`);
518
+ } else {
519
+ fail(result.message);
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Ollama answers GET /api/version on its origin — nothing else in this niche
525
+ * does. Catching it at doctor time turns a cryptic voice-time 404 into an
526
+ * upfront hint. Only ever contacts the user-configured endpoint's origin.
527
+ */
528
+ async function detectOllama(base: string): Promise<string | null> {
529
+ try {
530
+ const origin = new URL(base).origin;
531
+ const res = await fetch(`${origin}/api/version`, {
532
+ signal: AbortSignal.timeout(1500),
533
+ });
534
+ if (!res.ok) return null;
535
+ const body = (await res.json()) as { version?: string } | null;
536
+ return body?.version ? `Ollama v${body.version}` : null;
537
+ } catch {
538
+ return null; // unreachable or not Ollama — no verdict either way
539
+ }
540
+ }
541
+
542
+ export interface DoctorReport {
543
+ engineVersion: string;
544
+ node: string;
545
+ ffmpeg: string | null;
546
+ chrome: string | null;
547
+ gh: string | null;
548
+ endpoint: { url: string; custom: boolean; warning?: string };
549
+ apiKey: "set" | "not-required" | "missing";
550
+ /** TTS backend (AIDEMO_TTS_PROVIDER); key status only reported when non-default. */
551
+ tts: {
552
+ provider: string;
553
+ elevenLabsKey?: "set" | "missing";
554
+ /** Present when provider=local: is kokoro-js installed, is the model cached. */
555
+ local?: {
556
+ installed: boolean;
557
+ dtype: string;
558
+ modelDir: string;
559
+ modelCached: boolean;
560
+ cacheSizeMB: number | null;
561
+ };
562
+ };
563
+ playwright: boolean;
564
+ skill: {
565
+ target: string;
566
+ installed: { version: string; stableAvailable: string | null } | null;
567
+ };
568
+ /** Everything the pipeline needs (gh is feedback-only, doesn't gate). */
569
+ ok: boolean;
570
+ }
571
+
572
+ /** Build the structured preflight report (used by CLI doctor + MCP doctor). */
573
+ export async function doctorReport(dir: string | undefined): Promise<DoctorReport> {
574
+ const base = openAiBaseUrl();
575
+ let endpointWarning: string | undefined;
576
+ if (base) {
577
+ const ollama = await detectOllama(base);
578
+ if (ollama) {
579
+ endpointWarning =
580
+ `endpoint is ${ollama} — Ollama serves chat/embeddings only, no /v1/audio ` +
581
+ `(TTS/STT), so voice and captions will fail. Pair it with a speech server ` +
582
+ `such as speaches — see README "Local models & offline".`;
583
+ }
584
+ }
585
+ let playwrightOk = false;
586
+ // Ensure playwright's chromium/driver resolves (best-effort require probe).
587
+ try {
588
+ await import("playwright");
589
+ playwrightOk = true;
590
+ } catch {
591
+ playwrightOk = false;
592
+ }
593
+ const target = targetOf(dir);
594
+ const manifest = await readManifest(target);
595
+ const latest = engineVersion();
596
+ const localTts = ttsProvider() === "local" ? await localTtsStatus() : null;
597
+ const report: DoctorReport = {
598
+ engineVersion: latest,
599
+ node: process.version,
600
+ ffmpeg: await probeCmd("ffmpeg", ["-version"]),
601
+ chrome: await probeChrome(),
602
+ gh: await probeCmd("gh", ["--version"]),
603
+ endpoint: {
604
+ url: base ?? "https://api.openai.com/v1",
605
+ custom: Boolean(base),
606
+ ...(endpointWarning ? { warning: endpointWarning } : {}),
607
+ },
608
+ apiKey: process.env.OPENAI_API_KEY ? "set" : base ? "not-required" : "missing",
609
+ tts: {
610
+ provider: process.env.AIDEMO_TTS_PROVIDER || "openai",
611
+ ...(process.env.AIDEMO_TTS_PROVIDER === "elevenlabs"
612
+ ? { elevenLabsKey: process.env.ELEVENLABS_API_KEY ? "set" : "missing" }
613
+ : {}),
614
+ ...(localTts
615
+ ? {
616
+ local: {
617
+ installed: localTts.installed,
618
+ dtype: localTts.dtype,
619
+ modelDir: localTts.modelDir,
620
+ modelCached: localTts.modelCached,
621
+ cacheSizeMB: localTts.cacheSizeMB,
622
+ },
623
+ }
624
+ : {}),
625
+ },
626
+ playwright: playwrightOk,
627
+ skill: {
628
+ target,
629
+ installed: manifest
630
+ ? {
631
+ version: manifest.version,
632
+ stableAvailable:
633
+ cmpVersion(latest, manifest.version) > 0 ? latest : null,
634
+ }
635
+ : null,
636
+ },
637
+ ok: false,
638
+ };
639
+ report.ok = Boolean(
640
+ report.ffmpeg &&
641
+ report.chrome &&
642
+ report.playwright &&
643
+ // Local TTS needs no key: voice is in-process, captions auto-fall-back
644
+ // to the offline derivation in `render`.
645
+ (report.apiKey !== "missing" || report.tts.provider === "local") &&
646
+ report.tts.elevenLabsKey !== "missing" &&
647
+ (report.tts.local?.installed ?? true) &&
648
+ !endpointWarning
649
+ );
650
+ return report;
651
+ }
652
+
653
+ /** Preflight: check prereqs + report installed-vs-stable skill version. */
654
+ export async function doctor(dir: string | undefined): Promise<void> {
655
+ const r = await doctorReport(dir);
656
+ step(`aidemo doctor — engine v${r.engineVersion}`);
657
+ const line = (label: string, val: string | null) =>
658
+ val ? ok(`${label}: ${val}`) : fail(`${label}: NOT FOUND`);
659
+
660
+ line("node", r.node);
661
+ line("ffmpeg", r.ffmpeg);
662
+ line("chrome", r.chrome);
663
+ line("gh (feedback)", r.gh);
664
+ ok(
665
+ `voice/captions endpoint: ${r.endpoint.custom ? `${r.endpoint.url} (custom)` : "api.openai.com (default)"}`
666
+ );
667
+ if (r.endpoint.warning) fail(r.endpoint.warning);
668
+ if (r.tts.provider === "local" && r.tts.local) {
669
+ const l = r.tts.local;
670
+ if (l.installed)
671
+ ok(`TTS provider: local (Kokoro-82M in-process, dtype ${l.dtype}) — kokoro-js installed`);
672
+ else
673
+ fail(`TTS provider: local — kokoro-js is not installed (run: npm install kokoro-js)`);
674
+ if (l.modelCached)
675
+ ok(`local TTS model: cached at ${l.modelDir} (${l.cacheSizeMB} MB)`);
676
+ else
677
+ log(
678
+ `local TTS model: not downloaded yet — first voice run fetches it once from ` +
679
+ `huggingface.co (~90 MB at q8) → ${l.modelDir}`
680
+ );
681
+ } else if (r.tts.provider !== "openai") {
682
+ const line =
683
+ `TTS provider: ${r.tts.provider} (captions still use the endpoint above)` +
684
+ (r.tts.elevenLabsKey ? ` — ELEVENLABS_API_KEY ${r.tts.elevenLabsKey}` : "");
685
+ if (r.tts.elevenLabsKey === "missing") fail(line);
686
+ else ok(line);
687
+ }
688
+ ok(
689
+ `OPENAI_API_KEY: ${
690
+ r.apiKey === "set"
691
+ ? "set"
692
+ : r.apiKey === "not-required"
693
+ ? "not required (custom endpoint set)"
694
+ : r.tts.provider === "local"
695
+ ? "not set — fine for local TTS (render derives captions offline)"
696
+ : "MISSING (needed for voice/captions)"
697
+ }`
698
+ );
699
+ if (r.playwright) ok(`playwright: resolvable`);
700
+ else fail(`playwright: not installed`);
701
+
702
+ if (r.skill.installed) {
703
+ ok(
704
+ `installed skill: v${r.skill.installed.version}${
705
+ r.skill.installed.stableAvailable
706
+ ? ` (stable v${r.skill.installed.stableAvailable} available — aidemo skill update)`
707
+ : " (up to date)"
708
+ }`
709
+ );
710
+ } else {
711
+ log(`no record-demo skill installed in ${r.skill.target} (run: aidemo repo-init)`);
712
+ }
713
+ }