@hypersocial/decks-cli 0.2.2 → 0.3.1

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,71 @@
1
+ // The deck templates as text: the narrated outline and the layout library, filled
2
+ // for a slug and title. No file I/O beyond reading the packaged templates, so the
3
+ // hosted MCP route (new_deck) and the CLI (deck new) assemble the same deck.
4
+ import { packageText } from "./files.mjs";
5
+
6
+ export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
7
+ // The library, in a sensible deck order; `--layouts` takes any order and repeats.
8
+ export const LAYOUTS = ["hero", "number", "product", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
9
+ const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", comparison: "compare", "three-up": "features" };
10
+
11
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12
+
13
+ // Template text with __SLUG__ / __TITLE__ filled; HTML gets an escaped title.
14
+ // Without a presenter style the player tag carries none (the player's default).
15
+ export function fillTemplate(file, { slug, title, presenterStyle }) {
16
+ const text = packageText(`templates/${file}`);
17
+ const t = /\.html$/.test(file) ? escapeHtml(title) : title;
18
+ const styled = presenterStyle ? text.split("__PRESENTER_STYLE__").join(presenterStyle) : text.replace(/\s*data-presenter-style="__PRESENTER_STYLE__"/g, "");
19
+ return styled.split("__SLUG__").join(slug).split("__TITLE__").join(t);
20
+ }
21
+
22
+ // One layout file: a leading comment of `key: value` lines (layout, summary,
23
+ // use, slot, presenter, cues, script), its <style> and its <section>.
24
+ export function readLayout(name) {
25
+ const text = packageText(`templates/layouts/${name}.html`);
26
+ const head = /^<!--([\s\S]*?)-->/.exec(text);
27
+ const style = /<style>[\s\S]*?<\/style>/.exec(text);
28
+ const section = /<section\b[\s\S]*<\/section>/.exec(text);
29
+ if (!head || !style || !section) throw new Error(`layout ${name}: needs a leading comment, a <style> and a <section>`);
30
+ const meta = {};
31
+ for (const line of head[1].split("\n")) {
32
+ const m = /^([a-z]+):\s*(.*)$/.exec(line.trim());
33
+ if (m) meta[m[1]] = m[2];
34
+ }
35
+ return { name, meta, style: style[0], section: section[0] };
36
+ }
37
+
38
+ export function parseLayouts(list) {
39
+ const names = String(list).split(",").map((n) => n.trim().toLowerCase()).filter(Boolean).map((n) => LAYOUT_ALIASES[n] ?? n);
40
+ const unknown = names.filter((n) => !LAYOUTS.includes(n));
41
+ if (unknown.length) throw new Error(`unknown layout${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}: pick from ${LAYOUTS.join(", ")}`);
42
+ if (!names.length) throw new Error(`--layouts needs at least one of ${LAYOUTS.join(", ")}`);
43
+ return names;
44
+ }
45
+
46
+ // index.html, script.md and evidence.md for a deck of `layouts`, one slide
47
+ // each, in order. A layout used twice gets ids hero, hero-2, …; ids inside a
48
+ // slide start with its id, so its cues stay unique.
49
+ export function assembleLayoutDeck({ slug, title, presenterStyle, layouts }) {
50
+ const names = parseLayouts(layouts);
51
+ const used = new Map();
52
+ const slides = names.map((name) => {
53
+ const n = (used.get(name) ?? 0) + 1;
54
+ used.set(name, n);
55
+ return { layout: readLayout(name), id: n === 1 ? name : `${name}-${n}` };
56
+ });
57
+ const fill = (text, id, t) => text.split("__ID__").join(id).split("__SLUG__").join(slug).split("__TITLE__").join(t);
58
+ const sections = slides.map(({ layout, id }, i) => {
59
+ let html = fill(layout.section, id, escapeHtml(title));
60
+ if (i === 0 && !/\sdata-intro=/.test(html.slice(0, html.indexOf(">")))) html = html.replace("<section ", '<section data-intro="Hi! Press play." ');
61
+ return `<!-- layout: ${layout.name}. ${layout.meta.summary ?? ""} -->\n${html}`;
62
+ });
63
+ const styles = [...new Set(names)].map((name) => `<!-- layout: ${name} -->\n${readLayout(name).style}`);
64
+ const shell = fillTemplate("layouts/_deck.html", { slug, title, presenterStyle });
65
+ // Function replacements: a "$" in the title or a layout is text, not a replacement pattern.
66
+ const html = shell.replace("<!-- LAYOUT STYLES -->", () => styles.join("\n")).replace("<!-- SLIDES -->", () => sections.join("\n\n"));
67
+ const intro = fillTemplate("script.md", { slug, title }).split(/\n## /)[0].trimEnd();
68
+ const script = `${intro}\n\n${slides.map(({ layout, id }) => `## ${id}\n${fill(layout.meta.script ?? "", id, title)}`).join("\n\n")}\n`;
69
+ return { "index.html": html, "script.md": script, "evidence.md": fillTemplate("evidence.md", { slug, title }) };
70
+ }
71
+
package/src/narrate.mjs CHANGED
@@ -8,18 +8,8 @@
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { basename, join, resolve } from "node:path";
10
10
  import * as ops from "./ops.mjs";
11
-
12
- // Same rule as sectionIds() in lib/deckScript.ts (the published CLI cannot import it).
13
- export const sectionIds = (html) => [...html.matchAll(/<section\b[^>]*?\sid\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
14
-
15
- // Set data-manifest on the hosted player's <script> tag; html is unchanged when
16
- // there is no player tag or it already names this URL.
17
- export function setManifestUrl(html, url) {
18
- return html.replace(/<script\b[^>]*\/player\/v\d+\/player\.js"[^>]*>/, (tag) =>
19
- /\sdata-manifest\s*=\s*"[^"]*"/.test(tag)
20
- ? tag.replace(/(\sdata-manifest\s*=\s*)"[^"]*"/, (_, lead) => `${lead}"${url}"`)
21
- : tag.replace(/\s*>$/, () => ` data-manifest="${url}">`));
22
- }
11
+ export { sectionIds, setManifestUrl } from "./checkRules.mjs";
12
+ import { sectionIds, setManifestUrl } from "./checkRules.mjs";
23
13
 
24
14
  function slideList(value) {
25
15
  const ids = value.split(",").map((s) => s.trim()).filter(Boolean);
package/src/ops.mjs CHANGED
@@ -36,6 +36,21 @@ export async function activityOverview(opts = {}) {
36
36
  return (await client(opts)).query(api.activity.overview, {});
37
37
  }
38
38
 
39
+ // What one recipient watched, per deck link (or one link by token): the data
40
+ // the owner's agent drafts a follow-up from. A 16-character link token is
41
+ // looked up as a token; anything else is a recipient name.
42
+ export const RECIPIENT_ENGAGEMENT_DESCRIPTION =
43
+ "What one recipient watched, per deck link: visits, narration heard (%), replayed / skipped slides, " +
44
+ "where they dropped off, time per slide, each visit, and forwards. Use it to draft a follow-up: lead " +
45
+ "with what they spent time on, and never tell the recipient they were tracked.";
46
+ export const SHARE_TOKEN = /^[a-f0-9]{16}$/;
47
+ export async function recipientEngagement(opts, who, deck) {
48
+ const value = String(who ?? "").trim();
49
+ if (!value) throw new Error("a recipient name or share token is required");
50
+ const args = SHARE_TOKEN.test(value) ? { token: value } : { recipient: value, ...(deck ? { deckSlug: String(deck) } : {}) };
51
+ return (await client(opts)).query(api.engagement.recipientEngagement, args);
52
+ }
53
+
39
54
  // Generate an image via fal (GPT Image 2.5 by default, or Nano Banana 2.1 / GPT
40
55
  // Image 2 with `model`; t2i/i2i, optional BiRefNet transparency),
41
56
  // store it in Convex, and return the hosted URL. Runs the imagegen action as the
@@ -181,6 +196,7 @@ export async function reviewDeck(opts, fields = {}) {
181
196
  if (!fields.slug) throw new Error("slug is required");
182
197
  const args = { slug: String(fields.slug) };
183
198
  if (fields.script) args.script = String(fields.script);
199
+ if (fields.draft) args.draft = true;
184
200
  return (await client(opts)).action(api.review.reviewDeck, args);
185
201
  }
186
202
 
@@ -245,8 +261,9 @@ export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs =
245
261
  // thin client calls shared by the CLI `narrate` command and the MCP tools.
246
262
  function narrationArgs(fields = {}) {
247
263
  if (!fields.slug) throw new Error("slug is required");
248
- if (!fields.script) throw new Error("script is required (the deck's script.md)");
249
- const args = { slug: String(fields.slug), script: String(fields.script) };
264
+ // No script: narrate the deck's hosted script.md (deckSources), and the
265
+ // finished job writes the manifest URL into the hosted draft.
266
+ const args = fields.script ? { slug: String(fields.slug), script: String(fields.script) } : { slug: String(fields.slug), fromSource: true };
250
267
  if (fields.voice) args.voice = String(fields.voice);
251
268
  if (fields.slides) {
252
269
  if (!fields.slides.length) throw new Error("slides is empty; omit it to narrate every slide");
package/src/runtime.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  import { ConvexHttpClient } from "convex/browser";
9
9
  import { makeFunctionReference } from "convex/server";
10
10
 
11
- export const VERSION = "0.2.2";
11
+ export const VERSION = "0.3.1";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -24,10 +24,23 @@ export const api = {
24
24
  generateUploadUrl: makeFunctionReference("assets:generateUploadUrl"),
25
25
  saveAsset: makeFunctionReference("assets:saveAsset"),
26
26
  },
27
+ assetsNode: {
28
+ importAssetFromUrl: makeFunctionReference("assetsNode:importAssetFromUrl"),
29
+ },
30
+ deckSources: {
31
+ create: makeFunctionReference("deckSources:create"),
32
+ get: makeFunctionReference("deckSources:get"),
33
+ write: makeFunctionReference("deckSources:write"),
34
+ check: makeFunctionReference("deckSources:check"),
35
+ release: makeFunctionReference("deckSources:release"),
36
+ },
27
37
  activity: {
28
38
  overview: makeFunctionReference("activity:overview"),
29
39
  setArchived: makeFunctionReference("activity:setArchived"),
30
40
  },
41
+ engagement: {
42
+ recipientEngagement: makeFunctionReference("engagement:recipientEngagement"),
43
+ },
31
44
  cliCredentials: {
32
45
  revokeByHash: makeFunctionReference("cliCredentials:revokeByHash"),
33
46
  },
@@ -89,7 +102,7 @@ export const api = {
89
102
  },
90
103
  };
91
104
 
92
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force"]);
105
+ const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated", "layout", "full-page", "force", "hosted", "yes", "no-voice", "no-skill", "no-release", "no-open"]);
93
106
  // release's recipients, in order: each --to starts one, and an --email right
94
107
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
95
108
  // opts.email stays the last --email for the other commands (share new).
@@ -274,7 +287,42 @@ function splitAgents(value) {
274
287
  return String(value || "").split(/[,\s]+/).filter(Boolean);
275
288
  }
276
289
 
277
- export function installHyperdecksSkill(opts = {}, extraArgs = []) {
290
+ export const DEFAULT_AGENTS = ["codex", "claude-code"];
291
+
292
+ // Agents `npx skills` installs into, keyed by its agent id: the directory whose
293
+ // presence means the agent is installed (the same test `npx skills` uses) and
294
+ // where its global skills live. From skills@1.7 (dist/cli.mjs, agent table).
295
+ export function agentTable({ home = homedir(), env = process.env } = {}) {
296
+ const config = env.XDG_CONFIG_HOME || join(home, ".config");
297
+ const claude = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
298
+ return [
299
+ { id: "claude-code", label: "Claude Code", home: claude, skills: join(claude, "skills") },
300
+ { id: "codex", label: "Codex", home: env.CODEX_HOME || join(home, ".codex"), skills: join(home, ".agents", "skills") },
301
+ { id: "cursor", label: "Cursor", home: join(home, ".cursor"), skills: join(home, ".cursor", "skills") },
302
+ { id: "gemini-cli", label: "Gemini CLI", home: join(home, ".gemini"), skills: join(home, ".gemini", "skills") },
303
+ { id: "opencode", label: "OpenCode", home: join(config, "opencode"), skills: join(config, "opencode", "skills") },
304
+ { id: "windsurf", label: "Windsurf", home: join(home, ".codeium", "windsurf"), skills: join(home, ".codeium", "windsurf", "skills") },
305
+ { id: "github-copilot", label: "GitHub Copilot", home: join(home, ".copilot"), skills: join(home, ".copilot", "skills") },
306
+ ];
307
+ }
308
+
309
+ // The agents whose home directory exists on this machine.
310
+ export function detectAgents({ home, env, exists = existsSync } = {}) {
311
+ return agentTable({ home, env }).filter((a) => exists(a.home)).map((a) => a.id);
312
+ }
313
+
314
+ // Which agents an install targets: --all-agents, --agent, or the detected ones
315
+ // (today's codex,claude-code when none is found).
316
+ export function chooseAgents(opts = {}, extraArgs = [], detect = {}) {
317
+ if (opts["all-agents"]) return { agents: ["*"], source: "all" };
318
+ if (opts.agent) return { agents: [...splitAgents(opts.agent), ...extraArgs.flatMap(splitAgents)], source: "flag" };
319
+ const found = detectAgents(detect);
320
+ return found.length ? { agents: found, source: "detected" } : { agents: DEFAULT_AGENTS, source: "default" };
321
+ }
322
+
323
+ // `quiet` pipes `npx skills` output instead of inheriting the terminal (init
324
+ // --json keeps stdout for its one JSON object); a failure then carries its tail.
325
+ export function installHyperdecksSkill(opts = {}, extraArgs = [], { spawn = spawnSync, quiet = false, log = (line) => console.log(line), home, env } = {}) {
278
326
  const skillDir = join(PACKAGED_SKILLS_DIR, "hyperdecks");
279
327
  if (!existsSync(skillDir)) {
280
328
  throw new Error(`packaged hyperdecks skill not found at ${skillDir}`);
@@ -292,19 +340,19 @@ export function installHyperdecksSkill(opts = {}, extraArgs = []) {
292
340
  throw new Error(`unexpected argument(s): ${extraArgs.join(" ")}; pass agent ids with --agent`);
293
341
  }
294
342
 
295
- const agents = opts["all-agents"]
296
- ? ["*"]
297
- : [...splitAgents(opts.agent || "codex,claude-code"), ...extraArgs.flatMap(splitAgents)];
343
+ const { agents, source } = chooseAgents(opts, extraArgs, { home, env });
344
+ if (source === "default" && !quiet) log(`no agent found in your home directory; installing for ${agents.join(", ")}`);
298
345
  const args = ["-y", "skills@latest", "add", PACKAGED_SKILLS_DIR, "-g"];
299
346
  args.push("-a", ...agents, "-s", "hyperdecks", "--copy", "-y", "--full-depth");
300
347
 
301
348
  const command = process.platform === "win32" ? "npx.cmd" : "npx";
302
- const result = spawnSync(command, args, { stdio: "inherit" });
349
+ const result = spawn(command, args, quiet ? { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" } : { stdio: "inherit" });
303
350
  if (result.error) throw result.error;
304
351
  if (result.status !== 0) {
305
- throw new Error(`skills add failed with exit code ${result.status}`);
352
+ const tail = quiet ? `${result.stdout || ""}\n${result.stderr || ""}`.trim().split("\n").filter((l) => l.trim() && !/^npm warn/i.test(l)).slice(-3).join(" ") : "";
353
+ throw new Error(`skills add failed with exit code ${result.status}${tail ? `: ${tail}` : ""}`);
306
354
  }
307
- return { installed: true };
355
+ return { installed: true, agents, source };
308
356
  }
309
357
 
310
358
  async function verifyAndStampEmail(t, opts) {
@@ -316,14 +364,14 @@ async function verifyAndStampEmail(t, opts) {
316
364
  return who;
317
365
  }
318
366
 
319
- export async function login(opts) {
367
+ export async function login(opts, { log = (line) => console.log(line), open = openBrowser } = {}) {
320
368
  const t = target(opts);
321
369
 
322
370
  if (opts.token) {
323
371
  saveCred(t.name, { refreshToken: opts.token, convexUrl: t.convexUrl, brokerBase: t.brokerBase });
324
372
  try {
325
373
  const who = await verifyAndStampEmail(t, opts);
326
- console.log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
374
+ log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
327
375
  } catch (e) {
328
376
  clearCred(t.name);
329
377
  throw e;
@@ -381,14 +429,14 @@ export async function login(opts) {
381
429
  server.listen(0, "127.0.0.1", () => {
382
430
  const { port } = server.address();
383
431
  const url = `${t.brokerBase}/cli/authorize?port=${port}&state=${state}`;
384
- console.log(`Opening your browser to sign in (${t.name})...\n ${url}\nIf it does not open, paste that URL into your browser.`);
385
- openBrowser(url);
432
+ log(`Opening your browser to sign in (${t.name})...\n ${url}\nIf it does not open, paste that URL into your browser.`);
433
+ open(url);
386
434
  });
387
435
  timer = setTimeout(() => finish(new Error("login timed out after 3 min")), 180000);
388
436
  });
389
437
 
390
438
  const who = await verifyAndStampEmail(t, opts);
391
- console.log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
439
+ log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
392
440
  }
393
441
 
394
442
  export async function logout(opts) {
package/src/scaffold.mjs CHANGED
@@ -5,82 +5,17 @@
5
5
  // templates/layouts/), script.md (the narration) and evidence.md (where each
6
6
  // fact comes from), then registers the deck as a draft you own so narrate can
7
7
  // render into it.
8
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
- import { dirname, join, resolve } from "node:path";
10
- import { fileURLToPath } from "node:url";
8
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
11
10
  import * as ops from "./ops.mjs";
12
- import { PRESENTER_STYLES } from "./check.mjs";
11
+ import { PRESENTER_STYLES } from "./checkRules.mjs";
12
+ import { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
13
+
14
+ export { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts, readLayout } from "./layouts.mjs";
13
15
 
14
- export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
15
- export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
16
16
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
17
- export const LAYOUT_DIR = join(TEMPLATE_DIR, "layouts");
18
- // The library, in a sensible deck order; `--layouts` takes any order and repeats.
19
- export const LAYOUTS = ["hero", "number", "product", "quote", "compare", "timeline", "features", "chart", "team", "closer"];
20
- const LAYOUT_ALIASES = { "big-number": "number", "product-shot": "product", comparison: "compare", "three-up": "features" };
21
17
  const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--dir <path>]';
22
18
 
23
- const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24
-
25
- // Template text with __SLUG__ / __TITLE__ filled; HTML gets an escaped title.
26
- // Without a presenter style the player tag carries none (the player's default).
27
- export function fillTemplate(file, { slug, title, presenterStyle }) {
28
- const text = readFileSync(join(TEMPLATE_DIR, file), "utf8");
29
- const t = /\.html$/.test(file) ? escapeHtml(title) : title;
30
- const styled = presenterStyle ? text.split("__PRESENTER_STYLE__").join(presenterStyle) : text.replace(/\s*data-presenter-style="__PRESENTER_STYLE__"/g, "");
31
- return styled.split("__SLUG__").join(slug).split("__TITLE__").join(t);
32
- }
33
-
34
- // One layout file: a leading comment of `key: value` lines (layout, summary,
35
- // use, slot, presenter, cues, script), its <style> and its <section>.
36
- export function readLayout(name) {
37
- const text = readFileSync(join(LAYOUT_DIR, `${name}.html`), "utf8");
38
- const head = /^<!--([\s\S]*?)-->/.exec(text);
39
- const style = /<style>[\s\S]*?<\/style>/.exec(text);
40
- const section = /<section\b[\s\S]*<\/section>/.exec(text);
41
- if (!head || !style || !section) throw new Error(`layout ${name}: needs a leading comment, a <style> and a <section>`);
42
- const meta = {};
43
- for (const line of head[1].split("\n")) {
44
- const m = /^([a-z]+):\s*(.*)$/.exec(line.trim());
45
- if (m) meta[m[1]] = m[2];
46
- }
47
- return { name, meta, style: style[0], section: section[0] };
48
- }
49
-
50
- export function parseLayouts(list) {
51
- const names = String(list).split(",").map((n) => n.trim().toLowerCase()).filter(Boolean).map((n) => LAYOUT_ALIASES[n] ?? n);
52
- const unknown = names.filter((n) => !LAYOUTS.includes(n));
53
- if (unknown.length) throw new Error(`unknown layout${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}: pick from ${LAYOUTS.join(", ")}`);
54
- if (!names.length) throw new Error(`--layouts needs at least one of ${LAYOUTS.join(", ")}`);
55
- return names;
56
- }
57
-
58
- // index.html, script.md and evidence.md for a deck of `layouts`, one slide
59
- // each, in order. A layout used twice gets ids hero, hero-2, …; ids inside a
60
- // slide start with its id, so its cues stay unique.
61
- export function assembleLayoutDeck({ slug, title, presenterStyle, layouts }) {
62
- const names = parseLayouts(layouts);
63
- const used = new Map();
64
- const slides = names.map((name) => {
65
- const n = (used.get(name) ?? 0) + 1;
66
- used.set(name, n);
67
- return { layout: readLayout(name), id: n === 1 ? name : `${name}-${n}` };
68
- });
69
- const fill = (text, id, t) => text.split("__ID__").join(id).split("__SLUG__").join(slug).split("__TITLE__").join(t);
70
- const sections = slides.map(({ layout, id }, i) => {
71
- let html = fill(layout.section, id, escapeHtml(title));
72
- if (i === 0 && !/\sdata-intro=/.test(html.slice(0, html.indexOf(">")))) html = html.replace("<section ", '<section data-intro="Hi! Press play." ');
73
- return `<!-- layout: ${layout.name}. ${layout.meta.summary ?? ""} -->\n${html}`;
74
- });
75
- const styles = [...new Set(names)].map((name) => `<!-- layout: ${name} -->\n${readLayout(name).style}`);
76
- const shell = fillTemplate("layouts/_deck.html", { slug, title, presenterStyle });
77
- // Function replacements: a "$" in the title or a layout is text, not a replacement pattern.
78
- const html = shell.replace("<!-- LAYOUT STYLES -->", () => styles.join("\n")).replace("<!-- SLIDES -->", () => sections.join("\n\n"));
79
- const intro = fillTemplate("script.md", { slug, title }).split(/\n## /)[0].trimEnd();
80
- const script = `${intro}\n\n${slides.map(({ layout, id }) => `## ${id}\n${fill(layout.meta.script ?? "", id, title)}`).join("\n\n")}\n`;
81
- return { "index.html": html, "script.md": script, "evidence.md": fillTemplate("evidence.md", { slug, title }) };
82
- }
83
-
84
19
  export async function deckNew(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
85
20
  const [slug, title] = pos;
86
21
  if (!slug || !title) throw new Error(USAGE);
@@ -90,7 +25,7 @@ export async function deckNew(pos, opts = {}, { log = (line) => console.log(line
90
25
  throw new Error(`--presenter-style must be one of ${PRESENTER_STYLES.join(", ")}`);
91
26
  }
92
27
  if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
93
- const dir = resolve(cwd, typeof opts.dir === "string" ? opts.dir : join("decks", slug));
28
+ const dir = resolve(/*turbopackIgnore: true*/ cwd, typeof opts.dir === "string" ? opts.dir : join("decks", slug));
94
29
  if (existsSync(dir)) throw new Error(`${dir} already exists; pick another slug or --dir`);
95
30
  if (opts.layouts === true) throw new Error(`--layouts needs a list, e.g. --layouts ${LAYOUTS.slice(0, 3).join(",")},closer`);
96
31
  const files = opts.layouts !== undefined
@@ -99,7 +34,7 @@ export async function deckNew(pos, opts = {}, { log = (line) => console.log(line
99
34
  await ops.createDeck(opts, { slug, title });
100
35
  try {
101
36
  mkdirSync(dir, { recursive: true });
102
- for (const [out, text] of files) writeFileSync(join(dir, out), text);
37
+ for (const [out, text] of files) writeFileSync(join(/*turbopackIgnore: true*/ dir, out), text);
103
38
  } catch (e) {
104
39
  rmSync(dir, { recursive: true, force: true });
105
40
  throw new Error(`deck ${slug} is registered but its files could not be written (${e.message}); remove the draft with \`hyperdecks deck rm ${slug}\` and run deck new again`);
package/src/source.mjs ADDED
@@ -0,0 +1,69 @@
1
+ // The hosted deck source (convex/deckSources.ts): a deck's files kept on
2
+ // Hyperdecks as a draft, for chat agents on the remote MCP and for
3
+ // hyperdecks deck new <slug> "<title>" --narrated --hosted [--layouts …]
4
+ // hyperdecks source pull <slug> [dir] the draft into a folder
5
+ // hyperdecks source push <slug> [dir] [--base-rev N] a folder's files to the draft
6
+ // hyperdecks check <slug> --hosted
7
+ // hyperdecks release <slug> --hosted [--to "<name>" [--email e]]... [--qa]
8
+ // hyperdecks asset add <slug> <file|url> [--name n]
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { basename, extname, join, resolve } from "node:path";
11
+ import { api, client, uploadBytes } from "./runtime.mjs";
12
+ import { SOURCE_FILES, getDeckSource, uploadDeckAsset, writeDeckSource } from "./hosted.mjs";
13
+
14
+ export * from "./hosted.mjs";
15
+
16
+ // ---- CLI ------------------------------------------------------------------
17
+
18
+ export async function sourcePull(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
19
+ const [slug, dirArg] = pos;
20
+ if (!slug) throw new Error("usage: source pull <slug> [dir]");
21
+ const dir = resolve(/*turbopackIgnore: true*/ cwd, dirArg ?? join("decks", slug));
22
+ const got = await getDeckSource(opts, { slug });
23
+ mkdirSync(dir, { recursive: true });
24
+ const written = [];
25
+ for (const [file, text] of Object.entries(got.files)) {
26
+ if (typeof text !== "string") continue;
27
+ writeFileSync(join(/*turbopackIgnore: true*/ dir, file), text);
28
+ written.push(file);
29
+ }
30
+ log(`✓ pulled ${slug} rev ${got.rev} → ${dir} (${written.join(", ")})\n push it back with: hyperdecks source push ${slug} ${dirArg ?? dir} --base-rev ${got.rev}`);
31
+ return { slug, rev: got.rev, dir, files: written };
32
+ }
33
+
34
+ export async function sourcePush(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
35
+ const [slug, dirArg] = pos;
36
+ if (!slug) throw new Error("usage: source push <slug> [dir] [--base-rev N]");
37
+ const dir = resolve(/*turbopackIgnore: true*/ cwd, dirArg ?? join("decks", slug));
38
+ const files = SOURCE_FILES.filter((f) => existsSync(join(/*turbopackIgnore: true*/ dir, f)));
39
+ if (!files.includes("index.html")) throw new Error(`no index.html in ${dir}`);
40
+ let baseRev = opts["base-rev"] !== undefined ? Number(opts["base-rev"]) : undefined;
41
+ let rev;
42
+ for (const file of files) {
43
+ ({ rev } = await writeDeckSource(opts, { slug, file, content: readFileSync(join(/*turbopackIgnore: true*/ dir, file), "utf8"), baseRev }));
44
+ baseRev = baseRev === undefined ? undefined : rev; // each write moves the rev
45
+ }
46
+ log(`✓ pushed ${files.join(", ")} to ${slug}: rev ${rev}`);
47
+ return { slug, rev, files };
48
+ }
49
+
50
+ const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff" };
51
+
52
+ export async function assetAdd(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
53
+ const [slug, from] = pos;
54
+ if (!slug || !from) throw new Error("usage: asset add <slug> <file|url> [--name <name>]");
55
+ let out;
56
+ if (/^https?:\/\//i.test(from)) out = await uploadDeckAsset(opts, { slug, name: opts.name, url: from });
57
+ else {
58
+ const path = resolve(/*turbopackIgnore: true*/ cwd, from);
59
+ const contentType = TYPES[extname(path).toLowerCase()];
60
+ if (!contentType) throw new Error(`${from}: asset add takes images, video and fonts (${Object.keys(TYPES).join(" ")})`);
61
+ const c = await client(opts);
62
+ const bytes = readFileSync(path);
63
+ const storageId = await uploadBytes(c, bytes, contentType);
64
+ const saved = await c.mutation(api.assets.saveAsset, { name: String(opts.name || basename(path)), storageId, contentType, deckSlug: slug });
65
+ out = { url: saved.url, name: saved.name, contentType, bytes: bytes.byteLength };
66
+ }
67
+ log(out.url);
68
+ return out;
69
+ }
package/src/voice.mjs CHANGED
@@ -11,6 +11,17 @@ import * as ops from "./ops.mjs";
11
11
  // Same text as convex/lib/voice/consent.ts (the published CLI cannot import it).
12
12
  export const CONSENT_STATEMENT = "I confirm this is my own voice and I consent to it being cloned for narrating my decks";
13
13
  export const CONSENT_PHRASE = "I consent";
14
+ // About 45 s read aloud at an easy pace (init prints it to record). Plain
15
+ // sentences with varied sounds, questions and numbers give the clone range.
16
+ export const READING_PASSAGE = [
17
+ "Hi, this is a recording of my own voice, for my Hyperdecks presenter.",
18
+ "I'm reading at my normal pace, the way I would talk to a friend across the table.",
19
+ "Some days start slowly, with coffee and a long list. Others start with a phone call at seven thirty.",
20
+ "Either way, I like to get to the point: what's the problem, who has it, and what would make it better?",
21
+ "When I show people my work, I want them to hear me, not a robot.",
22
+ "So here are a few numbers, said clearly: twelve, forty-five, three hundred, and two thousand and six.",
23
+ "Thanks for listening. That's the end of my sample.",
24
+ ].join(" ");
14
25
  export const MAX_SAMPLES = 3;
15
26
  export const MAX_SAMPLE_BYTES = 10 * 1024 * 1024;
16
27
  const TYPES = { ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".wav": "audio/wav" };
@@ -0,0 +1,26 @@
1
+ {
2
+ "about": "The first deck `hyperdecks init` makes when D.A.S.H. presents: the same three slides, introduced in the third person. D.A.S.H. quotes the answers rather than rephrasing them, so no pronoun is guessed from a name.",
3
+ "hero": {
4
+ "kicker": "Meet",
5
+ "title": "Meet __NAME__.",
6
+ "lead": "__WHAT__",
7
+ "script": "Hi, [wave] I'm D.A.S.H. [look: h1] I'm here to introduce __NAME__."
8
+ },
9
+ "features": {
10
+ "kicker": "In short",
11
+ "title": "Three things to know.",
12
+ "cards": [
13
+ { "name": "Who", "line": "__NAME__" },
14
+ { "name": "What", "line": "__WHAT__" },
15
+ { "name": "The ask", "line": "__ASK__" }
16
+ ],
17
+ "script": "Three quick things. [go: #features-f1] Who this is. [go: #features-f2] What __NAME__ does, in their own words. __WHAT__ [go: #features-f3] And what __NAME__ would love from you. [go: slot]"
18
+ },
19
+ "closer": {
20
+ "title": "Thanks.",
21
+ "lead": "What __NAME__ would love from you:",
22
+ "cta": "__ASK__",
23
+ "contact": "Presented by D.A.S.H. on hyperdecks.ai",
24
+ "script": "Here's the ask. [go: #closer-cta] __ASK__ Thanks [go: slot] for watching! [wave]"
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "about": "The first deck `hyperdecks init` makes, in your own voice: three slides from your three answers. __NAME__, __WHAT__ and __ASK__ are the answers; text is escaped before it goes into index.html.",
3
+ "hero": {
4
+ "kicker": "Hello",
5
+ "title": "Hi, I'm __NAME__.",
6
+ "lead": "__WHAT__",
7
+ "script": "Hi, [wave] I'm __NAME__. [look: h1] Thanks for taking a minute to watch this."
8
+ },
9
+ "features": {
10
+ "kicker": "In short",
11
+ "title": "Me, in three cards.",
12
+ "cards": [
13
+ { "name": "Who", "line": "__NAME__" },
14
+ { "name": "What I do", "line": "__WHAT__" },
15
+ { "name": "My ask", "line": "__ASK__" }
16
+ ],
17
+ "script": "Here's me, in three cards. [go: #features-f1] Who I am. [go: #features-f2] What I do. __WHAT__ [go: #features-f3] And what I'd love from you. [go: slot]"
18
+ },
19
+ "closer": {
20
+ "title": "Thanks.",
21
+ "lead": "One thing I'd love from you:",
22
+ "cta": "__ASK__",
23
+ "contact": "Made with hyperdecks.ai",
24
+ "script": "So here's my ask. [go: #closer-cta] __ASK__ Thanks [go: slot] for watching! [wave]"
25
+ }
26
+ }
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  maxTotalSeconds: 180
3
+ transition: drift
3
4
  ---
4
5
 
5
6
  # __TITLE__ — narration script
@@ -7,12 +8,16 @@ maxTotalSeconds: 180
7
8
  One `## <slide-id>` block per slide, in slide order; the id is the slide's
8
9
  `<section id>` in index.html. Each sentence is one caption: keep it a short
9
10
  phrase (4 to 85 characters, two lines in the bubble). Aim for 150 to 180 s in
10
- all. Holds: 1.1 s by default, `hold: 2` on interactive slides. Stage tags become
11
+ all. Holds: 0.4 s by default, `hold: 1.5` for a number, chart or reveal,
12
+ `hold: 2` on interactive slides. Stage tags become
11
13
  cues at the word before them: [wave], [mood: excited], [reveal: #detail],
12
14
  [go: #detail] (the presenter flies beside it; [go: slot] brings it home),
13
15
  [look: h2] (the head looks at it), [custom: confetti]. `presenterStyle: stage`
14
16
  above sets the deck's presenter; `presenter-style: corner` under a heading sets
15
- one slide's. With no `voice:` above, your first ready voice narrates.
17
+ one slide's. `transition: drift` above is how every slide enters;
18
+ `transition: push` (cut, wipe, zoom #id, morph, fly) under a heading changes
19
+ one slide's: see "Transitions" in the skill. With no `voice:` above, your first
20
+ ready voice narrates.
16
21
 
17
22
  hyperdecks narrate decks/__SLUG__ --dry-run
18
23
  hyperdecks narrate decks/__SLUG__