@hypersocial/decks-cli 0.4.0 → 0.6.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/commands.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  VERSION,
10
10
  } from "./runtime.mjs";
11
11
  import * as ops from "./ops.mjs";
12
+ import { presenterLine } from "./presenter.mjs";
12
13
 
13
14
  export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
14
15
  : p.narrationStarted ? "narration started"
@@ -306,8 +307,9 @@ commands:
306
307
  deck list|stats|open|rm|forward
307
308
  deck forward on|off <slug>
308
309
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
309
- deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
310
- scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
310
+ deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
311
+ scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
312
+ in your saved brand's colours, fonts and logo (--no-brand: the template's look)
311
313
  layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
312
314
  check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
313
315
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
@@ -320,7 +322,7 @@ commands:
320
322
  file an image, video or font under the deck; prints its hosted URL
321
323
  guide [workflow|format|script|design|layouts|presenter|transitions|check]
322
324
  the skill, one topic at a time (what deck_guide serves)
323
- release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
325
+ release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v] [--captions-only (--hosted)]
324
326
  on a committed deck: check → narrate → import → share new per --to
325
327
  share new|list|on|off|forward|mute|unmute|rm|url
326
328
  share new <slug> <recipient> [--qa] [--no-notify] [--allow-forward|--no-forward]
@@ -347,8 +349,17 @@ commands:
347
349
  a PNG of a public web page (private and local addresses are refused)
348
350
  chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
349
351
  a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
352
+ icons search <word...> [--limit 8] [--stroke-width 2] [--json]
353
+ real icons from the Lucide library as inline SVG in currentColor (free, offline)
350
354
  icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
351
- a style-consistent set of transparent icons, one per subject (max 12)
355
+ a style-consistent set of generated (illustrated) icons, one per subject (max 12)
356
+ brand [show] your saved brand (colours, fonts, logo, design.md) that every new deck starts from
357
+ brand set [--accent "#hex"] [--ink|--paper|--accent-2|--card "#hex"] [--fonts <pairing>]
358
+ [--display|--body|--mono "<Google Fonts family>"] [--logo <url|file>] [--no-logo]
359
+ [--presenter-style s] [--design <file.md|->] [--name "<company>"]
360
+ brand edit edit design.md (voice, do and don't) in $EDITOR
361
+ brand fonts the curated font pairings (display + body + mono)
362
+ brand clear forget the brand; new decks get the template look
352
363
  review <slug> [--script <script.md>] [--json]
353
364
  GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
354
365
  review-job <jobId> [--json]
@@ -368,6 +379,8 @@ commands:
368
379
  asks you to type "I consent" before anything is uploaded
369
380
  voice upload <file...> upload recordings and print their sample ids (for the MCP clone_voice tool)
370
381
  voice rm <name|id> delete a voice, also at the provider
382
+ voice hear <voice> | voice hear --narrator <name|id>
383
+ a short sample of the voice: its clip URL and the line it says
371
384
  avatar [--json] your avatars (who presents decks in your voice) and the default
372
385
  avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
373
386
  avatar from-photo [file] [--save-to-minimoji]
@@ -381,6 +394,8 @@ commands:
381
394
  narrator default <name|id|dash> make it the default
382
395
  narrator remove <name|id> remove it; decks already shared keep working
383
396
  narrator voice <name|id> <voice> change its voice (one of yours, or dash)
397
+ narrator rename <name|id> <new name> rename it
398
+ narrator face <name|id> <avatar> change its face (one of your avatars)
384
399
  deck narrator <slug> <name|id|none|default> the deck's own narrator (none: captions only)
385
400
 
386
401
  global:
@@ -625,7 +640,7 @@ function commandTable(pos, rest, opts) {
625
640
  if (opts.hosted) {
626
641
  const { hostedNewDeck } = await import("./source.mjs");
627
642
  if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
628
- const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"] });
643
+ const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"], brand: !opts["no-brand"] });
629
644
  return void console.log(opts.json ? JSON.stringify(out, null, 2) : `✓ created hosted deck "${out.slug}" (rev ${out.rev})\n preview: ${out.previewUrl}\n edit locally: hyperdecks source pull ${out.slug}`);
630
645
  }
631
646
  const { deckNew } = await import("./scaffold.mjs");
@@ -648,9 +663,11 @@ function commandTable(pos, rest, opts) {
648
663
  release: async () => {
649
664
  if (opts.hosted) {
650
665
  const { hostedRelease } = await import("./source.mjs");
651
- const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid });
666
+ const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid, captionsOnly: opts["captions-only"] });
652
667
  if (opts.json) return void console.log(JSON.stringify(out, null, 2));
653
668
  console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
669
+ const who = presenterLine(out.presenter);
670
+ if (who) console.log(who);
654
671
  for (const s of out.shares) console.log(`✓ share for "${s.recipient}"${s.qa ? " (QA, untracked)" : ""} → ${s.url}`);
655
672
  return;
656
673
  }
