#!/usr/bin/env bash # AgentCollision installer. # # One-shot bootstrap for macOS and Linux (including WSL). Downloads the # `ac` binary (via Homebrew if you have it, or directly from GitHub if # not), registers the daemon to start at login, and prints a reminder to # run `ac login` to connect the machine to your account. # # Usage (normal): # curl -fsSL https://install.agentcollision.com | sh # # That's it. After the install finishes, run: # ac login # # Env vars (all optional): # AC_NO_BREW=1 force the direct-binary path even if Homebrew is present # (useful when the tap formula is lagging behind the # latest release, or for reproducible builds) # AC_VERSION pin a specific version, e.g. "0.10.0". Defaults to the # latest GitHub release. # AC_INSTALL_DIR where to install the binary. Defaults to /usr/local/bin # if writable, else ~/.local/bin. # AC_NO_SERVICE=1 skip launchd/systemd registration (just install) # AC_TOKEN pre-configure team.toml with this install token. The # dashboard's "Advanced: generate install token" flow # sets this for CI/headless setups where `ac login` # can't open a browser. Setting AC_TOKEN also skips # the brew auto-detect (brew can't write team.toml). # AC_HMAC_KEY HMAC key for path hashing; set alongside AC_TOKEN by # the advanced-install flow. set -eu # ---------- styling ---------- if [ -t 1 ]; then BOLD=$'\033[1m'; DIM=$'\033[2m'; CYAN=$'\033[36m'; GREEN=$'\033[32m' YELLOW=$'\033[33m'; RED=$'\033[31m'; RESET=$'\033[0m' else BOLD=''; DIM=''; CYAN=''; GREEN=''; YELLOW=''; RED=''; RESET='' fi step() { printf "\n%s==>%s %s%s%s\n" "${BOLD}${CYAN}" "${RESET}" "${BOLD}" "$*" "${RESET}"; } ok() { printf " %s✓%s %s\n" "${GREEN}" "${RESET}" "$*"; } warn() { printf " %s!%s %s\n" "${YELLOW}" "${RESET}" "$*"; } fail() { printf " %s✗%s %s\n" "${RED}" "${RESET}" "$*" >&2; exit 1; } info() { printf " %s\n" "$*"; } # ---------- preflight ---------- command -v curl >/dev/null 2>&1 || fail "curl not found. Install curl first." command -v tar >/dev/null 2>&1 || fail "tar not found." # AC_TOKEN is optional. If it's set (from the dashboard's personalized # install command), we pre-configure team.toml with it and the daemon is # ready to go. If it's absent, we still install the binary and register # the service — the user runs `ac login` afterward to link to their account. # This lets the canonical curl|sh command work for anyone, signed up or not. # ---------- prefer Homebrew if available ----------------------------- # If the user has brew on their PATH, delegate to the tap rather than # downloading the binary directly. Benefits: # - brew handles the macOS Gatekeeper xattr # - `brew upgrade` keeps agentcollision current # - clean uninstall via `brew uninstall` # - our own script has fewer moving parts to go wrong # # Opt-outs: # AC_NO_BREW=1 — force the direct-binary path (useful for # comparing, or when the tap formula is lagging) # AC_TOKEN set — the dashboard's "advanced: generate install # token" flow writes a pre-configured team.toml # as part of the install, which brew can't do. # In that case we skip brew and use our own path. if command -v brew >/dev/null 2>&1 && [ "${AC_NO_BREW:-0}" != "1" ] && [ -z "${AC_TOKEN:-}" ]; then step "Homebrew detected — installing via tap" info "Tap: agentcollision/homebrew-tap · formula: agentcollision" if brew install agentcollision/tap/agentcollision; then ok "Installed via brew. The 'ac' command is on your PATH." printf "\n" step "Next: connect this machine to your account" printf " %sac login%s\n" "${CYAN}${BOLD}" "${RESET}" printf "\n" printf " Opens your browser for a one-click approval.\n" printf " Don't have an account? %shttps://dashboard.agentcollision.com%s\n" "${CYAN}" "${RESET}" printf "\n" printf " %sSolo mode (no account) still protects against file conflicts%s\n" "${DIM}" "${RESET}" printf " %son this machine. Log in only if you want cross-machine or team%s\n" "${DIM}" "${RESET}" printf " %scoordination.%s\n" "${DIM}" "${RESET}" printf "\n" exit 0 fi # If brew install fell over (e.g. tap unreachable, formula error), fall # through to the direct-binary path rather than leaving the user stuck. warn "brew install failed — falling back to direct binary download." fi # ---------- detect OS/arch ---------- UNAME_S=$(uname -s) UNAME_M=$(uname -m) case "$UNAME_S" in Darwin) OS=darwin ;; Linux) OS=linux ;; *) fail "Unsupported OS: $UNAME_S. This installer works on macOS and Linux. Windows users: please run this from WSL (Windows Subsystem for Linux). A native Windows installer is on the roadmap." ;; esac case "$UNAME_M" in x86_64|amd64) ARCH=amd64 ;; arm64|aarch64) ARCH=arm64 ;; *) fail "Unsupported architecture: $UNAME_M" ;; esac ok "Detected $OS/$ARCH" # ---------- resolve version ---------- # Source of truth: releases.agentcollision.com/latest.json # (written by scripts/release/upload-to-r2.sh on every release tag). # The GitHub repo is private, so the public GitHub API can't be used # for version resolution here. AC_RELEASES_BASE="${AC_RELEASES_BASE:-https://releases.agentcollision.com}" if [ -z "${AC_VERSION:-}" ]; then step "Looking up latest release" # jq isn't always on the user's machine; do the parsing with sed. latest.json # has a predictable shape: { "latest": { "version": "0.10.3", ... }, ... } LATEST_JSON=$(curl -fsSL "${AC_RELEASES_BASE}/latest.json" 2>/dev/null || true) AC_VERSION=$(printf "%s" "$LATEST_JSON" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) if [ -z "$AC_VERSION" ]; then fail "Couldn't resolve latest version from ${AC_RELEASES_BASE}/latest.json. Try again later or pin AC_VERSION=x.y.z manually." fi fi ok "Installing v$AC_VERSION" # ---------- pick install dir ---------- if [ -z "${AC_INSTALL_DIR:-}" ]; then if [ -w /usr/local/bin ] 2>/dev/null; then AC_INSTALL_DIR=/usr/local/bin elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then AC_INSTALL_DIR=/usr/local/bin USE_SUDO=1 else AC_INSTALL_DIR="$HOME/.local/bin" mkdir -p "$AC_INSTALL_DIR" case ":$PATH:" in *":$AC_INSTALL_DIR:"*) ;; *) warn "Add $AC_INSTALL_DIR to your PATH (e.g. in ~/.zshrc or ~/.bashrc)." ;; esac fi fi # ---------- download ---------- step "Downloading agentcollision v$AC_VERSION ($OS/$ARCH)" TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT TARBALL="agentcollision_${AC_VERSION}_${OS}_${ARCH}.tar.gz" URL="${AC_RELEASES_BASE}/v${AC_VERSION}/${TARBALL}" if ! curl -fsSL "$URL" -o "$TMPDIR/$TARBALL"; then fail "Download failed: $URL" fi ok "Downloaded $(du -h "$TMPDIR/$TARBALL" | cut -f1)" # Verify sha256 against checksums.txt if sha256sum/shasum is available. # Don't hard-fail if hashing tool is missing (we trust TLS + R2's signed # domain), but if we can verify, we do — belt-and-braces against a # compromised mirror or corrupt download. if command -v shasum >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1; then step "Verifying checksum" CHECKSUMS_URL="${AC_RELEASES_BASE}/v${AC_VERSION}/checksums.txt" if CHECKSUMS=$(curl -fsSL "$CHECKSUMS_URL" 2>/dev/null); then EXPECTED=$(printf "%s" "$CHECKSUMS" | awk -v f="$TARBALL" '$2 == f {print $1}') if [ -n "$EXPECTED" ]; then if command -v shasum >/dev/null 2>&1; then ACTUAL=$(shasum -a 256 "$TMPDIR/$TARBALL" | awk '{print $1}') else ACTUAL=$(sha256sum "$TMPDIR/$TARBALL" | awk '{print $1}') fi if [ "$EXPECTED" = "$ACTUAL" ]; then ok "SHA256 verified" else fail "SHA256 mismatch — expected $EXPECTED, got $ACTUAL. The download may be corrupt; try again." fi else warn "Couldn't find $TARBALL in checksums.txt — skipping verification." fi else warn "Couldn't fetch checksums.txt — skipping verification." fi fi step "Extracting" tar -xzf "$TMPDIR/$TARBALL" -C "$TMPDIR" # The archive contains a Go binary named `agentcollision`. GoReleaser's # `builds[].binary` setting sets the archive's binary name; the tap # formula then creates an `ac` symlink. Here we do the equivalent: look # for `agentcollision` in the archive, and on install create both # `$AC_INSTALL_DIR/agentcollision` and `$AC_INSTALL_DIR/ac` (symlink). BINARY="$TMPDIR/agentcollision" if [ ! -f "$BINARY" ]; then BINARY=$(find "$TMPDIR" -type f \( -name "agentcollision" -o -name "ac" \) -perm -u+x | head -1) [ -z "$BINARY" ] && fail "Couldn't find 'agentcollision' binary in archive" fi # ---------- install ---------- # Install the real binary as `agentcollision` and create an `ac` symlink, # matching the Homebrew formula so `ac` works on both install paths. step "Installing to $AC_INSTALL_DIR/agentcollision (with 'ac' symlink)" if [ "${USE_SUDO:-0}" = "1" ]; then sudo install -m 0755 "$BINARY" "$AC_INSTALL_DIR/agentcollision" sudo ln -sf "$AC_INSTALL_DIR/agentcollision" "$AC_INSTALL_DIR/ac" else install -m 0755 "$BINARY" "$AC_INSTALL_DIR/agentcollision" ln -sf "$AC_INSTALL_DIR/agentcollision" "$AC_INSTALL_DIR/ac" fi ok "Installed" # ---------- macOS Gatekeeper workaround ---------- # Until we ship a $99/year Apple Developer cert + GoReleaser notarization, # the binary is unsigned. macOS quarantines downloaded unsigned binaries # and refuses to run them with a "cannot be opened, malware" prompt. # # Stripping the quarantine xattr immediately after install bypasses the # prompt for THIS install. The xattr was added by curl when it wrote the # tarball; tar copies it to extracted files. We remove it from the live # binary so the very next command (the version check below) doesn't bounce. # # Future fix: notarize via GoReleaser. Tracked in HANDOFF.md §10. if [ "$OS" = "darwin" ]; then # Strip from the real binary (not the `ac` symlink — xattrs don't follow). if [ "${USE_SUDO:-0}" = "1" ]; then sudo xattr -d com.apple.quarantine "$AC_INSTALL_DIR/agentcollision" 2>/dev/null || true else xattr -d com.apple.quarantine "$AC_INSTALL_DIR/agentcollision" 2>/dev/null || true fi fi # Verify the binary actually runs. If macOS still quarantined it (e.g. xattr # not in PATH, or stricter Gatekeeper policy), give actionable instructions # instead of leaving the user with a cryptic "killed: 9". if ! "$AC_INSTALL_DIR/ac" --version >/dev/null 2>&1; then printf "\n" warn "The installed binary did not run successfully on this machine." if [ "$OS" = "darwin" ]; then info "" info "${BOLD}macOS may be blocking the unsigned binary.${RESET} Run these two commands" info "to manually allow it (one-time per install):" info "" info " ${DIM}sudo xattr -d com.apple.quarantine $AC_INSTALL_DIR/ac${RESET}" info " ${DIM}sudo spctl --add $AC_INSTALL_DIR/ac${RESET}" info "" info "Then re-run this installer or run ${DIM}ac --version${RESET} to confirm." else info "Run ${DIM}$AC_INSTALL_DIR/ac --version${RESET} manually to see the error." fi fail "Aborting before service registration to avoid leaving a broken install." fi # ---------- write config ---------- step "Configuring" CONFIG_DIR="$HOME/.agentcollision" mkdir -p "$CONFIG_DIR" chmod 700 "$CONFIG_DIR" # team.toml is what the daemon reads. Two cases: # (a) AC_TOKEN set → we pre-write a complete config, daemon is fully online # (b) AC_TOKEN unset → we write a placeholder, user runs `ac login` to fill it in # Either way the daemon starts — (b) mode will be idle until login, which is # fine for local/solo coordination (the daemon doesn't need team-sync to # protect file leases on one machine). if [ -n "${AC_TOKEN:-}" ]; then cat > "$CONFIG_DIR/team.toml" <> "$CONFIG_DIR/team.toml" fi CONFIG_MODE="token_preloaded" else cat > "$CONFIG_DIR/team.toml" </dev/null; then ok "ac init ran successfully" else warn "ac init didn't finish cleanly (can run manually later). Continuing." fi # ---------- register service ---------- if [ "${AC_NO_SERVICE:-0}" = "1" ]; then info "Skipping service registration (AC_NO_SERVICE=1)" else step "Registering daemon to start on login" case "$OS" in darwin) PLIST="$HOME/Library/LaunchAgents/com.agentcollision.daemon.plist" mkdir -p "$HOME/Library/LaunchAgents" cat > "$PLIST" < Labelcom.agentcollision.daemon ProgramArguments $AC_INSTALL_DIR/ac daemon start --foreground RunAtLoad KeepAlive StandardOutPath$CONFIG_DIR/daemon.log StandardErrorPath$CONFIG_DIR/daemon.log EOF launchctl unload "$PLIST" 2>/dev/null || true launchctl load "$PLIST" 2>/dev/null || warn "launchctl load failed — daemon will run but may not auto-start on next login." ok "Registered launchd agent" ;; linux) if command -v systemctl >/dev/null 2>&1; then UNIT_DIR="$HOME/.config/systemd/user" mkdir -p "$UNIT_DIR" cat > "$UNIT_DIR/agentcollision.service" </dev/null || warn "systemctl enable failed — you can start manually with 'ac daemon start'." systemctl --user start agentcollision.service 2>/dev/null || true ok "Registered systemd user unit" else warn "systemd not found. Starting daemon in background; you'll need to restart it after reboot." nohup "$AC_INSTALL_DIR/ac" daemon start >/dev/null 2>&1 & fi ;; esac fi # ---------- start daemon if not already running ---------- if ! "$AC_INSTALL_DIR/ac" status >/dev/null 2>&1; then "$AC_INSTALL_DIR/ac" daemon start >/dev/null 2>&1 || true fi # ---------- final report ---------- step "Done" printf "\n" printf " %s✓ AgentCollision v%s is installed and running.%s\n" "${GREEN}${BOLD}" "$AC_VERSION" "${RESET}" printf "\n" if [ "$CONFIG_MODE" = "awaiting_login" ]; then # The canonical "fresh install" path. Nudge the user toward `ac login` # prominently — without it they only get local-only coordination. printf " %sNext: connect to your account.%s\n" "${BOLD}" "${RESET}" printf " %sac login%s\n" "${CYAN}${BOLD}" "${RESET}" printf "\n" printf " This opens your browser to approve the link. Already signed in? Two clicks.\n" printf " Don't have an account? %shttps://dashboard.agentcollision.com%s — sign up is free.\n" "${CYAN}" "${RESET}" printf "\n" printf " %sSolo mode works without logging in%s — AgentCollision will still\n" "${DIM}" "${RESET}" printf " %sprotect against file conflicts on this machine. Team coordination%s\n" "${DIM}" "${RESET}" printf " %s(seeing teammates' edits across machines) requires logging in.%s\n" "${DIM}" "${RESET}" else # AC_TOKEN was pre-set (dashboard-generated install command). Daemon is # already fully configured. printf " Next steps:\n" printf " • Open your dashboard: %shttps://dashboard.agentcollision.com%s\n" "${CYAN}" "${RESET}" printf " • Check local status: %sac status%s\n" "${DIM}" "${RESET}" printf " • View live dashboard: %sac team dashboard%s\n" "${DIM}" "${RESET}" fi printf "\n"