SLOPSHOPPER

Verse8

Vibe-code a web game with Claude and publish it on Verse8, simply and quickly: project bootstrap, V8-format commits, local preview, asset generation, CDN…

newbandguardprocess
v0.8.0MITupdated 2026-10-13planetarium/verse8-mcp/plugins/verse8
A shopper browsing a rack in a slop shop
README

Verse8 for Claude

Build, preview and publish Verse8 web games from Claude. The plugin runs the @verse8/mcp server, which detects Verse8 projects (repositories hosted on gitlab.verse8.io) and gives Claude the tools to create a project, commit in Verse8's history format, run a local preview, generate game assets, upload files to the Verse8 CDN and publish the game.

Install

/plugin marketplace add planetarium/verse8-mcp
/plugin install verse8@verse8-plugins

Then ask Claude to make a game, for example "Make a 3D platformer on Verse8". Claude signs you in with a browser code the first time (verse8_login_start).

What it does

ToolWhat it does
verse8_bootstrap, verse8_statusDetect whether the working directory is a Verse8 project and what to do next
verse8_login_start, verse8_login_completeSign in with a one-time browser code (OAuth device flow)
verse8_new_project_start, verse8_new_project_confirmCreate a Verse8 project from a template and clone it
verse8_attachSet up an existing clone (git hooks, AGENTS.md, skills)
verse8_checkpointCommit and push in Verse8's commit-message format
verse8_previewCommit, install dependencies and start the local dev server
verse8_upload_asset, verse8_upload_thumbnailUpload game assets and store-listing images
verse8_deployBuild the game and publish it (first release or update)
image_*, music_generate, sfx_generate, and other generatorsAsset generation through Verse8's hosted services

Claude Code mod (2.1.287 or later)

On Claude Code 2.1.287+, the plugin also loads a small in-session module (hooks/verse8.mjs) that runs only inside a Verse8 project (a directory with .verse8/project.json):

  • Gatekeeper — a Bash command that would bypass the Verse8 workflow is refused before it runs, and Claude is told which tool to use instead: git commit → verse8_checkpoint; --no-verify → not allowed; bun run dev / npm run dev / a vite that starts a server (not vite build) → verse8_preview. Text inside quotes or a heredoc is not read as a command. The git hooks stay in place as the hard layer; the mod only catches the mistake earlier. If the mod itself fails, the command goes through as before.
  • Preview reminder — at the end of every turn the mod asks git what differs from the last preview (or from session start before any): files committed since then plus files the working tree changed. If anything does, a line above the prompt says how many files changed, whatever edited them (Edit, a Bash sed -i, a subagent, a checkpoint commit, a rebase). It disappears on the next preview.

Older Claude Code versions ignore the module and keep the MCP server only. The mod keeps its state in Claude Code's plugin state, runs only git rev-parse, git diff --name-only and git status --porcelain in the project, reads nothing but .verse8/project.json, and sends nothing anywhere.

What it runs, sends and stores

Once you are signed in, the server lists its tools at the start of every session — in any directory — and that lookup contacts Verse8's asset services (mcp-*-svc.verse8.io) with your token. Everything else below happens only in a Verse8 project, or when you ask Claude to sign in or create one.

Runs on your machine

  • npx -y @verse8/mcp@<pinned version> from the npm registry.
  • In a Verse8 project: git (commit, push to gitlab.verse8.io), the project's package manager (bun update by default), bun run dev for previews, and bun run build for deploys.
  • On every deploy, npx -y @agent8/deploy uploads the built game; for projects with a game server, npx -y @agent8/gameserver-node builds the server code first.

Changes in your project

