@hypersocial/decks-cli 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +86 -1
- package/skills/hyperdecks/chat.md +49 -0
- package/src/check.mjs +8 -363
- package/src/checkRules.mjs +558 -0
- package/src/commands.mjs +62 -1
- package/src/files.mjs +35 -0
- package/src/guide.mjs +55 -0
- package/src/hosted.mjs +74 -0
- package/src/init.mjs +605 -0
- package/src/layouts.mjs +71 -0
- package/src/narrate.mjs +2 -12
- package/src/ops.mjs +4 -2
- package/src/runtime.mjs +59 -14
- package/src/scaffold.mjs +8 -73
- package/src/source.mjs +69 -0
- package/src/voice.mjs +11 -0
- package/templates/first-deck/character.json +26 -0
- package/templates/first-deck/self.json +26 -0
- package/templates/script.md +7 -2
package/src/layouts.mjs
ADDED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
|
|
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
|
@@ -181,6 +181,7 @@ export async function reviewDeck(opts, fields = {}) {
|
|
|
181
181
|
if (!fields.slug) throw new Error("slug is required");
|
|
182
182
|
const args = { slug: String(fields.slug) };
|
|
183
183
|
if (fields.script) args.script = String(fields.script);
|
|
184
|
+
if (fields.draft) args.draft = true;
|
|
184
185
|
return (await client(opts)).action(api.review.reviewDeck, args);
|
|
185
186
|
}
|
|
186
187
|
|
|
@@ -245,8 +246,9 @@ export async function pollVideoJob(opts, jobId, { intervalMs = 5000, timeoutMs =
|
|
|
245
246
|
// thin client calls shared by the CLI `narrate` command and the MCP tools.
|
|
246
247
|
function narrationArgs(fields = {}) {
|
|
247
248
|
if (!fields.slug) throw new Error("slug is required");
|
|
248
|
-
|
|
249
|
-
|
|
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 };
|
|
250
252
|
if (fields.voice) args.voice = String(fields.voice);
|
|
251
253
|
if (fields.slides) {
|
|
252
254
|
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.
|
|
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"),
|
|
@@ -89,7 +99,7 @@ export const api = {
|
|
|
89
99
|
},
|
|
90
100
|
};
|
|
91
101
|
|
|
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"]);
|
|
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"]);
|
|
93
103
|
// release's recipients, in order: each --to starts one, and an --email right
|
|
94
104
|
// after it belongs to it (`--to A --email a --to B`). Collected as opts.recipients;
|
|
95
105
|
// opts.email stays the last --email for the other commands (share new).
|
|
@@ -274,7 +284,42 @@ function splitAgents(value) {
|
|
|
274
284
|
return String(value || "").split(/[,\s]+/).filter(Boolean);
|
|
275
285
|
}
|
|
276
286
|
|
|
277
|
-
export
|
|
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 } = {}) {
|
|
278
323
|
const skillDir = join(PACKAGED_SKILLS_DIR, "hyperdecks");
|
|
279
324
|
if (!existsSync(skillDir)) {
|
|
280
325
|
throw new Error(`packaged hyperdecks skill not found at ${skillDir}`);
|
|
@@ -292,19 +337,19 @@ export function installHyperdecksSkill(opts = {}, extraArgs = []) {
|
|
|
292
337
|
throw new Error(`unexpected argument(s): ${extraArgs.join(" ")}; pass agent ids with --agent`);
|
|
293
338
|
}
|
|
294
339
|
|
|
295
|
-
const agents = opts
|
|
296
|
-
|
|
297
|
-
: [...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(", ")}`);
|
|
298
342
|
const args = ["-y", "skills@latest", "add", PACKAGED_SKILLS_DIR, "-g"];
|
|
299
343
|
args.push("-a", ...agents, "-s", "hyperdecks", "--copy", "-y", "--full-depth");
|
|
300
344
|
|
|
301
345
|
const command = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
302
|
-
const result =
|
|
346
|
+
const result = spawn(command, args, quiet ? { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" } : { stdio: "inherit" });
|
|
303
347
|
if (result.error) throw result.error;
|
|
304
348
|
if (result.status !== 0) {
|
|
305
|
-
|
|
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}` : ""}`);
|
|
306
351
|
}
|
|
307
|
-
return { installed: true };
|
|
352
|
+
return { installed: true, agents, source };
|
|
308
353
|
}
|
|
309
354
|
|
|
310
355
|
async function verifyAndStampEmail(t, opts) {
|
|
@@ -316,14 +361,14 @@ async function verifyAndStampEmail(t, opts) {
|
|
|
316
361
|
return who;
|
|
317
362
|
}
|
|
318
363
|
|
|
319
|
-
export async function login(opts) {
|
|
364
|
+
export async function login(opts, { log = (line) => console.log(line), open = openBrowser } = {}) {
|
|
320
365
|
const t = target(opts);
|
|
321
366
|
|
|
322
367
|
if (opts.token) {
|
|
323
368
|
saveCred(t.name, { refreshToken: opts.token, convexUrl: t.convexUrl, brokerBase: t.brokerBase });
|
|
324
369
|
try {
|
|
325
370
|
const who = await verifyAndStampEmail(t, opts);
|
|
326
|
-
|
|
371
|
+
log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
|
|
327
372
|
} catch (e) {
|
|
328
373
|
clearCred(t.name);
|
|
329
374
|
throw e;
|
|
@@ -381,14 +426,14 @@ export async function login(opts) {
|
|
|
381
426
|
server.listen(0, "127.0.0.1", () => {
|
|
382
427
|
const { port } = server.address();
|
|
383
428
|
const url = `${t.brokerBase}/cli/authorize?port=${port}&state=${state}`;
|
|
384
|
-
|
|
385
|
-
|
|
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);
|
|
386
431
|
});
|
|
387
432
|
timer = setTimeout(() => finish(new Error("login timed out after 3 min")), 180000);
|
|
388
433
|
});
|
|
389
434
|
|
|
390
435
|
const who = await verifyAndStampEmail(t, opts);
|
|
391
|
-
|
|
436
|
+
log(`OK logged in to ${t.name}${who.email ? ` as ${who.email}` : ""}`);
|
|
392
437
|
}
|
|
393
438
|
|
|
394
439
|
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,
|
|
9
|
-
import {
|
|
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 "./
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|
package/templates/script.md
CHANGED
|
@@ -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:
|
|
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.
|
|
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__
|