@@ -766,6 +783,21 @@ function commandTable(pos, rest, opts) {
766
783
  const out = await ops.setNarratorVoice(opts, rest[0], rest.slice(1).join(" "));
767
784
  console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} speaks in ${out.voice} now`);
768
785
  },
786
+ "narrator rename": async () => {
787
+ if (rest.length < 2) throw new Error("usage: narrator rename <name|id> <new name>");
788
+ const out = await ops.renameNarrator(opts, rest[0], rest.slice(1).join(" "));
789
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.was} is ${out.name} now`);
790
+ },
791
+ "narrator face": async () => {
792
+ if (rest.length < 2) throw new Error("usage: narrator face <name|id> <avatar>");
793
+ const out = await ops.setNarratorFace(opts, rest[0], rest.slice(1).join(" "));
794
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `OK ${out.name} wears ${out.face} now`);
795
+ },
796
+ "voice hear": async () => {
797
+ if (!rest[0] && !opts.narrator) throw new Error("usage: voice hear <voice> | voice hear --narrator <name|id>");
798
+ const out = await ops.hearVoice(opts, opts.narrator ? { narrator: opts.narrator } : { voice: rest.join(" ") });
799
+ console.log(opts.json ? JSON.stringify(out, null, 2) : `"${out.caption}"\n ${out.url}`);
800
+ },
769
801
  "deck narrator": async () => {
770
802
  if (rest.length < 2) throw new Error("usage: deck narrator <slug> <name|id|none|default>");
771
803
  const out = await ops.setDeckNarrator(opts, rest[0], rest.slice(1).join(" "));
@@ -807,6 +839,20 @@ function commandTable(pos, rest, opts) {
807
839
  screenshot: () => screenshot(pos.slice(1), opts),
808
840
  chart: () => chart(pos.slice(1), opts),
809
841
  icons: () => icons(pos.slice(1), opts),
842
+ // The icon library (Lucide); `icons search … --style` still generates.
843
+ "icons search": async () => {
844
+ if (opts.style) return icons(pos.slice(1), opts);
845
+ if (!rest.length) throw new Error("usage: icons search <word...> [--limit N] [--stroke-width N] [--json]");
846
+ const { icons: searchIcons } = await import("./icons.mjs");
847
+ const out = await searchIcons(rest, opts);
848
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
849
+ },
850
+ brand: async () => (await import("./brandCli.mjs")).brandShow(opts),
851
+ "brand show": async () => (await import("./brandCli.mjs")).brandShow(opts),
852
+ "brand set": async () => (await import("./brandCli.mjs")).brandSet(opts),
853
+ "brand edit": async () => (await import("./brandCli.mjs")).brandEdit(opts),
854
+ "brand fonts": async () => (await import("./brandCli.mjs")).brandFonts(opts),
855
+ "brand clear": async () => (await import("./brandCli.mjs")).brandClear(opts),
810
856
  review: () => review(pos.slice(1), opts),
811
857
  "review-job": () => reviewJob(pos.slice(1), opts),
812
858
  poster: () => poster(pos.slice(1), opts),
package/src/files.mjs CHANGED
@@ -19,6 +19,9 @@ const FILES = {
19
19
  "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
20
20
  "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
21
21
  "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
22
+ "templates/icons/lucide.json": () => new URL("../templates/icons/lucide.json", import.meta.url),
23
+ "templates/first-deck/self.json": () => new URL("../templates/first-deck/self.json", import.meta.url),
24
+ "templates/first-deck/character.json": () => new URL("../templates/first-deck/character.json", import.meta.url),
22
25
  "skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),
23
26
  "skills/hyperdecks/chat.md": () => new URL("../skills/hyperdecks/chat.md", import.meta.url),
24
27
  };
@@ -0,0 +1,107 @@
1
+ // The first deck `hyperdecks init` (and the hosted get_started tool) makes: a
2
+ // three-slide hello deck (hero, features, closer) filled from three answers.
3
+ // No file or process I/O beyond the packaged templates, so the hosted MCP route
4
+ // can bundle it.
5
+ import { packageText } from "./files.mjs";
6
+ import { slugify } from "./importer.mjs";
7
+
8
+ export const FIRST_DECK_LAYOUTS = "hero,features,closer";
9
+ export const DASH_AVATAR = "https://decks.hypersocial.ai/player/v1/dash-avatar.json";
10
+ export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
11
+
12
+ // One answer as it goes on a slide and into the script: one line, no stage-tag
13
+ // brackets, ending in a full stop so it is its own caption.
14
+ export function cleanAnswer(key, value) {
15
+ const text = String(value ?? "").replace(/[[\]]/g, "").replace(/\s+/g, " ").trim();
16
+ if (text.length < 2) throw new Error(`--${key} needs an answer`);
17
+ 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`);
18
+ return key === "name" ? text.replace(/[.!?]+$/, "") : /[.!?…]$/.test(text) ? text : `${text}.`;
19
+ }
20
+
21
+ export const firstDeckSlug = (email) => `hello-${slugify(String(email ?? "").split("@")[0]) || "there"}`.slice(0, 58).replace(/-+$/, "");
22
+
23
+ const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
24
+
25
+ export function firstDeckCopy(presenter) {
26
+ return JSON.parse(packageText(`templates/first-deck/${presenter === "dash" ? "character" : "self"}.json`));
27
+ }
28
+
29
+ function slots(text, answers, html) {
30
+ const map = { __NAME__: answers.name, __WHAT__: answers.about, __ASK__: answers.ask };
31
+ return text.replace(/__NAME__|__WHAT__|__ASK__/g, (k) => (html ? escapeHtml(map[k]) : map[k]));
32
+ }
33
+
34
+ // Replace exactly one match, or fail loudly: a layout change must not ship a deck
35
+ // that still says "One sentence on what this deck is about."
36
+ function swap(html, pattern, to, what) {
37
+ let hit = 0;
38
+ const out = html.replace(pattern, (...m) => (hit++, typeof to === "function" ? to(...m) : to));
39
+ if (hit !== 1) throw new Error(`first deck: the ${what} in the layout template changed (${hit} matches); update init's first-deck fill`);
40
+ return out;
41
+ }
42
+
43
+ function section(html, id, edit) {
44
+ return swap(html, new RegExp(`<section\\b[^>]*\\sid="${id}"[\\s\\S]*?</section>`), (s) => edit(s), `#${id} slide`);
45
+ }
46
+
47
+ // index.html and script.md of the scaffolded hero,features,closer deck, filled
48
+ // with the answers: no template copy is left, and no generated imagery.
49
+ export function fillFirstDeck({ html, title, answers, presenter, voice }) {
50
+ const copy = firstDeckCopy(presenter);
51
+ const h = (t) => slots(t, answers, true);
52
+ const note = (s) => swap(s, /\sdata-note="[^"]*"/, ' data-note="From your init answers."', "data-note");
53
+ let out = section(html, "hero", (s) => {
54
+ s = note(s);
55
+ // No art: the presenter is the opening's visual, and check fails on the stand-in.
56
+ s = swap(s, /\s*<div class="art"[^>]*><\/div>/, "", "hero art");
57
+ s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.hero.kicker)}</div>`, "hero kicker");
58
+ 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");
59
+ return swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.hero.lead)}</p>`, "hero lead");
60
+ });
61
+ out = section(out, "features", (s) => {
62
+ s = note(s);
63
+ s = swap(s, /<div class="kicker">[^<]*<\/div>/, `<div class="kicker">${h(copy.features.kicker)}</div>`, "features kicker");
64
+ 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");
65
+ copy.features.cards.forEach((card, i) => {
66
+ 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}`);
67
+ });
68
+ return s;
69
+ });
70
+ out = section(out, "closer", (s) => {
71
+ s = note(s);
72
+ 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");
73
+ s = swap(s, /<p class="lead">[\s\S]*?<\/p>/, `<p class="lead">${h(copy.closer.lead)}</p>`, "closer lead");
74
+ s = swap(s, /<a class="cta"[^>]*>[\s\S]*?<\/a>/, `<p class="cta" id="closer-cta">${h(copy.closer.cta)}</p>`, "closer call to action");
75
+ return swap(s, /<div class="contact muted">[^<]*<\/div>/, `<div class="contact muted">${h(copy.closer.contact)}</div>`, "closer contact");
76
+ });
77
+ // A character's live head presents its own deck; a person's voice ships with
78
+ // captions only (data-presenter="none"), never on a character's face.
79
+ if (presenter === "dash") {
80
+ 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");
81
+ }
82
+ const s = (t) => slots(t, answers, false);
83
+ const script = `---
84
+ voice: ${voice}
85
+ maxTotalSeconds: 90
86
+ transition: drift
87
+ ---
88
+
89
+ # ${title} — narration script
90
+
91
+ Written by \`hyperdecks init\` from your three answers. Make it yours: rewrite
92
+ these lines and the slides in index.html, then
93
+
94
+ hyperdecks narrate .
95
+ hyperdecks release . --to "<name>"
96
+
97
+ ## hero
98
+ ${s(copy.hero.script)}
99
+
100
+ ## features
101
+ ${s(copy.features.script)}
102
+
103
+ ## closer
104
+ ${s(copy.closer.script)}
105
+ `;
106
+ return { html: out, script };
107
+ }
package/src/guide.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // each layout file's leading comment.
7
7
  import { packageText } from "./files.mjs";
8
8
  import { LAYOUTS, readLayout } from "./layouts.mjs";
9
+ import { fontPairingsMarkdown } from "./brand.mjs";
9
10
 
10
11
  export const GUIDE_TOPICS = ["workflow", "format", "script", "design", "layouts", "presenter", "transitions", "check"];
11
12
  const CLI_NOTE = "_Written for the CLI. On the hosted MCP there is no folder: use the tool names in `deck_guide` (workflow), hosted URLs for every file, and write_deck_source for edits._\n\n";
@@ -43,7 +44,8 @@ export function deckGuide(topic = "workflow") {
43
44
  else {
44
45
  const section = skillSection(packageText("skills/hyperdecks/SKILL.md"), t);
45
46
  if (!section) throw new Error(`SKILL.md has no <!-- topic: ${t} --> section`);
46
- text = CLI_NOTE + section;
47
+ // The design topic carries the curated font pairings (brand.mjs) in place of its marker.
48
+ text = CLI_NOTE + section.replace("<!-- font pairings -->", () => fontPairingsMarkdown());
47
49
  }
48
50
  return { topic: t, topics: GUIDE_TOPICS, text };
49
51
  }
package/src/hosted.mjs CHANGED
@@ -3,7 +3,9 @@
3
3
  // (source.mjs) share these; no local file I/O, so the route can bundle them.
4
4
  import { api, client, target } from "./runtime.mjs";
5
5
  import { PRESENTER_STYLES } from "./checkRules.mjs";
6
- import { NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
6
+ import { NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts } from "./layouts.mjs";
7
+ import { applyBrand, brandForNewDeck, brandSummary } from "./brand.mjs";
8
+ import { getBrand } from "./ops.mjs";
7
9
 
8
10
  export const SOURCE_FILES = Object.keys(NARRATED_FILES);
9
11
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -13,16 +15,22 @@ const urls = (opts, slug, previewToken) => {
13
15
  };
14
16
 
15
17
  // Scaffold the files (the same templates as deck new) and register the draft.
16
- export async function hostedNewDeck(opts, { slug, title, layouts, presenterStyle } = {}) {
18
+ // The account's brand (colours, fonts, logo, presenter style) goes on the
19
+ // template unless brand is false; an explicit presenterStyle wins over it.
20
+ export async function hostedNewDeck(opts, { slug, title, layouts, presenterStyle, brand: useBrand } = {}) {
17
21
  if (!slug || !title) throw new Error("slug and title are required");
18
22
  if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
19
23
  if (presenterStyle !== undefined && !PRESENTER_STYLES.includes(presenterStyle)) throw new Error(`presenterStyle must be one of ${PRESENTER_STYLES.join(", ")}`);
20
24
  const list = Array.isArray(layouts) ? layouts.join(",") : layouts;
25
+ if (list) parseLayouts(list);
26
+ const { brand, readError } = await brandForNewDeck(() => getBrand(opts), useBrand);
27
+ const style = presenterStyle ?? (PRESENTER_STYLES.includes(brand?.presenterStyle) ? brand.presenterStyle : undefined);
21
28
  const files = list
22
- ? assembleLayoutDeck({ slug, title, presenterStyle, layouts: list })
23
- : Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle })]));
29
+ ? assembleLayoutDeck({ slug, title, presenterStyle: style, layouts: list })
30
+ : Object.fromEntries(Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle: style })]));
31
+ files["index.html"] = applyBrand(files["index.html"], brand);
24
32
  const made = await (await client(opts)).mutation(api.deckSources.create, { slug, title, files });
