@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.
package/src/commands.mjs CHANGED
@@ -237,15 +237,30 @@ export function help() {
237
237
  console.log(`decks ${VERSION}
238
238
 
239
239
  commands:
240
+ init [--yes] [--json] [--agent <id>]... [--all-agents] [--voice dash] [--name <n> --about <a> --ask <k>] [--dir <path>]
241
+ [--no-voice] [--no-skill] [--no-release] [--no-open]
242
+ the first run: sign in, install the skill, pick a voice (clone yours, or D.A.S.H.),
243
+ scaffold a 3-slide hello deck from your answers, narrate it and release a QA link.
244
+ Re-running resumes where it stopped. --json prints one object; it never clones
245
+ (consent is typed by a person) and reports that step as needs-human.
240
246
  login [--token <pat>] [--broker <url>] [--dev]
241
247
  logout [--dev]
242
248
  whoami [--json] [--dev]
243
249
  deck list|stats|open|rm
244
- deck new <slug> "<title>" --narrated [--dir <path>]
245
- scaffold decks/<slug>/: index.html (8 slides, player/v1), script.md, evidence.md
246
- check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
250
+ deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--dir <path>]
251
+ scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md
252
+ layouts: hero, number, product, quote, compare, timeline, features, chart, team, closer
253
+ check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--layout] [--json]
247
254
  the narrated-deck gate: slides vs manifest, timing, captions, clip URLs,
248
255
  local refs, pinned player, a data-note per slide, mobile titles
256
+ deck new|check|release … --hosted
257
+ the same on a hosted draft (no folder, no git: what chat agents use over the remote MCP)
258
+ source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
259
+ move a hosted draft (index.html, script.md, evidence.md) to a folder and back
260
+ asset add <slug> <file|url> [--name n]
261
+ file an image, video or font under the deck; prints its hosted URL
262
+ guide [workflow|format|script|design|layouts|presenter|transitions|check]
263
+ the skill, one topic at a time (what deck_guide serves)
249
264
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
250
265
  on a committed deck: check → narrate → import → share new per --to
251
266
  share new|list|on|off|mute|unmute|rm|url
@@ -255,12 +270,26 @@ commands:
255
270
  import-design <project-id> --serve-token <token> --entry <file.html> --slug <slug>
256
271
  skill install [--agent codex,claude-code] [--all-agents]
257
272
  skills install [--agent codex,claude-code] [--all-agents]
273
+ default: every agent found in your home directory (else codex, claude-code)
258
274
  activity [--deck <slug>] [--limit <n>] [--json]
259
- gen-image "<prompt>" [--transparent] [--image <url>] [--size <s>] [--quality <q>] [--count <n>] [--slug <deck>] [--download <file>]
275
+ gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--transparent] [--image <url>] [--size <s>]
276
+ [--quality <q>] [--count <n>] [--seed <n>] [--slug <deck>] [--download <file>]
277
+ default model gpt-image-2.5; --seed applies to nano-banana-2.1
260
278
  image-job <jobId> [--json]
261
- gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
262
- a 3–10s clip (no audio) via fal; --image animates that still and keeps its aspect
279
+ gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]
280
+ a 3–10s clip via fal (default MiniMax H3 Max); --image animates that still and keeps its aspect
263
281
  video-job <jobId> [--json]
282
+ screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]
283
+ a PNG of a public web page (private and local addresses are refused)
284
+ chart <spec.json|-> [--slug <deck>] [--name <name>] [--download <file>]
285
+ a branded SVG: { "kind": "bar|line|donut|number", "labels", "series", "values", "value", "palette" }
286
+ icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--slug <deck>] [--name <prefix>]
287
+ a style-consistent set of transparent icons, one per subject (max 12)
288
+ review <slug> [--script <script.md>] [--json]
289
+ GPT-6 Luna reviews every slide (desktop + phone screenshots) and lists what to fix
290
+ review-job <jobId> [--json]
291
+ poster <slug> [--force] [--json]
292
+ regenerate the share-link poster (release makes one) and print its URL
264
293
  credits [--limit <n>] [--json] show your credit balance and recent history
265
294
 
266
295
  preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
@@ -289,7 +318,7 @@ async function genImage(pos, opts) {
289
318
  const prompt = pos.join(" ").trim() || opts.prompt;
290
319
  if (!prompt) {
291
320
  throw new Error(
292
- 'usage: gen-image "<prompt>" [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--slug <deck>] [--name <name>] [--download <file>]',
321
+ 'usage: gen-image "<prompt>" [--model gpt-image-2.5|nano-banana-2.1|gpt-image-2] [--image <url>] [--transparent] [--size <s>] [--quality <q>] [--count <n>] [--format png|jpeg|webp] [--seed <n>] [--slug <deck>] [--name <name>] [--download <file>]',
293
322
  );
294
323
  }
295
324
  let out = await ops.generateImage(opts, {
@@ -300,6 +329,8 @@ async function genImage(pos, opts) {
300
329
  quality: opts.quality,
301
330
  count: opts.count,
302
331
  format: opts.format,
332
+ model: opts.model,
333
+ seed: opts.seed,
303
334
  slug: opts.slug,
304
335
  name: opts.name,
305
336
  });
@@ -348,19 +379,20 @@ async function imageJob(pos, opts) {
348
379
  async function genVideo(pos, opts) {
349
380
  const prompt = pos.join(" ").trim() || opts.prompt;
350
381
  if (!prompt) {
351
- throw new Error('usage: gen-video "<prompt>" [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
382
+ throw new Error('usage: gen-video "<prompt>" [--model h3-max|kling-v3] [--image <url>] [--seconds 5] [--aspect 16:9|9:16] [--slug <deck>] [--name <name>] [--download <file>]');
352
383
  }
353
384
  const queued = await ops.generateVideo(opts, {
354
385
  prompt,
355
386
  image: opts.image,
356
387
  seconds: opts.seconds,
357
388
  aspect: opts.aspect,
389
+ model: opts.model,
358
390
  slug: opts.slug,
359
391
  name: opts.name,
360
392
  });
361
393
  // A clip takes a minute or more; poll so gen-video stays one blocking command.
362
394
  // Progress goes to stderr so stdout stays the URL (or --json).
363
- process.stderr.write(`queued a ${queued.seconds}s clip — job ${queued.jobId}\n`);
395
+ process.stderr.write(`queued a ${queued.seconds}s ${queued.model} clip — job ${queued.jobId}\n`);
364
396
  const job = await ops.pollVideoJob(opts, queued.jobId, {
365
397
  onProgress: (j, ms) => process.stderr.write(` ${j.status} ${Math.round(ms / 1000)}s…\r`),
366
398
  });
@@ -391,6 +423,112 @@ async function videoJob(pos, opts) {
391
423
  if (job.video?.url) console.log(job.video.url);
392
424
  }
393
425
 
426
+ async function screenshot(pos, opts) {
427
+ const [url] = pos;
428
+ if (!url) throw new Error("usage: screenshot <url> [--width 1440] [--height 900] [--full-page] [--slug <deck>] [--name <name>] [--download <file>]");
429
+ const out = await ops.captureUrl(opts, {
430
+ url,
431
+ width: opts.width,
432
+ height: opts.height,
433
+ fullPage: opts["full-page"],
434
+ slug: opts.slug,
435
+ name: opts.name,
436
+ });
437
+ // Print the paid asset's URL before downloading, so a failed download never hides it.
438
+ console.log(opts.json ? JSON.stringify(out, null, 2) : out.url);
439
+ if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
440
+ await saveTo(opts.download, out.url);
441
+ }
442
+
443
+ async function chart(pos, opts) {
444
+ const [file] = pos;
445
+ if (!file) throw new Error("usage: chart <spec.json> [--slug <deck>] [--name <name>] [--download <file>]");
446
+ let spec;
447
+ try {
448
+ spec = JSON.parse(file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8"));
449
+ } catch (e) {
450
+ throw new Error(`could not read chart spec ${file}: ${e.message}`);
451
+ }
452
+ const out = await ops.renderChart(opts, { ...spec, slug: opts.slug ?? spec.slug, name: opts.name ?? spec.name });
453
+ console.log(opts.json ? JSON.stringify(out, null, 2) : out.url);
454
+ if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
455
+ await saveTo(opts.download, out.url);
456
+ }
457
+
458
+ async function icons(pos, opts) {
459
+ if (!pos.length || !opts.style) {
460
+ throw new Error('usage: icons <subject...> --style "<style>" [--palette "#hex,#hex"] [--model nano-banana-2.1|gpt-image-2.5|gpt-image-2] [--slug <deck>] [--name <prefix>]');
461
+ }
462
+ const queued = await ops.generateIconSet(opts, {
463
+ subjects: pos,
464
+ style: opts.style,
465
+ palette: opts.palette ? String(opts.palette).split(",").map((c) => c.trim()).filter(Boolean) : undefined,
466
+ model: opts.model,
467
+ slug: opts.slug,
468
+ name: opts.name,
469
+ });
470
+ process.stderr.write(`queued ${queued.count} icon(s) — job ${queued.jobId}\n`);
471
+ const out = await ops.pollImageJob(opts, queued.jobId, {
472
+ onProgress: (job) => process.stderr.write(` ${job.progress} (${job.status})…\r`),
473
+ });
474
+ process.stderr.write("\n");
475
+ if (opts.json) return void console.log(JSON.stringify({ ...out, subjects: queued.subjects }, null, 2));
476
+ (out.images || []).forEach((im, i) => console.log(`${queued.subjects[i] ?? im.name}\t${im.url}`));
477
+ if (out.creditsRemaining != null) process.stderr.write(`credits left: ${out.creditsRemaining}\n`);
478
+ }
479
+
480
+ function printReview(job) {
481
+ console.log(`review of ${job.slug}: ${job.fix} to fix, ${job.consider} to consider (${job.progress} slides, ${job.model})`);
482
+ for (const f of job.findings) {
483
+ console.log(` [${f.severity}] ${f.slide} (${f.viewport}, ${f.category}): ${f.issue}`);
484
+ console.log(` → ${f.suggestion}`);
485
+ }
486
+ for (const s of job.failedSlides || []) console.log(` ! ${s.slide}: not reviewed (${s.error}); not charged`);
487
+ if (!job.findings.length && !job.failedSlides?.length) console.log(" no findings: every slide looks clean");
488
+ }
489
+
490
+ async function review(pos, opts) {
491
+ const [slug] = pos;
492
+ if (!slug) throw new Error("usage: review <slug> [--script <script.md>] [--json]");
493
+ const script = opts.script ? readFileSync(opts.script, "utf8") : undefined;
494
+ const queued = await ops.reviewDeck(opts, { slug, script });
495
+ process.stderr.write(`reviewing ${queued.slides} slide(s) on desktop and phone — job ${queued.jobId}\n`);
496
+ const job = await ops.pollReviewJob(opts, queued.jobId, {
497
+ onProgress: (j) => process.stderr.write(` ${j.progress} slides…\r`),
498
+ });
499
+ process.stderr.write("\n");
500
+ if (opts.json) console.log(JSON.stringify(job, null, 2));
501
+ else printReview(job);
502
+ }
503
+
504
+ async function reviewJob(pos, opts) {
505
+ const [jobId] = pos;
506
+ if (!jobId) throw new Error("usage: review-job <jobId>");
507
+ const job = await ops.checkReviewJob(opts, jobId);
508
+ if (opts.json) return void console.log(JSON.stringify(job, null, 2));
509
+ console.log(`status: ${job.status}`);
510
+ if (job.error) console.log(`error: ${job.error}`);
511
+ printReview(job);
512
+ }
513
+
514
+ async function poster(pos, opts) {
515
+ const [slug] = pos;
516
+ if (!slug) throw new Error("usage: poster <slug> [--force] [--json]");
517
+ const out = await ops.generatePoster(opts, { slug, force: opts.force });
518
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
519
+ if (out.unchanged) process.stderr.write("poster already matches the deck (pass --force to re-render)\n");
520
+ console.log(out.url);
521
+ }
522
+
523
+ // Download a hosted asset to `file` when --download was given.
524
+ async function saveTo(file, url) {
525
+ if (!file || !url) return;
526
+ const res = await fetch(url);
527
+ if (!res.ok) throw new Error(`download of ${url} failed (${res.status})`);
528
+ writeFileSync(file, Buffer.from(await res.arrayBuffer()));
529
+ process.stderr.write(`saved to ${file}\n`);
530
+ }
531
+
394
532
  // The command table: "<group> <cmd>" or "<group>" → handler. Exported (via
395
533
  // COMMANDS) so the CLI/MCP parity test can enumerate it.
396
534
  function commandTable(pos, rest, opts) {
@@ -400,18 +538,44 @@ function commandTable(pos, rest, opts) {
400
538
  const result = await createPreview(rest, opts);
401
539
  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."}`);
402
540
  },