verse8_attach and verse8_new_project_confirm install pre-commit and commit-msg git hooks that keep Verse8's commit format — an existing hook of the same name that is not Verse8's is replaced, not backed up — and write AGENTS.md, CLAUDE.md, .claude/skills/*, .verse8/project.json, .verse8/.gitignore and .env (your wallet address as VITE_AGENT8_ACCOUNT and the game's VITE_AGENT8_VERSE id; Verse8 projects commit .env). Files you edited are kept unless you ask to overwrite them.

A new project is cloned with a 30-day GitLab access token embedded in the origin URL in .git/config, so pushes work without a credential prompt.

Each verse8_checkpoint / verse8_preview / verse8_deploy commit message contains your prompt for that turn, Claude's summary of the work and a truncated record of the tool calls (inputs and outputs), and is pushed to gitlab.verse8.io — the same history format the Verse8 web editor writes.

Sends data to Verse8 services (all over HTTPS, authenticated with your Verse8 token)

DestinationWhy
v8-meme-api.verse8.ioSign-in, profile, publishing the game listing
agent8-backend.verse8.ioProject creation, repository access tokens, asset uploads
verse8.ioCover image and video uploads for the listing. Videos are then sent with PUT to a short-lived Cloudflare R2 upload URL that verse8.io issues (served from contents.verse8.io)
gitlab.verse8.ioYour project's git repository
mcp-*-svc.verse8.ioAsset generation (image, audio, sprite sheet, skybox, 3D, UI theme, cinematic, vector search, Crossramp)
Verse8 game backend (verse8-game-backend-*.run.app, via @agent8/deploy)Uploading the built game and game-server code, served from agent8-games.verse8.io

Prompts you give to generation tools and the files you upload are sent to these services. Apart from downloading packages from the npm registry, the plugin sends nothing to other third parties.

Stores locally

  • Your Verse8 access token in ~/.config/verse8/token (file mode 0600; $XDG_CONFIG_HOME/verse8/token if set).
  • An audit log of tool calls, with secrets redacted, in ~/.local/state/verse8/mcp.log ($XDG_STATE_HOME/verse8/mcp.log or $VERSE8_AUDIT_LOG_PATH if set).

Requirements

  • Node.js 18.17 or later (for npx), git, and Bun for Verse8 projects.
  • A Verse8 account with credits. Installing the plugin is free; creating a project and some asset tools require Verse8 credits. A new account gets no credits at sign-up — verify a phone number and claim the one-time welcome credit. Recharge at <https://verse8.io/settings>; an insufficient_credits error means the account has run out (see Troubleshooting).

Support

Report issues at <https://github.com/planetarium/verse8-mcp/issues>. Source code: <https://github.com/planetarium/verse8-mcp>.

License

MIT. See LICENSE.

Source 2 files
hooks/verse8.mjs 331 lines
1// Verse8 mod — runs inside Claude Code (2.1.287+) next to the MCP server.
2//
3// Older Claude Code (measured 2026-10-07 with `claude -p --plugin-dir`):
4//  - 2.1.251–2.1.286: `modules` is read, but this module fails to load
5//    (`session.start` unknown, or the mods rollout flag is off). The MCP
6//    server still connects — the mod degrades to nothing.
7//  - 2.1.240 and older: `modules` is ignored, and hooks.json MUST carry a
8//    `hooks` object (even `{}`). Without it the schema check fails and the
9//    WHOLE plugin is skipped: `Plugin not available for MCP: verse8 —
10//    error type: hook-load-failed`. That is why hooks.json keeps `"hooks": {}`
11//    next to `"modules"`; do not remove it.
12//
13// Two jobs, both scoped to a Verse8 project (cwd has .verse8/project.json):
14//
15//  1. Gatekeeper  — a Bash call that would bypass the Verse8 workflow
16//                   (raw `git commit`, `--no-verify`, a hand-started dev
17//                   server) is refused BEFORE it runs, with the tool to use
18//                   instead. The git hooks stay as the hard layer; this is
19//                   the early, explanatory one.
20//  2. Preview bell — "what the user sees == what is in git" is the project's
21//                   rule (verse8_preview bundles a commit), so staleness is
22//                   read from git, not from tool calls: the HEAD at the last
23//                   preview (or at session start) is the baseline, and at the
24//                   end of every main-loop turn `git diff` + `git status`
25//                   count the files that differ from it. Edits through Edit,
26//                   Bash (`sed -i`), subagents, a checkpoint commit or a rebase
27//                   all show up the same way. A band above the prompt reports
28//                   the count; it clears on the next preview.
29//
30// Everything outside the sandbox goes through `$`; module variables reset on
31// hot reload, so all data lives in `$.state`.
32
33const BASELINE = { plugin: "verse8", key: "baseline" };
34const REMINDER = { plugin: "verse8", key: "reminder" };
35
36const PROJECT_MARKER = ".verse8/project.json";
37
38// Bash commands the Verse8 MCP instructions (rules 2–4) forbid, and what to
39// use instead. A Bash call is read as a list of simple commands, each a list
40// of shell words, and each rule looks at ONE simple command: what it starts
41// with (after env assignments) and, for git, which subcommand it is. So `git
42// log --grep commit`, `git config commit.gpgsign`, `grep commit`, `git commit`
43// inside a quoted string or a heredoc body, or `-n` inside a commit message is
44// not a commit / --no-verify, while `git -c core.hooksPath=x commit`, a commit
45// on the second line, or one after `then` still is.
46//
47// Deliberately NOT caught (the git hooks remain the hard layer): a commit
48// made by `sh -c`, `$( … )`, a script file, `eval`, or through `git merge` /
49// `revert` / `cherry-pick` / `rebase` — the commit-msg hook lets git's own
50// Merge/Revert subjects through and rejects the rest.
51
52// Split a Bash command into its simple commands, each a list of words.
53// `;`, `&`, `|` and newlines end a command only outside quotes; quotes are
54// removed from the words, and a heredoc body (`<<EOF` … `EOF`, `<<-`,
55// quoted delimiters) is skipped, so text in a string or a heredoc never
56// becomes a command. `then` / `do` / `else` are dropped so `if …; then git
57// commit` is seen as `git commit`. One pass over the string, no regex
58// backtracking.
59function simpleCommands(cmd) {
60  const commands = [];
61  const heredocs = []; // delimiters whose bodies start after the next newline
62  let words = [];
63  let word = null; // null: between words
64  const endWord = () => {
65    if (word !== null) words.push(word);
66    word = null;
67  };
68  const endCommand = () => {
69    endWord();
70    while (words.length && /^(?:then|do|else)$/.test(words[0])) words.shift();
71    if (words.length) commands.push(words);
72    words = [];
73  };
74  let i = 0;
75  while (i < cmd.length) {
76    const c = cmd[i];
77    if (c === "'") {
78      const end = cmd.indexOf("'", i + 1);
79      const stop = end === -1 ? cmd.length : end;
80      word = (word ?? "") + cmd.slice(i + 1, stop);
81      i = stop + 1;
82    } else if (c === '"') {
83      word = word ?? "";
84      i++;
85      while (i < cmd.length && cmd[i] !== '"') {
86        if (cmd[i] === "\\" && i + 1 < cmd.length) i++;
87        word += cmd[i++];
88      }
89      i++;
90    } else if (c === "\\") {
91      if (cmd[i + 1] !== "\n") word = (word ?? "") + (cmd[i + 1] ?? "");
92      i += 2;
93    } else if (c === "<" && cmd.startsWith("<<", i) && !cmd.startsWith("<<<", i)) {
94      endWord();
95      i += 2;
96      const dash = cmd[i] === "-";
97      if (dash) i++;
98      while (cmd[i] === " " || cmd[i] === "\t") i++;
99      const m = /^(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()]+))/.exec(cmd.slice(i));
100      if (m) {
101        heredocs.push({ delim: (m[1] ?? m[2] ?? m[3]).replace(/\\/g, ""), dash });
102        i += m[0].length;
103      }
104    } else if (c === "\n") {
105      endCommand();
106      i++;
107      for (const { delim, dash } of heredocs.splice(0)) {
108        for (;;) {
109          if (i >= cmd.length) break;
110          const eol = cmd.indexOf("\n", i);
111          const stop = eol === -1 ? cmd.length : eol;
112          const line = cmd.slice(i, stop);
113          i = stop + 1;
114          if ((dash ? line.replace(/^\t+/, "") : line) === delim) break;
115        }
116      }
117    } else if (c === ";" || c === "&" || c === "|") {
118      endCommand();
119      i++;
120    } else if (c === " " || c === "\t") {
121      endWord();
122      i++;
123    } else {
124      word = (word ?? "") + c;
125      i++;
126    }
127  }
128  endCommand();
129  return commands;
130}
131
132// `FOO=1 env BAR=2 git …` → { env: ["FOO=1", "BAR=2"], argv: ["git", …] }
133function splitEnv(words) {
134  let i = 0;
135  while (i < words.length && (words[i] === "env" || /^[A-Za-z_]\w*=/.test(words[i]))) i++;
136  return { env: words.slice(0, i).filter((w) => w !== "env"), argv: words.slice(i) };
137}
138
139// Global git options that take their value as the next word.
140const GIT_OPTION_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--config-env"]);
141
142// `git -c a=b -C dir commit -m x` → { sub: "commit", args: ["-m", "x"] };
143// null when the command is not git. Global options before the subcommand
144// (`-c key=val`, `-C dir`, `--git-dir=…`, `--no-pager`) are skipped, one
145// word at a time.
146function gitCall(argv) {
147  if (argv[0] !== "git") return null;
148  let i = 1;
149  while (i < argv.length && argv[i].startsWith("-")) i += GIT_OPTION_WITH_VALUE.has(argv[i]) ? 2 : 1;
150  const sub = argv[i];
151  return sub && /^[a-z][\w-]*$/.test(sub) ? { sub, args: argv.slice(i + 1) } : null;
152}
153
154// Whether `args` (up to a `--`) holds one of `flags`, alone or as `--flag=…`.
155function hasFlag(args, flags) {
156  const end = args.indexOf("--");
157  return (end === -1 ? args : args.slice(0, end)).some((a) => flags.includes(a.replace(/=.*$/, "")));
158}
159
160// `vite` with no subcommand, or `dev` / `serve` / `preview`, starts a server;
161// `build`, `optimize`, `--help`, `--version` do not, wherever they sit among
162// the options (`vite --mode production build`).
163const VITE_NOT_A_SERVER = new Set(["build", "optimize", "--help", "-h", "--version", "-v"]);
164function startsDevServer(argv) {
165  if (/^(?:bun|npm|pnpm|yarn)$/.test(argv[0] ?? "")) {
166    return (argv[1] === "run" ? argv[2] : argv[1]) === "dev";
167  }
168  const vite = argv[0] === "npx" || argv[0] === "bunx" ? argv.slice(1) : argv;
169  return vite[0] === "vite" && !vite.slice(1).some((a) => VITE_NOT_A_SERVER.has(a));
170}
171
172// The one sanctioned Bash commit: AGENTS.md allows
173// `VERSE8_ALLOW_NONCOMPLIANT=1 git commit -m "…"` for a deliberate,
174// user-approved one-off. The commit-msg hook honours the same variable.
175// It never excuses --no-verify.
176const ALLOW_NONCOMPLIANT = "VERSE8_ALLOW_NONCOMPLIANT=1";
177
178const GUARDS = [
179  {
180    // `-n` is --no-verify on commit; on push it is --dry-run, so only the
181    // long form counts there.
182    test: ({ argv }) => {
183      const g = gitCall(argv);
184      if (!g) return false;
185      if (g.sub === "commit") return hasFlag(g.args, ["--no-verify", "-n"]);
186      if (g.sub === "push") return hasFlag(g.args, ["--no-verify"]);
187      return false;
188    },
189    deny: "Verse8 project: --no-verify is not allowed. The commit-msg / pre-commit hooks keep Verse8's commit format. Use verse8_checkpoint instead.",
190  },
191  {
192    test: ({ env, argv }) => gitCall(argv)?.sub === "commit" && !env.includes(ALLOW_NONCOMPLIANT),
193    deny: "Verse8 project: do not run `git commit` through Bash. Use the verse8_checkpoint tool (it commits and pushes in Verse8's V8 history format).",
194  },
195  {
196    // `bun run dev`, `npm dev`, `npx vite`, `vite --port 3000`, `vite preview`
197    // (it serves too). Not `dev:server`, `vite build`, `vite optimize`,
198    // `vite --help`, `vitest`, `vite-node`.
199    test: ({ argv }) => startsDevServer(argv),
200    deny: "Verse8 project: do not start the dev server through Bash. Use the verse8_preview tool (it bundles a V8 commit with the spawn so the preview matches git).",
201  },
202];
203
204// The first rule, in GUARDS order, that any simple command trips.
205function findGuard(cmd) {
206  const parts = simpleCommands(cmd).map(splitEnv);
207  return GUARDS.find((g) => parts.some((p) => g.test(p)));
208}
209
210export function register(on) {
211  // 1. Gatekeeper
212  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
213    const guard = findGuard(String(e.command ?? ""));
214    if (!guard) return next(e);
215    if (!(await inVerse8Project($))) return next(e);
216    return { deny: guard.deny };
217  }).catch(failOpen);
218
219  // 2a. Session start: the baseline is where git stands now, until a preview.
220  // A plugin reload raises session.start again with `$.state` intact; a
221  // preview baseline it finds is kept, so the reload does not forget it.
222  on("session.start", async ($, e, next) => {
223    const result = await next(e);
224    if (await inVerse8Project($)) {
225      const { value: baseline } = await $.state.get(BASELINE);
226      if (baseline?.kind !== "preview") await setBaseline($, "session");
227    }
228    return result;
229  });
230
231  // 2b. A preview that ran moves the baseline to its commit and clears the band.
232  // The MCP tool is `mcp__verse8__verse8_preview` when the server is configured
233  // by the user and `mcp__plugin_verse8_verse8__verse8_preview` when this plugin
234  // starts it; both end the same way. A preview that failed (`isError`) leaves
235  // the baseline where it was.
236  on("tool.call", { tool: /^mcp__.+__verse8_preview$/ }, async ($, e, next) => {
237    const result = await next(e);
238    if (!e.agentId && !result?.deny && !result?.isError) {
239      await setBaseline($, "preview");
240    }
241    return result;
242  }).catch(failOpen);
243
244  // 2c. Turn ended on the main loop: ask git what differs from the baseline.
245  on("turn.complete", async ($, e, next) => {
246    const result = await next(e);
247    if (e.agentId || !(await inVerse8Project($))) return result;
248    let { value: baseline } = await $.state.get(BASELINE);
249    if (!baseline) {
250      // No baseline yet (session.start found no Verse8 project, or git could
251      // not answer then): start counting now.
252      baseline = await setBaseline($, "session");
253      if (!baseline) return result;
254    }
255    const files = await changedSince($, baseline.head);
256    await $.state.set(REMINDER, files === null || files.length === 0 ? null : { files: files.length, since: baseline.kind });
257    return result;
258  });
259
260  // 2d. The band above the prompt. Reading REMINDER here subscribes the
261  // render, so a later `$.state.set` redraws it without `$.ui.invalidate`.
262  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
263    const { value: reminder } = await $.state.get(REMINDER);
264    if (!reminder || e.props.hasSurvey) return next(e);
265    const { Box, Text } = $.ui.resolve(e);
266    const n = reminder.files;
267    const since = reminder.since === "preview" ? "since the last preview" : "since this session started (no preview yet)";
268    return Box({
269      flexDirection: "row",
270      paddingX: 1,
271      children: [
272        Text({ color: "warning", bold: true, children: "⚠ Verse8" }),
273        Text({ children: `  ${n} file${n === 1 ? "" : "s"} changed ${since}` }),
274        Text({ dimColor: true, children: "  · run verse8_preview to refresh" }),
275      ],
276    });
277  });
278}
279
280// A hook that throws or overruns its budget must not block Bash: the git
281// hooks remain the hard layer, so this one fails open.
282function failOpen($, e, next) {
283  return next(e);
284}
285
286async function inVerse8Project($) {
287  try {
288    return await $.fs.exists(PROJECT_MARKER);
289  } catch {
290    return false;
291  }
292}
293
294// A git that hangs (a lock, a slow disk) must not hold the turn up.
295const GIT_TIMEOUT_MS = 5000;
296
297async function git($, args) {
298  try {
299    const { exitCode, stdout } = await $.process.run(["git", ...args], { timeoutMs: GIT_TIMEOUT_MS });
300    return exitCode === 0 ? stdout : null;
301  } catch {
302    return null;
303  }
304}
305
306async function setBaseline($, kind) {
307  const head = (await git($, ["rev-parse", "HEAD"]))?.trim();
308  if (!head) return null;
309  const baseline = { head, kind };
310  await $.state.set(BASELINE, baseline);
311  await $.state.set(REMINDER, null);
312  return baseline;
313}
314
315// Files that differ from `head`: committed since it (diff) plus anything the
316// working tree holds that git does not (status). Null when git could not say.
317async function changedSince($, head) {
318  const diff = await git($, ["diff", "--name-only", head, "HEAD"]);
319  // --no-optional-locks: status would otherwise refresh the index and take
320  // index.lock, racing the user's own git.
321  const status = await git($, ["--no-optional-locks", "status", "--porcelain"]);
322  if (diff === null || status === null) return null;
323  const files = new Set(diff.split("\n").map((l) => l.trim()).filter(Boolean));
324  for (const line of status.split("\n")) {
325    if (line.length < 4) continue;
326    const path = line.slice(3);
327    files.add(path.includes(" -> ") ? path.split(" -> ").pop() : path);
328  }
329  return [...files];
330}
331
types/index.d.ts 27 lines
1// State contract of the Verse8 mod (hooks/verse8.mjs). `claude plugin validate`
2// checks every `$.state.get/set` in the module against this declaration.
3
4/** Where git stood at the last preview, or at session start before any. */
5export type Verse8Baseline = {
6  /** Full commit hash of HEAD at that moment. */
7  head: string;
8  kind: "preview" | "session";
9};
10
11/** Files differ from the baseline: the preview is stale. */
12export type Verse8PreviewReminder = {
13  /** Files committed since the baseline plus files the working tree changed. */
14  files: number;
15  since: "preview" | "session";
16};
17
18declare module "claude-code" {
19  interface PluginState {
20    verse8: {
21      baseline: Verse8Baseline | null;
22      /** Set at the end of a main-loop turn when git differs from the baseline; null otherwise. */
23      reminder: Verse8PreviewReminder | null;
24    };
25  }
26}
27