@hypersocial/decks-cli 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/commands.mjs CHANGED
@@ -12,7 +12,7 @@ import * as ops from "./ops.mjs";
12
12
 
13
13
  export const playbackLabel = (p) => p.narrationFinished ? "narration finished"
14
14
  : p.narrationStarted ? "narration started"
15
- : p.telemetryVersion === 2 ? "narration not started" : "playback unknown (legacy)";
15
+ : (p.telemetryVersion ?? 0) >= 2 ? "narration not started" : "playback unknown (legacy)";
16
16
 
17
17
  const fmtMs = (ms) => (ms >= 60000 ? `${(ms / 60000).toFixed(1)}m` : `${Math.round(ms / 1000)}s`);
18
18
 
@@ -167,6 +167,41 @@ async function activity(pos, opts) {
167
167
  }
168
168
  }
169
169
 
170
+ const pct = (n) => (n === null || n === undefined ? "n/a" : `${n}%`);
171
+ const slideName = (s) => (s.title ? `${s.index} ${s.title}` : `slide ${s.index}`);
172
+
173
+ async function engagement(pos, opts) {
174
+ const who = pos.join(" ");
175
+ if (!who) throw new Error("usage: engagement <recipient|token> [--deck <slug>] [--json]");
176
+ const entries = await ops.recipientEngagement(opts, who, opts.deck);
177
+ if (opts.json) {
178
+ console.log(JSON.stringify(entries, null, 2));
179
+ return;
180
+ }
181
+ if (!entries.length) {
182
+ console.log(`(no links for "${who}"${opts.deck ? ` on ${opts.deck}` : ""})`);
183
+ return;
184
+ }
185
+ for (const e of entries) {
186
+ const s = e.summary;
187
+ console.log(`${e.recipient} — ${e.deck.title} (${e.deck.slug}, link ${e.link.token}${e.link.enabled ? "" : ", off"})`);
188
+ if (!s.visits) {
189
+ console.log(" never opened");
190
+ continue;
191
+ }
192
+ const titles = new Map(e.slides.map((r) => [r.index, r]));
193
+ const list = (xs) => (xs === null ? "n/a" : xs.length ? xs.map((i) => slideName(titles.get(i) ?? { index: i })).join(", ") : "none");
194
+ console.log(` visits: ${s.visits} (last ${new Date(s.lastSeenAt).toISOString().slice(0, 16).replace("T", " ")}) · ${fmtMs(s.totalMs)} engaged`);
195
+ console.log(` narration: ${s.narration.finished ? "finished" : s.narration.started ? "started" : "not started"} · heard ${pct(s.narration.heardPct)}${s.narration.telemetry === "v3" ? "" : " (playback detail unavailable)"}`);
196
+ console.log(` reached: slide ${s.furthestSlide}${e.deck.slides ? ` of ${e.deck.slides}` : ""}${s.droppedOffOn ? ` · dropped off on ${slideName(s.droppedOffOn)}` : ""}`);
197
+ console.log(` replayed: ${list(s.replayedSlides)}`);
198
+ console.log(` skipped: ${list(s.skippedSlides)}`);
199
+ console.log(` most time: ${s.mostTime.map((m) => `${slideName(m)} (${fmtMs(m.ms)})`).join(", ") || "none"}`);
200
+ if (!s.slideData) console.log(" slide data unavailable (this deck reports time only)");
201
+ if (s.deckChangedSinceView) console.log(" the deck changed since their last visit");
202
+ }
203
+ }
204
+
170
205
  async function credits(pos, opts) {
171
206
  const bal = await ops.creditsBalance(opts);
172
207
  const ledger = await ops.creditsLedger(opts, opts.limit || 10);
@@ -237,6 +272,12 @@ export function help() {
237
272
  console.log(`decks ${VERSION}
238
273
 
239
274
  commands:
275
+ init [--yes] [--json] [--agent <id>]... [--all-agents] [--voice dash] [--name <n> --about <a> --ask <k>] [--dir <path>]
276
+ [--no-voice] [--no-skill] [--no-release] [--no-open]
277
+ the first run: sign in, install the skill, pick a voice (clone yours, or D.A.S.H.),
278
+ scaffold a 3-slide hello deck from your answers, narrate it and release a QA link.
279
+ Re-running resumes where it stopped. --json prints one object; it never clones
280
+ (consent is typed by a person) and reports that step as needs-human.
240
281
  login [--token <pat>] [--broker <url>] [--dev]
241
282
  logout [--dev]
242
283
  whoami [--json] [--dev]
@@ -247,6 +288,14 @@ commands:
247
288
  check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
248
289
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
249
290
  local refs, pinned player, a data-note per slide, mobile titles
291
+ deck new|check|release … --hosted
292
+ the same on a hosted draft (no folder, no git: what chat agents use over the remote MCP)
293
+ source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
294
+ move a hosted draft (index.html, script.md, evidence.md) to a folder and back
295
+ asset add <slug> <file|url> [--name n]
296
+ file an image, video or font under the deck; prints its hosted URL
297
+ guide [workflow|format|script|design|layouts|presenter|transitions|check]
298
+ the skill, one topic at a time (what deck_guide serves)
250
299
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
251
300
  on a committed deck: check → narrate → import → share new per --to
252
301
  share new|list|on|off|mute|unmute|rm|url
@@ -256,7 +305,10 @@ commands:
256
305
  import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
257
306
  skill install [--agent codex,claude-code] [--all-agents]
258
307
  skills install [--agent codex,claude-code] [--all-agents]
308
+ default: every agent found in your home directory (else codex, claude-code)
259
309
  activity [--deck <slug>] [--limit <n>] [--json]
310
+ engagement <recipient|token> [--deck <slug>] [--json]
311
+ what one recipient watched per deck: narration heard, replays, drop-off, visits
260
312
  gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--transparent] [--image <url>] [--size <s>]
261
313
  [--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
262
314
  default model gpt-image-2.5; --seed applies to nano-banana-2.1
@@ -523,18 +575,44 @@ function commandTable(pos, rest, opts) {
523
575
  const result = await createPreview(rest, opts);
524
576
  console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
525
577
  },
578
+ init: async () => {
579
+ const { init } = await import("./init.mjs");
580
+ const out = await init(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
581
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
582
+ },
526
583
  "deck new": async () => {
584
+ if (opts.hosted) {
585
+ const { hostedNewDeck } = await import("./source.mjs");
586
+ if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
587
+ const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"] });
588
+ 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}`);
589
+ }
527
590
  const { deckNew } = await import("./scaffold.mjs");
528
591
  const out = await deckNew(rest, opts, opts.json ? { log: (line) => console.error(line) } : {});
529
592
  if (opts.json) console.log(JSON.stringify(out, null, 2));
530
593
  },
