#!/usr/bin/env bash # # OpenAgents Control — Architect Fork · installer # ============================================================================ # All-in-one bootstrap: installs OpenCode (the runtime) when it is missing, then # the OAC Architect Fork — the agents, subagents, commands, context, skills and # tools — into a project's .opencode/ directory or the global # ~/.config/opencode/ directory. # # Adapted from Darren Hinde's OpenAgents Control installer (MIT). # upstream: https://github.com/darrenhinde/OpenAgentsControl # fork: https://git.lohmar.co.uk/cclohmar/NewOpenAgents # # Unlike upstream, this fork ships the COMPLETE .opencode payload at the repo # root (no registry.json), so the installer syncs the whole payload with # collision handling instead of resolving per-component from a registry. # # Usage: # ./install.sh # interactive: choose location # ./install.sh --local # into ./.opencode # ./install.sh --global # into ~/.config/opencode # ./install.sh --install-dir PATH # into an exact path # ./install.sh --source DIR # install from a local clone # ./install.sh --global --yes # non-interactive, skip existing files # ./install.sh --global --force # non-interactive, overwrite existing # ./install.sh --no-opencode # install the fork only (skip OpenCode) # ./install.sh --dry-run # show the plan, change nothing # # Environment: # OAC_TOKEN Forgejo token with repo:read (for a private remote fetch) # OPENCODE_BRANCH branch/tag to install from (default: main) # OPENCODE_INSTALL_DIR alternative to --install-dir # ============================================================================ set -euo pipefail # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- REPO_HOST="git.lohmar.co.uk" REPO_OWNER="cclohmar" REPO_NAME="NewOpenAgents" REPO_URL="https://${REPO_HOST}/${REPO_OWNER}/${REPO_NAME}" BRANCH="${OPENCODE_BRANCH:-main}" TOKEN="${OAC_TOKEN:-}" # The payload that makes up an install (paths relative to the repo root) PAYLOAD_DIRS="agent command context skills tool" PAYLOAD_FILES="env.example package.json" # OpenCode runtime — the official installer places the binary in $HOME/.opencode/bin OPENCODE_INSTALLER_URL="${OPENCODE_INSTALLER_URL:-https://opencode.ai/v2/install}" OPENCODE_BIN_DIR="${HOME}/.opencode/bin" SERVICE_UNIT_NAME="opencode.service" # --------------------------------------------------------------------------- # Colours (only when writing to a terminal) # --------------------------------------------------------------------------- if [ -t 1 ]; then RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' BLUE='\033[0;34m'; CYAN='\033[0;36m'; MAGENTA='\033[0;35m' BOLD='\033[1m'; NC='\033[0m' else RED=''; GREEN=''; YELLOW=''; BLUE=''; CYAN=''; MAGENTA=''; BOLD=''; NC='' fi info() { printf "${BLUE}ℹ${NC} %s\n" "$1"; } success() { printf "${GREEN}✓${NC} %s\n" "$1"; } warn() { printf "${YELLOW}⚠${NC} %s\n" "$1"; } error() { printf "${RED}✗${NC} %s\n" "$1" >&2; } step() { printf "\n${MAGENTA}${BOLD}▶${NC} %s\n\n" "$1"; } die() { error "$1"; exit 1; } # Prompting — works even under `curl … | bash`, where stdin is the script pipe. # We prompt on the controlling terminal instead of relying on stdin. have_tty() { [ -t 1 ] || return 1 [ -t 0 ] && return 0 [ -r /dev/tty ] } tty_read() { local __var="$1" __val="" if [ -t 0 ] || [ ! -r /dev/tty ]; then read -r __val || true else read -r __val /dev/null 2>&1 || die "tar is required but not installed" if [ -z "$SOURCE" ] && [ -z "$TOKEN" ]; then command -v git >/dev/null 2>&1 || die "git is required (or pass --token and install curl)" fi if [ -n "$TOKEN" ]; then command -v curl >/dev/null 2>&1 || die "curl is required when using --token" fi # --------------------------------------------------------------------------- # OpenCode runtime (all-in-one bootstrap) # --------------------------------------------------------------------------- have_opencode() { command -v opencode >/dev/null 2>&1 && return 0 [ -x "${OPENCODE_BIN_DIR}/opencode" ] && return 0 return 1 } opencode_path() { if command -v opencode >/dev/null 2>&1; then command -v opencode else printf '%s' "${OPENCODE_BIN_DIR}/opencode" fi } opencode_on_path() { command -v opencode >/dev/null 2>&1; } # The official installer writes the PATH line to the shell rc but never activates # it. Report that, and offer to make `opencode` work immediately via a symlink. activate_opencode() { opencode_on_path && return 0 have_opencode || return 0 warn "OpenCode is installed at ${OPENCODE_BIN_DIR} but is not on your PATH yet." if [ "$OPENCODE_NO_PATH" = 1 ]; then info "PATH was left untouched (--opencode-no-path). Add it yourself: export PATH=\"${OPENCODE_BIN_DIR}:\$PATH\"" return 0 fi local rc="" case "$(basename "${SHELL:-bash}")" in bash) [ -f "$HOME/.bashrc" ] && rc="$HOME/.bashrc" ;; zsh) [ -f "$HOME/.zshrc" ] && rc="$HOME/.zshrc" ;; *) [ -f "$HOME/.profile" ] && rc="$HOME/.profile" ;; esac if [ -n "$rc" ] && grep -q "${OPENCODE_BIN_DIR}" "$rc" 2>/dev/null; then info "A PATH line is already in ${rc} — run source ${rc} or open a new terminal." else info "Add it to your shell profile: export PATH=\"${OPENCODE_BIN_DIR}:\$PATH\"" fi if have_tty; then printf "${BOLD}Make 'opencode' available now (symlink into /usr/local/bin)?${NC} [y/N]: " ans=""; tty_read ans case "$ans" in [Yy]*) local sudo="" [ "$(id -u)" != "0" ] && sudo="sudo" if $sudo ln -sf "${OPENCODE_BIN_DIR}/opencode" /usr/local/bin/opencode 2>/dev/null; then success "Linked /usr/local/bin/opencode — 'opencode' now works in this and every shell." else warn "Could not create the symlink — use: export PATH=\"${OPENCODE_BIN_DIR}:\$PATH\"" fi ;; esac fi return 0 } ensure_opencode() { if [ "$DRY_RUN" = 1 ]; then if [ "$OPENCODE_MODE" = "no" ]; then info "[dry-run] would skip OpenCode (--no-opencode)" elif have_opencode; then info "[dry-run] OpenCode already present — $(opencode_path)" else info "[dry-run] would install OpenCode from ${OPENCODE_INSTALLER_URL}" fi return 0 fi if [ "$OPENCODE_MODE" = "no" ]; then info "Skipping the OpenCode install (--no-opencode)" return 0 fi if have_opencode && [ "$OPENCODE_MODE" != "yes" ]; then success "OpenCode is already installed — $(opencode_path)" activate_opencode return 0 fi if [ "$OPENCODE_MODE" = "auto" ] && ! have_opencode && have_tty; then printf "${BOLD}OpenCode is not installed. Install it now?${NC} [Y/n]: " ans="" tty_read ans case "$ans" in [Nn]*) info "Skipping OpenCode."; return 0 ;; esac fi command -v curl >/dev/null 2>&1 || die "curl is required to install OpenCode" step "Installing OpenCode…" local tmp tmp="$(mktemp 2>/dev/null || mktemp -t oac)" if ! curl -fsSL "$OPENCODE_INSTALLER_URL" -o "$tmp"; then rm -f "$tmp" die "Could not download the OpenCode installer (${OPENCODE_INSTALLER_URL})" fi local ok=0 if [ -n "$OPENCODE_VERSION" ] && [ "$OPENCODE_NO_PATH" = 1 ]; then bash "$tmp" --no-modify-path --version "$OPENCODE_VERSION" && ok=1 elif [ -n "$OPENCODE_VERSION" ]; then bash "$tmp" --version "$OPENCODE_VERSION" && ok=1 elif [ "$OPENCODE_NO_PATH" = 1 ]; then bash "$tmp" --no-modify-path && ok=1 else bash "$tmp" && ok=1 fi rm -f "$tmp" [ "$ok" = 1 ] || die "OpenCode installation failed" if have_opencode; then success "OpenCode installed — $(opencode_path)" else warn "The OpenCode installer finished, but the binary was not found at ${OPENCODE_BIN_DIR}/opencode." fi activate_opencode return 0 } # --------------------------------------------------------------------------- # Web UI service (systemd) — offered interactively # --------------------------------------------------------------------------- is_systemd() { if [ "${OAC_FORCE_SYSTEMD:-}" = "1" ]; then return 0; fi # override for containers/CI if [ "${OAC_FORCE_SYSTEMD:-}" = "0" ]; then return 1; fi command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] } setup_service() { [ "$SERVICE_MODE" = "no" ] && { info "Skipping the web UI service (--no-service)"; return 0; } have_opencode || { info "OpenCode is not installed — skipping the web UI service."; return 0; } local interactive=0 if [ "$SERVICE_MODE" = "auto" ]; then if have_tty; then interactive=1 else return 0 # non-interactive: only set up the service when explicitly asked fi fi if [ "$interactive" = 1 ]; then printf "\n${BOLD}Set up the OpenCode web UI as a systemd service?${NC} [y/N]: " ans=""; tty_read ans case "$ans" in [Yy]*) ;; *) info "Skipping the web UI service."; return 0 ;; esac fi if ! is_systemd; then warn "systemd not detected — cannot install a service here." info "Start the web UI manually: $(opencode_path) serve --hostname 127.0.0.1 --port 4096" return 0 fi local host port enable dir u g oc_bin v oc_bin="$(opencode_path)" host="127.0.0.1" port="4096" enable=1 dir="$(pwd)" u="$(id -un)" g="$(id -gn)" if [ "$interactive" = 1 ]; then printf " Bind address [%s]: " "$host"; v=""; tty_read v; [ -n "$v" ] && host="$v" printf " Port [%s]: " "$port"; v=""; tty_read v; [ -n "$v" ] && port="$v" printf " Working directory [%s]: " "$dir"; v=""; tty_read v; [ -n "$v" ] && dir="$v" printf " Start now and enable on boot? [Y/n]: "; v=""; tty_read v case "$v" in [Nn]*) enable=0 ;; esac fi local unit unit="$(mktemp 2>/dev/null || mktemp -t oac-unit)" cat > "$unit" </dev/null; then rm -f "$unit" warn "Could not write /etc/systemd/system/${SERVICE_UNIT_NAME} (needs root)." info "Re-run with sudo, or copy the unit and run: sudo systemctl enable --now ${SERVICE_UNIT_NAME}" return 0 fi rm -f "$unit" $sudo systemctl daemon-reload 2>/dev/null || true if [ "$enable" = 1 ]; then if $sudo systemctl enable --now "$SERVICE_UNIT_NAME" 2>/dev/null; then success "Web UI service enabled — http://${host}:${port}" else warn "Unit installed; enable it later: sudo systemctl enable --now ${SERVICE_UNIT_NAME}" fi else success "Unit installed — start it with: sudo systemctl start ${SERVICE_UNIT_NAME}" fi return 0 } # --------------------------------------------------------------------------- # Resolve the source payload # --------------------------------------------------------------------------- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd)" TMP_DIR="" cleanup() { if [ -n "$TMP_DIR" ]; then rm -rf "$TMP_DIR" 2>/dev/null || true; fi; } trap cleanup EXIT INT TERM resolve_source() { # 1) explicit --source if [ -n "$SOURCE" ]; then [ -d "$SOURCE/agent" ] || die "--source '$SOURCE' does not look like the OAC repo (no agent/ dir)" SOURCE="$(cd "$SOURCE" && pwd)" info "Using local source: $SOURCE" return fi # 2) the script's own directory, when run from a clone if [ -d "$SCRIPT_DIR/agent" ] && [ -d "$SCRIPT_DIR/context" ]; then SOURCE="$SCRIPT_DIR" info "Using the clone this script lives in: $SOURCE" return fi # 3) download TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t oac)" if [ -n "$TOKEN" ]; then step "Downloading the fork (branch: ${BRANCH})…" local archive="https://${REPO_HOST}/${REPO_OWNER}/${REPO_NAME}/archive/${BRANCH}.tar.gz" curl -fsSL -H "Authorization: token ${TOKEN}" "$archive" -o "$TMP_DIR/repo.tar.gz" \ || die "Download failed. Check --token / --branch." tar -xzf "$TMP_DIR/repo.tar.gz" -C "$TMP_DIR" SOURCE="$(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d -print -quit)" [ -n "$SOURCE" ] || die "Could not find the extracted payload" success "Downloaded." else step "Cloning the fork (branch: ${BRANCH})…" git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$TMP_DIR/repo" \ || die "Clone failed. For a private repo, configure git credentials or pass --token." SOURCE="$TMP_DIR/repo" success "Cloned." fi } # --------------------------------------------------------------------------- # Resolve the install directory # --------------------------------------------------------------------------- global_path() { printf '%s/.config/opencode' "$HOME"; } choose_location_interactive() { local gp; gp="$(global_path)" echo -e "${BOLD}Where should the fork be installed?${NC}\n" echo -e " ${GREEN}1)${NC} Local — ${CYAN}./.opencode${NC} (this project only)" echo -e " ${BLUE}2)${NC} Global — ${CYAN}${gp}${NC} (every project)" echo -e " ${MAGENTA}3)${NC} Custom — enter a path" echo -e " 4) Cancel" echo printf "Enter your choice [1-4]: " tty_read choice case "$choice" in 1) INSTALL_DIR="$(pwd)/.opencode" ;; 2) INSTALL_DIR="$gp" ;; 3) printf "Enter installation path: "; tty_read custom [ -n "$custom" ] || die "No path entered" case "$custom" in "~"*) custom="${HOME}${custom#\~}" ;; esac INSTALL_DIR="$custom" ;; 4) info "Cancelled."; exit 0 ;; *) die "Invalid choice" ;; esac } resolve_install_dir() { if [ -n "$INSTALL_DIR" ]; then return fi case "$LOCATION" in local) INSTALL_DIR="$(pwd)/.opencode" ;; global) INSTALL_DIR="$(global_path)" ;; custom) die "--install-dir requires a path" ;; *) if have_tty; then choose_location_interactive else INSTALL_DIR="$(pwd)/.opencode" info "No location given — defaulting to ./.opencode" fi ;; esac } # --------------------------------------------------------------------------- # Collision strategy # --------------------------------------------------------------------------- STRATEGY="overwrite" # skip | overwrite | backup do_choose_strategy() { echo warn "Some items already exist in ${INSTALL_DIR}" echo echo -e "${BOLD}How should existing items be handled?${NC}\n" echo -e " ${GREEN}1)${NC} Skip — keep existing items, install only new ones" echo -e " ${YELLOW}2)${NC} Overwrite — replace existing items (your changes are lost)" echo -e " ${CYAN}3)${NC} Backup — back up existing items, then overwrite" echo -e " ${RED}4)${NC} Cancel" echo printf "Enter your choice [1-4]: " tty_read choice case "$choice" in 1) STRATEGY="skip" ;; 2) printf "Type 'yes' to confirm overwrite: "; tty_read c [ "$c" = "yes" ] || { info "Cancelled."; exit 0; } STRATEGY="overwrite" ;; 3) STRATEGY="backup" ;; 4) info "Cancelled."; exit 0 ;; *) die "Invalid choice" ;; esac } # --------------------------------------------------------------------------- # Install # --------------------------------------------------------------------------- BACKUP_DIR="" copy_entry() { local name="$1" kind="$2" src="$3" dest="$4" if [ -e "$dest" ]; then case "$STRATEGY" in skip) info "Skipped existing ${kind}: ${name}" return 0 ;; backup) local rel="${dest#${INSTALL_DIR}/}" mkdir -p "$BACKUP_DIR/$(dirname "$rel")" cp -R "$dest" "$BACKUP_DIR/$rel" ;; esac [ "$DRY_RUN" = 1 ] || rm -rf "$dest" fi if [ "$DRY_RUN" = 1 ]; then info "[dry-run] would install ${kind}: ${name} -> ${dest}" else cp -R "$src" "$(dirname "$dest")/" success "Installed ${kind}: ${name}" fi } perform_install() { local installed=0 skipped=0 # Scan for collisions to decide the strategy local have_collision=0 e for e in $PAYLOAD_DIRS $PAYLOAD_FILES; do if [ -e "$INSTALL_DIR/$e" ]; then have_collision=1; break; fi done if [ "$have_collision" = 1 ]; then if [ "$FORCE" = 1 ]; then STRATEGY="overwrite" elif [ "$ASSUME_YES" = 1 ]; then STRATEGY="skip" info "Non-interactive: existing items will be skipped" elif have_tty; then do_choose_strategy else STRATEGY="skip" info "Non-interactive: existing items will be skipped" fi fi if [ "$STRATEGY" = "backup" ] && [ "$DRY_RUN" != 1 ]; then BACKUP_DIR="${INSTALL_DIR}.backup.$(date +%Y%m%d-%H%M%S)" mkdir -p "$BACKUP_DIR" info "Backing up to ${BACKUP_DIR}" fi if [ "$DRY_RUN" != 1 ]; then mkdir -p "$INSTALL_DIR" fi local d f for d in $PAYLOAD_DIRS; do [ -d "$SOURCE/$d" ] || continue if [ -e "$INSTALL_DIR/$d" ] && [ "$STRATEGY" = "skip" ]; then info "Skipped existing directory: ${d}/" skipped=$((skipped + 1)) continue fi copy_entry "$d/" "directory" "$SOURCE/$d" "$INSTALL_DIR/$d" installed=$((installed + 1)) done for f in $PAYLOAD_FILES; do [ -f "$SOURCE/$f" ] || continue if [ -e "$INSTALL_DIR/$f" ] && [ "$STRATEGY" = "skip" ]; then info "Skipped existing file: ${f}" skipped=$((skipped + 1)) continue fi copy_entry "$f" "file" "$SOURCE/$f" "$INSTALL_DIR/$f" installed=$((installed + 1)) done echo if [ "$DRY_RUN" = 1 ]; then warn "Dry run complete — nothing was changed." else success "Installation complete." echo -e " Destination: ${CYAN}${INSTALL_DIR}${NC}" [ "$skipped" -gt 0 ] && echo -e " Skipped: ${CYAN}${skipped}${NC}" [ -n "$BACKUP_DIR" ] && echo -e " Backup: ${CYAN}${BACKUP_DIR}${NC}" fi return 0 } show_post_install() { step "Next steps" echo "1. Review what was installed in ${CYAN}${INSTALL_DIR}${NC}" if [ -f "$INSTALL_DIR/env.example" ]; then echo "2. (Optional) configure integrations: ${CYAN}cp ${INSTALL_DIR}/env.example .env${NC}" fi if opencode_on_path; then echo "3. Start OpenCode and pick an agent:" echo -e " ${CYAN}opencode${NC} → choose ${BOLD}OpenArchitect${NC} to spec/verify, ${BOLD}OpenAgent${NC} to build" elif have_opencode; then echo "3. OpenCode is installed but not on PATH yet — get it available:" echo -e " ${CYAN}export PATH=\"${OPENCODE_BIN_DIR}:\$PATH\"${NC} (or open a new terminal)" else echo "3. Install OpenCode, then pick an agent:" echo -e " ${CYAN}curl -fsSL ${OPENCODE_INSTALLER_URL} | bash${NC}" fi echo info "Docs: ${REPO_URL}" } # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- main() { print_header ensure_opencode resolve_source resolve_install_dir info "Source: ${SOURCE}" info "Destination: ${INSTALL_DIR}" [ "$DRY_RUN" = 1 ] && warn "Dry run — no changes will be written" perform_install if [ "$DRY_RUN" != 1 ]; then show_post_install setup_service fi } main