@hypersocial/decks-cli 0.6.0 → 0.8.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 CHANGED
@@ -79,6 +79,7 @@ voice list|clone|upload|rm
79
79
 
80
80
  deck new <slug> "<title>" --narrated [--dir <path>]
81
81
  check <slug|deck-dir> [--forbid "<phrase>,<phrase>"] [--max-duration <s>] [--json]
82
+ play <slug|deck-dir> [--voice <v>] [--slug <slug>] [--port <n>] [--no-open] [--json]
82
83
  release <slug|deck-dir> [--to "<name>" [--email <e>]]... [--qa] [--no-notify] [--title t] [--voice v]
83
84
  ```
84
85
 
@@ -93,7 +94,12 @@ clip's audio and timeline resolve with the right type; captions are in time,
93
94
  cover the clip text and run 4 to 85 characters; local `src`/`href` resolve inside
94
95
  the folder; the player is the pinned `player/v1`; `--forbid` phrases appear
95
96
  nowhere; every slide has a `data-note` and long titles a `data-title-mobile`.
96
- `release` refuses uncommitted changes in the deck folder, runs check and narrate
97
+ `play` plays the folder as it is, committed or not, with the real face and voice:
98
+ the face goes on the served copy in memory, on 127.0.0.1 behind a random path, until
99
+ Ctrl-C. It writes no file, uploads nothing, creates no share and renders no audio, so
100
+ changed lines need `narrate` first. The hosted draft link `/p/<token>` shows the same face.
101
+ `release` refuses uncommitted changes in the deck folder (`--qa` too: a release
102
+ replaces what every link to the deck plays), runs check and narrate
97
103
  (stopping for a commit if narration changed the folder), imports `index.html` with
98
104
  the local files it references, and creates one share per `--to`. The agent
99
105
  procedure is the "Author a narrated deck" section of the bundled skill.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -66,7 +66,8 @@ returns a link; hand it to them, word for word, and wait.
66
66
  The faces narrators wear; these commands still work as before.
67
67
 
68
68
  A deck narrated in the user's own voice is presented by their default avatar
69
- (a cartoon Minimoji, never a photo) from its next release; with none it plays
69
+ (a cartoon Minimoji, never a photo): in `play` and the draft link at once, on
70
+ share links from its next release. With none it plays
70
71
  captions only. D.A.S.H. presents only in its own voice. **Never pick or approve
71
72
  a face for the person.** Every command that needs a choice returns a link (and,
72
73
  for an import, a code): hand it to them, word for word, and wait.
@@ -124,6 +125,9 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
124
125
  | Step | CLI | MCP tool |
125
126
  |---|---|---|
126
127
  | Brand (once; every new deck starts from it) | `hyperdecks brand show` / `brand set` / `brand edit` | `get_brand` / `set_brand` |
128
+ | Import a brand (website, .pptx/.potx, .pdf, a deck; preview first) | `hyperdecks brand import <url\|file\|slug>` | `import_brand` |
129
+ | Revise the brand with a prompt (preview first) | `hyperdecks brand revise "<change>"` | `revise_brand` |
130
+ | Brand history / undo a brand change | `hyperdecks brand history` / `brand undo [<version>]` | `brand_history` / `undo_brand` |
127
131
  | Scaffold | `hyperdecks deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer]` | `new_deck` (`layouts`) |
128
132
  | Images | `hyperdecks gen-image "<prompt>" --slug <slug>` | `generate_image` |
129
133
  | Video clips | `hyperdecks gen-video "<prompt>" --slug <slug>` | `generate_video` |
@@ -136,6 +140,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
136
140
  | Who narrates (ask the person first; see *Narrators*) | `hyperdecks deck narrator <slug> <name\|none\|default>` | `list_narrators`, then `set_deck_narrator` |
137
141
  | Narrate | `hyperdecks narrate decks/<slug>` | `narrate_deck` |
138
142
  | Check | `hyperdecks check decks/<slug>` | `check_deck` |
143
+ | Watch a draft with its real face | `hyperdecks play decks/<slug>` | the `previewUrl` from `get_deck_source` |
139
144
  | Ship | `hyperdecks release decks/<slug> --to "<name>" --email <e>` | `release_deck` |
140
145
  | Share-link poster | `hyperdecks poster <slug>` | `generate_deck_poster` |
141
146
  | Import only | `hyperdecks import <file> --slug <slug>` | `import_deck_from_url` |
@@ -226,6 +231,9 @@ its `##` heading.
226
231
  | `glass` | dark or busy slides: head and captions on one frosted panel |
