@homie-rocks/studio 0.51.3 → 0.51.5

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +1 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/docs/mcp-events.md +68 -0
  6. package/guides/app/SKILL.md +25 -1
  7. package/guides/art/SKILL.md +68 -140
  8. package/guides/art/scripts/art.mjs +6 -79
  9. package/guides/models/SKILL.md +73 -177
  10. package/guides/models/references/RIGHTS.md +1 -1
  11. package/guides/models/scripts/models.mjs +7 -288
  12. package/guides/music/SKILL.md +74 -183
  13. package/guides/music/references/COMPOSITION.md +21 -26
  14. package/guides/music/references/GAME-AUDIO.md +3 -3
  15. package/guides/music/references/RIGHTS.md +12 -15
  16. package/guides/music/scripts/music.mjs +11 -202
  17. package/guides/office/SKILL.md +24 -0
  18. package/guides/parts/SKILL.md +1 -1
  19. package/guides/servers/SKILL.md +24 -0
  20. package/guides/shop/SKILL.md +68 -77
  21. package/guides/sound/SKILL.md +70 -1
  22. package/guides/sound/scripts/player/sound.js +233 -0
  23. package/guides/studio-setup/SKILL.md +11 -27
  24. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  25. package/guides/style/SKILL.md +66 -3
  26. package/guides/video/SKILL.md +83 -0
  27. package/guides/video/references/DELIVERY.md +73 -0
  28. package/guides/video/references/HONESTY.md +40 -0
  29. package/guides/video/references/METHOD.md +144 -0
  30. package/guides/video/references/RECORD.md +137 -0
  31. package/guides/video/references/TRAILER.md +154 -0
  32. package/guides/video/references/examples/site-walkthrough.json +14 -0
  33. package/guides/video/references/examples/studio-play-phone.json +15 -0
  34. package/guides/video/references/examples/studio-play.json +17 -0
  35. package/guides/video/scripts/capture-game.mjs +175 -0
  36. package/guides/video/scripts/clock.js +136 -0
  37. package/guides/video/scripts/drawover/film.js +58 -0
  38. package/guides/video/scripts/drawover/index.html +9 -0
  39. package/guides/video/scripts/drawover/look.js +115 -0
  40. package/guides/video/scripts/drawover/shots.json +12 -0
  41. package/guides/video/scripts/lib/browser.mjs +87 -0
  42. package/guides/video/scripts/lib/frames.mjs +170 -0
  43. package/guides/video/scripts/lib/remix.mjs +159 -0
  44. package/guides/video/scripts/qa.mjs +127 -0
  45. package/guides/video/scripts/record-fixed.mjs +403 -0
  46. package/guides/video/scripts/record-page.mjs +477 -0
  47. package/guides/video/scripts/tap.js +108 -0
  48. package/guides/video/scripts/video.mjs +888 -0
  49. package/lib/build.mjs +12 -9
  50. package/lib/cloudflare.mjs +6 -11
  51. package/lib/dev.mjs +1 -2
  52. package/lib/doctor.mjs +10 -54
  53. package/lib/functions-build.mjs +8 -1
  54. package/lib/mcp-tools.mjs +42 -21
  55. package/lib/mcp.mjs +1 -1
  56. package/lib/media-import.mjs +41 -0
  57. package/lib/media-workflows.mjs +13 -0
  58. package/lib/official-media.mjs +15 -0
  59. package/lib/rules-build.mjs +13 -5
  60. package/lib/scaffold.mjs +2 -2
  61. package/lib/shop-links.mjs +54 -18
  62. package/lib/shop-listen.mjs +43 -0
  63. package/lib/shop-local.mjs +1 -1
  64. package/lib/shop-mcp.mjs +58 -0
  65. package/lib/stripe-connect.mjs +36 -15
  66. package/lib/template-history.json +87 -0
  67. package/lib/upgrade-media.mjs +13 -0
  68. package/lib/upgrade.mjs +10 -0
  69. package/package.json +1 -1
  70. package/parts/PARTS.md +1 -1
  71. package/rules/view.ts +4 -0
  72. package/shop/SHOP.md +66 -34
  73. package/worker/build-id.mjs +1 -1
  74. package/worker/event-resources.mjs +12 -3
  75. package/worker/event-webhooks.mjs +14 -9
  76. package/worker/functions.mjs +11 -6
  77. package/worker/index.mjs +5 -1
  78. package/worker/mcp-event-streams.mjs +58 -0
  79. package/worker/mcp-events-wire.mjs +41 -0
  80. package/worker/mcp-events.mjs +132 -0
  81. package/worker/mcp.mjs +4 -1
  82. package/worker/purchase-reconciliation.mjs +16 -1
  83. package/worker/shop-subscriptions.mjs +2 -0
  84. package/worker/shop.mjs +7 -5
  85. package/worker/stripe-ownership.mjs +7 -0
  86. package/worker/version.mjs +1 -1
  87. package/guides/models/references/models.json +0 -175
  88. package/guides/music/scripts/lib/eleven.mjs +0 -260
  89. package/lib/projects-env.mjs +0 -68
  90. package/lib/projects.mjs +0 -311
  91. package/lib/provider-key.mjs +0 -21
package/lib/build.mjs CHANGED
@@ -384,7 +384,7 @@ function pointAtBundle(html, bundle) {
384
384
  * game is served (/games/<id>/ on the site, /<id>/__game/ in its frame, the lab). A game with no dynamic import is
385
385
  * one file, as before. `hashed` (the site's build) names the bundle by its content too: see the top of this file.
386
386
  */
387
- export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false } = {}) {
387
+ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sourcemap = null, cache = {}, log = () => {}, hashed = false, lab = false, longCheck = false, quick = false } = {}) {
388
388
  rmSync(out, { recursive: true, force: true });
389
389
  mkdirSync(join(out, 'assets'), { recursive: true });
390
390
  const mode = g.build?.mode ?? 'bundle';
@@ -411,7 +411,7 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
411
411
  }
412
412
  }
413
413
  const { prepareRules, viewPlugin } = ruled ? await rulesBuild() : {};
414
- const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck }) : null;
414
+ const rules = ruled ? await prepareRules(esbuild, root, g, { log, longCheck, quick }) : null;
415
415
  const bundle = async (entryRel) => {
416
416
  const entry = join(g.dir, entryRel);
417
417
  if (!existsSync(entry)) throw new Error(`${label}: entry ${entryRel} not found`);
@@ -509,7 +509,8 @@ export function orderGames(games, order, log = () => {}) {
509
509
  return [...games].sort((a, b) => (at.get(a.id) ?? Infinity) - (at.get(b.id) ?? Infinity) || a.id.localeCompare(b.id));
510
510
  }
511
511
 
512
- export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
512
+ export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false, quick = false } = {}) {
513
+ log(`Building studio${quick?' (quick: rules play checks skipped)':''}…`);
513
514
  const esbuild = await studioEsbuild(root);
514
515
  const studio = readStudio(root);
515
516
  if (Object.hasOwn(studio, 'referrals')) log('studio.json referrals: ignored; Homie has no referral system.');
@@ -535,13 +536,13 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
535
536
  // from the site as it is.
536
537
  const dist = openStage(root, { from: only ? live : null });
537
538
  try {
538
- return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck });
539
+ return await buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck, quick });
539
540
  } finally {
540
541
  rmSync(dist, { recursive: true, force: true });
541
542
  }
542
543
  }
543
544
 
544
- async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck }) {
545
+ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typed, root, only, log, deploy, maps, beforePublish, longCheck, quick }) {
545
546
  const built = [];
546
547
  const retired = [];
547
548
  const cache = {};
@@ -549,8 +550,9 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
549
550
  const browserHosted = [];
550
551
  for (const g of games) {
551
552
  const out = join(dist, 'games', g.id);
553
+ log(`Building ${g.id}…`);
552
554
  const started = Date.now();
553
- const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck });
555
+ const { mode, warnings, bundle, chunks, rules } = await buildGameFiles(esbuild, root, g, out, { maps, cache, log, hashed: true, longCheck, quick });
554
556
  if (rules) ruled.push({ id: g.id, ...rules }); else browserHosted.push(g.id);
555
557
  // The guides' vocabulary (NETPLAY.md section 18): checked here, so a room never meets a line it cannot say.
556
558
  vocabFor(g, out);
@@ -571,14 +573,14 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
571
573
  if (seats.asked > SEAT_MAX && !rules) log(`games/${g.id} asks for ${seats.asked} players; browser-hosted netplay rooms retain their ${SEAT_MAX}-player limit, so this build uses ${SEAT_MAX} seats. For larger rooms, rewrite as rules plus view: node_modules/@homie-rocks/studio/guides/game/REWRITE.md`);
572
574
  built.push({
573
575
  id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
574
- ...(rules ? { capacityTrial: rules.check.capacityTrial } : {}),
576
+ ...(rules ? { capacityTrial: rules.check.capacityTrial, rulesChecksSkipped: rules.check.skipped === true } : {}),
575
577
  ...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
576
578
  });
577
579
  log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
578
580
  if (rules && (rules.settings.offline || rules.settings.host === 'browser')) log(`${g.id}: this ${g.kind === 'app' ? 'app' : 'game'}'s rules and tunables are sent to players' devices so it can be played offline`);
579
- if (rules) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
581
+ if (rules && !quick) log(` ${g.id}: its rules run ${rules.settings.host === 'browser' ? "in a player's browser" : 'on the server'} (checked and guarded, ${Math.max(1, Math.round(rules.code.length / 1024))} KB, build ${rules.build}; ${rules.check.ticks} ticks played, the room rebuilt from its save ${rules.check.restores} times: the busiest tick used ${rules.tickUnits} of ${rules.settings.budget.tick} budget units, ${rules.units} of them in one handler; the largest save was ${rules.check.largestSaveBytes} bytes)`);
580
582
  if (rules && rules.settings.host === 'server' && (rules.snapshotBytes > rules.snapshotCap || rules.checkpointBytes > rules.checkpointCap)) log(` ${g.id}: this state fits server and offline play but exceeds browser hosting limits.`);
581
- if (rules) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
583
+ if (rules && !quick) log(` ${g.id}: smoke-run largest snapshot ${rules.snapshotBytes} B / ${rules.snapshotCap} B browser cap; checkpoint ${rules.checkpointBytes} B / ${rules.checkpointCap} B browser cap (measured, not an upper bound)`);
582
584
  }
583
585
  // A game written before rules (its own code is the host) builds and runs exactly as it did. Said in one line.
584
586
  if (browserHosted.length) log(`hosted by a player's browser, as before (no room object; nothing to do): ${browserHosted.join(', ')}`);
@@ -752,6 +754,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
752
754
  // site, never before: a game that did not build has left both as they were.
753
755
  await beforePublish();
754
756
  const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
757
+ log('Building site Worker…');
755
758
  await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
756
759
  // All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
757
760
  const swapped = swapIn(dist, live);
@@ -48,7 +48,6 @@ import { repoOf } from './repo.mjs';
48
48
  import { keptRoutes, readConfig, readZoneRoutes, routesOf, shadowedDomain, wideRouteRefusal, zoneFinding } from './routes.mjs';
49
49
  import { ensureLocalIgnored, ensureMigrations, migrationWord } from './scaffold.mjs';
50
50
  import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layoutOf, listExperiences as listGames, readLocal, readStudio, siteUrl, workerDir, writeLocal, writeStudio } from './studio.mjs';
51
- import { projectsCloudflareEnv } from './projects-env.mjs';
52
51
 
