#!/bin/sh # Ad Engine installer for macOS and Linux. # # curl -fsSL https://runargusapp.com/install.sh | sh # curl -fsSL https://runargusapp.com/install.sh | sh -s -- --editor resolve # # This file is the single source of the served installer. site/ad-engine/build_deploy.py copies it # into the website with https://runargusapp.com replaced by the site URL. It needs no sudo, is safe to re-run, # and never installs the unrelated `adengine` project from PyPI. In order, it: # 1. asks the release channel for the current release, always without a token first. The public channel # answers everyone. Only when a private (development) channel says it needs one is the token in # ADENGINE_ACCESS_TOKEN sent, from a private (0600) curl config file, so it never appears on a command line; # 2. downloads the wheel and checks it against the published SHA-256 (a mismatch installs nothing); # 3. keeps the verified wheel in ~/.adengine/releases/ and describes it in releases/current.json, # so uv and `adengine upgrade` can find it again later; # 4. installs uv from astral.sh when it is missing (current.json records that, so `adengine uninstall` can # list it), then runs uv tool install on the kept wheel; # 5. provides ffmpeg when no suitable copy is found; # 6. runs `adengine setup --yes`, forwarding any arguments given to this script, and ends with the # next steps setup reports. The first one is always to open your agent and run /adengine-setup. # Setup can finish with a step still left for you (open DaVinci Resolve once, install an agent); # that is a finished install, and the step is printed last. # # Environment: # ADENGINE_ACCESS_TOKEN access token, only for a private (development) release channel # ADENGINE_SITE release channel (default https://runargusapp.com) # ADENGINE_HOME Ad Engine folder (default ~/.adengine) # ADENGINE_PYTHON Python version for the tool environment (default 3.12) # ADENGINE_UV_INSTALLER_URL uv installer (default https://astral.sh/uv/install.sh) # ADENGINE_ALLOW_ROOT=1 allow running under sudo # # Everything runs inside main(), which is called on the last line, so a download that stops halfway # runs nothing. set -eu TMP_DIR='' say() { printf '%s\n' "$*" } warn() { printf 'Warning: %s\n' "$*" >&2 } fail() { printf 'Ad Engine install failed: %s\n' "$*" >&2 exit 1 } cleanup() { if [ -n "$TMP_DIR" ]; then rm -rf "$TMP_DIR" fi } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then DIGEST=$(sha256sum "$1") || return 1 elif command -v shasum >/dev/null 2>&1; then DIGEST=$(shasum -a 256 "$1") || return 1 elif command -v openssl >/dev/null 2>&1; then DIGEST=$(openssl dgst -sha256 -r "$1") || return 1 else return 1 fi printf '%s\n' "${DIGEST%% *}" } find_uv() { if command -v uv >/dev/null 2>&1; then command -v uv return 0 fi for CANDIDATE in "${UV_INSTALL_DIR:+$UV_INSTALL_DIR/uv}" "${UV_INSTALL_DIR:+$UV_INSTALL_DIR/bin/uv}" \ "${XDG_BIN_HOME:+$XDG_BIN_HOME/uv}" "${XDG_DATA_HOME:+$XDG_DATA_HOME/../bin/uv}" \ "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do if [ -n "$CANDIDATE" ] && [ -f "$CANDIDATE" ] && [ -x "$CANDIDATE" ]; then printf '%s\n' "$CANDIDATE" return 0 fi done return 1 } json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' } # ~/.adengine/releases/current.json: what the last successful install put in place. "access" says whether the # channel needed a token ("private") or not ("public"); the token itself is never written. "uv_installed_by_installer" # and "uv" say whether this installer added uv, and where, for `adengine uninstall`. write_current() { NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) || NOW=unknown WHEEL_JSON=$(json_escape "$WHEEL") || return 1 UV_JSON=$(json_escape "$UV") || return 1 { printf '{\n' printf ' "schema": "adengine-release/1",\n' printf ' "site": "%s",\n' "$SITE" printf ' "channel": "%s/api/releases/latest",\n' "$SITE" printf ' "download_url": "%s/releases/%s",\n' "$SITE" "$NAME" printf ' "name": "%s",\n' "$NAME" printf ' "version": "%s",\n' "$VERSION" printf ' "sha256": "%s",\n' "$SHA" printf ' "wheel": "%s",\n' "$WHEEL_JSON" printf ' "python": "%s",\n' "$PYTHON_VERSION" printf ' "installed_at": "%s",\n' "$NOW" printf ' "access": "%s",\n' "$ACCESS" printf ' "uv_installed_by_installer": %s,\n' "$UV_ADDED" printf ' "uv": "%s",\n' "$UV_JSON" printf ' "installer": "install.sh"\n' printf '}\n' } >"$RELEASES/.current.json.tmp" || return 1 mv -f "$RELEASES/.current.json.tmp" "$RELEASES/current.json" } # 0 when a startup file that a new terminal of shell $1 reads already puts $BIN_DIR on PATH: uv's own installer, # an earlier run of this one or the user wrote it (as the absolute folder or as $HOME/...). Only that shell's files # count, and commented lines never do: a line for another shell does not help the terminal the user opens next. path_configured() { REL='' case "$BIN_DIR" in "$HOME"/*) REL="\$HOME/${BIN_DIR#"$HOME"/}" ;; esac ZDIR="${ZDOTDIR:-$HOME}" CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" OS_NAME=$(uname -s 2>/dev/null) || OS_NAME='' case "${1##*/}" in zsh) # Every zsh reads .zshenv, and every interactive one .zshrc. macOS terminals open login shells. set -- "$ZDIR/.zshenv" "$ZDIR/.zshrc" if [ "$OS_NAME" = Darwin ]; then set -- "$@" "$ZDIR/.zprofile" "$ZDIR/.zlogin" fi ;; bash) if [ "$OS_NAME" = Darwin ]; then # A macOS terminal opens a login bash, which reads only the first of these that exists. set -- "$HOME/.bash_profile" for FILE in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do if [ -f "$FILE" ]; then set -- "$FILE" break fi done else set -- "$HOME/.bashrc" fi ;; fish) set -- "$CONFIG_HOME/fish/config.fish" "$CONFIG_HOME/fish/conf.d/uv.env.fish" ;; csh|tcsh) set -- "$HOME/.tcshrc" "$HOME/.cshrc" "$HOME/.login" ;; ksh|ksh93|mksh) set -- "$HOME/.profile" "$HOME/.kshrc" ;; *) set -- "$HOME/.profile" ;; esac for FILE in "$@"; do if [ -f "$FILE" ]; then if grep -v '^[[:space:]]*#' "$FILE" 2>/dev/null | grep -F -q -e "$BIN_DIR"; then return 0 fi if [ -n "$REL" ] && grep -v '^[[:space:]]*#' "$FILE" 2>/dev/null | grep -F -q -e "$REL"; then return 0 fi fi done return 1 } # The user's login shell, for `uv tool update-shell` when $SHELL is not set (cron, CI, `env -i`, docker exec). login_shell() { ME=$(id -un 2>/dev/null) || ME='' FOUND='' if [ -n "$ME" ] && command -v getent >/dev/null 2>&1; then FOUND=$(getent passwd "$ME" 2>/dev/null | cut -d: -f7) || FOUND='' elif [ -n "$ME" ] && command -v dscl >/dev/null 2>&1; then FOUND=$(dscl . -read "/Users/$ME" UserShell 2>/dev/null | sed -n 's/^UserShell: *//p') || FOUND='' fi if [ -z "$FOUND" ] && [ "$(uname -s 2>/dev/null)" = Darwin ]; then FOUND=/bin/zsh fi printf '%s\n' "$FOUND" } # One PATH notice, for the user's shell ($SHELL, else the login shell). When that shell's startup files do not put # $BIN_DIR on PATH yet, `uv tool update-shell` adds it for the same shell. uv's own messages stay hidden: it also # reports an error when its lines are already there, which still means a new terminal finds adengine. path_notice() { case ":$ORIGINAL_PATH:" in *":$BIN_DIR:"*) return 0 ;; esac UPDATE_SHELL="${SHELL:-}" if [ -z "$UPDATE_SHELL" ]; then UPDATE_SHELL=$(login_shell) || UPDATE_SHELL='' fi PATH_SET=0 if path_configured "$UPDATE_SHELL"; then PATH_SET=1 else UPDATE_OUT='' if [ -n "$UPDATE_SHELL" ] && UPDATE_OUT=$(SHELL="$UPDATE_SHELL" "$UV" tool update-shell &1); then PATH_SET=1 else # uv checked the same shell's files and found its PATH line already there. case "$UPDATE_OUT" in *already\ up-to-date*|*already\ up\ to\ date*) PATH_SET=1 ;; esac if path_configured "$UPDATE_SHELL"; then PATH_SET=1 fi fi fi if [ "$PATH_SET" = 1 ]; then say "Open a new terminal to use the adengine command, or run this in the current one:" else warn "could not add $BIN_DIR to your PATH automatically. To use the adengine command, run:" fi say " export PATH=\"$BIN_DIR:\$PATH\"" } # Turns `adengine setup --json` into readable lines. Modes: # report.py FILE the setup results. Exit 7: setup reported its own readiness and next steps # (0.5.0 and later); for older releases, 0: an agent is registered and the # adengine-setup skill is in its skill folder; 6: an agent is registered without # that skill; 3: no agent is; 4: the output was not JSON (printed as it came); # 5: JSON without agent results. # report.py FILE next setup's next steps only. # report.py FILE closing the closing lines: "Done." with the first step, or the steps that are left. write_report_script() { cat >"$1" <<'PY' import json import sys MODE = sys.argv[2] if len(sys.argv) > 2 else "body" text = open(sys.argv[1], encoding="utf-8", errors="replace").read() data = None try: data = json.loads(text) except ValueError: start = text.rfind("\n{") if start >= 0: try: data = json.loads(text[start + 1:]) if MODE == "body": sys.stdout.write(text[:start + 1]) except ValueError: data = None if not isinstance(data, dict): if MODE == "body": sys.stdout.write(text) sys.exit(4) def sentence(value): words = " ".join(str(value or "").split()) if not words: return "" words = words[0].upper() + words[1:] return words if words[-1] in ".!?" else words + "." def next_hints(): found = [sentence(item.get("hint")) for item in data.get("next_steps") or [] if isinstance(item, dict) and item.get("hint")] if not found and data.get("next_step"): found = [sentence(data.get("next_step"))] return [hint for hint in dict.fromkeys(found) if hint] def show_steps(items): if len(items) == 1: print("Next step: " + items[0]) elif items: print("Next steps:") for number, item in enumerate(items, 1): print(" %d. %s" % (number, item)) LABELS = {"claude-code": "Claude Code", "codex": "Codex", "claude-desktop": "Claude Desktop", "cursor": "Cursor"} clients = data.get("clients") names = [LABELS.get(c.get("client"), str(c.get("client"))) for c in clients if isinstance(c, dict) and c.get("status") in ("registered", "unchanged")] if isinstance(clients, list) else [] # Setup 0.5.0 and later decides readiness with doctor's checks and lists the next steps itself. READINESS = isinstance(data.get("ready"), bool) and isinstance(data.get("next_steps"), list) if MODE == "next": show_steps(next_hints()) sys.exit(0) if MODE == "closing": if data.get("ready") is True: print("Done. " + sentence(data.get("next_step") or "Open your agent and run " + str(data.get("first_prompt") or "/adengine-setup"))) else: if not names: print("No agent is connected to Ad Engine yet.") show_steps(next_hints() or ["Run adengine doctor to see what is left."]) sys.exit(0) TAGS = {"ok": "ok", "unchanged": "ok", "registered": "ok", "restored": "ok", "removed": "removed", "warn": "warn", "skipped": "skip", "next": "next", "failed": "FAIL", "refused": "FAIL"} for step in data.get("steps") or []: if isinstance(step, dict): tag = TAGS.get(step.get("status"), step.get("status") or "?") message = step.get("message") or "" print(" [%s] %s%s" % (tag, step.get("step") or "", ": " + str(message) if message else "")) ffmpeg = data.get("ffmpeg") if isinstance(ffmpeg, dict) and ffmpeg.get("ok") is False: for hint in ffmpeg.get("hints") or []: print(" " + str(hint)) editors = data.get("editor_mcps") # An editor step that is also one of setup's next steps is printed once, with the next steps. listed = [item.get("hint") for item in data.get("next_steps") or [] if isinstance(item, dict)] if READINESS else [] steps = [item for item in dict.fromkeys(editors.get("instructions") or []) if item not in listed] \ if isinstance(editors, dict) else [] if steps: print("One more step:" if len(steps) == 1 else "Remaining manual steps:") for item in steps: print(" - " + str(item)) if data.get("workspace"): print(" Workspace: " + str(data["workspace"])) if not isinstance(clients, list): sys.exit(5) print(" Agents: " + (", ".join(names) or "none registered")) if READINESS: sys.exit(7) if not names: sys.exit(3) skills = data.get("skills") roots = skills.get("roots") if isinstance(skills, dict) else None exported = set() for root in roots if isinstance(roots, list) else []: if isinstance(root, dict): for key in ("written", "unchanged"): if isinstance(root.get(key), list): exported.update(str(folder) for folder in root[key]) sys.exit(0 if "adengine-setup" in exported else 6) PY } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM main() { ORIGINAL_PATH="${PATH:-}" SITE_DEFAULT='https://runargusapp.com' SITE="${ADENGINE_SITE:-$SITE_DEFAULT}" SITE="${SITE%/}" case "$SITE" in __*) fail "this is the unbuilt installer template. Use the command on the Ad Engine website, or set ADENGINE_SITE." ;; *[!A-Za-z0-9.:/_-]*) fail "ADENGINE_SITE must be a plain URL such as https://example.com." ;; https://?*|http://127.0.0.1:*|http://localhost:*) ;; *) fail "ADENGINE_SITE must start with https://." ;; esac PYTHON_VERSION="${ADENGINE_PYTHON:-3.12}" case "$PYTHON_VERSION" in ''|*[!A-Za-z0-9.@+_-]*) fail "ADENGINE_PYTHON must be a Python version such as 3.12." ;; esac UV_INSTALLER_URL="${ADENGINE_UV_INSTALLER_URL:-https://astral.sh/uv/install.sh}" # An access token is optional: only a private (development) channel needs one, and it is sent only when the # channel asks. It stays in this shell only, so the programs started below never see it. TOKEN="${ADENGINE_ACCESS_TOKEN:-}" unset ADENGINE_ACCESS_TOKEN ACCESS=public if [ "$(id -u 2>/dev/null || echo 1)" = 0 ] && [ -n "${SUDO_USER:-}" ] && [ "${ADENGINE_ALLOW_ROOT:-}" != 1 ]; then fail "run the command without sudo. Ad Engine installs into your own user account (set ADENGINE_ALLOW_ROOT=1 to override)." fi command -v curl >/dev/null 2>&1 || fail "curl is required." TMP_DIR=$(mktemp -d 2>/dev/null || mktemp -d -t adengine) || fail "could not create a temporary folder." chmod 700 "$TMP_DIR" || fail "could not protect the temporary folder $TMP_DIR." AUTH="$TMP_DIR/auth.curlrc" # The channel is asked without a token first (an empty curl config sends no Authorization header). A public # channel answers "available", and a leftover ADENGINE_ACCESS_TOKEN is then never sent or recorded. (umask 077 && : >"$AUTH") || fail "could not write a private file in $TMP_DIR." ANSWER=$(curl -fsS --retry 2 -K "$AUTH" "$SITE/api/releases/latest?format=sh" "$AUTH") \ || fail "could not write a private file in $TMP_DIR." ACCESS=private ANSWER=$(curl -fsS --retry 2 -K "$AUTH" "$SITE/api/releases/latest?format=sh" /dev/null; then UV_ADDED=true fi if UV=$(find_uv); then : else say "Installing uv, the Python package manager from astral.sh" curl -fsSL --retry 2 -o "$TMP_DIR/uv-installer.sh" "$UV_INSTALLER_URL" /dev/null || fail "the uv installer failed (see the messages above)." UV=$(find_uv) || fail "uv was installed but could not be found. Open a new terminal and run the command again." UV_ADDED=true fi say "Using uv at $UV" BIN_DIR=$("$UV" tool dir --bin "$TMP_DIR/setup.json" || SETUP_RC=$? AGENTS=unknown if [ -x "$TOOL_PY" ]; then write_report_script "$TMP_DIR/report.py" REPORT_RC=0 "$TOOL_PY" "$TMP_DIR/report.py" "$TMP_DIR/setup.json"