@hypersocial/decks-cli 0.2.1 → 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.
@@ -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);
@@ -52,7 +42,8 @@ export async function narrate(pos, opts = {}, { log = (line) => console.log(line
52
42
  const { deckDir, fields } = narrationRequest(pos, opts);
53
43
  if (opts["dry-run"]) {
54
44
  const preview = await ops.previewNarration(opts, fields);
55
- for (const s of preview.slides) log(`${s.render ? "render" : s.cached ? "cached" : "skip "} ${s.id} ${s.hash.slice(0, 12)} ${s.chars} chars`);
45
+ if (preview.presenterStyle) log(`presenter style: ${preview.presenterStyle}`);
46
+ 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}` : ""}`);
56
47
  const render = preview.slides.filter((s) => s.render);
57
48
  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`);
58
49
  return preview;
package/src/ops.mjs CHANGED
@@ -36,7 +36,8 @@ export async function activityOverview(opts = {}) {
36
36
  return (await client(opts)).query(api.activity.overview, {});
37
37
  }
38
38
 
39
- // Generate an image via fal (GPT Image 2 t2i/i2i, optional BiRefNet transparency),
39
+ // Generate an image via fal (GPT Image 2.5 by default, or Nano Banana 2.1 / GPT
40
+ // Image 2 with `model`; t2i/i2i, optional BiRefNet transparency),
40
41
  // store it in Convex, and return the hosted URL. Runs the imagegen action as the
41
42
  // authenticated user (injected client supported via opts.client).
42
43
  export async function generateImage(opts, fields = {}) {
@@ -51,6 +52,8 @@ export async function generateImage(opts, fields = {}) {
51
52
  if (fields.quality) args.quality = String(fields.quality);
52
53
  if (fields.count != null && fields.count !== "") args.count = Number(fields.count);
53
54
  if (fields.format) args.format = String(fields.format);
55
+ if (fields.model) args.model = String(fields.model);
56
+ if (fields.seed != null && fields.seed !== "") args.seed = Number(fields.seed);
54
57
  // Returns { images } for fast requests, or { jobId, status, count } for slow ones
55
58
  // (high/auto quality, transparent, count>1). The MCP tool returns this as-is so it
56
59
  // never blocks; the CLI polls with pollImageJob to stay one-command (see commands).
@@ -99,8 +102,9 @@ export async function pollImageJob(opts, jobId, { intervalMs = 3000, timeoutMs =
99
102
 
100
103
  // ---- video (convex/videogen.ts) ------------------------------------------
101
104
 
102
- // Generate a short clip via fal (Kling v3: text→video, or image→video when an image
103
- // URL is given as the first frame). Always a background job: returns
105
+ // Generate a short clip via fal (MiniMax H3 Max by default, Kling v3 with
106
+ // model "kling-v3": text→video, or image→video when an image URL is given as the
107
+ // first frame). Always a background job: returns
104
108
  // { jobId, status, seconds, aspect?, creditsRemaining? } without waiting, so the MCP
105
109
  // tool never blocks; the CLI polls with pollVideoJob.
106
110
  export async function generateVideo(opts, fields = {}) {
@@ -110,11 +114,100 @@ export async function generateVideo(opts, fields = {}) {
110
114
  if (img) args.imageUrl = String(img);
111
115
  if (fields.seconds != null && fields.seconds !== "") args.seconds = Number(fields.seconds);
112
116
  if (fields.aspect) args.aspect = String(fields.aspect);
117
+ if (fields.model) args.model = String(fields.model);
113
118
  if (fields.slug) args.slug = String(fields.slug);
114
119
  if (fields.name) args.name = String(fields.name);
115
120
  return (await client(opts)).action(api.videogen.generateVideo, args);
116
121
  }
117
122
 
123
+ // ---- deck assets: screenshots, charts, icon sets --------------------------
124
+
125
+ // Screenshot a public URL (convex/screenshots.ts) and store it as a PNG asset.
126
+ // Returns { url, storageId, contentType, name?, width, height, creditsRemaining? }.
127
+ export async function captureUrl(opts, fields = {}) {
128
+ if (!fields.url) throw new Error("url is required");
129
+ const args = { url: String(fields.url) };
130
+ if (fields.width != null && fields.width !== "") args.width = Number(fields.width);
131
+ if (fields.height != null && fields.height !== "") args.height = Number(fields.height);
132
+ if (fields.fullPage) args.fullPage = true;
133
+ if (fields.slug) args.slug = String(fields.slug);
134
+ if (fields.name) args.name = String(fields.name);
135
+ return (await client(opts)).action(api.screenshots.captureUrl, args);
136
+ }
137
+
138
+ const CHART_FIELDS = ["kind", "title", "subtitle", "labels", "series", "values", "value", "label", "delta", "prefix", "suffix", "palette", "width", "height", "slug", "name"];
139
+
140
+ // Render a chart spec (convex/charts.ts) to a branded SVG asset. Unknown keys are
141
+ // dropped so a spec file may carry notes of its own.
142
+ export async function renderChart(opts, spec = {}) {
143
+ if (!spec.kind) throw new Error("kind is required (bar, line, donut or number)");
144
+ const args = {};
145
+ for (const k of CHART_FIELDS) if (spec[k] !== undefined && spec[k] !== null) args[k] = spec[k];
146
+ return (await client(opts)).action(api.charts.renderChart, args);
147
+ }
148
+
149
+ // Queue a style-consistent transparent icon set (convex/imagegen.ts
150
+ // generateIconSet). Returns { jobId, status, count, subjects }; poll it like any
151
+ // image job (checkImageJob / pollImageJob).
152
+ export async function generateIconSet(opts, fields = {}) {
153
+ const subjects = (fields.subjects || []).map(String).filter(Boolean);
154
+ if (!subjects.length) throw new Error("subjects is required (one or more things to draw)");
155
+ if (!fields.style) throw new Error("style is required");
156
+ const args = { subjects, style: String(fields.style) };
157
+ if (fields.palette?.length) args.palette = fields.palette.map(String);
158
+ if (fields.slug) args.slug = String(fields.slug);
159
+ if (fields.name) args.name = String(fields.name);
160
+ if (fields.model) args.model = String(fields.model);
161
+ return (await client(opts)).action(api.imagegen.generateIconSet, args);
162
+ }
163
+
164
+ // ---- share-link poster (convex/posters.ts) -------------------------------
165
+
166
+ // Regenerate the deck's share-link poster (release and import already make
167
+ // one). Returns { slug, url, unchanged }: unchanged when the poster already
168
+ // shows the deck's current HTML, unless `force`.
169
+ export async function generatePoster(opts, fields = {}) {
170
+ if (!fields.slug) throw new Error("slug is required");
171
+ const args = { slug: String(fields.slug) };
172
+ if (fields.force) args.force = true;
173
+ return (await client(opts)).action(api.posters.generatePoster, args);
174
+ }
175
+
176
+ // ---- deck review (convex/review.ts) --------------------------------------
177
+
178
+ // Queue a GPT-6 Luna review of every slide (desktop + phone screenshots of the
179
+ // deck's QA share). Returns { jobId, status, slides, shareToken, creditsRequired }.
180
+ export async function reviewDeck(opts, fields = {}) {
181
+ if (!fields.slug) throw new Error("slug is required");
182
+ const args = { slug: String(fields.slug) };
183
+ if (fields.script) args.script = String(fields.script);
184
+ if (fields.draft) args.draft = true;
185
+ return (await client(opts)).action(api.review.reviewDeck, args);
186
+ }
187
+
188
+ export async function checkReviewJob(opts, jobId) {
189
+ if (!jobId) throw new Error("jobId is required");
190
+ return (await client(opts)).query(api.review.checkReviewJob, { jobId: String(jobId) });
191
+ }
192
+
193
+ // Poll a review job until it finishes; returns the job (findings and all).
194
+ // Throws on job error or timeout.
195
+ export async function pollReviewJob(opts, jobId, { intervalMs = 4000, timeoutMs = 1_800_000, onProgress } = {}) {
196
+ const c = await client(opts);
197
+ const start = Date.now();
198
+ for (;;) {
199
+ const job = await c.query(api.review.checkReviewJob, { jobId: String(jobId) });
200
+ if (job.status === "done") return job;
201
+ if (job.status === "error") {
202
+ const done = job.findings?.length ? ` (${job.progress} slides reviewed, ${job.findings.length} finding(s) so far: review-job ${jobId})` : "";
203
+ throw new Error(`${job.error || "review failed"}${done}`);
204
+ }
205
+ if (Date.now() - start > timeoutMs) throw new Error(`review ${jobId} is still running; check it later with: review-job ${jobId}`);
206
+ if (onProgress) onProgress(job);
207
+ await new Promise((r) => setTimeout(r, intervalMs));
208
+ }
209
+ }
210
+
118
211
  // Read one video job's status (and the hosted MP4 once done). Shared by the MCP
119
212
  // check_video_job tool and the CLI poller.
120
213
  export async function checkVideoJob(opts, jobId) {
@@ -153,8 +246,9 @@ export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs =
153
246
  // thin client calls shared by the CLI `narrate` command and the MCP tools.
154
247
  function narrationArgs(fields = {}) {
155
248
  if (!fields.slug) throw new Error("slug is required");
156
- if (!fields.script) throw new Error("script is required (the deck's script.md)");
157
- const args = { slug: String(fields.slug), script: String(fields.script) };
249
+ // No script: narrate the deck's hosted script.md (deckSources), and the
250
+ // finished job writes the manifest URL into the hosted draft.
251
+ const args = fields.script ? { slug: String(fields.slug), script: String(fields.script) } : { slug: String(fields.slug), fromSource: true };
158
252
  if (fields.voice) args.voice = String(fields.voice);
159
253
  if (fields.slides) {
160
254
  if (!fields.slides.length) throw new Error("slides is empty; omit it to narrate every slide");
@@ -613,7 +707,7 @@ function designServeUrl(projectId, token, path) {
613
707
  }
614
708
 
615
709
  // Pull every local asset path a deck file references (HTML or CSS): the
616
- // ext-resource-dependency manifest, src/href/poster, srcset, <script>/<link>,
710
+ // ext-resource-dependency manifest, src/href/poster/data-presenter-image, srcset, <script>/<link>,
617
711
  // and CSS url(). Mirrors the importer's rewrite surface so we fetch the same set
618
712
  // it will later rewrite. External/data/blob refs are skipped.
619
713
  function extractDesignRefs(text) {
@@ -625,7 +719,7 @@ function extractDesignRefs(text) {
625
719
  if (clean) out.add(clean);
626
720
  };
627
721
  for (const m of text.matchAll(/ext-resource-dependency"\s+content="([^"]+)"/gi)) add(m[1]);
628
- for (const m of text.matchAll(/\b(?:src|href|poster)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
722
+ for (const m of text.matchAll(/\b(?:src|href|poster|data-presenter-image)\s*=\s*["']([^"']+)["']/gi)) add(m[1]);
629
723
  for (const m of text.matchAll(/\bsrcset\s*=\s*["']([^"']+)["']/gi)) {
630
724
  for (const part of m[1].split(",")) add(part.trim().split(/\s+/)[0]);
631
725
  }
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.1.12";
11
+ export const VERSION = "0.3.0";
12
12
  const UPLOAD_TIMEOUT_MS = 125_000;
13
13
 
14
14
  export const PROD = {
@@ -24,6 +24,16 @@ 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"),
@@ -50,6 +60,20 @@ export const api = {
50
60
  imagegen: {
51
61
  generateImage: makeFunctionReference("imagegen:generateImage"),
52
62
  checkImageJob: makeFunctionReference("imagegen:checkImageJob"),
63
+ generateIconSet: makeFunctionReference("imagegen:generateIconSet"),
64
+ },
65
+ screenshots: {
66
+ captureUrl: makeFunctionReference("screenshots:captureUrl"),
67
+ },
68
+ charts: {
69
+ renderChart: makeFunctionReference("charts:renderChart"),
70
+ },
71
+ posters: {
72
+ generatePoster: makeFunctionReference("posters:generatePoster"),
73
+ },
74
+ review: {
75
+ reviewDeck: makeFunctionReference("review:reviewDeck"),
76
+ checkReviewJob: makeFunctionReference("review:checkReviewJob"),
53
77
  },
54
78
  videogen: {
55
79
  generateVideo: makeFunctionReference("videogen:generateVideo"),
@@ -75,7 +99,7 @@ export const api = {
75
99
  },
76
100
  };
77
101
 
78
- const BOOL = new Set(["prod", "dev", "admin", "json", "require-email", "qa", "no-notify", "help", "version", "all-agents", "global", "project", "transparent", "upload", "dry-run", "narrated"]);
102
+ 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"]);
79
103
  // release's recipients, in order: each --to starts one, and an --email right
80
104
  // after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
81
105
  // opts.email stays the last --email for the other commands (share new).
@@ -260,7 +284,42 @@ function splitAgents(value) {
260
284
  return String(value || "").split(/[,\s]+/).filter(Boolean);
261
285
  }
262
286
 
263
- export function installHyperdecksSkill(opts = {}, extraArgs = []) {
287
+ export const DEFAULT_AGENTS = ["codex", "claude-code"];
288
+
289
+ // Agents `npx skills` installs into, keyed by its agent id: the directory whose
290
+ // presence means the agent is installed (the same test `npx skills` uses) and
291
+ // where its global skills live. From skills@1.7 (dist/cli.mjs, agent table).
292
+ export function agentTable({ home = homedir(), env = process.env } = {}) {
293
+ const config = env.XDG_CONFIG_HOME || join(home, ".config");
294
+ const claude = env.CLAUDE_CONFIG_DIR || join(home, ".claude");
295
+ return [
296
+ { id: "claude-code", label: "Claude Code", home: claude, skills: join(claude, "skills") },
297
+ { id: "codex", label: "Codex", home: env.CODEX_HOME || join(home, ".codex"), skills: join(home, ".agents", "skills") },
298
+ { id: "cursor", label: "Cursor", home: join(home, ".cursor"), skills: join(home, ".cursor", "skills") },
299
+ { id: "gemini-cli", label: "Gemini CLI", home: join(home, ".gemini"), skills: join(home, ".gemini", "skills") },
300
+ { id: "opencode", label: "OpenCode", home: join(config, "opencode"), skills: join(config, "opencode", "skills") },
301
+ { id: "windsurf", label: "Windsurf", home: join(home, ".codeium", "windsurf"), skills: join(home, ".codeium", "windsurf", "skills") },
302
+ { id: "github-copilot", label: "GitHub Copilot", home: join(home, ".copilot"), skills: join(home, ".copilot", "skills") },
303
+ ];
304
+ }
305
+
306
+ // The agents whose home directory exists on this machine.
307
+ export function detectAgents({ home, env, exists = existsSync } = {}) {
308
+ return agentTable({ home, env }).filter((a) => exists(a.home)).map((a) => a.id);
309
+ }
310
+
311
+ // Which agents an install targets: --all-agents, --agent, or the detected ones
312
+ // (today's codex,claude-code when none is found).
313
+ export function chooseAgents(opts = {}, extraArgs = [], detect = {}) {
314
+ if (opts["all-agents"]) return { agents: ["*"], source: "all" };
315
+ if (opts.agent) return { agents: [...splitAgents(opts.agent), ...extraArgs.flatMap(splitAgents)], source: "flag" };
316
+ const found = detectAgents(detect);
317
+ return found.length ? { agents: found, source: "detected" } : { agents: DEFAULT_AGENTS, source: "default" };
318
+ }
319
+
320
+ // `quiet` pipes `npx skills` output instead of inheriting the terminal (init
321
+ // --json keeps stdout for its one JSON object); a failure then carries its tail.
322
+ export function installHyperdecksSkill(opts = {}, extraArgs = [], { spawn = spawnSync, quiet = false, log = (line) => console.log(line), home, env } = {}) {
264
323
  const skillDir = join(PACKAGED_SKILLS_DIR, "hyperdecks");
265
324
  if (!existsSync(skillDir)) {
266
325
  throw new Error(`packaged hyperdecks skill not found at ${skillDir}`);
@@ -278,19 +337,19 @@ export function installHyperdecksSkill(opts = {}, extraArgs = []) {
278
337
  throw new Error(`unexpected argument(s): ${extraArgs.join(" ")}; pass agent ids with --agent`);
279
338
  }
280
339
 
281
- const agents = opts["all-agents"]
282
- ? ["*"]
283
- : [...splitAgents(opts.agent || "codex,claude-code"), ...extraArgs.flatMap(splitAgents)];
340
+ const { agents, source } = chooseAgents(opts, extraArgs, { home, env });
341
+ if (source === "default" && !quiet) log(`no agent found in your home directory; installing for ${agents.join(", ")}`);
284
342
  const args = ["-y", "skills@latest", "add", PACKAGED_SKILLS_DIR, "-g"];
285
343
  args.push("-a", ...agents, "-s", "hyperdecks", "--copy", "-y", "--full-depth");
286
344
 
287
345
  const command = process.platform === "win32" ? "npx.cmd" : "npx";
288
- const result = spawnSync(command, args, { stdio: "inherit" });
346
+ const result = spawn(command, args, quiet ? { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" } : { stdio: "inherit" });
289
347
  if (result.error) throw result.error;
290
348
  if (result.status !== 0) {
291
- throw new Error(`skills add failed with exit code ${result.status}`);
349
+ const tail = quiet ? `${result.stdout || ""}\n${result.stderr || ""}`.trim().split("\n").filter((l) => l.trim() && !/^npm warn/i.test(l)).slice(-3).join(" ") : "";
350
+ throw new Error(`skills add failed with exit code ${result.status}${tail ? `: ${tail}` : ""}`);
292
351
  }
293
- return { installed: true };
352
+ return { installed: true, agents, source };
294
353
  }
295
354
 
296
355
  async function verifyAndStampEmail(t, opts) {
@@ -302,14 +361,14 @@ async function verifyAndStampEmail(t, opts) {
302
361
  return who;
303
362
  }
304
363
 
305
- export async function login(opts) {
364
+ export async function login(opts, { log = (line) => console.log(line), open = openBrowser } = {}) {
306
365
  const t = target(opts);
307
366
 
308
367
  if (opts.token) {
309
368
  saveCred(t.name, { refreshToken: opts.token, convexUrl: t.convexUrl, brokerBase: t.brokerBase });
310
369
  try {
311
370
  const who = await verifyAndStampEmail(t, opts);
312
- console.log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
371
+ log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
313
372
  } catch (e) {
314
373
  clearCred(t.name);
315
374
  throw e;
@@ -367,14 +426,14 @@ export async function login(opts) {
367
426
  server.listen(0, "127.0.0.1", () => {
368
427
  const { port } = server.address();
369
428
  const url = `${t.brokerBase}/cli/authorize?port=${port}&state=${state}`;
370
- console.log(`Opening your browser to sign in (${t.name})...\n ${url}\nIf it does not open, paste that URL into your browser.`);
371
- openBrowser(url);
429
+ log(`Opening your browser to sign in (${t.name})...\n ${url}\nIf it does not open, paste that URL into your browser.`);
430
+ open(url);
372
431
  });
373
432
  timer = setTimeout(() => finish(new Error("login timed out after 3 min")), 180000);
374
433
  });
375
434
 
376
435
  const who = await verifyAndStampEmail(t, opts);
377
- console.log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
436
+ log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
378
437
  }
379
438
 
380
439
  export async function logout(opts) {
package/src/scaffold.mjs CHANGED
@@ -1,39 +1,40 @@
1
- // hyperdecks deck new <slug> "<title>" --narrated [--dir <path>]
1
+ // hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,…] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
2
2
  //
3
3
  // Writes decks/<slug>/ (or --dir) from the packaged templates: index.html (eight
4
- // slides for the hosted player/v1), script.md (the narration) and evidence.md
5
- // (where each fact comes from), then registers the deck as a draft you own so
6
- // narrate can render into it.
7
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
- import { dirname, join, resolve } from "node:path";
9
- import { fileURLToPath } from "node:url";
4
+ // slides for the hosted player/v1, or one slide per --layouts entry from
5
+ // templates/layouts/), script.md (the narration) and evidence.md (where each
6
+ // fact comes from), then registers the deck as a draft you own so narrate can
7
+ // render into it.
8
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
10
  import * as ops from "./ops.mjs";
11
+ import { PRESENTER_STYLES } from "./checkRules.mjs";
12
+ import { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
11
13
 
12
- export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "templates");
13
- export const NARRATED_FILES = { "index.html": "narrated-deck.html", "script.md": "script.md", "evidence.md": "evidence.md" };
14
- const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
15
-
16
- const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
14
+ export { LAYOUTS, NARRATED_FILES, assembleLayoutDeck, fillTemplate, parseLayouts, readLayout } from "./layouts.mjs";
17
15
 
18
- // Template text with __SLUG__ / __TITLE__ filled; HTML gets an escaped title.
19
- export function fillTemplate(file, { slug, title }) {
20
- const text = readFileSync(join(TEMPLATE_DIR, file), "utf8");
21
- const t = /\.html$/.test(file) ? escapeHtml(title) : title;
22
- return text.split("__SLUG__").join(slug).split("__TITLE__").join(t);
23
- }
16
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
17
+ const USAGE = 'usage: deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style <stage|bubble|glass|corner>] [--dir <path>]';
24
18
 
25
19
  export async function deckNew(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
26
20
  const [slug, title] = pos;
27
- if (!slug || !title) throw new Error('usage: deck new <slug> "<title>" --narrated [--dir <path>]');
21
+ if (!slug || !title) throw new Error(USAGE);
28
22
  if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
23
+ const presenterStyle = opts["presenter-style"];
24
+ if (presenterStyle !== undefined && !PRESENTER_STYLES.includes(presenterStyle)) {
25
+ throw new Error(`--presenter-style must be one of ${PRESENTER_STYLES.join(", ")}`);
26
+ }
29
27
  if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
30
- 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));
31
29
  if (existsSync(dir)) throw new Error(`${dir} already exists; pick another slug or --dir`);
32
- const files = Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title })]);
30
+ if (opts.layouts === true) throw new Error(`--layouts needs a list, e.g. --layouts ${LAYOUTS.slice(0, 3).join(",")},closer`);
31
+ const files = opts.layouts !== undefined
32
+ ? Object.entries(assembleLayoutDeck({ slug, title, presenterStyle, layouts: opts.layouts }))
33
+ : Object.entries(NARRATED_FILES).map(([out, file]) => [out, fillTemplate(file, { slug, title, presenterStyle })]);
33
34
  await ops.createDeck(opts, { slug, title });
34
35
  try {
35
36
  mkdirSync(dir, { recursive: true });
36
- 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);
37
38
  } catch (e) {
38
39
  rmSync(dir, { recursive: true, force: true });
39
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
+ }