53
52
  const ANSI = /\u001b\[[0-9;]*m/g;
54
53
 
@@ -62,11 +61,7 @@ export function wranglerBin(root) {
62
61
  return existsSync(local) ? local : null;
63
62
  }
64
63
 
65
- /**
66
- * Wrangler from the studio's own node_modules, run in site/ with the given environment. A studio whose Cloudflare came
67
- * through Stripe Projects (studio.json `cloudflare.auth: "stripe-projects"`, 0.24.3) runs it with the token and account
68
- * Projects synced to its git-ignored output file; every other studio with Wrangler's own sign-in.
69
- */
64
+ /** Wrangler from the studio's own node_modules, using Cloudflare's own sign-in. */
70
65
  export function runner(root, env) {
71
66
  const bin = wranglerBin(root);
72
67
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
@@ -74,7 +69,7 @@ export function runner(root, env) {
74
69
  const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
75
70
  if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
76
71
  const res = spawnSync(bin, args, {
77
- cwd, env: { ...process.env, ...projectsCloudflareEnv(root), ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
72
+ cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
78
73
  input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
79
74
  });
80
75
  const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
@@ -152,7 +147,7 @@ export async function zoneCheck(root, { studio = readStudio(root), config = read
152
147
  try {
153
148
  const w = bin ? runner(root, accountId ? { CLOUDFLARE_ACCOUNT_ID: accountId } : {}) : null;
154
149
  headers = await cloudflareAuth({
155
- env: { ...process.env, ...projectsCloudflareEnv(root) }, bin, cwd: root,
150
+ env: { ...process.env }, bin, cwd: root,
156
151
  exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; },
157
152
  });
158
153
  } catch { headers = null; }
@@ -398,7 +393,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
398
393
  const started = Date.now();
399
394
  let dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
400
395
  if (dep.code !== 0 && explainCloudflare(dep.out, accountId)?.needs === 'workers-dev-subdomain') {
401
- const headers = await cloudflareAuth({ env: { ...process.env, ...projectsCloudflareEnv(root) }, bin: wranglerBin(root), cwd: root,
396
+ const headers = await cloudflareAuth({ env: { ...process.env }, bin: wranglerBin(root), cwd: root,
402
397
  exec: async (_bin, args) => { const r = w(args, { cwd: root }); return { code: r.code, stdout: r.stdout }; } });
403
398
  const address = await registerWorkersAddress({ accountId, slug: studio.slug, headers, ...(fetchFn ? { fetchFn } : {}) });
404
399
  if (!address.ok) return { ok: false, command: 'deploy', needs: 'workers-dev-subdomain', why: address.next };
@@ -408,7 +403,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
408
403
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
409
404
  if (paidReleases(root).length) {
410
405
  const bin = wranglerBin(root);
411
- const uploadOptions = { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
406
+ const uploadOptions = { cwd: workerDir(root), env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
412
407
  await uploadPaidParts(root, { bucket: purchaseBucket, log, put: (key, file) => wranglerRun(bin, ['r2', 'object', 'put', key, '--file', file, '--remote'], uploadOptions), hash: (key) => hashRemote(bin, key, uploadOptions) });
413
408
  }
414
409
  remember(`worker:${cf.worker}`);
@@ -772,7 +767,7 @@ export async function mediaMove(root, { paths = null, dryRun = false, verify = f
772
767
  const bin = wranglerBin(root);
773
768
  if (!bin) return { ok: false, ...base, why: 'Wrangler is not installed in this studio yet: run `npm install` in the studio folder first' };
774
769
  const account = accountId || process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || null;
775
- const opts = { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
770
+ const opts = { cwd: workerDir(root), env: { ...process.env, ...(account ? { CLOUDFLARE_ACCOUNT_ID: account } : {}), WRANGLER_SEND_METRICS: 'false', CI: '1' } };
776
771
  const moved = [];
777
772
  const failed = [];
778
773
  const verified = [];
package/lib/dev.mjs CHANGED
@@ -32,7 +32,6 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpat
32
32
  import { connect } from 'node:net';
33
33
  import { dirname, join, sep } from 'node:path';
34
34
  import { detectLocalAi, localAiVars } from './local-ai.mjs';
35
- import { projectsCloudflareEnv } from './projects-env.mjs';
36
35
  import { routesOf } from './routes.mjs';
37
36
  import { ensureMigrations, migrationWord } from './scaffold.mjs';
38
37
  import { isRulesGame, listExperiences as listGames, workerDir } from './studio.mjs';
@@ -343,7 +342,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
343
342
  if (!b.catalogue.length && !b.songs.length && !b.videos.length) log('No game yet: the home page says "First game coming soon" until the first one is made.');
344
343
  const bin = wranglerBin(root);
345
344
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
346
- const env = { ...process.env, ...projectsCloudflareEnv(root), WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
345
+ const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
347
346
  for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
348
347
  await new Promise((done) => {
349
348
  const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
package/lib/doctor.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { providerKey } from './provider-key.mjs';
1
+ import { officialConnections } from './official-media.mjs';
2
2
  /**
3
3
  * `homie-studio setup status` (also `homie-studio doctor`): what this computer and this person's accounts have for
4
4
  * a studio, as one checklist, before anything is made. For each thing: green, missing, or "do this now", what it
@@ -35,8 +35,7 @@ import { providerKey } from './provider-key.mjs';
35
35
  * status line optional (Claude Code only) the build's progress under the prompt
36
36
  *
37
37
  * It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
38
- * tools, `wrangler whoami`, `gh auth status`, `elevenlabs auth status`, one GET to the directory and, when a fal key
39
- * is set, fal's free pricing API; when a server's AI guides use Workers AI, one read of the live database and one
38
+ * tools, `wrangler whoami`, `gh auth status`, one GET to the directory and explicit AI-host connection evidence; when a server's AI guides use Workers AI, one read of the live database and one
40
39
  * one-word call to the model; in Codex or Grok, the `[mcp_servers.homie]` table of that app's config.toml, of which
41
40
  * it keeps only whether it is an address or a command, and the hooks' mark in this user's cache), never changes
42
41
  * anything, and never prints a key, a token, a command line or an account's name or id.
@@ -53,7 +52,6 @@ import { whyFailed } from './net.mjs';
53
52
  import { isOurs } from './statusline.mjs';
54
53
  import { findStudio, listExperiences as listGames, readLocal, readStudio } from './studio.mjs';
55
54
  import { standaloneRows } from './standalone.mjs';
56
- import { projectsCloudflareEnv, projectsElevenLabs } from './projects-env.mjs';
57
55
  import { STUDIO_VERSION } from './version.mjs';
58
56
 
59
57
  /** A command's result, never throwing: code 127 when the program is not installed, 124 when it ran out of time. */
@@ -175,7 +173,7 @@ const ago = (ms) => (ms < 90_000 ? `${Math.max(1, Math.round(ms / 1000))} s` : m
175
173
  * replaceable for tests.
176
174
  */
177
175
  export async function setupStatus({
178
- cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, homie = null, client = null,
176
+ cwd = process.cwd(), env = process.env, platform = process.platform, connector = null, integrations = {}, homie = null, client = null,
179
177
  exec = defaultExec, fetchFn = globalThis.fetch, chrome = findChrome, node = process.versions.node, nowMs = Date.now(),
180
178
  } = {}) {
181
179
  const root = findStudio(cwd);
@@ -198,18 +196,12 @@ export async function setupStatus({
198
196
 
199
197
  // The Homie connector (the AI says; the network is checked here), Cloudflare, GitHub and the providers, at once.
200
198
  const said = connector === true || connector === 'yes' ? 'yes' : connector === false || connector === 'no' ? 'no' : null;
201
- const [net, who, gh, eleven, fal] = await Promise.all([
199
+ const [net, who, gh] = await Promise.all([
202
200
  reach(`${directory}/mcp`, fetchFn),
203
201
  (async () => {
204
- if (env.CLOUDFLARE_API_TOKEN) return { signedIn: true, how: 'an API token in the environment' };
205
202
  const bin = root ? join(root, 'node_modules', '.bin', win ? 'wrangler.cmd' : 'wrangler') : null;
206
203
  if (!bin || !existsSync(bin)) return { signedIn: null };
207
- // Through Stripe Projects (studio.json "auth": "stripe-projects"): Wrangler with the token Projects synced.
208
- const projects = projectsCloudflareEnv(root);
209
- const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, ...projects, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
210
- if (projects.CLOUDFLARE_API_TOKEN && r.code === 0) {
211
- try { if (JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))).loggedIn) return { signedIn: true, how: 'through Stripe Projects (its token, in the studio\'s git-ignored .env; never shown)', accounts: 1, accountIds: [projects.CLOUDFLARE_ACCOUNT_ID].filter(Boolean) }; } catch { /* read below */ }
212
- }
204
+ const r = await exec(bin, ['whoami', '--json'], { cwd: root, timeout: 25_000, env: { ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' } });
213
205
  if (r.code === 124) return { signedIn: null, why: 'Wrangler did not answer in time' };
214
206
  // The accounts' ids stay in this process (the Workers AI row calls the studio's own account); never printed.
215
207
  try { const d = JSON.parse(r.stdout.slice(Math.max(0, r.stdout.indexOf('{')))); return { signedIn: Boolean(d.loggedIn), accounts: Array.isArray(d.accounts) ? d.accounts.length : null, accountIds: Array.isArray(d.accounts) ? d.accounts.map((a) => a?.id).filter(Boolean) : [] }; } catch { return { signedIn: false }; }
@@ -224,26 +216,6 @@ export async function setupStatus({
224
216
  if (v.code !== 0) return { git: git.code === 0, gh: false, signedIn: false, onGitHub };
225
217
  return { git: git.code === 0, gh: true, signedIn: (await exec('gh', ['auth', 'status'], { timeout: 10_000 })).code === 0, onGitHub };
226
218
  })(),
227
- (async () => {
228
- const cliBin = env.ELEVENLABS_CLI || 'elevenlabs';
229
- const v = await exec(cliBin, ['--version']);
230
- let signedIn = false;
231
- if (v.code === 0) {
232
- const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
233
- try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
234
- }
235
- const credential = providerKey('elevenlabs', env);
236
- return { cli: v.code === 0, signedIn, key: Boolean(credential.key), source: credential.source, projects: root ? projectsElevenLabs(root) : false };
237
- })(),
238
- (async () => {
239
- const { key, source } = providerKey('fal', env);
240
- if (!key) return { key: false };
241
- const base = String(env.FAL_API_URL || 'https://api.fal.ai').replace(/\/+$/, '');
242
- try {
243
- const res = await fetchFn(`${base}/v1/models/pricing?endpoint_id=fal-ai/flux/dev`, { headers: { Authorization: `Key ${key}` }, signal: AbortSignal.timeout(6000) });
244
- return { key: true, source, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
245
- } catch { return { key: true, source, valid: null }; }
246
- })(),
247
219
  ]);
248
220
 
249
221
  // The Homie connector. A session with a shell makes, deploys and lists a studio without it, so it never blocks.
@@ -385,23 +357,7 @@ export async function setupStatus({
385
357
  : { who: 'person', open: 'https://github.com/signup', say: `Make a free GitHub account; your AI installs the GitHub CLI${mac ? ' (brew install gh)' : ''} when you want the backup.` },
386
358
  });
387
359
 
388
- // ElevenLabs.
389
- rows.push({
390
- id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key || eleven.projects ? 'ok' : 'optional',
391
- detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? `key from ${eleven.source}` : eleven.projects ? 'through Stripe Projects (its key, in the studio\'s git-ignored .env; never shown)' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
392
- unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
393
- fix: eleven.signedIn || eleven.key || eleven.projects ? null : eleven.cli
394
- ? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
395
- : { who: mac ? 'ai' : 'person', run: mac ? 'brew install elevenlabs/tap/elevenlabs && elevenlabs auth login' : undefined, open: 'https://elevenlabs.io/sign-up', say: 'Make an ElevenLabs account when you want songs; your AI installs their CLI and you sign in once in the browser.' },
396
- });
397
-
398
- // fal.
399
- rows.push({
400
- id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
401
- detail: !fal.key ? 'no fal key in the environment or Homie key files' : `key from ${fal.source}; ${fal.valid === true ? 'fal accepts it' : fal.valid === false ? 'fal refused it (wrong or revoked)' : 'fal did not answer just now'}`,
402
- unlocks: 'painted art and backdrops (the art skill) and generated video clips (the video skill), on your own fal account under a budget; covers from a real frame and captured trailers are free',
403
- fix: fal.key && fal.valid !== false ? null : { who: 'ai', say: 'Use local art and captured footage. Paid fal scripts need an already connected account; never ask the person to find or set a key. Offer the provider browser connection only when generated media is requested.', open: 'https://fal.ai/dashboard' },
404
- });
360
+ rows.push(...officialConnections(integrations));
405
361
 
406
362
  // Stripe, only in a studio that sells. Installing agent tooling is not proof of Worker credentials.
407
363
  // shop connect runs browser pairing and reports the remaining steps; this read-only status never reads keys.
@@ -411,11 +367,11 @@ export async function setupStatus({
411
367
  const mcp = stripeAgentConfigured({ root, env });
412
368
  rows.push({
413
369
  id: 'stripe', label: 'Stripe', need: 'for selling', state: 'act',
414
- detail: [version ? `Stripe CLI ${version} is installed` : 'Stripe CLI is not installed', mcp ? `optional MCP is set up for ${mcp}` : 'optional MCP is not set up', 'CLI access is not proof that Worker credentials or a purchase work'].join('; '),
415
- unlocks: 'Connect the studio’s own Stripe with browser approval; shop connect syncs Payment Links and the webhook without a Worker API key. Verify a test purchase before calling the shop ready.',
370
+ detail: [version ? `Stripe CLI ${version} is installed` : 'Stripe CLI is not installed', mcp ? `official MCP is set up for ${mcp}` : 'official MCP is not set up', 'Provider sign-in is not proof that the shop and signed events work'].join('; '),
371
+ unlocks: 'Use Stripe’s official plugin for shop planning and verification, then install the private webhook. Verify a test purchase before calling the shop ready.',
416
372
  fix: {
417
- who: 'ai', run: version ? 'npx --no-install homie-studio shop connect' : 'npm install -g @stripe/cli@latest',
418
- say: 'Use stripe_login or shop connect. Never ask for a key in chat. Sync Products, Prices, Payment Links and the webhook through the approved CLI. The Worker needs only the signing secret; repeat connect after edits.',
373
+ who: 'ai', run: mcp ? 'npx --no-install homie-studio shop plan' : version ? 'stripe agent setup' : 'npm install -g @stripe/cli@latest',
374
+ say: 'Use stripe_shop_plan with Stripe’s official MCP for catalog and links, then stripe_webhook for private signing-secret installation. Never ask for a key. Repeat plan and verify after edits.',
419
375
  open: 'https://dashboard.stripe.com/register',
420
376
  },
421
377
  });
@@ -1,3 +1,4 @@
1
+ import {callbackUrl} from '../worker/mcp-events-wire.mjs';
1
2
  import {validateWebhook,webhookName} from '../worker/event-webhook-config.mjs';
2
3
  import {parseSchedule,nextDue,scheduleStatus} from '../worker/function-schedule.mjs';
3
4
  import {existsSync,readdirSync,mkdirSync,writeFileSync,readFileSync} from 'node:fs';
@@ -12,6 +13,8 @@ export async function buildFunctions(root,esbuild,{log=()=>{}}={}) {
12
13
  if(!feeds||typeof feeds!=='object'||Array.isArray(feeds))throw new Error('mcp.events must be a map of named event feeds');
13
14
  for(const [name,feed] of Object.entries(feeds)) {
14
15
  if(feed?.webhook)validateWebhook(name,feed.webhook);
16
+ if(feed?.maxSubscriptions!==undefined&&(!Number.isSafeInteger(feed.maxSubscriptions)||feed.maxSubscriptions<1))throw new Error('maxSubscriptions must be a positive integer');
17
+ if(feed?.receivers!==undefined){if(!Array.isArray(feed.receivers))throw new Error('Event receivers must be an array of exact HTTPS callback URLs');for(const url of feed.receivers)callbackUrl(url);}
15
18
  if(!/^[a-z][a-z0-9-]*$/.test(name)||!feed||typeof feed.event!=='string'||!feed.event.trim())throw new Error('Event feed needs a name and event type');
16
19
  if(feed.event==='record.changed'&&(!/^[a-z][a-z0-9-]*$/.test(feed.app??'')||!/^[a-z][a-z0-9-]*$/.test(feed.collection??'')))throw new Error('Record event feed needs app and collection');
17
20
  if(feed.audience!==undefined&&feed.audience!=='owner'&&!(feed.audience&&typeof feed.audience==='object'&&feed.audience.app===feed.app&&typeof feed.audience.role==='string'))throw new Error('Event feed audience must be owner or an app role');
@@ -51,5 +54,9 @@ export function functionDeploymentSQL(root) {
51
54
  const definitions=existsSync(file)?JSON.parse(readFileSync(file,'utf8')):[];
52
55
  for(const [name,feed] of Object.entries(readStudio(root).mcp?.events??{})){definitions.push({name:'@events/'+name,event:feed.event});if(feed.webhook)definitions.push({name:webhookName(name,feed.webhook),event:feed.event});}
53
56
  const literal=value=>"'"+String(value).replaceAll("'","''")+"'";
54
- return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),`DELETE FROM function_subscriptions WHERE name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
57
+ const feedNames=Object.keys(readStudio(root).mcp?.events??{}).map(literal).join(',')||"''";
58
+ // Declaration removal also removes private subscription secrets. Runtime studios
59
+ // without event feeds can retain their original no-MCP-storage fast path.
60
+ const cleanup=[`DELETE FROM function_deliveries WHERE function IN (SELECT '@mcp-webhook/' || json_extract(value,'$.id') FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames}));`,`DELETE FROM function_subscriptions WHERE name IN (SELECT '@mcp-webhook/' || json_extract(value,'$.id') FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames}));`,`DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND json_extract(value,'$.name') NOT IN (${feedNames});`];
61
+ return [...definitions.map(fn=>`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(${literal(fn.name)},${literal(fn.event)},(SELECT COALESCE(MAX(seq),0) FROM studio_events),unixepoch()*1000) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type;`),...cleanup,`DELETE FROM function_subscriptions WHERE name NOT LIKE '@mcp-webhook/%' AND name NOT IN (${definitions.map(fn=>literal(fn.name)).join(',')||"''"});`,"INSERT INTO meta(key,value) VALUES('function-registry-deployed','1') ON CONFLICT(key) DO UPDATE SET value='1';"].join(' ');
55
62
  }
package/lib/mcp-tools.mjs CHANGED
@@ -1,4 +1,3 @@
1
- import { providerKey } from './provider-key.mjs';
2
1
  /**
3
2
  * THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
4
3
  * desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
@@ -200,14 +199,14 @@ function needsInstall(ctx, root) {
200
199
 
201
200
  /* ------------------------------------------------------------------ the setup card */
202
201
 
203
- async function statusOf(ctx, root, { fresh = false } = {}) {
202
+ async function statusOf(ctx, root, { fresh = false, integrations = {} } = {}) {
204
203
  const key = root ?? '';
205
- if (!fresh && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
204
+ if (!fresh && !Object.keys(integrations).length && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
206
205
  const node = findNode({ fresh });
207
206
  // Claude Code's own status line is not this host's: its row stays out (CLAUDECODE says the server runs inside it).
208
207
  const env = toolEnv();
209
208
  delete env.CLAUDECODE;
210
- const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', node: node?.version ?? '0', env });
209
+ const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', integrations, node: node?.version ?? '0', env });
211
210
  const row = value.rows.find((r) => r.id === 'node');
212
211
  if (row) {
213
212
  if (!node) { row.detail = 'not found on this computer (22 or newer is needed)'; row.state = 'missing'; }
@@ -240,8 +239,8 @@ function checklist(ctx, root) {
240
239
  return steps.map((label, n) => ({ n, label, state: done[n] ? 'done' : n === now ? 'now' : 'todo' }));
241
240
  }
242
241
 
243
- async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null } = {}) {
244
- const status = await statusOf(ctx, root, { fresh });
242
+ async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null, integrations = {} } = {}) {
243
+ const status = await statusOf(ctx, root, { fresh, integrations });
245
244
  const studios = ctx.studios().map(({ root: r, ...s }) => s);
246
245
  const current = root ? ctx.describe(root) : null;
247
246
  const running = root ? runningJobs(root).find((j) => j.label.startsWith('npm install')) : null;
@@ -814,13 +813,13 @@ export function toolDefs(ctx, avail = {}) {
814
813
  {
815
814
  name: 'setup_status', title: 'Setup status',
816
815
  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.',
817
- 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' } } },
816
+ 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' }, integrations: { type: 'object', description: 'Session evidence from official MCP tools: connected only after a successful authenticated call; unavailable when absent, otherwise unknown. Never credentials.', properties: Object.fromEntries(['fal','elevenlabs','cloudflare'].map(id => [id, { type: 'string', enum: ['connected','unavailable','unknown'] }])) } } },
818
817
  annotations: { title: 'Setup status', ...RO }, _meta: ui(UI.setup),
819
818
  run: async (a) => {
820
819
  // A name with no studio of it yet is the studio about to be made: its own checklist, from step 1.
821
820
  const named = String(a.studio ?? '').trim().slice(0, 60);
822
- if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, wanted: named });
823
- return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true });
821
+ if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, integrations: a.integrations, wanted: named });
822
+ return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true, integrations: a.integrations });
824
823
  },
825
824
  },
826
825
  {
@@ -1031,13 +1030,13 @@ export function toolDefs(ctx, avail = {}) {
1031
1030
  },
1032
1031
  {
1033
1032
  name: 'build', title: 'Build the site',
1034
- description: 'Bundle every game or app and the site into site/dist. Rules builds always run strict types, the guard and deterministic generated play, including save/restore and prediction checks. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
1035
- inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), ...STUDIO_ARG } },
1033
+ description: 'Bundle every game or app and the site into site/dist. Rules builds run strict types, the guard and deterministic generated play. quick:true skips generated play and snapshot smoke checks for local iteration; full build before release. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
1034
+ inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), quick:{type:'boolean'}, ...STUDIO_ARG } },
1036
1035
  annotations: { title: 'Build', ...RW },
1037
1036
  run: async (a) => {
1038
1037
  const root = ctx.root(a.studio);
1039
1038
  const need = needsInstall(ctx, root); if (need) return need;
1040
- const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : [])]);
1039
+ const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : []), ...(a.quick?['--quick']:[])]);
1041
1040
  if (!r.ended) return stillRunning(r.job, 'The build');
1042
1041
  if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
1043
1042
  const g = r.result?.games ?? [];
@@ -1177,9 +1176,31 @@ export function toolDefs(ctx, avail = {}) {
1177
1176
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
1178
1177
  },
1179
1178
  },
1179
+ {
1180
+ name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
1181
+ description: 'Default setup with Stripe official MCP. Supply public API observations as {path,params,body}, account ID, mode, and complete booking slots. Returns the exact next read/write with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, clear observations after writes, repeat until verified. verify:true saves the public config for stripe_webhook. Never pass webhook creation responses or secrets.',
1182
+ inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
1183
+ annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
1184
+ run:async a=>{
1185
+ const {shopMcpPlan,saveMcpVerification}=await import('./shop-mcp.mjs');
1186
+ const result=await (a.verify?saveMcpVerification:shopMcpPlan)(ctx.root(a.studio),a);
1187
+ return ok(JSON.stringify(result),result);
1188
+ },
1189
+ },
1190
+ {
1191
+ name:'stripe_webhook',title:'Install Stripe webhook privately',
1192
+ description:'After stripe_shop_plan verify:true, create/install only the webhook and verified public links. Uses a separate official CLI browser approval if needed; never shows secrets. Follow studio_job. Never create a webhook through model-visible Stripe MCP.',
1193
+ inputSchema:{type:'object',properties:{...STUDIO_ARG,live:{type:'boolean'},renew:{type:'boolean'}}},
1194
+ annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
1195
+ run:async a=>{
1196
+ const job=cliJob(ctx.root(a.studio),'Private Stripe webhook',['shop','webhook',...(a.live?['--live']:[]),...(a.renew?['--renew']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
1197
+ await waitJob(job,Math.min(ctx.waitMs,1000));
1198
+ return ok('Follow studio_job; approve Stripe in the browser if requested.',{kind:'job',...jobView(job)});
1199
+ },
1200
+ },
1180
1201
  {
1181
1202
  name:'stripe_bookings_sync', title:'Sync published booking slots to Stripe',
1182
- description:'Owner AI: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
1203
+ description:'CLI alternative to stripe_shop_plan with Stripe official MCP: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
1183
1204
  inputSchema:{type:'object',properties:{...STUDIO_ARG,slots:{type:'array',items:{type:'object',properties:{app:{type:'string'},collection:{type:'string'},slots:{type:'string'},slot:{type:'string'},item:{type:'string'},capacity:{type:'integer',minimum:0},confirmed:{type:'integer',minimum:0},active:{type:'boolean'}},required:['app','collection','slots','slot','item','capacity'],additionalProperties:false}},live:{type:'boolean'}},required:['slots']},
1184
1205
  annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
1185
1206
  run:async a=>{
@@ -1193,12 +1214,12 @@ export function toolDefs(ctx, avail = {}) {
1193
1214
  },
1194
1215
  {
1195
1216
  name: 'stripe_login', title: 'Connect the studio to Stripe',
1196
- description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. An existing key supports fuller checkout; manual only if explicitly chosen for those features. live only when the owner says go live.',
1197
- inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' }, manual: { type: 'boolean' } } },
1217
+ description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. Alternative to the default Stripe official MCP plan and private stripe_webhook flow. live only when the owner says go live.',
1218
+ inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' } } },
1198
1219
  annotations: { title: 'Connect Stripe', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1199
1220
  run: async (a) => {
1200
1221
  const root = ctx.root(a.studio);
1201
- const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : []), ...(a.manual ? ['--manual'] : [])];
1222
+ const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : [])];
1202
1223
  const job = cliJob(root, 'Stripe connection', args, { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs'), keep: false });
1203
1224
  const ended = await waitJob(job, Math.min(ctx.waitMs, 1000));
1204
1225
  ctx.status = null;
@@ -1496,13 +1517,13 @@ export function toolDefs(ctx, avail = {}) {
1496
1517
  },
1497
1518
  });
1498
1519
  }
1499
- // The media skills' own scripts, where their provider (or the free tool they need) is set up on this computer.
1500
- 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'], ['models', 'Models: generated 3D props (paid, on your fal account)', 'node']]) {
1520
+ // Local media processing; provider tools are discovered by the AI host.
1521
+ for (const [name, title, needs] of [['music', 'Music preparation', 'ffmpeg'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg'], ['models', 'Models: import completed 3D assets', 'node']]) {
1501
1522
  const script = ctx.skillsDir ? join(ctx.skillsDir, name, 'scripts', `${name}.mjs`) : null;
1502
1523
  if (!script || !existsSync(script) || !avail[needs]) continue;
1503
1524
  tools.push({
1504
1525
  name, title,
1505
- 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.`,
1526
+ description: `Local ${name} preparation only; read studio_guide for the workflow. Generation, pricing and provider connections use the official provider MCP in the AI client.`,
1506
1527
  inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The script\'s words' }, ...STUDIO_ARG }, required: ['args'] },
1507
1528
  annotations: { title, readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1508
1529
  run: async (a) => {
@@ -1523,9 +1544,9 @@ export function toolDefs(ctx, avail = {}) {
1523
1544
  return tools;
1524
1545
  }
1525
1546
 
1526
- /** What the media tools need, cheaply (no network): ffmpeg on the PATH, ElevenLabs' CLI or key. */
1547
+ /** Local processing availability only; provider connections belong to the AI host. */
1527
1548
  export function availability(env = process.env) {
1528
- return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(providerKey('elevenlabs', env).key || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
1549
+ return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')) };
1529
1550
  }
1530
1551
 
1531
1552
  /** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
package/lib/mcp.mjs CHANGED
@@ -37,7 +37,7 @@ Carry the person's request through to a working result without a lesson or an in
37
37
  Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
38
38
  New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists; existing netplay games keep building and running with their original 32-seat limit; larger rooms require a rewrite. Repair the full build diagnostics and prove the requested behavior in two browsers. Set players.max to the chosen room size, including hundreds; public connections use Gates. Choose room.view radiusM, precisionM, nearM and farHz explicitly and measure at the requested size. Private per-seat state is not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
39
39
  Show progress briefly and give the preview link as soon as it works. Only when going live was requested, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
40
- For a shop, stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through one Stripe browser approval. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
40
+ For a shop, default to stripe_shop_plan with Stripe official MCP for catalog and links, then stripe_webhook for private webhook installation. stripe_login (shop connect) is the CLI alternative. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
41
41
  For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
42
42
 
43
43
  If studio_scaffold finds an earlier folder of the studio's name that is not a studio, ask the person whether to fold its premise in (studio_fold), and remove it only with a second yes.
@@ -0,0 +1,41 @@
1
+ /** Place already downloaded official MCP outputs. No provider networking, credentials or generation. */
2
+ import { createHash } from 'node:crypto';
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
5
+ function inside(root, name) {
6
+ const path = resolve(root, name), rel = relative(root, path);
7
+ if (!rel || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) throw new Error('asset paths must stay inside the studio');
8
+ let ancestor = path;
9
+ while (!existsSync(ancestor)) ancestor = dirname(ancestor);
10
+ const actual = relative(realpathSync(root), realpathSync(ancestor));
11
+ if (isAbsolute(actual) || actual === '..' || actual.startsWith(`..${sep}`)) throw new Error('symlink leaves studio');
12
+ return path;
13
+ }
14
+ export function importMedia(root, { file, receipt, out, usedBy }) {
15
+ if (!existsSync(resolve(root, 'studio.json'))) throw new Error('not a studio');
16
+ const r = typeof receipt === 'string' ? JSON.parse(readFileSync(receipt, 'utf8')) : receipt;
17
+ for (const key of ['provider','tool','model','requestId','prompt','styleRevision','rights','cost']) if (r?.[key] === undefined) throw new Error(`receipt needs ${key}`);
18
+ if (!['fal','elevenlabs'].includes(r.provider) || !r.requestId || !r.tool || !r.model || !r.prompt) throw new Error('incomplete official MCP provenance');
19
+ if (!r.rights?.source || !r.rights?.checkedAt || typeof r.rights?.commercial !== 'boolean') throw new Error('record rights source, date and commercial permission');
20
+ if (!['estimate','actual','unknown'].includes(r.cost?.status) || (r.cost.status !== 'unknown' && !(Number.isFinite(r.cost.amount) && r.cost.amount >= 0 && r.cost.unit))) throw new Error('cost must be actual, estimate or explicitly unknown');
21
+ const target = inside(root, out), use = inside(root, usedBy);
22
+ const bytes = readFileSync(file);
23
+ if (!bytes.length) throw new Error('empty asset');
24
+ const hash = createHash('sha256').update(bytes).digest('hex');
25
+ const registry = inside(root, 'media/provenance.json');
26
+ const records = existsSync(registry) ? JSON.parse(readFileSync(registry,'utf8')) : [];
27
+ const previous = records.find(x => x.provider === r.provider && x.requestId === r.requestId && x.output === out);
28
+ if (previous && previous.sha256 !== hash) throw new Error('same output identity has different bytes');
29
+ if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') !== hash) throw new Error('destination already contains a different asset');
30
+ // Store only allowlisted provenance; raw tool responses may contain temporary signed URLs.
31
+ const record = { provider:r.provider, tool:r.tool, model:r.model, requestId:r.requestId, prompt:r.prompt, styleRevision:r.styleRevision,
32
+ rights:{ source:r.rights.source, checkedAt:r.rights.checkedAt, commercial:r.rights.commercial, note:r.rights.note ?? null },
33
+ cost:{ status:r.cost.status, amount:r.cost.amount ?? null, unit:r.cost.unit ?? null }, fixture:r.fixture === true,
34
+ output:out, usedBy, sha256:hash, bytes:bytes.length };
35
+ // Static reference evidence is not a runtime claim. Skills must also inspect the running scene.
36
+ const reference = relative(dirname(use), target).split(sep).join('/');
37
+ if (!readFileSync(use,'utf8').includes(reference)) throw new Error(`consumer does not reference ${reference}`);
38
+ mkdirSync(dirname(target),{recursive:true}); copyFileSync(file,target);
39
+ if (!previous) { records.push(record); mkdirSync(dirname(registry),{recursive:true}); writeFileSync(registry,JSON.stringify(records,null,2)+'\n'); }
40
+ return { ok:true, command:'media import', artifact:out, provenance:'media/provenance.json', sha256:hash, already:Boolean(previous), usage:'static reference verified; inspect the running scene' };
41
+ }
@@ -0,0 +1,13 @@
1
+ /** Declarative tool references for Homie's media skills; host namespaces are resolved by the AI. */
2
+ export const MEDIA_WORKFLOWS = Object.freeze({
3
+ art: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result','upload_file'] },
4
+ models: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','submit_job','check_job','get_job_result'] },
5
+ video: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','submit_job','check_job','get_job_result'] },
6
+ style: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result'] },
7
+ music: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
8
+ sound: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
9
+ });
10
+ export function validateMediaTools(workflow, tools) {
11
+ const available=new Set(tools.map(t=>t.name));
12
+ return {ready:workflow.tools.every(t=>available.has(t)), missing:workflow.tools.filter(t=>!available.has(t))};
13
+ }
@@ -0,0 +1,15 @@
1
+ /** Provider connections belong to the AI host. Never inspect credentials or host configuration. */
2
+ export const INTEGRATIONS = Object.freeze({
3
+ fal: { label: 'fal', url: 'https://mcp.fal.ai/mcp-relay', connectUrl: 'https://fal.ai/docs/documentation/setting-up/mcp', unlocks: 'Images, 3D and generated video', connect: 'Install the official fal plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once.' },
4
+ elevenlabs: { label: 'ElevenLabs', url: 'https://api.elevenlabs.io/v1/mcp', connectUrl: 'https://elevenlabs.io/mcp', unlocks: 'Music, speech and sound where exposed by the connected MCP', connect: 'Install elevenlabs/plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once. Discover the workspace tools before promising a media capability.' },
5
+ cloudflare: { label: 'Cloudflare MCP', url: 'https://mcp.cloudflare.com/mcp', connectUrl: 'https://github.com/cloudflare/skills', unlocks: 'Inspect Workers Issues, logs and D1 through official tools', connect: 'Connect the official Cloudflare plugin or this MCP URL and approve account access in the browser. Deploys separately use Wrangler login (cloudflare_login).' },
6
+ });
7
+ export function officialConnections(evidence = {}) {
8
+ return Object.entries(INTEGRATIONS).map(([provider, p]) => {
9
+ const connected = evidence[provider] === 'connected';
10
+ return { id: provider === 'cloudflare' ? 'cloudflare-mcp' : provider, label: p.label, need: 'optional', state: connected ? 'ok' : 'optional',
11
+ connection: connected ? 'connected' : evidence[provider] === 'unavailable' ? 'unavailable' : 'unknown',
12
+ detail: connected ? 'Official MCP connected (authenticated call reported by this AI session)' : evidence[provider] === 'unavailable' ? 'Official MCP unavailable in this AI session' : 'Connection unknown: the AI host must discover tools and verify an authenticated read',
13
+ unlocks: p.unlocks, fix: connected ? null : { who: 'person', open: p.connectUrl, say: `${p.connect} MCP URL: ${p.url}` } };
14
+ });
15
+ }