227
232
  | `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
228
233
 
234
+ The live head's face follows script cues too (`[nod]`, `[look #card]`,
235
+ `[smile]`, `[gesture: lightbulb]`; see the script topic); a still face ignores them.
236
+
229
237
  Below 650 px stage and glass become corner on their own. Run
230
238
  `hyperdecks check <deck> --layout` to be warned about any slide whose presenter
231
239
  would cover its title.
@@ -307,10 +315,17 @@ a presenter style and a design.md (voice, do and don't). `deck new` and
307
315
  `new_deck` put its tokens in `:root` and its logo on the first slide, so read
308
316
  it first (`hyperdecks brand show --json`, `get_brand`) and write in its voice.
309
317
  An explicit request for this deck wins over the brand; `--no-brand`
310
- (`brand: false`) starts from the template's look. No brand yet? Offer to set
311
- one up: read the person's website (`capture_url`, its colours, fonts and logo),
312
- show them what you would save, and `brand set` / `set_brand` only after they
313
- agree.
318
+ (`brand: false`) starts from the template's look. No brand yet? Offer to
319
+ import it from what they already have: `hyperdecks brand import
320
+ <url|file.pptx|file.pdf|deck-slug>` (`import_brand`) reads the real colours,
321
+ fonts and logo (a PowerPoint's theme exactly) and returns a preview link, a
322
+ sample slide in that brand, and a token. Always show them the preview and
323
+ ask; save with `brand set --draft <token>` (`set_brand { draft }`) only after
324
+ they say yes.
325
+ To change it in words ("darker accent, serif titles"), `hyperdecks brand
326
+ revise "<change>"` (`revise_brand`) gives a draft and preview the same way.
327
+ Every brand save is kept in its history: `brand history` (`brand_history`)
328
+ lists the last changes and `brand undo [<version>]` (`undo_brand`) goes back.
314
329
 
315
330
  **Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
316
331
  curated pairings below (display for titles, body for text, mono for kickers
@@ -371,7 +386,26 @@ where there is room so slide changes move the presenter.
371
386
  palette hexes, "no text"). `--model nano-banana-2.1` when the image must
372
387
  carry legible type.
373
388
  - Video: `gen-video` for a short loop behind a hero or a product moment, with
374
- `[play: video]` at its word; never needed for a deck to work.
389
+ `[play: video]` at its word; never needed for a deck to work. A clip that
390
+ plays on repeat should be made with `--loop` (`loop: true`): it starts and
391
+ ends on the same frame, so there is no jump at the seam. Give it `--image`
392
+ to loop a still you already have; without one a keyframe is rendered first.
393
+ - Moving cut-outs: `--transparent` (`transparent: true`) mattes the
394
+ background out, the moving version of a `gen-image --transparent` PNG: a
395
+ spinning part, a product turning, over the slide's own background. Use it
396
+ with `--loop` and a subject on a plain background. It returns a VP9 WebM
397
+ with alpha (Chrome, Firefox, Edge) and a stacked-alpha MP4 that the hosted
398
+ player draws on a canvas in Safari and on iOS, which ignore WebM alpha. The
399
+ job's `markup` is the tag to paste, as is:
400
+
401
+ ```html
402
+ <video muted loop playsinline autoplay crossorigin="anonymous"
403
+ src="(video.url)" data-alpha-stacked="(video.stacked.url)"></video>
404
+ ```
405
+
406
+ Keep `crossorigin` and `data-alpha-stacked`: the player needs both. A deck
407
+ without the hosted player shows the clip in a box in Safari. A transparent
408
+ clip costs 2 credits a second more than an opaque one.
375
409
  - People: real photos only with permission; initials otherwise.
376
410
 
377
411
  **Motion.** The presenter's moves are the deck's motion; keep everything else
@@ -425,6 +459,12 @@ phone screenshots, the same `fix` bar.
425
459
  showcase is a slide of stacked cards, one point each, with a `go` to each
426
460
  card at the word that starts it: `Write [go: #step-1] the slides, write
427
461
  [go: #step-2] what you'd say`. Give each card an id.
462
+ - Face: the live head already moves with the speech (eyes, brows, a nod on a
463
+ falling end); add a few face cues only where they carry the point, each on
464
+ the word before it: `This is the big one [nod] and here [look #m3] is the
465
+ proof.` / `Not a slide tool [shake], a presenter.` / `Then it clicked
466
+ [gesture: lightbulb].` / `Really? [brow up] Yes [smile].` A still or
467
+ captions-only presenter ignores them.
428
468
 
429
469
  | Cue | Effect |
430
470
  |---|---|
@@ -434,7 +474,12 @@ phone screenshots, the same `fix` bar.
434
474
  | `[pause: 0.5]` | a beat of silence (≤ 5 s) |
435
475
  | `[play: video]` / `[pause: video]` | play or pause the slide's video |
436
476
  | `[go: .list]` / `[go: slot]` | fly beside `.list` / back to the slot |
437
- | `[look: h2]` | look at the title |
477
+ | `[look: h2]` / `[look #card]` | look at the title / the card, then back |
478
+ | `[look viewer]` / `[look away]` | look back at the viewer / glance aside |
479
+ | `[nod]` / `[shake]` | a nod or head shake that suits the mood |
480
+ | `[brow up]` / `[brow down]` | raise or lower the brows |
481
+ | `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
482
+ | `[gesture: <id>]` | one of the head's tracks: `knowing-nod`, `ponder-tilt`, `lightbulb`, `side-eye-hmm`, `chuckle-bob`, `sunny-nod`, `double-take`… (check lists them all) |
438
483
  | `[custom: name]` | only the slide's controller sees it |
439
484
  - `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
440
485
  character voice (not a clone of anyone) any account may use, for decks that
@@ -502,7 +547,16 @@ an internal codename). Messages and what to do:
502
547
 
503
548
  ### 6. Look at it, at both widths
504
549
 
505
- Serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
550
+ To watch it as it will really play, run `hyperdecks play decks/<slug>`. It plays
551
+ the folder as it is, committed or not, with the real face and voice, on
552
+ 127.0.0.1 until Ctrl-C. It writes nothing, uploads nothing, creates no share and
553
+ spends nothing, so it plays the last narration: changed lines need `narrate`
554
+ first. It prints who presents, or why it is captions only. `--no-open` skips the
555
+ browser (an agent reads the URL; `--json` prints it as one line).
556
+ `release --qa` is not a preview: it needs a commit, and it changes what every
557
+ link to this deck plays.
558
+
559
+ For the layout pass, serve the folder (`npx serve decks/<slug>` or `python3 -m http.server -d decks/<slug>`)
506
560
  and screenshot it with Playwright at 1440×900 and 390×844, every slide
507
561
  (`#scene=<id>`). Look at both: nothing clipped, the presenter and its captions
508
562
  clear of the text, the mobile titles in use. Fix and repeat until both read well.
@@ -515,7 +569,8 @@ the deck's QA link on desktop and phone and list each problem as `fix` or
515
569
 
516
570
  Pick image models by job: `gen-image` defaults to GPT Image 2.5; `--model
517
571
  nano-banana-2.1` is stronger on typography and photoreal scenes. `gen-video`
518
- defaults to MiniMax H3 Max (`--model kling-v3` is the fallback). Use `chart` for
572
+ defaults to MiniMax H3 Max (`--model kling-v3` is the fallback); `--loop` and
573
+ `--transparent` work with both. Use `chart` for
519
574
  real numbers instead of a generated picture of a chart, `icons search` for
520
575
  real line icons, and `icons --style` with the deck's palette for an
521
576
  illustrated set that matches.
@@ -23,12 +23,20 @@ makes the hello deck; continue from step 5 below.
23
23
 
24
24
  1. `deck_guide` (this page), then `deck_guide { topic: "layouts" }` to pick slides.
25
25
  `get_brand` for the person's saved brand: write in its designMd's voice.
26
- None yet and they have a website? Offer to set one up: `capture_url` the
27
- site, read its colours, fonts and logo, show what you would save and
28
- `set_brand` once they agree (`deck_guide { topic: "design" }` lists the
29
- font pairings).
26
+ None yet? Offer to import it from what they have: `import_brand` with
27
+ their website (`url`), a PowerPoint or PDF they attach (`file`) or one of
28
+ their decks (`slug`). It reads the real colours, fonts and logo and returns
29
+ a `previewUrl` (a sample slide in that brand) and a `token`. Always give
30
+ them the preview link, say what it found (and its notes), and ask; only
31
+ after a yes call `set_brand { draft: token }`. Never save without the yes.
32
+ To change the brand in words ("less yellow", "serif titles"),
33
+ `revise_brand { instruction }` gives a draft and preview the same way.
34
+ Any brand change can be undone: `brand_history`, then `undo_brand`.
35
+ (`deck_guide { topic: "design" }` lists the font pairings.)
30
36
  2. `new_deck { slug, title, layouts }`: registers your draft and returns the
31
- full text of every file plus a `previewUrl` (the draft, untracked). It
37
+ full text of every file plus a `previewUrl` (the draft, untracked; once
38
+ narrated it plays with the face its release would have, so the person can
39
+ watch it before anything ships). It
32
40
  starts in the saved brand's colours, fonts and logo (`brand` in the result
33
41
  says what it took); `brand: false` gives the template's look.
