Files
ryaneggzandClaude Opus 4.7 ddbf2d504a task: remove dead config.example.json seed template
config.example.json was the tracked seed for the gitignored config.json,
but the base ships zero compose overlays and every reader (Makefile,
install.sh, banner.sh) tolerates a missing config.json via jq fallbacks.
Remove the file and its now-dead seeding block in install.sh.

Closes #323

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 01:38:42 +00:00

431 lines
20 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
# Surface silent set -e exits — without this, any non-zero return mid-script
# kills bash with no `ERROR:` line and the user is left staring at a prompt
# wondering why install bailed.
trap 'printf "\n\033[0;31mERROR:\033[0m install.sh aborted (exit %s) at line %s: %s\n" "$?" "$LINENO" "$BASH_COMMAND" >&2' ERR
# ─── Colours / helpers ───────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
banner() { printf "\n${CYAN}==> %s${NC}\n" "$*"; }
ok() { printf "${GREEN} ✓ %s${NC}\n" "$*"; }
warn() { printf "${YELLOW}WARN: %s${NC}\n" "$*" >&2; }
die() { printf "${RED}ERROR: %s${NC}\n" "$*" >&2; exit 1; }
# ─── normalize_gh_slug: strip GitHub URL decoration → owner/repo ─────
# Handles four forms:
# https://github.com/owner/repo.git → owner/repo
# https://github.com/owner/repo → owner/repo
# git@github.com:owner/repo.git → owner/repo
# git@github.com:owner/repo → owner/repo
normalize_gh_slug() {
local _url="$1"
# Strip https://github.com/ prefix (case-insensitive via tr would add
# complexity; GitHub slugs are case-insensitive but we preserve original case
# for comparison — OH_GITHUB_REPO is user-supplied already normalized).
_url="${_url#https://github.com/}"
# Strip git@github.com: prefix
_url="${_url#git@github.com:}"
# Strip trailing .git suffix
_url="${_url%.git}"
printf '%s' "$_url"
}
# ─── prompt_input: env-var > /dev/tty > default fallback > die ──────
# Args: $1=varname, $2=prompt msg, $3=default (optional), $4=-s for secret
# Reads from /dev/tty so curl-piped installs still get keystrokes (stdin
# is the script source in pipe mode, not the user's keyboard).
prompt_input() {
local __var="$1"; local __msg="$2"; local __default="${3:-}"; local __secret="${4:-}"
if [ -n "${!__var:-}" ]; then
ok "Using $__var from environment"
return 0
fi
if [ -r /dev/tty ]; then
if [ -n "$__default" ]; then
printf " %s [%s]: " "$__msg" "$__default"
else
printf " %s: " "$__msg"
fi
local reply
if [ "$__secret" = "-s" ]; then
read -rs reply </dev/tty || reply=""
printf "\n"
else
read -r reply </dev/tty || reply=""
fi
printf -v "$__var" '%s' "${reply:-$__default}"
else
if [ -n "$__default" ]; then
printf -v "$__var" '%s' "$__default"
warn "$__var defaulted (no TTY available)"
else
die "$__var required but no TTY available. Set ${__var}=<value> as env var and re-run."
fi
fi
}
# ─── prompt_yn: yes/no prompt with /dev/tty discipline ──────────────
# Args: $1=prompt msg, $2=default ("y" or "n")
# Returns 0 for yes, 1 for no.
# Respects ASSUME_YES=true and ASSUME_NO=true.
prompt_yn() {
local __msg="$1"; local __default="${2:-y}"
if [ "${ASSUME_YES:-false}" = true ]; then
return 0
fi
if [ "${ASSUME_NO:-false}" = true ]; then
return 1
fi
local __bracket
if [ "$__default" = "y" ] || [ "$__default" = "Y" ]; then
__bracket="[Y/n]"
else
__bracket="[y/N]"
fi
if [ -r /dev/tty ]; then
local __reply
printf " %s %s: " "$__msg" "$__bracket"
read -r __reply </dev/tty || __reply=""
__reply="${__reply:-$__default}"
case "$__reply" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
else
warn "No TTY available — using default for: $__msg"
case "$__default" in
[Yy]*) return 0 ;;
*) return 1 ;;
esac
fi
}
# ─── Help ────────────────────────────────────────────────────────────
print_help() {
cat <<HELPEOF
Open Harness — Installer
Usage:
curl -fsSL https://oh.mifune.dev/install.sh | bash [-s -- <flags>]
./scripts/install.sh [<flags>]
Clones (or pulls) the repo into ~/.openharness, prepares host auth dirs,
and brings up the sandbox via 'docker compose'. Per SPEC v0.7, docker
compose is the canonical substrate; the legacy 'oh' CLI is removed.
Flags:
-y, --yes Accept default at any prompt.
-n, --no Decline at any prompt (abort path).
-h, --help Show this help and exit.
Env vars:
OH_INSTALL_REF Git ref (tag/SHA) to clone instead of main
OH_ASSUME_YES Set to 1 for --yes
SANDBOX_NAME Skip the "Container name" prompt
OH_GITHUB_REPO GitHub repo to clone (default: ryaneggz/open-harness)
OH_GITHUB_REF Git ref to clone (alias: OH_INSTALL_REF)
Examples:
curl -fsSL https://oh.mifune.dev/install.sh | bash
curl -fsSL https://oh.mifune.dev/install.sh | bash -s -- --yes
./scripts/install.sh
OH_GITHUB_REPO=myorg/my-harness curl -fsSL \
https://raw.githubusercontent.com/myorg/my-harness/main/scripts/install.sh | bash
HELPEOF
}
# ─── Arg parsing ─────────────────────────────────────────────────────
ASSUME_YES="${OH_ASSUME_YES:+true}"; ASSUME_YES="${ASSUME_YES:-false}"
ASSUME_NO=false
while [ $# -gt 0 ]; do
case "$1" in
-y|--yes)
ASSUME_YES=true
;;
-n|--no)
ASSUME_NO=true
;;
-h|--help)
print_help; exit 0
;;
--yes=*|--no=*)
die "Flags do not take =value (got '$1'). Use space-separated form, e.g. '--yes'."
;;
*)
warn "Unknown argument: $1 (ignoring)"
;;
esac
shift
done
[ "$ASSUME_YES" = true ] && [ "$ASSUME_NO" = true ] && die "--yes and --no are mutually exclusive."
# ─── Banner ──────────────────────────────────────────────────────────
printf "\n${CYAN}╔══════════════════════════════════════╗${NC}\n"
printf "${CYAN}║ Open Harness — Installer ║${NC}\n"
printf "${CYAN}╚══════════════════════════════════════╝${NC}\n\n"
# ─── 1. Check Docker ────────────────────────────────────────────────
banner "Checking Docker"
if ! command -v docker >/dev/null 2>&1; then
die "Docker is not installed. Install Docker from: https://docs.docker.com/get-docker/"
fi
if ! docker compose version >/dev/null 2>&1; then
die "Docker Compose plugin is not installed. Install it from: https://docs.docker.com/compose/install/"
fi
ok "Docker $(docker --version | awk '{print $3}') — OK"
ok "Docker Compose $(docker compose version --short) — OK"
# ─── 2. Check git ────────────────────────────────────────────────────
banner "Checking git"
if ! command -v git >/dev/null 2>&1; then
die "git is not installed. Install git from: https://git-scm.com"
fi
ok "git $(git --version | awk '{print $3}') — OK"
# ─── 3. Resolve repo directory ────────────────────────────────────────
banner "Resolving repository"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)"
# install.sh lives at scripts/install.sh; the repo root is its parent.
REPO_CANDIDATE="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)"
if [ -n "$REPO_CANDIDATE" ] && [ -f "$REPO_CANDIDATE/.devcontainer/docker-compose.yml" ] && [ -f "$REPO_CANDIDATE/scripts/install.sh" ]; then
REPO_DIR="$REPO_CANDIDATE"
ok "Using local repo: $REPO_DIR"
else
# Install target is ~/.openharness (hidden dir, no collision with repo subdirs).
# OLD_REPO is the post-#200 path that was briefly the install target.
OLD_REPO="$HOME/openharness"
REPO_DIR="$HOME/.openharness"
# ── OH_GITHUB_REPO: fork-parameterized clone source ───────────────────
OH_GITHUB_REPO="${OH_GITHUB_REPO:-ryaneggz/open-harness}"
if [[ ! "$OH_GITHUB_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
die "OH_GITHUB_REPO must be <owner>/<repo>: got '$OH_GITHUB_REPO'"
fi
if [ "$OH_GITHUB_REPO" != "ryaneggz/open-harness" ]; then
warn "Cloning from fork: $OH_GITHUB_REPO"
fi
# ── OH_GITHUB_REF: alias for OH_INSTALL_REF (OH_GITHUB_REF wins if set) ─
if [ -n "${OH_GITHUB_REF:-}" ] && [ -n "${OH_INSTALL_REF:-}" ] && [ "$OH_GITHUB_REF" != "$OH_INSTALL_REF" ]; then
warn "OH_GITHUB_REF and OH_INSTALL_REF both set with different values; OH_GITHUB_REF wins."
fi
OH_GITHUB_REF="${OH_GITHUB_REF:-${OH_INSTALL_REF:-}}"
# ── Population classification + migration ─────────────────────────────
__HAS_OLD=0; __HAS_NEW=0
[ -d "$OLD_REPO/.git" ] && __HAS_OLD=1
if [ -d "$REPO_DIR" ] && [ ! -d "$REPO_DIR/.git" ]; then
die "~/.openharness exists but is not a git clone. Inspect and remove it, then re-run."
fi
[ -d "$REPO_DIR/.git" ] && __HAS_NEW=1
if [ "$__HAS_OLD" = "1" ] && [ "$__HAS_NEW" = "1" ]; then
# ── B+C collision: both paths exist ───────────────────────────────────
__OLD_DIRTY=0; __NEW_DIRTY=0
git -C "$OLD_REPO" diff --quiet 2>/dev/null && git -C "$OLD_REPO" diff --cached --quiet 2>/dev/null || __OLD_DIRTY=1
git -C "$REPO_DIR" diff --quiet 2>/dev/null && git -C "$REPO_DIR" diff --cached --quiet 2>/dev/null || __NEW_DIRTY=1
if [ "$__OLD_DIRTY" = "1" ] && [ "$__NEW_DIRTY" = "0" ]; then
# OLD has changes; keep OLD as active (rename to new), archive NEW
__ARCHIVE="${REPO_DIR}.legacy.$(date +%Y%m%d%H%M%S)"
mv "$REPO_DIR" "$__ARCHIVE"
warn "Archived $REPO_DIR → $__ARCHIVE"
git -C "$OLD_REPO" stash push -u -m "install.sh: pre-rename autostash" 2>/dev/null || true
mv "$OLD_REPO" "$REPO_DIR"
ok "Migrated $OLD_REPO → $REPO_DIR (had local changes — autostashed)"
elif [ "$__NEW_DIRTY" = "1" ] && [ "$__OLD_DIRTY" = "0" ]; then
# NEW has changes; keep NEW, archive OLD
__ARCHIVE="${OLD_REPO}.legacy.$(date +%Y%m%d%H%M%S)"
mv "$OLD_REPO" "$__ARCHIVE"
warn "Archived $OLD_REPO → $__ARCHIVE"
ok "Keeping $REPO_DIR (had local changes)"
else
# Neither (or both) dirty: prefer OLD_REPO (post-#200, more recent install)
__ARCHIVE="${REPO_DIR}.legacy.$(date +%Y%m%d%H%M%S)"
mv "$REPO_DIR" "$__ARCHIVE"
warn "Archived $REPO_DIR → $__ARCHIVE"
git -C "$OLD_REPO" stash push -u -m "install.sh: pre-rename autostash" 2>/dev/null || true
mv "$OLD_REPO" "$REPO_DIR"
ok "Migrated $OLD_REPO → $REPO_DIR"
fi
unset __OLD_DIRTY __NEW_DIRTY __ARCHIVE
elif [ "$__HAS_OLD" = "1" ] && [ "$__HAS_NEW" = "0" ]; then
# ── Population B: ~/openharness only — rename to ~/.openharness ────────
git -C "$OLD_REPO" stash push -u -m "install.sh: pre-rename autostash" 2>/dev/null || true
mv "$OLD_REPO" "$REPO_DIR"
ok "Migrated $OLD_REPO → $REPO_DIR"
fi
# Population C (~/.openharness only) and A (neither) fall through to the
# pull / clone logic below.
unset __HAS_OLD __HAS_NEW OLD_REPO
if [ -d "$REPO_DIR/.git" ]; then
# ── US-003: Validate remote origin matches OH_GITHUB_REPO ─────────────
__ORIGIN_RAW="$(git -C "$REPO_DIR" remote get-url origin 2>/dev/null || true)"
__ORIGIN_SLUG="$(normalize_gh_slug "${__ORIGIN_RAW:-}")"
__EXPECTED_SLUG="$(normalize_gh_slug "$OH_GITHUB_REPO")"
if [ -z "$__ORIGIN_RAW" ] || [ "$__ORIGIN_SLUG" != "$__EXPECTED_SLUG" ]; then
warn "Existing clone origin (${__ORIGIN_RAW:-<none>}) does not match OH_GITHUB_REPO=${OH_GITHUB_REPO}."
warn "Skipping pull. To switch sources:"
warn " 1. Back up customizations: cp ~/.openharness/.devcontainer/.env /tmp/oh.env.bak"
warn " 2. Remove the clone: rm -rf ~/.openharness"
warn " 3. Re-run with the desired OH_GITHUB_REPO and (if needed) OH_GITHUB_REF."
warn " Note: rm -rf also discards any local changes and pinned OH_INSTALL_REF state."
else
# ── Pull (clean tree only) ─────────────────────────────────────────────
if git -C "$REPO_DIR" diff --quiet 2>/dev/null && git -C "$REPO_DIR" diff --cached --quiet 2>/dev/null; then
printf " Repository exists — pulling latest changes...\n"
git -C "$REPO_DIR" pull --ff-only
ok "Repository updated: $REPO_DIR"
else
warn "Local changes detected in $REPO_DIR — skipping git pull. Stash or commit them, then re-run if you want the latest main."
fi
fi
unset __ORIGIN_RAW __ORIGIN_SLUG __EXPECTED_SLUG
else
# ── Population A: fresh clone ──────────────────────────────────────────
if [ -n "$OH_GITHUB_REF" ]; then
git clone --branch "$OH_GITHUB_REF" "https://github.com/${OH_GITHUB_REPO}.git" "$REPO_DIR"
ok "Repository cloned at ref '$OH_GITHUB_REF': $REPO_DIR"
else
git clone "https://github.com/${OH_GITHUB_REPO}.git" "$REPO_DIR"
ok "Repository cloned: $REPO_DIR"
fi
fi
# ── Shell CWD notice (for B migration) ────────────────────────────────
# If the user's shell was open in ~/openharness, that path no longer exists.
printf "\n"
warn "If your current shell is still in ~/openharness, run: cd ~/.openharness"
printf "\n"
fi
cd "$REPO_DIR"
# ─── 4. Configure sandbox ────────────────────────────────────────────
banner "Configuring sandbox"
DEFAULT_NAME=$(basename "$REPO_DIR")
prompt_input SANDBOX_NAME "Container name" "$DEFAULT_NAME"
ok "Name: $SANDBOX_NAME"
# Single-quoting handles names containing shell metacharacters; literal
# single quotes are escaped.
mkdir -p "$REPO_DIR/.devcontainer"
if [ -f "$REPO_DIR/.devcontainer/.env" ]; then
ok "Existing .devcontainer/.env preserved (delete it to regenerate)"
else
# Detect host values for pre-population
__TZ="$(cat /etc/timezone 2>/dev/null || echo America/Denver)"
__GIT_NAME="$(git config --get user.name 2>/dev/null || true)"
__GIT_EMAIL="$(git config --get user.email 2>/dev/null || true)"
__SN_ESC="${SANDBOX_NAME//\'/\'\\\'\'}"
cat > "$REPO_DIR/.devcontainer/.env" <<ENVEOF
# ─── Open Harness — Generated environment ─────────────────────────────────
# Generated by install.sh. Edit freely; rerun \`make destroy && make sandbox\`
# to apply changes.
# ─── Sandbox ─────────────────────────────────────────────────────────────────
SANDBOX_NAME='$__SN_ESC'
# ─── Timezone ────────────────────────────────────────────────────────────────
TZ=$__TZ
# ─── GitHub ──────────────────────────────────────────────────────────────────
# Personal access token. Entrypoint runs gh auth login + setup-git on boot.
# Leave blank to authenticate manually inside the sandbox.
GH_TOKEN=
# ─── Heartbeat ───────────────────────────────────────────────────────────────
HEARTBEAT_AGENT=claude
# ─── Agent Browser ───────────────────────────────────────────────────────────
# Set true to install Chromium (~1 GB, +3 min build).
INSTALL_AGENT_BROWSER=false
ENVEOF
# Append optional git identity lines only when non-empty
if [ -n "$__GIT_NAME" ]; then
__GN_ESC="${__GIT_NAME//\'/\'\\\'\'}"
printf "GIT_USER_NAME='%s'\n" "$__GN_ESC" >> "$REPO_DIR/.devcontainer/.env"
unset __GN_ESC
fi
if [ -n "$__GIT_EMAIL" ]; then
__GE_ESC="${__GIT_EMAIL//\'/\'\\\'\'}"
printf "GIT_USER_EMAIL='%s'\n" "$__GE_ESC" >> "$REPO_DIR/.devcontainer/.env"
unset __GE_ESC
fi
unset __SN_ESC __TZ __GIT_NAME __GIT_EMAIL
ok "Wrote .devcontainer/.env"
# ─── Auto-detect host gh token ────────────────────────────────────────
__GH_AUTOCONFIGURED=0
if command -v gh >/dev/null 2>&1; then
if __GH_TOKEN_RAW="$(gh auth token 2>/dev/null)" && [ -n "$__GH_TOKEN_RAW" ]; then
banner "Detected host gh token"
if prompt_yn "Share host gh token with sandbox? (skips in-sandbox 'gh auth login')" y; then
__GHT_ESC="${__GH_TOKEN_RAW//\'/\'\\\'\'}"
printf "GH_TOKEN='%s'\n" "$__GHT_ESC" >> "$REPO_DIR/.devcontainer/.env"
ok "Wrote GH_TOKEN to .devcontainer/.env"
__GH_AUTOCONFIGURED=1
unset __GHT_ESC
else
ok "Skipped — you'll run 'gh auth login' inside the sandbox"
fi
unset __GH_TOKEN_RAW
fi
fi
fi
# ─── 5. Bring up the sandbox ─────────────────────────────────────────
banner "Building and starting sandbox"
printf "${CYAN}==> Building image — ~10 min on cold cache, ~30s on warm cache. Compose output below.${NC}\n"
# Base ships with zero in-tree overlays. Downstream packs (e.g. Pi
# extensions, BYO harness packs) can register their own overlays by
# appending paths to composeOverrides[] in config.json.
COMPOSE_FILES="-f .devcontainer/docker-compose.yml"
if command -v jq >/dev/null 2>&1 && [ -f "$REPO_DIR/config.json" ]; then
while IFS= read -r override; do
[ -n "$override" ] && COMPOSE_FILES="$COMPOSE_FILES -f $override"
done < <(jq -r '.composeOverrides[]?' "$REPO_DIR/config.json")
fi
docker compose $COMPOSE_FILES up -d --build
ok "Sandbox '$SANDBOX_NAME' started"
# ─── Next Steps ──────────────────────────────────────────────────────
printf "\n${GREEN}Installation complete!${NC}\n\n"
printf " ${CYAN}Configuration${NC}\n"
printf " ──────────────────────────────────────\n"
printf " ${CYAN}.devcontainer/.env${NC} — sandbox name, timezone, optional GH_TOKEN, etc.\n"
printf " Defaults work; edit if you want to customize.\n"
printf "\n"
printf " ${CYAN}Lifecycle (from %s)${NC}\n" "$REPO_DIR"
printf " ──────────────────────────────────────\n"
printf " cd %s\n" "$REPO_DIR"
printf " make shell # enter the sandbox\n"
printf " # then pick your agent: claude, codex, opencode, pi, ...\n"
printf " make help # all targets\n"
printf " make destroy # tear down later\n"
printf "\n"
printf " ${CYAN}VS Code (alternative)${NC}\n"
printf " ──────────────────────────────────────\n"
printf " Open the repo → Cmd+Shift+P → \"Reopen in Container\"\n"
if [ "${__GH_AUTOCONFIGURED:-0}" = "0" ]; then
printf "\n"
printf " ${CYAN}First run inside the sandbox${NC}\n"
printf " ──────────────────────────────────────\n"
printf " gh auth login && gh auth setup-git\n"
fi
printf "\n"