@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.
- package/.claude/skills/record-demo/SKILL.md +66 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/bin/aidemo.mjs +53 -0
- package/docs/AUTHORING.md +728 -0
- package/package.json +47 -0
- package/src/caption-render.ts +69 -0
- package/src/captions.ts +441 -0
- package/src/capture.ts +574 -0
- package/src/cards.ts +67 -0
- package/src/cli.ts +646 -0
- package/src/compose.ts +937 -0
- package/src/config.ts +222 -0
- package/src/cursor-overlay.ts +186 -0
- package/src/cursor.ts +134 -0
- package/src/distribute.ts +713 -0
- package/src/embed.ts +202 -0
- package/src/ffmpeg.ts +83 -0
- package/src/gif.ts +65 -0
- package/src/golden.ts +109 -0
- package/src/i18n.ts +86 -0
- package/src/mcp/jobs.ts +285 -0
- package/src/mcp/server.ts +1016 -0
- package/src/music.ts +153 -0
- package/src/params.ts +134 -0
- package/src/player.ts +1173 -0
- package/src/project.ts +287 -0
- package/src/recorder.ts +320 -0
- package/src/starter.ts +141 -0
- package/src/stills.ts +121 -0
- package/src/types.ts +660 -0
- package/src/util.ts +171 -0
- package/src/variants.ts +117 -0
- package/src/voice-local.ts +230 -0
- package/src/voice.ts +405 -0
- package/src/zoom.ts +183 -0
|
@@ -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
|
+
}
|