@homie-rocks/studio 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -5
- 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 +54 -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/netplay/NETPLAY.md +1 -1
- package/package.json +2 -1
- package/worker/index.mjs +20 -1
- package/worker/office.mjs +16 -6
- package/worker/site.mjs +24 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,1000 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
|
|
3
|
+
* desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
|
|
4
|
+
* the cards. Where a tool overlaps the remote Homie MCP (homie.rocks/mcp) it has the same name and the same input
|
|
5
|
+
* shape: studio_scaffold, studio_card, game_make, game_remix, game_port, preview_run, studio_deploy,
|
|
6
|
+
* studio_publish, build_open, build_progress, build_stop. The remote ones say what to run; these run it, here.
|
|
7
|
+
*
|
|
8
|
+
* Long work never holds a tool call: a check, a playtest, a deploy or an npm install starts as a job (lib/jobs.mjs)
|
|
9
|
+
* and the call answers within about 40 s (the Claude desktop app gives a local tool call 60 s). A check, playtest
|
|
10
|
+
* or deploy reports into the studio's progress feed, which the build card follows by itself.
|
|
11
|
+
*
|
|
12
|
+
* Every tool works in ONE studio folder: one the person named, else the one in use, else the only one there is. The
|
|
13
|
+
* file tools never leave it (lib/files.mjs).
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
19
|
+
import { renderCodex, newCodex } from './codex.mjs';
|
|
20
|
+
import { demoGames, formatDemo } from './demo.mjs';
|
|
21
|
+
import { formatStatus, setupStatus } from './doctor.mjs';
|
|
22
|
+
import { editStudioFile, listFiles, readStudioFile, searchStudio, writeStudioFile } from './files.mjs';
|
|
23
|
+
import { cliJob, findNode, getJob, installJob, installed, jobView, runningJobs, startJob, toolEnv, waitJob, which } from './jobs.mjs';
|
|
24
|
+
import { Feed, currentId, publicFeed, readFeed, startProgress } from './progress.mjs';
|
|
25
|
+
import { newStudio, slugify } from './scaffold.mjs';
|
|
26
|
+
import { repoFromUrl, studioRepo } from './repo.mjs';
|
|
27
|
+
import { GAME_ID, findStudio, listGames, readLocal, readStudio, siteUrl, workerDir } from './studio.mjs';
|
|
28
|
+
import { STUDIO_VERSION } from './version.mjs';
|
|
29
|
+
|
|
30
|
+
export const UI = Object.freeze({
|
|
31
|
+
setup: 'ui://homie-studio/setup',
|
|
32
|
+
build: 'ui://homie-studio/build',
|
|
33
|
+
studio: 'ui://homie-studio/studio',
|
|
34
|
+
codex: 'ui://homie-studio/codex',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const inside = (base, p) => p === base || p.startsWith(base.endsWith(sep) ? base : `${base}${sep}`);
|
|
38
|
+
const realOr = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
39
|
+
|
|
40
|
+
/** A folder as the Claude desktop app's settings give it: `${HOME}/Studios` arrives as written, so expand it here. */
|
|
41
|
+
export function expandHome(value, home = homedir()) {
|
|
42
|
+
const v = String(value ?? '').trim();
|
|
43
|
+
if (!v) return null;
|
|
44
|
+
const map = { HOME: home, DOCUMENTS: join(home, 'Documents'), DESKTOP: join(home, 'Desktop'), DOWNLOADS: join(home, 'Downloads') };
|
|
45
|
+
return resolve(v.replace(/^~(?=$|[\\/])/, home).replace(/\$\{(HOME|DOCUMENTS|DESKTOP|DOWNLOADS)\}|\$HOME\b/g, (m, k) => map[k ?? 'HOME']));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ------------------------------------------------------------------ which studio */
|
|
49
|
+
|
|
50
|
+
export class StudioContext {
|
|
51
|
+
constructor({ studiosDir = null, cwd = process.cwd(), skillsDir = null, waitMs = 40_000, directory = null, install = true } = {}) {
|
|
52
|
+
this.install = install;
|
|
53
|
+
this.studiosDir = studiosDir ? expandHome(studiosDir) : null;
|
|
54
|
+
this.cwdStudio = findStudio(cwd);
|
|
55
|
+
this.current = this.cwdStudio;
|
|
56
|
+
this.skillsDir = skillsDir && existsSync(skillsDir) ? skillsDir : null;
|
|
57
|
+
this.waitMs = waitMs;
|
|
58
|
+
this.directory = directory;
|
|
59
|
+
this.notes = new Map();
|
|
60
|
+
this.runs = new Map();
|
|
61
|
+
this.status = null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
note(root) { if (!this.notes.has(root)) this.notes.set(root, {}); return this.notes.get(root); }
|
|
65
|
+
|
|
66
|
+
/** The studios in the studios folder (each a folder with studio.json). */
|
|
67
|
+
studios() {
|
|
68
|
+
if (!this.studiosDir || !existsSync(this.studiosDir)) return this.cwdStudio ? [this.describe(this.cwdStudio)] : [];
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const e of readdirSync(this.studiosDir, { withFileTypes: true })) {
|
|
71
|
+
if (!e.isDirectory() || e.name.startsWith('.')) continue;
|
|
72
|
+
const root = join(this.studiosDir, e.name);
|
|
73
|
+
if (existsSync(join(root, 'studio.json'))) out.push(this.describe(root));
|
|
74
|
+
}
|
|
75
|
+
if (this.cwdStudio && !out.some((s) => s.root === this.cwdStudio)) out.unshift(this.describe(this.cwdStudio));
|
|
76
|
+
return out.sort((a, b) => a.folder.localeCompare(b.folder));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe(root) {
|
|
80
|
+
let studio = {};
|
|
81
|
+
try { studio = readStudio(root); } catch { studio = {}; }
|
|
82
|
+
let games = [];
|
|
83
|
+
try { games = listGames(root).map((g) => g.id); } catch { games = []; }
|
|
84
|
+
return { folder: basename(root), root, name: String(studio.name ?? basename(root)).slice(0, 60), games, installed: installed(root) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
allowed(p) {
|
|
88
|
+
const real = realOr(p);
|
|
89
|
+
return Boolean((this.studiosDir && inside(realOr(this.studiosDir), real)) || (this.cwdStudio && inside(realOr(this.cwdStudio), real)));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The studio a tool works in: `arg` (a folder name, a studio's name, or a path inside the studios folder). */
|
|
93
|
+
root(arg, { need = true } = {}) {
|
|
94
|
+
if (arg !== undefined && arg !== null && String(arg).trim()) {
|
|
95
|
+
const a = String(arg).trim();
|
|
96
|
+
const list = this.studios();
|
|
97
|
+
const byName = list.find((s) => s.folder === a || s.name.toLowerCase() === a.toLowerCase() || slugify(s.name) === slugify(a));
|
|
98
|
+
const path = byName?.root ?? (isAbsolute(expandHome(a) ?? '') && (a.includes('/') || a.includes('\\') || a.startsWith('~')) ? expandHome(a) : this.studiosDir ? join(this.studiosDir, a) : null);
|
|
99
|
+
if (!path || !existsSync(join(path, 'studio.json'))) throw new Error(`no studio "${a}"${list.length ? ` (studios here: ${list.map((s) => s.folder).join(', ')})` : ''}`);
|
|
100
|
+
if (!this.allowed(path)) throw new Error(`${path} is outside the studios folder (${this.studiosDir ?? 'none is set'}); these tools only work in it`);
|
|
101
|
+
this.current = realOr(path);
|
|
102
|
+
return this.current;
|
|
103
|
+
}
|
|
104
|
+
if (this.current && existsSync(join(this.current, 'studio.json'))) return this.current;
|
|
105
|
+
const list = this.studios();
|
|
106
|
+
if (list.length === 1) { this.current = list[0].root; return this.current; }
|
|
107
|
+
if (!need) return null;
|
|
108
|
+
throw new Error(list.length ? `which studio? Pass studio: one of ${list.map((s) => `"${s.folder}"`).join(', ')}` : 'there is no studio yet: studio_scaffold makes one');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ------------------------------------------------------------------ results */
|
|
113
|
+
|
|
114
|
+
const ok = (text, structured, extra = {}) => ({ content: [{ type: 'text', text }], ...(structured ? { structuredContent: structured } : {}), ...extra });
|
|
115
|
+
const fail = (text, structured) => ({ content: [{ type: 'text', text }], ...(structured ? { structuredContent: structured } : {}), isError: true });
|
|
116
|
+
|
|
117
|
+
/** Run the pinned CLI and wait a while; { ended, job, result }. */
|
|
118
|
+
async function cli(ctx, root, label, args, { wait = ctx.waitMs, onEnd = null } = {}) {
|
|
119
|
+
const job = cliJob(root, label, args, { onEnd });
|
|
120
|
+
const ended = await waitJob(job, wait);
|
|
121
|
+
return { ended, job, result: job.result };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stillRunning(job, what) {
|
|
125
|
+
return ok(`${what} is still running (job ${job.id}, ${jobView(job).seconds} s so far). studio_job { "job": "${job.id}" } says when it is done; tell the person what is happening meanwhile.`, { kind: 'job', ...jobView(job) });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A command's own words when it failed (homie-studio prints `why`). */
|
|
129
|
+
function whyOf(job) {
|
|
130
|
+
const r = job.result;
|
|
131
|
+
if (r && r.why) return r.why;
|
|
132
|
+
return jobView(job, { lines: 6 }).tail.join('\n') || `exited with ${job.code}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The studio's npm install, when it has not been done: started once, and the tool says so. */
|
|
136
|
+
function needsInstall(ctx, root) {
|
|
137
|
+
if (installed(root) || !ctx.install) return null;
|
|
138
|
+
const running = runningJobs(root).find((j) => j.label.startsWith('npm install'));
|
|
139
|
+
const job = running ?? installJob(root);
|
|
140
|
+
return ok(`The studio's toolkit is still being installed (npm install, job ${job.id}; usually 20 to 60 s). Ask again in a moment, or follow it with studio_job { "job": "${job.id}" }.`, { kind: 'job', ...jobView(job) });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ------------------------------------------------------------------ the setup card */
|
|
144
|
+
|
|
145
|
+
async function statusOf(ctx, root, { fresh = false } = {}) {
|
|
146
|
+
const key = root ?? '';
|
|
147
|
+
if (!fresh && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
|
|
148
|
+
const node = findNode({ fresh });
|
|
149
|
+
// Claude Code's own status line is not this host's: its row stays out (CLAUDECODE says the server runs inside it).
|
|
150
|
+
const env = toolEnv();
|
|
151
|
+
delete env.CLAUDECODE;
|
|
152
|
+
const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', node: node?.version ?? '0', env });
|
|
153
|
+
const row = value.rows.find((r) => r.id === 'node');
|
|
154
|
+
if (row) {
|
|
155
|
+
if (!node) { row.detail = 'not found on this computer (22 or newer is needed)'; row.state = 'missing'; }
|
|
156
|
+
else row.detail = `v${node.version}${node.npm ? ', with npm' : ', but no npm next to it'}`;
|
|
157
|
+
}
|
|
158
|
+
const conn = value.rows.find((r) => r.id === 'connector');
|
|
159
|
+
if (conn) { conn.label = 'Homie tools'; conn.detail = `on this computer (the Homie extension, toolkit ${STUDIO_VERSION})${conn.detail.includes('directory answers') ? '; the homie.rocks directory answers' : ''}`; conn.unlocks = 'everything here: the studio, its games, the checks, deploys and the cards'; }
|
|
160
|
+
ctx.status = { key, at: Date.now(), value };
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function checklist(ctx, root) {
|
|
165
|
+
const steps = ['Setup status', 'The studio', 'See a working game', 'One small change', 'Plan your game: its Game Codex', 'Build it, with progress you can watch', 'Playtest it, then put it online'];
|
|
166
|
+
const done = [true, Boolean(root)];
|
|
167
|
+
if (root) {
|
|
168
|
+
const games = listGames(root);
|
|
169
|
+
const note = ctx.note(root);
|
|
170
|
+
const codex = (() => { try { return readdirSync(join(root, 'games'), { withFileTypes: true }).some((d) => d.isDirectory() && existsSync(join(root, 'games', d.name, 'CODEX.md'))); } catch { return false; } })();
|
|
171
|
+
const local = readLocal(root);
|
|
172
|
+
// A build whose checks passed (any feed on disk, so it holds across the app's restarts).
|
|
173
|
+
const checked = note.checked || (() => {
|
|
174
|
+
try { return readdirSync(join(root, '.studio', 'progress')).some((f) => f.endsWith('.json') && readFeed(root, f.slice(0, -5))?.stages?.some((x) => x.id === 'checks' && x.state === 'done')); } catch { return false; }
|
|
175
|
+
})();
|
|
176
|
+
done.push(Boolean(note.demo || games.length || codex), Boolean(note.changed || codex), codex, Boolean(checked), Boolean(local.deployedAt || local.connectedAt));
|
|
177
|
+
}
|
|
178
|
+
// In order: a step counts as done only when every step before it is (an early file edit is not "one small change").
|
|
179
|
+
for (let i = 1; i < done.length; i++) done[i] = done[i] && done[i - 1];
|
|
180
|
+
let now = done.findIndex((d) => !d);
|
|
181
|
+
if (now < 0) now = done.length;
|
|
182
|
+
return steps.map((label, n) => ({ n, label, state: done[n] ? 'done' : n === now ? 'now' : 'todo' }));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function setupCard(ctx, root, { fresh = false, made = null, install = null } = {}) {
|
|
186
|
+
const status = await statusOf(ctx, root, { fresh });
|
|
187
|
+
const studios = ctx.studios().map(({ root: r, ...s }) => s);
|
|
188
|
+
const current = root ? ctx.describe(root) : null;
|
|
189
|
+
const running = root ? runningJobs(root).find((j) => j.label.startsWith('npm install')) : null;
|
|
190
|
+
const job = install ?? running ?? null;
|
|
191
|
+
const steps = checklist(ctx, root);
|
|
192
|
+
const data = {
|
|
193
|
+
kind: 'setup', version: STUDIO_VERSION, studiosDir: ctx.studiosDir,
|
|
194
|
+
current: current ? { folder: current.folder, name: current.name, games: current.games, installed: current.installed, site: siteUrl(root) } : null,
|
|
195
|
+
studios, checklist: steps,
|
|
196
|
+
status: { rows: status.rows.map(({ parts, ...r }) => r), features: status.features, next: status.next, meanwhile: status.meanwhile, blocking: status.blocking },
|
|
197
|
+
install: job ? jobView(job) : null, made,
|
|
198
|
+
};
|
|
199
|
+
const text = [
|
|
200
|
+
current ? `Studio: ${current.name} (${root})${current.installed ? '' : job ? ' (installing its toolkit)' : ''}` : `No studio yet${ctx.studiosDir ? ` in ${ctx.studiosDir}` : ''}.`,
|
|
201
|
+
'',
|
|
202
|
+
`New studio${current ? `: ${current.name}` : ''}`,
|
|
203
|
+
...steps.map((s) => ` ${s.state === 'done' ? '✓' : s.state === 'now' ? '→' : ' '} ${s.n}. ${s.label}`),
|
|
204
|
+
'',
|
|
205
|
+
formatStatus(status),
|
|
206
|
+
'',
|
|
207
|
+
'The card on screen shows this; it follows the install by itself.',
|
|
208
|
+
].join('\n');
|
|
209
|
+
return ok(text, data);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* ------------------------------------------------------------------ the studio card */
|
|
213
|
+
|
|
214
|
+
const COVER_MAX = 80 * 1024;
|
|
215
|
+
function coverData(dir) {
|
|
216
|
+
for (const rel of ['hero/wide.jpg', 'cover.jpg', 'cover.webp', 'cover.png', 'public/cover.jpg', 'public/cover.png']) {
|
|
217
|
+
const f = join(dir, rel);
|
|
218
|
+
try {
|
|
219
|
+
if (existsSync(f) && statSync(f).size <= COVER_MAX) {
|
|
220
|
+
const type = { '.jpg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }[extname(f)];
|
|
221
|
+
return `data:${type};base64,${readFileSync(f).toString('base64')}`;
|
|
222
|
+
}
|
|
223
|
+
} catch { /* none */ }
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function devOf(root) {
|
|
229
|
+
try {
|
|
230
|
+
const rec = JSON.parse(readFileSync(join(workerDir(root), '.wrangler', 'homie-dev.json'), 'utf8'));
|
|
231
|
+
process.kill(rec.pid, 0);
|
|
232
|
+
return { url: `http://127.0.0.1:${rec.port}`, port: rec.port, pid: rec.pid };
|
|
233
|
+
} catch { return null; }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function published(root, kind) {
|
|
237
|
+
try {
|
|
238
|
+
const m = JSON.parse(readFileSync(join(root, kind, 'manifest.json'), 'utf8'));
|
|
239
|
+
return (Array.isArray(m.items) ? m.items : []).filter((e) => e && e.publish !== false && e.slug).map((e) => ({ slug: String(e.slug), title: String(e.title ?? e.slug).slice(0, 80) }));
|
|
240
|
+
} catch { return []; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function studioCard(ctx, root) {
|
|
244
|
+
const studio = readStudio(root);
|
|
245
|
+
const dev = devOf(root);
|
|
246
|
+
const site = siteUrl(root, studio);
|
|
247
|
+
const live = site && /^https:\/\//.test(site) ? site : null;
|
|
248
|
+
const headers = { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION} (studio card)` };
|
|
249
|
+
// Which games the LIVE site has (a game made since the last deploy is only here): its own /api/games.
|
|
250
|
+
let onLine = new Set();
|
|
251
|
+
if (live) {
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(`${live}/api/games`, { headers, signal: AbortSignal.timeout(4000) });
|
|
254
|
+
const body = res.ok ? await res.json() : null;
|
|
255
|
+
onLine = new Set((Array.isArray(body?.games) ? body.games : []).map((g) => String(g?.id ?? '')));
|
|
256
|
+
} catch { onLine = new Set(); }
|
|
257
|
+
}
|
|
258
|
+
let budget = 100 * 1024;
|
|
259
|
+
const games = listGames(root).slice(0, 12).map((g) => {
|
|
260
|
+
const cover = budget > 0 ? coverData(g.dir) : null;
|
|
261
|
+
if (cover) budget -= cover.length;
|
|
262
|
+
const isLive = live && onLine.has(g.id);
|
|
263
|
+
return { id: g.id, name: String(g.name ?? g.id).slice(0, 60), blurb: String(g.blurb ?? '').slice(0, 160), cover, live: Boolean(isLive), play: { dev: dev ? `${dev.url}/${g.id}/play` : null, live: isLive ? `${live}/${g.id}/play` : null }, page: isLive ? `${live}/${g.id}/` : null };
|
|
264
|
+
});
|
|
265
|
+
const posts = (() => { try { return readdirSync(join(root, 'posts')).filter((f) => /\.md$/.test(f) && f !== 'README.md' && !/^[_.]/.test(f)).length; } catch { return 0; } })();
|
|
266
|
+
let rooms = [];
|
|
267
|
+
if (live && games.length) {
|
|
268
|
+
try {
|
|
269
|
+
const res = await fetch(`${live}/api/rooms`, { headers, signal: AbortSignal.timeout(4000) });
|
|
270
|
+
const body = res.ok ? await res.json() : null;
|
|
271
|
+
rooms = (Array.isArray(body?.rooms) ? body.rooms : []).slice(0, 8).map((r) => ({ game: String(r.game ?? '').slice(0, 40), room: String(r.room ?? '').slice(0, 20), players: Number(r.players) || 0 }));
|
|
272
|
+
} catch { rooms = []; }
|
|
273
|
+
}
|
|
274
|
+
const demo = games.length ? null : await demoGames({ timeoutMs: 4000 }).then((d) => ({ name: d.pick.name, play: d.pick.play, studio: d.studio.name })).catch(() => null);
|
|
275
|
+
const data = {
|
|
276
|
+
kind: 'studio', folder: basename(root), root, name: studio.name, tagline: studio.tagline ?? null,
|
|
277
|
+
games, songs: published(root, 'music'), videos: published(root, 'videos'), posts,
|
|
278
|
+
dev: dev ? { url: dev.url } : null, site: live, rooms, demo, installed: installed(root),
|
|
279
|
+
};
|
|
280
|
+
const text = [
|
|
281
|
+
`${studio.name} (${root})`,
|
|
282
|
+
games.length ? `Games: ${games.map((g) => `${g.name} (${g.id}${live ? (g.live ? ', live' : ', not deployed yet') : ''})`).join(', ')}` : 'No game yet: its home page says "First game coming soon".',
|
|
283
|
+
dev ? `Running here: ${dev.url}/` : 'Not running here (preview_run starts it).',
|
|
284
|
+
live ? `Live: ${live}/` : 'Not online yet (studio_deploy).',
|
|
285
|
+
...(rooms.length ? [`Live rooms: ${rooms.map((r) => `${r.game} ${r.room} (${r.players})`).join(', ')}`] : []),
|
|
286
|
+
...(demo ? [`See a working game meanwhile: ${demo.name}, ${demo.play}`] : []),
|
|
287
|
+
].join('\n');
|
|
288
|
+
return ok(text, data);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* ------------------------------------------------------------------ builds: the feed the card follows */
|
|
292
|
+
|
|
293
|
+
function feedFor(root, build) {
|
|
294
|
+
const id = String(build ?? '').trim();
|
|
295
|
+
if (!id || id === 'current') { const c = currentId(root); return c ? { id: c, doc: readFeed(root, c) } : null; }
|
|
296
|
+
const doc = readFeed(root, id);
|
|
297
|
+
if (doc) return { id, doc };
|
|
298
|
+
// A shared build's id (hb_…) names the same feed.
|
|
299
|
+
try {
|
|
300
|
+
for (const f of readdirSync(join(root, '.studio', 'progress'))) {
|
|
301
|
+
if (!f.endsWith('.json')) continue;
|
|
302
|
+
const d = readFeed(root, f.slice(0, -5));
|
|
303
|
+
if (d?.shared?.build === id) return { id: f.slice(0, -5), doc: d };
|
|
304
|
+
}
|
|
305
|
+
} catch { /* none */ }
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A feed for a check, a playtest or a deploy, unless the build the person is watching is open already. */
|
|
310
|
+
async function feedForRun(ctx, root, { id, title }) {
|
|
311
|
+
const open = currentId(root);
|
|
312
|
+
if (open && readFeed(root, open)?.state === 'running') return { build: open, auto: false };
|
|
313
|
+
const r = await startProgress(root, { what: 'game', id: id ?? null, title });
|
|
314
|
+
if (!r.ok) throw new Error(r.why);
|
|
315
|
+
new Feed(root, r.build).stage('plan', 'skipped', 'run as it is');
|
|
316
|
+
return { build: r.build, auto: true };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function endRun(root, run, state, note) {
|
|
320
|
+
if (!run.auto) return;
|
|
321
|
+
try {
|
|
322
|
+
const feed = new Feed(root, run.build);
|
|
323
|
+
const doc = feed.doc;
|
|
324
|
+
if (!doc || doc.state !== 'running') return;
|
|
325
|
+
feed.change((d) => { for (const s of d.stages) if (s.state === 'pending') s.state = 'skipped'; });
|
|
326
|
+
feed.end(state, note);
|
|
327
|
+
} catch { /* the feed stays as it was */ }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function buildCard(ctx, root, found) {
|
|
331
|
+
const feed = publicFeed(found.doc);
|
|
332
|
+
const run = ctx.runs.get(found.id);
|
|
333
|
+
const jobs = (run?.jobs ?? []).map((j) => jobView(j, { lines: 4 }));
|
|
334
|
+
const stage = feed.stages.find((s) => s.id === feed.stage) ?? feed.stages[0];
|
|
335
|
+
const passed = feed.checks.filter((c) => c.state === 'pass').length;
|
|
336
|
+
const failed = feed.checks.filter((c) => c.state === 'fail');
|
|
337
|
+
const text = [
|
|
338
|
+
`${feed.title}: ${feed.state === 'running' ? `running: ${stage?.label ?? ''}` : feed.state}.`,
|
|
339
|
+
feed.checks.length ? `Checks: ${passed} of ${feed.checks.length} passed${failed.length ? `; failing: ${failed.map((c) => `${c.label}${c.note ? ` (${c.note})` : ''}`).join('; ')}` : ''}.` : null,
|
|
340
|
+
feed.preview?.url ? `Play it: ${feed.preview.url}` : null,
|
|
341
|
+
feed.error ? `Last error: ${feed.error}` : null,
|
|
342
|
+
...(run?.look ? [`Pictures of the site (file_read shows one): ${run.look.join(', ')}`] : []),
|
|
343
|
+
...jobs.filter((j) => j.state === 'failed').map((j) => `${j.label} failed: ${j.result?.why ?? j.tail.slice(-2).join(' ')}`),
|
|
344
|
+
feed.state === 'running' ? 'The card on screen follows it live; you need not call this again to watch it.' : null,
|
|
345
|
+
].filter(Boolean).join('\n');
|
|
346
|
+
// A check, playtest or deploy inside a build the person opened (build_open) ends before the build does: say so.
|
|
347
|
+
const runView = run ? { kind: run.kind, title: run.title, state: run.state, why: run.why ?? null } : null;
|
|
348
|
+
const runLine = run && !run.auto && run.state !== 'running' ? `${run.title}: ${run.state}${run.why ? ` (${run.why})` : ''}; the build itself stays open until you end it (studio_run ["progress","end","passed"]).` : null;
|
|
349
|
+
return ok(runLine ? `${runLine}\n${text}` : text, { kind: 'build', build: found.id, feed, jobs, run: runView, now: new Date().toISOString() });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Start a check / playtest / deploy: its feed first, then the work, and the card at once. */
|
|
353
|
+
async function startRun(ctx, root, { kind, game, title, steps }) {
|
|
354
|
+
const run = { kind, title, state: 'running', ...(await feedForRun(ctx, root, { id: game ?? null, title })), jobs: [] };
|
|
355
|
+
ctx.runs.set(run.build, run);
|
|
356
|
+
const next = (i) => {
|
|
357
|
+
if (i >= steps.length) { run.state = 'passed'; endRun(root, run, 'passed', `${title}: done.`); if (kind !== 'deploy') ctx.note(root).checked = true; return; }
|
|
358
|
+
const step = steps[i];
|
|
359
|
+
const job = cliJob(root, step.label, step.args, {
|
|
360
|
+
onEnd: (j) => {
|
|
361
|
+
step.after?.(j, run);
|
|
362
|
+
if (j.code !== 0 && !step.soft) { run.state = 'failed'; run.why = `${step.label}: ${whyOf(j)}`; endRun(root, run, 'failed', run.why); return; }
|
|
363
|
+
next(i + 1);
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
run.jobs.push(job);
|
|
367
|
+
};
|
|
368
|
+
next(0);
|
|
369
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
370
|
+
return buildCard(ctx, root, feedFor(root, run.build));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function devUrl(ctx, root, port) {
|
|
374
|
+
const dev = devOf(root);
|
|
375
|
+
if (dev) return dev.url;
|
|
376
|
+
const r = await startDev(ctx, root, port);
|
|
377
|
+
if (r.error) throw new Error(r.error);
|
|
378
|
+
return r.url;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function freePort(start = 8787) {
|
|
382
|
+
for (let p = start; p < start + 20; p++) {
|
|
383
|
+
try { await fetch(`http://127.0.0.1:${p}/`, { signal: AbortSignal.timeout(800) }); } catch (error) {
|
|
384
|
+
if (/ECONNREFUSED|fetch failed/i.test(String(error?.cause?.code ?? error?.cause?.message ?? error?.message))) return p;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return start;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function startDev(ctx, root, port) {
|
|
391
|
+
const at = Number(port) || await freePort();
|
|
392
|
+
const job = cliJob(root, `the studio's site, here (port ${at})`, ['dev', '--port', String(at)]);
|
|
393
|
+
const until = Date.now() + Math.min(ctx.waitMs, 45_000);
|
|
394
|
+
while (Date.now() < until) {
|
|
395
|
+
if (job.endedAt) return { error: `the dev site stopped: ${whyOf(job)}` };
|
|
396
|
+
try { if ((await fetch(`http://127.0.0.1:${at}/api/games`, { signal: AbortSignal.timeout(1500) })).ok) return { url: `http://127.0.0.1:${at}`, job }; } catch { /* not yet */ }
|
|
397
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
398
|
+
}
|
|
399
|
+
return { url: `http://127.0.0.1:${at}`, job, slow: true };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* ------------------------------------------------------------------ a studio from GitHub */
|
|
403
|
+
|
|
404
|
+
/** owner/name of a checkout's origin remote, lowercased, or null. */
|
|
405
|
+
function originOf(dir) {
|
|
406
|
+
const r = spawnSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8', timeout: 5000, env: toolEnv() });
|
|
407
|
+
return r.status === 0 ? repoFromUrl(r.stdout)?.toLowerCase() ?? null : null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Whether the GitHub CLI is here and signed in: { gh, signedIn }. */
|
|
411
|
+
function ghState() {
|
|
412
|
+
const gh = which(process.platform === 'win32' ? 'gh.exe' : 'gh');
|
|
413
|
+
if (!gh) return { gh: null, signedIn: false };
|
|
414
|
+
return { gh, signedIn: spawnSync(gh, ['auth', 'status', '--hostname', 'github.com'], { encoding: 'utf8', timeout: 10_000, env: toolEnv() }).status === 0 };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const GITHUB_REFUSED = 'GitHub did not let this computer read that repository. Sign this computer in to GitHub (github_login: a one-time code in the browser), or check the repository\'s name; then ask again. Never paste a token into the chat.';
|
|
418
|
+
|
|
419
|
+
/** studio_open { repo }: clone a studio from GitHub into the studios folder with this computer's own sign-in. */
|
|
420
|
+
async function openFromGitHub(ctx, a) {
|
|
421
|
+
const raw = String(a.repo ?? '').trim();
|
|
422
|
+
const repo = studioRepo(raw) ?? repoFromUrl(raw);
|
|
423
|
+
if (!repo) return fail('repo is the studio\'s GitHub repository: owner/name or its github.com address (never Homie\'s own engine repository)');
|
|
424
|
+
if (!ctx.studiosDir) return fail('no studios folder is set: in Claude, Settings > Extensions > Homie, choose the folder your studios live in');
|
|
425
|
+
mkdirSync(ctx.studiosDir, { recursive: true });
|
|
426
|
+
const base = realOr(ctx.studiosDir);
|
|
427
|
+
const name = basename(String(a.folder ?? '').trim() || repo.split('/')[1]);
|
|
428
|
+
if (!name || name.startsWith('.')) return fail('folder is a plain folder name');
|
|
429
|
+
const dir = join(base, name);
|
|
430
|
+
if (existsSync(dir)) {
|
|
431
|
+
if (existsSync(join(dir, 'studio.json')) && originOf(dir) === repo.toLowerCase()) {
|
|
432
|
+
ctx.current = dir;
|
|
433
|
+
const card = await studioCard(ctx, dir);
|
|
434
|
+
card.content[0].text = `${repo} is here already, in ${dir}.\n${card.content[0].text}`;
|
|
435
|
+
return card;
|
|
436
|
+
}
|
|
437
|
+
return fail(`a folder called ${name} is in the studios folder already: pass folder with another name`);
|
|
438
|
+
}
|
|
439
|
+
const { gh, signedIn } = ghState();
|
|
440
|
+
// Never a prompt in a hidden terminal: git's own credential helper (the keychain, a sign-in window) or gh's sign-in.
|
|
441
|
+
const env = toolEnv({ GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1' });
|
|
442
|
+
const job = gh && signedIn
|
|
443
|
+
? startJob({ root: base, label: `clone ${repo}`, cmd: gh, args: ['repo', 'clone', repo, dir], env })
|
|
444
|
+
: startJob({ root: base, label: `clone ${repo}`, cmd: which(process.platform === 'win32' ? 'git.exe' : 'git') ?? 'git', args: ['clone', `https://github.com/${repo}.git`, dir], env });
|
|
445
|
+
if (!(await waitJob(job, ctx.waitMs))) return stillRunning(job, `Cloning ${repo}`);
|
|
446
|
+
if (job.code !== 0) return fail(`${GITHUB_REFUSED}\n(${jobView(job, { lines: 3 }).tail.join(' ')})`, { kind: 'job', ...jobView(job), needs: 'github' });
|
|
447
|
+
if (!existsSync(join(dir, 'studio.json'))) {
|
|
448
|
+
// The folder is the clone this call just made: it is not a studio, so it goes.
|
|
449
|
+
rmSync(dir, { recursive: true, force: true });
|
|
450
|
+
return fail(`${repo} is not a Homie studio (it has no studio.json): nothing was kept`);
|
|
451
|
+
}
|
|
452
|
+
ctx.current = dir;
|
|
453
|
+
let install = null;
|
|
454
|
+
if (ctx.install) { try { install = installJob(dir); } catch { install = null; } }
|
|
455
|
+
const card = await studioCard(ctx, dir);
|
|
456
|
+
card.content[0].text = `Cloned ${repo} into ${dir}${gh && signedIn ? ' (with the GitHub CLI\'s sign-in)' : ' (with git\'s own credentials)'}.${install ? ` Its toolkit is installing (job ${install.id}).` : ''}\n${card.content[0].text}`;
|
|
457
|
+
return card;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** github_login: GitHub's device sign-in through the GitHub CLI; the person types the one-time code in the browser. */
|
|
461
|
+
async function githubLogin(ctx) {
|
|
462
|
+
const { gh, signedIn } = ghState();
|
|
463
|
+
if (!gh) return fail('The GitHub CLI is not on this computer: it is one download from https://cli.github.com (or, on a Mac with Homebrew, brew install gh). Then ask again. No token is ever pasted anywhere.');
|
|
464
|
+
if (signedIn) return ok('This computer is signed in to GitHub already.');
|
|
465
|
+
const job = startJob({ root: ctx.current ?? ctx.studiosDir ?? homedir(), label: 'GitHub sign-in (gh auth login)', cmd: gh, args: ['auth', 'login', '--hostname', 'github.com', '--git-protocol', 'https', '--web'], env: toolEnv() });
|
|
466
|
+
let code = null;
|
|
467
|
+
const until = Date.now() + 15_000;
|
|
468
|
+
while (!job.endedAt && Date.now() < until && !code) {
|
|
469
|
+
code = /\b([A-Z0-9]{4}-[A-Z0-9]{4})\b/.exec(`${job.out}\n${job.err}`)?.[1] ?? null;
|
|
470
|
+
if (!code) await new Promise((r) => setTimeout(r, 300));
|
|
471
|
+
}
|
|
472
|
+
if (job.endedAt && job.code === 0) return ok('Signed in to GitHub.');
|
|
473
|
+
if (!code) return fail(`GitHub's sign-in did not start: ${jobView(job, { lines: 3 }).tail.join(' ') || 'no answer from the GitHub CLI'}`);
|
|
474
|
+
ctx.status = null;
|
|
475
|
+
return ok(`GitHub's sign-in is waiting for the person: open https://github.com/login/device and enter the code ${code} (job ${job.id}; studio_job says when it is done). The code is GitHub's one-time device code, not a password.`, { kind: 'job', ...jobView(job), deviceCode: code });
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/* ------------------------------------------------------------------ guides (the plugin's skills) */
|
|
479
|
+
|
|
480
|
+
function guideTopics(ctx) {
|
|
481
|
+
if (!ctx.skillsDir) return [];
|
|
482
|
+
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 []; }
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
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.';
|
|
486
|
+
|
|
487
|
+
/* ------------------------------------------------------------------ the tools */
|
|
488
|
+
|
|
489
|
+
const str = (description, extra = {}) => ({ type: 'string', description, ...extra });
|
|
490
|
+
const STUDIO_ARG = { studio: str('Optional: which studio (its folder name); else the one in use') };
|
|
491
|
+
const ui = (uri) => ({ ui: { resourceUri: uri }, 'ui/resourceUri': uri });
|
|
492
|
+
const RO = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
|
|
493
|
+
const RW = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
|
|
494
|
+
|
|
495
|
+
export function toolDefs(ctx, avail = {}) {
|
|
496
|
+
const tools = [
|
|
497
|
+
{
|
|
498
|
+
name: 'setup_status', title: 'Setup status',
|
|
499
|
+
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.',
|
|
500
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG, fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' } } },
|
|
501
|
+
annotations: { title: 'Setup status', ...RO }, _meta: ui(UI.setup),
|
|
502
|
+
run: async (a) => setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true }),
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
name: 'studio_scaffold', title: 'Make a studio',
|
|
506
|
+
description: 'Step 1: make a new game studio ON THIS COMPUTER, in the studios folder: one folder with AGENTS.md, games/, music/, videos/, posts/ and a site that later goes online on the studio\'s own Cloudflare (free plan). It has NO game: its home page says "First game coming soon". Then installs its pinned toolkit (npm install, in the background). Never put a game in it the person did not ask for.',
|
|
507
|
+
inputSchema: { type: 'object', properties: { name: str('The studio\'s name, e.g. "Night Owls"'), folder: str('Optional: the folder name inside the studios folder (default: from the name)') }, required: ['name'] },
|
|
508
|
+
annotations: { title: 'Make a studio', ...RW }, _meta: ui(UI.setup),
|
|
509
|
+
run: async (a) => {
|
|
510
|
+
const name = String(a.name ?? '').trim().slice(0, 60);
|
|
511
|
+
if (!name) return fail('name the studio, e.g. "Night Owls"');
|
|
512
|
+
if (!ctx.studiosDir) return fail('no studios folder is set: in Claude, Settings > Extensions > Homie, choose the folder your studios live in');
|
|
513
|
+
mkdirSync(ctx.studiosDir, { recursive: true });
|
|
514
|
+
const folder = a.folder ? String(a.folder).trim() : slugify(name);
|
|
515
|
+
const base = realOr(ctx.studiosDir);
|
|
516
|
+
const asked = isAbsolute(folder) ? resolve(folder) : join(base, folder);
|
|
517
|
+
// Compared on real paths (a temporary or linked folder may have two names).
|
|
518
|
+
const dir = join(realOr(dirname(asked)), basename(asked));
|
|
519
|
+
if (!inside(base, dir) || dir === base) return fail(`a studio goes in its own folder inside ${ctx.studiosDir}`);
|
|
520
|
+
let made;
|
|
521
|
+
try { made = newStudio(dir, { name, install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
|
|
522
|
+
ctx.current = made.dir;
|
|
523
|
+
let install = null;
|
|
524
|
+
if (ctx.install) {
|
|
525
|
+
try { install = installJob(made.dir); } catch (error) { return fail(`${name} is made at ${made.dir}, but ${error.message}`); }
|
|
526
|
+
await waitJob(install, Math.min(ctx.waitMs, 35_000));
|
|
527
|
+
}
|
|
528
|
+
const r = await setupCard(ctx, made.dir, { install, made: { dir: made.dir, wrote: made.wrote } });
|
|
529
|
+
r.content[0].text = `Made ${name} at ${made.dir} (no game yet: its home page says "First game coming soon").\nWrote: ${made.wrote.join(', ')}\n${!install ? '' : install.endedAt ? (install.code === 0 ? 'Its toolkit is installed.' : `npm install failed: ${whyOf(install)}`) : `Its toolkit is installing (job ${install.id}).`}\n\n${r.content[0].text}`;
|
|
530
|
+
return r;
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: 'studio_install', title: 'Install the studio\'s dependencies',
|
|
535
|
+
description: 'npm install in the studio: its pinned toolkit and Wrangler, and anything its package.json gained (after an upgrade, a new pin, or a library a game needs). Runs in the background; studio_job follows it.',
|
|
536
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
537
|
+
annotations: { title: 'Install dependencies', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
538
|
+
run: async (a) => {
|
|
539
|
+
const root = ctx.root(a.studio);
|
|
540
|
+
const running = runningJobs(root).find((j) => j.label.startsWith('npm install'));
|
|
541
|
+
if (running) return stillRunning(running, 'npm install');
|
|
542
|
+
let job;
|
|
543
|
+
try { job = installJob(root); } catch (error) { return fail(error.message); }
|
|
544
|
+
if (!(await waitJob(job, ctx.waitMs))) return stillRunning(job, 'npm install');
|
|
545
|
+
ctx.status = null;
|
|
546
|
+
return job.code === 0 ? ok(`Installed (${jobView(job).seconds} s).`, { kind: 'job', ...jobView(job) }) : fail(`npm install failed: ${whyOf(job)}`, { kind: 'job', ...jobView(job) });
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: 'studio_open', title: 'Open a studio',
|
|
551
|
+
description: 'Work in a studio on this computer: one in the studios folder (by its folder name or name), or, with repo, a studio that lives on GitHub (one the Claude app set up with Deploy to Cloudflare, say), cloned into the studios folder with this computer\'s own GitHub sign-in (the GitHub CLI, or git\'s own credentials). Never ask for a token: when GitHub refuses, github_login signs this computer in. Later tools use the studio until another is opened. Shows the studio card.',
|
|
552
|
+
inputSchema: { type: 'object', properties: { studio: str('The studio\'s folder name or name, in the studios folder'), repo: str('Or: its GitHub repository, owner/name or its github.com address'), folder: str('Optional, with repo: the folder name in the studios folder (default: the repository\'s name)') } },
|
|
553
|
+
annotations: { title: 'Open a studio', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(UI.studio),
|
|
554
|
+
run: async (a) => (a.repo ? openFromGitHub(ctx, a) : studioCard(ctx, ctx.root(a.studio))),
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
name: 'github_login', title: 'Sign in to GitHub',
|
|
558
|
+
description: 'Sign this computer in to GitHub with GitHub\'s own device sign-in (the GitHub CLI): it gives a one-time code to type at github.com/login/device, in the person\'s browser. For opening a studio from GitHub and publishing changes by pull request. Never ask for, or accept, a token in the chat.',
|
|
559
|
+
inputSchema: { type: 'object', properties: {} },
|
|
560
|
+
annotations: { title: 'Sign in to GitHub', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
561
|
+
run: async () => githubLogin(ctx),
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
name: 'studio_card', title: 'Show a studio',
|
|
565
|
+
description: 'A card for the studio: its games with Play (here, and live), songs, videos and posts, whether its site runs on this computer and online, its live rooms, and a live game to try while it has none.',
|
|
566
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
567
|
+
annotations: { title: 'Show a studio', ...RO, openWorldHint: true }, _meta: ui(UI.studio),
|
|
568
|
+
run: async (a) => studioCard(ctx, ctx.root(a.studio)),
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: 'game_demo', title: 'See a working game',
|
|
572
|
+
description: 'Step 2: a live multiplayer game on Homie Arcade to play right now, made with this same toolkit, with NOTHING copied into the studio. Give the person the Play link: two browser tabs (or a phone and a computer) are two players in the same room; bots fill the empty seats. Copy a starter into the studio (game_make) only if they ask.',
|
|
573
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
574
|
+
annotations: { title: 'See a working game', ...RO, openWorldHint: true },
|
|
575
|
+
run: async (a) => {
|
|
576
|
+
const root = ctx.root(a.studio, { need: false });
|
|
577
|
+
const r = await demoGames();
|
|
578
|
+
if (root) ctx.note(root).demo = true;
|
|
579
|
+
return ok(formatDemo(r), { kind: 'demo', ...r });
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
name: 'game_make', title: 'Make a multiplayer game',
|
|
584
|
+
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: gem-rush (an arena: every browser renders, one hosts the rules, bots fill empty seats, rounds restart) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays.',
|
|
585
|
+
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
|
|
586
|
+
annotations: { title: 'Make a game', ...RW },
|
|
587
|
+
run: async (a) => {
|
|
588
|
+
const root = ctx.root(a.studio);
|
|
589
|
+
if (!GAME_ID.test(String(a.id ?? ''))) return fail('the id is lowercase letters, digits and hyphens, up to 40');
|
|
590
|
+
const r = await cli(ctx, root, `game new ${a.id}`, ['game', 'new', a.id, '--from', String(a.from ?? 'gem-rush'), '--name', String(a.name ?? a.id).slice(0, 60)]);
|
|
591
|
+
if (!r.ended) return stillRunning(r.job, 'Making the game');
|
|
592
|
+
if (r.job.code !== 0) return fail(`Not made: ${whyOf(r.job)}`);
|
|
593
|
+
return ok(`games/${a.id} is ${a.name}, from the ${a.from ?? 'gem-rush'} starter (${(r.result?.files ?? []).length} files). Change it in games/${a.id}/src/main.ts (file_read, file_edit), then build and preview_run; check proves two browsers finish a round.`, { kind: 'game', ...r.result });
|
|
594
|
+
},
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
name: 'game_remix', title: 'Remix a shared game',
|
|
598
|
+
description: 'Bring a game another studio shared in the Homie directory into this studio as a new game of its own (the original stays credited in game.json).',
|
|
599
|
+
inputSchema: { type: 'object', properties: { game: str('The directory game: "<studio site>/<game id>" or its play URL'), id: str('The new game id in this studio'), name: str('Optional: its new name'), ...STUDIO_ARG }, required: ['game', 'id'] },
|
|
600
|
+
annotations: { title: 'Remix a game', ...RW, openWorldHint: true },
|
|
601
|
+
run: async (a) => {
|
|
602
|
+
const root = ctx.root(a.studio);
|
|
603
|
+
let site; let gameId;
|
|
604
|
+
try { const u = new URL(String(a.game ?? '')); site = u.origin; gameId = u.pathname.split('/').filter(Boolean)[0]; } catch { return fail('game is "<studio site>/<game id>" or its play URL'); }
|
|
605
|
+
if (!/^https:\/\//.test(site) || !GAME_ID.test(String(gameId ?? '')) || !GAME_ID.test(String(a.id ?? ''))) return fail('game is "https://<studio site>/<game id>", and id a new lowercase id');
|
|
606
|
+
const source = `${site}/games/${gameId}/source.json`;
|
|
607
|
+
const r = await cli(ctx, root, `game remix ${a.id}`, ['game', 'remix', source, '--id', a.id, ...(a.name ? ['--name', String(a.name).slice(0, 60)] : [])]);
|
|
608
|
+
if (!r.ended) return stillRunning(r.job, 'The remix');
|
|
609
|
+
if (r.job.code !== 0) return fail(`Not remixed: ${whyOf(r.job)}`);
|
|
610
|
+
return ok(`games/${a.id} is a remix of ${site}/${gameId} (${(r.result?.files ?? []).length} files).`, { kind: 'game', ...r.result });
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
name: 'game_port', title: 'Make an existing game multiplayer',
|
|
615
|
+
description: 'Read an existing single-player web game on this computer and grade how hard making it multiplayer will be (easy, medium, hard, not a fit, with its risks); with id, also bring it into the studio as games/<id>/ for the port. studio_guide { "topic": "port" } has the whole job; check (and studio_run ["port","check",…]) proves it.',
|
|
616
|
+
inputSchema: { type: 'object', properties: { folder: str('The game\'s folder on this computer, as the person named it (e.g. a folder in Downloads)'), id: str('Optional: the id it gets in the studio; with it, the game is brought in'), name: str('Optional: its name'), ...STUDIO_ARG }, required: ['folder'] },
|
|
617
|
+
annotations: { title: 'Port a game', ...RW },
|
|
618
|
+
run: async (a) => {
|
|
619
|
+
const root = ctx.root(a.studio);
|
|
620
|
+
const folder = expandHome(a.folder);
|
|
621
|
+
const home = realOr(homedir());
|
|
622
|
+
if (!folder || !existsSync(folder) || !statSync(folder).isDirectory()) return fail(`no folder ${a.folder}`);
|
|
623
|
+
const real = realOr(folder);
|
|
624
|
+
if (!inside(home, real) || real === home || relative(home, real).split(sep).some((p) => p.startsWith('.'))) return fail('the game to port is a folder of its own inside the home folder (not the home folder, not a hidden folder)');
|
|
625
|
+
const plan = await cli(ctx, root, 'port plan', ['port', 'plan', real]);
|
|
626
|
+
if (!plan.ended) return stillRunning(plan.job, 'Reading the game');
|
|
627
|
+
if (plan.job.code !== 0) return fail(`Could not read it: ${whyOf(plan.job)}`);
|
|
628
|
+
const p = plan.result;
|
|
629
|
+
const lines = [`Port plan for ${basename(real)}: ${String(p.grade ?? '?').toUpperCase()}`, ...(p.reasons ?? []).map((x) => ` - ${x}`), 'Risks:', ...((p.risks ?? []).length ? p.risks.map((x) => ` - ${x}`) : [' - none found by reading'])];
|
|
630
|
+
if (!a.id) return ok(`${lines.join('\n')}\n\nWith an id, game_port brings it into the studio.`, { kind: 'port', plan: p });
|
|
631
|
+
if (!GAME_ID.test(String(a.id))) return fail('the id is lowercase letters, digits and hyphens');
|
|
632
|
+
const imp = await cli(ctx, root, `port import ${a.id}`, ['port', 'import', real, '--id', a.id, ...(a.name ? ['--name', String(a.name).slice(0, 60)] : [])]);
|
|
633
|
+
if (!imp.ended) return stillRunning(imp.job, 'Bringing the game in');
|
|
634
|
+
if (imp.job.code !== 0) return fail(`${lines.join('\n')}\n\nNot brought in: ${whyOf(imp.job)}`);
|
|
635
|
+
return ok(`${lines.join('\n')}\n\ngames/${a.id} is the port (${imp.result?.files ?? '?'} files).\nNext:\n${(imp.result?.next ?? []).map((n) => ` - ${n}`).join('\n')}`, { kind: 'port', plan: p, import: imp.result });
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
name: 'game_plan', title: 'Plan a game (its Game Codex)',
|
|
640
|
+
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 file_edit, then game_codex shows it.',
|
|
641
|
+
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'] },
|
|
642
|
+
annotations: { title: 'Plan a game', ...RW },
|
|
643
|
+
run: async (a) => {
|
|
644
|
+
const root = ctx.root(a.studio);
|
|
645
|
+
const file = join(root, 'games', String(a.id), 'CODEX.md');
|
|
646
|
+
let made = null;
|
|
647
|
+
if (!existsSync(file)) {
|
|
648
|
+
made = newCodex(root, String(a.id), { name: a.name ?? null });
|
|
649
|
+
if (!made.ok) return fail(made.why);
|
|
650
|
+
}
|
|
651
|
+
const guide = ctx.skillsDir && existsSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md')) ? readFileSync(join(ctx.skillsDir, 'plan', 'references', 'INTERVIEW.md'), 'utf8') : null;
|
|
652
|
+
return ok([
|
|
653
|
+
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.`,
|
|
654
|
+
'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 each section) and show it with game_codex.',
|
|
655
|
+
...(guide ? ['', guide] : []),
|
|
656
|
+
].join('\n'), { kind: 'plan', id: a.id, file: `games/${a.id}/CODEX.md`, created: Boolean(made) });
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
{
|
|
660
|
+
name: 'game_codex', title: 'Show a Game Codex',
|
|
661
|
+
description: 'A game\'s Game Codex as a card in the game\'s own look: a tab per section, character cards, the controls table, milestones, decisions, open questions, and the build\'s status. Show it after every change to the plan.',
|
|
662
|
+
inputSchema: { type: 'object', properties: { id: str('The game\'s id'), ...STUDIO_ARG }, required: ['id'] },
|
|
663
|
+
annotations: { title: 'Show a Game Codex', ...RO }, _meta: ui(UI.codex),
|
|
664
|
+
run: async (a) => {
|
|
665
|
+
const root = ctx.root(a.studio);
|
|
666
|
+
let r;
|
|
667
|
+
try { r = renderCodex(root, String(a.id), { mode: 'artifact' }); } catch (error) { return fail(error.message); }
|
|
668
|
+
const html = slimCodex(r.html);
|
|
669
|
+
return ok([
|
|
670
|
+
`The Game Codex for ${r.title}: ${r.sections.map((s) => s.title).join(' · ')}`,
|
|
671
|
+
r.missing.length ? `Not decided yet: ${r.missing.join(', ')}` : 'Every section has something in it.',
|
|
672
|
+
`Open questions: ${r.openQuestions}`,
|
|
673
|
+
...(html ? [] : ['(The page is too big to show here with its pictures: it is drawn without them.)']),
|
|
674
|
+
].join('\n'), { kind: 'codex', id: a.id, title: r.title, html: html ?? slimCodex(r.html, { pictures: false }), sections: r.sections, missing: r.missing, openQuestions: r.openQuestions, build: r.build });
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
name: 'build', title: 'Build the site',
|
|
679
|
+
description: 'Bundle every game (or one) and the studio\'s site into site/dist. A few seconds. A running preview shows the new build on reload.',
|
|
680
|
+
inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), ...STUDIO_ARG } },
|
|
681
|
+
annotations: { title: 'Build', ...RW },
|
|
682
|
+
run: async (a) => {
|
|
683
|
+
const root = ctx.root(a.studio);
|
|
684
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
685
|
+
const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : [])]);
|
|
686
|
+
if (!r.ended) return stillRunning(r.job, 'The build');
|
|
687
|
+
if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
|
|
688
|
+
const g = r.result?.games ?? [];
|
|
689
|
+
return ok(`Built ${g.length ? g.map((x) => `${x.id} (${Math.round(x.bytes / 1024)} KB)`).join(', ') : 'the home page (no game yet)'}.${devOf(root) ? ' Reload the preview to see it.' : ''}`, { kind: 'built', ...r.result });
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
name: 'preview_run', title: 'Run the site here',
|
|
694
|
+
description: 'Run the studio\'s whole site on this computer (pages, public rooms, the database) and give the addresses to open: the home page, and each game\'s Play page. Two browser tabs on a game are two players in the same room. It keeps running until preview_stop (or the app quits).',
|
|
695
|
+
inputSchema: { type: 'object', properties: { game: str('Optional: the game to give the Play address of'), port: { type: 'number', description: 'Optional port (default: 8787, or the next free one)' }, ...STUDIO_ARG } },
|
|
696
|
+
annotations: { title: 'Run the site here', ...RW },
|
|
697
|
+
run: async (a) => {
|
|
698
|
+
const root = ctx.root(a.studio);
|
|
699
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
700
|
+
let url = devOf(root)?.url ?? null;
|
|
701
|
+
let slow = false;
|
|
702
|
+
if (!url) { const r = await startDev(ctx, root, a.port); if (r.error) return fail(r.error); url = r.url; slow = Boolean(r.slow); }
|
|
703
|
+
const games = listGames(root);
|
|
704
|
+
const g = a.game ? games.find((x) => x.id === a.game) : games[0];
|
|
705
|
+
return ok([
|
|
706
|
+
`${slow ? 'Starting' : 'Running'} here: ${url}/${games.length ? '' : ' (the home page: "First game coming soon")'}`,
|
|
707
|
+
...games.map((x) => ` ${x.name ?? x.id}: ${url}/${x.id}/play`),
|
|
708
|
+
g ? `Open ${url}/${g.id}/play in two browser tabs: two players, one room.` : '',
|
|
709
|
+
].filter(Boolean).join('\n'), { kind: 'preview', url, games: games.map((x) => ({ id: x.id, play: `${url}/${x.id}/play` })) });
|
|
710
|
+
},
|
|
711
|
+
},
|
|
712
|
+
{
|
|
713
|
+
name: 'preview_stop', title: 'Stop the site here',
|
|
714
|
+
description: 'Stop this studio\'s site on this computer (exactly its own, nothing else).',
|
|
715
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
716
|
+
annotations: { title: 'Stop the site here', ...RW },
|
|
717
|
+
run: async (a) => {
|
|
718
|
+
const root = ctx.root(a.studio);
|
|
719
|
+
const r = await cli(ctx, root, 'dev --stop', ['dev', '--stop'], { wait: 20_000 });
|
|
720
|
+
return ok(r.result?.stopped?.length ? 'Stopped the studio\'s site here.' : 'It was not running.', { kind: 'preview', stopped: r.result?.stopped ?? [] });
|
|
721
|
+
},
|
|
722
|
+
},
|
|
723
|
+
{
|
|
724
|
+
name: 'check', title: 'Two-browser check',
|
|
725
|
+
description: 'Prove a game works: two fresh browsers (a computer and a phone) press Play, land in the same public room and finish a round (about 70 s). Starts the site here first when it is not running. Runs in the background; the build card follows each step going green. Run it before saying a game works.',
|
|
726
|
+
inputSchema: { type: 'object', properties: { game: str('The game\'s id (default: the only game)'), url: str('Optional: the site to check (default: this computer\'s preview); the live site after a deploy'), ...STUDIO_ARG } },
|
|
727
|
+
annotations: { title: 'Two-browser check', ...RW }, _meta: ui(UI.build),
|
|
728
|
+
run: async (a) => {
|
|
729
|
+
const root = ctx.root(a.studio);
|
|
730
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
731
|
+
const games = listGames(root);
|
|
732
|
+
const game = a.game ?? (games.length === 1 ? games[0].id : null);
|
|
733
|
+
if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to check yet');
|
|
734
|
+
const url = a.url ? String(a.url) : await devUrl(ctx, root);
|
|
735
|
+
return startRun(ctx, root, { kind: 'check', game, title: `${games.find((g) => g.id === game)?.name ?? game}: two-browser check`, steps: [
|
|
736
|
+
{ label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
|
|
737
|
+
] });
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
name: 'playtest', title: 'Playtest',
|
|
742
|
+
description: 'Playtest a game: the two-browser check, then pictures of its landing and Play pages on a computer and a phone with what looks wrong. Runs in the background with the build card. When it ends, look at two of the pictures (file_read) and tell the person what is weak, ranked; studio_guide { "topic": "playtest" } has the whole method.',
|
|
743
|
+
inputSchema: { type: 'object', properties: { game: str('The game\'s id (default: the only game)'), url: str('Optional: the site (default: this computer\'s preview)'), ...STUDIO_ARG } },
|
|
744
|
+
annotations: { title: 'Playtest', ...RW }, _meta: ui(UI.build),
|
|
745
|
+
run: async (a) => {
|
|
746
|
+
const root = ctx.root(a.studio);
|
|
747
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
748
|
+
const games = listGames(root);
|
|
749
|
+
const game = a.game ?? (games.length === 1 ? games[0].id : null);
|
|
750
|
+
if (!game) return fail(games.length ? `which game? ${games.map((g) => g.id).join(', ')}` : 'there is no game to playtest yet');
|
|
751
|
+
const url = a.url ? String(a.url) : await devUrl(ctx, root);
|
|
752
|
+
const shots = join('.checks', `${game}-look`);
|
|
753
|
+
return startRun(ctx, root, { kind: 'playtest', game, title: `${games.find((g) => g.id === game)?.name ?? game}: playtest`, steps: [
|
|
754
|
+
{ label: 'two-browser check', args: ['check', game, '--url', url, '--shots', join('.checks', game)] },
|
|
755
|
+
{ label: 'pictures on a computer and a phone', soft: true, args: ['look', `/${game}/`, `/${game}/play`, '--url', url, '--only', 'computer,phone', '--shots', shots],
|
|
756
|
+
// The first screen of each page first (a whole page is a tall picture): what a person sees on arrival.
|
|
757
|
+
after: (j, run) => { run.look = (j.result?.rows ?? []).flatMap((r) => [r.fold, r.shot]).filter(Boolean).map((f) => relative(root, resolve(root, f))).slice(0, 8); } },
|
|
758
|
+
] });
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
name: 'studio_deploy', title: 'Put the studio online',
|
|
763
|
+
description: 'Put the studio\'s site online on the studio\'s OWN Cloudflare account: one Worker, one D1 database and two Durable Objects, free plan, no payment method. Call it with plan: true first and tell the person in two or three lines what it creates and costs. If Cloudflare is not signed in, cloudflare_login opens it in their browser to approve once. Runs in the background with the build card.',
|
|
764
|
+
inputSchema: { type: 'object', properties: { plan: { type: 'boolean', description: 'Only say what it will create and what it costs; change nothing' }, ...STUDIO_ARG } },
|
|
765
|
+
annotations: { title: 'Put the studio online', readOnlyHint: false, destructiveHint: false, openWorldHint: true }, _meta: ui(UI.build),
|
|
766
|
+
run: async (a) => {
|
|
767
|
+
const root = ctx.root(a.studio);
|
|
768
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
769
|
+
if (a.plan === true) {
|
|
770
|
+
const r = await cli(ctx, root, 'deploy --plan', ['deploy', '--plan']);
|
|
771
|
+
if (!r.ended) return stillRunning(r.job, 'The deploy plan');
|
|
772
|
+
if (r.job.code !== 0) return fail(whyOf(r.job));
|
|
773
|
+
const p = r.result;
|
|
774
|
+
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 ?? ''}`].join('\n'), { kind: 'deploy-plan', ...p });
|
|
775
|
+
}
|
|
776
|
+
const studio = readStudio(root);
|
|
777
|
+
return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy'] }] });
|
|
778
|
+
},
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
name: 'cloudflare_login', title: 'Sign in to Cloudflare',
|
|
782
|
+
description: 'Open Cloudflare in the person\'s browser to approve this computer once (Wrangler\'s own sign-in; a free account, no payment method, no key pasted anywhere). Say in one line that Cloudflare opened and they should approve it.',
|
|
783
|
+
inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
|
|
784
|
+
annotations: { title: 'Sign in to Cloudflare', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
785
|
+
run: async (a) => {
|
|
786
|
+
const root = ctx.root(a.studio);
|
|
787
|
+
const need = needsInstall(ctx, root); if (need) return need;
|
|
788
|
+
const bin = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'wrangler.cmd' : 'wrangler');
|
|
789
|
+
const job = startJob({ root, label: 'Cloudflare sign-in (wrangler login)', cmd: bin, args: ['login'], keep: false });
|
|
790
|
+
const ended = await waitJob(job, Math.min(ctx.waitMs, 40_000));
|
|
791
|
+
ctx.status = null;
|
|
792
|
+
if (!ended) return ok(`Cloudflare opened in the browser: approve it there (job ${job.id}). setup_status says when it is done.`, { kind: 'job', ...jobView(job) });
|
|
793
|
+
return job.code === 0 ? ok('Signed in to Cloudflare.', { kind: 'job', ...jobView(job) }) : fail(`The sign-in did not finish: ${whyOf(job)}`);
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
name: 'studio_publish', title: 'List the studio in the Homie directory',
|
|
798
|
+
description: 'List a deployed studio\'s games, songs and videos in the homie.rocks directory, with their Play links (at most 12 games per studio in the beta).',
|
|
799
|
+
inputSchema: { type: 'object', properties: { site: str('Optional: the live site address (default: the one deploy got)'), ...STUDIO_ARG } },
|
|
800
|
+
annotations: { title: 'List in the directory', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
801
|
+
run: async (a) => {
|
|
802
|
+
const root = ctx.root(a.studio);
|
|
803
|
+
const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
|
|
804
|
+
if (!r.ended) return stillRunning(r.job, 'Listing');
|
|
805
|
+
if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`);
|
|
806
|
+
return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}`, { kind: 'publish', ...r.result });
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
name: 'build_open', title: 'Open a build',
|
|
811
|
+
description: 'Step 5: open a progress feed for one build (a game milestone from its codex, a song, a video) BEFORE the work, so its live card is on screen first: the stages (a game: plan → build → checks → deploy), each check going green, a picture, spend against a budget, and Stop. build, check and deploy report into it. Mark the plan done with studio_run ["progress","stage","plan","done","--note","<the plan in one line>"]. share: true also shows it on the Claude app on a phone (build_progress there).',
|
|
812
|
+
inputSchema: { type: 'object', properties: { what: { type: 'string', enum: ['game', 'song', 'video'], description: 'What is being made (default game)' }, id: str('The game, song or video id'), title: str('One line: what this build does'), budget: { type: 'number', description: 'Optional: dollars (or credits with unit) the person agreed to' }, unit: { type: 'string', enum: ['usd', 'credits'] }, share: { type: 'boolean', description: 'Also send it to homie.rocks for the Claude app on a phone' }, ...STUDIO_ARG } },
|
|
813
|
+
annotations: { title: 'Open a build', ...RW }, _meta: ui(UI.build),
|
|
814
|
+
run: async (a) => {
|
|
815
|
+
const root = ctx.root(a.studio);
|
|
816
|
+
const r = await startProgress(root, { what: a.what ?? 'game', id: a.id ?? null, title: a.title, budget: a.budget, unit: a.unit, share: a.share === true });
|
|
817
|
+
if (!r.ok) return fail(r.why);
|
|
818
|
+
const card = buildCard(ctx, root, feedFor(root, r.build));
|
|
819
|
+
card.content[0].text = `Build ${r.build} is open: ${r.title}.${r.shared ? ` Shared as ${r.shared.build} (build_progress on the Claude app shows it).` : ''}\n${card.content[0].text}`;
|
|
820
|
+
return card;
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
{
|
|
824
|
+
name: 'build_progress', title: 'Show a build\'s progress',
|
|
825
|
+
description: 'A live card for one build: its stages, each check going green, a picture and the address to play it, money spent against its budget, and Stop. Without build, the open one. The card follows it by itself until it ends.',
|
|
826
|
+
inputSchema: { type: 'object', properties: { build: str('The build id (or the shared hb_… id); default: the open build'), ...STUDIO_ARG } },
|
|
827
|
+
annotations: { title: 'Show a build', ...RO }, _meta: ui(UI.build),
|
|
828
|
+
run: async (a) => {
|
|
829
|
+
const root = ctx.root(a.studio);
|
|
830
|
+
const found = feedFor(root, a.build);
|
|
831
|
+
if (!found?.doc) {
|
|
832
|
+
// The newest build, when none is open: what the person last watched.
|
|
833
|
+
let newest = null;
|
|
834
|
+
try { newest = readdirSync(join(root, '.studio', 'progress')).filter((f) => f.endsWith('.json')).sort().pop()?.slice(0, -5) ?? null; } catch { newest = null; }
|
|
835
|
+
if (!a.build && newest) return buildCard(ctx, root, { id: newest, doc: readFeed(root, newest) });
|
|
836
|
+
return fail(a.build ? `no build ${a.build} in this studio` : 'no build yet: build_open opens one (check, playtest and studio_deploy open their own)');
|
|
837
|
+
}
|
|
838
|
+
return buildCard(ctx, root, found);
|
|
839
|
+
},
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
name: 'build_stop', title: 'Stop a build',
|
|
843
|
+
description: 'Ask a running build to stop at its next safe point (a check closes its browsers). The card\'s Stop button calls this; call it yourself only when the person says stop. Nothing is undone.',
|
|
844
|
+
inputSchema: { type: 'object', properties: { build: str('The build id (default: the open build)'), ...STUDIO_ARG } },
|
|
845
|
+
annotations: { title: 'Stop a build', readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _meta: ui(UI.build),
|
|
846
|
+
run: async (a) => {
|
|
847
|
+
const root = ctx.root(a.studio);
|
|
848
|
+
const found = feedFor(root, a.build);
|
|
849
|
+
if (!found?.doc) return fail('no such build');
|
|
850
|
+
if (found.doc.state === 'running') new Feed(root, found.id).stop('person');
|
|
851
|
+
return buildCard(ctx, root, feedFor(root, found.id));
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
{
|
|
855
|
+
name: 'studio_run', title: 'Run a studio command',
|
|
856
|
+
description: 'Any other homie-studio command in the studio, by its words (the guides name them): e.g. ["progress","stage","plan","done","--note","…"], ["progress","spend","0.40","--what","a cover"], ["look"], ["stats"], ["codex","link","<id>"], ["port","check","<id>","--url","…"], ["upgrade"], ["storage","add"]. Runs the studio\'s own pinned toolkit; long commands hand back a job.',
|
|
857
|
+
inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The command\'s words after `homie-studio`' }, ...STUDIO_ARG }, required: ['args'] },
|
|
858
|
+
annotations: { title: 'Run a studio command', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
859
|
+
run: async (a) => {
|
|
860
|
+
const root = ctx.root(a.studio);
|
|
861
|
+
const args = Array.isArray(a.args) ? a.args.map((x) => String(x)).filter((x) => x !== '--json') : [];
|
|
862
|
+
if (!args.length) return fail('args: the command\'s words, e.g. ["look"]');
|
|
863
|
+
const refused = { mcp: 'this is it', new: 'studio_scaffold makes a studio', dev: 'preview_run runs the site here, preview_stop stops it', statusline: 'only for Claude Code' }[args[0]];
|
|
864
|
+
if (refused) return fail(`not here: ${refused}`);
|
|
865
|
+
if (!['help', 'version', 'setup', 'doctor', 'demo', 'starters', 'progress', 'codex', 'games', 'status'].includes(args[0])) { const need = needsInstall(ctx, root); if (need) return need; }
|
|
866
|
+
if (args[0] === 'game' && args[1] === 'new') ctx.note(root).demo = true;
|
|
867
|
+
const r = await cli(ctx, root, `homie-studio ${args.slice(0, 3).join(' ')}`, args);
|
|
868
|
+
if (!r.ended) return stillRunning(r.job, `homie-studio ${args.slice(0, 2).join(' ')}`);
|
|
869
|
+
const text = r.result ? JSON.stringify(r.result, null, 1).slice(0, 12_000) : jobView(r.job, { lines: 40 }).tail.join('\n');
|
|
870
|
+
return r.job.code === 0 ? ok(text, { kind: 'run', args, result: r.result }) : fail(`homie-studio ${args.join(' ')}: ${whyOf(r.job)}`, { kind: 'run', args, result: r.result });
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
name: 'studio_job', title: 'A job, so far',
|
|
875
|
+
description: 'Where a job a tool handed back is (an npm install, a check, a deploy, a render): running or done, its last lines and its result. Waits up to 30 s for it to finish.',
|
|
876
|
+
inputSchema: { type: 'object', properties: { job: str('The job id (j_…)') }, required: ['job'] },
|
|
877
|
+
annotations: { title: 'A job', ...RO },
|
|
878
|
+
run: async (a) => {
|
|
879
|
+
const job = getJob(a.job);
|
|
880
|
+
if (!job) return fail(`no job ${a.job} (jobs end with the app; build_progress shows a build)`);
|
|
881
|
+
await waitJob(job, Math.min(ctx.waitMs, 30_000));
|
|
882
|
+
const v = jobView(job, { lines: 20 });
|
|
883
|
+
if (job.endedAt) ctx.status = null;
|
|
884
|
+
return ok(`${v.label}: ${v.state}${v.state === 'running' ? ` (${v.seconds} s so far)` : ` after ${v.seconds} s`}${v.state === 'failed' ? `: ${whyOf(job)}` : ''}${v.result ? `\n${JSON.stringify(v.result, null, 1).slice(0, 8000)}` : v.tail.length ? `\n${v.tail.join('\n')}` : ''}`, { kind: 'job', ...v });
|
|
885
|
+
},
|
|
886
|
+
},
|
|
887
|
+
{
|
|
888
|
+
name: 'file_list', title: 'List studio files',
|
|
889
|
+
description: 'The files and folders in the studio (or one folder of it), two levels deep. Paths are relative to the studio.',
|
|
890
|
+
inputSchema: { type: 'object', properties: { path: str('A folder in the studio (default: its root)'), depth: { type: 'number', description: '1 to 4 (default 2)' }, ...STUDIO_ARG } },
|
|
891
|
+
annotations: { title: 'List files', ...RO },
|
|
892
|
+
run: async (a) => {
|
|
893
|
+
const r = listFiles(ctx.root(a.studio), a.path ?? '.', { depth: Math.min(4, Math.max(1, Number(a.depth) || 2)) });
|
|
894
|
+
return ok(`${r.path}/\n${r.entries.map((e) => ` ${e.path}${e.type === 'file' && e.size !== null ? ` (${e.size < 1024 ? `${e.size} B` : `${Math.round(e.size / 1024)} KB`})` : ''}`).join('\n')}${r.truncated ? '\n …' : ''}`, { kind: 'files', ...r });
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
{
|
|
898
|
+
name: 'file_read', title: 'Read a studio file',
|
|
899
|
+
description: 'A file in the studio, with line numbers (offset and limit for long ones), or a picture (png, jpg, webp, gif) to look at. node_modules/@homie-rocks/studio has the toolkit\'s own guides (netplay/NETPLAY.md, site/SITE.md, media/MEDIA.md).',
|
|
900
|
+
inputSchema: { type: 'object', properties: { path: str('The file, relative to the studio'), offset: { type: 'number' }, limit: { type: 'number' }, ...STUDIO_ARG }, required: ['path'] },
|
|
901
|
+
annotations: { title: 'Read a file', ...RO },
|
|
902
|
+
run: async (a) => {
|
|
903
|
+
const r = readStudioFile(ctx.root(a.studio), a.path, { offset: a.offset, limit: a.limit });
|
|
904
|
+
if (r.image) return { content: [{ type: 'image', data: r.image.data, mimeType: r.image.mimeType }, { type: 'text', text: `${r.rel} (${Math.round(r.bytes / 1024)} KB)` }] };
|
|
905
|
+
return ok(`${r.rel} (lines ${r.from}-${r.to} of ${r.lines}${r.more ? '; more with offset' : ''})\n${r.text}`);
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
name: 'file_write', title: 'Write a studio file',
|
|
910
|
+
description: 'Write a whole file in the studio (a new one, or replacing one you read first). For a change to part of a file, file_edit.',
|
|
911
|
+
inputSchema: { type: 'object', properties: { path: str('The file, relative to the studio'), content: str('Its whole text'), ...STUDIO_ARG }, required: ['path', 'content'] },
|
|
912
|
+
annotations: { title: 'Write a file', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
913
|
+
run: async (a) => {
|
|
914
|
+
const root = ctx.root(a.studio);
|
|
915
|
+
const r = writeStudioFile(root, a.path, a.content);
|
|
916
|
+
ctx.note(root).changed = true;
|
|
917
|
+
return ok(`${r.created ? 'Wrote' : 'Replaced'} ${r.rel} (${r.bytes} bytes).`);
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
name: 'file_edit', title: 'Edit a studio file',
|
|
922
|
+
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.',
|
|
923
|
+
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' }, ...STUDIO_ARG }, required: ['path', 'old', 'new'] },
|
|
924
|
+
annotations: { title: 'Edit a file', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
|
|
925
|
+
run: async (a) => {
|
|
926
|
+
const root = ctx.root(a.studio);
|
|
927
|
+
const r = editStudioFile(root, a.path, a.old, a.new, { all: a.all === true });
|
|
928
|
+
ctx.note(root).changed = true;
|
|
929
|
+
return ok(`Changed ${r.rel} (${r.replaced} place${r.replaced === 1 ? '' : 's'}).`);
|
|
930
|
+
},
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
name: 'file_search', title: 'Search studio files',
|
|
934
|
+
description: 'Lines matching a regular expression in the studio\'s own text files (never node_modules, git, builds).',
|
|
935
|
+
inputSchema: { type: 'object', properties: { pattern: str('A JavaScript regular expression'), path: str('Optional folder'), ignoreCase: { type: 'boolean' }, ...STUDIO_ARG }, required: ['pattern'] },
|
|
936
|
+
annotations: { title: 'Search files', ...RO },
|
|
937
|
+
run: async (a) => {
|
|
938
|
+
const r = searchStudio(ctx.root(a.studio), a.pattern, { path: a.path ?? '.', ignoreCase: a.ignoreCase === true });
|
|
939
|
+
return ok(r.matches.length ? r.matches.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n') + (r.truncated ? '\n…' : '') : 'No match.');
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
];
|
|
943
|
+
const topics = guideTopics(ctx);
|
|
944
|
+
if (topics.length) {
|
|
945
|
+
tools.push({
|
|
946
|
+
name: 'studio_guide', title: 'Homie\'s guides',
|
|
947
|
+
description: `How Homie does each job, in full: ${topics.join(', ')}. Read the one for the job before doing it (studio-setup is the new-studio checklist; game, plan, parallel, port, playtest and publish the rest of a game's life; music, sound, art and video the media).`,
|
|
948
|
+
inputSchema: { type: 'object', properties: { topic: str('One of the guides', { enum: topics }), file: str('Optional: one of its references, e.g. "INTERVIEW.md"') }, required: ['topic'] },
|
|
949
|
+
annotations: { title: 'Homie\'s guides', ...RO },
|
|
950
|
+
run: async (a) => {
|
|
951
|
+
if (!topics.includes(a.topic)) return fail(`guides: ${topics.join(', ')}`);
|
|
952
|
+
const dir = join(ctx.skillsDir, a.topic);
|
|
953
|
+
if (a.file) {
|
|
954
|
+
const f = join(dir, 'references', basename(String(a.file)));
|
|
955
|
+
if (!existsSync(f)) return fail(`no reference ${a.file} in ${a.topic}`);
|
|
956
|
+
return ok(readFileSync(f, 'utf8'));
|
|
957
|
+
}
|
|
958
|
+
const refs = (() => { try { return readdirSync(join(dir, 'references')); } catch { return []; } })();
|
|
959
|
+
return ok(`${DESKTOP_NOTE}\n\n${readFileSync(join(dir, 'SKILL.md'), 'utf8')}${refs.length ? `\n\nReferences (file): ${refs.join(', ')}` : ''}`);
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
// The media skills' own scripts, where their provider (or the free tool they need) is set up on this computer.
|
|
964
|
+
for (const [name, title, needs] of [['music', 'Music (ElevenLabs)', 'elevenlabs'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg']]) {
|
|
965
|
+
const script = ctx.skillsDir ? join(ctx.skillsDir, name, 'scripts', `${name}.mjs`) : null;
|
|
966
|
+
if (!script || !existsSync(script) || !avail[needs]) continue;
|
|
967
|
+
tools.push({
|
|
968
|
+
name, title,
|
|
969
|
+
description: `The ${name} guide's own script, in the studio, by its words (studio_guide { "topic": "${name}" } says which, in what order, and what costs money): e.g. ["check"]${name === 'music' ? ', ["quote","--seconds","30"]' : name === 'sound' ? ', ["sfx","pickup"]' : ''}. Paid steps are quoted first and capped by the budget the person agreed to. Long renders hand back a job.`,
|
|
970
|
+
inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The script\'s words' }, ...STUDIO_ARG }, required: ['args'] },
|
|
971
|
+
annotations: { title, readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
972
|
+
run: async (a) => {
|
|
973
|
+
const root = ctx.root(a.studio);
|
|
974
|
+
const node = findNode();
|
|
975
|
+
if (!node) return fail('no Node.js 22 or newer on this computer');
|
|
976
|
+
const args = (Array.isArray(a.args) ? a.args.map(String) : []).filter((x) => x !== '--json');
|
|
977
|
+
const job = startJob({ root, label: `${name} ${args.slice(0, 2).join(' ')}`, cmd: node.bin, args: [script, ...args, '--json'], json: true });
|
|
978
|
+
if (!(await waitJob(job, ctx.waitMs))) return stillRunning(job, `${name} ${args[0] ?? ''}`);
|
|
979
|
+
const text = job.result ? JSON.stringify(job.result, null, 1).slice(0, 12_000) : jobView(job, { lines: 30 }).tail.join('\n');
|
|
980
|
+
return job.code === 0 ? ok(text, { kind: name, result: job.result }) : fail(`${name} ${args.join(' ')}: ${whyOf(job)}`);
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
return tools;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** What the media tools need, cheaply (no network): ffmpeg on the PATH, ElevenLabs' CLI or key. */
|
|
988
|
+
export function availability(env = process.env) {
|
|
989
|
+
return { ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(env.ELEVENLABS_API_KEY || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
|
|
993
|
+
export function slimCodex(html, { max = 140_000, pictures = true } = {}) {
|
|
994
|
+
let h = String(html ?? '');
|
|
995
|
+
if (h.length <= max && pictures) return h;
|
|
996
|
+
h = h.replace(/(<img\b[^>]*?\ssrc=")data:[^"]*(")/g, '$1$2').replace(/url\((['"]?)data:[^)]*\1\)/g, 'none');
|
|
997
|
+
return h.length <= max ? h : null;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export const _test = { checklist, feedFor, slimCodex, expandHome };
|