531
594
  check: async () => {
532
595
  const { check } = await import("./check.mjs");
533
- const result = await check(pos.slice(1), opts);
596
+ const { hostedCheck } = await import("./source.mjs");
597
+ const result = opts.hosted ? await hostedCheck(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"] }) : await check(pos.slice(1), opts);
598
+ if (opts.hosted && !opts.json) {
599
+ console.log(`deck "${pos[1]}" rev ${result.rev}: ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
600
+ for (const e of result.errors) console.log(` - ${e}`);
601
+ if (!result.errors.length) console.log("✓ check passed");
602
+ for (const w of result.warnings) console.log(` ⚠ ${w}`);
603
+ }
534
604
  if (opts.json) console.log(JSON.stringify(result, null, 2));
535
605
  if (result.errors.length) throw new Error(`check found ${result.errors.length} issue(s)`);
536
606
  },
537
607
  release: async () => {
608
+ if (opts.hosted) {
609
+ const { hostedRelease } = await import("./source.mjs");
610
+ const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid });
611
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
612
+ console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
613
+ for (const s of out.shares) console.log(`✓ share for "${s.recipient}"${s.qa ? " (QA, untracked)" : ""} → ${s.url}`);
614
+ return;
615
+ }
538
616
  const { release } = await import("./release.mjs");
539
617
  const out = await release(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
540
618
  if (opts.json) console.log(JSON.stringify(out, null, 2));
@@ -544,6 +622,26 @@ function commandTable(pos, rest, opts) {
544
622
  const result = await narrate(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
545
623
  if (opts.json) console.log(JSON.stringify(result, null, 2));
546
624
  },
625
+ "source pull": async () => {
626
+ const { sourcePull } = await import("./source.mjs");
627
+ const out = await sourcePull(rest, opts, opts.json ? { log: (line) => console.error(line) } : {});
628
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
629
+ },
630
+ "source push": async () => {
631
+ const { sourcePush } = await import("./source.mjs");
632
+ const out = await sourcePush(rest, opts, opts.json ? { log: (line) => console.error(line) } : {});
633
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
634
+ },
635
+ "asset add": async () => {
636
+ const { assetAdd } = await import("./source.mjs");
637
+ const out = await assetAdd(rest, opts, opts.json ? { log: () => {} } : {});
638
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
639
+ },
640
+ guide: async () => {
641
+ const { guide } = await import("./guide.mjs");
642
+ const out = await guide(pos.slice(1), opts);
643
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
644
+ },
547
645
  "voice list": async () => {
548
646
  const voices = await ops.listVoices(opts);
549
647
  if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
@@ -592,6 +690,7 @@ function commandTable(pos, rest, opts) {
592
690
  "skill install": () => installHyperdecksSkill(opts, rest),
593
691
  "skills install": () => installHyperdecksSkill(opts, rest),
594
692
  activity: () => activity(pos.slice(1), opts),
693
+ engagement: () => engagement(pos.slice(1), opts),
595
694
  "gen-image": () => genImage(pos.slice(1), opts),
596
695
  "image-job": () => imageJob(pos.slice(1), opts),
597
696
  "gen-video": () => genVideo(pos.slice(1), opts),
package/src/files.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // The packaged text files the hosted MCP route reads at runtime: templates and
2
+ // the skill. Each is a literal new URL(…, import.meta.url), which the Next.js
3
+ // bundler copies next to the route and the CLI resolves next to this file. A
4
+ // path that is not here cannot be read, so nothing outside the package is.
5
+ import { readFileSync } from "node:fs";
6
+
7
+ const FILES = {
8
+ "templates/narrated-deck.html": () => new URL("../templates/narrated-deck.html", import.meta.url),
9
+ "templates/script.md": () => new URL("../templates/script.md", import.meta.url),
10
+ "templates/evidence.md": () => new URL("../templates/evidence.md", import.meta.url),
11
+ "templates/layouts/_deck.html": () => new URL("../templates/layouts/_deck.html", import.meta.url),
12
+ "templates/layouts/chart.html": () => new URL("../templates/layouts/chart.html", import.meta.url),
13
+ "templates/layouts/closer.html": () => new URL("../templates/layouts/closer.html", import.meta.url),
14
+ "templates/layouts/compare.html": () => new URL("../templates/layouts/compare.html", import.meta.url),
15
+ "templates/layouts/features.html": () => new URL("../templates/layouts/features.html", import.meta.url),
16
+ "templates/layouts/hero.html": () => new URL("../templates/layouts/hero.html", import.meta.url),
17
+ "templates/layouts/number.html": () => new URL("../templates/layouts/number.html", import.meta.url),
18
+ "templates/layouts/product.html": () => new URL("../templates/layouts/product.html", import.meta.url),
19
+ "templates/layouts/quote.html": () => new URL("../templates/layouts/quote.html", import.meta.url),
20
+ "templates/layouts/team.html": () => new URL("../templates/layouts/team.html", import.meta.url),
21
+ "templates/layouts/timeline.html": () => new URL("../templates/layouts/timeline.html", import.meta.url),
22
+ "skills/hyperdecks/SKILL.md": () => new URL("../skills/hyperdecks/SKILL.md", import.meta.url),
23
+ "skills/hyperdecks/chat.md": () => new URL("../skills/hyperdecks/chat.md", import.meta.url),
24
+ };
25
+
26
+ const cache = new Map();
27
+
28
+ export function packageText(path) {
29
+ const url = FILES[path];
30
+ if (!url) throw new Error(`no packaged file ${path}`);
31
+ if (!cache.has(path)) cache.set(path, readFileSync(url(), "utf8"));
32
+ return cache.get(path);
33
+ }
34
+
35
+ export const PACKAGE_FILES = Object.keys(FILES);
package/src/guide.mjs ADDED
@@ -0,0 +1,55 @@
1
+ // hyperdecks guide [topic]
2
+ //
3
+ // The skill, served in sections, for agents that cannot install it (chat apps on
4
+ // the hosted MCP call deck_guide). `workflow` is chat.md; the other topics are
5
+ // the SKILL.md sections marked <!-- topic: … -->, and `layouts` is built from
6
+ // each layout file's leading comment.
7
+ import { packageText } from "./files.mjs";
8
+ import { LAYOUTS, readLayout } from "./layouts.mjs";
9
+
10
+ export const GUIDE_TOPICS = ["workflow", "format", "script", "design", "layouts", "presenter", "transitions", "check"];
11
+ 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";
12
+
13
+ // One <!-- topic: name --> section of SKILL.md: its heading and body, up to the
14
+ // next topic marker or the next heading of the same or a higher level.
15
+ export function skillSection(skill, topic) {
16
+ const lines = skill.split("\n");
17
+ const start = lines.findIndex((l) => l.trim() === `<!-- topic: ${topic} -->`);
18
+ if (start === -1) return null;
19
+ const level = /^(#+)\s/.exec(lines[start + 1] ?? "")?.[1].length ?? 6;
20
+ const out = [];
21
+ for (const line of lines.slice(start + 1)) {
22
+ if (out.length && (/^<!-- topic: /.test(line.trim()) || ((/^(#+)\s/.exec(line)?.[1].length ?? 99) <= level))) break;
23
+ out.push(line);
24
+ }
25
+ return out.join("\n").trim();
26
+ }
27
+
28
+ function layoutsTopic() {
29
+ const rows = LAYOUTS.map((name) => {
30
+ const { meta } = readLayout(name);
31
+ return [`### ${name}`, meta.summary, meta.use && `Use: ${meta.use}`, meta.slot && `Slots: ${meta.slot}`, meta.cues && `Cues: ${meta.cues}`].filter(Boolean).join("\n");
32
+ });
33
+ return `# Layouts\n\nPass any of these, in order, as new_deck's \`layouts\` (repeat one for more of the same: number, number gives slides number and number-2).\n\n${rows.join("\n\n")}`;
34
+ }
35
+
36
+ // { topic, topics, text } for one topic; no topic gives the workflow.
37
+ export function deckGuide(topic = "workflow") {
38
+ const t = String(topic || "workflow").toLowerCase();
39
+ if (!GUIDE_TOPICS.includes(t)) throw new Error(`unknown topic "${topic}": pick one of ${GUIDE_TOPICS.join(", ")}`);
40
+ let text;
41
+ if (t === "workflow") text = packageText("skills/hyperdecks/chat.md");
42
+ else if (t === "layouts") text = layoutsTopic();
43
+ else {
44
+ const section = skillSection(packageText("skills/hyperdecks/SKILL.md"), t);
45
+ if (!section) throw new Error(`SKILL.md has no <!-- topic: ${t} --> section`);
46
+ text = CLI_NOTE + section;
47
+ }
48
+ return { topic: t, topics: GUIDE_TOPICS, text };
49
+ }
50
+
51
+ export async function guide(pos, opts = {}, { log = (line) => console.log(line) } = {}) {
52
+ const out = deckGuide(pos[0]);
53
+ if (!opts.json) log(out.text);
54
+ return out;
55
+ }
package/src/hosted.mjs ADDED
@@ -0,0 +1,74 @@
1
+ // The hosted deck source (convex/deckSources.ts) as ops: a deck's files kept on
2
+ // Hyperdecks as a draft. The hosted MCP route, the stdio MCP and the CLI
3
+ // (source.mjs) share these; no local file I/O, so the route can bundle them.
4
+ import { api, client, target } from "./runtime.mjs";
5
+ import { PRESENTER_STYLES } from "./checkRules.mjs";
6
+ import { NARRATED_FILES, assembleLayoutDeck, fillTemplate } from "./layouts.mjs";
7
+
8
+ export const SOURCE_FILES = Object.keys(NARRATED_FILES);
9
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
10
+ const urls = (opts, slug, previewToken) => {
11
+ const base = target(opts).shareBase;
12
+ return { previewUrl: previewToken ? `${base}/p/${previewToken}` : null, dashboardUrl: `${base}/dashboard/d/${slug}` };
13
+ };
14
+
15
+ // Scaffold the files (the same templates as deck new) and register the draft.
16
+ export async function hostedNewDeck(opts, { slug, title, layouts, presenterStyle } = {}) {
17
+ if (!slug || !title) throw new Error("slug and title are required");
18
+ if (!SLUG.test(slug)) throw new Error("slug must be lowercase letters, digits and dashes (max 64)");
19
+ if (presenterStyle !== undefined && !PRESENTER_STYLES.includes(presenterStyle)) throw new Error(`presenterStyle must be one of ${PRESENTER_STYLES.join(", ")}`);
20
+ const list = Array.isArray(layouts) ? layouts.join(",") : layouts;
21
+ 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 })]));
24
+ 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) };
26
+ }
27
+
28
+ export async function getDeckSource(opts, { slug, file } = {}) {
29
+ if (!slug) throw new Error("slug is required");
30
+ const got = await (await client(opts)).query(api.deckSources.get, { slug, ...(file ? { file } : {}) });
31
+ return { ...got, ...urls(opts, slug, got.previewToken) };
32
+ }
33
+
34
+ export async function writeDeckSource(opts, { slug, file, content, edits, baseRev } = {}) {
35
+ if (!slug || !file) throw new Error("slug and file are required");
36
+ if ((content === undefined) === (edits === undefined)) throw new Error("pass exactly one of content or edits");
37
+ const args = { slug, file, ...(content !== undefined ? { content: String(content) } : { edits }) };
38
+ if (baseRev !== undefined && baseRev !== null) args.baseRev = Number(baseRev);
39
+ return (await client(opts)).mutation(api.deckSources.write, args);
40
+ }
41
+
42
+ // A public URL (or a ChatGPT upload's download_url) filed under the deck.
43
+ export async function uploadDeckAsset(opts, { slug, name, url, file } = {}) {
44
+ if (!slug) throw new Error("slug is required");
45
+ const from = url ?? file?.download_url;
46
+ if (!from) throw new Error("pass url, or file (a chat upload with a download_url)");
47
+ // A same-name upload replaces the earlier asset (and its URL stops working), so
48
+ // a name taken from the file or URL gets a unique suffix; only an explicit name replaces.
49
+ const fallback = file?.file_name ?? (() => { try { return new URL(from).pathname.split("/").pop(); } catch { return ""; } })();
50
+ const clean = (s) => String(s).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "");
51
+ const unique = (s) => { const m = /^(.*?)(\.[A-Za-z0-9]{1,8})?$/.exec(clean(s) || "asset"); return `${(m[1] || "asset").slice(0, 100)}-${Date.now().toString(36)}${m[2] ?? ""}`; };
52
+ const assetName = name ? clean(name).slice(0, 120) || "asset" : unique(fallback || "asset");
53
+ return (await client(opts)).action(api.assetsNode.importAssetFromUrl, { slug, name: assetName, url: String(from), ...(file?.mime_type ? { mimeType: String(file.mime_type) } : {}) });
54
+ }
55
+
56
+ export async function hostedCheck(opts, { slug, forbid, maxDuration } = {}) {
57
+ if (!slug) throw new Error("slug is required");
58
+ const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
59
+ return (await client(opts)).action(api.deckSources.check, { slug, ...(list?.length ? { forbid: list } : {}), ...(maxDuration ? { maxDuration: Number(maxDuration) } : {}) });
60
+ }
61
+
62
+ export async function hostedRelease(opts, { slug, recipients, qa, title, forbid, rev, voice } = {}) {
63
+ if (!slug) throw new Error("slug is required");
64
+ const list = typeof forbid === "string" ? forbid.split(",").map((s) => s.trim()).filter(Boolean) : forbid;
65
+ const args = { slug };
66
+ if (recipients?.length) args.recipients = recipients.map((r) => ({ recipient: String(r.recipient), ...(r.email ? { email: String(r.email) } : {}) }));
67
+ if (qa) args.qa = true;
68
+ if (title) args.title = String(title);
69
+ if (list?.length) args.forbid = list;
70
+ if (rev !== undefined && rev !== null) args.rev = Number(rev);
71
+ if (voice) args.voice = String(voice);
72
+ return (await client(opts)).action(api.deckSources.release, args);
73
+ }
74
+