25
- return { slug, rev: made.rev, files, ...urls(opts, slug, made.previewToken) };
33
+ return { slug, rev: made.rev, files, brand: brandSummary(brand, readError), ...urls(opts, slug, made.previewToken) };
26
34
  }
27
35
 
28
36
  export async function getDeckSource(opts, { slug, file } = {}) {
@@ -59,7 +67,7 @@ export async function hostedCheck(opts, { slug, forbid, maxDuration } = {}) {
59
67
  return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}) });
60
68
  }
61
69
 
62
- export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice } = {}) {
70
+ export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice, captionsOnly } = {}) {
63
71
  if (!slug) throw new Error("slug is required");
64
72
  const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
65
73
  const args = { slug };
@@ -69,6 +77,7 @@ export async function hostedRelease(opts, { slug, recipients, qa, title, forbid,
69
77
  if (list?.length) args.forbid = list;
70
78
  if (rev !== undefined && rev !== null) args.rev = Number(rev);
71
79
  if (voice) args.voice = String(voice);
80
+ if (captionsOnly) args.captionsOnly = true;
72
81
  return (await client(opts)).action(api.deckSources.release, args);
73
82
  }
74
83
 
package/src/icons.mjs ADDED
@@ -0,0 +1,66 @@
1
+ // hyperdecks icons search <word> [--limit N] [--stroke-width N]
2
+ //
3
+ // The icon library: Lucide's open-source line icons (ISC licence, vendored in
4
+ // templates/icons/lucide.json by scripts/sync-icons.mjs). A search returns
5
+ // inline SVG stroked in currentColor, so a pasted icon takes the colour of the
6
+ // text around it, i.e. the deck's palette. No network and no credits; for
7
+ // illustrated icons in a style of your own, `hyperdecks icons <subject...>
8
+ // --style` (generate_icon_set) still draws them.
9
+ import { packageText } from "./files.mjs";
10
+
11
+ export const MAX_ICON_RESULTS = 24;
12
+ const DEFAULT_RESULTS = 8;
13
+ let library;
14
+ const lib = () => (library ??= JSON.parse(packageText("templates/icons/lucide.json")));
15
+
16
+ export function iconSvg(name, { strokeWidth = 2 } = {}) {
17
+ const icon = lib().icons[name];
18
+ if (!icon) return null;
19
+ const sw = Number(strokeWidth);
20
+ const width = Number.isFinite(sw) && sw > 0 && sw <= 4 ? sw : 2;
21
+ return `<svg class="icon lucide-${name}" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon[0]}</svg>`;
22
+ }
23
+
24
+ // Best matches first: the exact name, a name starting with or containing a
25
+ // word, then an exact tag, then a tag containing a word. Several words rank by
26
+ // how many of them match.
27
+ export function searchIcons({ query, limit, strokeWidth } = {}) {
28
+ const words = String(query ?? "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
29
+ if (!words.length) throw new Error("search needs a word, e.g. rocket");
30
+ const n = Math.min(Math.max(Number(limit) || DEFAULT_RESULTS, 1), MAX_ICON_RESULTS);
31
+ const phrase = words.join("-");
32
+ const scored = [];
33
+ for (const [name, [, tagText]] of Object.entries(lib().icons)) {
34
+ const parts = name.split("-");
35
+ const tags = tagText ? tagText.split(",") : [];
36
+ let score = name === phrase ? 100 : 0;
37
+ for (const w of words) {
38
+ if (parts[0] === w) score += 12;
39
+ else if (parts.includes(w)) score += 10;
40
+ else if (name.includes(w)) score += 6;
41
+ else if (tags.includes(w)) score += 5;
42
+ else if (tags.some((t) => t.split(" ").includes(w))) score += 3;
43
+ else if (tags.some((t) => t.includes(w))) score += 1;
44
+ }
45
+ // Plainer icons first among equals: rocket before rocket-launch.
46
+ if (score) scored.push({ name, tags, score: score - parts.length * 0.1 });
47
+ }
48
+ scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
49
+ return {
50
+ query: words.join(" "),
51
+ library: lib().source,
52
+ license: lib().license,
53
+ total: scored.length,
54
+ icons: scored.slice(0, n).map(({ name, tags }) => ({ name, tags, svg: iconSvg(name, { strokeWidth }) })),
55
+ usage: "Paste svg inline (e.g. in .l-features .card). It is stroked in currentColor: set `color` on it or its parent to put it in the deck's palette.",
56
+ };
57
+ }
58
+
59
+ export async function icons(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
60
+ const out = searchIcons({ query: pos.join(" "), limit: opts.limit, strokeWidth: opts["stroke-width"] });
61
+ if (opts.json) return out;
62
+ if (!out.icons.length) log(`no icons match "${out.query}"; try a plainer word (e.g. chart, user, lock)`);
63
+ for (const icon of out.icons) log(`${icon.name}${icon.tags.length ? ` (${icon.tags.slice(0, 5).join(", ")})` : ""}\n${icon.svg}\n`);
64
+ if (out.icons.length) log(`${out.icons.length} of ${out.total} from ${out.library}. ${out.usage}`);
65
+ return out;
66
+ }
package/src/init.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // QA link and print it. Every step checks real state before it acts, so running
7
7
  // init again resumes where it stopped and changes nothing once it is complete.
8
8
  // Each step returns { step, status: "done" | "skipped" | "ran" | "needs-human", detail }.
9
- // CLI-only, like login: there is no MCP tool for it.
9
+ // In chat (no folder, no git) the hosted MCP's get_started tool walks the same
10
+ // steps on a hosted draft (lib/getStarted.ts); the deck fill is shared (firstDeck.mjs).
10
11
  import { execFileSync } from "node:child_process";
11
12
  import { createHash, randomBytes } from "node:crypto";
12
13
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
@@ -15,7 +16,7 @@ import { dirname, join, relative, resolve } from "node:path";
15
16
  import { createInterface } from "node:readline/promises";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import * as ops from "./ops.mjs";
18
- import { slugify } from "./importer.mjs";
19
+ import { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug } from "./firstDeck.mjs";
19
20
  import { agentTable, api, chooseAgents, client, CRED_DIR, installHyperdecksSkill, login as runLogin, openBrowser, target } from "./runtime.mjs";
20
21
  import { READING_PASSAGE, voiceClone as runVoiceClone } from "./voice.mjs";
21
22
  import { assembleLayoutDeck, deckNew as runDeckNew } from "./scaffold.mjs";
@@ -23,17 +24,13 @@ import { narrate as runNarrate } from "./narrate.mjs";
23
24
  import { release as runRelease } from "./release.mjs";
24
25
 
25
26
  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
27
  // Same numbers as convex/lib/tokenCost.ts (the published CLI cannot import it).
29
28
  export const VOICE_CLONE_COST = 10;
30
29
  export const STARTER_GRANT = 30;
31
- export const ANSWER_MAX = { name: 40, about: 80, ask: 80 };
32
30
  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
31
  const CHARACTERS = ["dash", "d.a.s.h."];
36
32
  const RESUME = "npx hyperdecks init";
33
+ export { ANSWER_MAX, DASH_AVATAR, FIRST_DECK_LAYOUTS, cleanAnswer, fillFirstDeck, firstDeckCopy, firstDeckSlug };
37
34
  export const AGENT_VOICE_HELP = "ask the user to run `npx hyperdecks voice clone <file> --name <name>` in a terminal, " +
38
35
  "or upload with `voice upload` and call `clone_voice` after they agree to the statement; " +
39
36
  "or run init again with `--voice dash` to let D.A.S.H. present";
@@ -66,100 +63,6 @@ export function writeState(path, all) {
66
63
 
67
64
  // ---- answers and the first deck -------------------------------------------
68
65
 
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
66
 
164
67
  // ---- git ------------------------------------------------------------------
165
68
 
package/src/narrate.mjs CHANGED
@@ -8,6 +8,7 @@
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
+ import { presenterLine } from "./presenter.mjs";
11
12
  export { sectionIds, setManifestUrl } from "./checkRules.mjs";
12
13
  import { sectionIds, setManifestUrl } from "./checkRules.mjs";
13
14
 
@@ -46,10 +47,14 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
46
47
  for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars${s.presenterStyle ? ` presenter-style: ${s.presenterStyle}` : ""}`);
47
48
  const render = preview.slides.filter((s) => s.render);
48
49
  log(`${render.length} of ${preview.slides.length} slide(s) would render in voice "${preview.voice}", ~${render.reduce((n, s) => n + s.chars, 0)} characters of TTS`);
50
+ const who = presenterLine(preview.presenter);
51
+ if (who) log(who);
49
52
  return preview;
50
53
  }
51
- const { jobId, slides } = await ops.narrateDeck(opts, fields);
54
+ const { jobId, slides, presenter } = await ops.narrateDeck(opts, fields);
52
55
  log(`narration job ${jobId}: ${slides.filter((s) => s.status === "render").length} to render, ${slides.filter((s) => s.status === "cached").length} cached`);
56
+ const who = presenterLine(presenter);
57
+ if (who) log(who);
53
58
  const seen = new Map();
54
59
  const job = await ops.pollNarrationJob(opts, jobId, {
55
60
  onProgress: (j) => {
@@ -63,6 +68,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
63
68
  },
64
69
  });
65
70
  if (job.status === "error") throw new Error(job.error || "narration failed");
71
+ // release reads who presents from here (older backends send no presenter).
72
+ if (presenter) job.presenter = presenter;
66
73
  if (!job.manifest) {
67
74
  log(`⚠ ${job.notice || "done, but no manifest was written"}`);
68
75
  return job;