34
42
  3. Images: `generate_image`, `render_chart`, `capture_url`, or
@@ -40,6 +48,12 @@ makes the hello deck; continue from step 5 below.
40
48
  you swap each for an `<img>` of its real file, or delete the hero's art.
41
49
  Icons: `search_icons { query }` returns real line icons as inline `<svg>`
42
50
  in `currentColor`; paste one where a features card has its icon.
51
+ For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
52
+ clip that repeats with no jump, `transparent: true` for a moving cut-out
53
+ over the slide. Paste the finished job's `markup` as is (a transparent
54
+ clip's `<video>` names a second file in `data-alpha-stacked`, which the
55
+ player shows in Safari and on iOS).
56
+ `deck_guide { topic: "design" }` says when each fits.
43
57
  4. `write_deck_source` to change a file: `edits: [{ find, replace }]` with
44
58
  exact text that occurs once (copy it from `get_deck_source`), or `content`
45
59
  to replace the file. Pass `baseRev` (the `rev` you last read) so a
@@ -55,11 +69,14 @@ makes the hello deck; continue from step 5 below.
55
69
  poll `check_narration_job` until `done`. The draft's player tag then has its
56
70
  `data-manifest`. Its `presenter` says who will present (`face: null` is
57
71
  captions only, `reason` says why): tell the person. No voice of their own
