#!/usr/bin/env bash set -euo pipefail GITHUB_REPO="PromptForcePrime/prefex" SITE_URL="https://promptforce.ai" BASE_URL="https://github.com/${GITHUB_REPO}/releases/latest/download" INSTALL_DIR="${HOME}/.prefex" # ── User-tunable knobs (env vars or defaults) ──────────────────────────────── # Override before piping: # PREFEX_BIND=0.0.0.0 PREFEX_PORT=9019 curl -fsSL ... | bash # PREFEX_USAGE_REPORTING=0 turn off the anonymous daily summary + install ping (default: on; DO_NOT_TRACK=1 also works) PREFEX_BIND="${PREFEX_BIND:-127.0.0.1}" PREFEX_PORT="${PREFEX_PORT:-8019}" # dashboard_port FILE prints the `port:` under the `dashboard:` block only. # A bare grep for the first indented `port:` picked up whichever block came # first (redis, a sidecar), and the upgrade then stopped and health-checked # the wrong port. dashboard_port() { awk ' /^[[:space:]]*(#|$)/ { next } { match($0, /^ */); ind = RLENGTH } in_d && ind <= dind { in_d = 0 } /^ *dashboard:[[:space:]]*(#.*)?$/ { in_d = 1; dind = ind; next } in_d && /^ *port:[[:space:]]*[0-9]+/ { sub(/^ *port:[[:space:]]*/, ""); sub(/[^0-9].*$/, ""); print; exit } ' "$1" 2>/dev/null || true } # ── Colours (no-op when not a terminal) ───────────────────────────────────── if [ -t 1 ]; then BOLD="\033[1m"; GREEN="\033[0;32m"; YELLOW="\033[0;33m"; RED="\033[0;31m"; RESET="\033[0m" else BOLD=""; GREEN=""; YELLOW=""; RED=""; RESET="" fi info() { printf "${GREEN}==>${RESET} %s\n" "$*"; } warn() { printf "${YELLOW}warn:${RESET} %s\n" "$*" >&2; } die() { printf "${RED}error:${RESET} %s\n" "$*" >&2; exit 1; } heading() { printf "\n${BOLD}%s${RESET}\n" "$*"; } # verify_sha256 FILE URL — fatal on every failure: no published checksum, no # hasher on this machine, or a mismatch. An unverified binary is never installed. verify_sha256() { local file="$1" url="$2" expected actual expected=$(curl -fsSL "$url" 2>/dev/null | awk '{print tolower($1)}' || true) case "$expected" in ""|*[!0-9a-f]*) rm -f "$file"; die "Could not fetch a valid checksum from ${url} — refusing to install an unverified binary." ;; esac [ "${#expected}" -eq 64 ] || { rm -f "$file"; die "Malformed checksum at ${url} — refusing to install an unverified binary."; } if command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "$file" | awk '{print tolower($1)}') elif command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$file" | awk '{print tolower($1)}') else rm -f "$file"; die "Neither shasum nor sha256sum is installed — cannot verify the download. Install one and re-run." fi if [ "$actual" != "$expected" ]; then rm -f "$file" die "SHA256 mismatch! Expected ${expected}, got ${actual}. Aborted." fi info "SHA256 verified." } # ── Stop anything on port $PREFEX_PORT ─────────────────────────────────────── stop_existing() { # `prefex stop` is the canonical shutdown: it restores settings.json and # clears the pidfile. --prewarm first pre-pays each live session's cache # re-write; a binary too old to know the flag rejects it, so fall back. local stopper stopper=$(command -v prefex 2>/dev/null || true) [ -z "$stopper" ] && [ -x "$DEST" ] && stopper="$DEST" if [ -n "$stopper" ]; then info "Stopping existing Prefex…" "$stopper" stop --prewarm /dev/null || "$stopper" stop /dev/null || true sleep 1 fi # Whatever still holds the port: kill it only if it is a prefex process. A # blanket kill of the port holder could take down an unrelated service the # user happens to run on the same port. local blocker p comm blocker=$(lsof -ti ":${PREFEX_PORT}" 2>/dev/null || true) for p in $blocker; do comm=$(ps -p "$p" -o comm= 2>/dev/null || true) case "$(basename "${comm:-?}")" in prefex*|inferx*) info "Stopping leftover prefex on port ${PREFEX_PORT} (PID ${p})…" kill "$p" 2>/dev/null || true ;; *) die "Port ${PREFEX_PORT} is held by '${comm:-unknown}' (PID ${p}), not prefex. Free it, or re-run with PREFEX_PORT=." ;; esac done [ -n "$blocker" ] && sleep 1 return 0 } # Suspend Claude Code routing during upgrade so in-flight calls go direct. # Written atomically (temp file + rename in the same directory): a crash or a # full disk mid-write must never leave settings.json truncated. CLAUDE_SETTINGS="${HOME}/.claude/settings.json" suspend_routing() { if [ -f "$CLAUDE_SETTINGS" ] && grep -q "localhost\|127.0.0.1" "$CLAUDE_SETTINGS" 2>/dev/null; then info "Suspending Claude Code routing during upgrade…" if command -v python3 >/dev/null 2>&1; then python3 - "$CLAUDE_SETTINGS" <<'PY2' import json, os, sys, tempfile path = sys.argv[1] try: with open(path) as f: cfg = json.load(f) cfg.setdefault('env', {})['ANTHROPIC_BASE_URL'] = 'https://api.anthropic.com' fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or '.', prefix='.settings.', suffix='.tmp') try: with os.fdopen(fd, 'w') as f: json.dump(cfg, f, indent=2) f.write('\n') os.chmod(tmp, os.stat(path).st_mode & 0o7777) os.replace(tmp, path) except BaseException: os.unlink(tmp) raise except Exception as e: print(f"warn: could not suspend routing: {e}", file=sys.stderr) PY2 fi fi } # The whole body runs inside main(), so a download cut off mid-stream by # curl|bash executes nothing rather than a truncated script. main() { # On upgrade, prefer whatever port is already in the config. if [ -f "${INSTALL_DIR}/config.yaml" ]; then CONFIGURED_PORT=$(dashboard_port "${INSTALL_DIR}/config.yaml") if [ -n "$CONFIGURED_PORT" ] && [ "$CONFIGURED_PORT" -gt 0 ] 2>/dev/null; then PREFEX_PORT="$CONFIGURED_PORT" fi fi PROXY_URL="http://localhost:${PREFEX_PORT}" # ── Platform detection ─────────────────────────────────────────────────────── OS=$(uname -s | tr '[:upper:]' '[:lower:]') case "$OS" in darwin) ;; linux) ;; *) die "Unsupported OS: $(uname -s). Prefex supports macOS and Linux." ;; esac ARCH=$(uname -m) case "$ARCH" in x86_64) ARCH="amd64" ;; arm64|aarch64) ARCH="arm64" ;; *) die "Unsupported architecture: $(uname -m)" ;; esac # ── Detect existing installation ───────────────────────────────────────────── UPGRADE=false if [ -f "${INSTALL_DIR}/data/prefex.db" ] || [ -f "${INSTALL_DIR}/prefex.pid" ]; then UPGRADE=true fi # ── Download binary ─────────────────────────────────────────────────────────── if [ "$UPGRADE" = "true" ]; then heading "Upgrading Prefex (${OS}/${ARCH})" else heading "Installing Prefex (${OS}/${ARCH})" fi # ── Guard: never silently clobber a developer build ────────────────────────── # A locally-built binary reports version "dev". Overwriting one with a release # throws away un-released work and swaps a known-good build for one the # developer has not tested — and it has happened twice on the maintainer's own # machine (2026-07, 2026-08-06). Interactive runs must confirm; scripted runs # (no TTY) refuse outright, since nobody is watching to answer. if command -v prefex >/dev/null 2>&1 && prefex --version 2>/dev/null | grep -q " dev\b"; then warn "The installed prefex is a DEVELOPMENT build ($(prefex --version 2>/dev/null | head -1))." warn "Installing the release will overwrite it." if [ "${PREFEX_FORCE_OVERWRITE_DEV:-}" = "1" ]; then warn "PREFEX_FORCE_OVERWRITE_DEV=1 — continuing." elif [ -t 0 ]; then printf " Overwrite the dev build with the release? [y/N] " read -r _reply /dev/null | grep -i '^etag:' | tr -d '\r' | awk '{print $2}' || true) SKIP_DOWNLOAD=false if [ -f "$DEST" ] && [ -f "$ETAG_CACHE" ] && [ -n "$REMOTE_ETAG" ]; then if [ "$REMOTE_ETAG" = "$(cat "$ETAG_CACHE")" ]; then info "Binary is already up-to-date, skipping download." SKIP_DOWNLOAD=true fi fi if [ "$SKIP_DOWNLOAD" = "false" ]; then info "Downloading ${BINARY_NAME}…" mkdir -p "${INSTALL_DIR}/data" trap 'rm -f "${DEST}.tmp"' EXIT curl -fsSL "${BINARY_URL}" -o "${DEST}.tmp" verify_sha256 "${DEST}.tmp" "${BASE_URL}/${BINARY_NAME}.sha256" chmod +x "${DEST}.tmp" mv "${DEST}.tmp" "$DEST" [ -n "$REMOTE_ETAG" ] && printf '%s' "$REMOTE_ETAG" > "$ETAG_CACHE" fi if [ "$UPGRADE" = "true" ] && [ -f "${INSTALL_DIR}/data/prefex.db" ]; then info "Existing database preserved at ${INSTALL_DIR}/data/prefex.db" fi info "Binary → ${DEST}" # Which prefex runs when the user types `prefex`? An older copy earlier on # PATH would shadow this one, and every later `prefex stop`/`status` would # talk to the wrong binary. RESOLVED=$(command -v prefex 2>/dev/null || true) if [ -n "$RESOLVED" ] && [ "$RESOLVED" != "$DEST" ]; then warn "Just installed ${DEST}, but 'prefex' on your PATH resolves to ${RESOLVED}." warn "That copy shadows the one installed now. Remove it, or put ${BIN_DIR} earlier on your PATH." fi # ── Configure via binary subcommand ────────────────────────────────────────── # The binary owns the install lifecycle from here: # - writes ~/.prefex/config.yaml (or patches bind/port on upgrade) # - backs up and wires ~/.claude/settings.json # See: prefex install --help INSTALL_ARGS=(--bind "${PREFEX_BIND}" --port "${PREFEX_PORT}") # Anonymous usage reporting is on by default, set without asking; the # installer prints what is sent. PREFEX_USAGE_REPORTING=0 or DO_NOT_TRACK=1 # turns it off up front; Settings · Privacy turns it off any time. if [ "${PREFEX_USAGE_REPORTING:-}" = "0" ] || [ -n "${DO_NOT_TRACK:-}" ]; then INSTALL_ARGS+=(--no-usage-reporting) fi INSTALL_SHOWN="${INSTALL_ARGS[*]}" # Feature-detect flags newer than the binary may be: the site can deploy # before the release that adds them. INSTALL_HELP=$("${DEST}" install --help 2>&1 || true) # The first pass must not wire Claude Code, and must not be REMEMBERED as # "don't wire Claude Code" either: --no-claude-code persists as the client # choice on newer binaries, so the second pass would then skip it too. DEFER_FLAG="--no-claude-code" case "$INSTALL_HELP" in *defer-claude-code*) DEFER_FLAG="--defer-claude-code" ;; esac # The API key goes through the environment, never argv (visible in ps) and # never echoed. Only an old binary without --anthropic-key-from-env gets it on # the command line, and even then what is printed is redacted. KEY_ARGS=() KEY_SHOWN="" if [ -n "${ANTHROPIC_API_KEY:-}" ]; then case "$INSTALL_HELP" in *anthropic-key-from-env*) KEY_ARGS=(--anthropic-key-from-env); KEY_SHOWN=" --anthropic-key-from-env" ;; *) KEY_ARGS=(--anthropic-key "${ANTHROPIC_API_KEY}"); KEY_SHOWN=" --anthropic-key ***" ;; esac fi # ── Beta notice ─────────────────────────────────────────────────────────────── printf "${YELLOW} ⚠ Prefex is beta software, use at your own risk.${RESET}\n" printf " Provided as-is with no warranty. Day Two AI is an Anthropic partner; Prefex is built independently and is not an Anthropic product.\n" printf " Advanced features (thinking, CoD, loop guard) are disabled by default; enable via the dashboard.\n\n" # ── Preview: show exactly what install will change, then confirm ────────────── heading "What will change" "${DEST}" preview --bind "${PREFEX_BIND}" --port "${PREFEX_PORT}" || true echo "" printf " Proceed with install? [Y/n] " if [ ! -t 0 ]; then echo "y (non-interactive)" CONFIRM="y" else read -r CONFIRM fi case "${CONFIRM:-y}" in [Nn]*) printf "\n Aborted, nothing was changed.\n\n"; exit 0 ;; esac # Only NOW stop the running instance and suspend routing, # after the user has seen the preview and confirmed. if [ "$UPGRADE" = "true" ]; then suspend_routing stop_existing fi # Write config / statusline / skills, but DO NOT route Claude Code yet. # Routing is flipped to localhost only after the daemon proves healthy below, so # we never point Claude Code at a dead port (a slow cold start would otherwise # 500 in-flight sessions). On upgrade, suspend_routing already pointed Claude # Code back at api.anthropic.com and it stays there until the new daemon is up. info "Configuring (prefex install ${INSTALL_SHOWN}${KEY_SHOWN} ${DEFER_FLAG})…" "${DEST}" install "${INSTALL_ARGS[@]}" ${KEY_ARGS[@]+"${KEY_ARGS[@]}"} "${DEFER_FLAG}" # ── Start the daemon ────────────────────────────────────────────────────────── # `prefex start` daemonizes, writes the pidfile and refuses when it must (a # licence that has stopped, an instance already running) with a non-zero exit. # The old `nohup serve & echo $!` raced its own pidfile and could never fail. # --no-claude-code here: routing is flipped only after the health check below. info "Starting Prefex…" START_LOG=$(mktemp "${TMPDIR:-/tmp}/prefex-start.XXXXXX") if ! "${DEST}" start --no-claude-code \ -config "${INSTALL_DIR}/config.yaml" \ -db "${INSTALL_DIR}/data/prefex.db" "$START_LOG" 2>&1; then cat "$START_LOG" >&2 rm -f "$START_LOG" die "prefex start failed — Claude Code was left on its current endpoint. Log: ${INSTALL_DIR}/prefex.log" fi rm -f "$START_LOG" # Wait for health. The release binary is size-optimized and cold-starts slower # than a dev build, so allow generous time before deciding it failed. HEALTH_TIMEOUT=90 PROXY_UP=false printf " Waiting for proxy" for _ in $(seq 1 "$HEALTH_TIMEOUT"); do if curl -fsSo /dev/null "${PROXY_URL}/health" 2>/dev/null; then printf " ready.\n" PROXY_UP=true break fi printf "." sleep 1 done if [ "$PROXY_UP" = "true" ]; then # Daemon is healthy — NOW it is safe to route Claude Code through it. info "Routing Claude Code through Prefex…" "${DEST}" install "${INSTALL_ARGS[@]}" else # Never leave Claude Code pointed at a proxy that did not come up. Routing is # still on api.anthropic.com (upgrade) or untouched (fresh), so sessions keep # working; the user finishes wiring once the daemon is confirmed up. printf "\n" warn "Proxy is taking longer than ${HEALTH_TIMEOUT}s to start — leaving Claude Code on its current endpoint so nothing breaks." warn "Check it: tail -f ${INSTALL_DIR}/prefex.log (then: prefex status)" warn "Finish wiring once it is up: ${DEST} install ${INSTALL_SHOWN}" fi # ── Capture installed version ───────────────────────────────────────────────── INSTALLED_VERSION=$("${DEST}" -version 2>/dev/null | awk '{print $2}' || true) # ── Status-line dependency check ───────────────────────────────────────────── # `prefex install` already wrote ~/.prefex/statusline.sh and wired the # statusLine key into ~/.claude/settings.json. The script needs `jq` and # `curl` at runtime, warn the user if jq is missing so they can install it. # Settings are added regardless; statusline will start working once jq lands. if ! command -v jq &>/dev/null; then echo "" warn "Status-line wired, but \`jq\` is not installed." case "$OS" in darwin) echo " Install with: brew install jq" ;; linux) if command -v apt-get &>/dev/null; then echo " Install with: sudo apt-get install -y jq" elif command -v dnf &>/dev/null; then echo " Install with: sudo dnf install -y jq" elif command -v yum &>/dev/null; then echo " Install with: sudo yum install -y jq" elif command -v pacman &>/dev/null; then echo " Install with: sudo pacman -S --noconfirm jq" elif command -v apk &>/dev/null; then echo " Install with: sudo apk add jq" else echo " Install jq from your package manager or https://jqlang.org/download/" fi ;; esac echo " After installing, statusline will appear in your next Claude Code session." echo "" fi # ── Optional: Kompress text-compaction sidecar (opt-in, default no) ────────── # Sets up a LOCAL Python sidecar. The Go binary stays model-free and fails open # if it is absent. Needs python3 + ~700MB + 3GB free. PREFEX_KOMPRESS=1 auto-yes. # # An existing sidecar is left strictly alone: an upgrade must never re-run a # sidecar installer, nor re-ask a question the user already answered. Their # venv, model and running process stay exactly as they were. if [ -x "${INSTALL_DIR}/kompress/venv/bin/python" ]; then printf "\n Kompress sidecar already installed — left untouched.\n" else printf "\n Optional: install the Kompress text-trimming sidecar?\n" printf " Extra savings on free-text tool output. Needs Python 3, ~700MB, downloads a model.\n" printf " Install it now? [y/N] " if [ ! -t 0 ]; then KREPLY="$([ "${PREFEX_KOMPRESS:-}" = "1" ] && echo y || echo n)" echo "${KREPLY} (non-interactive)" else read -r KREPLY fi case "${KREPLY:-n}" in [Yy]*) if curl -fsSL "${SITE_URL}/kompress/setup.sh" -o "${INSTALL_DIR}/kompress-setup.sh"; then bash "${INSTALL_DIR}/kompress-setup.sh" install \ || echo " Sidecar setup did not finish (non-fatal). Prefex runs fine without it." else echo " Could not fetch the sidecar installer (non-fatal)." fi ;; *) echo " Skipped. Enable later: curl -fsSL ${SITE_URL}/kompress/setup.sh | bash -s install" ;; esac fi echo "" # ── Optional: router encoder sidecar (opt-in, default no) ──────────────────── # Sharpens the router's complexity/intent scoring. The Go binary routes on a # built-in heuristic without it and fails open if it's absent. Needs python3 + # ~800MB. PREFEX_ENCODER=1 auto-yes. Fully managed by `prefex encoder`. # Same rule as above: an existing install is never touched or re-prompted. if [ -x "${INSTALL_DIR}/encoder/encoder-server" ]; then printf " Router encoder sidecar already installed — left untouched.\n" else printf " Optional: install the router encoder sidecar (sharper routing)?\n" printf " ModernBERT model for complexity scoring. Needs Python 3, ~800MB, downloads a model.\n" printf " Install it now? [y/N] " if [ ! -t 0 ]; then EREPLY="$([ "${PREFEX_ENCODER:-}" = "1" ] && echo y || echo n)" echo "${EREPLY} (non-interactive)" else read -r EREPLY fi case "${EREPLY:-n}" in [Yy]*) "${DEST}" encoder install \ || echo " Encoder setup did not finish (non-fatal). Prefex routes fine on the heuristic without it." ;; *) echo " Skipped. Enable later: prefex encoder install && prefex encoder serve" ;; esac fi echo "" # ── Done ───────────────────────────────────────────────────────────────────── # Install ping: version, OS, arch, and whether this was an install or an # upgrade. Nothing else — no hostname, no path, no identifier. On by default; # skipped with PREFEX_USAGE_REPORTING=0, PREFEX_NO_PING=1 or DO_NOT_TRACK=1. # TRUST.md documents this (issue #267). if [ "${PREFEX_USAGE_REPORTING:-}" != "0" ] && [ -z "${PREFEX_NO_PING:-}" ] && [ -z "${DO_NOT_TRACK:-}" ]; then curl -sf "${SITE_URL}/api/ping?v=${INSTALLED_VERSION:-latest}&os=${OS}&arch=${ARCH}&e=$([ "$UPGRADE" = "true" ] && echo upgrade || echo install)" >/dev/null 2>&1 & fi if [ "$UPGRADE" = "true" ]; then heading "Prefex upgraded to ${INSTALLED_VERSION:-latest} (Beta)" else heading "Prefex installed, ${INSTALLED_VERSION:-latest} (Beta)" fi echo "" echo " Dashboard: ${PROXY_URL}/dashboard" echo " Preview: prefex preview" echo " Benchmark: curl -fsSL ${SITE_URL}/prefex-bench.sh | bash" echo "" echo " What is on: the cost-saving layers are enabled by default. See them, with" echo " what each one is doing right now: prefex status" echo " Change any of them: prefex toggle on|off" echo " Your data: prompts and responses are not logged by default, and nothing" echo " is sent anywhere except the model provider you already use." echo " The full statement: ${SITE_URL}/trust" echo "" echo " Adding teammates? Their machines do NOT need this installer — it sets up a" echo " server (daemon, database, open port) that a client node has no use for." echo " Generate an invite in the dashboard (Members -> Invite Developer), then on" echo " each developer's machine:" echo "" echo " curl -fsSL ${SITE_URL}/join.sh | bash -s -- --proxy http://:${PREFEX_PORT}" echo "" echo " That installs only the binary and points Claude Code here — no second" echo " daemon, no second database. For them to reach this machine, it must bind" echo " beyond localhost: PREFEX_BIND=0.0.0.0 prefex start" echo "" echo " Privacy: Prefex logs no prompt or response text by default." echo " Content capture exists but is opt-in and local — see ${SITE_URL}/trust" echo " Verify: ${SITE_URL}/trust" echo "" printf "${YELLOW} Beta software, use at your own risk. No warranty.${RESET}\n" printf "${YELLOW} Prefex is by Promptforce.ai (a Day Two AI company), built independently of Anthropic.${RESET}\n" printf "\n" printf "${YELLOW} To uninstall:${RESET} prefex uninstall\n" printf " or: curl -fsSL ${SITE_URL}/uninstall.sh | bash\n" echo "" printf " Open dashboard in browser? [Y/n] " if [ ! -t 0 ]; then echo "y (non-interactive)" REPLY="y" else read -r REPLY fi case "${REPLY:-y}" in [Yy]*|"") if command -v open &>/dev/null; then open "${PROXY_URL}/dashboard" elif command -v xdg-open &>/dev/null; then xdg-open "${PROXY_URL}/dashboard" fi ;; esac } main "$@"