demobite 0.0.1 → 1.0.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/README.md CHANGED
@@ -7,16 +7,26 @@ a fully editable demo: AI narration, zooms, cursor rendering, intro and outro,
7
7
  localization, all tweakable in the studio.
8
8
 
9
9
  ```bash
10
- npx demobite@latest
10
+ npx demobite
11
11
  ```
12
12
 
13
13
  That one command checks your setup, installs the recorder skill for
14
- [Claude Code](https://claude.com/claude-code), and connects your machine to
15
- DemoBites through your own browser no passwords in the terminal, ever.
14
+ your coding agent — [Claude Code](https://claude.com/claude-code), Cursor,
15
+ or Codexand wires the DemoBites
16
+ management MCP so your agent can also run your Update Center and Demo Center.
17
+ Sign-in happens through your own browser — no passwords in the terminal, ever.
16
18
  Then you just ask your agent:
17
19
 
18
20
  > "Record a demo of how search works on our app, and upload it to DemoBites."
19
21
 
22
+ > "Create a release with my latest bites and add Spanish."
23
+
24
+ Commands: `npx demobite` (full setup) · `npx demobite login` · `npx demobite mcp`
25
+ (register the management MCP with your agent) · `npx demobite logout`.
26
+ Anything public — publishing a release, taking a language offline — always
27
+ shows you a preview to approve first. Managing by agent requires the Grow plan;
28
+ recording works on every plan.
29
+
20
30
  The agent storyboards the flow, films it in a real Chrome, and stages the take
21
31
  for your approval inside DemoBites. You approve in the product; the platform
22
32
  does the rest.
@@ -26,7 +36,7 @@ does the rest.
26
36
  | Directory | What it is |
27
37
  |---|---|
28
38
  | `launcher/` | The `npx demobite` entry — environment checks, skill install, login |
29
- | `skill/` | The DemoBites recorder skill for Claude Code (staging, preview, approval flow) |
39
+ | `skill/` | The DemoBites recorder skill for coding agents (staging, preview, approval flow) |
30
40
  | `recorder/` | The open recorder — same filming engine, no account, ends at a polished `demo.mp4` |
31
41
  | `scripts/` | The shared engine: filming, clock calibration, cutting |
32
42
 
@@ -34,7 +44,7 @@ does the rest.
34
44
 
35
45
  The `recorder/` directory is a standalone skill: the same real-browser filming,
36
46
  hover-anchor clock calibration and camera work, delivering a finished, styled
37
- `demo.mp4` on your disk — no account, no upload. Point Claude Code at it and
47
+ `demo.mp4` on your disk — no account, no upload. Point your coding agent at it and
38
48
  film. When you want narration, zooms, an editable timeline and hosting, the
39
49
  sibling skill in `skill/` is one login away.
40
50
 
@@ -48,7 +58,7 @@ is cryptographically tied to a public commit in this repository.
48
58
  ## Requirements
49
59
 
50
60
  - Node 18+
51
- - [Claude Code](https://claude.com/claude-code) the recorder is agent-driven
61
+ - A coding agent — [Claude Code](https://claude.com/claude-code), Cursor, or Codex; the recorder is agent-driven
52
62
  - Google Chrome (recommended; films with the real browser) — otherwise
53
63
  Chromium is downloaded on first take
54
64
  - ffmpeg (`brew install ffmpeg` on macOS)
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
- // demobite — the DemoBites agentic recorder, one command away.
2
+ // demobite — connect DemoBites to your agent, one command away.
3
3
  //
4
- // npx demobite@latest install/update the skill + check your setup
4
+ // npx demobite@latest full setup: checks, recorder skill, MCP
5
5
  // npx demobite@latest login connect this machine to DemoBites
6
+ // npx demobite@latest mcp register the DemoBites management MCP
6
7
  // npx demobite@latest logout disconnect (revokes the key server-side)
7
8
  //
9
+ // One front door (founder 2026-08-31): the user never chooses between the
10
+ // recorder skill and the management MCP — bare `npx demobite` sets up both.
11
+ // The MCP is universal (every agent gets it); the skill is the bonus layer
12
+ // for code agents, and stays the advocated recording lane because the agent
13
+ // knows the customer's code. `agentic-recorder` remains a docs alias of bare.
14
+ //
8
15
  // This launcher is deliberately boring: it verifies the environment, installs
9
- // the recorder skill into your agent's skills directory, and hands off. The
10
- // recorder itself is driven by your coding agent (Claude Code): once set up,
11
- // you just ask it — "record a demo of how search works on our app".
16
+ // the recorder skill into your agent's skills directory, wires the MCP, and
17
+ // hands off. The recorder itself is driven by your coding agent (Claude
18
+ // Code): once set up, you just ask it — "record a demo of our search flow".
12
19
  import fs from "node:fs";
13
20
  import os from "node:os";
14
21
  import path from "node:path";
@@ -42,8 +49,8 @@ if (chromePaths.some((p) => fs.existsSync(p)) || hasBin("google-chrome")) ok("Go
42
49
  else warn("Google Chrome not found — the recorder will download Chromium on first take");
43
50
  if (hasBin("ffmpeg")) ok("ffmpeg");
44
51
  else warn("ffmpeg not found — install it (macOS: brew install ffmpeg) before recording");
45
- if (hasBin("claude")) ok("Claude Code (the agent that drives the recorder)");
46
- else warn("Claude Code not found — install it from https://claude.com/claude-code, the recorder is agent-driven");
52
+ if (hasBin("claude")) ok("Claude Code (drives the recorder; MCP registers automatically)");
53
+ else warn("Claude Code not found — using Cursor or Codex? They drive the recorder too; MCP setup prints below");
47
54
 
48
55
  // ── 2. Install / update the skill ──────────────────────────────────────────
49
56
  const skillsDir = path.join(os.homedir(), ".claude", "skills");
@@ -73,12 +80,72 @@ if (arg === "login" || arg === "logout") {
73
80
  process.exit(r.status ?? 0);
74
81
  }
75
82
 
83
+ // ── 3b. Management MCP ─────────────────────────────────────────────────────
84
+ // The key lives in <cwd>/.recorder/config.json (login.mjs writes it there),
85
+ // so MCP registration is per-project too — `claude mcp add` default (local)
86
+ // scope matches that exactly and keeps the key out of committable files.
87
+ const readCfg = () => {
88
+ try { return JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "config.json"), "utf8")); }
89
+ catch { return null; }
90
+ };
91
+ const registerMcp = (cfg, { quiet = false } = {}) => {
92
+ const url = `${cfg.base ?? "https://app.demobites.com"}/api/mcp`;
93
+ const header = `Authorization: Bearer ${cfg.api_key}`;
94
+ if (hasBin("claude")) {
95
+ const r = spawnSync("claude", ["mcp", "add", "--transport", "http", "demobites", url, "--header", header], {
96
+ stdio: quiet ? "ignore" : "inherit",
97
+ cwd: process.cwd(),
98
+ });
99
+ if (r.status === 0) { ok(`DemoBites MCP registered with Claude Code (${url})`); return true; }
100
+ }
101
+ if (!quiet) {
102
+ console.log(`
103
+ Add the DemoBites MCP to your agent manually — Streamable HTTP:
104
+
105
+ URL: ${url}
106
+ Header: ${header}
107
+
108
+ Claude Code: claude mcp add --transport http demobites ${url} --header "${header}"
109
+ Cursor: add the URL + header under Settings → MCP
110
+ Other MCP clients: any Streamable HTTP client works with the same URL + header.
111
+ `);
112
+ }
113
+ return false;
114
+ };
115
+
116
+ if (arg === "mcp") {
117
+ let cfg = readCfg();
118
+ if (!cfg?.api_key) {
119
+ console.log("\n Not connected yet — linking this machine to DemoBites first…\n");
120
+ const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs")], { stdio: "inherit", cwd: process.cwd() });
121
+ if (r.status !== 0) process.exit(r.status ?? 1);
122
+ cfg = readCfg();
123
+ }
124
+ if (!cfg?.api_key) { warn("Login did not complete — run: npx demobite login"); process.exit(1); }
125
+ registerMcp(cfg);
126
+ console.log(`
127
+ Your agent can now manage DemoBites — try asking it:
128
+
129
+ "Create a release with my latest bites and add Spanish"
130
+
131
+ Publishing always shows you a preview to approve first.
132
+ `);
133
+ process.exit(0);
134
+ }
135
+
76
136
  // ── 4. Handoff ─────────────────────────────────────────────────────────────
137
+ // Bare invocation (and the `agentic-recorder` docs alias): if this project is
138
+ // already linked, quietly wire the MCP too — one command, both magics.
139
+ const cfg = readCfg();
140
+ if (cfg?.api_key) registerMcp(cfg, { quiet: true });
77
141
  console.log(`
78
- Ready. The recorder is agent-driven — open Claude Code in your project and ask:
142
+ Ready. Everything is agent-driven — open your coding agent (Claude Code,
143
+ Cursor, Codex) in your project and ask:
79
144
 
80
145
  "Record a demo of <your flow> and upload it to DemoBites"
146
+ "Create a release with my latest bites and add Spanish"
81
147
 
82
148
  It signs in via your browser on first use (or run: npx demobite login).
149
+ Manage-by-agent needs the MCP: npx demobite mcp (once, after login).
83
150
  Only the recorder, no DemoBites? See the open recorder in this package's repo.
84
151
  `);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "demobite",
3
- "version": "0.0.1",
4
- "description": "The DemoBites agentic recorder you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
3
+ "version": "1.0.1",
4
+ "description": "The DemoBites agentic recorder \u2014 you prompt, it films a real browser, and DemoBites turns the take into an editable demo bite.",
5
5
  "bin": {
6
6
  "demobite": "launcher/index.mjs"
7
7
  },
@@ -27,12 +27,12 @@
27
27
  ],
28
28
  "repository": {
29
29
  "type": "git",
30
- "url": "git+https://github.com/demobites/demobite.git"
30
+ "url": "git+https://github.com/demobites/agentic-recorder.git"
31
31
  },
32
- "homepage": "https://github.com/demobites/demobite#readme",
32
+ "homepage": "https://github.com/demobites/agentic-recorder#readme",
33
33
  "bugs": {
34
- "url": "https://github.com/demobites/demobite/issues"
34
+ "url": "https://github.com/demobites/agentic-recorder/issues"
35
35
  },
36
36
  "license": "MIT",
37
37
  "type": "module"
38
- }
38
+ }
@@ -20,7 +20,7 @@ if (!outArg || !storyArg) {
20
20
  }
