@homie-rocks/studio 0.30.0 → 0.30.2
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/CHANGELOG.md +63 -1
- package/README.md +18 -1
- package/bin/homie-studio.mjs +6 -2
- package/lib/demo.mjs +16 -4
- package/lib/doctor.mjs +143 -16
- package/lib/feedback.mjs +3 -2
- package/lib/files.mjs +24 -10
- package/lib/mcp-tools.mjs +63 -19
- package/lib/mcp.mjs +12 -6
- package/lib/scaffold.mjs +7 -2
- package/lib/template-history.json +80 -0
- package/mcp/ui/bridge.js +21 -5
- package/mcp/ui/build.js +22 -1
- package/mcp/ui/card.css +1 -0
- package/mcp/ui/setup.js +2 -2
- package/package.json +1 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,71 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.30.2] - 2026-10-04
|
|
21
|
+
|
|
22
|
+
**Plugin 0.31.2** · [#48](https://github.com/homie-rocks/homie/pull/48)
|
|
23
|
+
|
|
24
|
+
Fixes from first runs in Codex and Grok: a missing connector no longer stops a new studio, you are told when Homie's holds are off (in Grok they are), Grok has an install command, and a one-sentence studio gives you a Play link before it polishes.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- `homie-studio setup status --client codex` (or `--client grok`) has a row, **Homie's holds**: on when the plugin's hooks ran in that app in the last ten minutes, off otherwise, with how to turn them on. Codex runs no plugin's hooks until you trust them in `/hooks`, and says nothing when it skips them, so a session where nothing was held looked the same as one where everything was. The hooks now leave a small dated mark each time they run (in `.cache/homie-studio/holds/` of your home folder: the app, the time and which hook, nothing else), and the status reads it. When the holds are off, your AI says so in its first reply and asks you itself before a deploy, a Cloudflare change, a paid call or a model download. When they are on, nothing changes. In Grok the row is off (see Fixed).
|
|
29
|
+
- The setup status says when another MCP server has taken the connector's name. The plugin's connector is the MCP server `homie`. If your Codex or Grok configuration already has a server of that name, that one keeps the name and the plugin's tools do not load. The connector row says so and gives the command that adds Homie's connector under its own name (`codex mcp add homie-rocks --url https://homie.rocks/mcp`), which your AI runs only with your yes. It reads only whether your entry is an address or a command, never the command itself.
|
|
30
|
+
- Grok Build has an install command in the README: `grok plugin install homie-rocks/homie#plugins/homie` (checked on Grok Build 1.0.41). Grok asks whether to trust the plugin, and loads its skills, its MCP server and its hooks once you do.
|
|
31
|
+
- A note you send to Homie from Grok says it came from Grok, not from "another app".
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- **A missing connector no longer stops a new studio.** In Codex, "set up a game studio called X and make a multiplayer game" stopped after a minute with nothing made: the `studio-setup` skill said to stop when the Homie connector was not connected. A session that can run commands does not need it. Your AI now says in one line that the connector is not connected and what it would add, and makes the studio with the toolkit's own command (`npx -y @homie-rocks/studio@latest new <folder> --name "<Name>"`). It stops only when it has neither the connector nor a way to run a command, and then it says how to add the connector in that app. The setup status no longer calls the connector required.
|
|
36
|
+
- Other studios in the same folder are left alone. A new studio's `AGENTS.md` and the `studio-setup` and `game` skills say that a studio in a folder beside yours is somebody else's work: your AI does not read it or copy from it unless you ask. In a test, an AI read the game next door as a model for its own.
|
|
37
|
+
- **A Play link first.** Asked for everything at once, your AI now tells you the moment two browsers finish a round, with the link to play, and only then goes on to sound, art, the playtest's fixes and the landing page, saying what it is doing. The same sentence took 55 to 80 minutes to its first word in Claude Code, Codex and Grok, polishing all the way.
|
|
38
|
+
- Going online never lists a studio. The `studio-setup` and `publish` skills now say that listing in the homie.rocks directory is its own step, public, and yours to ask for: your AI lists a studio when you asked for it or said yes to its offer, never as part of a deploy.
|
|
39
|
+
- The skills no longer suggest a status line, a pane or a card in Codex or Grok, where Homie draws none.
|
|
40
|
+
- The README says what Claude Code means by "9 userConfig options not yet set" after an install: the Homie mod's nine switches, all optional and already at their defaults. Nothing is left to do.
|
|
41
|
+
- Shorter ways in, in the README. Claude Code: one line inside a session, `/plugin install homie --marketplace homie-rocks/homie` (2.1.275 or later). The Claude app: a link that opens "Add custom connector" with Homie filled in, so nobody types the address.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **Homie's holds do not run in Grok, and Homie no longer says they do.** 0.30.0 said Grok held the same calls as Claude Code and Codex. In a real run Grok deployed without asking. Grok Build 1.0.41 runs no plugin's hooks: its own log shows none registered in headless and agent sessions, for every installed plugin that ships hooks and for a test plugin holding nothing but a standard hooks file, so it is not Homie's layout. The README, the plugin's description and the skills now say that nothing is held in Grok and no secret is taken out of what Grok reads; the setup status says "Homie's holds: off"; and the `studio-setup` skill tells Grok to ask you before a deploy, a Cloudflare change, a paid call, a model download or an edit to a protected file. The hooks still ship, and the status turns on by itself with a Grok that runs them.
|
|
46
|
+
- "See a working game" where commands have no network (Codex runs them that way by default). `homie-studio demo` answered with the arcade's standing first pick, and your AI passed on that "the lookup couldn't reach the arcade". It now gives you the link as the live game it is: your browser opens it, not the session. The answer says `reached: false` and no longer blames the arcade for a network the session did not have.
|
|
47
|
+
- The setup status showed "List games in the homie.rocks directory" as not ready without the connector. Listing needs the directory, which `homie-studio publish` reaches by itself.
|
|
48
|
+
|
|
49
|
+
### Upgrade notes
|
|
50
|
+
|
|
51
|
+
- Codex: if you have not yet, open `/hooks` and trust Homie's three hooks. `setup status --client codex` says whether they are on.
|
|
52
|
+
- Grok: nothing to install or switch on. Nothing holds a deploy or a paid call in Grok yet, so read what Grok is about to do when it asks; if it does not ask, tell it to.
|
|
53
|
+
- An existing studio: `upgrade --apply` brings the new lines into the Rules section of its `AGENTS.md` when you have not changed that section. Nothing else in a studio changes.
|
|
54
|
+
|
|
55
|
+
## [0.30.1] - 2026-10-04
|
|
56
|
+
|
|
57
|
+
**Plugin 0.31.1** · [#47](https://github.com/homie-rocks/homie/pull/47) · [release-2026-10-04-studio-0.30.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-04-studio-0.30.1)
|
|
58
|
+
|
|
59
|
+
Fixes from a filmed first run in the Claude desktop app: Claude tells you once to choose Always allow and what to do when a step sits on a spinner, the deploy plan's card fills in, and a starter is changed in a few large edits instead of twenty small ones.
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
- `file_edit` (Homie for Claude Desktop, and any client of `homie-studio mcp`) takes every change to a file in one call: `edits`, a list of `{ old, new }` applied in order, all of them or none. A miss says which edit it was and leaves the file as it was. One change with `old` and `new` works as before.
|
|
64
|
+
- The build card draws a deploy plan: what going online creates on your own Cloudflare, what is there already and what it costs, before anything is made.
|
|
65
|
+
|
|
66
|
+
### Changed
|
|
67
|
+
|
|
68
|
+
- In the Claude desktop app, Claude says once, in its first words to you: the app asks before each Homie Studio tool, **Always allow** lets a build run without a click at every step (the filmed first build made about fifty tool calls), and if a step sits on a spinner with nothing to click, a request is waiting out of sight: ⌘ Return (Ctrl+Enter on Windows) allows it, and scrolling the chat can bring it into view. Claude also writes a line of text between a card and its next tool call, because a request that came straight after a card is the one the app has failed to draw. Only the desktop extension says any of this (its manifest starts the server with `HOMIE_STUDIO_EXTENSION=1`): in Claude Code, Codex, Grok and other MCP clients it is not true, and nothing is said.
|
|
69
|
+
- The README's desktop section, `desktop/README.md` and the extension's install screen say the same in a sentence.
|
|
70
|
+
- Fewer, larger edits. The `game` skill, the note above every guide in `homie-studio mcp`, `game_make` and `game_plan` ask the AI to read a file once and make every change to it in one call. A first build spent about twenty single edits on one starter file and ran into the app's limit of tool calls in a turn, so the person had to say "keep going".
|
|
71
|
+
- `setup_status` takes the name of a studio that is not made yet as `studio`: the card and the checklist are then the new studio's.
|
|
72
|
+
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- The deploy plan's card stayed on a grey "Loading" skeleton for good. `studio_deploy` with `plan: true` answers with a plan, not a build, and the build card knew only builds. A card whose tool answered with something else (a plan, a job still running, plain words) now shows that answer; "Loading" is only for a tool that has not answered yet.
|
|
76
|
+
- With one studio already in the Studios folder, the setup card for a second studio led with the first one's name and checklist.
|
|
77
|
+
|
|
78
|
+
### Upgrade notes
|
|
79
|
+
|
|
80
|
+
- Homie for Claude Desktop: install this version's `homie-studio.mcpb` over the one you have. A studio needs no change.
|
|
81
|
+
|
|
20
82
|
## [0.30.0] - 2026-10-03
|
|
21
83
|
|
|
22
|
-
**Plugin 0.31.0** · [#45](https://github.com/homie-rocks/homie/pull/45)
|
|
84
|
+
**Plugin 0.31.0** · [#45](https://github.com/homie-rocks/homie/pull/45) · [release-2026-10-03-studio-0.30.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.30.0)
|
|
23
85
|
|
|
24
86
|
Grok can set up a studio and run it with the same holds as Claude Code and Codex.
|
|
25
87
|
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ contract (every browser renders, strangers meet in public rooms, bots fill seats
|
|
|
6
6
|
restart), and a site Worker with Table/Lobby Durable Objects and D1 that the studio deploys
|
|
7
7
|
to its own Cloudflare account with Wrangler. The homie.rocks directory lists the games.
|
|
8
8
|
|
|
9
|
-
Most people never run this by hand: the Homie plugin for Claude Code and
|
|
9
|
+
Most people never run this by hand: the Homie plugin for Claude Code, Codex and Grok does,
|
|
10
10
|
and the person approves Cloudflare once in their browser.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
@@ -442,6 +442,23 @@ one token out: about 0.1 of the 10,000 free neurons a day; `lib/brain-probe.mjs`
|
|
|
442
442
|
Workers Paid (error 5035) or retired (5007) is named, with what to do: pick another model, or, the person's money,
|
|
443
443
|
Workers Paid. A spent daily allowance (3036) is said as that, not as a broken model.
|
|
444
444
|
|
|
445
|
+
A missing connector never blocks (0.30.2): a session that can run commands makes, deploys and lists a studio with
|
|
446
|
+
this toolkit alone (`new`, `deploy`, `publish`), and the connector row says so. With `--client codex` or
|
|
447
|
+
`--client grok` (in Codex the toolkit also reads the app from its environment) two things more:
|
|
448
|
+
|
|
449
|
+
- **The connector's name.** The Homie plugin's connector is the MCP server `homie`. When the app's own
|
|
450
|
+
configuration (`config.toml` in its home folder, or `.codex/` or `.grok/` in this folder) already has another
|
|
451
|
+
server of that name, the plugin's does not load. The row says so and gives the command that adds Homie's connector
|
|
452
|
+
under its own name (`codex mcp add homie-rocks --url https://homie.rocks/mcp`). It reads only whether that entry is
|
|
453
|
+
an address or a command; the command itself is never read out.
|
|
454
|
+
- **Homie's holds**: whether the plugin's hooks ran in this app in the last ten minutes, from the dated mark
|
|
455
|
+
they leave in `.cache/homie-studio/holds/` of the home folder. Off means nothing is held: Codex runs no plugin's hooks
|
|
456
|
+
until the person trusts them in `/hooks`, and the row says so; Grok Build 1.0.41 runs no plugin's hooks at all, so
|
|
457
|
+
there the row is off until a Grok that does.
|
|
458
|
+
|
|
459
|
+
`homie-studio demo` answers without a network too (0.30.2): where it cannot reach the arcade it names the arcade's
|
|
460
|
+
standing first pick, with `reached: false`.
|
|
461
|
+
|
|
445
462
|
## The Game Codex
|
|
446
463
|
|
|
447
464
|
`games/<id>/CODEX.md` is a game's plan: Markdown with a small frontmatter for its look (palette, fonts, a
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -187,11 +187,15 @@
|
|
|
187
187
|
* pages; the account id goes into studio.json, the token stays in Projects'
|
|
188
188
|
* vault and its git-ignored .env, and Wrangler runs with it here. The usual
|
|
189
189
|
* way, npx wrangler login, stays the default. lib/projects.mjs)
|
|
190
|
-
* homie-studio setup status [--connector yes|no] (also: homie-studio doctor)
|
|
190
|
+
* homie-studio setup status [--connector yes|no] [--client claude|codex|grok] (also: homie-studio doctor)
|
|
191
191
|
* what this computer and the person's accounts have for a studio: Node, the Homie
|
|
192
192
|
* connector, Cloudflare (signed in, email verified), Chrome, ffmpeg, GitHub,
|
|
193
193
|
* ElevenLabs, fal; green, missing or "do this now", what each unlocks and its
|
|
194
194
|
* exact fix. Read-only and safe any time, inside a studio or before one exists.
|
|
195
|
+
* A missing connector never blocks: a session with a shell makes a studio with
|
|
196
|
+
* `new`. In Codex and Grok Build (--client, or the app's own environment) it
|
|
197
|
+
* also says whether Homie's holds are on (the plugin's hooks ran just now), and
|
|
198
|
+
* when another MCP server named homie has taken the connector's name there.
|
|
195
199
|
*
|
|
196
200
|
* homie-studio codex new <id> [--name "<Name>"]
|
|
197
201
|
* games/<id>/CODEX.md: the Game Codex, every section, in the game's colours (a game
|
|
@@ -692,7 +696,7 @@ async function main() {
|
|
|
692
696
|
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
693
697
|
if (cmd === 'chrome' && sub === 'install') return installChrome({ log, fresh: flags.has('fresh') });
|
|
694
698
|
if (cmd === 'chrome') { const chrome = findChrome(); return chrome ? { ok: true, command: 'chrome', chrome, args: chromeArgs() } : { ok: false, command: 'chrome', why: noChrome() }; }
|
|
695
|
-
if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null });
|
|
699
|
+
if ((cmd === 'setup' && sub === 'status') || cmd === 'doctor') return setupStatus({ connector: flags.get('connector') ?? null, homie: flags.get('homie') ?? null, client: flags.get('client') ?? null });
|
|
696
700
|
if (cmd === 'statusline' && !flags.has('install') && !flags.has('remove')) {
|
|
697
701
|
const line = statusLine({ columns: Number(process.env.COLUMNS) || 100, color: !process.env.NO_COLOR && !asJson && process.stdout.isTTY });
|
|
698
702
|
if (asJson) return { ok: true, command: 'statusline', line };
|
package/lib/demo.mjs
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* studio only when the person asks for one: `homie-studio game new <id> --from gem-rush`.
|
|
9
9
|
*
|
|
10
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
|
|
11
|
+
* links are the ones live right now. When this session cannot reach the arcade (a sandbox with no network, as Codex
|
|
12
|
+
* runs commands by default; a proxy; the arcade slow), it still answers, with the arcade's standing first pick
|
|
13
|
+
* (DEMO_FALLBACK, built in) and `reached: false`: the person's own browser opens that link, not this session, so it
|
|
14
|
+
* is still a live game to try. Read-only.
|
|
12
15
|
*/
|
|
16
|
+
import { whyFailed } from './net.mjs';
|
|
13
17
|
import { STUDIO_VERSION } from './version.mjs';
|
|
14
18
|
|
|
15
19
|
export const DEMO_STUDIO = 'https://arcade.homie.rocks';
|
|
@@ -34,13 +38,17 @@ async function getJson(fetchFn, url, ms) {
|
|
|
34
38
|
|
|
35
39
|
export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUDIO, timeoutMs = 6000 } = {}) {
|
|
36
40
|
const site = String(studio).replace(/\/+$/, '');
|
|
37
|
-
let manifest = null; let rooms = null; let why = null;
|
|
41
|
+
let manifest = null; let rooms = null; let why = null; let refused = false;
|
|
38
42
|
try {
|
|
39
43
|
[manifest, rooms] = await Promise.all([
|
|
40
44
|
getJson(fetchFn, `${site}/.well-known/homie-studio.json`, timeoutMs),
|
|
41
45
|
getJson(fetchFn, `${site}/api/rooms`, timeoutMs).catch(() => null),
|
|
42
46
|
]);
|
|
43
|
-
} catch (error) {
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// The arcade answered with an error, or this session never got to it (no network here, a proxy, a timeout).
|
|
49
|
+
refused = /answered \d{3}$/.test(String(error?.message));
|
|
50
|
+
why = refused ? error.message : whyFailed(error, site).why;
|
|
51
|
+
}
|
|
44
52
|
const live = new Map();
|
|
45
53
|
for (const r of Array.isArray(rooms?.rooms) ? rooms.rooms : []) {
|
|
46
54
|
const id = text(r?.game, 40);
|
|
@@ -63,7 +71,11 @@ export async function demoGames({ fetchFn = globalThis.fetch, studio = DEMO_STUD
|
|
|
63
71
|
ok: true, command: 'demo',
|
|
64
72
|
studio: { name: text(manifest?.name, 60) || 'Homie Arcade', site },
|
|
65
73
|
pick, games: games.length ? games : [pick],
|
|
66
|
-
|
|
74
|
+
// Whether this session read the arcade just now. Not reached: the pick is the built-in one, and its link is as good.
|
|
75
|
+
reached: !why,
|
|
76
|
+
...(why ? { note: refused
|
|
77
|
+
? `the arcade did not give its list just now (${why}), so this is its standing first pick`
|
|
78
|
+
: `this session could not reach the arcade just now (${why}), so this is its standing first pick; the Play link opens in the person's own browser all the same` } : {}),
|
|
67
79
|
how: HOW, copy: COPY,
|
|
68
80
|
};
|
|
69
81
|
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
* unlocks, and the exact fix: one command the AI runs, or one page the person taps.
|
|
5
5
|
*
|
|
6
6
|
* Node.js required the toolkit runs on it
|
|
7
|
-
* Homie connector
|
|
8
|
-
* Homie tools are there: --connector yes|no)
|
|
7
|
+
* Homie connector recommended the directory's search and remixes, the cards and notes to Homie (the AI says
|
|
8
|
+
* whether its Homie tools are there: --connector yes|no). A session with a shell makes,
|
|
9
|
+
* deploys and lists a studio without it, so a missing connector never blocks. When
|
|
10
|
+
* the tools are missing and the app in use (Codex, Grok) has another MCP server named
|
|
11
|
+
* homie in its own config, the row says so: that name is the plugin's connector's
|
|
12
|
+
* Homie's holds in Codex and Grok Build only: whether the plugin's hooks ran just now (they leave a dated
|
|
13
|
+
* mark, hooks/codex.mjs), so a session where nothing is held says so, and how to turn
|
|
14
|
+
* them on (Codex: trust them in /hooks; Grok Build 1.0.41 runs no plugin's hooks, so
|
|
15
|
+
* there the row is off until a Grok that runs them). The app is the one the AI names
|
|
16
|
+
* (--client codex|grok) or its environment shows
|
|
9
17
|
* Cloudflare to go online: signed in, and the account's email verified (Cloudflare checks that at the
|
|
10
18
|
* first deploy; a deploy that went through proves it)
|
|
11
19
|
* Workers AI for AI guides only when a server's guides think with Workers AI: the model they use
|
|
@@ -25,13 +33,16 @@
|
|
|
25
33
|
* It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
|
|
26
34
|
* tools, `wrangler whoami`, `gh auth status`, `elevenlabs auth status`, one GET to the directory and, when a fal key
|
|
27
35
|
* is set, fal's free pricing API; when a server's AI guides use Workers AI, one read of the live database and one
|
|
28
|
-
* one-word call to the model
|
|
36
|
+
* one-word call to the model; in Codex or Grok, the `[mcp_servers.homie]` table of that app's config.toml, of which
|
|
37
|
+
* it keeps only whether it is an address or a command, and the hooks' mark in this user's cache), never changes
|
|
38
|
+
* anything, and never prints a key, a token, a command line or an account's name or id.
|
|
29
39
|
* Every check has a time limit, so it answers in seconds even with no network.
|
|
30
40
|
*/
|
|
31
41
|
import { spawn } from 'node:child_process';
|
|
32
42
|
import { existsSync, readFileSync } from 'node:fs';
|
|
33
43
|
import { join } from 'node:path';
|
|
34
44
|
import { workersAiRow } from './brain-probe.mjs';
|
|
45
|
+
import { studioClient } from './client.mjs';
|
|
35
46
|
import { detectLocalAi } from './local-ai.mjs';
|
|
36
47
|
import { findChrome } from './chrome.mjs';
|
|
37
48
|
import { whyFailed } from './net.mjs';
|
|
@@ -72,14 +83,95 @@ async function reach(url, fetchFn, ms = 5000) {
|
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
/** The apps whose own configuration this status reads: where it is, and that app's own words for a fix. */
|
|
87
|
+
const APPS = {
|
|
88
|
+
codex: { name: 'Codex', home: (env, home) => env.CODEX_HOME || (home && join(home, '.codex')) || null, project: '.codex', add: (name, url) => `codex mcp add ${name} --url ${url}` },
|
|
89
|
+
grok: { name: 'Grok', home: (env, home) => env.GROK_HOME || (home && join(home, '.grok')) || null, project: '.grok', add: (name, url) => `grok mcp add ${name} ${url}` },
|
|
90
|
+
};
|
|
91
|
+
/** The name Homie's connector takes when another MCP server already has `homie`. No underscore: Grok's hooks match on it. */
|
|
92
|
+
export const CONNECTOR_NAME = 'homie-rocks';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Which app this session runs in, for the rows that differ by app: the one the AI named (`--client`, or HOMIE_CLIENT),
|
|
96
|
+
* else what the app's own environment shows (Claude Code and Codex set variables of their own on the commands they
|
|
97
|
+
* run). Null when neither says: never a guess.
|
|
98
|
+
*/
|
|
99
|
+
export function sessionApp(client, env = process.env) {
|
|
100
|
+
const said = studioClient(client, env).id;
|
|
101
|
+
if (said !== 'chat') return said;
|
|
102
|
+
if (env.CLAUDECODE === '1') return 'claude';
|
|
103
|
+
if (env.CODEX_THREAD_ID || env.CODEX_SANDBOX || env.CODEX_SANDBOX_NETWORK_DISABLED || env.CODEX_CI) return 'codex';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Another MCP server under the name the Homie plugin's connector uses (`homie`), in the configuration of the app in
|
|
109
|
+
* use: its config.toml in the app's home folder, or the one in this folder (`.codex/`, `.grok/`). The person's own
|
|
110
|
+
* entry wins over a plugin's of the same name, so the plugin's tools never load. It reads only the
|
|
111
|
+
* `[mcp_servers.homie]` table's `url` and `command` keys, and returns only what kind of server it is and where it
|
|
112
|
+
* was found, never the command, its arguments, its environment or a header. Null when there is none, or it is
|
|
113
|
+
* Homie's own address.
|
|
114
|
+
*/
|
|
115
|
+
export function connectorClash({ app, env = process.env, cwd = process.cwd(), root = null, directory = 'https://homie.rocks' } = {}) {
|
|
116
|
+
const a = APPS[app];
|
|
117
|
+
if (!a) return null;
|
|
118
|
+
const home = env.HOME || env.USERPROFILE || '';
|
|
119
|
+
const ours = new Set(['https://homie.rocks/mcp', `${String(directory).replace(/\/+$/, '')}/mcp`]);
|
|
120
|
+
const places = [
|
|
121
|
+
...[...new Set([root, cwd].filter(Boolean))].map((dir) => ({ file: join(dir, a.project, 'config.toml'), where: `this folder's ${a.project}/config.toml` })),
|
|
122
|
+
{ file: a.home(env, home) ? join(a.home(env, home), 'config.toml') : null, where: `config.toml in ${a.name}'s home folder` },
|
|
123
|
+
];
|
|
124
|
+
for (const { file, where } of places) {
|
|
125
|
+
let text;
|
|
126
|
+
try { if (!file || !existsSync(file)) continue; text = readFileSync(file, 'utf8'); } catch { continue; }
|
|
127
|
+
if (text.length > 16 * 1024 * 1024) continue;
|
|
128
|
+
const lines = text.split(/\r?\n/);
|
|
129
|
+
const at = lines.findIndex((l) => /^\s*\[\s*mcp_servers\s*\.\s*(?:"homie"|'homie'|homie)\s*\]\s*(?:#.*)?$/.test(l));
|
|
130
|
+
if (at < 0) continue;
|
|
131
|
+
let url = null; let command = false;
|
|
132
|
+
for (let i = at + 1; i < lines.length && !/^\s*\[/.test(lines[i]); i += 1) {
|
|
133
|
+
const m = /^\s*(url|command)\s*=\s*(.*?)\s*$/.exec(lines[i]);
|
|
134
|
+
if (!m) continue;
|
|
135
|
+
if (m[1] === 'command') command = true;
|
|
136
|
+
else url = (/^(["'])(.*?)\1/.exec(m[2])?.[2] ?? m[2]).replace(/\/+$/, '');
|
|
137
|
+
}
|
|
138
|
+
if (url && !command && ours.has(url)) return null;
|
|
139
|
+
return { app, name: a.name, where, kind: command ? 'a local command' : url ? 'another address' : 'another server' };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** How long a mark of Homie's hooks counts as "ran just now": they stamp it at every message and every tool call. */
|
|
145
|
+
export const HOLDS_FRESH_MS = 10 * 60_000;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The mark Homie's hooks leave each time they run in Codex or Grok Build (the plugin's hooks/codex.mjs `mark`): a
|
|
149
|
+
* small dated file in this user's cache, <app>.json, { v, app, at, event }. { at, event, ageMs } or null. HOMIE_HOLDS_MARKS
|
|
150
|
+
* names another folder (the tests use it).
|
|
151
|
+
*/
|
|
152
|
+
export function holdsMark(app, { env = process.env, now = Date.now() } = {}) {
|
|
153
|
+
const home = env.HOME || env.USERPROFILE || '';
|
|
154
|
+
const dir = env.HOMIE_HOLDS_MARKS || (home && join(home, '.cache', 'homie-studio', 'holds'));
|
|
155
|
+
if (!dir || !/^[a-z]+$/.test(String(app ?? ''))) return null;
|
|
156
|
+
try {
|
|
157
|
+
const m = JSON.parse(readFileSync(join(dir, `${app}.json`), 'utf8'));
|
|
158
|
+
const at = Number(m?.at);
|
|
159
|
+
if (!Number.isFinite(at) || at <= 0) return null;
|
|
160
|
+
return { at, event: typeof m.event === 'string' ? m.event.slice(0, 20) : null, ageMs: Math.max(0, now - at) };
|
|
161
|
+
} catch { return null; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const ago = (ms) => (ms < 90_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : ms < 90 * 60_000 ? `${Math.round(ms / 60_000)} min` : ms < 36 * 3_600_000 ? `${Math.round(ms / 3_600_000)} h` : `${Math.round(ms / 86_400_000)} days`);
|
|
165
|
+
|
|
75
166
|
/**
|
|
76
167
|
* The checklist. `connector`: what the AI knows about its own tools ('yes' when the Homie MCP tools such as
|
|
77
|
-
* studio_scaffold are in its tool list, 'no' when they are not, null when unsaid). `
|
|
168
|
+
* studio_scaffold are in its tool list, 'no' when they are not, null when unsaid). `client`: the app the AI says it
|
|
169
|
+
* is ('claude', 'codex', 'grok'; sessionApp reads the environment when it is unsaid). `exec`, `fetchFn` and `nowMs` are
|
|
78
170
|
* replaceable for tests.
|
|
79
171
|
*/
|
|
80
172
|
export async function setupStatus({
|
|
81
|
-
cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null,
|
|
82
|
-
exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node,
|
|
173
|
+
cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null, client = null,
|
|
174
|
+
exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node, nowMs = Date.now(),
|
|
83
175
|
} = {}) {
|
|
84
176
|
const root = findStudio(cwd);
|
|
85
177
|
let studio = null;
|
|
@@ -148,24 +240,58 @@ export async function setupStatus({
|
|
|
148
240
|
})(),
|
|
149
241
|
]);
|
|
150
242
|
|
|
151
|
-
// The Homie connector.
|
|
243
|
+
// The Homie connector. A session with a shell makes, deploys and lists a studio without it, so it never blocks.
|
|
244
|
+
const app = sessionApp(client, env);
|
|
152
245
|
{
|
|
246
|
+
const host = new URL(directory).host;
|
|
153
247
|
const fixConnector = {
|
|
154
248
|
who: 'person',
|
|
155
|
-
say:
|
|
249
|
+
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 (codex plugin marketplace add homie-rocks/homie, then codex plugin add homie@homie) and start a new session. Grok Build: grok plugin install homie-rocks/homie#plugins/homie (Grok asks whether to trust it), then a new session. The Claude app, or Grok chat: add the connector https://homie.rocks/mcp; its setup card (studio_setup) makes the studio on your own Cloudflare. Grok has no Cloudflare connector: you still approve Cloudflare in the browser. A Grok Bot on this computer runs the checklist and checks in with setup attach <hs_…> --client grok. Meanwhile a session with a shell goes on without it: ${cli} ${root ? '<command>' : 'new <folder> --name "<Name>"'} makes ${root ? 'everything in the studio' : 'the studio'}.`,
|
|
156
250
|
};
|
|
251
|
+
// Another MCP server already called `homie` in this app's own config: the plugin's connector cannot load under it.
|
|
252
|
+
const clash = said !== 'yes' ? connectorClash({ app, env, cwd, root, directory }) : null;
|
|
253
|
+
const fixClash = clash ? {
|
|
254
|
+
who: 'ai', run: APPS[clash.app].add(CONNECTOR_NAME, `${directory}/mcp`),
|
|
255
|
+
say: `Another MCP server named homie is set up in ${clash.name} (${clash.where}). The Homie plugin's connector has the same name, and an app loads one server under a name, so the plugin's tools are missing while that one is there. This adds Homie's connector under its own name, ${CONNECTOR_NAME}, and leaves the other server as it is; you approve it, then start a new session. Until then your AI goes on with the studio's own commands.`,
|
|
256
|
+
} : null;
|
|
157
257
|
let state; let detail;
|
|
158
|
-
if (net.blocked) { state = 'act'; detail = `the network proxy of this machine refused ${
|
|
258
|
+
if (net.blocked) { state = 'act'; detail = `the network proxy of this machine refused ${host}`; }
|
|
159
259
|
else if (said === 'yes') { state = 'ok'; detail = net.ok ? 'the Homie tools are here, and the directory answers' : `the Homie tools are here; this computer's request to the directory failed just now: ${net.why}`; }
|
|
160
|
-
else if (
|
|
260
|
+
else if (clash) { state = 'act'; detail = `${said === 'no' ? 'the Homie tools are not in this session' : 'your AI knows whether its Homie tools (studio_scaffold) are here'}; another MCP server named homie (${clash.kind}) is set up in ${clash.name}, in ${clash.where}, and it takes the name the Homie plugin's connector uses`; }
|
|
261
|
+
else if (said === 'no') { state = 'act'; detail = 'the Homie tools are not in this session; a studio is still made, checked, deployed and listed with the studio\'s own commands'; }
|
|
161
262
|
// The directory not answering this computer says nothing about the connector: say what failed, as it is.
|
|
162
|
-
else { state = 'unknown'; detail = net.ok ? `${
|
|
263
|
+
else { state = 'unknown'; detail = net.ok ? `${host} answers; your AI knows whether its Homie tools (studio_scaffold) are here` : `this computer's request to the directory failed: ${net.why}`; }
|
|
163
264
|
rows.push({
|
|
164
|
-
id: 'connector', label: 'Homie connector', need: '
|
|
165
|
-
unlocks: '
|
|
265
|
+
id: 'connector', label: 'Homie connector', need: 'recommended', state, detail,
|
|
266
|
+
unlocks: 'searching the homie.rocks directory and remixing a game from it, the cards where your app draws them, and notes to Homie; a session with a shell makes, deploys and lists a studio without it',
|
|
166
267
|
fix: state === 'ok' ? null : net.blocked
|
|
167
|
-
? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${
|
|
168
|
-
: said === 'no' || net.ok ? fixConnector : null,
|
|
268
|
+
? { who: 'person', say: `In claude.ai/code, open this environment's settings, set Network access to Custom, add ${host} (keep the default package managers), and start a new session.` }
|
|
269
|
+
: fixClash ?? (said === 'no' || net.ok ? fixConnector : null),
|
|
270
|
+
...(clash && !net.blocked ? { clash: { app: clash.app, server: 'homie', kind: clash.kind, where: clash.where, as: CONNECTOR_NAME } } : {}),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Homie's holds, in Codex and Grok Build: Codex runs the plugin's hooks only once the person has trusted them, Grok
|
|
275
|
+
// Build 1.0.41 was seen to run no plugin's hooks at all, and neither app says so. The hooks leave a dated mark at
|
|
276
|
+
// every message and tool call; a fresh one means they are on. Off in Grok is Grok's doing, so it is said as that.
|
|
277
|
+
if (app === 'codex' || app === 'grok') {
|
|
278
|
+
const name = APPS[app].name;
|
|
279
|
+
const seen = holdsMark(app, { env, now: nowMs });
|
|
280
|
+
const on = Boolean(seen && seen.ageMs <= HOLDS_FRESH_MS);
|
|
281
|
+
rows.push({
|
|
282
|
+
// Off in Codex is the person's to fix now (trust the hooks); off in Grok is nothing they can fix.
|
|
283
|
+
id: 'holds', label: 'Homie\'s holds', need: 'recommended', state: on ? 'ok' : app === 'codex' ? 'act' : 'optional', on, app,
|
|
284
|
+
detail: on ? `on: Homie's hooks ran in ${name} ${ago(seen.ageMs)} ago`
|
|
285
|
+
: seen ? `off: Homie's hooks last ran in ${name} ${ago(seen.ageMs)} ago, and not for this session's calls`
|
|
286
|
+
: app === 'grok' ? 'off: Grok Build runs no plugin\'s hooks yet (1.0.41), so nothing is held in Grok' : `off: Homie's hooks have not run in ${name} on this computer`,
|
|
287
|
+
...(seen ? { seen: { at: new Date(seen.at).toISOString(), event: seen.event } } : {}),
|
|
288
|
+
unlocks: 'a wait for your own "proceed <code>" before a production deploy, an edit to a file the studio protects, a Cloudflare change outside the deploy, a paid call past the budget and a model download; and secrets taken out of what your AI reads',
|
|
289
|
+
fix: on ? null : {
|
|
290
|
+
who: 'person',
|
|
291
|
+
say: app === 'codex'
|
|
292
|
+
? 'In Codex, open /hooks and trust Homie\'s three hooks: Codex runs no plugin\'s hooks until you do. Then run this again. Until then nothing is held, so your AI asks you before each of those itself.'
|
|
293
|
+
: 'Nothing to do on your side: Grok Build 1.0.41 runs no plugin\'s hooks (none are registered in its headless and agent sessions, from any plugin; checked 2026-10-04), so Homie\'s holds are off in Grok. Nothing is held and no secret is taken out of what your AI reads, so your AI asks you before each of those itself. This row turns on by itself when a Grok that runs them leaves the hooks\' mark.',
|
|
294
|
+
},
|
|
169
295
|
});
|
|
170
296
|
}
|
|
171
297
|
|
|
@@ -306,7 +432,8 @@ export async function setupStatus({
|
|
|
306
432
|
{ feature: 'Two-browser checks and playtests', state: ready(by.chrome.state), needs: ['chrome'] },
|
|
307
433
|
{ feature: 'Sound effects and a theme (free)', state: ready(by.ffmpeg.state), needs: ['ffmpeg'] },
|
|
308
434
|
{ 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'] },
|
|
309
|
-
|
|
435
|
+
// Listing needs the directory, not the connector: `homie-studio publish` reaches it from this computer.
|
|
436
|
+
{ feature: 'List games in the homie.rocks directory', state: by.connector.state === 'ok' || net.ok ? 'ready' : 'not yet', needs: ['connector'] },
|
|
310
437
|
{ feature: 'Backup and pull requests', state: ready(by.github.state), needs: ['github'] },
|
|
311
438
|
{ feature: 'Songs and game scores', state: ready(by.elevenlabs.state), needs: ['elevenlabs'] },
|
|
312
439
|
{ feature: 'Painted art and generated video', state: ready(by.fal.state), needs: ['fal'] },
|
package/lib/feedback.mjs
CHANGED
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
import { redactText } from './redact.mjs';
|
|
21
21
|
|
|
22
22
|
export const FEEDBACK_KINDS = Object.freeze(['stuck', 'confusing', 'idea', 'praise', 'bug']);
|
|
23
|
-
export const FEEDBACK_APPS = Object.freeze(['claude-code', 'codex', 'claude-desktop', 'claude-ai', 'other']);
|
|
23
|
+
export const FEEDBACK_APPS = Object.freeze(['claude-code', 'codex', 'grok', 'claude-desktop', 'claude-ai', 'other']);
|
|
24
24
|
export const FEEDBACK_LIMITS = Object.freeze({ text: 1500, raw: 6000, lines: 30, step: 80, email: 254, version: 32, perSession: 5, offers: 3 });
|
|
25
25
|
export const KIND_LABEL = Object.freeze({ stuck: 'Stuck', confusing: 'Confusing', idea: 'Idea', praise: 'Praise', bug: 'Bug' });
|
|
26
|
-
export const APP_LABEL = Object.freeze({ 'claude-code': 'Claude Code', codex: 'Codex', 'claude-desktop': 'the Claude desktop app', 'claude-ai': 'Claude on the web or a phone', other: 'another app' });
|
|
26
|
+
export const APP_LABEL = Object.freeze({ 'claude-code': 'Claude Code', codex: 'Codex', grok: 'Grok', 'claude-desktop': 'the Claude desktop app', 'claude-ai': 'Claude on the web or a phone', other: 'another app' });
|
|
27
27
|
|
|
28
28
|
/** Where a note goes on a Homie directory (homie.rocks, or a local one for tests). */
|
|
29
29
|
export const feedbackUrl = (directory = 'https://homie.rocks') => `${String(directory || 'https://homie.rocks').replace(/\/+$/, '')}/api/feedback/tell`;
|
|
@@ -175,6 +175,7 @@ export async function sendNote(note, { directory, source = 'plugin', consent = '
|
|
|
175
175
|
export function appOf(clientInfo) {
|
|
176
176
|
const name = String(clientInfo?.name ?? '').toLowerCase();
|
|
177
177
|
if (/codex/.test(name)) return 'codex';
|
|
178
|
+
if (/grok/.test(name)) return 'grok';
|
|
178
179
|
if (/claude[-_ ]?code/.test(name)) return 'claude-code';
|
|
179
180
|
if (/claude/.test(name)) return 'claude-desktop';
|
|
180
181
|
return 'other';
|
package/lib/files.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
|
10
10
|
import { PICTURE_MAX, PICTURE_TYPES, pictureFor } from './pictures.mjs';
|
|
11
11
|
|
|
12
12
|
// A picture is at most PICTURE_MAX raw in an answer (lib/pictures.mjs): a bigger one goes as a smaller JPEG copy.
|
|
13
|
-
export const FILE_LIMITS = Object.freeze({ write: 2 * 1024 * 1024, read: 400 * 1024, image: PICTURE_MAX, lines: 2000, list: 600, matches: 200 });
|
|
13
|
+
export const FILE_LIMITS = Object.freeze({ write: 2 * 1024 * 1024, read: 400 * 1024, image: PICTURE_MAX, lines: 2000, list: 600, matches: 200, edits: 100 });
|
|
14
14
|
const IMAGES = PICTURE_TYPES;
|
|
15
15
|
const SKIP_DIRS = new Set(['node_modules', '.git', '.wrangler', '.studio', 'dist']);
|
|
16
16
|
|
|
@@ -112,19 +112,33 @@ export function writeStudioFile(root, path, content) {
|
|
|
112
112
|
return { rel, bytes: Buffer.byteLength(text), created: !existed };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Replace exact text in a file. One change (`oldText`, `newText`, `all`), or several in one call (`edits`: a list of
|
|
117
|
+
* { old, new, all? }), applied in order to the text in memory and written once: all of them, or none. A change that
|
|
118
|
+
* does not match says which one it was, and the file is left as it was.
|
|
119
|
+
*/
|
|
120
|
+
export function editStudioFile(root, path, oldText, newText, { all = false, edits = null } = {}) {
|
|
116
121
|
const { abs, rel } = studioPath(root, path, { write: true });
|
|
117
122
|
if (!existsSync(abs)) throw new Error(`${rel} does not exist (file_write makes a new file)`);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
const list = Array.isArray(edits) && edits.length ? edits : [{ old: oldText, new: newText, all }];
|
|
124
|
+
if (list.length > FILE_LIMITS.edits) throw new Error(`at most ${FILE_LIMITS.edits} edits in one call`);
|
|
125
|
+
const many = list.length > 1;
|
|
126
|
+
let next = readFileSync(abs, 'utf8');
|
|
127
|
+
let replaced = 0;
|
|
128
|
+
list.forEach((e, i) => {
|
|
129
|
+
const from = String(e?.old ?? '');
|
|
130
|
+
const which = many ? `edit ${i + 1} of ${list.length} (${JSON.stringify(from.slice(0, 60))}${from.length > 60 ? '…' : ''}): ` : '';
|
|
131
|
+
const none = many ? '; nothing was changed' : '';
|
|
132
|
+
if (!from) throw new Error(`${which}old is the exact text to replace (it cannot be empty)${none}`);
|
|
133
|
+
const count = next.split(from).length - 1;
|
|
134
|
+
if (!count) throw new Error(`${which}the text to replace is not in ${rel} (it must match exactly, spaces and line breaks too${many ? '; an earlier edit in this call may have changed it' : ''})${none}`);
|
|
135
|
+
if (count > 1 && e.all !== true) throw new Error(`${which}the text to replace is in ${rel} ${count} times: give more of it so it is unique, or all: true${none}`);
|
|
136
|
+
next = e.all === true ? next.split(from).join(String(e.new ?? '')) : next.replace(from, () => String(e.new ?? ''));
|
|
137
|
+
replaced += e.all === true ? count : 1;
|
|
138
|
+
});
|
|
125
139
|
if (Buffer.byteLength(next) > FILE_LIMITS.write) throw new Error(`at most ${FILE_LIMITS.write / 1024 / 1024} MB a file`);
|
|
126
140
|
writeFileSync(abs, next);
|
|
127
|
-
return { rel, replaced
|
|
141
|
+
return { rel, replaced, edits: list.length };
|
|
128
142
|
}
|
|
129
143
|
|
|
130
144
|
/** Lines matching a regular expression, in the studio's text files (never node_modules, git, builds or .studio). */
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -54,6 +54,20 @@ export const HOMIE_UPDATES = 'https://homie.rocks/updates/?from=plugin';
|
|
|
54
54
|
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
55
55
|
const realOr = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
56
56
|
|
|
57
|
+
/** In Homie for Claude Desktop: the extension's manifest starts the server with HOMIE_STUDIO_EXTENSION=1 (desktop/manifest.json). */
|
|
58
|
+
export const inDesktopApp = (env = process.env) => env.HOMIE_STUDIO_EXTENSION === '1';
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
* A FIRST RUN IN THE CLAUDE DESKTOP APP (0.30.1). The app asks the person before every tool call of an extension:
|
|
62
|
+
* about fifty requests in one filmed first build, each a click, and nothing told the person that "Always allow"
|
|
63
|
+
* exists. Twice the app raised a request it did not draw (it came straight after a card, with no text between):
|
|
64
|
+
* the chat showed a spinner until the request timed out four minutes later, while the request's own key still
|
|
65
|
+
* answered it. That is the app's to fix; Homie's part is that the person is told, once, by the model, in its first
|
|
66
|
+
* reply. Only the desktop extension says this (inDesktopApp): in Claude Code, Codex, Grok or any other MCP client
|
|
67
|
+
* it would not be true.
|
|
68
|
+
*/
|
|
69
|
+
export const DESKTOP_APPROVALS = 'This is the Claude desktop app: it asks the person before each Homie Studio tool call. ONCE in a chat, in your first words to the person (skip it if you already said it in this chat), tell them in two short sentences of your own: (1) Claude will ask before it uses each Homie Studio tool, and choosing "Always allow" lets the build run without a click at every step; (2) if a step ever sits on a spinner for more than a few seconds with nothing to click, a request is waiting out of sight: Cmd+Return on a Mac (Ctrl+Enter on Windows) allows it once, and scrolling the chat can bring it into view (a request shows its keys beside its buttons). Do not repeat it later. And after a tool that shows a card (setup_status, studio_scaffold, game_codex, build_open, check, playtest, studio_deploy, …), write one short line of text before your next tool call: a request that came straight after a card, with no text between, is the one the app has failed to draw.';
|
|
70
|
+
|
|
57
71
|
/** A folder as the Claude desktop app's settings give it: `${HOME}/Studios` arrives as written, so expand it here. */
|
|
58
72
|
export function expandHome(value, home = homedir()) {
|
|
59
73
|
const v = String(value ?? '').trim();
|
|
@@ -65,8 +79,10 @@ export function expandHome(value, home = homedir()) {
|
|
|
65
79
|
/* ------------------------------------------------------------------ which studio */
|
|
66
80
|
|
|
67
81
|
export class StudioContext {
|
|
68
|
-
constructor({ studiosDir = null, cwd = process.cwd(), skillsDir = null, waitMs = 40_000, directory = null, install = true } = {}) {
|
|
82
|
+
constructor({ studiosDir = null, cwd = process.cwd(), skillsDir = null, waitMs = 40_000, directory = null, install = true, desktop = inDesktopApp() } = {}) {
|
|
69
83
|
this.install = install;
|
|
84
|
+
// Homie for Claude Desktop (the .mcpb): the one host these tools know asks the person before every call.
|
|
85
|
+
this.desktop = desktop === true;
|
|
70
86
|
this.studiosDir = studiosDir ? expandHome(studiosDir) : null;
|
|
71
87
|
this.cwdStudio = findStudio(cwd);
|
|
72
88
|
this.current = this.cwdStudio;
|
|
@@ -110,13 +126,24 @@ export class StudioContext {
|
|
|
110
126
|
return Boolean((this.studiosDir && inside(realOr(this.studiosDir), real)) || (this.cwdStudio && inside(realOr(this.cwdStudio), real)));
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
/** Where `a` (a folder name, a studio's name, or a path inside the studios folder) would be a studio, or null. */
|
|
130
|
+
pathOf(a, list = this.studios()) {
|
|
131
|
+
const byName = list.find((s) => s.folder === a || s.name.toLowerCase() === a.toLowerCase() || slugify(s.name) === slugify(a));
|
|
132
|
+
return byName?.root ?? (isAbsolute(expandHome(a) ?? '') && (a.includes('/') || a.includes('\\') || a.startsWith('~')) ? expandHome(a) : this.studiosDir ? join(this.studiosDir, a) : null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether `arg` names a studio that exists (as root() would find it). */
|
|
136
|
+
knows(arg) {
|
|
137
|
+
const path = this.pathOf(String(arg ?? '').trim());
|
|
138
|
+
return Boolean(path && existsSync(join(path, 'studio.json')));
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
/** The studio a tool works in: `arg` (a folder name, a studio's name, or a path inside the studios folder). */
|
|
114
142
|
root(arg, { need = true } = {}) {
|
|
115
143
|
if (arg !== undefined && arg !== null && String(arg).trim()) {
|
|
116
144
|
const a = String(arg).trim();
|
|
117
145
|
const list = this.studios();
|
|
118
|
-
const
|
|
119
|
-
const path = byName?.root ?? (isAbsolute(expandHome(a) ?? '') && (a.includes('/') || a.includes('\\') || a.startsWith('~')) ? expandHome(a) : this.studiosDir ? join(this.studiosDir, a) : null);
|
|
146
|
+
const path = this.pathOf(a, list);
|
|
120
147
|
if (!path || !existsSync(join(path, 'studio.json'))) throw new Error(`no studio "${a}"${list.length ? ` (studios here: ${list.map((s) => s.folder).join(', ')})` : ''}`);
|
|
121
148
|
if (!this.allowed(path)) throw new Error(`${path} is outside the studios folder (${this.studiosDir ?? 'none is set'}); these tools only work in it`);
|
|
122
149
|
this.current = realOr(path);
|
|
@@ -203,7 +230,7 @@ function checklist(ctx, root) {
|
|
|
203
230
|
return steps.map((label, n) => ({ n, label, state: done[n] ? 'done' : n === now ? 'now' : 'todo' }));
|
|
204
231
|
}
|
|
205
232
|
|
|
206
|
-
async function setupCard(ctx, root, { fresh = false, made = null, install = null } = {}) {
|
|
233
|
+
async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null } = {}) {
|
|
207
234
|
const status = await statusOf(ctx, root, { fresh });
|
|
208
235
|
const studios = ctx.studios().map(({ root: r, ...s }) => s);
|
|
209
236
|
const current = root ? ctx.describe(root) : null;
|
|
@@ -212,6 +239,8 @@ async function setupCard(ctx, root, { fresh = false, made = null, install = null
|
|
|
212
239
|
const steps = checklist(ctx, root);
|
|
213
240
|
const data = {
|
|
214
241
|
kind: 'setup', version: STUDIO_VERSION, studiosDir: ctx.studiosDir,
|
|
242
|
+
// The name of a studio that is not made yet (setup_status asked for it by name): the card is that new studio's.
|
|
243
|
+
wanted: current ? null : wanted,
|
|
215
244
|
current: current ? { folder: current.folder, name: current.name, games: current.games, installed: current.installed, site: siteUrl(root) } : null,
|
|
216
245
|
studios, checklist: steps,
|
|
217
246
|
status: { rows: status.rows.map(({ parts, ...r }) => r), features: status.features, next: status.next, meanwhile: status.meanwhile, blocking: status.blocking },
|
|
@@ -220,16 +249,22 @@ async function setupCard(ctx, root, { fresh = false, made = null, install = null
|
|
|
220
249
|
// homie.rocks). The card never asks for an address, and an agent never types one in for the person.
|
|
221
250
|
updates: HOMIE_UPDATES,
|
|
222
251
|
};
|
|
252
|
+
const others = studios.map((s) => s.name);
|
|
253
|
+
const first = (steps.find((s) => s.state === 'now')?.n ?? steps.length) <= 2;
|
|
223
254
|
const text = [
|
|
224
|
-
current ? `Studio: ${current.name} (${root})${current.installed ? '' : job ? ' (installing its toolkit)' : ''}`
|
|
255
|
+
current ? `Studio: ${current.name} (${root})${current.installed ? '' : job ? ' (installing its toolkit)' : ''}`
|
|
256
|
+
: wanted ? `No studio called "${wanted}" yet${ctx.studiosDir ? ` in ${ctx.studiosDir}` : ''}${others.length ? ` (the studios here: ${others.join(', ')}; this checklist is the new studio's, not theirs)` : ''}: studio_scaffold makes it.`
|
|
257
|
+
: `No studio yet${ctx.studiosDir ? ` in ${ctx.studiosDir}` : ''}.`,
|
|
225
258
|
'',
|
|
226
|
-
`New studio${current ? `: ${current.name}` : ''}`,
|
|
259
|
+
`New studio${current ? `: ${current.name}` : wanted ? `: ${wanted}` : ''}`,
|
|
227
260
|
...steps.map((s) => ` ${s.state === 'done' ? '✓' : s.state === 'now' ? '→' : ' '} ${s.n}. ${s.label}`),
|
|
228
261
|
'',
|
|
229
262
|
formatStatus(status),
|
|
230
263
|
'',
|
|
231
264
|
`Homie updates by email (optional): ${HOMIE_UPDATES} (the person signs up there themselves; never type an address in for them).`,
|
|
232
265
|
'The card on screen shows this; it follows the install by itself.',
|
|
266
|
+
// The first minutes of a new studio in the Claude desktop app: what the person is told once (DESKTOP_APPROVALS).
|
|
267
|
+
...(ctx.desktop && first ? ['', DESKTOP_APPROVALS] : []),
|
|
233
268
|
].join('\n');
|
|
234
269
|
return ok(text, data);
|
|
235
270
|
}
|
|
@@ -703,7 +738,7 @@ function guideTopics(ctx) {
|
|
|
703
738
|
try { return readdirSync(ctx.skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync(join(ctx.skillsDir, d.name, 'SKILL.md'))).map((d) => d.name).sort(); } catch { return []; }
|
|
704
739
|
}
|
|
705
740
|
|
|
706
|
-
const DESKTOP_NOTE = 'In this app, where a guide says to run `npx --no-install homie-studio <command>`, call the tool of the same job (build, check, preview_run, studio_deploy, …) or studio_run with that command\'s words; where it says to edit a file, use file_read, file_edit and file_write; a guide\'s own script (music, sound, art, video) is the tool of that name.';
|
|
741
|
+
const DESKTOP_NOTE = 'In this app, where a guide says to run `npx --no-install homie-studio <command>`, call the tool of the same job (build, check, preview_run, studio_deploy, …) or studio_run with that command\'s words; where it says to edit a file, use file_read, file_edit and file_write, in few, large calls: read a file once, then make every change it needs in ONE file_edit (its edits list takes many replacements) or one file_write of a new or short file, never a call per change (every call is a step the person waits on, and a turn has only so many); a guide\'s own script (music, sound, art, video) is the tool of that name.';
|
|
707
742
|
|
|
708
743
|
/* ------------------------------------------------------------------ the tools */
|
|
709
744
|
|
|
@@ -717,10 +752,15 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
717
752
|
const tools = [
|
|
718
753
|
{
|
|
719
754
|
name: 'setup_status', title: 'Setup status',
|
|
720
|
-
description: 'Step 0 of a new studio, and any time the person asks what they need or whether they are set up: what this computer and their accounts have (Node, Cloudflare signed in and email verified, Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and fal), what each unlocks, and the exact fix; the studios in the studios folder; and the new-studio checklist with where they are on it. Read-only, a few seconds. Optional rows never block anything.',
|
|
721
|
-
inputSchema: { type: 'object', properties: {
|
|
755
|
+
description: 'Step 0 of a new studio, and any time the person asks what they need or whether they are set up: what this computer and their accounts have (Node, Cloudflare signed in and email verified, Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and fal), what each unlocks, and the exact fix; the studios in the studios folder; and the new-studio checklist with where they are on it. For a studio that is not made yet, pass its name as studio: the checklist is then the new studio\'s, never another studio\'s that happens to be in the folder. Read-only, a few seconds. Optional rows never block anything.',
|
|
756
|
+
inputSchema: { type: 'object', properties: { studio: str('Optional: which studio (its folder name or name), or the name of a new studio that is not made yet; else the one in use'), fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' } } },
|
|
722
757
|
annotations: { title: 'Setup status', ...RO }, _meta: ui(UI.setup),
|
|
723
|
-
run: async (a) =>
|
|
758
|
+
run: async (a) => {
|
|
759
|
+
// A name with no studio of it yet is the studio about to be made: its own checklist, from step 1.
|
|
760
|
+
const named = String(a.studio ?? '').trim().slice(0, 60);
|
|
761
|
+
if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, wanted: named });
|
|
762
|
+
return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true });
|
|
763
|
+
},
|
|
724
764
|
},
|
|
725
765
|
{
|
|
726
766
|
name: 'studio_scaffold', title: 'Make a studio',
|
|
@@ -840,7 +880,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
840
880
|
}
|
|
841
881
|
const m = r.result?.models;
|
|
842
882
|
const got = m ? ` Its models: ${m.fetched} from the starter library${m.missing.length ? `; ${m.missing.length} could not be fetched (${m.why ?? m.missing[0].why}), and it draws stand-ins for those` : ''}.` : '';
|
|
843
|
-
return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Change it in games/${a.id}/src/main.ts (
|
|
883
|
+
return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files).${got}${install} Change it in games/${a.id}/src/main.ts: file_read it once (a long file in two or three reads), decide every change, and make them in ONE file_edit with an edits list (never a call per change: twenty single edits use up a turn before the game is checked); then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
|
|
844
884
|
},
|
|
845
885
|
},
|
|
846
886
|
{
|
|
@@ -888,7 +928,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
888
928
|
},
|
|
889
929
|
{
|
|
890
930
|
name: 'game_plan', title: 'Plan a game (its Game Codex)',
|
|
891
|
-
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers with
|
|
931
|
+
description: 'Step 4: start the game\'s Game Codex (games/<id>/CODEX.md, every section) and get the plan interview to run with the person: two or three questions a message, each with options and your pick, about game type and genre, style, devices, players and rooms, art and film, music and sound, and scope. A game is planned before it is made: with no game <id> yet, this starts its folder with only the codex. Fill CODEX.md from their answers in one call (file_write of the whole file, or one file_edit with an edits list), then game_codex shows it.',
|
|
892
932
|
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens)'), name: str('Its name, when the game is not made yet'), ...STUDIO_ARG }, required: ['id'] },
|
|
893
933
|
annotations: { title: 'Plan a game', ...RW },
|
|
894
934
|
run: async (a) => {
|
|
@@ -902,7 +942,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
902
942
|
const guide = ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
|
|
903
943
|
return ok([
|
|
904
944
|
made ? `Wrote games/${a.id}/CODEX.md (every section${made.planned ? '; the game is planned here before it is made' : ''}).` : `games/${a.id}/CODEX.md is there already: change it, never replace it.`,
|
|
905
|
-
'Now the interview: two or three questions a message, each with concrete options and your pick, so "yes" is an answer. Say back what you heard in one line before the next. Stop after three or four rounds; "just build it" means fill the rest with your own choices and list them under Open questions. Then fill CODEX.md (file_read it, file_edit
|
|
945
|
+
'Now the interview: two or three questions a message, each with concrete options and your pick, so "yes" is an answer. Say back what you heard in one line before the next. Stop after three or four rounds; "just build it" means fill the rest with your own choices and list them under Open questions. Then fill CODEX.md in one call (file_read it once; then file_write the whole file, or one file_edit whose edits list has every section) and show it with game_codex.',
|
|
906
946
|
...(guide ? ['', guide] : []),
|
|
907
947
|
].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
|
|
908
948
|
},
|
|
@@ -1062,7 +1102,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1062
1102
|
if (!r.ended) return stillRunning(r.job, 'The deploy plan');
|
|
1063
1103
|
if (r.job.code !== 0) return fail(whyOf(r.job));
|
|
1064
1104
|
const p = r.result;
|
|
1065
|
-
return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}
|
|
1105
|
+
return ok([`What going online does for ${p.studio}:`, ...p.cloudflare.map((x) => ` ${x.kind}${x.name ? ` ${x.name}` : ''}: ${x.what} [${x.state}]`), `Cost: ${p.cost}`, `Sign-in: ${p.login}`, `The directory stores: ${p.directory?.stores ?? ''}`, 'The card on screen shows this plan (nothing is made yet): tell the person its gist in two or three lines, then go on.'].join('\n'), { kind: 'deploy-plan', ...p });
|
|
1066
1106
|
}
|
|
1067
1107
|
const studio = readStudio(root);
|
|
1068
1108
|
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy'] }] });
|
|
@@ -1294,14 +1334,18 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1294
1334
|
},
|
|
1295
1335
|
{
|
|
1296
1336
|
name: 'file_edit', title: 'Edit a studio file',
|
|
1297
|
-
description: 'Replace exact text in a studio file: old must match exactly once (spaces and line breaks too), or all: true for every match. Read the file first.',
|
|
1298
|
-
inputSchema: { type: 'object', properties: { path: str('The file, relative to the studio'), old: str('The exact text to replace'), new: str('What replaces it'), all: { type: 'boolean' },
|
|
1337
|
+
description: 'Replace exact text in a studio file: old must match exactly once (spaces and line breaks too), or all: true for every match. Read the file first. Several changes to one file go in ONE call: edits is a list of { old, new, all? } applied in order, all of them or none (a miss says which one, and the file is left as it was). Prefer one call with every change over a call per change.',
|
|
1338
|
+
inputSchema: { type: 'object', properties: { path: str('The file, relative to the studio'), old: str('The exact text to replace (one change)'), new: str('What replaces it'), all: { type: 'boolean' }, edits: { type: 'array', description: 'Several changes to this file in one call, applied in order, all or none', items: { type: 'object', properties: { old: str('The exact text to replace'), new: str('What replaces it'), all: { type: 'boolean' } }, required: ['old', 'new'] } }, ...STUDIO_ARG }, required: ['path'] },
|
|
1299
1339
|
annotations: { title: 'Edit a file', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
1300
1340
|
run: async (a) => {
|
|
1301
1341
|
const root = ctx.root(a.studio);
|
|
1302
|
-
const
|
|
1342
|
+
const list = Array.isArray(a.edits) && a.edits.length ? a.edits : null;
|
|
1343
|
+
if (!list && (a.old === undefined || a.new === undefined)) return fail('give old and new (one change), or edits: [{ "old": "…", "new": "…" }, …] for several changes to this file in one call');
|
|
1344
|
+
const r = list
|
|
1345
|
+
? editStudioFile(root, a.path, null, null, { edits: [...(a.old !== undefined && a.new !== undefined ? [{ old: a.old, new: a.new, all: a.all === true }] : []), ...list] })
|
|
1346
|
+
: editStudioFile(root, a.path, a.old, a.new, { all: a.all === true });
|
|
1303
1347
|
ctx.note(root).changed = true;
|
|
1304
|
-
return ok(`Changed ${r.rel} (${r.replaced} place${r.replaced === 1 ? '' : 's'}).`);
|
|
1348
|
+
return ok(`Changed ${r.rel} (${r.replaced} place${r.replaced === 1 ? '' : 's'}${r.edits > 1 ? `, ${r.edits} edits in one call` : ''}).`);
|
|
1305
1349
|
},
|
|
1306
1350
|
},
|
|
1307
1351
|
{
|
|
@@ -1333,7 +1377,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
1333
1377
|
return ok(readFileSync(f, 'utf8'));
|
|
1334
1378
|
}
|
|
1335
1379
|
const refs = (() => { try { return readdirSync(join(dir, 'references')); } catch { return []; } })();
|
|
1336
|
-
return ok(`${DESKTOP_NOTE}\n\n${readFileSync(join(dir, 'SKILL.md'), 'utf8')}${refs.length ? `\n\nReferences (file): ${refs.join(', ')}` : ''}`);
|
|
1380
|
+
return ok(`${DESKTOP_NOTE}${ctx.desktop ? `\n\n${DESKTOP_APPROVALS}` : ''}\n\n${readFileSync(join(dir, 'SKILL.md'), 'utf8')}${refs.length ? `\n\nReferences (file): ${refs.join(', ')}` : ''}`);
|
|
1337
1381
|
},
|
|
1338
1382
|
});
|
|
1339
1383
|
}
|
package/lib/mcp.mjs
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* --no-install never run npm install in a studio (tests, and a studio whose node_modules are linked by hand)
|
|
13
13
|
* --homie the directory a new studio names (default https://homie.rocks)
|
|
14
14
|
*
|
|
15
|
+
* HOMIE_STUDIO_EXTENSION=1 (the desktop extension's manifest sets it) says the server runs in the Claude desktop app,
|
|
16
|
+
* which asks the person before every tool call: only there, the model is told to say so once (DESKTOP_APPROVALS).
|
|
17
|
+
*
|
|
15
18
|
* The transport is the MCP stdio transport: one JSON-RPC 2.0 message per line on stdin and stdout; logs go to
|
|
16
19
|
* stderr. No SDK. MCP Apps cards (spec 2026-01-26, `text/html;profile=mcp-app`) are served as ui:// resources from
|
|
17
20
|
* mcp/ui/ (real files, read as they are: a card's script is never built from a function's source), and every tool
|
|
@@ -20,7 +23,7 @@
|
|
|
20
23
|
import { existsSync, readFileSync } from 'node:fs';
|
|
21
24
|
import { join } from 'node:path';
|
|
22
25
|
import { PACKAGE_ROOT } from './studio.mjs';
|
|
23
|
-
import { StudioContext, UI, availability, toolDefs } from './mcp-tools.mjs';
|
|
26
|
+
import { DESKTOP_APPROVALS, StudioContext, UI, availability, inDesktopApp, toolDefs } from './mcp-tools.mjs';
|
|
24
27
|
import { stopAllJobs, toolPath } from './jobs.mjs';
|
|
25
28
|
import { shrinkPictureData } from './pictures.mjs';
|
|
26
29
|
import { STUDIO_VERSION } from './version.mjs';
|
|
@@ -31,12 +34,12 @@ export const MCP_APP_MIME = 'text/html;profile=mcp-app';
|
|
|
31
34
|
export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios and their multiplayer web games, music and videos, run them here, and put them online on the studio's own Cloudflare. (It is not the Homie house app: that app's own server, with its room and TV tools, is a different one.) These tools do the work in this chat; the person never types a command and never needs a terminal. A studio is a folder in the studios folder; the file tools (file_list, file_read, file_edit, file_write, file_search) work inside it.
|
|
32
35
|
|
|
33
36
|
A new studio follows one checklist, in order and never ahead. Show it in your first reply and again, ticked, as each step ends:
|
|
34
|
-
0. Setup status (setup_status): what this computer and their accounts have; optional rows never block.
|
|
37
|
+
0. Setup status (setup_status; for a studio that is not made yet, pass its name as studio, so the checklist is the new studio's and never another's): what this computer and their accounts have; optional rows never block.
|
|
35
38
|
1. The studio (studio_scaffold): it has NO game; its home page says "First game coming soon".
|
|
36
39
|
2. See a working game (game_demo): a live game on Homie Arcade with its Play link, nothing copied in. Copy a starter in (game_make) only if they ask.
|
|
37
40
|
3. One small change from one sentence of theirs: to the copied game, or to the studio's home (site/theme.json colours, a tagline in studio.json, a first post in posts/). Then build and preview_run, and they reload.
|
|
38
41
|
4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex).
|
|
39
|
-
5. Build it: build_open, then game_make under the planned id (the codex stays), file edits, build, preview_run, check. The card follows every build.
|
|
42
|
+
5. Build it: build_open, then game_make under the planned id (the codex stays), file edits (few and large: one file_edit carries every change to a file in its edits list, never a call per change), build, preview_run, check. The card follows every build.
|
|
40
43
|
6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
|
|
41
44
|
If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
|
|
42
45
|
|
|
@@ -54,6 +57,9 @@ Telling Homie (homie_feedback): when the person is stuck, confused or frustrated
|
|
|
54
57
|
|
|
55
58
|
Long work (npm install, check, playtest, deploy, renders) runs in the background: the tool answers at once with a card that follows it, and build_progress or studio_job reads where it is. studio_guide has Homie's full guide for each job (game, plan, port, playtest, publish, music, sound, art, video). Never put a key or password in a file or the chat. If Homie's homie.rocks connector is connected too, its tools of the same names say what to run; these run it.`;
|
|
56
59
|
|
|
60
|
+
/** What the server tells the model as it connects: the checklist, and in the Claude desktop app what it asks the person. */
|
|
61
|
+
export const instructionsFor = ({ desktop = false } = {}) => (desktop ? `${INSTRUCTIONS}\n\n${DESKTOP_APPROVALS}` : INSTRUCTIONS);
|
|
62
|
+
|
|
57
63
|
const CARD_FILES = { [UI.setup]: 'setup.js', [UI.build]: 'build.js', [UI.studio]: 'studio.js', [UI.codex]: 'codex.js', [UI.lab]: 'lab.js', [UI.style]: 'style.js', [UI.decision]: 'decision.js', [UI.cast]: 'cast.js', [UI.lineup]: 'lineup.js', [UI.rights]: 'rights.js', [UI.animation]: 'animation.js', [UI.feedback]: 'feedback.js' };
|
|
58
64
|
const CARD_TITLES = { [UI.setup]: 'Studio setup', [UI.build]: 'Build progress', [UI.studio]: 'Studio', [UI.codex]: 'Game Codex', [UI.lab]: 'Game Lab', [UI.style]: 'Style board', [UI.decision]: 'Look decision', [UI.cast]: 'Cast', [UI.lineup]: 'Lineup', [UI.rights]: 'Rights', [UI.animation]: 'Clips', [UI.feedback]: 'Tell Homie' };
|
|
59
65
|
const UI_DIR = join(PACKAGE_ROOT, 'mcp', 'ui');
|
|
@@ -136,11 +142,11 @@ function promptText(name, args = {}) {
|
|
|
136
142
|
/**
|
|
137
143
|
* The server. `input`/`output` are streams (stdin/stdout by default). Returns a promise that ends with the input.
|
|
138
144
|
*/
|
|
139
|
-
export async function serveMcp({ studios = null, skills = null, cwd = process.cwd(), input = process.stdin, output = process.stdout, waitMs, directory = null, install = true, log = (line) => process.stderr.write(`[homie-studio mcp] ${line}\n`) } = {}) {
|
|
145
|
+
export async function serveMcp({ studios = null, skills = null, cwd = process.cwd(), input = process.stdin, output = process.stdout, waitMs, directory = null, install = true, desktop = inDesktopApp(), log = (line) => process.stderr.write(`[homie-studio mcp] ${line}\n`) } = {}) {
|
|
140
146
|
// A GUI app starts this with a short PATH: everything it runs sees the usual places Node, npm and ffmpeg live.
|
|
141
147
|
process.env.PATH = toolPath();
|
|
142
148
|
const skillsDir = skills ?? [join(PACKAGE_ROOT, '..', '..', 'plugins', 'homie', 'skills'), join(PACKAGE_ROOT, '..', 'skills')].find((d) => existsSync(join(d, 'studio-setup', 'SKILL.md'))) ?? null;
|
|
143
|
-
const ctx = new StudioContext({ studiosDir: studios, cwd, skillsDir, waitMs: waitMs ?? Number(process.env.HOMIE_MCP_WAIT_MS || 40_000), directory, install });
|
|
149
|
+
const ctx = new StudioContext({ studiosDir: studios, cwd, skillsDir, waitMs: waitMs ?? Number(process.env.HOMIE_MCP_WAIT_MS || 40_000), directory, install, desktop });
|
|
144
150
|
let avail = availability();
|
|
145
151
|
let names = '';
|
|
146
152
|
const send = (message) => output.write(`${JSON.stringify(message)}\n`);
|
|
@@ -168,7 +174,7 @@ export async function serveMcp({ studios = null, skills = null, cwd = process.cw
|
|
|
168
174
|
protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
|
|
169
175
|
capabilities: { tools: { listChanged: true }, resources: { listChanged: false }, prompts: { listChanged: false } },
|
|
170
176
|
serverInfo: { name: 'homie-studio', title: 'Homie Studio', version: STUDIO_VERSION },
|
|
171
|
-
instructions:
|
|
177
|
+
instructions: instructionsFor({ desktop: ctx.desktop }),
|
|
172
178
|
});
|
|
173
179
|
}
|
|
174
180
|
case 'ping': return reply({});
|
package/lib/scaffold.mjs
CHANGED
|
@@ -422,11 +422,16 @@ chat's card follows the work from then on) and, for a studio still being set up,
|
|
|
422
422
|
studio.json as \`cloudflare.domain\`.
|
|
423
423
|
- A game's room size is its netplay manifest's \`maxPlayers\` (game.json \`netplay\`, or netplay.json), up to 32.
|
|
424
424
|
- Nothing in this studio needs \`~/.homie\` or a Homie box.
|
|
425
|
+
- This studio is this folder. A studio in a folder beside it (another folder with its own \`studio.json\`) is
|
|
426
|
+
somebody else's work: never read it or copy from it unless the person asks.
|
|
425
427
|
- Optional in studio.json: \`"protect"\` (globs of files the owner wants to look at before any change, such as
|
|
426
428
|
\`"games/*/game.json"\`) and \`"budget"\` (\`{ "usd": <n>, "credits": <n> }\`: the most the studio's media jobs spend
|
|
427
429
|
in all). In Claude Code, the Homie plugin's mod holds an edit to a protected file, a deploy, and a paid call past
|
|
428
|
-
the budget until the person says Proceed, and takes keys out of command output.
|
|
429
|
-
|
|
430
|
+
the budget until the person says Proceed, and takes keys out of command output. In Codex the plugin's hooks hold
|
|
431
|
+
the same calls until the person's own \`proceed <code>\`, once the person has trusted them; in Grok Build nothing
|
|
432
|
+
holds them yet. \`npx --no-install homie-studio setup status --client codex\` (or \`grok\`) says whether Homie's
|
|
433
|
+
holds are on, and while they are off, ask before each of those yourself. When a call is refused, say what you
|
|
434
|
+
meant to do and why; do not retry it unless the person asks.
|
|
430
435
|
|
|
431
436
|
## Telling Homie
|
|
432
437
|
|
|
@@ -1471,6 +1471,86 @@
|
|
|
1471
1471
|
}
|
|
1472
1472
|
}
|
|
1473
1473
|
}
|
|
1474
|
+
},
|
|
1475
|
+
"0.30.1": {
|
|
1476
|
+
"files": {
|
|
1477
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1478
|
+
"README.md": "684b3cca8001673c",
|
|
1479
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1480
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1481
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1482
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1483
|
+
"posts/README.md": "37841aae476bd540",
|
|
1484
|
+
"site/README.md": "2928ca4f079ab76b",
|
|
1485
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1486
|
+
},
|
|
1487
|
+
"sections": {
|
|
1488
|
+
"AGENTS.md": {
|
|
1489
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1490
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1491
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1492
|
+
"## The site": "366ecce1a424837a",
|
|
1493
|
+
"## Making games": "abf2f0d59341512d",
|
|
1494
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1495
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1496
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1497
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1498
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1499
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1500
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1501
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1502
|
+
"## Beta": "9d02638207a26e73"
|
|
1503
|
+
}
|
|
1504
|
+
},
|
|
1505
|
+
"named": {
|
|
1506
|
+
"Homie Arcade": {
|
|
1507
|
+
"sections": {
|
|
1508
|
+
"AGENTS.md": {
|
|
1509
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
},
|
|
1515
|
+
"0.30.2": {
|
|
1516
|
+
"files": {
|
|
1517
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1518
|
+
"README.md": "684b3cca8001673c",
|
|
1519
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1520
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1521
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1522
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1523
|
+
"posts/README.md": "37841aae476bd540",
|
|
1524
|
+
"site/README.md": "2928ca4f079ab76b",
|
|
1525
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1526
|
+
},
|
|
1527
|
+
"sections": {
|
|
1528
|
+
"AGENTS.md": {
|
|
1529
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1530
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1531
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1532
|
+
"## The site": "366ecce1a424837a",
|
|
1533
|
+
"## Making games": "abf2f0d59341512d",
|
|
1534
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1535
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1536
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1537
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1538
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1539
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1540
|
+
"## Rules": "13617a3dd1a66be6",
|
|
1541
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1542
|
+
"## Beta": "9d02638207a26e73"
|
|
1543
|
+
}
|
|
1544
|
+
},
|
|
1545
|
+
"named": {
|
|
1546
|
+
"Homie Arcade": {
|
|
1547
|
+
"sections": {
|
|
1548
|
+
"AGENTS.md": {
|
|
1549
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1474
1554
|
}
|
|
1475
1555
|
}
|
|
1476
1556
|
}
|
package/mcp/ui/bridge.js
CHANGED
|
@@ -75,13 +75,26 @@ var Card = (function () {
|
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
77
|
function textOf(r) { return (r && r.content && r.content[0] && r.content[0].text) || ''; }
|
|
78
|
+
// An answer's own words, for a card that has nothing of its own to draw from it: short enough to stay a card.
|
|
79
|
+
function words(r) {
|
|
80
|
+
var j = r && r.structuredContent;
|
|
81
|
+
if (j && j.kind === 'job' && j.label) return j.label + (j.state === 'running' ? ' is still running (' + (j.seconds || 0) + ' s so far). Ask again in a moment.' : ': ' + (j.state || 'done') + '.');
|
|
82
|
+
var t = textOf(r).trim(); return t.length > 1500 ? t.slice(0, 1500).replace(/\s+\S*$/, '') + ' …' : t;
|
|
83
|
+
}
|
|
78
84
|
function render() {
|
|
79
85
|
if (!spec) return;
|
|
80
|
-
var card;
|
|
86
|
+
var card = null;
|
|
81
87
|
if (!S.data) {
|
|
82
|
-
card
|
|
83
|
-
|
|
84
|
-
|
|
88
|
+
// The tool answered, but not with this card's own data (a plan, a job still running, plain words). The card
|
|
89
|
+
// may draw that answer itself (spec.other); else it shows the answer's words. "Loading" is only for a tool
|
|
90
|
+
// that has not answered yet: an answered card never stays on the skeleton.
|
|
91
|
+
if (S.result && !S.result.isError && spec.other) { try { card = spec.other(S.result.structuredContent || {}, S.result) || null; } catch (e) { card = null; } }
|
|
92
|
+
if (!card) {
|
|
93
|
+
card = el('main', 'card');
|
|
94
|
+
if (S.result && S.result.isError) add(card, top(spec.label, pill('Not done', 'bad')), el('p', 'note bad', textOf(S.result) || 'That did not work.'));
|
|
95
|
+
else if (S.result) add(card, top(spec.label), el('p', 'note words', words(S.result) || 'Done.'));
|
|
96
|
+
else add(card, top(spec.label, pill('Loading', 'live', true)), el('div', 'skel'), el('div', 'skel'));
|
|
97
|
+
}
|
|
85
98
|
} else {
|
|
86
99
|
try { card = spec.render(S.data, S.result); } catch (e) { card = add(el('main', 'card'), top(spec.label), el('p', 'note bad', String(e && e.message || e))); }
|
|
87
100
|
if (S.result && S.result.isError && S.data) card.appendChild(el('p', 'note bad', textOf(S.result)));
|
|
@@ -131,7 +144,10 @@ var Card = (function () {
|
|
|
131
144
|
size: size, render: render, take: take, fullscreenButton: fullscreenButton, textOf: textOf,
|
|
132
145
|
ctx: function () { return ctx; },
|
|
133
146
|
state: S,
|
|
134
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* spec: { kind, label, render(data, result) -> element, poll(data) -> { tool, args, every } | null,
|
|
149
|
+
* other(structured, result) -> element | null: an answer of another kind this card draws itself }
|
|
150
|
+
*/
|
|
135
151
|
start: function (s) {
|
|
136
152
|
spec = s;
|
|
137
153
|
rpc('ui/initialize', {
|
package/mcp/ui/build.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* The build card: one build's stages, each check going green, a picture and where to play it, money against the
|
|
2
|
-
* budget, and Stop. It follows the build every 2 s while it runs and tells the model once when it ends.
|
|
2
|
+
* budget, and Stop. It follows the build every 2 s while it runs and tells the model once when it ends. A deploy's
|
|
3
|
+
* plan (studio_deploy with plan: true) is drawn here too: what going online makes and costs, before it is made. */
|
|
3
4
|
(function () {
|
|
4
5
|
var C = Card;
|
|
5
6
|
var ICON = { pass: '✓', done: '✓', fail: '✕', failed: '✕', running: '●', pending: '·', skip: '–', skipped: '–', stopped: '■' };
|
|
@@ -56,8 +57,28 @@
|
|
|
56
57
|
}
|
|
57
58
|
return card;
|
|
58
59
|
}
|
|
60
|
+
// The deploy plan (studio_deploy with plan: true): what going online makes on the person's own Cloudflare and what
|
|
61
|
+
// it costs, before anything is made. It is the same tool as the deploy, so the app draws this card for it too.
|
|
62
|
+
function plan(p) {
|
|
63
|
+
var card = C.el('main', 'card');
|
|
64
|
+
C.add(card, C.top('Build', C.pill('Plan', '')), C.el('h1', '', (p.studio || 'The studio') + ': going online'),
|
|
65
|
+
C.el('p', 'sub', 'On your own Cloudflare account. Nothing has been made or changed yet.'));
|
|
66
|
+
var ul = C.el('ul', 'list');
|
|
67
|
+
(p.cloudflare || []).filter(function (x) { return x && x.name; }).forEach(function (x) {
|
|
68
|
+
var made = /^exists/.test(x.state || '');
|
|
69
|
+
ul.appendChild(C.add(C.el('li', made ? 'state-ok' : ''), C.el('span', 'cn', made ? '✓' : '+'),
|
|
70
|
+
C.add(C.el('div', 'cb'), C.el('b', '', x.kind + ' ' + x.name), C.el('span', '', x.state + (x.plan ? ' · ' + x.plan : '')))));
|
|
71
|
+
});
|
|
72
|
+
card.appendChild(ul);
|
|
73
|
+
if (p.cost) card.appendChild(C.el('p', 'note', p.cost));
|
|
74
|
+
card.appendChild(C.el('p', 'fine', 'Cloudflare asks you to approve once in your browser if this computer is not signed in (a free account works).'));
|
|
75
|
+
if (p.directory && p.directory.stores) card.appendChild(C.el('p', 'fine', 'The Homie directory stores ' + p.directory.stores));
|
|
76
|
+
if (p.never) card.appendChild(C.el('p', 'fine', p.never));
|
|
77
|
+
return card;
|
|
78
|
+
}
|
|
59
79
|
C.start({
|
|
60
80
|
kind: 'build', label: 'Build', fullscreen: true, render: render,
|
|
81
|
+
other: function (sc) { return sc && sc.kind === 'deploy-plan' ? plan(sc) : null; },
|
|
61
82
|
poll: function (sc) { return sc.feed && sc.feed.state === 'running' ? { tool: 'build_progress', args: { build: sc.build }, every: 2000 } : null; },
|
|
62
83
|
});
|
|
63
84
|
})();
|
package/mcp/ui/card.css
CHANGED
|
@@ -20,6 +20,7 @@ h2{margin:16px 0 8px;font:600 13px/1.2 var(--f-sans);color:var(--c-fg2);text-tra
|
|
|
20
20
|
.sub{margin:0 0 12px;color:var(--c-fg2);overflow-wrap:anywhere}
|
|
21
21
|
.note{margin:10px 0 0;padding:10px 12px;border-radius:var(--r-md);background:var(--c-bg2);color:var(--c-fg2)}
|
|
22
22
|
.note.bad{background:var(--c-badbg);color:var(--c-bad)}.note.ok{background:var(--c-okbg);color:var(--c-ok);font-weight:600}
|
|
23
|
+
.note.words{margin-top:0;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
23
24
|
.fine{margin:10px 0 0;font-size:12px;color:var(--c-fg3)}
|
|
24
25
|
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:var(--r-full);font:600 12px/1.2 var(--f-sans);background:var(--c-bg2);color:var(--c-fg2);white-space:nowrap}
|
|
25
26
|
.pill.ok{background:var(--c-okbg);color:var(--c-ok)}.pill.bad{background:var(--c-badbg);color:var(--c-bad)}.pill.warn{background:var(--c-warnbg);color:var(--c-warn)}
|
package/mcp/ui/setup.js
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
var install = sc.install;
|
|
22
22
|
var head = install && install.state === 'running' ? C.pill('Installing · ' + install.seconds + ' s', 'live', true)
|
|
23
23
|
: cur ? C.pill(cur.games.length ? cur.games.length + (cur.games.length === 1 ? ' game' : ' games') : 'No game yet', cur.games.length ? 'ok' : '') : C.pill('New studio', '');
|
|
24
|
-
C.add(card, C.top('Studio setup', head), C.el('h1', '', cur ? cur.name : 'Set up a studio'),
|
|
25
|
-
C.el('p', 'sub', cur ? (cur.games.length ? 'On this computer, in ' + cur.folder : 'On this computer, in ' + cur.folder + '. Its home page says "First game coming soon".') : 'Studios live in ' + (sc.studiosDir || 'the folder set in the Homie extension') + '.'));
|
|
24
|
+
C.add(card, C.top('Studio setup', head), C.el('h1', '', cur ? cur.name : sc.wanted || 'Set up a studio'),
|
|
25
|
+
C.el('p', 'sub', cur ? (cur.games.length ? 'On this computer, in ' + cur.folder : 'On this computer, in ' + cur.folder + '. Its home page says "First game coming soon".') : (sc.wanted ? 'Not made yet. ' : '') + 'Studios live in ' + (sc.studiosDir || 'the folder set in the Homie extension') + '.'));
|
|
26
26
|
var ol = C.el('ol', 'list');
|
|
27
27
|
(sc.checklist || []).forEach(function (s) { ol.appendChild(step(s)); });
|
|
28
28
|
card.appendChild(ol);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.2",
|
|
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, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.30.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.30.2';
|