541
+ init: async () => {
542
+ const { init } = await import("./init.mjs");
543
+ const out = await init(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
544
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
545
+ },
403
546
  "deck new": async () => {
547
+ if (opts.hosted) {
548
+ const { hostedNewDeck } = await import("./source.mjs");
549
+ if (!opts.narrated) throw new Error("the published CLI scaffolds narrated decks only: add --narrated");
550
+ const out = await hostedNewDeck(opts, { slug: rest[0], title: rest[1], layouts: opts.layouts, presenterStyle: opts["presenter-style"] });
551
+ 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}`);
552
+ }
404
553
  const { deckNew } = await import("./scaffold.mjs");
405
554
  const out = await deckNew(rest, opts, opts.json ? { log: (line) => console.error(line) } : {});
406
555
  if (opts.json) console.log(JSON.stringify(out, null, 2));
407
556
  },
408
557
  check: async () => {
409
558
  const { check } = await import("./check.mjs");
410
- const result = await check(pos.slice(1), opts);
559
+ const { hostedCheck } = await import("./source.mjs");
560
+ const result = opts.hosted ? await hostedCheck(opts, { slug: pos[1], forbid: opts.forbid, maxDuration: opts["max-duration"] }) : await check(pos.slice(1), opts);
561
+ if (opts.hosted && !opts.json) {
562
+ console.log(`deck "${pos[1]}" rev ${result.rev}: ${result.slides} slides${result.totalDuration != null ? `, ${result.totalDuration}s narrated` : ""}`);
563
+ for (const e of result.errors) console.log(` - ${e}`);
564
+ if (!result.errors.length) console.log("✓ check passed");
565
+ for (const w of result.warnings) console.log(` ⚠ ${w}`);
566
+ }
411
567
  if (opts.json) console.log(JSON.stringify(result, null, 2));
412
568
  if (result.errors.length) throw new Error(`check found ${result.errors.length} issue(s)`);
413
569
  },
414
570
  release: async () => {
571
+ if (opts.hosted) {
572
+ const { hostedRelease } = await import("./source.mjs");
573
+ const out = await hostedRelease(opts, { slug: pos[1], recipients: opts.recipients, qa: opts.qa, title: opts.title, forbid: opts.forbid });
574
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
575
+ console.log(`✓ released ${out.slug} rev ${out.rev}\n deck: ${out.deckUrl}`);
576
+ for (const s of out.shares) console.log(`✓ share for "${s.recipient}"${s.qa ? " (QA, untracked)" : ""} → ${s.url}`);
577
+ return;
578
+ }
415
579
  const { release } = await import("./release.mjs");
416
580
  const out = await release(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
417
581
  if (opts.json) console.log(JSON.stringify(out, null, 2));
@@ -421,6 +585,26 @@ function commandTable(pos, rest, opts) {
421
585
  const result = await narrate(pos.slice(1), opts, opts.json ? { log: (line) => console.error(line) } : {});
422
586
  if (opts.json) console.log(JSON.stringify(result, null, 2));
423
587
  },
588
+ "source pull": async () => {
589
+ const { sourcePull } = await import("./source.mjs");
590
+ const out = await sourcePull(rest, opts, opts.json ? { log: (line) => console.error(line) } : {});
591
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
592
+ },
593
+ "source push": async () => {
594
+ const { sourcePush } = await import("./source.mjs");
595
+ const out = await sourcePush(rest, opts, opts.json ? { log: (line) => console.error(line) } : {});
596
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
597
+ },
598
+ "asset add": async () => {
599
+ const { assetAdd } = await import("./source.mjs");
600
+ const out = await assetAdd(rest, opts, opts.json ? { log: () => {} } : {});
601
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
602
+ },
603
+ guide: async () => {
604
+ const { guide } = await import("./guide.mjs");
605
+ const out = await guide(pos.slice(1), opts);
606
+ if (opts.json) console.log(JSON.stringify(out, null, 2));
607
+ },
424
608
  "voice list": async () => {
425
609
  const voices = await ops.listVoices(opts);
426
610
  if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
@@ -473,6 +657,12 @@ function commandTable(pos, rest, opts) {
473
657
  "image-job": () => imageJob(pos.slice(1), opts),
474
658
  "gen-video": () => genVideo(pos.slice(1), opts),
475
659
  "video-job": () => videoJob(pos.slice(1), opts),
660
+ screenshot: () => screenshot(pos.slice(1), opts),
661
+ chart: () => chart(pos.slice(1), opts),
662
+ icons: () => icons(pos.slice(1), opts),
663
+ review: () => review(pos.slice(1), opts),
664
+ "review-job": () => reviewJob(pos.slice(1), opts),
665
+ poster: () => poster(pos.slice(1), opts),
476
666
  credits: () => credits(pos.slice(1), opts),
477
667
  };
478
668
  }
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
+
package/src/importer.mjs CHANGED
@@ -158,7 +158,7 @@ export function rewriteTextAssetUrls(text, sourcePath, assets, uploadedByName) {
158
158
  export function rewriteHtmlAssetUrls(html, htmlEntry, assets, uploadedByName) {
159
159
  const assetMap = buildAssetUrlMap(assets, uploadedByName, htmlEntry);
160
160
  let out = html.replace(
161
- /\b(src|href|poster)\s*=\s*(["'])([^"']+)\2/gi,
161
+ /\b(src|href|poster|data-presenter-image)\s*=\s*(["'])([^"']+)\2/gi,
162
162
  (match, attr, quote, raw) => {
163
163
  const url = resolveLocalUrl(raw, assetMap);
164
164
  return url ? `${attr}=${quote}${url}${quote}` : match;