58
- yet? Pass `voice: "dash"`.
72
+ yet? Pass `voice: "dash"`. To direct the face, put a few cues in
73
+ script.md on the words that carry the point (`[nod]`, `[look #card]`,
74
+ `[smile]`, `[gesture: lightbulb]`; `deck_guide script` lists them) and
75
+ narrate again.
59
76
  8. `check_deck { slug }` again, then `review_deck { slug, draft: true }` and
60
- `check_review_job`: it shoots desktop and 390×844 phone frames, which is how
61
- you catch a slide that overflows a phone (the hosted check cannot measure
62
- layout).
77
+ `check_review_job`: it shoots desktop and 390×844 phone frames, face included,
78
+ which is how you catch a slide that overflows a phone (the hosted check cannot
79
+ measure layout).
63
80
  9. `release_deck { slug, qa: true }` gives the user a QA link to watch first.
64
81
  `release_deck { slug, recipients: [{ recipient, email }] }` makes one
65
82
  tracked link each. Release creates live links: confirm the recipients with
package/src/avatar.mjs CHANGED
@@ -86,6 +86,6 @@ export async function avatarConnect(opts, { log = console.log, interactive = int
86
86
  log("Waiting for you to pick a face…");
87
87
  const done = await ops.waitForConnect(opts, out.connectId);
88
88
  if (done.status !== "added") throw new Error(done.status === "denied" ? "not shared on minimoji.com" : done.status === "expired" ? "the code expired; run avatar connect again" : done.error || `the import ended (${done.status})`);
89
- log("✓ Presenter: your Minimoji. Your decks use it from the next release.");
89
+ log("✓ Presenter: your Minimoji. Your drafts show it now; share links get it from the next release.");
90
90
  return { status: "added", avatarId: done.avatarId };
91
91
  }
package/src/brandCli.mjs CHANGED
@@ -7,20 +7,26 @@
7
7
  // [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …]
8
8
  // [--logo <url|file>] [--no-logo] [--presenter-style s]
9
9
  // [--design <file.md|->] [--name "<company>"]
10
+ // [--draft <token>] (a brand import, saved after you saw its preview)
11
+ // brand import <url|file|slug> read a brand from a website, a .pptx / .potx /
12
+ // .pdf or one of your decks into a draft + preview
13
+ // brand revise "<change>" a draft + preview of the brand with that change
14
+ // brand history the last changes (from any surface)
15
+ // brand undo [<version>] undo the last change, or go back to a version
10
16
  // brand edit design.md in $VISUAL / $EDITOR, saved on exit
11
17
  // brand fonts the curated font pairings
12
18
  // brand clear forget the brand (new decks get the template look)
13
19
  import { spawnSync } from "node:child_process";
14
20
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
21
  import { tmpdir } from "node:os";
16
- import { extname, join } from "node:path";
22
+ import { basename, extname, join } from "node:path";
17
23
  import * as ops from "./ops.mjs";
18
24
  import { FONT_PAIRINGS } from "./brand.mjs";
19
25
 
20
26
  const LOGO_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml", ".avif": "image/avif" };
21
27
  const COLOR_FLAGS = { ink: "ink", paper: "paper", accent: "accent", "accent-2": "accent2", card: "card" };
22
28
  const FONT_FLAGS = ["display", "body", "mono"];
23
- const SET_USAGE = 'usage: brand set [--accent "#hex"] [--ink …] [--paper …] [--accent-2 …] [--card …] [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …] [--logo <url|file>] [--no-logo] [--presenter-style stage|bubble|glass|corner] [--design <file.md|->] [--name "<company>"]';
29
+ const SET_USAGE = 'usage: brand set [--draft <token>] [--accent "#hex"] [--ink …] [--paper …] [--accent-2 …] [--card …] [--fonts <pairing>] [--display "<family>"] [--body …] [--mono …] [--logo <url|file>] [--no-logo] [--presenter-style stage|bubble|glass|corner] [--design <file.md|->] [--name "<company>"]';
24
30
 
25
31
  export function brandLines(b) {
26
32
  if (!b) return ["no brand saved: new decks get the template look", " set one: hyperdecks brand set --accent \"#ff5a1f\" --fonts editorial --logo ./logo.svg"];
@@ -64,6 +70,7 @@ export function brandArgs(opts, { readFile = readFileSync, stdin = () => readFil
64
70
  }
65
71
  }
66
72
  if (opts["no-logo"]) args.clearLogo = true;
73
+ if (flag(opts, "draft") !== undefined) args.draft = flag(opts, "draft");
67
74
  return args;
68
75
  }
69
76
 
@@ -125,3 +132,80 @@ export async function brandClear(opts = {}, { log = (line) => console.log(line)
125
132
  log(opts.json ? JSON.stringify(out) : "✓ brand cleared; new decks get the template look");
126
133
  return out;
127
134
  }
135
+
136
+ // What `brand import` read, for the terminal.
137
+ export function importLines(d) {
138
+ const colors = Object.entries(d.colors ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
139
+ const fonts = Object.entries(d.fonts ?? {}).map(([k, v]) => `${k} ${v}`).join(", ");
140
+ return [
141
+ d.source?.kind === "revise" ? `revised: ${d.source.ref}` : `read from ${d.source?.kind} ${d.source?.ref}`,
142
+ ...(d.name ? [`name: ${d.name}`] : []),
143
+ `colours: ${colors || "(none found)"}`,
144
+ `fonts: ${fonts || "(none found)"}`,
145
+ `logo: ${d.logoUrl ?? "(none found)"}`,
146
+ ...(d.notes ?? []).map((n) => ` · ${n}`),
147
+ "",
148
+ `preview: ${d.previewUrl}`,
149
+ `Nothing is saved yet. To make it your brand: hyperdecks brand set --draft ${d.token}`,
150
+ ];
151
+ }
152
+
153
+ // brand import <url|file|slug>: an http(s) URL or a domain is a website, an
154
+ // existing path a file, anything else one of your deck slugs.
155
+ export async function brandImport(from, opts = {}, { log = (line) => console.log(line), readFile = readFileSync, exists = existsSync } = {}) {
156
+ const src = String(from ?? "").trim();
157
+ if (!src) throw new Error("usage: brand import <https://your.site | deck.pptx | brand.pdf | deck-slug> [--json]");
158
+ let args;
159
+ if (/^https?:\/\//i.test(src)) args = { url: src };
160
+ else if (exists(src)) args = { bytes: new Uint8Array(readFile(src)), fileName: basename(src) };
161
+ else if (/\.(pptx|potx|pdf)$/i.test(src)) throw new Error(`${src}: no such file`);
162
+ else if (/^[a-z0-9][a-z0-9-]*$/.test(src)) args = { slug: src };
163
+ else if (/^[\w-]+(\.[\w-]+)+(\/.*)?$/.test(src)) args = { url: `https://${src}` };
164
+ else throw new Error(`${src}: not a URL, a file or a deck slug`);
165
+ const d = await ops.importBrand(opts, args);
166
+ if (opts.json) log(JSON.stringify(d, null, 2));
167
+ else for (const line of importLines(d)) log(line);
168
+ return d;
169
+ }
170
+
171
+ // brand revise "<change>": the model's take on the brand, as a draft + preview.
172
+ export async function brandRevise(words, opts = {}, { log = (line) => console.log(line) } = {}) {
173
+ const instruction = (Array.isArray(words) ? words.join(" ") : String(words ?? "")).trim();
174
+ if (!instruction) throw new Error('usage: brand revise "<change>", e.g. brand revise "make the accent darker and use a serif for titles"');
175
+ const d = await ops.reviseBrand(opts, { instruction });
176
+ if (opts.json) log(JSON.stringify(d, null, 2));
177
+ else for (const line of importLines(d)) log(line);
178
+ return d;
179
+ }
180
+
181
+ const ago = (at, now = Date.now()) => {
182
+ const m = Math.round((now - at) / 60000);
183
+ if (m < 1) return "just now";
184
+ if (m < 60) return `${m} min ago`;
185
+ const h = Math.round(m / 60);
186
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`;
187
+ };
188
+
189
+ export function historyLines(versions, now = Date.now()) {
190
+ if (!versions.length) return ["no brand changes yet"];
191
+ return [
192
+ ...versions.map((v) => `${v.current ? "* " : " "}${v.label.padEnd(48)} ${ago(v.at, now).padEnd(12)} ${v.id}`),
193
+ "",
194
+ "hyperdecks brand undo undo the last change",
195
+ "hyperdecks brand undo <version> go back to one of these",
196
+ ];
197
+ }
198
+
199
+ export async function brandHistory(opts = {}, { log = (line) => console.log(line) } = {}) {
200
+ const out = await ops.brandHistory(opts);
201
+ if (opts.json) log(JSON.stringify(out, null, 2));
202
+ else for (const line of historyLines(out.versions)) log(line);
203
+ return out;
204
+ }
205
+
206
+ export async function brandUndo(version, opts = {}, { log = (line) => console.log(line) } = {}) {
207
+ const b = await ops.undoBrand(opts, { version });
208
+ if (opts.json) log(JSON.stringify(b, null, 2));
209
+ else for (const line of [version ? "✓ brand restored" : "✓ last change undone", ...brandLines(b)]) log(line);
210
+ return b;
211
+ }