@homie-rocks/studio 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -4
- package/bin/homie-studio.mjs +32 -5
- package/lib/cloudflare.mjs +2 -2
- package/lib/codex.mjs +28 -9
- package/lib/demo.mjs +85 -0
- package/lib/files.mjs +154 -0
- package/lib/handoff.mjs +128 -0
- package/lib/jobs.mjs +181 -0
- package/lib/mcp-tools.mjs +1000 -0
- package/lib/mcp.mjs +180 -0
- package/lib/scaffold.mjs +56 -24
- package/lib/studio.mjs +4 -1
- package/lib/template-history.json +27 -0
- package/lib/upgrade.mjs +1 -1
- package/mcp/ui/bridge.js +148 -0
- package/mcp/ui/build.js +63 -0
- package/mcp/ui/card.css +73 -0
- package/mcp/ui/codex.js +23 -0
- package/mcp/ui/setup.js +72 -0
- package/mcp/ui/studio.js +41 -0
- package/package.json +2 -1
- package/worker/site.mjs +24 -2
- package/worker/version.mjs +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,32 @@ npx homie-studio office # who is playing now, in
|
|
|
23
23
|
npx homie-studio upgrade # what a newer template adds to this studio (--apply to take it)
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
A new studio has no game: its home page says "First game coming soon" until the first one is made, and
|
|
27
|
+
`homie-studio demo` names a live game on Homie Arcade to try meanwhile. A starter is copied in only when the person
|
|
28
|
+
asks (`game new <id> --from gem-rush`, or `--from ember-vale` for a hero who lasts, with cloud saves).
|
|
29
|
+
|
|
30
|
+
## In one chat: `homie-studio mcp`
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx -y @homie-rocks/studio mcp --studios "<the folder the studios live in>" # the toolkit as a local MCP server (stdio)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The toolkit as MCP tools, so the chat that shows Homie's cards also does the work: the setup status, a new studio,
|
|
37
|
+
the live demo, make / remix / port / plan a game and its Game Codex, build, run it here, the two-browser check, a
|
|
38
|
+
playtest, deploy, publish, the progress feed, the studio's files, and the music, sound, art and video scripts where
|
|
39
|
+
their provider is set up. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and
|
|
40
|
+
input shape. Long work (npm install, a check, a deploy) runs in the background and reports through the progress
|
|
41
|
+
feed; MCP Apps cards (setup, build progress, studio, codex) are served as `ui://homie-studio/*`, and every tool also
|
|
42
|
+
answers in plain text. Homie for Claude Desktop (`desktop/` in this repository) is this server as a Desktop
|
|
43
|
+
Extension: the plain Claude app gets it with one install, and no terminal.
|
|
44
|
+
|
|
45
|
+
## From a phone: a one-line hand-off
|
|
46
|
+
|
|
47
|
+
The Claude app on a phone hands a build to a Claude Code cloud session with one line, `Continue building <Studio>:
|
|
48
|
+
build hb_…`. In the session, `homie-studio handoff hb_…` fetches the brief from the directory by the build id, checks
|
|
49
|
+
in for a studio still being set up, takes the build so the chat's card follows it, and prints the steps (every
|
|
50
|
+
studio's `HANDOFF.md` says so).
|
|
51
|
+
|
|
26
52
|
## From GitHub, with Cloudflare's own CI (Workers Builds, Previews, Deploy to Cloudflare)
|
|
27
53
|
|
|
28
54
|
A studio keeps `wrangler.jsonc` at its root (a studio made before 0.10.0 keeps `site/wrangler.jsonc`, and every
|
|
@@ -39,7 +65,8 @@ computer involved:
|
|
|
39
65
|
|
|
40
66
|
The button copies the public template (`template/` in this repository, exactly what `homie-studio new --template`
|
|
41
67
|
writes) into the person's GitHub, makes the Worker, the D1 database and the rooms on their Cloudflare account,
|
|
42
|
-
and connects Workers Builds. The site
|
|
68
|
+
and connects Workers Builds. The site goes live with its own home page ("First game coming soon": no starter game
|
|
69
|
+
is put in a studio unless the person asks for one), and its Home has a
|
|
43
70
|
**Connect to Claude** band: one tap links it to the Claude chat that set it up (the Homie MCP tool `studio_setup`).
|
|
44
71
|
In the Claude Code session that works in the repository, `homie-studio setup attach <hs_…>` then gives the studio
|
|
45
72
|
the name chosen in the chat and removes the band.
|
|
@@ -73,8 +100,8 @@ site. A game made with Homie's arcade controls that knocks for a Homie box (`/__
|
|
|
73
100
|
A studio pins one version, so nothing changes until it asks. To take a newer one:
|
|
74
101
|
|
|
75
102
|
```sh
|
|
76
|
-
npx -y @homie-rocks/studio@0.
|
|
77
|
-
npx -y @homie-rocks/studio@0.
|
|
103
|
+
npx -y @homie-rocks/studio@0.14.0 upgrade # the plan; changes nothing
|
|
104
|
+
npx -y @homie-rocks/studio@0.14.0 upgrade --apply # after the person agrees
|
|
78
105
|
npm install && npm run build
|
|
79
106
|
```
|
|
80
107
|
|
|
@@ -203,7 +230,7 @@ device; the owner's own passkey account (`homie-studio players owner`) counts as
|
|
|
203
230
|
## Setup status
|
|
204
231
|
|
|
205
232
|
```sh
|
|
206
|
-
npx -y @homie-rocks/studio@0.
|
|
233
|
+
npx -y @homie-rocks/studio@0.14.0 setup status --connector yes # before a studio exists
|
|
207
234
|
npx homie-studio setup status # in a studio (also: homie-studio doctor)
|
|
208
235
|
```
|
|
209
236
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -5,8 +5,14 @@
|
|
|
5
5
|
* list its games in the homie.rocks directory.
|
|
6
6
|
*
|
|
7
7
|
* homie-studio new <folder> --name "<Studio Name>" [--homie <directory url>] [--no-install] [--template]
|
|
8
|
-
* (--template: the public "Deploy to Cloudflare" template
|
|
8
|
+
* (--template: the public "Deploy to Cloudflare" template, with a Connect band;
|
|
9
|
+
* a new studio has no game: its home says "First game coming soon")
|
|
9
10
|
* homie-studio starters
|
|
11
|
+
* homie-studio demo a live multiplayer game to try now (Homie Arcade), nothing copied into the studio
|
|
12
|
+
* homie-studio mcp [--studios <folder>] [--skills <folder>]
|
|
13
|
+
* this toolkit as a local MCP server (stdio): the Claude desktop app's Homie
|
|
14
|
+
* extension, Claude Code or any MCP client builds in the same chat that shows
|
|
15
|
+
* the cards (lib/mcp.mjs); --studios: the folder the studios live in
|
|
10
16
|
* homie-studio game new <id> [--from gem-rush] [--name "<Game Name>"]
|
|
11
17
|
* homie-studio game remix <source.json url> --id <new id>
|
|
12
18
|
* homie-studio games
|
|
@@ -75,6 +81,9 @@
|
|
|
75
81
|
* into it (stage, each check going green, a preview picture) and stop
|
|
76
82
|
* when asked. Without one, nothing changes.
|
|
77
83
|
*
|
|
84
|
+
* homie-studio handoff <hb_…> (a Claude Code session started from the Claude app with one line, "Continue building
|
|
85
|
+
* <Studio>: build hb_…": fetch the person's brief, check in for a new studio, take
|
|
86
|
+
* the build so the chat's card follows it, and print the steps; HANDOFF.md)
|
|
78
87
|
* homie-studio setup attach <hs_…> (this repository is the studio the Claude app's setup card is making: say so, once,
|
|
79
88
|
* learn its live address, and give a template copy its real name)
|
|
80
89
|
* homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
|
|
@@ -83,7 +92,9 @@
|
|
|
83
92
|
* ElevenLabs, fal; green, missing or "do this now", what each unlocks and its
|
|
84
93
|
* exact fix. Read-only and safe any time, inside a studio or before one exists.
|
|
85
94
|
*
|
|
86
|
-
* homie-studio codex new <id>
|
|
95
|
+
* homie-studio codex new <id> [--name "<Name>"]
|
|
96
|
+
* games/<id>/CODEX.md: the Game Codex, every section, in the game's colours (a game
|
|
97
|
+
* is planned before it is made: with no game <id> yet, it starts its folder)
|
|
87
98
|
* homie-studio codex <id> [--artifact] [--open]
|
|
88
99
|
* the codex as a page in the game's own look (.studio/codex/<id>.html; it redraws
|
|
89
100
|
* itself as the build's progress changes); --artifact: a copy to publish as a
|
|
@@ -123,6 +134,9 @@ import { formatStatus, setupStatus } from '../lib/doctor.mjs';
|
|
|
123
134
|
import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
|
|
124
135
|
import { installStatusLine, statusLine } from '../lib/statusline.mjs';
|
|
125
136
|
import { restartWithProxy } from '../lib/net.mjs';
|
|
137
|
+
import { demoGames, formatDemo } from '../lib/demo.mjs';
|
|
138
|
+
import { serveMcp } from '../lib/mcp.mjs';
|
|
139
|
+
import { formatHandoff, handoff } from '../lib/handoff.mjs';
|
|
126
140
|
|
|
127
141
|
const argv = process.argv.slice(2);
|
|
128
142
|
const flags = new Map();
|
|
@@ -142,7 +156,7 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
|
|
|
142
156
|
|
|
143
157
|
function print(result) {
|
|
144
158
|
if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
|
|
145
|
-
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
159
|
+
if (result.ok === false && result.command !== 'port check' && !(result.command === 'look' && result.rows)) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}${result.instead ? `\n${result.instead}` : ''}\n`); return; }
|
|
146
160
|
if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
147
161
|
const lines = [];
|
|
148
162
|
switch (result.command) {
|
|
@@ -271,6 +285,12 @@ function print(result) {
|
|
|
271
285
|
case 'setup status':
|
|
272
286
|
lines.push(formatStatus(result));
|
|
273
287
|
break;
|
|
288
|
+
case 'demo':
|
|
289
|
+
lines.push(formatDemo(result));
|
|
290
|
+
break;
|
|
291
|
+
case 'handoff':
|
|
292
|
+
lines.push(formatHandoff(result));
|
|
293
|
+
break;
|
|
274
294
|
case 'codex':
|
|
275
295
|
lines.push(`The Game Codex for ${result.title}: ${result.file}${result.opened ? ' (opened in the browser)' : ''}`,
|
|
276
296
|
` tabs: ${result.sections.map((x) => x.title).join(' · ')} · Build status${result.build ? ` (${result.build.state}, ${result.build.percent}%)` : ''}`,
|
|
@@ -370,6 +390,12 @@ async function main() {
|
|
|
370
390
|
if (cmd === 'version' || flags.has('version')) return { ok: true, command: 'version', version: STUDIO_VERSION };
|
|
371
391
|
if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install'), template: flags.has('template') });
|
|
372
392
|
if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
|
|
393
|
+
if (cmd === 'demo') return demoGames();
|
|
394
|
+
if (cmd === 'mcp') {
|
|
395
|
+
// stdout carries MCP messages only from here on (lib/mcp.mjs); every word for a person goes to stderr.
|
|
396
|
+
await serveMcp({ studios: flags.get('studios') ?? process.env.HOMIE_STUDIOS ?? null, skills: flags.get('skills') ?? process.env.HOMIE_SKILLS ?? null, install: !flags.has('no-install'), directory: flags.get('homie') ?? null });
|
|
397
|
+
return { ok: true, command: 'help' };
|
|
398
|
+
}
|
|
373
399
|
|
|
374
400
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
375
401
|
if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
|
|
@@ -386,6 +412,7 @@ async function main() {
|
|
|
386
412
|
if (cmd === 'codex') return codexCommand(root, sub);
|
|
387
413
|
if (cmd === 'progress') return progressCommand(root, sub);
|
|
388
414
|
if (cmd === 'setup' && sub === 'attach') return setupAttach(root, positional[2], { homie: flags.get('homie') });
|
|
415
|
+
if (cmd === 'handoff') return handoff(root, sub, { homie: flags.get('homie') });
|
|
389
416
|
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
390
417
|
if (cmd === 'port' && sub === 'check') {
|
|
391
418
|
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
@@ -469,7 +496,7 @@ async function main() {
|
|
|
469
496
|
/** `homie-studio codex …` (lib/codex.mjs): the Game Codex of one game. */
|
|
470
497
|
function codexCommand(root, sub) {
|
|
471
498
|
const games = listGames(root);
|
|
472
|
-
if (sub === 'new') return newCodex(root, positional[2] ?? (games.length === 1 ? games[0].id : undefined));
|
|
499
|
+
if (sub === 'new') return newCodex(root, positional[2] ?? (games.length === 1 ? games[0].id : undefined), { name: flags.get('name') ?? null });
|
|
473
500
|
if (sub === 'link') {
|
|
474
501
|
const id = positional[2] ?? (games.length === 1 ? games[0].id : null);
|
|
475
502
|
if (!id || !games.some((g) => g.id === id)) return { ok: false, command: 'codex link', why: 'name the game: homie-studio codex link <id>' };
|
|
@@ -620,7 +647,7 @@ async function stopDev(root) {
|
|
|
620
647
|
async function dev(root) {
|
|
621
648
|
const port = String(flags.get('port') ?? 8787);
|
|
622
649
|
const b = await build(root, { log });
|
|
623
|
-
if (!b.catalogue.length && !b.songs.length && !b.videos.length)
|
|
650
|
+
if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
|
|
624
651
|
const bin = wranglerBin(root);
|
|
625
652
|
if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
|
|
626
653
|
const studio = readStudio(root);
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -159,8 +159,8 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
const b = await build(root, { log });
|
|
162
|
-
|
|
163
|
-
step(`built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)`);
|
|
162
|
+
// A new studio goes live with its own Home ("First game coming soon") before it has a game.
|
|
163
|
+
step(b.catalogue.length || b.songs.length || b.videos.length ? `built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)` : 'built the home page (no game yet: "First game coming soon")');
|
|
164
164
|
|
|
165
165
|
// The Worker: never one this studio did not make.
|
|
166
166
|
if (!created.has(`worker:${cf.worker}`)) {
|
package/lib/codex.mjs
CHANGED
|
@@ -471,6 +471,20 @@ function lookOf(root, game, meta, emb) {
|
|
|
471
471
|
return { palette, fonts: { display: fam.display ?? stack.display, body: fam.body ?? stack.body, mono: fam.mono ?? stack.mono }, links: [...new Set(links)], faces };
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* The game a codex belongs to. A new studio has no game, and the plan comes before the game is made, so a codex may
|
|
476
|
+
* sit in games/<id>/ before its game.json does: then the "game" is that planned folder. `create`: a codex is being
|
|
477
|
+
* started for an id with no folder yet.
|
|
478
|
+
*/
|
|
479
|
+
function codexGame(root, id, { create = false } = {}) {
|
|
480
|
+
const game = listGames(root).find((g) => g.id === id);
|
|
481
|
+
if (game) return game;
|
|
482
|
+
if (!GAME_ID.test(String(id ?? ''))) return null;
|
|
483
|
+
const dir = join(root, 'games', id);
|
|
484
|
+
if (existsSync(join(dir, 'game.json'))) return null;
|
|
485
|
+
return existsSync(join(dir, CODEX_FILE)) || create ? { id, dir, name: null, planned: true } : null;
|
|
486
|
+
}
|
|
487
|
+
|
|
474
488
|
/**
|
|
475
489
|
* The page for one game. `mode`: 'file' (this computer; reloads while a build runs), 'artifact' (one file to publish
|
|
476
490
|
* as a Claude artifact), 'site' (the studio site's private page). `feed`: a progress feed document, else the game's
|
|
@@ -478,8 +492,8 @@ function lookOf(root, game, meta, emb) {
|
|
|
478
492
|
*/
|
|
479
493
|
export function renderCodex(root, id, { mode = 'file', feed, now = new Date() } = {}) {
|
|
480
494
|
if (!GAME_ID.test(String(id ?? ''))) throw new Error(`no game id ${JSON.stringify(id)}`);
|
|
481
|
-
const game =
|
|
482
|
-
if (!game) throw new Error(`no game "${id}" in games
|
|
495
|
+
const game = codexGame(root, id);
|
|
496
|
+
if (!game) throw new Error(`no game "${id}" in games/, and no codex planning it`);
|
|
483
497
|
const file = join(game.dir, CODEX_FILE);
|
|
484
498
|
if (!existsSync(file)) throw new Error(`games/${id}/${CODEX_FILE} does not exist yet: npx --no-install homie-studio codex new ${id}`);
|
|
485
499
|
if (statSync(file).size > LIMITS.source) throw new Error(`games/${id}/${CODEX_FILE} is over ${LIMITS.source / 1024} KB`);
|
|
@@ -576,9 +590,12 @@ export function buildCodexPages(root, dist, ids, { log = () => {} } = {}) {
|
|
|
576
590
|
/* ------------------------------------------------------------------ a new codex */
|
|
577
591
|
|
|
578
592
|
/** `homie-studio codex new <id>`: CODEX.md with every section, its look taken from the game and the studio. */
|
|
579
|
-
export function newCodex(root, id) {
|
|
580
|
-
|
|
581
|
-
|
|
593
|
+
export function newCodex(root, id, { name = null } = {}) {
|
|
594
|
+
// A game is planned before it is made: with no game of this id, the codex starts its folder (games/<id>/CODEX.md),
|
|
595
|
+
// and `game new <id>` later makes the game around it.
|
|
596
|
+
const game = codexGame(root, id, { create: true });
|
|
597
|
+
if (!game) return { ok: false, command: 'codex new', why: `"${id}" is not a game id (lowercase letters, digits and hyphens, up to 40)` };
|
|
598
|
+
if (game.planned) { game.name = name ? String(name).slice(0, 60) : id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '); mkdirSync(game.dir, { recursive: true }); }
|
|
582
599
|
const file = join(game.dir, CODEX_FILE);
|
|
583
600
|
if (existsSync(file)) return { ok: false, command: 'codex new', why: `games/${id}/${CODEX_FILE} exists already: change it, never replace it` };
|
|
584
601
|
const theme = readTheme(root);
|
|
@@ -608,7 +625,7 @@ ${game.blurb ?? ''}
|
|
|
608
625
|
|
|
609
626
|
## Latest
|
|
610
627
|
|
|
611
|
-
- ${today}: The codex starts. The game is a copy of the ${game.from?.starter ?? 'starter'} starter for now
|
|
628
|
+
- ${today}: The codex starts. ${game.planned ? 'The game is planned here first; it is made once the plan is agreed.' : `The game is a copy of the ${game.from?.starter ?? 'starter'} starter for now.`}
|
|
612
629
|
|
|
613
630
|
## Concept
|
|
614
631
|
|
|
@@ -645,18 +662,20 @@ ${game.blurb ?? ''}
|
|
|
645
662
|
|
|
646
663
|
## Milestones
|
|
647
664
|
|
|
648
|
-
|
|
665
|
+
${game.planned ? `- [ ] The plan (this codex)
|
|
666
|
+
- [ ] The first playable version of the plan, checked with two browsers
|
|
667
|
+
- [ ] Art, sound and its landing page; online` : `- [x] Step 1: a working copy of the starter plays in two browsers
|
|
649
668
|
- [ ] Step 2: one small change from one sentence
|
|
650
669
|
- [ ] Step 3: the plan (this codex)
|
|
651
670
|
- [ ] Step 4: the first playable version of the plan, checked with two browsers
|
|
652
|
-
- [ ] Step 5: art, sound and its landing page; online
|
|
671
|
+
- [ ] Step 5: art, sound and its landing page; online`}
|
|
653
672
|
|
|
654
673
|
## Open questions
|
|
655
674
|
|
|
656
675
|
-
|
|
657
676
|
`;
|
|
658
677
|
writeFileSync(file, text);
|
|
659
|
-
return { ok: true, command: 'codex new', id, file: relative(root, file), next: [`fill it from the plan interview, then: npx --no-install homie-studio codex ${id}`] };
|
|
678
|
+
return { ok: true, command: 'codex new', id, file: relative(root, file), planned: Boolean(game.planned), next: [`fill it from the plan interview, then: npx --no-install homie-studio codex ${id}`, ...(game.planned ? [`once the plan is agreed: npx --no-install homie-studio game new ${id} --from gem-rush --name "<Name>" makes the game around it (the codex stays)`] : [])] };
|
|
660
679
|
}
|
|
661
680
|
|
|
662
681
|
/** A sign-in link that opens one game's codex on the live site (the stats page's one-time sign-in, then the codex). */
|
package/lib/demo.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio demo` — "see a working game" with nothing copied into the studio: a live multiplayer game on Homie
|
|
3
|
+
* Arcade (arcade.homie.rocks), the house's own studio of small public games, made with this same toolkit. Press Play
|
|
4
|
+
* and you are in a live public room with whoever is playing, bots in the empty seats; open it in two tabs (or a
|
|
5
|
+
* phone and a computer) and you are two players in the same room.
|
|
6
|
+
*
|
|
7
|
+
* A new studio starts with no game (its home page says "First game coming soon"). A starter is copied INTO the
|
|
8
|
+
* studio only when the person asks for one: `homie-studio game new <id> --from gem-rush`.
|
|
9
|
+
*
|
|
10
|
+
* It reads the arcade's own public manifest (/.well-known/homie-studio.json) and its live rooms (/api/rooms), so the
|
|
11
|
+
* links are the ones live right now; when the arcade does not answer, it names its usual first pick. Read-only.
|
|
12
|
+
*/
|
|
13
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
14
|
+
|
|
15
|
+
export const DEMO_STUDIO = 'https://arcade.homie.rocks';
|
|
16
|
+
/** The arcade's games that show a stranger the most in a minute, in order. */
|
|
17
|
+
export const DEMO_PICKS = ['asteroids-arena', 'bone-burglar', 'tiny-platformer', '2048-race', 'octree-arena'];
|
|
18
|
+
export const DEMO_FALLBACK = Object.freeze({
|
|
19
|
+
id: 'asteroids-arena', name: 'Asteroids Arena', blurb: 'Up to six ships on one wrap-around rock field. Top score in two minutes wins.',
|
|
20
|
+
page: `${DEMO_STUDIO}/asteroids-arena/`, play: `${DEMO_STUDIO}/asteroids-arena/play`, cover: null, players: { min: 1, max: 6 },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const HOW = 'Open Play in two browser tabs, or on a phone and a computer: you are two players in the same live public room, and bots fill the empty seats. Keys on a computer, touch on a phone.';
|
|
24
|
+
const COPY = 'Want a working game inside your own studio instead? Only when you ask: `npx --no-install homie-studio game new <id> --from gem-rush` copies the Gem Rush starter (an arena: rounds, bots, one readable file), or `--from ember-vale` the Ember Vale starter (a hero who lasts for days, with cloud saves), into games/<id>/.';
|
|
25
|
+
|
|
26
|
+
const text = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
27
|
+
const onSite = (url, site) => (typeof url === 'string' && url.startsWith(`${site}/`) ? url : null);
|
|
28
|
+
|
|
29
|
+
async function getJson(fetchFn, url, ms) {
|
|
30
|
+
const res = await fetchFn(url, { headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION} (demo)` }, signal: AbortSignal.timeout(ms) });
|
|
31
|
+
if (!res.ok) throw new Error(`${url} answered ${res.status}`);
|
|
32
|
+
return res.json();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUDIO, timeoutMs = 6000 } = {}) {
|
|
36
|
+
const site = String(studio).replace(/\/+$/, '');
|
|
37
|
+
let manifest = null; let rooms = null; let why = null;
|
|
38
|
+
try {
|
|
39
|
+
[manifest, rooms] = await Promise.all([
|
|
40
|
+
getJson(fetchFn, `${site}/.well-known/homie-studio.json`, timeoutMs),
|
|
41
|
+
getJson(fetchFn, `${site}/api/rooms`, timeoutMs).catch(() => null),
|
|
42
|
+
]);
|
|
43
|
+
} catch (error) { why = error?.message ?? String(error); }
|
|
44
|
+
const live = new Map();
|
|
45
|
+
for (const r of Array.isArray(rooms?.rooms) ? rooms.rooms : []) {
|
|
46
|
+
const id = text(r?.game, 40);
|
|
47
|
+
if (id) live.set(id, (live.get(id) ?? 0) + (Number(r?.players) || 0));
|
|
48
|
+
}
|
|
49
|
+
const listed = (Array.isArray(manifest?.games) ? manifest.games : [])
|
|
50
|
+
.map((g) => ({
|
|
51
|
+
id: text(g?.id, 40), name: text(g?.name, 60), blurb: text(g?.blurb, 200),
|
|
52
|
+
page: onSite(g?.page, site), play: onSite(g?.play, site), cover: onSite(g?.cover, site),
|
|
53
|
+
players: g?.players && Number.isFinite(g.players.max) ? { min: Number(g.players.min) || 1, max: Number(g.players.max) } : null,
|
|
54
|
+
}))
|
|
55
|
+
.filter((g) => /^[a-z0-9][a-z0-9-]{0,39}$/.test(g.id) && g.play);
|
|
56
|
+
const rank = (g) => { const i = DEMO_PICKS.indexOf(g.id); return i < 0 ? DEMO_PICKS.length : i; };
|
|
57
|
+
// A game people are playing right now goes first: that is the best demo of all.
|
|
58
|
+
const games = listed.map((g) => ({ ...g, playingNow: live.get(g.id) ?? 0 }))
|
|
59
|
+
.sort((a, b) => (b.playingNow > 0) - (a.playingNow > 0) || rank(a) - rank(b) || a.name.localeCompare(b.name))
|
|
60
|
+
.slice(0, 5);
|
|
61
|
+
const pick = games[0] ?? { ...DEMO_FALLBACK, playingNow: 0 };
|
|
62
|
+
return {
|
|
63
|
+
ok: true, command: 'demo',
|
|
64
|
+
studio: { name: text(manifest?.name, 60) || 'Homie Arcade', site },
|
|
65
|
+
pick, games: games.length ? games : [pick],
|
|
66
|
+
...(why ? { note: `the arcade did not answer just now (${why}); this is its usual first pick` } : {}),
|
|
67
|
+
how: HOW, copy: COPY,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The demo as a person reads it in a terminal. */
|
|
72
|
+
export function formatDemo(r) {
|
|
73
|
+
const p = r.pick;
|
|
74
|
+
return [
|
|
75
|
+
`See a working game: ${p.name} on ${r.studio.name}${p.playingNow ? ` (${p.playingNow} playing right now)` : ''}`,
|
|
76
|
+
` Play: ${p.play}`,
|
|
77
|
+
...(p.blurb ? [` ${p.blurb}`] : []),
|
|
78
|
+
'',
|
|
79
|
+
r.how,
|
|
80
|
+
...(r.games.length > 1 ? ['', 'More to try:', ...r.games.slice(1).map((g) => ` ${g.name}: ${g.play}`)] : []),
|
|
81
|
+
...(r.note ? ['', `(${r.note})`] : []),
|
|
82
|
+
'',
|
|
83
|
+
r.copy,
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
package/lib/files.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE STUDIO'S FILES, FOR THE LOCAL MCP SERVER (lib/mcp.mjs). The Claude desktop app's chat has no file tools of its
|
|
3
|
+
* own, so the server gives it these, and only inside ONE studio folder: list, read, write, edit and search. A path
|
|
4
|
+
* is relative to the studio; nothing outside it is ever read or written (a symbolic link that leads out is refused
|
|
5
|
+
* too). Secrets never pass: .env and .dev.vars files and the progress feeds' write keys are neither read nor
|
|
6
|
+
* written, and git's own folder and node_modules are never written.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const FILE_LIMITS = Object.freeze({ write: 2 * 1024 * 1024, read: 400 * 1024, image: 1536 * 1024, lines: 2000, list: 600, matches: 200 });
|
|
12
|
+
const IMAGES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
13
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.studio', 'dist']);
|
|
14
|
+
|
|
15
|
+
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
16
|
+
|
|
17
|
+
function secret(rel) {
|
|
18
|
+
const parts = rel.split(/[\\/]/);
|
|
19
|
+
const name = parts[parts.length - 1];
|
|
20
|
+
return /^\.env(\..*)?$/.test(name) || name === '.dev.vars' || /\.key$/.test(name) && parts.includes('progress');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A path in the studio, checked: { abs, rel }. `write`: also refuse git's folder and node_modules. */
|
|
24
|
+
export function studioPath(root, path, { write = false } = {}) {
|
|
25
|
+
const raw = String(path ?? '').trim() || '.';
|
|
26
|
+
if (raw.includes('\0')) throw new Error('that path has a NUL in it');
|
|
27
|
+
const base = realpathSync(root);
|
|
28
|
+
const abs = resolve(base, raw);
|
|
29
|
+
if (!inside(base, abs)) throw new Error(`${raw} is outside the studio (${base}); only the studio's own files are reachable`);
|
|
30
|
+
// The nearest existing folder on the way must also be inside (a symbolic link must not lead out).
|
|
31
|
+
let at = abs;
|
|
32
|
+
while (!existsSync(at)) at = dirname(at);
|
|
33
|
+
const real = realpathSync(at);
|
|
34
|
+
if (!inside(base, real)) throw new Error(`${raw} leads outside the studio through a link`);
|
|
35
|
+
const rel = relative(base, abs) || '.';
|
|
36
|
+
if (secret(rel)) throw new Error(`${rel} holds a secret: it is never read or written here`);
|
|
37
|
+
if (write) {
|
|
38
|
+
const top = rel.split(/[\\/]/)[0];
|
|
39
|
+
if (top === '.git' || rel.split(/[\\/]/).includes('node_modules')) throw new Error(`${rel} is git's or npm's own: never written by hand`);
|
|
40
|
+
if (rel === '.') throw new Error('name a file');
|
|
41
|
+
}
|
|
42
|
+
return { abs, rel, base };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listFiles(root, path = '.', { depth = 2 } = {}) {
|
|
46
|
+
const { abs, rel } = studioPath(root, path);
|
|
47
|
+
if (!existsSync(abs)) throw new Error(`${rel} does not exist`);
|
|
48
|
+
if (!statSync(abs).isDirectory()) throw new Error(`${rel} is a file (file_read reads it)`);
|
|
49
|
+
const out = [];
|
|
50
|
+
const walk = (dir, d) => {
|
|
51
|
+
let entries = [];
|
|
52
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
53
|
+
entries.sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name));
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
if (out.length >= FILE_LIMITS.list) return;
|
|
56
|
+
if (e.name === '.DS_Store') continue;
|
|
57
|
+
const p = join(dir, e.name);
|
|
58
|
+
const r = relative(realpathSync(root), p);
|
|
59
|
+
if (secret(r)) continue;
|
|
60
|
+
if (e.isDirectory()) {
|
|
61
|
+
out.push({ path: `${r}/`, type: 'dir' });
|
|
62
|
+
if (d < depth && !SKIP_DIRS.has(e.name)) walk(p, d + 1);
|
|
63
|
+
} else {
|
|
64
|
+
let size = null;
|
|
65
|
+
try { size = lstatSync(p).size; } catch { /* gone */ }
|
|
66
|
+
out.push({ path: r, type: e.isSymbolicLink() ? 'link' : 'file', size });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(abs, 1);
|
|
71
|
+
return { path: rel, entries: out, truncated: out.length >= FILE_LIMITS.list };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Text (with line numbers from `offset`), or a picture as MCP image content. */
|
|
75
|
+
export function readStudioFile(root, path, { offset = 1, limit = FILE_LIMITS.lines } = {}) {
|
|
76
|
+
const { abs, rel } = studioPath(root, path);
|
|
77
|
+
if (!existsSync(abs)) throw new Error(`${rel} does not exist`);
|
|
78
|
+
const st = statSync(abs);
|
|
79
|
+
if (st.isDirectory()) throw new Error(`${rel} is a folder (file_list lists it)`);
|
|
80
|
+
const mime = IMAGES[extname(abs).toLowerCase()];
|
|
81
|
+
if (mime) {
|
|
82
|
+
if (st.size > FILE_LIMITS.image) throw new Error(`${rel} is ${Math.round(st.size / 1024)} KB, over the ${FILE_LIMITS.image / 1024} KB a picture may be here`);
|
|
83
|
+
return { rel, image: { mimeType: mime, data: readFileSync(abs).toString('base64') }, bytes: st.size };
|
|
84
|
+
}
|
|
85
|
+
if (st.size > FILE_LIMITS.read * 8) throw new Error(`${rel} is ${Math.round(st.size / 1024)} KB: too big to read here`);
|
|
86
|
+
const buf = readFileSync(abs);
|
|
87
|
+
if (buf.subarray(0, 8000).includes(0)) throw new Error(`${rel} is not a text file`);
|
|
88
|
+
const lines = buf.toString('utf8').split('\n');
|
|
89
|
+
const from = Math.max(1, Number(offset) || 1);
|
|
90
|
+
const n = Math.min(Math.max(1, Number(limit) || FILE_LIMITS.lines), FILE_LIMITS.lines);
|
|
91
|
+
let text = '';
|
|
92
|
+
let shown = 0;
|
|
93
|
+
for (let i = from - 1; i < lines.length && shown < n; i++, shown++) {
|
|
94
|
+
const line = `${String(i + 1).padStart(5)}\t${lines[i]}\n`;
|
|
95
|
+
if (text.length + line.length > FILE_LIMITS.read) break;
|
|
96
|
+
text += line;
|
|
97
|
+
}
|
|
98
|
+
return { rel, text, from, to: from + shown - 1, lines: lines.length, more: from + shown - 1 < lines.length };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function writeStudioFile(root, path, content) {
|
|
102
|
+
const { abs, rel } = studioPath(root, path, { write: true });
|
|
103
|
+
const text = String(content ?? '');
|
|
104
|
+
if (Buffer.byteLength(text) > FILE_LIMITS.write) throw new Error(`at most ${FILE_LIMITS.write / 1024 / 1024} MB a file`);
|
|
105
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) throw new Error(`${rel} is a folder`);
|
|
106
|
+
const existed = existsSync(abs);
|
|
107
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
108
|
+
writeFileSync(abs, text);
|
|
109
|
+
return { rel, bytes: Buffer.byteLength(text), created: !existed };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function editStudioFile(root, path, oldText, newText, { all = false } = {}) {
|
|
113
|
+
const { abs, rel } = studioPath(root, path, { write: true });
|
|
114
|
+
if (!existsSync(abs)) throw new Error(`${rel} does not exist (file_write makes a new file)`);
|
|
115
|
+
const was = readFileSync(abs, 'utf8');
|
|
116
|
+
const from = String(oldText ?? '');
|
|
117
|
+
if (!from) throw new Error('old is the exact text to replace (it cannot be empty)');
|
|
118
|
+
const count = was.split(from).length - 1;
|
|
119
|
+
if (!count) throw new Error(`the text to replace is not in ${rel} (it must match exactly, spaces and line breaks too)`);
|
|
120
|
+
if (count > 1 && !all) throw new Error(`the text to replace is in ${rel} ${count} times: give more of it so it is unique, or all: true`);
|
|
121
|
+
const next = all ? was.split(from).join(String(newText ?? '')) : was.replace(from, () => String(newText ?? ''));
|
|
122
|
+
if (Buffer.byteLength(next) > FILE_LIMITS.write) throw new Error(`at most ${FILE_LIMITS.write / 1024 / 1024} MB a file`);
|
|
123
|
+
writeFileSync(abs, next);
|
|
124
|
+
return { rel, replaced: all ? count : 1 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Lines matching a regular expression, in the studio's text files (never node_modules, git, builds or .studio). */
|
|
128
|
+
export function searchStudio(root, pattern, { path = '.', ignoreCase = false } = {}) {
|
|
129
|
+
let re;
|
|
130
|
+
try { re = new RegExp(String(pattern ?? ''), ignoreCase ? 'i' : ''); } catch (error) { throw new Error(`not a regular expression: ${error.message}`); }
|
|
131
|
+
const { abs } = studioPath(root, path);
|
|
132
|
+
const base = realpathSync(root);
|
|
133
|
+
const matches = [];
|
|
134
|
+
const walk = (dir) => {
|
|
135
|
+
let entries = [];
|
|
136
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
137
|
+
for (const e of entries) {
|
|
138
|
+
if (matches.length >= FILE_LIMITS.matches) return;
|
|
139
|
+
const p = join(dir, e.name);
|
|
140
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(p); continue; }
|
|
141
|
+
if (!e.isFile() || IMAGES[extname(e.name).toLowerCase()]) continue;
|
|
142
|
+
const r = relative(base, p);
|
|
143
|
+
if (secret(r)) continue;
|
|
144
|
+
let buf;
|
|
145
|
+
try { if (statSync(p).size > 1024 * 1024) continue; buf = readFileSync(p); } catch { continue; }
|
|
146
|
+
if (buf.subarray(0, 8000).includes(0)) continue;
|
|
147
|
+
const lines = buf.toString('utf8').split('\n');
|
|
148
|
+
for (let i = 0; i < lines.length && matches.length < FILE_LIMITS.matches; i++) if (re.test(lines[i])) matches.push({ path: r, line: i + 1, text: lines[i].slice(0, 300) });
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
if (statSync(abs).isDirectory()) walk(abs);
|
|
152
|
+
else walk(dirname(abs));
|
|
153
|
+
return { pattern: String(pattern), matches, truncated: matches.length >= FILE_LIMITS.matches };
|
|
154
|
+
}
|
package/lib/handoff.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio handoff <hb_…>` — what a Claude Code session started from the Claude app does with the ONE line it
|
|
3
|
+
* was given ("Continue building <Studio>: build hb_…", HANDOFF.md in every studio):
|
|
4
|
+
*
|
|
5
|
+
* 1. fetch the brief the person gave in the chat from the directory (GET /api/studio/progress/<hb>/brief: the
|
|
6
|
+
* build id is the read capability, and it is only ever shown in the person's own chat and that link);
|
|
7
|
+
* 2. for a build that belongs to a studio still being set up, check in from this repository (`setup attach`);
|
|
8
|
+
* 3. take the build once (`progress attach`), so the chat's card follows the work;
|
|
9
|
+
* 4. print the brief and the steps for its kind, in this toolkit's own commands.
|
|
10
|
+
*
|
|
11
|
+
* No key travels in a prompt, and no wall of text either. When this session's network does not reach the directory,
|
|
12
|
+
* it says so and names the other road: the Homie connector's build_progress, which answers with the same brief.
|
|
13
|
+
*/
|
|
14
|
+
import { request } from './net.mjs';
|
|
15
|
+
import { startProgress } from './progress.mjs';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { studioRepo } from './repo.mjs';
|
|
18
|
+
|
|
19
|
+
/** The repository this checkout IS (its origin remote), even Homie's engine one: the session must be in the studio's. */
|
|
20
|
+
function originRepo(root) {
|
|
21
|
+
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', timeout: 5000 });
|
|
22
|
+
if (r.status !== 0) return null;
|
|
23
|
+
// Not repoFromUrl: that one never names the engine repository, and this must, to refuse it.
|
|
24
|
+
const m = /[/:]([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/([A-Za-z0-9._-]{1,100})$/.exec(r.stdout.trim().replace(/\.git$/, '').replace(/\/+$/, ''));
|
|
25
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
26
|
+
}
|
|
27
|
+
import { setupAttach } from './setup.mjs';
|
|
28
|
+
import { listGames, readStudio } from './studio.mjs';
|
|
29
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
30
|
+
|
|
31
|
+
const BUILD = /^hb_[a-f0-9]{32}$/;
|
|
32
|
+
const SETUP = /^hs_[a-f0-9]{32}$/;
|
|
33
|
+
|
|
34
|
+
const ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
35
|
+
const line = (v, max) => String(v ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
|
|
36
|
+
const lines = (v, max) => String(v ?? '').split(/\r?\n/).map((l) => line(l, max)).filter(Boolean).join('\n').slice(0, max);
|
|
37
|
+
|
|
38
|
+
/** The steps for a kind of build, in this toolkit's commands (the brief says what; these say how). */
|
|
39
|
+
export function stepsFor(h, { games = [] } = {}) {
|
|
40
|
+
const id = ID.test(String(h.id ?? '')) ? h.id : null;
|
|
41
|
+
const has = id && games.includes(id);
|
|
42
|
+
const q = (s) => JSON.stringify(String(s));
|
|
43
|
+
const work = {
|
|
44
|
+
setup: [
|
|
45
|
+
'This studio was just set up from the Claude app. Go through the new-studio checklist with the person, one step at a time and never ahead (the plugin\'s studio-setup skill): it has no game yet, and its home page says "First game coming soon".',
|
|
46
|
+
'See a working game: `npx --no-install homie-studio demo` names a live one on Homie Arcade; copy a starter in only if they ask.',
|
|
47
|
+
'One small change from one sentence of theirs (the studio\'s colours in site/theme.json, a tagline in studio.json, or a first post in posts/), then plan their game into its Game Codex (`npx --no-install homie-studio codex new <id> --name "<Name>"`) before any game is made.',
|
|
48
|
+
],
|
|
49
|
+
make: [
|
|
50
|
+
has ? `games/${id} is here already: change it as the brief says.` : id ? `npx --no-install homie-studio game new ${id} --from ${ID.test(String(h.from ?? '')) ? h.from : 'gem-rush'}${h.name ? ` --name ${q(line(h.name, 60))}` : ''}` : 'Pick an id and a name from the brief, then npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>".',
|
|
51
|
+
'Make it the game the brief describes (its Game Codex, games/<id>/CODEX.md, when there is one).',
|
|
52
|
+
],
|
|
53
|
+
port: [
|
|
54
|
+
`npx --no-install homie-studio port plan ${h.folder ? q(line(h.folder, 200)) : '<the game\'s folder>'}, then port import${id ? ` --id ${id}` : ''} (the plugin's port skill has the whole job).`,
|
|
55
|
+
],
|
|
56
|
+
remix: [
|
|
57
|
+
h.source && id ? `npx --no-install homie-studio game remix ${line(h.source, 300)} --id ${id}` : 'npx --no-install homie-studio game remix <source.json> --id <new id>',
|
|
58
|
+
'Then make it the brief\'s own.',
|
|
59
|
+
],
|
|
60
|
+
change: ['Make the change the brief asks for, in small steps.'],
|
|
61
|
+
}[h.kind] ?? ['Do what the brief asks.'];
|
|
62
|
+
return [
|
|
63
|
+
...work,
|
|
64
|
+
'npm run build; npm run dev in the background; npx --no-install homie-studio check <id> --url http://127.0.0.1:8787 (on Linux without Chrome, first: npx --no-install homie-studio chrome install).',
|
|
65
|
+
'npx --no-install homie-studio progress change "<what it does, one line>"; commit on a new branch, push, gh pr create; then npx --no-install homie-studio progress pr --url <the pull request>.',
|
|
66
|
+
'Never merge the pull request: the person publishes it from the chat\'s card, in GitHub.',
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function handoff(root, build, { homie, fetchFn = globalThis.fetch, attach = startProgress, checkIn = setupAttach, repoOf = originRepo } = {}) {
|
|
71
|
+
const studio = readStudio(root);
|
|
72
|
+
// "Continue building <Studio>: setup hs_…": a studio being set up, before any build: check in, then the checklist.
|
|
73
|
+
if (SETUP.test(String(build ?? ''))) {
|
|
74
|
+
const r = await checkIn(root, build, { homie });
|
|
75
|
+
if (!r.ok) return { ok: false, command: 'handoff', setup: build, why: r.why, ...(r.needs ? { needs: r.needs } : {}) };
|
|
76
|
+
const h = { kind: 'setup', what: 'game', title: 'first steps', studio: r.name ?? studio.name, brief: '', setup: build };
|
|
77
|
+
return { ok: true, command: 'handoff', build: null, ...h, setup: { ok: true, message: r.message, renamed: r.renamed ?? [] }, attached: null, attachWhy: 'no build was opened in the chat yet (the setup card\'s Start building opens one)', steps: stepsFor(h) };
|
|
78
|
+
}
|
|
79
|
+
if (!BUILD.test(String(build ?? ''))) return { ok: false, command: 'handoff', why: 'the id from the one line the Claude app gave: hb_ (a build) or hs_ (a setup) and 32 hex digits' };
|
|
80
|
+
const directory = String(homie || studio.homie?.directory || 'https://homie.rocks').replace(/\/+$/, '');
|
|
81
|
+
// lib/net.mjs: through the environment's proxy, and an honest reason when it fails (the directory's own words, or
|
|
82
|
+
// the connection's error; only a refusal by the proxy names the network setting).
|
|
83
|
+
const got = await request(`${directory}/api/studio/progress/${build}/brief`, { headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION}` } }, { fetchFn });
|
|
84
|
+
const instead = `If the Homie connector's tools are in this session, call build_progress with { "build": "${build}" }: it answers with the brief. Otherwise ask the person what to build; the card in their chat shows it.`;
|
|
85
|
+
if (!got.ok) return { ok: false, command: 'handoff', build, why: got.why, ...(got.needs ? { needs: got.needs } : {}), ...(got.status === 404 ? {} : { instead }) };
|
|
86
|
+
const body = got.body ?? {};
|
|
87
|
+
// The build is for one repository: a session in any other stops here (claude.ai may open the person's last one).
|
|
88
|
+
const want = studioRepo(body.repo)?.toLowerCase() ?? null;
|
|
89
|
+
const here = want ? (repoOf(root) ?? null)?.toLowerCase() ?? null : null;
|
|
90
|
+
if (want && here && here !== want) return { ok: false, command: 'handoff', build, why: `this build is for ${body.repo}, but this session is in ${here}: open it in ${body.repo} (the card's Build it does), and change nothing here` };
|
|
91
|
+
const h = {
|
|
92
|
+
kind: ['make', 'port', 'remix', 'change', 'setup'].includes(body.kind) ? body.kind : 'change',
|
|
93
|
+
what: ['game', 'song', 'video'].includes(body.what) ? body.what : 'game',
|
|
94
|
+
title: line(body.title, 120), studio: line(body.studio, 60) || studio.name, brief: lines(body.brief, 1200),
|
|
95
|
+
id: ID.test(String(body.id ?? '')) ? body.id : null, name: line(body.name, 60) || null, from: ID.test(String(body.from ?? '')) ? body.from : null,
|
|
96
|
+
source: /^https:\/\/\S{8,300}$/.test(String(body.source ?? '')) ? body.source : null, folder: line(body.folder, 200) || null,
|
|
97
|
+
setup: /^hs_[a-f0-9]{32}$/.test(String(body.setup ?? '')) ? body.setup : null,
|
|
98
|
+
};
|
|
99
|
+
let setup = null;
|
|
100
|
+
if (h.setup) {
|
|
101
|
+
const r = await checkIn(root, h.setup, { homie: directory });
|
|
102
|
+
setup = { ok: r.ok, message: r.ok ? r.message : r.why, renamed: r.renamed ?? [] };
|
|
103
|
+
}
|
|
104
|
+
const taken = await attach(root, { attach: build, what: h.what, id: h.id, directory });
|
|
105
|
+
let games = [];
|
|
106
|
+
try { games = listGames(root).map((g) => g.id); } catch { games = []; }
|
|
107
|
+
return {
|
|
108
|
+
ok: true, command: 'handoff', build, ...h, setup,
|
|
109
|
+
attached: taken.ok ? { feed: taken.build, shared: taken.shared?.build ?? build } : null,
|
|
110
|
+
attachWhy: taken.ok ? null : taken.why,
|
|
111
|
+
steps: stepsFor(h, { games }),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The hand-off as the session reads it. */
|
|
116
|
+
export function formatHandoff(r) {
|
|
117
|
+
return [
|
|
118
|
+
`Continue building ${r.studio}: ${r.title || r.kind}`,
|
|
119
|
+
'',
|
|
120
|
+
r.brief ? `The person's brief, from the chat:\n${r.brief.split('\n').map((l) => ` ${l}`).join('\n')}` : 'No brief beyond the title: ask the person one question if anything is unclear.',
|
|
121
|
+
'',
|
|
122
|
+
r.setup ? `Setup: ${r.setup.message}${r.setup.renamed?.length ? ` (changed: ${r.setup.renamed.join(', ')}; commit them on the branch)` : ''}` : null,
|
|
123
|
+
r.attached ? `This session took build ${r.build}: the chat's card follows the work.` : r.build ? `The chat's card will not follow this session: ${r.attachWhy}` : null,
|
|
124
|
+
'',
|
|
125
|
+
'Steps:',
|
|
126
|
+
...r.steps.map((s, i) => ` ${i + 1}. ${s}`),
|
|
127
|
+
].filter((l) => l !== null).join('\n');
|
|
128
|
+
}
|