@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/README.md +9 -4
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +297 -13
- package/skills/hyperdecks/chat.md +49 -0
- package/src/check.mjs +110 -240
- package/src/checkRules.mjs +558 -0
- package/src/commands.mjs +200 -10
- package/src/files.mjs +35 -0
- package/src/guide.mjs +55 -0
- package/src/hosted.mjs +74 -0
- package/src/importer.mjs +1 -1
- package/src/init.mjs +605 -0
- package/src/layouts.mjs +71 -0
- package/src/narrate.mjs +4 -13
- package/src/ops.mjs +101 -7
- package/src/runtime.mjs +73 -14
- package/src/scaffold.mjs +23 -22
- 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/layouts/_deck.html +102 -0
- package/templates/layouts/chart.html +39 -0
- package/templates/layouts/closer.html +28 -0
- package/templates/layouts/compare.html +33 -0
- package/templates/layouts/features.html +35 -0
- package/templates/layouts/hero.html +35 -0
- package/templates/layouts/number.html +29 -0
- package/templates/layouts/product.html +57 -0
- package/templates/layouts/quote.html +31 -0
- package/templates/layouts/team.html +37 -0
- package/templates/layouts/timeline.html +39 -0
- package/templates/narrated-deck.html +57 -11
- package/templates/script.md +11 -3
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
|
-
|
|
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>" [--
|
|
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 (
|
|
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
|
|
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;
|