@gtanczyk/diorama 0.4.0 → 0.4.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.
@@ -8,6 +8,8 @@ import { soundQuality } from '../sound/cost.ts';
8
8
  import { writeSoundQuality } from '../sound/settings.ts';
9
9
  import { blenderEnv, blenderState, writeBlenderSettings } from '../model/blender.ts';
10
10
  import { facesState, writeFacesSettings } from '../scene/faces-settings.ts';
11
+ import { installChromium } from '../commands/browsers.ts';
12
+ import { cli } from '../cli.ts';
11
13
 
12
14
  const audioState = () => ({ quality: soundQuality(), env: process.env.DIORAMA_SOUND ?? null });
13
15
 
@@ -87,6 +89,29 @@ defineAction({
87
89
  },
88
90
  });
89
91
 
92
+ defineAction({
93
+ name: 'setup_cli',
94
+ reads: true,
95
+ description: 'The command that runs diorama in a terminal on this machine (diorama, npx @gtanczyk/diorama or npm run diorama --), for hints to the person.',
96
+ input: z.object({}),
97
+ async run() {
98
+ return { command: cli(), setup: cli('setup') };
99
+ },
100
+ });
101
+
102
+ let downloading: Promise<unknown> | null = null;
103
+
104
+ defineAction({
105
+ name: 'setup_browsers',
106
+ description: 'Download Chromium for Explore, the playthrough bot and sprites (Playwright, ~150 MB, once), then check the machine again. Ask the person first.',
107
+ input: z.object({}),
108
+ async run(_input, ctx) {
109
+ downloading ??= installChromium([], (line) => /\d%/.test(line) && !/100%/.test(line) ? undefined : ctx.events.publish('log', { stream: 'out', line: `chromium: ${line}` })).finally(() => (downloading = null));
110
+ await downloading;
111
+ return runChecks();
112
+ },
113
+ });
114
+
90
115
  defineAction({
91
116
  name: 'ui_focus',
92
117
  description: 'Point the Studio view at a world, a screen or a place, so the person sees what the agent is talking about (when they follow the agent).',
package/app/src/cli.ts ADDED
@@ -0,0 +1,53 @@
1
+ // How to run diorama again from this shell, so every hint (setup fixes, errors, the Studio's Setup cards, the
2
+ // agent's prompt) shows a command that works: `npx @gtanczyk/diorama` when it was started through npx, `diorama`
3
+ // when the global install is on PATH, `npm run diorama --` from a checkout.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
9
+ const BIN = path.join(ROOT, 'app', 'bin', 'diorama.mjs');
10
+ const PACKAGE = '@gtanczyk/diorama';
11
+
12
+ const real = (p: string) => {
13
+ try {
14
+ return fs.realpathSync(p);
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
19
+
20
+ /** A `diorama` on PATH that is this very package (npx and npm scripts put their own node_modules/.bin first: not those). */
21
+ function globalOnPath(): boolean {
22
+ const mine = real(BIN);
23
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
24
+ if (!dir || dir.split(/[\\/]/).slice(-2).join('/') === 'node_modules/.bin') continue;
25
+ if (real(path.join(dir, 'diorama')) === mine) return true;
26
+ // Windows: npm's global shim sits next to the package's node_modules folder, not as a link.
27
+ if (process.platform === 'win32' && fs.existsSync(path.join(dir, 'diorama.cmd')) && real(path.join(dir, 'node_modules', '@gtanczyk', 'diorama')) === real(ROOT)) return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ function detect(): string {
33
+ if (process.env.DIORAMA_CLI) return process.env.DIORAMA_CLI;
34
+ const parts = ROOT.split(/[\\/]/);
35
+ if (parts.includes('_npx')) return `npx ${PACKAGE}`;
36
+ if (globalOnPath()) return 'diorama';
37
+ if (!parts.includes('node_modules') && fs.existsSync(path.join(ROOT, '.git'))) return 'npm run diorama --';
38
+ return `npx ${PACKAGE}`;
39
+ }
40
+
41
+ let prefix: string | null = null;
42
+
43
+ /** The command to type for `diorama <rest>` here, e.g. cli('browsers') → `npx @gtanczyk/diorama browsers`. */
44
+ export function cli(rest = ''): string {
45
+ prefix ??= detect();
46
+ return rest ? `${prefix} ${rest}` : prefix;
47
+ }
48
+
49
+ /** Rewrites the `diorama <command>` a message names (a usage line, an error) into the command that works here. */
50
+ export function cliText(text: string): string {
51
+ if (cli() === 'diorama') return text;
52
+ return text.replace(/(^|usage: |`|run )diorama (?=[a-z])/g, (_, lead: string) => `${lead}${cli()} `);
53
+ }
@@ -1,21 +1,37 @@
1
1
  // diorama browsers: downloads Chromium for the Playwright that ships with diorama, so explore, playthrough and
2
2
  // sprites work the same from a checkout, a global install or npx (a bare `npx playwright` may fetch another version).
3
+ // `diorama setup` and the Studio's Setup page offer the same download, so nobody has to find this command.
3
4
  import { spawn } from 'node:child_process';
4
5
  import { createRequire } from 'node:module';
5
6
  import path from 'node:path';
6
7
  import { defineCommand } from '../registry.ts';
7
8
 
9
+ /** Runs Playwright's installer for Chromium; `onLine` gets its output (the terminal gets it as is when absent). */
10
+ export async function installChromium(args: string[] = [], onLine?: (line: string) => void): Promise<void> {
11
+ const cli = path.join(path.dirname(createRequire(import.meta.url).resolve('playwright/package.json')), 'cli.js');
12
+ const code = await new Promise<number>((resolve) => {
13
+ // Loaded through -e so argv[1] is not the npx cache path, which makes Playwright warn about missing project dependencies.
14
+ const child = spawn(process.execPath, ['-e', `require(${JSON.stringify(cli)})`, 'diorama-browsers', 'install', 'chromium', ...args], { stdio: onLine ? ['ignore', 'pipe', 'pipe'] : 'inherit' });
15
+ if (onLine) {
16
+ for (const stream of [child.stdout, child.stderr]) {
17
+ let rest = '';
18
+ stream?.setEncoding('utf8').on('data', (chunk: string) => {
19
+ const lines = (rest + chunk).split(/\r?\n|\r/);
20
+ rest = lines.pop() ?? '';
21
+ for (const line of lines) if (line.trim()) onLine(line);
22
+ });
23
+ }
24
+ }
25
+ child.on('error', () => resolve(1));
26
+ child.on('close', (c) => resolve(c ?? 1));
27
+ });
28
+ if (code !== 0) throw new Error(`playwright install chromium exited with ${code}`);
29
+ }
30
+
8
31
  defineCommand({
9
32
  name: 'browsers',
10
33
  usage: 'browsers download Chromium for explore, playthrough and sprites (Playwright, ~150 MB, once)',
11
34
  async run(args) {
12
- const cli = path.join(path.dirname(createRequire(import.meta.url).resolve('playwright/package.json')), 'cli.js');
13
- const code = await new Promise<number>((resolve) => {
14
- // Loaded through -e so argv[1] is not the npx cache path, which makes Playwright warn about missing project dependencies.
15
- const child = spawn(process.execPath, ['-e', `require(${JSON.stringify(cli)})`, 'diorama-browsers', 'install', 'chromium', ...args], { stdio: 'inherit' });
16
- child.on('error', () => resolve(1));
17
- child.on('close', (c) => resolve(c ?? 1));
18
- });
19
- if (code !== 0) throw new Error(`playwright install chromium exited with ${code}`);
35
+ await installChromium(args);
20
36
  },
21
37
  });
@@ -9,6 +9,7 @@ import { CROP_JS, PROBE_JS, type ProbeHit } from '../qa/probe.ts';
9
9
  import { defineCommand, takeOption } from '../registry.ts';
10
10
  import { WorldFiles } from '../world/files.ts';
11
11
  import { serveWorld } from './play.ts';
12
+ import { cli } from '../cli.ts';
12
13
 
13
14
  export type Browser = { newPage(o?: object): Promise<Page>; close(): Promise<void> };
14
15
  export type Page = {
@@ -26,7 +27,7 @@ export async function chromium(): Promise<{ launch(o?: object): Promise<Browser>
26
27
  try {
27
28
  return (createRequire(import.meta.url)('playwright') as { chromium: never }).chromium;
28
29
  } catch {
29
- throw new Error('this command needs Playwright with Chromium: diorama browsers');
30
+ throw new Error(`this command needs Playwright with Chromium: ${cli('browsers')}`);
30
31
  }
31
32
  }
32
33
  }
@@ -2,6 +2,8 @@
2
2
  import { defineCommand, takeOption } from '../registry.ts';
3
3
  import { findVault, NO_VAULT, SECRET_NAMES, SECRETS, SecretStore, type SecretName } from '../secrets.ts';
4
4
  import { runChecks, type Check } from '../setup/checks.ts';
5
+ import { installChromium } from './browsers.ts';
6
+ import { cli } from '../cli.ts';
5
7
 
6
8
  const MARK: Record<Check['state'], string> = { ok: ' ok ', missing: ' MISSING ', optional: ' - ', fail: ' FAIL ' };
7
9
 
@@ -15,7 +17,7 @@ export function printChecks(checks: Check[]) {
15
17
  /** Reads a line without echoing it. Only from a terminal: a pasted key never lands in shell history or argv. */
16
18
  function askHidden(question: string): Promise<string> {
17
19
  const stdin = process.stdin;
18
- if (!stdin.isTTY) throw new Error('diorama setup reads keys from a terminal only; or set them as environment variables');
20
+ if (!stdin.isTTY) throw new Error(`${cli('setup')} reads keys from a terminal only; or set them as environment variables`);
19
21
  process.stdout.write(question);
20
22
  return new Promise((resolve, reject) => {
21
23
  let value = '';
@@ -42,6 +44,17 @@ function askHidden(question: string): Promise<string> {
42
44
  });
43
45
  }
44
46
 
47
+ /** A yes/no question in the terminal; Enter means yes. */
48
+ async function askYes(question: string): Promise<boolean> {
49
+ const { createInterface } = await import('node:readline/promises');
50
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
51
+ try {
52
+ return !/^n/i.test((await rl.question(question)).trim());
53
+ } finally {
54
+ rl.close();
55
+ }
56
+ }
57
+
45
58
  async function storeKey(name: SecretName, remove: boolean) {
46
59
  const vault = await findVault();
47
60
  if (!vault) throw new Error(NO_VAULT);
@@ -79,6 +92,17 @@ defineCommand({
79
92
  console.log('');
80
93
  printChecks(checks);
81
94
  }
95
+ // Chromium is one download: offer it here instead of sending the person off to find another command.
96
+ if (process.stdin.isTTY && checks.some((c) => c.id === 'chromium' && c.state === 'missing') && (await askYes('\nDownload Chromium for Explore and Playthrough now (~150 MB, once)? [Y/n] '))) {
97
+ try {
98
+ await installChromium();
99
+ } catch (error) {
100
+ console.log(`Chromium download failed (${error instanceof Error ? error.message : error}); try again with ${cli('browsers')}`);
101
+ }
102
+ checks = await runChecks({ live });
103
+ console.log('');
104
+ printChecks(checks);
105
+ }
82
106
  const blocking = checks.filter((c) => c.state === 'missing' || c.state === 'fail');
83
107
  console.log(blocking.length ? `\n${blocking.length} to fix before a full build: ${blocking.map((c) => c.label).join(', ')}` : '\nready to build');
84
108
  },
package/app/src/main.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import './commands/index.ts';
2
2
  import { registry } from './registry.ts';
3
3
  import { loadSecrets, redact } from './secrets.ts';
4
+ import { cli, cliText } from './cli.ts';
4
5
 
5
6
  function help() {
6
- console.log('diorama: a story prompt becomes painted scenes you can walk in\n\nusage: diorama <command> [args]\n');
7
+ console.log(`diorama: a story prompt becomes painted scenes you can walk in\n\nusage: ${cli('<command> [args]')}\n`);
7
8
  for (const cmd of registry.values()) console.log(` ${cmd.usage}`);
8
- console.log('\nfirst time: diorama setup (keys, what is missing), then diorama studio. Worlds are written to ./worlds here.');
9
+ console.log(`\nfirst time: ${cli('setup')} (keys, what is missing), then ${cli('studio')}. Worlds are written to ./worlds here.`);
9
10
  }
10
11
 
11
12
  const [name, ...args] = process.argv.slice(2);
@@ -15,7 +16,7 @@ if (!cmd) {
15
16
  process.exit(!name || name === 'help' ? 0 : 2);
16
17
  }
17
18
  if (args.includes('--help') || args.includes('-h')) {
18
- console.log(`usage: diorama ${cmd.usage}`);
19
+ console.log(`usage: ${cli(cmd.usage)}`);
19
20
  process.exit(0);
20
21
  }
21
22
  try {
@@ -23,6 +24,6 @@ try {
23
24
  if (name !== 'setup') await loadSecrets();
24
25
  await cmd.run(args);
25
26
  } catch (error) {
26
- console.error(redact(`diorama ${name}: ${error instanceof Error ? error.message : error}`));
27
+ console.error(redact(`diorama ${name}: ${cliText(error instanceof Error ? error.message : String(error))}`));
27
28
  process.exit(1);
28
29
  }
@@ -7,6 +7,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
+ import { cli } from './cli.ts';
10
11
 
11
12
  /** The keys diorama knows how to keep, with the other names each one is accepted under. */
12
13
  export const SECRETS = {
@@ -138,7 +139,7 @@ export class SecretStore {
138
139
  async read(): Promise<Record<string, string>> {
139
140
  if (!fs.existsSync(this.file)) return {};
140
141
  const key = await this.vault.get();
141
- if (!key) throw new Error(`credentials.bin exists but its key is missing from the ${this.vault.name}; run diorama setup to store your keys again`);
142
+ if (!key) throw new Error(`credentials.bin exists but its key is missing from the ${this.vault.name}; run ${cli('setup')} to store your keys again`);
142
143
  return open(key, fs.readFileSync(this.file));
143
144
  }
144
145
 
@@ -5,6 +5,7 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { blenderState } from '../model/blender.ts';
7
7
  import { envValue, findVault, loadSecrets, NO_VAULT, type SecretName } from '../secrets.ts';
8
+ import { cli } from '../cli.ts';
8
9
 
9
10
  export type Check = {
10
11
  id: string;
@@ -22,7 +23,7 @@ function sh(cmd: string, args: string[], timeout = 8000): Promise<string | null>
22
23
 
23
24
  async function keyCheck(name: SecretName, label: string, required: boolean, what: string, source?: 'env' | 'store'): Promise<Check> {
24
25
  const value = envValue(name);
25
- if (!value) return { id: name, label, state: required ? 'missing' : 'optional', detail: what, fix: [`diorama setup --key ${name}`] };
26
+ if (!value) return { id: name, label, state: required ? 'missing' : 'optional', detail: what, fix: [cli(`setup --key ${name}`)] };
26
27
  return { id: name, label, state: 'ok', detail: source === 'store' ? 'in your system keychain' : 'from the environment' };
27
28
  }
28
29
 
@@ -78,7 +79,7 @@ export async function runChecks(opts: { live?: boolean } = {}): Promise<Check[]>
78
79
  } catch {}
79
80
  checks.push(chromium && fs.existsSync(chromium)
80
81
  ? { id: 'chromium', label: 'Chromium for Explore and Playthrough', state: 'ok', detail: 'installed' }
81
- : { id: 'chromium', label: 'Chromium for Explore and Playthrough', state: 'missing', detail: 'Explore and the playthrough bot drive a browser.', fix: ['diorama browsers'] });
82
+ : { id: 'chromium', label: 'Chromium for Explore and Playthrough', state: 'missing', detail: 'Explore and the playthrough bot drive a browser.', fix: [cli('browsers')] });
82
83
 
83
84
  checks.push(await keyCheck('ELEVENLABS_API_KEY', 'ElevenLabs key', false, 'Optional: sound effects, music and voices. Without it the world plays silent.', sources.ELEVENLABS_API_KEY));
84
85
  checks.push(await keyCheck('HF_TOKEN', 'Hugging Face token', false, 'Optional: 3D hero figures (TRELLIS, auto-rig) on ZeroGPU Spaces.', sources.HF_TOKEN));
@@ -103,8 +104,8 @@ export async function runChecks(opts: { live?: boolean } = {}): Promise<Check[]>
103
104
 
104
105
  const remote = await sh('git', ['remote', 'get-url', 'origin']);
105
106
  checks.push(remote
106
- ? { id: 'publish', label: 'Publishing', state: 'ok', detail: `git remote ${remote.replace(/\/\/[^@/]+@/, '//')}; diorama publish pushes to gh-pages` }
107
- : { id: 'publish', label: 'Publishing', state: 'optional', detail: 'No git remote: diorama export --zip still makes an itch.io upload.' });
107
+ ? { id: 'publish', label: 'Publishing', state: 'ok', detail: `git remote ${remote.replace(/\/\/[^@/]+@/, '//')}; ${cli('publish <slug>')} pushes to gh-pages` }
108
+ : { id: 'publish', label: 'Publishing', state: 'optional', detail: `No git remote: ${cli('export <slug> --zip')} still makes an itch.io upload.` });
108
109
 
109
110
  return checks;
110
111
  }
@@ -10,6 +10,7 @@ import { configDir, redact, withoutSecrets } from '../secrets.ts';
10
10
  import type { Events } from './events.ts';
11
11
  import { History } from './history.ts';
12
12
  import { NPC_STORY_GUIDANCE } from '../story/npc-guidance.ts';
13
+ import { cli } from '../cli.ts';
13
14
 
14
15
  export const AGENT_BRIEF = (slug: string) => `You are the coding agent inside Diorama Studio, working on the world "${slug}" with the person.
15
16
  Use only the diorama MCP tools: they are the same actions as the Studio's buttons, and the person watches each call live.
@@ -26,7 +27,7 @@ When a job seems slow or the person asks what it is doing, call job_inspect for
26
27
  To fix a place use the fix tools, which are kept across rebuilds: mask_fix (a better SAM phrase, the real box, or the kind of a thing), floor_cells, mask_brush, stand_set, repaint_region (costs a Muse edit), paint_words (how the place, its things and floors are described to the painter); corrections_get shows what is fixed already, corrections_clear takes fixes back. When asked to go through the person's notes, read feedback_pending, fix each one and close it with feedback_done in one sentence. Check budget before a rebuild of many places.
27
28
  When the person wants the game in another language ("make it English", "po polsku"), call world_language: it translates the title, names and the whole story in one pass and casts and records the voices again as native speakers; first without go, tell them what it redoes and what the voices cost, then call it with go true once they agree (voices false when they only want the text now). Never translate it line by line with story_text, and never say the language cannot be changed.
28
29
  When the person wants the game to look different (more realistic, watercolour, like a picture book, less cartoon), call style_set with the style in a few words (medium, palette, light; scope place or cast for only one place or the characters): first without go, tell them what it repaints and about what it costs, then call it with go true once they agree. It repaints each picture keeping where everything is, so masks and their fixes still fit; children's drawings stay the reference; history_undo takes it back. Never write world.json's style by hand.
29
- Before a risky change, say what you will do; history_undo can take back a job. Respect locks. Keys are never yours: if a key is missing, ask the person to run \`diorama setup\` in a terminal.
30
+ Before a risky change, say what you will do; history_undo can take back a job. Respect locks. Keys are never yours: if a key is missing, ask the person to run \`${cli('setup')}\` in a terminal.
30
31
  Read files freely to find out why something failed, but do not edit world files or run commands that change them: make every change through the diorama MCP tools, so the person sees it live, can undo it, and it survives the next build (a hand edit is overwritten by the next build). When no single tool makes the change the person asks for, do not stop at "there is no tool": find the nearest way with the tools you have (several story_text calls, a story_redo with their wish, a run_step) and offer it with what it costs, using ask_person; mention the missing tool only as a side note, so it can be added later. Refuse only what would need a hand edit or would throw away their work. Start from world_status, place_status, place_view, job_wait, corrections_get and feedback_pending: place_view shows you a place as a picture (layer walk, masks or painting), which files and the shell cannot. Reach for the shell only when no Studio tool answers the question.
31
32
  When a build fails for another reason (a step that broke, a service that did not answer), the Studio wakes you with the error and the job's last lines: job_inspect it, give a service error (HTTP 5xx, a timeout, a busy Space) one job_retry, which picks up at the failed step, and fix anything else with that step's own tool; never retry the same error twice, tell the person instead. A character the 3D service left in 2D (cast_status: the 3D service did not answer) still plays with its 2D rig; cast_animate tries its model again once the service is back. When a build leaves places not playable, repair them one by one: place_status lists each error with its playbook (where to look, the fix tools in order, the last resort), and place_view shows you the walkable floor with the 60 px cells floor_cells takes, the gaps to bridge and the spots off the floor. An unreached patch where nothing is floor (a wall, the sky) is taken back, not bridged: corrections_clear the fix that made it, or mask_brush erase. Try the tools in that order; each fix rebuilds only that place, so read place_status again after its job ends. After three fixes that did not change the error, do not repeat one that failed: look again with place_view and take a different path (mask_brush by hand, floor_cells, thing_remove, corrections_clear); the fix tools refuse an exact repeat, and after six they keep repaints and relayouts for the person. If the new path fails too, tell the person briefly what you tried and what you would do next. Ask before the last resort, except one repaint_region per place.
32
33
  When layout failed and there is no picture, do not wait for place_view: inspect place_status errors, then call layout_fix with concrete layout guidance, or place_fix to resume. Both work before the place directory exists. Failed layout batches retain their diagnostics and retry with new feedback instead of replaying the same rejected batch. When paint fails with content_policy_violation, the image safety filter refused a word, not the picture: paint already lets Spark reword the place twice, so call paint_words with calm, plain words for the scene and the looks the brief had (leafless, not bare; a seat that rests, not hangs on chains; no children, blood or weapons), never an edit of world.json. A place that never got past a step is rebuilt from that step: place_fix (the Fix it button on its card) or job_retry does that by itself.
@@ -2,6 +2,7 @@
2
2
  // this over streamable HTTP at /mcp (Muse Code connects there) and `diorama mcp` over stdio.
3
3
  import { actions, describeActions, runAction, type ActionContext } from '../actions/registry.ts';
4
4
  import { redact } from '../secrets.ts';
5
+ import { cli } from '../cli.ts';
5
6
 
6
7
  type Message = { jsonrpc: '2.0'; id?: string | number | null; method?: string; params?: Record<string, unknown> };
7
8
  export type Reply = { jsonrpc: '2.0'; id: string | number | null; result?: unknown; error?: { code: number; message: string } };
@@ -10,7 +11,7 @@ const PROTOCOL = '2025-06-18';
10
11
 
11
12
  export const INSTRUCTIONS = 'Diorama builds painted point-and-click worlds. Every tool here is a Diorama Studio action; the person sees each call '
12
13
  + 'in the Studio at once. Long work (world_build, run_step) starts a job: follow it with job_wait. Use ui_focus to show the person the '
13
- + 'world or place you are talking about. Keys are never available to you: if setup_status says one is missing, ask the person to run diorama setup.';
14
+ + 'world or place you are talking about. Keys are never available to you: if setup_status says one is missing, ask the person to run ' + cli('setup') + '.';
14
15
 
15
16
  export function mcpTools() {
16
17
  return describeActions().map((a) => ({
@@ -24,6 +24,7 @@ import { bundle, WEB, worldData } from '../commands/play.ts';
24
24
  import { WorldFiles } from '../world/files.ts';
25
25
  import { snapshotBeforeJob } from './history.ts';
26
26
  import { handleMcp } from './mcp.ts';
27
+ import { cli } from '../cli.ts';
27
28
 
28
29
  export const STUDIO_WEB = fileURLToPath(new URL('../../studio/', import.meta.url));
29
30
  const CHAT_CSS = createRequire(import.meta.url).resolve('genaicode/react/styles.css');
@@ -184,7 +185,7 @@ export async function startStudio(opts: { port?: number; root?: string; cwd?: st
184
185
  });
185
186
 
186
187
  // The game and pictures read the world through the Studio's session cookie; a browser that never opened the Studio has none.
187
- const NO_SESSION = 'no session: open the Studio link from `diorama studio` in this browser first, or play the world on its own with `diorama play <slug>`';
188
+ const NO_SESSION = `no session: open the Studio link from \`${cli('studio')}\` in this browser first, or play the world on its own with \`${cli('play <slug>')}\``;
188
189
  const server = http.createServer(async (req, res) => {
189
190
  const headers = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', 'cross-origin-resource-policy': 'same-origin' };
190
191
  const send = (status: number, type: string, body: string | Buffer, extra: Record<string, string> = {}) => {
@@ -239,7 +240,7 @@ export async function startStudio(opts: { port?: number; root?: string; cwd?: st
239
240
  }
240
241
 
241
242
  if (url.pathname.startsWith('/api/')) {
242
- if (!authed) return json(401, { error: 'missing or wrong token: open the link diorama studio printed' });
243
+ if (!authed) return json(401, { error: `missing or wrong token: open the link ${cli('studio')} printed` });
243
244
  if (req.method === 'POST' && !(req.headers['content-type'] ?? '').startsWith('application/json')) return json(415, { error: 'JSON only' });
244
245
 
245
246
  if (url.pathname === '/api/session' && req.method === 'POST') {
@@ -19,7 +19,7 @@ export const SAYS: Record<string, string> = {
19
19
  corrections_get: 'Read fixes', corrections_clear: 'Take fixes back', mask_fix: 'Fix a mask', thing_remove: 'Remove a thing', thing_add: 'Add a thing', mask_brush: 'Brush a mask',
20
20
  floor_cells: 'Fix floor cells', stand_set: 'Move a stand spot', repaint_region: 'Repaint a region', water_flow: 'Set water flow', water_area: 'Fix where water moves', fog_drift: 'Set fog drift', weather_set: 'Set weather', reactions_set: 'Set reactions', reactions_status: 'Scene reactions',
21
21
  feedback_pending: 'Read open notes', feedback_list: 'Read notes', feedback_done: 'Close a note', feedback_add: 'Leave a note', feedback_reopen: 'Reopen a note',
22
- history_list: 'Read history', history_undo: 'Undo a job', history_redo: 'Redo', lock_set: 'Change a lock', ui_focus: 'Show a place', ask_person: 'Ask you', budget: 'Check budget', setup_status: 'Check Setup',
22
+ history_list: 'Read history', history_undo: 'Undo a job', history_redo: 'Redo', lock_set: 'Change a lock', ui_focus: 'Show a place', ask_person: 'Ask you', budget: 'Check budget', setup_status: 'Check Setup', setup_cli: 'Read the terminal command', setup_browsers: 'Download Chromium',
23
23
  cast_status: 'Look at the cast', cast_redo: 'Repaint a character', voice_list: 'List voices', voice_set: 'Change a voice',
24
24
  story_status: 'Read the story', story_text: 'Reword a line', story_redo: 'Rewrite the story', place_view: 'Look at the floor', play_reload: 'Reload the game', npc_move: 'Move an NPC',
25
25
  };
@@ -224,7 +224,9 @@ function Setup() {
224
224
  const [checks, setChecks] = useState<Check[] | null>(null);
225
225
  const [busy, setBusy] = useState('');
226
226
  const [error, setError] = useState('');
227
- const run = async (name: 'setup_status' | 'setup_check') => {
227
+ const [setupCommand, setSetupCommand] = useState('diorama setup');
228
+ useEffect(() => void call<{ setup: string }>('setup_cli').then((c) => setSetupCommand(c.setup)).catch(() => {}), []);
229
+ const run = async (name: 'setup_status' | 'setup_check' | 'setup_browsers') => {
228
230
  setBusy(name);
229
231
  setError('');
230
232
  try {
@@ -250,7 +252,7 @@ function Setup() {
250
252
  <h1>Setup</h1>
251
253
  </div>
252
254
  <p class="lead">{!checks ? 'Checking this machine…' : blocking.length ? `${blocking.map((c) => c.label).join(' and ')} still ${blocking.length > 1 ? 'need' : 'needs'} you. Everything else is ready or optional.` : `Everything diorama needs is here.${ok < checks.length ? ' The unticked ones are optional.' : ''}`}</p>
253
- <p class="note">Keys are typed only in your terminal with <code>diorama setup</code>, hidden, and kept in your system keychain. This page never sees them.</p>
255
+ <p class="note">Keys are typed only in your terminal with <code>{setupCommand}</code>, hidden, and kept in your system keychain. This page never sees them.</p>
254
256
  {error && <p class="error">{error}</p>}
255
257
  <div class="row">
256
258
  <button type="button" class="primary" disabled={!!busy} title="Look at this computer again: keys, tools, the agent. Free" onClick={() => run('setup_status')}>{busy === 'setup_status' ? 'Checking…' : 'Check again'}</button>
@@ -273,6 +275,7 @@ function Setup() {
273
275
  <div class="fix">
274
276
  <pre>{c.fix.join('\n')}</pre>
275
277
  <Copy text={c.fix.join('\n')} />
278
+ {c.id === 'chromium' && <button type="button" class="primary small" disabled={!!busy} title="Download Playwright's Chromium now (~150 MB, once), then check again. Free" onClick={() => run('setup_browsers')}>{busy === 'setup_browsers' ? 'Downloading…' : 'Download'}</button>}
276
279
  </div>
277
280
  )}
278
281
  </div>
package/docs/setup.md CHANGED
@@ -8,7 +8,7 @@ Without a checkout, from npm. Run it in an empty folder: worlds are written to `
8
8
 
9
9
  ```sh
10
10
  mkdir my-worlds && cd my-worlds
11
- npx -y @gtanczyk/diorama setup # what this machine still needs; asks for keys, hidden
11
+ npx -y @gtanczyk/diorama setup # what this machine still needs; asks for keys, hidden, and offers to download Chromium
12
12
  npx -y @gtanczyk/diorama doctor # one live call to each model
13
13
  npx -y @gtanczyk/diorama studio # the GUI
14
14
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtanczyk/diorama",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Turn a story prompt into playable painted point-and-click scenes with Meta Muse and SAM 3.1",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {