#!/bin/sh # Omnigent installer. # # Installs the published `omnigent` wheel from PyPI with uv, wires up PATH, # and points you at first-run. The wheel bundles the prebuilt web UI, so the # default install needs no Node/npm and runs no build. # # Options: # --version X install a specific PyPI release (default: latest) # --repo URL install from a git checkout instead (builds from source; # requires Node 22+/npm) — for development # --extra NAME install an optional-dependency extra (repeatable, or # comma-separated), e.g. --extra databricks # --non-interactive, --verbose # # uv and git (only with --repo) are required; the installer offers to install # them if missing. Node/npm are needed by the Claude/Codex/Pi harnesses, tmux # by their terminal launchers, and bubblewrap (Linux only) to OS-sandbox those # terminals — missing ones are warnings, not errors, unless building from # source. set -eu # Published PyPI package, the default install. --version pins a release. PACKAGE_NAME="omnigent" VERSION= # Comma-separated optional-dependency extras to install with the package # (e.g. "databricks"), accumulated from one or more --extra flags. Empty => # the base install with no extras. EXTRAS= # Set by --repo to install from a git checkout instead (development; builds # the web UI from source). Empty => install the published wheel from PyPI. REPO_URL= PYTHON_VERSION="3.12" INSTALL_URL= NON_INTERACTIVE=false VERBOSE=false ESC=$(printf '\033') RESET= BOLD= DIM= MAGENTA= GREEN= YELLOW= RED= use_terminal_ui() { [ -t 1 ] && [ "${TERM:-}" != "dumb" ] } init_style() { if use_terminal_ui && [ -z "${NO_COLOR:-}" ]; then RESET="${ESC}[0m" BOLD="${ESC}[1m" DIM="${ESC}[2m" # Brand accent — Otto's magenta-pink (#F43BA6), matching the Python CLI # palette in omnigent/inner/ui.py so the installer and the tool agree. MAGENTA="${ESC}[38;2;244;59;166m" GREEN="${ESC}[32m" YELLOW="${ESC}[33m" RED="${ESC}[31m" fi } # The Otto + "omnigent" wordmark lockup, printed once at the top of an # interactive install. Mirrors omnigent.inner.wordmark.lockup_lines(); the # whole lockup is painted in the brand magenta (flat — no gradient in sh). # Skipped off a TTY (use_terminal_ui) so piped/CI installs stay clean. print_banner() { use_terminal_ui || return 0 printf '\n' printf '%s ⠀⠀⠀⢠⣿⡄⠀⠀⠀ ██████╗ ███╗ ███╗███╗ ██╗██╗ ██████╗ ███████╗███╗ ██╗████████╗%s\n' "$MAGENTA" "$RESET" printf '%s ⢴⣶⣶⠉⣿⠉⣶⣶⡦ ██╔═══██╗████╗ ████║████╗ ██║██║██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝%s\n' "$MAGENTA" "$RESET" printf '%s ⠀⠙⣿⣶⣿⣶⣿⠋⠀ ██║ ██║██╔████╔██║██╔██╗ ██║██║██║ ███╗█████╗ ██╔██╗ ██║ ██║%s\n' "$MAGENTA" "$RESET" printf '%s ⠀⢠⣿⡿⠿⢿⣿⡄⠀ ╚██████╔╝██║ ╚═╝ ██║██║ ╚████║██║╚██████╔╝███████╗██║ ╚████║ ██║%s\n' "$MAGENTA" "$RESET" printf '%s ⠀⠈⠁⠀⠀⠀⠈⠁⠀ ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝%s\n' "$MAGENTA" "$RESET" printf '%s all your agents, one cli%s\n\n' "$DIM" "$RESET" } usage() { printf 'Usage: install_oss.sh [--non-interactive] [--verbose] [--version X] [--repo URL] [--extra NAME]\n' } step() { printf '%s==>%s %s\n' "$MAGENTA" "$RESET" "$1" } verbose() { if [ "$VERBOSE" = true ]; then printf '%sDEBUG:%s %s\n' "$DIM" "$RESET" "$1" >&2 fi } warn() { printf '%sWARNING:%s %s\n' "$YELLOW" "$RESET" "$1" >&2 } fail() { printf '%sERROR:%s %s\n' "$RED" "$RESET" "$1" >&2 exit 1 } spinner_frame() { case $(($1 % 4)) in 0) printf '-' ;; 1) printf '%s' "\\" ;; 2) printf '|' ;; *) printf '/' ;; esac } run_with_spinner() { label="$1" shift if [ "$VERBOSE" = true ]; then verbose "Running: $*" "$@" return fi if ! use_terminal_ui; then "$@" return fi log_file="${TMPDIR:-/tmp}/omnigent-oss-installer.$$.log" status_file="${TMPDIR:-/tmp}/omnigent-oss-installer.$$.status" rm -f "$log_file" "$status_file" ( if "$@" >"$log_file" 2>&1; then printf '0\n' >"$status_file" else printf '%s\n' "$?" >"$status_file" fi ) & command_pid=$! frame=0 while [ ! -f "$status_file" ]; do spinner="$(spinner_frame "$frame")" printf '\r\033[K%s%s%s %s%s%s' "$MAGENTA" "$spinner" "$RESET" "$BOLD" "$label" "$RESET" frame=$((frame + 1)) sleep 0.1 done wait "$command_pid" 2>/dev/null || true status="$(cat "$status_file")" rm -f "$status_file" if [ "$status" = 0 ]; then printf '\r\033[K%sok%s %s\n' "$GREEN" "$RESET" "$label" rm -f "$log_file" return fi printf '\r\033[K' warn "$label failed" cat "$log_file" >&2 rm -f "$log_file" return "$status" } parse_args() { while [ "$#" -gt 0 ]; do case "$1" in --non-interactive) NON_INTERACTIVE=true ;; --verbose) VERBOSE=true ;; --repo) if [ "$#" -lt 2 ]; then usage >&2 exit 1 fi REPO_URL="$2" shift ;; --version) if [ "$#" -lt 2 ]; then usage >&2 exit 1 fi VERSION="$2" shift ;; --extra) if [ "$#" -lt 2 ]; then usage >&2 exit 1 fi # Accumulate repeated flags into one comma-separated list; a value that # is itself comma-separated (--extra a,b) just concatenates cleanly. if [ -n "$EXTRAS" ]; then EXTRAS="$EXTRAS,$2" else EXTRAS="$2" fi shift ;; *) usage >&2 exit 1 ;; esac shift done } normalize_repo_url() { case "$REPO_URL" in "") # No --repo: install the published wheel from PyPI (the default). INSTALL_URL= return ;; git+ssh://* | git+https://* | git+http://*) INSTALL_URL="$REPO_URL" ;; ssh://*) INSTALL_URL="git+$REPO_URL" ;; https://* | http://*) INSTALL_URL="git+$REPO_URL" ;; *@*:*) user_host="${REPO_URL%%:*}" repo_path="${REPO_URL#*:}" INSTALL_URL="git+ssh://$user_host/$repo_path" ;; *) fail "Unsupported --repo URL: $REPO_URL. Use https://..., ssh://..., or git@host:org/repo.git." ;; esac if [ -n "$VERSION" ]; then fail "--version pins a PyPI release and cannot be combined with --repo (a git source install)." fi verbose "Repository install URL: $INSTALL_URL" } # True only for a from-source git install (--repo): that path builds the web # UI with npm, so Node/npm are hard requirements. A default PyPI install pulls # the prebuilt wheel and needs neither. building_from_source() { [ -n "$INSTALL_URL" ] } prompt_yes_no() { prompt="$1" if [ "$NON_INTERACTIVE" = true ]; then return 1 fi if ! ( : /dev/null; then return 1 fi printf '%s [Y/n] ' "$prompt" >/dev/tty if ! IFS= read -r answer /dev/null 2>&1; then step "git is available" return fi case "$(uname -s)" in Linux) install_cmd="$(linux_pkg_install_cmd git)" if [ -n "$install_cmd" ] && prompt_yes_no "git is required and not installed. Install it now ($install_cmd)?"; then # Run directly (not via run_with_spinner) so sudo can prompt for a password. sh -c "$install_cmd" || true command -v git >/dev/null 2>&1 && { step "git installed"; return; } fi ;; Darwin) warn "git is required. Install the Xcode command-line tools with: xcode-select --install" ;; esac fail "git is required. Install it, then rerun this installer." } # uv performs the install, so it's required; offer the official one-liner, # and fall back to a fail-with-hint on decline/non-interactive/failure. ensure_uv() { if command -v uv >/dev/null 2>&1; then step "uv is available" return fi installer='curl -LsSf https://astral.sh/uv/install.sh | sh' if prompt_yes_no "uv is required and not installed. Install it now ($installer)?"; then run_with_spinner "install uv" sh -c "$installer" || true # uv's installer drops the binary in ~/.local/bin (or ~/.cargo/bin) and # wires PATH for *future* shells; pull it onto PATH for the rest of this run. if ! command -v uv >/dev/null 2>&1; then for d in "$HOME/.local/bin" "$HOME/.cargo/bin"; do if [ -x "$d/uv" ]; then PATH="$d:$PATH" export PATH break fi done fi if command -v uv >/dev/null 2>&1; then step "Installed uv" return fi fail "uv installed but is not on PATH — open a new shell and rerun this installer." fi fail "uv is required. Install from https://docs.astral.sh/uv/getting-started/installation/, then rerun." } # ── Harness toolchain ──────────────────────────────────────────────── # # Node 22+/npm power the Claude/Codex/Pi harnesses; the default PyPI install # and the bare web UI don't need them, so a missing one is a warning here. # With --repo (source build) npm is required up front, so it becomes an error. # Report a missing prereq: fatal when building from source, otherwise a warning. require_or_warn() { if building_from_source; then fail "$1" fi warn "$1" } # Node >= 22.10: the Claude/Codex/Pi CLIs need worker_threads.markAsUncloneable # (added in 22.10). Probe the symbol rather than parse a version string. check_node() { if ! command -v node >/dev/null 2>&1; then require_or_warn "node not found — Node.js 22+ is needed for the Claude/Codex/Pi harnesses (https://nodejs.org)." return fi if node -e "process.exit(typeof require('node:worker_threads').markAsUncloneable === 'function' ? 0 : 1)" >/dev/null 2>&1; then step "Node.js is new enough for the harness CLIs" else require_or_warn "Node.js is older than 22.10 — the Claude/Codex/Pi harnesses need 22 LTS or newer (https://nodejs.org)." fi } check_npm() { if command -v npm >/dev/null 2>&1; then step "npm is available (installs the Claude/Codex/Pi harness CLIs on first run)" else require_or_warn "npm not found — needed to install the Claude/Codex/Pi harness CLIs (https://nodejs.org)." fi } # `omnigent claude` / `omnigent codex` launch through a local tmux terminal # and won't start without it, so surface it up front and offer to install it. # Emit the package-manager command that installs $1 on this Linux box, or # nothing when no known package manager is present. Shared by the tmux and # git install offers. linux_pkg_install_cmd() { pkg="$1" if command -v apt-get >/dev/null 2>&1; then printf 'sudo apt-get install -y %s' "$pkg" elif command -v dnf >/dev/null 2>&1; then printf 'sudo dnf install -y %s' "$pkg" elif command -v yum >/dev/null 2>&1; then printf 'sudo yum install -y %s' "$pkg" elif command -v pacman >/dev/null 2>&1; then printf 'sudo pacman -S --noconfirm %s' "$pkg" elif command -v zypper >/dev/null 2>&1; then printf 'sudo zypper install -y %s' "$pkg" fi } check_tmux() { if command -v tmux >/dev/null 2>&1; then step "tmux is available" return fi case "$(uname -s)" in Darwin) if command -v brew >/dev/null 2>&1; then if prompt_yes_no "tmux is missing (needed for \`omnigent claude\` / \`omnigent codex\`). Install it with brew?"; then run_with_spinner "brew install tmux" brew install tmux || warn "brew install tmux failed — install tmux manually before \`omnigent claude\`." return fi fi warn "tmux not found — \`omnigent claude\` / \`omnigent codex\` need it. Install with: brew install tmux" ;; Linux) install_cmd="$(linux_pkg_install_cmd tmux)" if [ -n "$install_cmd" ] && prompt_yes_no "tmux is missing (needed for \`omnigent claude\` / \`omnigent codex\`). Install it now ($install_cmd)?"; then # Run directly (not via run_with_spinner) so sudo can prompt for a password. sh -c "$install_cmd" || warn "tmux install failed — run manually: $install_cmd" command -v tmux >/dev/null 2>&1 && step "tmux installed" return fi if [ -n "$install_cmd" ]; then warn "tmux not found — \`omnigent claude\` / \`omnigent codex\` need it. Install with: $install_cmd" else warn "tmux not found — \`omnigent claude\` / \`omnigent codex\` need it. Install it with your package manager." fi ;; esac } # The native `omnigent claude` / `omnigent codex` / `pi` harnesses wrap each # agent terminal in a bubblewrap (`bwrap`) OS-sandbox; on Linux that isolation # is mandatory and fail-loud, so a missing `bwrap` binary makes those terminals # fail to start. macOS sandboxes with the built-in seatbelt backend and needs # nothing here, so this check is Linux-only. check_bubblewrap() { [ "$(uname -s)" = Linux ] || return 0 if command -v bwrap >/dev/null 2>&1; then step "bubblewrap (bwrap) is available" return fi install_cmd="$(linux_pkg_install_cmd bubblewrap)" if [ -n "$install_cmd" ] && prompt_yes_no "bubblewrap is missing (needed to sandbox native \`omnigent claude\` / \`omnigent codex\` terminals). Install it now ($install_cmd)?"; then run_with_spinner "install bubblewrap" sh -c "$install_cmd" || warn "bubblewrap install failed — run manually: $install_cmd" return fi if [ -n "$install_cmd" ]; then warn "bubblewrap (bwrap) not found — native \`omnigent claude\` / \`omnigent codex\` terminals need it on Linux. Install with: $install_cmd" else warn "bubblewrap (bwrap) not found — native \`omnigent claude\` / \`omnigent codex\` terminals need it on Linux. Install it with your package manager." fi } install_omnigent() { # Default: the published PyPI wheel (`omnigent`, optionally `omnigent==X`). # The wheel ships the prebuilt web UI, so there is no npm/Node step and no # source build — the fast, reliable path. `--repo` switches INSTALL_URL to a # git ref, which builds from source (and needs npm, checked above). # Extras suffix like "[databricks]" appended to the package name so the # optional-dependency group(s) install alongside the base package. Applies to # every mode below; empty when no --extra was given. extras_suffix= if [ -n "$EXTRAS" ]; then extras_suffix="[$EXTRAS]" fi if building_from_source; then # A PEP 508 direct reference attaches extras to a git source install: # "omnigent[databricks] @ git+https://...". Without extras, keep the bare # URL (the long-standing form uv accepts directly). if [ -n "$extras_suffix" ]; then target="${PACKAGE_NAME}${extras_suffix} @ ${INSTALL_URL}" else target="$INSTALL_URL" fi step "Installing Omnigent from source${extras_suffix:+ $extras_suffix} (Python $PYTHON_VERSION)" elif [ -n "$VERSION" ]; then target="${PACKAGE_NAME}${extras_suffix}==${VERSION}" step "Installing Omnigent $VERSION${extras_suffix:+ $extras_suffix} (Python $PYTHON_VERSION)" else target="${PACKAGE_NAME}${extras_suffix}" step "Installing Omnigent${extras_suffix:+ $extras_suffix} (Python $PYTHON_VERSION)" fi # --force so re-running upgrades instead of no-op'ing; -q hides uv's # "Installed N executables" summary (the package also ships an `omni` alias). run_with_spinner "uv tool install" uv tool install --force -q --python "$PYTHON_VERSION" "$target" } uv_tool_bin_dir() { uv tool dir --bin } path_contains() { dir="$1" case ":$PATH:" in *":$dir:"*) return 0 ;; *) return 1 ;; esac } pick_profile() { case "$(uname -s):${SHELL:-}" in Darwin:*/zsh) printf '%s/.zprofile\n' "$HOME" ;; Darwin:*/bash) printf '%s/.bash_profile\n' "$HOME" ;; Linux:*/zsh) printf '%s/.zshrc\n' "$HOME" ;; Linux:*/bash) printf '%s/.bashrc\n' "$HOME" ;; *) printf '%s/.profile\n' "$HOME" ;; esac } maybe_add_bin_to_path() { bin_dir="$1" if path_contains "$bin_dir"; then step "$bin_dir is already on PATH" return fi path_line="export PATH=\"$bin_dir:\$PATH\"" profile="$(pick_profile)" begin_marker="# >>> Omnigent installer >>>" end_marker="# <<< Omnigent installer <<<" warn "$bin_dir is not on PATH." if [ "$NON_INTERACTIVE" = true ]; then warn "Add it with: $path_line" return fi if [ -f "$profile" ] && grep -F "$begin_marker" "$profile" >/dev/null 2>&1; then if grep -F "$path_line" "$profile" >/dev/null 2>&1; then step "PATH is already configured in $profile" return fi fail "$profile already has an Omnigent installer block. Update it manually to: $path_line" fi if ! prompt_yes_no "Add $bin_dir to PATH in $profile?"; then warn "Skipping PATH update. Current shell can run: $path_line" return fi mkdir -p "${profile%/*}" { printf '\n%s\n' "$begin_marker" printf '%s\n' "$path_line" printf '%s\n' "$end_marker" } >>"$profile" step "Added $bin_dir to PATH in $profile" } verify_omnigent() { bin_dir="$1" cli_path="$bin_dir/omnigent" if [ ! -x "$cli_path" ]; then cli_path="$(command -v omnigent 2>/dev/null || true)" fi if [ -z "$cli_path" ]; then fail "Omnigent installed, but the omnigent command was not found." fi "$cli_path" --help >/dev/null step "Verified $cli_path" # `omni` is a shorthand alias installed alongside `omnigent`; check it so a # packaging regression that drops it surfaces here rather than later. for alias_cmd in omni; do if [ ! -x "$bin_dir/$alias_cmd" ] && ! command -v "$alias_cmd" >/dev/null 2>&1; then warn "the $alias_cmd alias was not installed (expected a console-script entry point alongside omnigent)." fi done } # No setup step here by design: the first `omnigent` run configures a model # credential and offers to install the harness CLI you pick. print_next_steps() { bin_dir="$1" command_prefix= if ! path_contains "$bin_dir"; then command_prefix="PATH=\"$bin_dir:\$PATH\" " fi printf '\n%sOmnigent installed successfully.%s\n\n' "$BOLD" "$RESET" printf 'Start chatting — first run sets up a model and a local web UI:\n' printf ' %s%somnigent%s\n\n' "$command_prefix" "$MAGENTA" "$RESET" printf 'Or launch a specific coding harness:\n' printf ' %somnigent claude # Claude Code\n' "$command_prefix" printf ' %somnigent codex # Codex\n\n' "$command_prefix" printf 'Manage model credentials any time:\n' printf ' %somnigent setup\n\n' "$command_prefix" printf '%sUsing a Databricks workspace as your model provider? Install the\n' "$DIM" printf 'Databricks CLI (https://docs.databricks.com/aws/en/dev-tools/cli/install)\n' printf 'and add it via: omnigent setup -> Databricks.%s\n' "$RESET" } main() { init_style parse_args "$@" print_banner normalize_repo_url check_platform check_prerequisites check_node check_npm check_tmux check_bubblewrap install_omnigent bin_dir="$(uv_tool_bin_dir)" verify_omnigent "$bin_dir" maybe_add_bin_to_path "$bin_dir" print_next_steps "$bin_dir" } main "$@"