@hypersocial/decks-cli 0.2.2 → 0.3.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/src/init.mjs ADDED
@@ -0,0 +1,605 @@
1
+ // hyperdecks init [--yes] [--json] [--agent <id>]... [--all-agents] [--voice dash] [--name n --about a --ask k] [--dir d]
2
+ // [--no-voice] [--no-skill] [--no-release] [--no-open]
3
+ //
4
+ // The first run, start to finish: sign in, install the skill, get a voice,
5
+ // scaffold a three-slide hello deck from three answers, narrate it, release one
6
+ // QA link and print it. Every step checks real state before it acts, so running
7
+ // init again resumes where it stopped and changes nothing once it is complete.
8
+ // Each step returns { step, status: "done" | "skipped" | "ran" | "needs-human", detail }.
9
+ // CLI-only, like login: there is no MCP tool for it.
10
+ import { execFileSync } from "node:child_process";
11
+ import { createHash, randomBytes } from "node:crypto";
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join, relative, resolve } from "node:path";
15
+ import { createInterface } from "node:readline/promises";
16
+ import { fileURLToPath } from "node:url";
17
+ import * as ops from "./ops.mjs";
18
+ import { slugify } from "./importer.mjs";
19
+ import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
20
+ import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
21
+ import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
22
+ import { narrate as runNarrate } from "./narrate.mjs";
23
+ import { release as runRelease } from "./release.mjs";
24
+
25
+ export const STATE_FILE = join(CRED_DIR, "init.json");
26
+ export const FIRST_DECK_LAYOUTS = "hero,features,closer";
27
+ export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
28
+ // Same numbers as convex/lib/tokenCost.ts (the published CLI cannot import it).
29
+ export const VOICE_CLONE_COST = 10;
30
+ export const STARTER_GRANT = 30;
31
+ export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
32
+ const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "skills", "hyperdecks");
33
+ // CLI-only, so read next to the package rather than through files.mjs (the hosted route's list).
34
+ const FIRST_DECK_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates", "first-deck");
35
+ const CHARACTERS = ["dash", "d.a.s.h."];
36
+ const RESUME = "npx hyperdecks init";
37
+ export const AGENT_VOICE_HELP = "ask the user to run `npx hyperdecks voice clone <file> --name <name>` in a terminal, " +
38
+ "or upload with `voice upload` and call `clone_voice` after they agree to the statement; " +
39
+ "or run init again with `--voice dash` to let D.A.S.H. present";
40
+
41
+ const isCharacter = (voice) => CHARACTERS.includes(String(voice ?? "").trim().toLowerCase());
42
+ const step = (name, status, detail, extra = {}) => ({ step: name, status, detail, ...extra });
43
+
44
+ // ---- local state ----------------------------------------------------------
45
+
46
+ // init.json: per deployment, the deck init made and its release. No credentials.
47
+ export function readState(path = STATE_FILE) {
48
+ let text;
49
+ try {
50
+ text = readFileSync(path, "utf8");
51
+ } catch (e) {
52
+ if (e.code === "ENOENT") return {};
53
+ throw e;
54
+ }
55
+ try {
56
+ return JSON.parse(text);
57
+ } catch {
58
+ throw new Error(`${path} is not valid JSON; fix it, or delete it to start over (init then makes a new deck)`);
59
+ }
60
+ }
61
+
62
+ export function writeState(path, all) {
63
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
64
+ writeFileSync(path, JSON.stringify(all, null, 2) + "\n", { mode: 0o600 });
65
+ }
66
+
67
+ // ---- answers and the first deck -------------------------------------------
68
+
69
+ // One answer as it goes on a slide and into the script: one line, no stage-tag
70
+ // brackets, ending in a full stop so it is its own caption.
71
+ export function cleanAnswer(key, value) {
72
+ const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
73
+ if (text.length < 2) throw new Error(`--${key} needs an answer`);
74
+ if (text.length > ANSWER_MAX[key]) throw new Error(`--${key} is ${text.length} characters; keep it to ${ANSWER_MAX[key]} so it fits a caption`);
75
+ return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
76
+ }
77
+
78
+ export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
79
+
80
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
81
+
82
+ export function firstDeckCopy(presenter) {
83
+ return JSON.parse(readFileSync(join(FIRST_DECK_DIR, `${presenter === "dash" ? "character" : "self"}.json`), "utf8"));
84
+ }
85
+
86
+ function slots(text, answers, html) {
87
+ const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
88
+ return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
89
+ }
90
+
91
+ // Replace exactly one match, or fail loudly: a layout change must not ship a deck
92
+ // that still says "One sentence on what this deck is about."
93
+ function swap(html, pattern, to, what) {
94
+ let hit = 0;
95
+ const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
96
+ if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
97
+ return out;
98
+ }
99
+
100
+ function section(html, id, edit) {
101
+ return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
102
+ }
103
+
104
+ // index.html and script.md of the scaffolded hero,features,closer deck, filled
105
+ // with the answers: no template copy is left, and no generated imagery.
106
+ export function fillFirstDeck({ html, title, answers, presenter, voice }) {
107
+ const copy = firstDeckCopy(presenter);
108
+ const h = (t) => slots(t, answers, true);
109
+ const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
110
+ let out = section(html, "hero", (s) => {
111
+ s = note(s);
112
+ s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
113
+ s = swap(s, /<h1 class="tt"[^>]*>[\s\S]*?<\/h1>/, `<h1 class="tt" data-title-mobile="${h(copy.hero.title)}">${h(copy.hero.title)}</h1>`, "hero title");
114
+ return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
115
+ });
116
+ out = section(out, "features", (s) => {
117
+ s = note(s);
118
+ s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
119
+ s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.features.title)}">${h(copy.features.title)}</h2>`, "features title");
120
+ copy.features.cards.forEach((card, i) => {
121
+ s = swap(s, new RegExp(`(<li class="card" id="features-f${i + 1}">[\\s\\S]*?</svg>)<b>[^<]*</b><p>[^<]*</p>`), (_, icon) => `${icon}<b>${h(card.name)}</b><p>${h(card.line)}</p>`, `features card ${i + 1}`);
122
+ });
123
+ return s;
124
+ });
125
+ out = section(out, "closer", (s) => {
126
+ s = note(s);
127
+ s = swap(s, /<h2 class="tt"[^>]*>[\s\S]*?<\/h2>/, `<h2 class="tt" data-title-mobile="${h(copy.closer.title)}">${h(copy.closer.title)}</h2>`, "closer title");
128
+ s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
129
+ s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
130
+ return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
131
+ });
132
+ // A character's live head presents its own deck; a person's voice ships with
133
+ // captions only (data-presenter="none"), never on a character's face.
134
+ if (presenter === "dash") {
135
+ out = swap(out, /(<script type="module" src="https:\/\/decks\.hypersocial\.ai\/player\/v1\/player\.js"[^>]*?)\sdata-presenter="none"/, (_, tag) => `${tag} data-presenter="head" data-avatar="${DASH_AVATAR}"`, "player tag");
136
+ }
137
+ const s = (t) => slots(t, answers, false);
138
+ const script = `---
139
+ voice: ${voice}
140
+ maxTotalSeconds: 90
141
+ transition: drift
142
+ ---
143
+
144
+ # ${title} — narration script
145
+
146
+ Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
147
+ these lines and the slides in index.html, then
148
+
149
+ hyperdecks narrate .
150
+ hyperdecks release . --to "<name>"
151
+
152
+ ## hero
153
+ ${s(copy.hero.script)}
154
+
155
+ ## features
156
+ ${s(copy.features.script)}
157
+
158
+ ## closer
159
+ ${s(copy.closer.script)}
160
+ `;
161
+ return { html: out, script };
162
+ }
163
+
164
+ // ---- git ------------------------------------------------------------------
165
+
166
+ function git(cwd, args, extra = {}) {
167
+ return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...extra }).trim();
168
+ }
169
+
170
+ export function insideRepo(dir) {
171
+ try {
172
+ return git(dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ // Commit only the deck folder, whatever else the repository has staged. A
179
+ // machine with no git identity commits as the signed-in account.
180
+ export function commitDeck(deckDir, message, who = {}) {
181
+ git(deckDir, ["add", "--", "."]);
182
+ if (!git(deckDir, ["status", "--porcelain", "--", "."])) return false;
183
+ const id = [];
184
+ if (!gitConfig(deckDir, "user.email")) id.push("-c", `user.email=${who.email || "hyperdecks@localhost"}`);
185
+ if (!gitConfig(deckDir, "user.name")) id.push("-c", `user.name=${who.name || "Hyperdecks"}`);
186
+ execFileSync("git", ["-C", deckDir, ...id, "-c", "commit.gpgsign=false", "commit", "-q", "-m", message, "--", "."], { stdio: ["ignore", "pipe", "pipe"] });
187
+ return true;
188
+ }
189
+
190
+ function gitConfig(dir, key) {
191
+ try {
192
+ return git(dir, ["config", key]);
193
+ } catch {
194
+ return "";
195
+ }
196
+ }
197
+
198
+ // ---- skill ----------------------------------------------------------------
199
+
200
+ function filesUnder(dir, base = dir) {
201
+ return readdirSync(dir).flatMap((name) => {
202
+ const p = join(dir, name);
203
+ return statSync(p).isDirectory() ? filesUnder(p, base) : [relative(base, p)];
204
+ }).sort();
205
+ }
206
+
207
+ // A hash of every file in a skill folder; null when it is not there.
208
+ export function skillHash(dir) {
209
+ if (!existsSync(join(dir, "SKILL.md"))) return null;
210
+ const h = createHash("sha256");
211
+ for (const f of filesUnder(dir)) h.update(f).update("\0").update(readFileSync(join(dir, f))).update("\0");
212
+ return h.digest("hex");
213
+ }
214
+
215
+ // ---- prompts --------------------------------------------------------------
216
+
217
+ // Ctrl-C or Ctrl-D cancels init: it never reads as an empty answer (which
218
+ // would mean "yes" or the default choice).
219
+ export const CANCELLED = `init cancelled; run \`${RESUME}\` again to pick up where it stopped`;
220
+ export async function askLine(question) {
221
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
222
+ let answered = false;
223
+ try {
224
+ return await new Promise((resolve, reject) => {
225
+ rl.once("close", () => answered || reject(new Error(CANCELLED)));
226
+ rl.once("SIGINT", () => rl.close());
227
+ rl.question(question).then((a) => { answered = true; resolve(a); }, () => reject(new Error(CANCELLED)));
228
+ });
229
+ } finally {
230
+ rl.close();
231
+ }
232
+ }
233
+
234
+ // Paths dragged into a terminal: space separated, with backslash escapes or quotes.
235
+ export function parseDroppedPaths(line) {
236
+ const out = [];
237
+ let cur = "";
238
+ let quote = null;
239
+ let any = false;
240
+ for (let i = 0; i < line.length; i++) {
241
+ const c = line[i];
242
+ if (quote) {
243
+ if (c === quote) quote = null;
244
+ else cur += c;
245
+ } else if (c === "'" || c === '"') {
246
+ quote = c;
247
+ any = true;
248
+ } else if (c === "\\" && i + 1 < line.length) {
249
+ cur += line[++i];
250
+ any = true;
251
+ } else if (/\s/.test(c)) {
252
+ if (cur || any) out.push(cur);
253
+ cur = "";
254
+ any = false;
255
+ } else cur += c;
256
+ }
257
+ if (cur || any) out.push(cur);
258
+ return out.filter(Boolean);
259
+ }
260
+
261
+ // ---- the run --------------------------------------------------------------
262
+
263
+ export async function init(pos = [], opts = {}, io = {}) {
264
+ const {
265
+ log = (line) => console.log(line),
266
+ ask = askLine,
267
+ interactive = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY,
268
+ login = runLogin,
269
+ installSkill = installHyperdecksSkill,
270
+ spawn,
271
+ voiceClone = runVoiceClone,
272
+ deckNew = runDeckNew,
273
+ narrate = runNarrate,
274
+ release = runRelease,
275
+ open = openBrowser,
276
+ statePath = STATE_FILE,
277
+ cwd = process.cwd(),
278
+ home = homedir(),
279
+ env = process.env,
280
+ random = () => randomBytes(2).toString("hex"),
281
+ fetch,
282
+ } = io;
283
+ if (pos.length) throw new Error(`unexpected argument(s): ${pos.join(" ")}; init takes flags only (hyperdecks init --help)`);
284
+ for (const k of ["voice", "name", "about", "ask", "dir"]) if (k in opts && typeof opts[k] !== "string") throw new Error(`--${k} requires a value`);
285
+ if (opts.voice !== undefined && opts["no-voice"]) throw new Error("use either --voice or --no-voice, not both");
286
+
287
+ const t = target(opts);
288
+ // Only the flags that pick the backend and credential reach the steps' own commands.
289
+ const base = Object.fromEntries(["prod", "dev", "convex", "broker", "token", "admin", "client"].filter((k) => opts[k] !== undefined).map((k) => [k, opts[k]]));
290
+ const all = readState(statePath);
291
+ const state = { ...(all[t.name] || {}) };
292
+ const save = (patch) => {
293
+ Object.assign(state, patch, { deployment: t.name });
294
+ writeState(statePath, { ...readState(statePath), [t.name]: state });
295
+ };
296
+ const steps = [];
297
+ const sub = (line) => log(String(line).split("\n").map((l) => ` ${l}`).join("\n"));
298
+ const mark = { done: "✓", ran: "✓", skipped: "–", "needs-human": "!" };
299
+ const record = (s) => {
300
+ steps.push(s);
301
+ log(`${mark[s.status] ?? "·"} ${s.detail}`);
302
+ return s;
303
+ };
304
+ const finish = (status, extra = {}) => {
305
+ const next = nextSteps(state);
306
+ if (status === "incomplete") log(`\nNot finished yet. When that is done, run \`${RESUME}\` again: it picks up where it stopped.`);
307
+ return { status, steps, slug: state.slug ?? null, deckDir: state.deckDir ?? null, shareUrl: state.shareUrl ?? null, next, ...extra };
308
+ };
309
+
310
+ // 1. Sign in.
311
+ let who = await whoamiOrNull(base, env);
312
+ if (who?.email) record(step("sign-in", "done", `signed in as ${who.email}`));
313
+ else {
314
+ if (!opts.token && !env.HSDECKS_TOKEN) log("No account yet? You can create one on the page that opens.");
315
+ try {
316
+ await login(base, { log: sub, open });
317
+ } catch (e) {
318
+ const why = ops.errorText(e);
319
+ throw new Error(/timed out/.test(why) ? `${why}. If the page said sign-up is closed: sign-up is invite only for now, write to hello@hypersocial.ai` : why);
320
+ }
321
+ who = await whoamiOrNull(base, env);
322
+ if (!who?.email) throw new Error("signed in, but the backend did not return your account; run `hyperdecks whoami`");
323
+ record(step("sign-in", "ran", `signed in as ${who.email}`));
324
+ }
325
+ if (state.email && state.email !== who.email) {
326
+ // Another account's deck is not this one's to resume.
327
+ for (const k of ["slug", "deckDir", "releasedAt", "shareUrl", "shareToken", "presenter", "voice"]) delete state[k];
328
+ }
329
+ save({ email: who.email, startedAt: state.startedAt ?? new Date().toISOString() });
330
+
331
+ // 2. The skill. Its failure never stops init: the deck does not need it.
332
+ record(await skillStep(opts, { interactive, ask, installSkill, spawn, home, env, sub }));
333
+
334
+ // 3. A voice.
335
+ const voiceStep = await chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log, state, who });
336
+ record(voiceStep);
337
+ if (voiceStep.status === "needs-human") return finish("incomplete");
338
+ const { voice, presenter } = voiceStep;
339
+
340
+ // 4. The first deck.
341
+ const scaffold = await scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save });
342
+ record(scaffold);
343
+ if (scaffold.status === "needs-human") return finish("incomplete");
344
+
345
+ // 5. Narrate, then commit, so release finds a committed deck with a manifest.
346
+ record(await narrateStep(base, state, { narrate, who, sub }));
347
+
348
+ // 6. Check and release one QA link for yourself.
349
+ const shipped = await releaseStep(base, opts, state, { release, sub, save, fetch });
350
+ record(shipped);
351
+ if (shipped.status === "skipped") return finish("complete");
352
+
353
+ // 7. The link, opened, and what to do next.
354
+ log(`\nYour deck: ${state.shareUrl}`);
355
+ log("This link is yours to watch: it is QA, so its views are not tracked.");
356
+ if (!opts["no-open"]) open(state.shareUrl);
357
+ log("\nNext:");
358
+ for (const n of nextSteps(state)) log(` ${n}`);
359
+ return finish("complete");
360
+ }
361
+
362
+ function nextSteps(state) {
363
+ if (!state.slug) return [RESUME];
364
+ return [
365
+ `hyperdecks share new ${state.slug} "<name>" a tracked link for each person you send it to`,
366
+ `Paste into your agent: "Make my hello deck yours: open ${state.deckDir}, ask me what I'm working on, and rewrite index.html and script.md around it. Keep three slides and about 40 seconds, then run hyperdecks release ${state.deckDir} --to \\"Me\\" --qa."`,
367
+ "hyperdecks activity who watched, and how far they got",
368
+ ];
369
+ }
370
+
371
+ async function whoamiOrNull(base, env) {
372
+ try {
373
+ return await ops.whoami(base);
374
+ } catch (e) {
375
+ const why = ops.errorText(e);
376
+ // No credential, or one the broker refuses (revoked): sign in again. An
377
+ // outage (5xx, 429, no answer) is reported, not mistaken for signed out.
378
+ if (!/^not authenticated|auth exchange failed \((401|403)\)|credential was rejected/i.test(why)) throw e;
379
+ if (!base.token && env.HSDECKS_TOKEN) throw new Error(`HSDECKS_TOKEN was rejected (${why}); unset it or replace it, then run init again`);
380
+ if (base.token) throw new Error(`--token was rejected (${why})`);
381
+ return null;
382
+ }
383
+ }
384
+
385
+ async function skillStep(opts, { interactive, ask, installSkill, spawn, home, env, sub }) {
386
+ if (opts["no-skill"]) return step("skill", "skipped", "skill: skipped (--no-skill)");
387
+ let chosen;
388
+ try {
389
+ chosen = chooseAgents(opts, [], { home, env });
390
+ } catch (e) {
391
+ return step("skill", "skipped", `skill: not installed (${e.message})`, { warning: true });
392
+ }
393
+ const table = agentTable({ home, env });
394
+ const label = (id) => table.find((a) => a.id === id)?.label ?? id;
395
+ let agents = chosen.agents;
396
+ if (chosen.source === "default") sub(`no agent found in your home directory; installing for ${agents.map(label).join(", ")}`);
397
+ const packaged = skillHash(SKILL_DIR);
398
+ // An install is current when every chosen agent's copy matches the packaged skill.
399
+ const current = (id) => {
400
+ const dir = table.find((a) => a.id === id)?.skills;
401
+ return !!dir && skillHash(join(dir, "hyperdecks")) === packaged;
402
+ };
403
+ if (agents[0] !== "*" && agents.every(current)) return step("skill", "done", `skill installed for ${agents.map(label).join(", ")}`);
404
+ if (agents[0] !== "*") agents = agents.filter((id) => !current(id));
405
+ if (interactive && chosen.source !== "flag") {
406
+ const names = agents[0] === "*" ? "every agent" : agents.map(label).join(", ");
407
+ const answer = (await ask(`Install the Hyperdecks skill for ${names}? [Y/n] `)).trim().toLowerCase();
408
+ if (answer && !answer.startsWith("y")) return step("skill", "skipped", "skill: skipped; install it later with `hyperdecks skills install`");
409
+ }
410
+ sub("installing with npx skills…");
411
+ try {
412
+ installSkill({ ...(agents[0] === "*" ? { "all-agents": true } : { agent: agents.join(",") }) }, [], { quiet: true, spawn, home, env });
413
+ } catch (e) {
414
+ return step("skill", "skipped", `skill: not installed (${e.message}); the deck does not need it, try \`hyperdecks skills install\` later`, { warning: true });
415
+ }
416
+ // Agents init has no folder for (a valid `npx skills` id outside agentTable) are trusted on its exit code.
417
+ const missing = agents[0] === "*" ? [] : agents.filter((id) => table.some((a) => a.id === id) && !current(id));
418
+ if (missing.length) {
419
+ const where = missing.map((id) => `${label(id)} (${table.find((a) => a.id === id)?.skills})`).join(", ");
420
+ return step("skill", "skipped", `skill: npx skills finished but the skill is not in ${where}; try \`hyperdecks skills install\``, { warning: true });
421
+ }
422
+ return step("skill", "ran", `skill installed for ${agents[0] === "*" ? "every agent" : agents.map(label).join(", ")}`, { agents });
423
+ }
424
+
425
+ // A ledger with no rows means the starter grant has not landed yet: it does on
426
+ // the first spend, so a new account can afford its first clone.
427
+ async function spendable(base) {
428
+ const { balance } = await ops.creditsBalance(base);
429
+ if (balance > 0) return balance;
430
+ const ledger = await ops.creditsLedger(base, 1);
431
+ return ledger.length ? balance : STARTER_GRANT;
432
+ }
433
+
434
+ async function chooseVoice(base, opts, { interactive, ask, voiceClone, sub, log, state, who }) {
435
+ const dash = () => step("voice", "done", "D.A.S.H. presents, in the dash character voice", { voice: "dash", presenter: "dash" });
436
+ if (isCharacter(opts.voice)) return dash();
437
+ const voices = await ops.listVoices(base);
438
+ const own = (v) => step("voice", "done", `voice "${v.name}" is ready`, { voice: v.name, presenter: "self" });
439
+ if (opts.voice) {
440
+ const want = opts.voice.trim().toLowerCase();
441
+ const hit = voices.find((v) => v.id === opts.voice || v.name.toLowerCase() === want || v.handle?.toLowerCase() === want);
442
+ if (!hit) throw new Error(`--voice ${opts.voice} is not one of your voices (${voices.map((v) => v.name).join(", ") || "none yet"}); use --voice dash for D.A.S.H.`);
443
+ if (hit.status === "ready") return own(hit);
444
+ // The voice asked for, or nothing: never a different one in its place.
445
+ const v = hit.status === "pending" ? (sub(`voice "${hit.name}" is still cloning; waiting for it…`), await ops.pollVoice(base, hit.id)) : hit;
446
+ if (v.status === "ready") return { ...own(v), status: "ran" };
447
+ throw new Error(`voice "${hit.name}" is not ready (${v.status}${v.error ? `: ${v.error}` : ""}); clone it again or pick another --voice`);
448
+ }
449
+ // The deck init already made keeps the voice it was written for.
450
+ if (state.presenter === "dash" && state.slug) return dash();
451
+ const ready = voices.find((v) => v.status === "ready");
452
+ if (ready) return own(ready);
453
+ const pending = voices.find((v) => v.status === "pending");
454
+ let failed = "";
455
+ if (pending) {
456
+ sub(`voice "${pending.name}" is still cloning; waiting for it…`);
457
+ const v = await ops.pollVoice(base, pending.id);
458
+ if (v.status === "ready") return { ...own(v), status: "ran" };
459
+ failed = `voice "${v.name}" failed (${v.error}); `;
460
+ sub(failed);
461
+ }
462
+ const later = (why) => step("voice", "needs-human", `voice: ${why}. Run \`${RESUME}\` when you have one`);
463
+ if (opts["no-voice"]) return later("skipped for now (--no-voice)");
464
+ if (!interactive) return step("voice", "needs-human", `voice: ${failed}none ready yet; ${AGENT_VOICE_HELP}`);
465
+
466
+ for (;;) {
467
+ log("\nYour deck needs a voice to narrate it. Pick one:");
468
+ log(` 1. Clone my voice (${VOICE_CLONE_COST} credits, refunded if it fails)`);
469
+ log(" 2. Let D.A.S.H. present it (free; a character, introducing you)");
470
+ log(" 3. Later");
471
+ const pick = (await ask("Choose 1, 2 or 3 [1]: ")).trim() || "1";
472
+ if (pick === "2") return dash();
473
+ if (pick === "3") return later("not chosen yet");
474
+ if (pick !== "1") continue;
475
+ const credits = await spendable(base);
476
+ if (credits < VOICE_CLONE_COST) {
477
+ log(`Cloning a voice costs ${VOICE_CLONE_COST} credits and you have ${credits}. D.A.S.H. is free.`);
478
+ continue;
479
+ }
480
+ log(`\nRecord yourself reading this aloud (about 45 seconds), with Voice Memos, QuickTime or your phone:\n\n ${READING_PASSAGE}\n`);
481
+ log("Save it as .m4a, .mp3 or .wav (up to 3 files, 10 MB each), then drag the file(s) into this terminal.");
482
+ const files = parseDroppedPaths(await ask("Recording(s): "));
483
+ if (!files.length) continue;
484
+ const name = (await ask(`Name for this voice [${who.email.split("@")[0]}]: `)).trim() || who.email.split("@")[0];
485
+ try {
486
+ const v = await voiceClone(files, { ...base, name }, { ask, log: sub });
487
+ return step("voice", "ran", `voice "${v.name}" is ready`, { voice: v.name, presenter: "self" });
488
+ } catch (e) {
489
+ log(`Voice clone did not finish: ${ops.errorText(e)}`);
490
+ // A clone still running (a poll timeout) is already paid for: wait for it, never start another.
491
+ const still = (await ops.listVoices(base)).find((v) => v.status === "pending");
492
+ if (still) return step("voice", "needs-human", `voice: "${still.name}" is still cloning. Run \`${RESUME}\` in a few minutes`);
493
+ }
494
+ }
495
+ }
496
+
497
+ async function deckOwnedBy(base, slug, who) {
498
+ const deck = await (await client(base)).query(api.decks.get, { slug });
499
+ return deck ? { exists: true, mine: deck.ownerId === who.subject } : { exists: false, mine: false };
500
+ }
501
+
502
+ async function scaffoldStep(base, opts, { state, who, voice, presenter, interactive, ask, deckNew, cwd, home, random, sub, log, save }) {
503
+ // A deck init registered but did not finish writing (interrupted before its
504
+ // commit) is resumed under the same slug, not orphaned for a suffixed one.
505
+ const resumable = !!state.slug && !!state.deckDir && existsSync(join(state.deckDir, "index.html")) && (await deckOwnedBy(base, state.slug, who)).mine;
506
+ if (resumable && state.scaffolded) return step("scaffold", "done", `deck "${state.slug}" → ${state.deckDir}`);
507
+ const keys = ["name", "about", "ask"];
508
+ const questions = { name: "Your name: ", about: "What you do, in one line: ", ask: "What should people do after watching? " };
509
+ if (!interactive && !keys.every((k) => opts[k] !== undefined)) {
510
+ return step("scaffold", "needs-human", "deck: pass --name, --about and --ask (your name, what you do in one line, what people should do after watching)");
511
+ }
512
+ if (interactive && !keys.every((k) => opts[k] !== undefined)) log("\nThree quick questions for your first deck (you can rewrite it later):");
513
+ const answers = {};
514
+ for (const k of keys) {
515
+ if (opts[k] !== undefined) answers[k] = cleanAnswer(k, opts[k]);
516
+ while (answers[k] === undefined) {
517
+ try {
518
+ answers[k] = cleanAnswer(k, await ask(questions[k]));
519
+ } catch (e) {
520
+ log(` ${e.message.replace(/^--\w+/, "That")}`);
521
+ }
522
+ }
523
+ }
524
+
525
+ // Slugs are global: on a clash, a short random suffix.
526
+ // The default folder is ./hyperdecks, except inside someone's repository (an
527
+ // agent's working directory, usually): init never commits onto their branch
528
+ // unasked, so the deck goes in ~/hyperdecks, a repository of its own.
529
+ const ownDir = typeof opts.dir === "string";
530
+ const inProject = !resumable && !ownDir && insideRepo(cwd);
531
+ const root = resumable ? dirname(state.deckDir) : ownDir ? dirname(resolve(cwd, opts.dir)) : join(inProject ? home : cwd, "hyperdecks");
532
+ if (inProject) sub(`${cwd} is a git repository, so the deck goes in ${root} rather than committing into it (--dir picks another folder)`);
533
+ let slug = resumable ? state.slug : firstDeckSlug(who.email);
534
+ for (let i = 0; !resumable; i++) {
535
+ const dir = typeof opts.dir === "string" ? resolve(cwd, opts.dir) : join(root, slug);
536
+ if (!(await deckOwnedBy(base, slug, who)).exists && (typeof opts.dir === "string" || !existsSync(dir))) break;
537
+ if (i >= 5) throw new Error(`could not find a free deck slug near ${firstDeckSlug(who.email)}; pass --dir`);
538
+ slug = `${firstDeckSlug(who.email)}-${random()}`;
539
+ }
540
+ const deckDir = resumable ? state.deckDir : ownDir ? resolve(cwd, opts.dir) : join(root, slug);
541
+ mkdirSync(root, { recursive: true });
542
+ const title = `Hello from ${answers.name}`;
543
+ if (!resumable) {
544
+ // Recorded first, so a run killed inside deck new resumes this slug.
545
+ for (const k of ["releasedAt", "shareUrl", "shareToken"]) delete state[k];
546
+ save({ slug, deckDir, scaffolded: false });
547
+ await deckNew([slug, title], { ...base, narrated: true, layouts: FIRST_DECK_LAYOUTS, dir: deckDir }, { log: () => {}, cwd });
548
+ }
549
+ // release ships a committed deck. A --dir outside any repository becomes one
550
+ // itself (never its parent, which may be your home folder).
551
+ if (!insideRepo(deckDir)) {
552
+ const repo = ownDir || resumable ? deckDir : root;
553
+ sub(`release ships a committed deck, so ${repo} becomes a git repository`);
554
+ git(repo, ["init", "-q"]);
555
+ }
556
+ // Filled from the layout template every time, so a half-written folder is overwritten whole.
557
+ const fresh = assembleLayoutDeck({ slug, title, layouts: FIRST_DECK_LAYOUTS });
558
+ const filled = fillFirstDeck({ html: fresh["index.html"], title, answers, presenter, voice });
559
+ writeFileSync(join(deckDir, "index.html"), filled.html);
560
+ writeFileSync(join(deckDir, "script.md"), filled.script);
561
+ writeFileSync(join(deckDir, "evidence.md"), fresh["evidence.md"]);
562
+ commitDeck(deckDir, "hyperdecks: first deck", { email: who.email, name: answers.name });
563
+ save({ slug, deckDir, presenter, voice, scaffolded: true });
564
+ return step("scaffold", "ran", `deck "${slug}" → ${deckDir}`);
565
+ }
566
+
567
+ async function narrateStep(base, state, { narrate, who, sub }) {
568
+ const html = () => readFileSync(join(state.deckDir, "index.html"), "utf8");
569
+ const hasManifest = () => /\sdata-manifest="https?:\/\/[^"]+"/.test(html()) && existsSync(join(state.deckDir, "narration", "manifest.json"));
570
+ const opts = { ...base, slug: state.slug };
571
+ if (hasManifest()) {
572
+ const preview = await narrate([state.deckDir], { ...opts, "dry-run": true }, { log: () => {} });
573
+ if (!preview.slides.some((s) => s.render)) {
574
+ // An earlier run may have stopped between narrating and committing.
575
+ const committed = commitDeck(state.deckDir, "hyperdecks: narrate", { email: who.email });
576
+ return step("narrate", committed ? "ran" : "done", "narration is current");
577
+ }
578
+ }
579
+ const job = await narrate([state.deckDir], opts, { log: sub });
580
+ if (!job?.manifestUrl) throw new Error(`narration finished without a manifest (${job?.notice || "no manifest URL"}); run \`${RESUME}\` again`);
581
+ commitDeck(state.deckDir, "hyperdecks: narrate", { email: who.email });
582
+ return step("narrate", "ran", `narrated: ${job.manifest?.clips?.length ?? "all"} slides, ${job.manifest?.totalDuration ?? "?"}s`);
583
+ }
584
+
585
+ async function releaseStep(base, opts, state, { release, sub, save, fetch }) {
586
+ const shares = await ops.listShares(base, state.slug);
587
+ if (state.releasedAt && state.shareToken && shares.some((s) => s.token === state.shareToken)) return step("release", "done", `released: ${state.shareUrl}`);
588
+ // A run killed after release made its link but before init.json recorded it.
589
+ const mine = !state.releasedAt && shares.find((s) => s.qa && s.recipient === "Me" && s.enabled);
590
+ if (mine) {
591
+ const url = `${target(base).shareBase}/v/${mine.token}`;
592
+ save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: mine.token });
593
+ return step("release", "done", `released: ${url}`);
594
+ }
595
+ if (opts["no-release"]) {
596
+ const { checkDeck } = await import("./check.mjs");
597
+ const checked = await checkDeck(state.deckDir, {}, fetch ? { fetch } : {});
598
+ if (checked.errors.length) throw new Error(`check failed for ${state.slug}:\n${checked.errors.map((e) => ` - ${e}`).join("\n")}`);
599
+ return step("release", "skipped", `check passed; not released (--no-release): hyperdecks release ${state.deckDir} --to "Me" --qa`);
600
+ }
601
+ const out = await release([state.deckDir], { ...base, slug: state.slug, recipients: [{ recipient: "Me" }], qa: true }, { log: sub });
602
+ const url = out.shares[0].url;
603
+ save({ releasedAt: new Date().toISOString(), shareUrl: url, shareToken: url.split("/v/").pop() });
604
+ return step("release", "ran", `released: ${url}`);
605
+ }