@homie-rocks/studio 0.10.0 → 0.11.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/lib/doctor.mjs ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * `homie-studio setup status` (also `homie-studio doctor`): what this computer and this person's accounts have for
3
+ * a studio, as one checklist, before anything is made. For each thing: green, missing, or "do this now", what it
4
+ * unlocks, and the exact fix: one command the AI runs, or one page the person taps.
5
+ *
6
+ * Node.js required the toolkit runs on it
7
+ * Homie connector required the directory, cards and the pinned toolkit's address (the AI says whether its
8
+ * Homie tools are there: --connector yes|no)
9
+ * Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
10
+ * first deploy; a deploy that went through proves it)
11
+ * Chrome for checks the two-browser check, the look pictures and playtests
12
+ * ffmpeg recommended sound effects and the theme, trailers and hero footage
13
+ * GitHub optional a private backup, publishing by pull request, building from the Claude app
14
+ * ElevenLabs optional songs and game scores
15
+ * fal optional painted art and generated video
16
+ * status line optional (Claude Code only) the build's progress under the prompt
17
+ *
18
+ * It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
19
+ * tools, `wrangler whoami`, `gh auth status`, `elevenlabs auth status`, one GET to the directory and, when a fal key
20
+ * is set, fal's free pricing API), never changes anything, and never prints a key, a token or an account's name.
21
+ * Every check has a time limit, so it answers in seconds even with no network.
22
+ */
23
+ import { spawn } from 'node:child_process';
24
+ import { existsSync, readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { findChrome } from './chrome.mjs';
27
+ import { isOurs } from './statusline.mjs';
28
+ import { findStudio, readLocal, readStudio } from './studio.mjs';
29
+ import { STUDIO_VERSION } from './version.mjs';
30
+
31
+ /** A command's result, never throwing: code 127 when the program is not installed, 124 when it ran out of time. */
32
+ export function defaultExec(cmd, args, { cwd, timeout = 8000, env } = {}) {
33
+ return new Promise((done) => {
34
+ let stdout = ''; let stderr = ''; let settled = false;
35
+ const finish = (r) => { if (!settled) { settled = true; clearTimeout(timer); done(r); } };
36
+ let child;
37
+ try { child = spawn(cmd, args, { cwd, env: env ?? process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); } catch (error) { finish({ code: 127, stdout: '', stderr: String(error?.message ?? error) }); return; }
38
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } finish({ code: 124, stdout, stderr }); }, timeout);
39
+ child.stdout.on('data', (d) => { stdout += d; });
40
+ child.stderr.on('data', (d) => { stderr += d; });
41
+ child.on('error', (error) => finish({ code: error.code === 'ENOENT' ? 127 : 1, stdout, stderr: String(error.message) }));
42
+ child.on('close', (code) => finish({ code: code ?? 1, stdout, stderr }));
43
+ });
44
+ }
45
+
46
+ const MARK = { ok: '✓', act: '→', missing: '✗', optional: '○', later: '…', unknown: '?' };
47
+ const RANK = { missing: 0, act: 1, unknown: 2, later: 3, optional: 4, ok: 5 };
48
+ const worst = (states) => states.reduce((a, b) => (RANK[b] < RANK[a] ? b : a), 'ok');
49
+
50
+ async function reach(url, fetchFn, ms = 5000) {
51
+ try {
52
+ const res = await fetchFn(url, { method: 'GET', headers: { 'user-agent': `homie-studio/${STUDIO_VERSION}`, accept: 'application/json' }, signal: AbortSignal.timeout(ms) });
53
+ const deny = res.headers?.get?.('x-deny-reason') ?? '';
54
+ if (res.status === 403 && /host_not_allowed/i.test(deny)) return { ok: false, blocked: true };
55
+ return { ok: true, status: res.status };
56
+ } catch (error) { return { ok: false, why: error?.name === 'TimeoutError' ? 'did not answer in time' : 'did not answer' }; }
57
+ }
58
+
59
+ /**
60
+ * The checklist. `connector`: what the AI knows about its own tools ('yes' when the Homie MCP tools such as
61
+ * studio_scaffold are in its tool list, 'no' when they are not, null when unsaid). `exec` and `fetchFn` are
62
+ * replaceable for tests.
63
+ */
64
+ export async function setupStatus({
65
+ cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null,
66
+ exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node,
67
+ } = {}) {
68
+ const root = findStudio(cwd);
69
+ let studio = null;
70
+ try { studio = root ? readStudio(root) : null; } catch { studio = null; }
71
+ const cli = root ? 'npx --no-install homie-studio' : `npx -y @homie-rocks/studio@${STUDIO_VERSION}`;
72
+ let directory = String(homie || (studio?.homie?.directory && typeof studio.homie.directory === 'string' ? studio.homie.directory : 'https://homie.rocks')).replace(/\/+$/, '');
73
+ try { new URL(directory); } catch { directory = 'https://homie.rocks'; }
74
+ const mac = platform === 'darwin';
75
+ const win = platform === 'win32';
76
+ const rows = [];
77
+
78
+ // Node.js
79
+ const major = Number(String(node).split('.')[0]);
80
+ rows.push({
81
+ id: 'node', label: 'Node.js', need: 'required', state: major >= 22 ? 'ok' : 'missing', detail: `v${node}${major >= 22 ? '' : ' (22 or newer is needed)'}`,
82
+ unlocks: 'everything: the studio\'s toolkit runs on it',
83
+ fix: major >= 22 ? null : { who: 'person', open: 'https://nodejs.org/en/download', say: 'Install Node.js 22 or newer (the LTS download), then start a new session.' },
84
+ });
85
+
86
+ // The Homie connector (the AI says; the network is checked here), Cloudflare, GitHub and the providers, at once.
87
+ const said = connector === true || connector === 'yes' ? 'yes' : connector === false || connector === 'no' ? 'no' : null;
88
+ const [net, who, gh, eleven, fal] = await Promise.all([
89
+ reach(`${directory}/mcp`, fetchFn),
90
+ (async () => {
91
+ if (env.CLOUDFLARE_API_TOKEN) return { signedIn: true, how: 'an API token in the environment' };
92
+ const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
93
+ if (!bin || !existsSync(bin)) return { signedIn: null };
94
+ const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
95
+ if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
96
+ try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null }; } catch { return { signedIn: false }; }
97
+ })(),
98
+ (async () => {
99
+ const git = await exec('git', ['--version']);
100
+ // The studio's own repository: on GitHub (a Claude app studio always is: its setup card made it there).
101
+ const remote = root && git.code === 0 ? (await exec('git', ['remote', 'get-url', 'origin'], { cwd: root })).stdout.trim() : '';
102
+ const onGitHub = /(^|[/@.])github\.com[/:]/i.test(remote);
103
+ if (env.GH_TOKEN || env.GITHUB_TOKEN) return { git: git.code === 0, gh: true, signedIn: true, onGitHub };
104
+ const v = await exec('gh', ['--version']);
105
+ if (v.code !== 0) return { git: git.code === 0, gh: false, signedIn: false, onGitHub };
106
+ return { git: git.code === 0, gh: true, signedIn: (await exec('gh', ['auth', 'status'], { timeout: 10_000 })).code === 0, onGitHub };
107
+ })(),
108
+ (async () => {
109
+ const cliBin = env.ELEVENLABS_CLI || 'elevenlabs';
110
+ const v = await exec(cliBin, ['--version']);
111
+ let signedIn = false;
112
+ if (v.code === 0) {
113
+ const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
114
+ try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
115
+ }
116
+ return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY) };
117
+ })(),
118
+ (async () => {
119
+ const key = String(env.FAL_KEY ?? '').trim();
120
+ if (!key) return { key: false };
121
+ const base = String(env.FAL_API_URL || 'https://api.fal.ai').replace(/\/+$/, '');
122
+ try {
123
+ const res = await fetchFn(`${base}/v1/models/pricing?endpoint_id=fal-ai/flux/dev`, { headers: { Authorization: `Key ${key}` }, signal: AbortSignal.timeout(6000) });
124
+ return { key: true, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
125
+ } catch { return { key: true, valid: null }; }
126
+ })(),
127
+ ]);
128
+
129
+ // The Homie connector.
130
+ {
131
+ const fixConnector = {
132
+ who: 'person',
133
+ say: 'Turn the Homie connector on. Claude Code: run /plugin, install or enable "homie" (marketplace homie-rocks/homie), then /mcp shows homie connected. Codex: install the Homie plugin from the same marketplace. The Claude app: Settings, Connectors, Add custom connector, https://homie.rocks/mcp; then its setup card (studio_setup) makes the studio on your own Cloudflare and GitHub in three taps.',
134
+ };
135
+ let state; let detail;
136
+ if (net.blocked) { state = 'act'; detail = `this environment's network does not reach ${new URL(directory).host}`; }
137
+ else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; ${new URL(directory).host} ${net.why ?? 'did not answer'} just now`; }
138
+ else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session'; }
139
+ else { state = net.ok ? 'unknown' : 'act'; detail = net.ok ? `${new URL(directory).host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `${new URL(directory).host} ${net.why ?? 'did not answer'}`; }
140
+ rows.push({
141
+ id: 'connector', label: 'Homie connector', need: 'required', state, detail,
142
+ unlocks: 'making the studio with the right toolkit version, listing games in the homie.rocks directory, and the cards in the Claude app',
143
+ fix: state === 'ok' ? null : net.blocked
144
+ ? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${new URL(directory).host} (keep the default package managers), and start a new session.` }
145
+ : fixConnector,
146
+ });
147
+ }
148
+
149
+ // Cloudflare: signed in, and the account's email verified.
150
+ {
151
+ const local = root ? readLocal(root) : {};
152
+ const created = studio?.cloudflare?.created ?? [];
153
+ const deployed = Boolean(local.deployedAt || created.some((c) => String(c).startsWith('worker:')));
154
+ // A studio the Claude app's setup card made (`setup attach` wrote connectedAt): Cloudflare's Deploy button made
155
+ // it on the person's account and Workers Builds deploys every merge, so no sign-in is needed on this computer.
156
+ const attached = Boolean(local.connectedAt);
157
+ const signedIn = attached || who.signedIn === true ? 'ok' : who.signedIn === false ? 'act' : 'later';
158
+ const verified = deployed || attached ? 'ok' : local.needs === 'cloudflare-verify-email' ? 'act' : 'later';
159
+ const parts = attached ? [
160
+ { label: 'connected', state: 'ok', detail: 'through the Claude app\'s setup card (Deploy to Cloudflare); Workers Builds deploys every merge' },
161
+ { label: 'email verified', state: 'ok', detail: 'the site is live' },
162
+ ] : [
163
+ { label: 'signed in', state: signedIn, detail: who.signedIn === true ? (who.how ?? `${who.accounts ?? 1} account${who.accounts === 1 || who.accounts === null || who.accounts === undefined ? '' : 's'}`) : who.signedIn === false ? 'not signed in' : root ? (who.why ?? 'run npm install in the studio, then this again') : 'checked once the studio exists (it brings its own Wrangler)' },
164
+ { label: 'email verified', state: verified, detail: deployed ? 'a deploy went through' : verified === 'act' ? 'the last deploy was refused until it is' : 'Cloudflare checks it at the first deploy' },
165
+ ];
166
+ const fix = attached ? null : signedIn === 'act'
167
+ ? { who: 'ai', run: 'npx wrangler login', say: 'Cloudflare opens in your browser: approve once. A free account works; no payment method.', open: 'https://dash.cloudflare.com/sign-up' }
168
+ : verified === 'act'
169
+ ? { who: 'person', open: 'https://dash.cloudflare.com/profile', say: 'Open the email Cloudflare sent and click its link (or Profile, "Send verification email"); then deploy again.' }
170
+ : signedIn === 'later' || verified === 'later'
171
+ ? { who: 'person', open: 'https://dash.cloudflare.com/sign-up', say: 'No Cloudflare account yet? Make a free one now (no payment method) and click the link in the email it sends. Your AI signs in for you later; you approve once.' }
172
+ : null;
173
+ rows.push({
174
+ id: 'cloudflare', label: 'Cloudflare', need: 'to go online', state: worst(parts.map((p) => p.state)), detail: parts.map((p) => `${p.label}: ${p.detail}`).join('; '), parts,
175
+ unlocks: 'putting the studio\'s site and public rooms online, on its own free Cloudflare account', fix,
176
+ });
177
+ }
178
+
179
+ // Chrome, for the checks.
180
+ {
181
+ const found = chrome();
182
+ rows.push({
183
+ id: 'chrome', label: 'Chrome', need: 'for checks', state: found ? 'ok' : 'act', detail: found ? 'found' : 'no Chrome on this computer',
184
+ unlocks: 'the two-browser check, the look pictures and playtests (games build and run without it)',
185
+ fix: found ? null : { who: 'ai', run: `${cli} chrome install`, say: 'Chrome for Testing goes into a cache folder (about 150 MB), only for the checks.', open: mac || win ? 'https://www.google.com/chrome/' : undefined },
186
+ });
187
+ }
188
+
189
+ // ffmpeg.
190
+ {
191
+ const ok = (await exec('ffmpeg', ['-version'])).code === 0;
192
+ rows.push({
193
+ id: 'ffmpeg', label: 'ffmpeg', need: 'recommended', state: ok ? 'ok' : 'optional', detail: ok ? 'found' : 'not installed',
194
+ unlocks: 'sound effects and a synthesized theme (free, on this computer), trailers, and footage in a landing page\'s hero',
195
+ fix: ok ? null : mac ? { who: 'ai', run: 'brew install ffmpeg', say: 'about a minute with Homebrew; you approve the install.' }
196
+ : win ? { who: 'ai', run: 'winget install --id Gyan.FFmpeg -e', say: 'you approve the install.' }
197
+ : { who: 'person', say: 'Install your system\'s ffmpeg package (for example: sudo apt-get install -y ffmpeg).' },
198
+ });
199
+ }
200
+
201
+ // GitHub.
202
+ rows.push({
203
+ id: 'github', label: 'GitHub', need: 'optional', state: gh.signedIn || gh.onGitHub ? 'ok' : 'optional',
204
+ detail: [gh.onGitHub ? 'this studio\'s repository is on GitHub' : null, gh.signedIn ? 'the GitHub CLI is signed in' : gh.gh ? 'the GitHub CLI is here, not signed in' : gh.git ? 'git is here; no GitHub CLI' : 'no git'].filter(Boolean).join('; '),
205
+ unlocks: 'a private backup of the studio, publishing changes by pull request, and building from the Claude app',
206
+ fix: gh.signedIn || gh.onGitHub ? null : gh.gh
207
+ ? { who: 'ai', run: 'gh auth login --hostname github.com --git-protocol https --web', say: 'GitHub shows a one-time code: open https://github.com/login/device, type it and approve.' }
208
+ : { who: 'person', open: 'https://github.com/signup', say: `Make a free GitHub account; your AI installs the GitHub CLI${mac ? ' (brew install gh)' : ''} when you want the backup.` },
209
+ });
210
+
211
+ // ElevenLabs.
212
+ rows.push({
213
+ id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key ? 'ok' : 'optional',
214
+ detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? 'ELEVENLABS_API_KEY is set' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
215
+ unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
216
+ fix: eleven.signedIn || eleven.key ? null : eleven.cli
217
+ ? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
218
+ : { who: mac ? 'ai' : 'person', run: mac ? 'brew install elevenlabs/tap/elevenlabs && elevenlabs auth login' : undefined, open: 'https://elevenlabs.io/sign-up', say: 'Make an ElevenLabs account when you want songs; your AI installs their CLI and you sign in once in the browser.' },
219
+ });
220
+
221
+ // fal.
222
+ rows.push({
223
+ id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
224
+ detail: !fal.key ? 'no FAL_KEY in this environment' : fal.valid === true ? 'FAL_KEY is set and fal accepts it' : fal.valid === false ? 'fal refused the FAL_KEY that is set (wrong or revoked)' : 'FAL_KEY is set (fal did not answer just now)',
225
+ unlocks: 'painted art and backdrops (the art skill) and generated video clips (the video skill), on your own fal account under a budget; covers from a real frame and captured trailers are free',
226
+ fix: fal.key && fal.valid !== false ? null : { who: 'person', open: 'https://fal.ai/dashboard/keys', say: 'Make a key, put FAL_KEY in the environment your AI runs in (for example a line in your shell profile), and start a new session. Never paste it into the chat.' },
227
+ });
228
+
229
+ // The Claude Code status line (only in Claude Code, only in a studio).
230
+ if (root && env.CLAUDECODE === '1') {
231
+ let on = false;
232
+ try { on = isOurs(JSON.parse(readFileSync(join(root, '.claude', 'settings.local.json'), 'utf8')).statusLine); } catch { on = false; }
233
+ rows.push({
234
+ id: 'statusline', label: 'Status line', need: 'optional', state: on ? 'ok' : 'optional', detail: on ? 'on in this studio' : 'off',
235
+ unlocks: 'the current build\'s stage and a progress bar under the prompt in Claude Code',
236
+ fix: on ? null : { who: 'ai', run: 'npx --no-install homie-studio statusline --install', say: 'one line under the prompt; off again with --remove.' },
237
+ });
238
+ }
239
+
240
+ const by = Object.fromEntries(rows.map((r) => [r.id, r]));
241
+ const ready = (state) => (state === 'ok' ? 'ready' : state === 'later' ? 'later' : 'not yet');
242
+ const features = [
243
+ { feature: 'Make games and play them here', state: ready(by.node.state), needs: ['node'] },
244
+ { feature: 'Two-browser checks and playtests', state: ready(by.chrome.state), needs: ['chrome'] },
245
+ { feature: 'Sound effects and a theme (free)', state: ready(by.ffmpeg.state), needs: ['ffmpeg'] },
246
+ { feature: 'Put the studio online', state: ready(by.cloudflare.parts[0].state === 'ok' ? by.cloudflare.parts[1].state : by.cloudflare.parts[0].state), needs: ['cloudflare'] },
247
+ { feature: 'List games in the homie.rocks directory', state: ready(by.connector.state), needs: ['connector'] },
248
+ { feature: 'Backup and pull requests', state: ready(by.github.state), needs: ['github'] },
249
+ { feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
250
+ { feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
251
+ ];
252
+ const now = rows.filter((r) => ['missing', 'act'].includes(r.state) && r.fix);
253
+ const meanwhile = rows.filter((r) => r.fix?.open && r.state !== 'ok' && (r.fix.who === 'person' || r.state === 'later'));
254
+ return {
255
+ ok: true, command: 'setup status', version: STUDIO_VERSION,
256
+ studio: root ? { name: studio?.name ?? null, root } : null,
257
+ rows, features,
258
+ blocking: rows.filter((r) => r.state === 'missing').map((r) => r.id),
259
+ next: now.map((r) => ({ id: r.id, who: r.fix.who, run: r.fix.run ?? null, open: r.fix.open ?? null, say: r.fix.say })),
260
+ meanwhile: meanwhile.map((r) => ({ id: r.id, open: r.fix.open, say: r.fix.say })),
261
+ note: 'Optional rows never block anything: each one is set up the first time a feature needs it.',
262
+ };
263
+ }
264
+
265
+ /** The checklist as a person reads it in a terminal. */
266
+ export function formatStatus(r) {
267
+ const lines = [`Setup status${r.studio?.name ? ` for ${r.studio.name}` : ' (before the studio exists)'}`, ''];
268
+ const pad = Math.max(...r.rows.map((x) => x.label.length)) + 2;
269
+ for (const row of r.rows) {
270
+ lines.push(` ${MARK[row.state] ?? '?'} ${row.label.padEnd(pad)}${row.need === 'optional' ? '(optional) ' : row.need === 'recommended' ? '(recommended) ' : ''}${row.parts ? row.parts.map((p) => `${MARK[p.state]} ${p.label}: ${p.detail}`).join(' ') : row.detail}`);
271
+ lines.push(` ${''.padEnd(pad)}unlocks ${row.unlocks}`);
272
+ if (row.fix && row.state !== 'ok') lines.push(` ${''.padEnd(pad)}${row.fix.run ? `fix: ${row.fix.run} ` : ''}${row.fix.open ? `${row.fix.run ? '' : 'fix: '}${row.fix.open} ` : ''}${row.fix.say}`);
273
+ }
274
+ lines.push('', `Ready: ${r.features.map((f) => `${f.feature} ${f.state === 'ready' ? MARK.ok : f.state === 'later' ? MARK.later : MARK.optional}`).join(' · ')}`);
275
+ if (r.next.length) lines.push('', 'Do this now:', ...r.next.map((n) => ` ${MARK.act} ${n.run ?? n.open ?? ''} ${n.say}`));
276
+ if (r.meanwhile.length) lines.push('', 'Safe to do any time, even while you wait for something else:', ...r.meanwhile.map((m) => ` ${m.open} ${m.say}`));
277
+ lines.push('', r.note, `(${MARK.ok} ready ${MARK.act} do this now ${MARK.missing} missing ${MARK.optional} optional, not set up ${MARK.later} checked later ? your AI knows)`);
278
+ return lines.join('\n');
279
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A build's progress feed (lib/progress.mjs), read for a person at a glance: how far along it is (a percentage and
3
+ * the stage it is in), its checks, its preview and what it spent. The Game Codex page (lib/codex.mjs) and the Claude
4
+ * Code status line (lib/statusline.mjs) both draw from this, so they always agree.
5
+ *
6
+ * Read-only and small on purpose: the status line runs it every few seconds, so it imports nothing but node:fs.
7
+ */
8
+ import { readdirSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ const KIND = 'homie-studio-progress';
12
+ const DIR = join('.studio', 'progress');
13
+ const BUILD_ID = /^[a-z0-9][a-z0-9-]{5,63}$/;
14
+
15
+ function read(root, id) {
16
+ if (!BUILD_ID.test(String(id ?? ''))) return null;
17
+ try { const d = JSON.parse(readFileSync(join(root, DIR, `${id}.json`), 'utf8')); return d?.kind === KIND ? d : null; } catch { return null; }
18
+ }
19
+
20
+ /** The open build's feed (`.studio/progress/current`), or null. */
21
+ export function currentFeedDoc(root) {
22
+ try {
23
+ const id = readFileSync(join(root, DIR, 'current'), 'utf8').trim();
24
+ const doc = read(root, id);
25
+ return doc?.state === 'running' ? doc : null;
26
+ } catch { return null; }
27
+ }
28
+
29
+ /** The newest feed of one game, song or video (`id`), or of anything (`id` null): the open one first. */
30
+ export function latestFeedFor(root, id = null) {
31
+ const open = currentFeedDoc(root);
32
+ if (open && (id === null || open.id === id)) return open;
33
+ let names = [];
34
+ try { names = readdirSync(join(root, DIR)).filter((n) => n.endsWith('.json')); } catch { return null; }
35
+ let best = null;
36
+ for (const name of names) {
37
+ const doc = read(root, name.slice(0, -5));
38
+ if (!doc || (id !== null && doc.id !== id)) continue;
39
+ if (!best || String(doc.updatedAt ?? '') > String(best.updatedAt ?? '')) best = doc;
40
+ }
41
+ return best;
42
+ }
43
+
44
+ const PASSED = new Set(['pass', 'skip']);
45
+
46
+ /**
47
+ * One feed as a person reads it. `percent` counts every stage alike: a finished or skipped stage is whole, the
48
+ * running one is part done (by its checks going green, when it has checks), and a build is 100 only once it passed.
49
+ */
50
+ export function summarize(doc) {
51
+ if (!doc || !Array.isArray(doc.stages)) return null;
52
+ const checks = Array.isArray(doc.checks) ? doc.checks : [];
53
+ let units = 0;
54
+ for (const s of doc.stages) {
55
+ if (s.state === 'done' || s.state === 'skipped') units += 1;
56
+ else if (s.state === 'running') {
57
+ const own = checks.filter((c) => c.stage === s.id);
58
+ units += own.length ? 0.1 + 0.85 * (own.filter((c) => PASSED.has(c.state)).length / own.length) : 0.35;
59
+ }
60
+ }
61
+ const total = Math.max(1, doc.stages.length);
62
+ const percent = doc.state === 'passed' ? 100 : Math.max(0, Math.min(99, Math.round((units / total) * 100)));
63
+ const at = doc.stages.find((s) => s.id === doc.stage) ?? doc.stages.find((s) => s.state === 'running') ?? null;
64
+ const done = doc.stages.filter((s) => s.state === 'done' || s.state === 'skipped').length;
65
+ const spend = doc.spend ?? { unit: 'usd', used: 0, budget: null };
66
+ const money = (n) => (spend.unit === 'credits' ? `${n} credits` : `$${Number(n).toFixed(2)}`);
67
+ return {
68
+ build: doc.build, what: doc.what, id: doc.id ?? null, title: doc.title ?? doc.id ?? 'Build', studio: doc.studio ?? '',
69
+ state: doc.state, percent, stagesDone: done, stagesTotal: doc.stages.length,
70
+ stage: at ? { id: at.id, label: at.label, state: at.state, note: at.note ?? '' } : null,
71
+ // A build that passed without a stage (a check run that never deployed) skipped it.
72
+ stages: doc.stages.map((s) => ({ id: s.id, label: s.label, state: doc.state === 'passed' && s.state === 'pending' ? 'skipped' : s.state, note: s.note ?? '', checks: checks.filter((c) => c.stage === s.id) })),
73
+ checks: {
74
+ total: checks.length,
75
+ pass: checks.filter((c) => PASSED.has(c.state)).length,
76
+ fail: checks.filter((c) => c.state === 'fail').length,
77
+ running: checks.filter((c) => c.state === 'running').length,
78
+ },
79
+ spend: { unit: spend.unit, used: Number(spend.used) || 0, budget: spend.budget ?? null, text: Number(spend.used) || spend.budget !== null ? `${money(Number(spend.used) || 0)}${spend.budget !== null && spend.budget !== undefined ? ` of ${money(spend.budget)}` : ''}` : '' },
80
+ preview: doc.preview?.url ?? null,
81
+ previewImage: doc.preview?.image ?? null,
82
+ previewCaption: doc.preview?.caption ?? '',
83
+ stopping: Boolean(doc.stop?.requested) && doc.state === 'running',
84
+ last: Array.isArray(doc.log) && doc.log.length ? doc.log[doc.log.length - 1].text : '',
85
+ error: doc.error ?? null,
86
+ startedAt: doc.startedAt ?? null, updatedAt: doc.updatedAt ?? null, endedAt: doc.endedAt ?? null,
87
+ };
88
+ }
package/lib/progress.mjs CHANGED
@@ -19,6 +19,10 @@
19
19
  * knows (the plan, money spent, a song's lyric check, a video's shots) with the other `progress` commands.
20
20
  * WITHOUT AN OPEN FEED NOTHING CHANGES: every command behaves exactly as before.
21
21
  *
22
+ * WHERE A PERSON SEES IT: the Claude app's build card (shared feeds), the Build status tab of the game's codex page
23
+ * (lib/codex.mjs: it redraws itself at every change), and Claude Code's status line (lib/statusline.mjs), all read
24
+ * through lib/feed-summary.mjs so they agree.
25
+ *
22
26
  * SHARED (`progress start --share`): the directory named in studio.json (homie.rocks by default) keeps a copy for
23
27
  * 24 hours, so the Homie MCP tool `build_progress { build }` can show it in the Claude app, and answers every
24
28
  * write with whether the person pressed Stop there. It receives the feed and nothing else: never a key, a file
@@ -51,6 +55,7 @@
51
55
  import { createHash, randomBytes } from 'node:crypto';
52
56
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
53
57
  import { extname, join } from 'node:path';
58
+ import { refreshCodexFile } from './codex.mjs';
54
59
  import { readStudio, siteUrl } from './studio.mjs';
55
60
 
56
61
  export const PROGRESS_KIND = 'homie-studio-progress';
@@ -245,6 +250,8 @@ export class Feed {
245
250
  change(fn, { soon = false } = {}) {
246
251
  const d = mutateDoc(this.root, this.id, fn);
247
252
  if (d) schedule(this.root, this.id, { soon });
253
+ // The game's codex page on this computer (.studio/codex/<id>.html), when there is one, shows the change too.
254
+ if (d?.what === 'game' && d.id) refreshCodexFile(this.root, d.id);
248
255
  return d;
249
256
  }
250
257
 
package/lib/scaffold.mjs CHANGED
@@ -192,6 +192,24 @@ studio's pinned copy, never a registry lookup of the bare name.
192
192
  - Change a game in small steps, build, and look at it (\`dev\`, then \`check\`).
193
193
  - A game's id is its URL (\`/<id>/\`); keep it once published.
194
194
 
195
+ ## The Game Codex and progress
196
+
197
+ - **Every game has a Game Codex:** \`games/<id>/CODEX.md\`, its plan in plain words for everyone who makes it, coder
198
+ or not: concept, world, characters, art direction, controls per device, rooms and players, music and sound,
199
+ milestones, open questions, and under Latest the decisions as they are made, newest first, each with its date.
200
+ It is the source of truth: when a decision changes, change the codex in the same change.
201
+ \`npx --no-install homie-studio codex new <id>\` starts one with every section.
202
+ - \`npx --no-install homie-studio codex <id>\` draws it as a page in the game's own look
203
+ (\`.studio/codex/<id>.html\`, \`--open\` opens it; \`--artifact\` makes a copy to publish as a Claude artifact).
204
+ The site has it too, for the owner only and never listed: \`codex link <id>\` gives the one-time sign-in link.
205
+ - **Progress:** \`npx --no-install homie-studio progress start <id> --title "<what this build does>"\` opens a
206
+ build's progress feed; build, check and deploy report into it. The codex page's Build status tab redraws itself
207
+ as it moves (stages, checks going green, how to try it, spend), and \`homie-studio statusline --install\` puts
208
+ one line of it under the prompt in Claude Code.
209
+ - \`npx --no-install homie-studio setup status\` says what this computer and the person's accounts have (Node,
210
+ Cloudflare, Chrome, ffmpeg, GitHub, ElevenLabs, fal), what each unlocks, and the exact fix. Optional ones never
211
+ block anything.
212
+
195
213
  ## Rules
196
214
 
197
215
  - Keys stay in the providers' own logins (Wrangler, ElevenLabs, fal) or the OS
@@ -288,6 +306,8 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
288
306
  const GITIGNORE = `node_modules/
289
307
  # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
290
308
  .studio/
309
+ # This person's own Claude Code settings for this studio (the status line, for one).
310
+ .claude/settings.local.json
291
311
  site/dist/
292
312
  site/.wrangler/
293
313
  .wrangler/
@@ -0,0 +1,155 @@
1
+ /**
2
+ * THE CLAUDE CODE STATUS LINE: one quiet line under the prompt with the studio's current build, from its progress
3
+ * feed (lib/progress.mjs), for example
4
+ *
5
+ * ▶ Ember Run · Checks ▰▰▰▰▰▰▱▱▱▱ 62% · 3/5 checks · $0.40 of $2.00
6
+ *
7
+ * Optional, and only on the person's yes: `homie-studio statusline --install` writes the `statusLine` setting into
8
+ * this studio's `.claude/settings.local.json` (this person, this studio; Claude Code keeps that file out of git, and
9
+ * the studio's .gitignore does too). Claude Code runs the command after each message and, with `refreshInterval`,
10
+ * every few seconds while a long check runs; it sends the session as JSON on stdin (`workspace.current_dir`), and
11
+ * shows what the command prints. With no build running it shows only the studio's name, dimmed; a build that ended
12
+ * stays for a few minutes (passed, failed or stopped), then goes.
13
+ *
14
+ * Plugins cannot set a status line (a plugin's settings take only `agent` and `subagentStatusLine`), so this is a
15
+ * one-line project setting the person agrees to, never something the plugin switches on.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join, resolve } from 'node:path';
20
+ import { currentFeedDoc, latestFeedFor, summarize } from './feed-summary.mjs';
21
+ import { PACKAGE_ROOT, findStudio } from './studio.mjs';
22
+
23
+ const ANSI = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m' };
24
+ /** How long an ended build stays on the line. */
25
+ export const RECENT_MS = 10 * 60_000;
26
+
27
+ function bar(percent, width) {
28
+ const full = Math.round((percent / 100) * width);
29
+ return { done: '▰'.repeat(full), left: '▱'.repeat(Math.max(0, width - full)) };
30
+ }
31
+
32
+ /**
33
+ * The line for the studio at or above `dir`, or '' outside a studio. Parts are dropped from the end (spend, then
34
+ * checks) until the line fits in `columns`.
35
+ */
36
+ export function statusLine({ dir = process.cwd(), now = Date.now(), columns = 100, color = true } = {}) {
37
+ const root = findStudio(dir);
38
+ if (!root) return '';
39
+ let name = 'Studio';
40
+ try { name = JSON.parse(readFileSync(join(root, 'studio.json'), 'utf8')).name || name; } catch { /* keep */ }
41
+ const s = summarize(currentFeedDoc(root) ?? latestFeedFor(root, null));
42
+ const paint = (code, text) => (color && text ? `${code}${text}${ANSI.reset}` : text);
43
+ const fit = (parts) => {
44
+ // parts: [[plain, painted], …]; keep the first two whole, then as many as fit.
45
+ const width = Math.max(20, columns - 2);
46
+ let keep = parts.length;
47
+ const len = (n) => parts.slice(0, n).reduce((a, [plain]) => a + plain.length, 0) + (n - 1) * 3;
48
+ while (keep > 1 && len(keep) > width) keep -= 1;
49
+ const out = parts.slice(0, keep).map(([, painted]) => painted).join(paint(ANSI.dim, ' · '));
50
+ const plain = parts.slice(0, keep).map(([p]) => p).join(' · ');
51
+ return plain.length > width ? `${plain.slice(0, width - 1)}…` : out;
52
+ };
53
+ const ended = s && s.state !== 'running' ? Date.parse(s.endedAt ?? s.updatedAt ?? 0) : null;
54
+ if (!s || (ended !== null && !(now - ended < RECENT_MS))) return fit([[`◇ ${name}`, paint(ANSI.dim, `◇ ${name}`)]]);
55
+
56
+ const title = s.title.length > 40 ? `${s.title.slice(0, 39)}…` : s.title;
57
+ if (s.state === 'passed') {
58
+ return fit([[`✓ ${title} done`, paint(ANSI.green, `✓ ${title} done`)], ...(s.preview ? [[s.preview, paint(ANSI.dim, s.preview)]] : [])]);
59
+ }
60
+ if (s.state === 'failed') {
61
+ const why = `failed${s.stage ? ` at ${s.stage.label}` : ''}${s.error ? `: ${s.error}` : ''}`;
62
+ return fit([[`✗ ${title}`, paint(ANSI.red, `✗ ${title}`)], [why, paint(ANSI.red, why)]]);
63
+ }
64
+ if (s.state === 'stopped') return fit([[`■ ${title} stopped`, paint(ANSI.yellow, `■ ${title} stopped`)]]);
65
+
66
+ const width = columns < 70 ? 6 : 10;
67
+ const b = bar(s.percent, width);
68
+ const stage = s.stopping ? 'stopping…' : (s.stage?.label ?? 'Starting');
69
+ const progress = `${stage} ${b.done}${b.left} ${s.percent}%`;
70
+ const parts = [
71
+ [`▶ ${title}`, paint(ANSI.bold, `▶ ${title}`)],
72
+ [progress, `${paint(ANSI.cyan, stage)} ${paint(ANSI.green, b.done)}${paint(ANSI.dim, b.left)} ${s.percent}%`],
73
+ ];
74
+ if (s.checks.total) {
75
+ const text = `${s.checks.pass}/${s.checks.total} checks${s.checks.fail ? `, ${s.checks.fail} failing` : ''}`;
76
+ parts.push([text, paint(s.checks.fail ? ANSI.red : ANSI.dim, text)]);
77
+ }
78
+ if (s.spend.text) parts.push([s.spend.text, paint(ANSI.dim, s.spend.text)]);
79
+ return fit(parts);
80
+ }
81
+
82
+ /* ------------------------------------------------------------------ the setting */
83
+
84
+ const LOCAL = join('.claude', 'settings.local.json');
85
+ const readJson = (path) => { if (!existsSync(path)) return {}; return JSON.parse(readFileSync(path, 'utf8')); };
86
+ /** Our own status line: a command that runs this toolkit's statusline. */
87
+ export const isOurs = (statusLine) => typeof statusLine?.command === 'string' && /homie-studio|@homie-rocks[\\/]studio/.test(statusLine.command) && /statusline/.test(statusLine.command);
88
+
89
+ /**
90
+ * The command the setting runs: this studio's pinned toolkit, by an absolute path (forward slashes, for Windows
91
+ * too). Set in another folder (the one Claude Code was started in, above the studio), it names the studio too.
92
+ */
93
+ export function statusLineCommand(root, { named = false } = {}) {
94
+ const pinned = join(root, 'node_modules', '@homie-rocks', 'studio', 'bin', 'statusline.mjs');
95
+ const script = existsSync(pinned) ? pinned : join(PACKAGE_ROOT, 'bin', 'statusline.mjs');
96
+ const slash = (p) => p.replace(/\\/g, '/');
97
+ return `node "${slash(script)}"${named ? ` --studio "${slash(root)}"` : ''}`;
98
+ }
99
+
100
+ /** Every settings file that may already hold a status line for this studio, most specific first. */
101
+ function settingsFiles(project, env) {
102
+ const user = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, 'settings.json') : join(env.HOME || homedir(), '.claude', 'settings.json');
103
+ return [
104
+ { where: 'this project, this person (.claude/settings.local.json)', path: join(project, LOCAL) },
105
+ { where: 'this project (.claude/settings.json)', path: join(project, '.claude', 'settings.json') },
106
+ { where: 'every project of this person (the user settings)', path: user },
107
+ ];
108
+ }
109
+
110
+ /**
111
+ * `homie-studio statusline --install`: the status line in this studio's .claude/settings.local.json. It never
112
+ * replaces a status line the person already has (anywhere Claude Code would read one) unless `replace`.
113
+ * `remove`: take ours out again.
114
+ */
115
+ export function installStatusLine(root, { replace = false, remove = false, project = null, env = process.env } = {}) {
116
+ // Claude Code reads project settings from the folder it was started in. A studio made as a subfolder of that
117
+ // folder is named in the command, and the setting goes where Claude Code reads it (`--project <that folder>`).
118
+ const here = project ? resolve(project) : root;
119
+ if (!existsSync(here)) return { ok: false, command: 'statusline install', why: `no folder ${here}` };
120
+ const files = settingsFiles(here, env);
121
+ const local = files[0].path;
122
+ let mine;
123
+ try { mine = readJson(local); } catch { return { ok: false, command: 'statusline install', why: `${LOCAL} is not valid JSON; fix it by hand first (nothing was changed)` }; }
124
+ if (remove) {
125
+ if (!isOurs(mine.statusLine)) return { ok: true, command: 'statusline remove', removed: false, message: 'No Homie status line in this studio; nothing changed.' };
126
+ delete mine.statusLine;
127
+ writeFileSync(local, `${JSON.stringify(mine, null, 2)}\n`);
128
+ return { ok: true, command: 'statusline remove', removed: true, file: LOCAL, message: `Removed the Homie status line from ${LOCAL}.` };
129
+ }
130
+ const command = statusLineCommand(root, { named: here !== root });
131
+ if (isOurs(mine.statusLine) && mine.statusLine.command === command) return { ok: true, command: 'statusline install', already: true, file: LOCAL, statusLine: mine.statusLine, message: 'The status line is already on in this studio.' };
132
+ if (!replace) {
133
+ for (const f of files) {
134
+ let s = null;
135
+ try { s = readJson(f.path).statusLine ?? null; } catch { s = null; }
136
+ if (s && !isOurs(s)) {
137
+ return { ok: false, command: 'statusline install', exists: { where: f.where, command: String(s.command ?? s.type ?? '').slice(0, 120) }, why: `there is already a status line (${f.where}). Nothing was changed. With --replace, this studio shows the build's progress instead (only here; the other one stays everywhere else).` };
138
+ }
139
+ }
140
+ }
141
+ mine.statusLine = { type: 'command', command, padding: 0, refreshInterval: 5 };
142
+ mkdirSync(join(here, '.claude'), { recursive: true });
143
+ writeFileSync(local, `${JSON.stringify(mine, null, 2)}\n`);
144
+ // Claude Code keeps settings.local.json out of git when it makes the file; this one we made, so say it here too.
145
+ const ignore = join(here, '.gitignore');
146
+ let ignored = false;
147
+ if (existsSync(ignore) || here === root) try {
148
+ const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
149
+ if (!/^\.claude\/settings\.local\.json\s*$/m.test(text)) { writeFileSync(ignore, `${text}${text && !text.endsWith('\n') ? '\n' : ''}# This person's own Claude Code settings for this studio (the status line).\n.claude/settings.local.json\n`); ignored = true; }
150
+ } catch { /* no .gitignore to keep */ }
151
+ return {
152
+ ok: true, command: 'statusline install', file: here === root ? LOCAL : join(here, LOCAL), statusLine: mine.statusLine, gitignore: ignored,
153
+ message: `The status line is on ${here === root ? `in this studio (${LOCAL})` : `for Claude Code started in ${here} (${LOCAL} there)`}: one line under the prompt with the current build's stage, a progress bar, its checks and what it spent. Claude Code reloads settings by itself; it shows from the next message. Turn it off with: npx --no-install homie-studio statusline --remove`,
154
+ };
155
+ }
@@ -175,6 +175,30 @@
175
175
  "## Beta": "9d02638207a26e73"
176
176
  }
177
177
  }
178
+ },
179
+ "0.11.0": {
180
+ "files": {
181
+ "CLAUDE.md": "ddf2b2a0376202f6",
182
+ "README.md": "684b3cca8001673c",
183
+ "games/README.md": "5e5e942baa7e0372",
184
+ "music/README.md": "b8a828de9311bd28",
185
+ "videos/README.md": "70614388cbb203c7",
186
+ "posts/README.md": "37841aae476bd540",
187
+ "site/README.md": "ca131cd0472464fb",
188
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
189
+ },
190
+ "sections": {
191
+ "AGENTS.md": {
192
+ "(intro)": "214aa46eb3a895d5",
193
+ "## Layout": "f14d641159480e6c",
194
+ "## Commands (all through the pinned CLI in node_modules)": "2af7c804c1e81d86",
195
+ "## The site": "7d3525ba39b7f262",
196
+ "## Making games": "c204473d9b468f49",
197
+ "## The Game Codex and progress": "0738ef719cef3a4c",
198
+ "## Rules": "969318425c99f5a5",
199
+ "## Beta": "9d02638207a26e73"
200
+ }
201
+ }
178
202
  }
179
203
  }
180
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",