21
21
 
22
22
  const STORYBOARD = JSON.parse(fs.readFileSync(storyArg, "utf8"));
23
- const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover"]);
23
+ const ACTIONS = new Set(["goto", "settle", "scroll", "click", "hover", "type", "expect"]);
24
24
  if (!Array.isArray(STORYBOARD.steps) || STORYBOARD.steps.length === 0) {
25
25
  console.error("Storyboard has no steps.");
26
26
  process.exit(2);
@@ -31,6 +31,8 @@ for (const [i, s] of STORYBOARD.steps.entries()) {
31
31
  if (s.action === "goto" && !s.url) { console.error(`${at}: goto needs url`); process.exit(2); }
32
32
  if (s.action === "scroll" && typeof s.dy !== "number") { console.error(`${at}: scroll needs numeric dy`); process.exit(2); }
33
33
  if ((s.action === "click" || s.action === "hover") && !s.selector) { console.error(`${at}: ${s.action} needs selector`); process.exit(2); }
34
+ if (s.action === "type" && (!s.selector || typeof s.text !== "string")) { console.error(`${at}: type needs selector and text`); process.exit(2); }
35
+ if (s.action === "expect" && !s.selector) { console.error(`${at}: expect needs selector`); process.exit(2); }
34
36
  }
35
37
 
36
38
  const DIR = path.resolve(outArg);
@@ -486,17 +488,30 @@ async function glide(x, y) {
486
488
  pushMouse("move", x, y);
487
489
  }
488
490
 
489
- async function smoothScroll(dy, ms = 1400) {
490
- await page.evaluate(([d, m]) => new Promise((res) => {
491
- const y0 = window.scrollY, t0 = performance.now();
491
+ async function smoothScroll(dy, ms = 1400, within = null) {
492
+ // `within` (2026-08-28, UC-studio lesson): full-viewport apps scroll INNER
493
+ // panels, not the window — window.scrollTo is a no-op there. When set, it
494
+ // is a CSS selector; the first matching element that actually scrolls
495
+ // (scrollHeight meaningfully over clientHeight) is eased instead of the
496
+ // window. Same easing, same on-camera motion.
497
+ await page.evaluate(([d, m, sel]) => new Promise((res) => {
498
+ let target = null;
499
+ if (sel) {
500
+ target = [...document.querySelectorAll(sel)].find(
501
+ (el) => el.scrollHeight > el.clientHeight + 40,
502
+ ) ?? null;
503
+ }
504
+ const y0 = target ? target.scrollTop : window.scrollY;
505
+ const t0 = performance.now();
492
506
  const step = (now) => {
493
507
  const e = Math.min(1, (now - t0) / m);
494
508
  const ease = e < 0.5 ? 2 * e * e : 1 - Math.pow(-2 * e + 2, 2) / 2;
495
- window.scrollTo(0, y0 + d * ease);
509
+ if (target) target.scrollTo(0, y0 + d * ease);
510
+ else window.scrollTo(0, y0 + d * ease);
496
511
  e < 1 ? requestAnimationFrame(step) : res();
497
512
  };
498
513
  requestAnimationFrame(step);
499
- }), [dy, ms]);
514
+ }), [dy, ms, within]);
500
515
  }
501
516
 
502
517
  let failure = null;
@@ -518,11 +533,46 @@ try {
518
533
  await ensureCursor();
519
534
  await applyHideCss();
520
535
  if (manifest.record_from === undefined) {
521
- // LAW (first-frame, founder 2026-08-08): the published cut opens on a
522
- // FULLY loaded page wait for network quiet (best effort, LinkedIn
523
- // style long-pollers never go fully idle) plus a short beat, THEN
524
- // stamp record_from: where the final video begins.
536
+ // LAW (first-frame, founder 2026-08-08, HARDENED 2026-08-13 after a
537
+ // take opened on a white page): the published cut opens on a FULLY
538
+ // loaded page. Network quiet is NOT paint client-rendered apps go
539
+ // networkidle while the screen is still blank. Three gates in order:
540
+ // 1. network quiet (best effort — long-pollers never go idle)
541
+ // 2. PAINT GATE: poll until the page shows real content (visible
542
+ // text mass or media elements), up to 15s
543
+ // 3. if the NEXT storyboard step is an `expect`, resolve it HERE,
544
+ // before the stamp — the cut then provably opens on the state
545
+ // the story assumes (the expect step later re-checks instantly)
546
+ // Only then stamp record_from: where the final video begins.
525
547
  await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
548
+ const paintDeadline = Date.now() + 15000;
549
+ let painted = false;
550
+ while (Date.now() < paintDeadline) {
551
+ painted = await page.evaluate(() => {
552
+ const textMass = (document.body?.innerText ?? "").trim().length;
553
+ const media = document.querySelectorAll("img, video, canvas, svg").length;
554
+ let visible = 0;
555
+ for (const el of document.querySelectorAll("div, section, main, button")) {
556
+ const r = el.getBoundingClientRect();
557
+ if (r.width > 40 && r.height > 20) { visible++; if (visible > 8) break; }
558
+ }
559
+ return (textMass > 80 || media >= 3) && visible > 8;
560
+ }).catch(() => false);
561
+ if (painted) break;
562
+ await page.waitForTimeout(250);
563
+ }
564
+ if (!painted) {
565
+ console.log("PAINT GATE: page never showed real content within 15s — the take would open on a blank frame. Judge the footage carefully.");
566
+ }
567
+ const nextStep = STORYBOARD.steps[STORYBOARD.steps.indexOf(step) + 1];
568
+ if (nextStep?.action === "expect" && nextStep.selector) {
569
+ const sel = nextStep.text
570
+ ? `${nextStep.selector}:has-text(${JSON.stringify(nextStep.text)})`
571
+ : nextStep.selector;
572
+ await page.locator(sel).first().waitFor({ state: "visible", timeout: nextStep.timeout ?? 15000 }).catch(() => {
573
+ console.log(`PAINT GATE: pre-stamp expect "${sel}" not visible — stamping anyway; the expect step will abort the take.`);
574
+ });
575
+ }
526
576
  await page.waitForTimeout(600);
527
577
  manifest.record_from = t();
528
578
  }
@@ -539,7 +589,7 @@ try {
539
589
  } else if (step.action === "scroll") {
540
590
  // scrollTo distances are ALSO zoomed-space under CSS zoom (measured:
541
591
  // scrollTo(0,600) moves 300 design px) — storyboards speak design px.
542
- await smoothScroll(step.dy * SUPERSAMPLE, step.ms ?? 1400);
592
+ await smoothScroll(step.dy * SUPERSAMPLE, step.ms ?? 1400, step.within ?? null);
543
593
  } else if (step.action === "click" || step.action === "hover") {
544
594
  const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
545
595
  if (!box) throw new Error("no visible target for " + step.selector);
@@ -551,7 +601,12 @@ try {
551
601
  rec.slow_content_ms = visibleTarget.lastWaitMs;
552
602
  console.log(`SLOW CONTENT: step ${rec.n} waited ${(visibleTarget.lastWaitMs / 1000).toFixed(1)}s for its target — content was loading on camera. Judge the take; prefer a retake.`);
553
603
  }
554
- const x = box.x + box.width / 2, y = box.y + box.height / 2;
604
+ // Optional fractional click point within the target box (step.at =
605
+ // [fx, fy], each 0..1, default center). Lets a click SET a value on
606
+ // track-style controls (sliders) without needing drag support.
607
+ const atFx = Array.isArray(step.at) ? Math.min(1, Math.max(0, step.at[0] ?? 0.5)) : 0.5;
608
+ const atFy = Array.isArray(step.at) ? Math.min(1, Math.max(0, step.at[1] ?? 0.5)) : 0.5;
609
+ const x = box.x + box.width * atFx, y = box.y + box.height * atFy;
555
610
  // Resolved BEFORE the glide so the answer costs no video time.
556
611
  const targetCursor = await cursorForElement(el);
557
612
  const shotStart = t();
@@ -673,6 +728,72 @@ try {
673
728
  }
674
729
  }
675
730
  }
731
+ } else if (step.action === "expect") {
732
+ // PRECONDITION GATE (brand-kit incident, 2026-08-11): assert the page
733
+ // is what the story assumes BEFORE any mutating click. Costs no camera
734
+ // time — no cursor movement, no shot. The take FAILS LOUDLY here if
735
+ // the state is wrong (wrong workspace, wrong account, wrong screen),
736
+ // instead of filming a confident mistake.
737
+ const sel = step.text
738
+ ? `${step.selector}:has-text(${JSON.stringify(step.text)})`
739
+ : step.selector;
740
+ try {
741
+ await page.locator(sel).first().waitFor({ state: "visible", timeout: step.timeout ?? 8000 });
742
+ } catch {
743
+ throw new Error(
744
+ `EXPECT FAILED at step ${rec.n}: "${sel}" not visible — the page is not what the storyboard assumes. Take aborted before any further action.`
745
+ );
746
+ }
747
+ } else if (step.action === "type") {
748
+ // Click the field, then type with a human cadence. Optional step.clear
749
+ // selects-all + deletes first; optional step.enter presses Enter after.
750
+ const { box, el } = await visibleTarget(step.selector, step.minY ?? 0);
751
+ if (!box) throw new Error("no visible target for " + step.selector);
752
+ const x = box.x + box.width / 2, y = box.y + box.height / 2;
753
+ const targetCursor = await cursorForElement(el);
754
+ const shotStart = t();
755
+ currentCursor = "arrow";
756
+ await glide(x, y);
757
+ const arrivalT = t();
758
+ currentCursor = targetCursor;
759
+ rec.cursor = currentCursor;
760
+ pushMouse("move", x, y);
761
+ rec.target = {
762
+ selector: step.selector,
763
+ x: Math.round(x / SUPERSAMPLE), y: Math.round(y / SUPERSAMPLE),
764
+ bbox: {
765
+ x: Math.round(box.x / SUPERSAMPLE), y: Math.round(box.y / SUPERSAMPLE),
766
+ w: Math.round(box.width / SUPERSAMPLE), h: Math.round(box.height / SUPERSAMPLE),
767
+ },
768
+ };
769
+ await page.waitForTimeout(step.dwell ?? DEFAULT_CLICK_DWELL);
770
+ await page.evaluate(([a, b]) => window.__recPulse?.(a, b), [x, y]);
771
+ await page.waitForTimeout(220);
772
+ rec.click_at = t();
773
+ pushMouse("click", x, y, "left");
774
+ await page.mouse.click(x, y);
775
+ await page.waitForTimeout(380);
776
+ if (step.clear) {
777
+ await page.keyboard.press(process.platform === "darwin" ? "Meta+a" : "Control+a");
778
+ await page.waitForTimeout(180);
779
+ await page.keyboard.press("Backspace");
780
+ await page.waitForTimeout(280);
781
+ }
782
+ const text = String(step.text);
783
+ rec.typed = text;
784
+ for (const ch of text) {
785
+ await page.keyboard.type(ch);
786
+ await page.waitForTimeout(34 + Math.random() * 70);
787
+ }
788
+ if (step.enter) {
789
+ await page.waitForTimeout(300);
790
+ await page.keyboard.press("Enter");
791
+ }
792
+ // One shot: the field, from arrival through the typing.
793
+ pushShot(box, Math.max(shotStart, arrivalT - 0.3), t() + 0.3, step.label ?? "type", { n: rec.n, glide: { t_start: Math.round(shotStart * 100) / 100, t_end: Math.round(arrivalT * 100) / 100 } });
794
+ currentCursor = await cursorUnderPoint();
795
+ pushMouse("move", cx, cy);
796
+ await page.waitForTimeout(step.after ?? DEFAULT_CLICK_AFTER);
676
797
  }
677
798
  rec.t_end = t();
678
799
  manifest.steps.push(rec);
package/scripts/trim.mjs CHANGED
@@ -69,4 +69,20 @@ execFileSync("ffmpeg", [
69
69
  const dur = execFileSync("ffprobe", [
70
70
  "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
71
71
  ]).toString().trim();
72
+ const duration = parseFloat(dur);
73
+ // LAW (founder): a Bite is capped at 90 seconds by product concept. A take
74
+ // that cuts longer than that must not proceed to staging — the story needs a
75
+ // SPLIT, not a trim. Fail loudly here, before any upload machinery runs.
76
+ if (duration > 90) {
77
+ console.error(
78
+ `HARD CAP: this take cuts to ${duration.toFixed(1)}s — a Bite is limited to 90 seconds. ` +
79
+ "Do not upload. Split the scenario into multiple Bites and refilm."
80
+ );
81
+ process.exit(1);
82
+ }
83
+ if (duration > 60) {
84
+ console.error(
85
+ `Note: ${duration.toFixed(1)}s is long for a Bite — the sweet spot is 30 to 45 seconds. Consider tightening.`
86
+ );
87
+ }
72
88
  console.log(`clean.mp4 ready in ${dir} (trimmed ${r0.toFixed ? r0.toFixed(2) : r0}s from the head), duration ${parseFloat(dur).toFixed(2)}s`);
package/skill/SKILL.md CHANGED
@@ -53,7 +53,7 @@ Look for `.recorder/config.json` next to the project. If it exists, use it and a
53
53
  - **app**: the product's name as it should appear in titles.
54
54
  - **url**: the starting URL of the flow.
55
55
  - **frame**: fixed at 1920x1080 for now, do not ask, just record it.
56
- - **base**: defaults to `https://dev.demobites.com`, only ask if the human mentions a different environment.
56
+ - **base**: defaults to `https://app.demobites.com`, only ask if the human mentions a different environment.
57
57
 
58
58
  Write the answers to `.recorder/config.json` and never ask again:
59
59
 
@@ -62,7 +62,7 @@ Write the answers to `.recorder/config.json` and never ask again:
62
62
  "app": "Acme",
63
63
  "url": "https://app.acme.com",
64
64
  "frame": { "width": 1920, "height": 1080 },
65
- "base": "https://dev.demobites.com"
65
+ "base": "https://app.demobites.com"
66
66
  }
67
67
  ```
68
68
 
@@ -91,6 +91,12 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
91
91
  **Budgets, hold yourself to them:**
92
92
 
93
93
  - **30 to 45 seconds total.** Over 45 is a rewrite, not a trim.
94
+ - **90 seconds is a HARD CAP, by product concept, and it is a limit, not a
95
+ target.** A Bite over 90 seconds does not exist. Budget the beats BEFORE
96
+ filming: if the human's scenario cannot honestly fit inside 90 seconds, do
97
+ not film-and-trim and do not compress it into uselessness — tell them up
98
+ front and ask them to SPLIT it into multiple Bites, one story per Bite.
99
+ State the estimated duration in every storyboard presentation.
94
100
  - **6 to 10 beats.** More than that and nothing gets seen.
95
101
  - **8 to 14 words per narration line.** Short beats one long one, every time.
96
102
  - Trust the defaults in `record.mjs` (`DEFAULT_HOVER_DWELL` 3.2s, `DEFAULT_CLICK_AFTER` 2.0s). Only override when the app itself is slow.
@@ -279,7 +285,7 @@ GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
279
285
  -> { status, title, durationSec, zooms, narrationTotal, narrationReady, transcription }
280
286
  ```
281
287
 
282
- The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`. Nothing else goes in the zip. Default base is `https://dev.demobites.com`, overridable via `config.base`.
288
+ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`. Nothing else goes in the zip. Default base is `https://app.demobites.com`, overridable via `config.base`.
283
289
 
284
290
  ## Standing rules
285
291
 
@@ -15,7 +15,7 @@
15
15
  // node login.mjs --logout revoke the key server-side AND strip it
16
16
  // from local config — a leaked config file
17
17
  // dies with the logout.
18
- // Base resolution: CLI arg, config.base, https://dev.demobites.com.
18
+ // Base resolution: CLI arg, config.base, https://app.demobites.com.
19
19
  import fs from "node:fs";
20
20
  import path from "node:path";
21
21
  import { spawnSync } from "node:child_process";
@@ -33,8 +33,8 @@ const wantLogout = process.argv.includes("--logout");
33
33
  // CLI base argument must not point the revoke at a different host (review
34
34
  // finding — the real key would stay live while we claim success).
35
35
  const base = (wantLogout
36
- ? (cfg.base || argv[0] || "https://dev.demobites.com")
37
- : (argv[0] || cfg.base || "https://dev.demobites.com")
36
+ ? (cfg.base || argv[0] || "https://app.demobites.com")
37
+ : (argv[0] || cfg.base || "https://app.demobites.com")
38
38
  ).replace(/\/+$/, "");
39
39
 
40
40
  if (wantLogout) {
@@ -126,6 +126,9 @@ while (Date.now() < deadline) {
126
126
  cfg.base = base;
127
127
  cfg.api_key = data.api_key;
128
128
  cfg.workspace = data.workspace;
129
+ // Capability scopes (MCP engine 2026-08-31): "manage" rides only on
130
+ // Grow-approved keys and unlocks `npx demobite mcp`.
131
+ cfg.scopes = Array.isArray(data.scopes) && data.scopes.length ? data.scopes : ["record"];
129
132
  fs.mkdirSync(cfgDir, { recursive: true });
130
133
  fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
131
134
  fs.chmodSync(cfgPath, 0o600);
@@ -133,7 +136,11 @@ while (Date.now() < deadline) {
133
136
  data.workspace && typeof data.workspace === "object"
134
137
  ? data.workspace.name ?? data.workspace.slug ?? JSON.stringify(data.workspace)
135
138
  : data.workspace;
139
+ const canManage = cfg.scopes.includes("manage");
136
140
  console.log(`Approved. Recorder key saved to .recorder/config.json (workspace: ${wsName}).`);
141
+ console.log(canManage
142
+ ? "This key can also MANAGE your centers — wire your agent with: npx demobite mcp"
143
+ : "This key records only. Managing by agent (MCP) comes with the Grow plan.");
137
144
  process.exit(0);
138
145
  }
139
146
  // denied / expired / consumed — terminal states. The wording matters:
@@ -167,15 +167,23 @@ await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
167
167
  await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
168
168
 
169
169
  // ── open the in-app preview — the review happens THERE ─────────────────────
170
+ // Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
171
+ // review sprint, auto-opening a tab per take is spam. `--no-open` (or
172
+ // config.open_preview === false) stages silently — the queue pill and the
173
+ // printed URL carry the message. Default stays open: for a single take the
174
+ // opened page IS the consent moment.
170
175
  const pageUrl = new URL(previewUrl, base).toString();
171
176
  console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
172
177
  if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
173
178
  console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
174
179
  }
175
- try {
176
- const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
177
- spawnSync(opener, [pageUrl], { stdio: "ignore" });
178
- } catch { /* printing the URL above is the fallback */ }
180
+ const noOpen = process.argv.includes("--no-open") || cfg.open_preview === false;
181
+ if (!noOpen) {
182
+ try {
183
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
184
+ spawnSync(opener, [pageUrl], { stdio: "ignore" });
185
+ } catch { /* printing the URL above is the fallback */ }
186
+ }
179
187
 
180
188
  // ── poll while the human decides, then until the bite is READY ─────────────
181
189
  // LAW (founder 2026-08-08): never hand a human a studio link before the bite