#!/usr/bin/env bash # Copyright 2026 Monterosa Productions Limited. All rights reserved. # # Onboards Gamestage into whichever coding agents this machine has. # # curl -fsSL https://gamestage.ai/install | bash # # Two things happen. The CLI lands as one file on disk, and the agent # instruction pack is installed for every coding agent found here. # # **The pack is instructions, not a tool.** Gamestage's surface is a CLI that # already speaks `--json`, so an agent that knows it exists can drive it. What # an agent cannot infer is the method: inspect first, move the answers rather than # deleting them, believe `verify` over your own reading. That is a document, and # it is the same document for every host, so this writes one file into whichever # place each host reads instructions from. # # The pack is fetched rather than embedded here, so there is one source of the # method and no copy of it ages inside this script. # # Env vars: # GAMESTAGE_INSTALL_YES=1 answer yes to every prompt (non-interactive) # GAMESTAGE_DIR= where the CLI file goes (default: current dir) # GAMESTAGE_SKIP_CLAUDE_CODE=1 skip Claude Code # GAMESTAGE_SKIP_CODEX=1 skip Codex # GAMESTAGE_SKIP_GEMINI=1 skip Gemini CLI # GAMESTAGE_SKIP_CURSOR=1 skip Cursor # GAMESTAGE_SKIP_CLI=1 skip the CLI download, wire agents only set -euo pipefail BASE="${GAMESTAGE_BASE:-https://gamestage.ai}" # The tool's own directory rather than the current one, because the current one # is the creator's game and a copy of the CLI in it is a file they did not write # and have to gitignore. `~/.gamestage` already holds the session. TARGET_DIR="${GAMESTAGE_DIR:-$HOME/.gamestage}" bold() { printf "\033[1m%s\033[0m\n" "$*"; } dim() { printf "\033[2m%s\033[0m\n" "$*"; } ok() { printf " \033[32m\xe2\x9c\x93\033[0m %s\n" "$*"; } skip() { printf " \033[2m\xe2\x97\x8b\033[0m %s\n" "$*"; } warn() { printf " \033[33m!\033[0m %s\n" "$*"; } fail() { printf "\033[31m\xe2\x9c\x97\033[0m %s\n" "$*" >&2; exit 1; } INTERACTIVE=0 if [ -t 1 ] && [ -r /dev/tty ]; then INTERACTIVE=1; fi # Returns 0 for yes. A non-interactive run takes the default, which is how # `curl | bash` behaves: stdin is the script, so there is no terminal to ask. ask() { local prompt="$1" default="${2:-Y}" yn="" [ "${GAMESTAGE_INSTALL_YES:-0}" = "1" ] && return 0 if [ "$INTERACTIVE" != "1" ]; then [ "$default" = "Y" ] && return 0 || return 1 fi local hint="[Y/n]"; [ "$default" = "N" ] && hint="[y/N]" printf " %s %s " "$prompt" "$hint" > /dev/tty read -r yn < /dev/tty || yn="" case "$yn" in [yY]|[yY][eE][sS]) return 0 ;; [nN]|[nN][oO]) return 1 ;; "") [ "$default" = "Y" ] && return 0 || return 1 ;; *) return 1 ;; esac } # -------------------------------------------------------------------- # Preflight # -------------------------------------------------------------------- bold "Gamestage" dim " Taking a game that works in a browser to a game you can trust with an audience." echo command -v curl >/dev/null 2>&1 || fail "curl is required" # The repository build gate needs Node 22, but the single-file CLI served here # was built on 2026-08-05 and its creator journey passed on Node 20. Keep this # at 20 unless that bundled journey stops passing: the two runtimes differ. if ! command -v node >/dev/null 2>&1; then fail "Node 20 or newer is required. Install it from https://nodejs.org, then rerun this command." fi NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]" 2>/dev/null || echo 0) if [ "$NODE_MAJOR" -lt 20 ]; then fail "Node 20 or newer is required. This is $(node -v). Install Node 20 or newer from https://nodejs.org, then rerun this command." fi PACK=$(curl -fsSL "$BASE/agent.md") || fail "Could not fetch the agent pack from $BASE/agent.md" [ -n "$PACK" ] || fail "The agent pack came back empty" # The pack states its own version on its second line, and this reads it rather # than carrying a copy. A skill is written once and never rewritten, so an agent # onboarded before a change repeats the old promise for ever unless it can see # how old its copy is. Rerunning this script is the update, and the pack tells # the agent to suggest it. SKILL_VERSION=$(printf '%s\n' "$PACK" | sed -n 's/^Pack version \([0-9][0-9-]*\)\.$/\1/p' | head -1) [ -n "$SKILL_VERSION" ] || SKILL_VERSION="unknown" # -------------------------------------------------------------------- # The CLI # -------------------------------------------------------------------- bold "The tool" if [ "${GAMESTAGE_SKIP_CLI:-0}" = "1" ]; then skip "CLI (GAMESTAGE_SKIP_CLI=1)" else # Downloaded rather than piped into a shell, and left on disk: nothing here # executes on arrival, and the path is printed so a creator knows what landed # and can remove it. The file is minified (GS-196), so the promise is about # arrival rather than legibility, and it is the promise that mattered. mkdir -p "$TARGET_DIR" || fail "Could not create $TARGET_DIR" if curl -fsSL "$BASE/cli" -o "$TARGET_DIR/gamestage.mjs"; then ok "the CLI, one file at $TARGET_DIR/gamestage.mjs" else fail "Could not download the CLI from $BASE/cli" fi # The command itself. # # Everything we publish says `gamestage`, so the install has to provide it. # It used to leave `gamestage.mjs` in the current directory and tell you to # run `node gamestage.mjs`, which meant an agent onboarded by this script # failed on the first command in our own documentation. # # The shim records the Node it found rather than only calling `node`, because # a creator whose Node comes from a version manager has it on PATH in their # login shell and not necessarily anywhere else. The PATH lookup is tried # first so a later Node upgrade is picked up, and the recorded one is the # fallback. NODE_BIN=$(command -v node) BIN_DIR="${GAMESTAGE_BIN_DIR:-}" if [ -z "$BIN_DIR" ]; then for candidate in "$HOME/.local/bin" "/usr/local/bin"; do if [ -d "$candidate" ] && [ -w "$candidate" ]; then BIN_DIR="$candidate"; break; fi done fi [ -n "$BIN_DIR" ] || BIN_DIR="$HOME/.local/bin" if mkdir -p "$BIN_DIR" 2>/dev/null && cat > "$BIN_DIR/gamestage" </dev/null 2>&1; then exec node "$TARGET_DIR/gamestage.mjs" "\$@" fi exec "$NODE_BIN" "$TARGET_DIR/gamestage.mjs" "\$@" SHIM then chmod +x "$BIN_DIR/gamestage" 2>/dev/null || true ok "the gamestage command ($BIN_DIR/gamestage)" # Saying "it is installed" when the shell cannot find it is the same defect # this ticket is about, so check rather than assume. case ":$PATH:" in *":$BIN_DIR:"*) ON_PATH=1 ;; *) ON_PATH=0 ;; esac if [ "$ON_PATH" != "1" ]; then warn "$BIN_DIR is not on your PATH, so \`gamestage\` will not be found yet." dim " Add this line to your shell profile, then open a new terminal:" echo printf " export PATH=\"%s:\$PATH\"\n" "$BIN_DIR" echo dim " Until then, run it as: $TARGET_DIR/gamestage.mjs" fi else warn "Could not write $BIN_DIR/gamestage." dim " Run the CLI directly instead: node $TARGET_DIR/gamestage.mjs start" fi fi echo # -------------------------------------------------------------------- # The agents # -------------------------------------------------------------------- bold "Your coding agents" WIRED=0 # Writes the pack to a path, preserving anything already there that is not ours. # The markers matter: a host file like AGENTS.md or GEMINI.md belongs to the # developer and may hold instructions for their own project, so this replaces # its own block rather than the file. write_block() { local path="$1" label="$2" local dir; dir=$(dirname "$path") mkdir -p "$dir" || { warn "could not create $dir"; return 1; } local begin="" local end="" if [ -f "$path" ] && grep -qF "$begin" "$path" 2>/dev/null; then # Replace in place with node, because sed's in-place flag differs between # BSD and GNU and this script runs on both. node -e ' const fs = require("fs"); const [path, begin, end, body] = process.argv.slice(1); const existing = fs.readFileSync(path, "utf8"); const from = existing.indexOf(begin); const to = existing.indexOf(end); if (from === -1 || to === -1 || to < from) process.exit(1); const next = existing.slice(0, from) + begin + "\n" + body + "\n" + existing.slice(to); fs.writeFileSync(path, next); ' "$path" "$begin" "$end" "$PACK" || { warn "could not update $path"; return 1; } ok "$label updated ($path)" else { [ -f "$path" ] && printf '\n'; printf '%s\n%s\n%s\n' "$begin" "$PACK" "$end"; } >> "$path" \ || { warn "could not write $path"; return 1; } ok "$label ($path)" fi WIRED=$((WIRED + 1)) } # Claude Code gets a skill rather than an always-loaded file: it is the one host # with a real on-demand mechanism, so the pack costs no context until a task # actually involves Gamestage. wire_claude_code() { [ "${GAMESTAGE_SKIP_CLAUDE_CODE:-0}" = "1" ] && { skip "Claude Code (skipped)"; return; } local detected=0 command -v claude >/dev/null 2>&1 && detected=1 [ -d "$HOME/.claude" ] && detected=1 [ "$detected" = "0" ] && { skip "Claude Code (not detected)"; return; } ask "Install the Gamestage skill for Claude Code?" "Y" || { skip "Claude Code (declined)"; return; } local dir="$HOME/.claude/skills/gamestage" mkdir -p "$dir" || { warn "could not create $dir"; return; } { printf -- '---\n' printf 'name: gamestage\n' printf 'description: "Take a browser game to production with Gamestage, which moves answers, scoring and settlement behind a server the player cannot edit. Use when someone wants to port or build a browser game with Gamestage, when a gamestage.yaml is present, or when Gamestage is mentioned."\n' printf 'version: %s\n' "$SKILL_VERSION" printf -- '---\n\n' printf '%s\n' "$PACK" } > "$dir/SKILL.md" || { warn "could not write $dir/SKILL.md"; return; } ok "Claude Code skill ($dir/SKILL.md), version $SKILL_VERSION" WIRED=$((WIRED + 1)) } # Codex reads AGENTS.md, which is also the cross-host convention worth betting # on: where a host has no native mechanism, this file is the fallback. wire_codex() { [ "${GAMESTAGE_SKIP_CODEX:-0}" = "1" ] && { skip "Codex (skipped)"; return; } local detected=0 command -v codex >/dev/null 2>&1 && detected=1 [ -d "$HOME/.codex" ] && detected=1 [ "$detected" = "0" ] && { skip "Codex (not detected)"; return; } ask "Add Gamestage to Codex's AGENTS.md?" "Y" || { skip "Codex (declined)"; return; } write_block "$HOME/.codex/AGENTS.md" "Codex" } wire_gemini() { [ "${GAMESTAGE_SKIP_GEMINI:-0}" = "1" ] && { skip "Gemini CLI (skipped)"; return; } local detected=0 command -v gemini >/dev/null 2>&1 && detected=1 [ -d "$HOME/.gemini" ] && detected=1 [ "$detected" = "0" ] && { skip "Gemini CLI (not detected)"; return; } ask "Add Gamestage to Gemini's GEMINI.md?" "Y" || { skip "Gemini CLI (declined)"; return; } write_block "$HOME/.gemini/GEMINI.md" "Gemini CLI" } wire_cursor() { [ "${GAMESTAGE_SKIP_CURSOR:-0}" = "1" ] && { skip "Cursor (skipped)"; return; } local detected=0 command -v cursor >/dev/null 2>&1 && detected=1 [ -d "$HOME/.cursor" ] && detected=1 [ "$detected" = "0" ] && { skip "Cursor (not detected)"; return; } ask "Add a Gamestage rule for Cursor?" "Y" || { skip "Cursor (declined)"; return; } write_block "$HOME/.cursor/rules/gamestage.md" "Cursor" } wire_claude_code wire_codex wire_gemini wire_cursor echo if [ "$WIRED" = "0" ]; then bold "No coding agent found here" dim " The CLI works on its own. To onboard an agent later, rerun this," dim " or point it at $BASE/agent.md yourself." echo bold "Next" dim " Run it yourself:" echo printf " gamestage start\n" else bold "Next" dim " Ask your agent to take your game to production with Gamestage," dim " or run it yourself:" echo printf " gamestage start\n" fi echo dim " Full entry point $BASE/start.md" dim " Rerun anytime curl -fsSL $BASE/install | bash"