#!/usr/bin/env bash
#
# sandbox-cli — the installer behind the sandbox desktop's menu
# menu entries (docker/sandbox-menu/).
#
#   sandbox-cli <tool>          install <tool> if missing, then run it here
#   sandbox-cli install <tool>  install only, don't run it
#   sandbox-cli settings        choose the g4f model, API key, HOME, provider and proxy
#   sandbox-cli reset           clear the saved settings, back to the defaults
#   sandbox-cli package         show, update or pin the g4f package
#   sandbox-cli update          update the g4f-go launcher itself
#   sandbox-cli upgrade         update the g4f package in the runtime
#   sandbox-cli uninstall       remove g4f-go and its managed data
#   sandbox-cli list            list the tools the desktop menu knows about
#   sandbox-cli urls            print the public URLs served by the g4f API
#   sandbox-cli hosts           print the /etc/hosts entries for the app domains
#   sandbox-cli harness         run a coding agent on the g4f API and models
#   sandbox-cli polli-sandbox   create/connect/manage hosted Polli sandboxes
#
# Each menu item runs:
#   xfce4-terminal --command="/usr/local/bin/sandbox-cli <tool>"
# The install happens on FIRST CLICK (vendor install script, npm package,
# `pip install --user`, or Google's Chrome .deb), so the image stays small;
# afterwards the CLI starts instantly in the same terminal window (Chrome
# opens on the desktop instead). nodejs/npm are already in the image for the
# npm-based tools, everything else is one click. Runs as `pn`.
#
# The g4f API runs the same command for the apps it serves by hostname: a
# request to <app>.g4f.local for an app that is not running starts
# `sandbox-cli <app>` in the background (see g4f/api/sandbox_apps.py), so the
# hostname works without opening the menu first.

set -u

# pip --user and the vendor installers write to ~/.local/bin, ~/.opencode/bin,
# ~/.openclaw/bin, ~/.kimi-code/bin, ~/.qoder/bin or ~/.lmstudio/bin, which are
# not on PATH inside a bare terminal.
if [ -n "${HOME:-}" ]; then
    PATH="$HOME/.local/bin:$HOME/.opencode/bin:$HOME/.openclaw/bin:$HOME/.kimi-code/bin:$HOME/.qoder/bin:$HOME/.lmstudio/bin:$PATH"
fi
export PATH

ANTIGRAVITY_INSTALLER="https://antigravity.google/cli/install.sh"
OPENCODE_INSTALLER="https://opencode.ai/v2/install"
# The headless OpenCode API behind opencode.g4f.local. Keep the port in step
# with the host router, which reads the same OPENCODE_PORT variable.
OPENCODE_PORT="${OPENCODE_PORT:-4096}"
# pi-freeflow is an extension for Pi and Oh My Pi (OMP), not a standalone CLI:
# the OMP and Pi entries install their host and add the extension to it, and
# the API entry runs the extension's own daemon on 28180. OMP ships a prebuilt
# binary from its installer, so the sandbox needs no Bun toolchain for it.
OMP_INSTALLER="https://omp.sh/install"
PI_INSTALLER="https://pi.dev/install.sh"
PI_FREEFLOW_PLUGIN="pi-freeflow"
# The shared pi-freeflow proxy listens on 28180. Its port can be moved with
# PI_FREEFLOW_PORT; keep the URL this entry prints in step with the daemon.
FREEFLOW_PORT="${PI_FREEFLOW_PORT:-28180}"
GOOSE_INSTALLER="https://github.com/block/goose/releases/download/stable/download_cli.sh"
CURSOR_INSTALLER="https://cursor.com/install"
OPENCLAW_INSTALLER="https://openclaw.ai/install.sh"
# Google publishes per-architecture .deb files; append the dpkg arch (amd64,
# arm64) to this prefix. The .deb pulls its own Chrome runtime libraries.
CHROME_DEB_BASE="https://dl.google.com/linux/direct/google-chrome-stable_current"
# The g4f docs are a website: open the real site in Chrome so links are
# clickable and code fences get the site's copy buttons.
G4F_DOCS_URL="https://g4f.dev/docs/"
# Vendor install scripts whose binaries land on PATH (~/.local/bin or
# /usr/local/bin, both searched above).
GROK_INSTALLER="https://x.ai/cli/install.sh"
DROID_INSTALLER="https://app.factory.ai/cli"
OPENHANDS_INSTALLER="https://install.openhands.dev/install.sh"
PLANDEX_INSTALLER="https://plandex.ai/install.sh"
FABRIC_INSTALLER="https://raw.githubusercontent.com/danielmiessler/fabric/main/scripts/installer/install.sh"
HERMES_INSTALLER="https://hermes-agent.nousresearch.com/install.sh"
# Oh My Hermes (OMH) is an operating layer on top of Hermes Agent: the npm
# package installs the `omh` launcher, and `omh setup --yes` registers its
# managed skills with the Hermes home the entry already configured for g4f.
OMH_PACKAGE="oh-my-hermes"
# The standalone hf CLI lives in its own virtualenv under ~/.hf-cli and drops
# a wrapper in ~/.local/bin. --exclude-skill keeps it from writing into the
# agents' skill directories; the Spaces entry is a terminal browser, not an
# agent integration.
HF_CLI_INSTALLER="https://hf.co/cli/install.sh"
# Humanizer is an agent skill rather than a CLI; `npx skills` copies it into
# ~/.agents/skills and links it for every agent it knows about.
HUMANIZER_SKILL="blader/humanizer"
# Both detectors are installed from their git checkouts with pip --user.
# ai-detect is a lightweight, offline text-risk analyzer; aidetect pulls the
# torch stack and is installed on first click only.
AI_TEXT_DETECTOR_REPO="https://github.com/lynote-ai/ai-text-detector.git"
AI_IMAGE_DETECTOR_REPO="https://github.com/lynote-ai/ai-image-detector.git"
# OpenBot, Orca and VoiceStudio are Electron desktop apps published as
# AppImages; the release tag is resolved at install time and the AppImage is
# unpacked into ~/.local/opt (see install_appimage).
OPENBOT_RELEASE_API="https://api.github.com/repos/nightly-labs/openbot/releases/latest"
OPENBOT_RELEASE_BASE="https://github.com/nightly-labs/openbot/releases/download"
# Orca keeps stable AppImage asset names, so the /releases/latest/download/
# redirect is enough. The GitHub API is rate-limited and was failing the
# install before the download even started.
ORCA_LATEST_BASE="https://github.com/stablyai/orca/releases/latest/download"
VOICESTUDIO_RELEASE_API="https://api.github.com/repos/debpalash/VoiceStudio/releases/latest"
VOICESTUDIO_RELEASE_BASE="https://github.com/debpalash/VoiceStudio/releases/download"
# ThinkRail is JetBrains' agentic IDE/CLI. Its installer verifies the release
# SHA-256 and writes the binary under ~/.local/bin.
THINKRAIL_INSTALLER="https://raw.githubusercontent.com/JetBrains/thinkrail/main/install.sh"
# HyperFrames renders HTML compositions to video with an agent; npm package.
HYPERFRAMES_PACKAGE="hyperframes"
# DeepTutor and nanobot are Python web apps. nanobot's WebUI binds 8765 by
# default, which the Remote Desktop entry already uses, so move it to 8766.
DEEPTUTOR_PACKAGE="deeptutor[full]"
NANOBOT_PACKAGE="nanobot-ai"
NANOBOT_PORT="8766"
# Agent Reach installs from its GitHub release zip through uv; the PyPI
# package named agent-reach is an unrelated project.
AGENT_REACH_ZIP="https://github.com/Panniantong/Agent-Reach/releases/latest/download/agent-reach.zip"
# Agent-skill integrations: ECC, Superpowers and Claude-Mem register
# themselves with OpenCode; Graphify copies its skill into ~/.agents/skills.
ECC_PACKAGE="ecc-universal@2.2.3"
SUPERPOWERS_PLUGIN="superpowers@git+https://github.com/obra/superpowers.git"
GRAPHIFY_PACKAGE="graphifyy"
CLAUDE_MEM_PACKAGE="claude-mem@15.4.0"
KIRO_INSTALLER="https://cli.kiro.dev/install"
KIMI_INSTALLER="https://code.kimi.com/kimi-code/install.sh"
# Roomote is a self-hosted service, not a CLI: its installer needs root, brings
# up Docker plus a Compose stack under /opt/roomote and drops the `roomote`
# management CLI in /usr/local/bin (see ensure_roomote).
ROOMOTE_INSTALLER="https://get.roomote.dev"
QODER_INSTALLER="https://qoder.com/install"
VIBE_INSTALLER="https://mistral.ai/vibe/install.sh"
# DeepSeek Harness (dsh) is DeepSeek's plugin-based coding harness on npm;
# Paseo and T3 Code are control surfaces that drive the agent CLIs already
# installed here. Paseo ships on npm, T3 Code through its vendor install script.
DEEPSEEK_HARNESS_PACKAGE="@deepseek-ai/dsh"
PASEO_PACKAGE="@getpaseo/cli"
T3CODE_INSTALLER="https://t3.codes/install.sh"
UV_INSTALLER="https://astral.sh/uv/install.sh"
# assistant-ui has no package of its own: its CLI creates the app (see
# https://www.assistant-ui.com/docs/installation). The app lives in
# ~/.assistant-ui, its chat route is pointed at the sandbox's g4f API -- which
# speaks the OpenAI protocol -- so it needs no provider key, and the g4f
# "auto" model lets g4f pick the provider and the model itself. The port can
# be moved with ASSISTANT_UI_PORT; keep the URL this entry prints in step with
# the host router, which reads the same variable.
ASSISTANT_UI_DIR_NAME=".assistant-ui"
ASSISTANT_UI_PORT="${ASSISTANT_UI_PORT:-3000}"
# Local model runtimes and their web UIs.
OLLAMA_INSTALLER="https://ollama.com/install.sh"
LMSTUDIO_INSTALLER="https://lmstudio.ai/install.sh"
# llama.cpp publishes per-build tarballs; the release tag is resolved at install
# time (see ensure_llamacpp) and the arch suffix appended to this prefix.
LLAMACPP_RELEASE_BASE="https://github.com/ggml-org/llama.cpp/releases/download"
# Jan ships a Linux AppImage; the release tag is resolved at install time.
JAN_RELEASE_BASE="https://github.com/janhq/jan/releases/download"
# Chat UIs. LobeChat publishes a Linux AppImage per release (tag resolved at
# install time, x64 only); Msty Studio serves a stable "latest" AppImage URL
# (x64 only). LibreChat is not packaged: it is cloned and built from source
# (see ensure_librechat) and needs MongoDB, which the image does not ship.
LOBECHAT_RELEASE_BASE="https://github.com/lobehub/lobehub/releases/download"
MSTY_APPIMAGE_URL="https://next-assets.msty.studio/app/latest/linux/MstyStudio_x86_64.AppImage"
# Cindy is an Electron desktop agent published as a regional Debian package.
# The tag is resolved from the GitHub API and the package is unpacked into a
# user-owned prefix by the installer shipped in the release itself.
CINDY_REPO="https://github.com/makecindy/cindy"
CINDY_REGION="${CINDY_REGION:-global}"
CINDY_PREFIX="${CINDY_PREFIX:-$HOME/.local/opt/cindy}"
LIBRECHAT_REPO="https://github.com/danny-avila/LibreChat.git"
# MongoDB publishes no trixie build of mongodb-org-server, so the server comes
# from the bookworm repository (the .deb is self-contained) or, on arm64, from
# the ubuntu2404 tarball -- fastdl serves no aarch64 debian12 tarball.
MONGODB_APT_BASE="https://repo.mongodb.org/apt/debian"
MONGODB_APT_DIST="bookworm/mongodb-org/8.0"
MONGODB_GPG_URL="https://pgp.mongodb.com/server-8.0.asc"
MONGODB_TARBALL_BASE="https://fastdl.mongodb.org/linux"
MONGODB_VERSION="8.0.34"
# SillyTavern is a Node web app without an upstream package: it is cloned and
# served with npm (see ensure_sillytavern). The port is moved off SillyTavern's
# default 8000 so a running Open WebUI Computer (cptr, also 8000) does not
# collide with it.
SILLYTAVERN_REPO="https://github.com/SillyTavern/SillyTavern.git"
SILLYTAVERN_DIR_NAME=".sillytavern"
SILLYTAVERN_PORT="8601"
# Open WebUI Computer (cptr) is pip-installable: one local server that serves
# files, terminal, git, editor and AI to any browser, on its documented
# default port 8000.
CPTR_PORT="8000"
# Open WebUI is the upstream chat interface from the Open WebUI organisation.
# Its documented default port 8080 is the g4f API's, so it listens on 8603
# instead (see the open-webui case). Its data directory keeps chats, accounts
# and settings under the (possibly custom) HOME applied for every entry.
OPEN_WEBUI_PORT="${OPEN_WEBUI_PORT:-8603}"
# code-server (VS Code in the browser) ships a vendor install script; the
# standalone method keeps the install under ~/.local. Its default port 8080 is
# the g4f API's, so it listens on 8602 instead (see the code-server case).
CODE_SERVER_INSTALLER="https://code-server.dev/install.sh"
CODE_SERVER_PORT="8602"
# The tunnel entry ("Tunnel Client") is backed by Cloudflare's
# cloudflared: `cloudflared tunnel --url <target>` opens a public HTTPS URL
# (trycloudflare.com) for a local port with no account and no config file. The
# per-architecture static binary is fetched from the latest release over HTTPS;
# the arch suffix is appended at install time (see ensure_cloudflared).
CLOUDFLARED_RELEASE_BASE="https://github.com/cloudflare/cloudflared/releases/latest/download"

INSTALL_ONLY=0

have() { command -v "$1" >/dev/null 2>&1; }
log()  { printf '==> %s\n' "$*"; }

# True when something accepts connections on 127.0.0.1:<port>.
port_open() {
    if have curl; then
        curl -s --max-time 1 "http://127.0.0.1:$1/json/version" >/dev/null 2>&1
    else
        (exec 3<>"/dev/tcp/127.0.0.1/$1") >/dev/null 2>&1
    fi
}

# Menu entries open a fresh terminal that closes on exit; hold it so the
# output of a one-shot command stays readable.
hold_open() {
    [ -t 0 ] || return 0
    printf '\nPress Enter to close...'
    read -r _ || true
}

die() {
    printf '\nsandbox-cli: error: %s\n' "$*" >&2
    hold_open
    exit 1
}

# Menu installs run as `pn`, which has passwordless sudo in the image.
as_root() {
    if [ "$(id -u)" -eq 0 ]; then
        "$@"
    else
        sudo -n "$@" || die "need root privileges for: $*"
    fi
}

# True when something accepts a TCP connection on 127.0.0.1:<port>. Unlike
# port_open this makes no HTTP request, so it also works for non-HTTP services
# such as the bundled MongoDB.
tcp_open() {
    (exec 3<>"/dev/tcp/127.0.0.1/$1") >/dev/null 2>&1
}

# Set KEY=value in an env-style file: replace the line when the key is already
# there (commented out or not), append it otherwise.
set_env_var() { # <file> <key> <value>
    local file="$1" key="$2" value="$3" tmp
    tmp="$(mktemp)" || die "could not create a temporary file"
    if grep -qE "^[[:space:]]*#?[[:space:]]*${key}=" "$file"; then
        sed -E "0,/^[[:space:]]*#?[[:space:]]*${key}=/s|^[[:space:]]*#?[[:space:]]*${key}=.*|${key}=${value}|" \
            "$file" >"$tmp" || die "could not update $key in $file"
    else
        cat "$file" >"$tmp"
        printf '%s=%s\n' "$key" "$value" >>"$tmp"
    fi
    mv "$tmp" "$file" || die "could not write $file"
}

# ---------------------------------------------------------------------------
# Sandbox settings (g4f model, API key, g4f provider + HOME)
#
# A desktop menu entry cannot export environment variables, so the launchers
# read their overrides from a small settings file that the "Sandbox Settings"
# menu entry writes. The file deliberately lives outside $HOME: changing HOME
# must not move the file that records it.
#
# Precedence, highest first:
#   G4F_SANDBOX_MODEL / G4F_SANDBOX_API_KEY / G4F_SANDBOX_HOME /
#   G4F_SANDBOX_PROVIDER / G4F_SANDBOX_PROXY
#                                          explicit environment override
#   the settings file                      what the menu entry saved
#   G4F_MODEL / G4F_API_KEY / $HOME / G4F_PROVIDER / G4F_PROXY
#                                          ambient environment, then defaults
#
# API KEY is exported as G4F_API_KEY for the g4f-aware launchers and for the
# API server the tray starts, so a key saved here authenticates both sides.
#
# HOME applies to every menu entry (see apply_sandbox_home): agents keep their
# config, credentials and caches under $HOME, so pointing it at a scratch
# directory gives a throwaway profile.
#
# PROXY applies to every menu entry too (see apply_sandbox_proxy): it is
# exported as G4F_PROXY (read by g4f itself) and as the conventional
# HTTP_PROXY/HTTPS_PROXY/ALL_PROXY variables (read by the agents and the tools
# they shell out to). It typically points at the local "Proxy
# Server" entry (mitmproxy on 127.0.0.1:8081); loopback stays direct. The
# proxy's CA is trusted at the same time (see trust_proxy_ca), and OpenCode's
# background server is told about both (see configure_opencode_service).
#
# The g4f-aware launchers always register a g4f provider that offers the
# "auto" model, so the API is reachable from the tool's model picker even
# before anything is chosen. A model chosen through G4F_SANDBOX_MODEL or the
# settings file is registered and used as the tool's default too; with no model
# chosen, the tool keeps its own default model. The provider is optional: with
# none set, g4f picks from its own rotation. When one is set and a model was
# chosen, the launchers pin it by prefixing the model name
# ("<provider>:<model>"), which is how g4f resolves a provider for a request;
# G4F_PROVIDER itself is read only by the g4f server.
# ---------------------------------------------------------------------------
real_home() {
    local home
    home="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6)"
    [ -n "$home" ] || home="${HOME:-/home/pn}"
    printf '%s' "$home"
}

SANDBOX_ENV_FILE="${G4F_SANDBOX_ENV:-$(real_home)/.config/g4f-sandbox/env}"
# mitmproxy signs the HTTPS traffic it intercepts with its own CA. The CA lives
# in a fixed confdir under the real home (not $HOME): the per-entry HOME
# setting must not move it, or a tool launched with another HOME would not find
# the certificate the proxy is using. The path is also exported to the tools
# (see apply_sandbox_proxy), so a custom location works the same way.
PROXY_CA_DIR="${G4F_SANDBOX_MITM_DIR:-$(real_home)/.config/g4f-sandbox/mitmproxy}"
PROXY_CA_CERT="$PROXY_CA_DIR/mitmproxy-ca-cert.pem"
PROXY_CA_BUNDLE="$PROXY_CA_DIR/ca-bundle.pem"
# The system trust store, used as the base of the bundle the tools are pointed
# at: mitmproxy's CA is appended to it rather than replacing it, so direct
# (NO_PROXY) connections keep working.
SYSTEM_CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt"
SANDBOX_MODEL=""
SANDBOX_HOME=""
SANDBOX_PROVIDER=""
SANDBOX_PROXY=""
SANDBOX_API_KEY=""
# 1 once a model was chosen (G4F_SANDBOX_MODEL, or the settings file), else 0.
SANDBOX_MODEL_SET=0
# 1 once the settings file contains G4F_API_KEY (even when it is empty), so a
# cleared key suppresses an ambient G4F_API_KEY instead of falling back to it.
SANDBOX_API_KEY_SET=0
# The model every g4f provider registration exposes even when no model was
# chosen: g4f's "auto" lets the server pick a provider and model itself.
G4F_AUTO_MODEL="auto"

load_sandbox_settings() {
    SANDBOX_MODEL=""
    SANDBOX_HOME=""
    SANDBOX_PROVIDER=""
    SANDBOX_PROXY=""
    SANDBOX_API_KEY=""
    SANDBOX_MODEL_SET=0
    SANDBOX_API_KEY_SET=0
    [ -f "$SANDBOX_ENV_FILE" ] || return 0
    local line key value
    while IFS= read -r line || [ -n "$line" ]; do
        case "$line" in ''|'#'*) continue ;; esac
        key="${line%%=*}"
        value="${line#*=}"
        case "$key" in
            G4F_MODEL)
                SANDBOX_MODEL="$value"
                [ -n "$value" ] && SANDBOX_MODEL_SET=1 ;;
            SANDBOX_HOME) SANDBOX_HOME="$value" ;;
            G4F_PROVIDER) SANDBOX_PROVIDER="$value" ;;
            SANDBOX_PROXY) SANDBOX_PROXY="$value" ;;
            G4F_API_KEY) SANDBOX_API_KEY="$value"; SANDBOX_API_KEY_SET=1 ;;
        esac
    done < "$SANDBOX_ENV_FILE"
}

save_sandbox_settings() { # <model> <home> <provider> <proxy> <api-key>
    local dir
    dir="$(dirname "$SANDBOX_ENV_FILE")"
    mkdir -p "$dir" || die "cannot create $dir"
    {
        printf '# g4f sandbox settings — written by: sandbox-cli settings\n'
        printf '# Read by the menu launchers (HOME, proxy, API key) and the g4f-aware\n'
        printf '# ones (OpenCode, Copilot, Hermes, LibreChat, assistant-ui).\n'
        printf 'G4F_MODEL=%s\n' "$1"
        printf 'SANDBOX_HOME=%s\n' "$2"
        printf 'G4F_PROVIDER=%s\n' "$3"
        printf 'SANDBOX_PROXY=%s\n' "$4"
        printf 'G4F_API_KEY=%s\n' "$5"
    } > "$SANDBOX_ENV_FILE" || die "cannot write $SANDBOX_ENV_FILE"
    # The file can hold G4F_API_KEY; keep it private to the sandbox user.
    chmod 600 "$SANDBOX_ENV_FILE" 2>/dev/null || true
}

mask_secret() { # <value>
    local len="${#1}"
    if [ "$len" -eq 0 ]; then
        printf '(unset)'
    else
        printf '******** (%s characters)' "$len"
    fi
}

show_sandbox_settings() {
    printf 'settings file: %s\n' "$SANDBOX_ENV_FILE"
    if [ -f "$SANDBOX_ENV_FILE" ]; then
        printf 'G4F_MODEL:     %s\n' "${SANDBOX_MODEL:-(unset)}"
        printf 'G4F_API_KEY:   %s\n' "$(mask_secret "$SANDBOX_API_KEY")"
        printf 'HOME:          %s\n' "${SANDBOX_HOME:-(unset)}"
        printf 'G4F_PROVIDER:  %s\n' "${SANDBOX_PROVIDER:-(unset)}"
        printf 'PROXY:         %s\n' "${SANDBOX_PROXY:-(unset)}"
    else
        printf 'G4F_MODEL:     %s (default)\n' "${G4F_MODEL:-(unset)}"
        printf 'G4F_API_KEY:   %s (default)\n' "$(mask_secret "${G4F_API_KEY:-}")"
        printf 'HOME:          %s (default)\n' "$(real_home)"
        printf 'G4F_PROVIDER:  %s (default)\n' "${G4F_PROVIDER:-(unset)}"
        printf 'PROXY:         %s (default)\n' "${G4F_PROXY:-(unset)}"
    fi
}

validate_sandbox_home() { # <dir>
    case "$1" in
        /*) ;;
        *) die "HOME must be an absolute path: $1" ;;
    esac
    mkdir -p "$1" 2>/dev/null || true
    [ -w "$1" ] && return 0
    # A path outside the user's own tree (e.g. /opt/agent-home) needs root.
    # Create it as root, then hand it to the invoking user so the agents can
    # actually write their config there.
    log "creating $1 as root (needs sudo)..."
    as_root mkdir -p "$1" || die "cannot create HOME directory: $1"
    as_root chown "$(id -u):$(id -g)" "$1" \
        || die "cannot take ownership of HOME directory: $1"
    [ -w "$1" ] || die "HOME directory is not writable: $1"
}

prompt_sandbox_settings() {
    local model home provider proxy api_key answer
    model="${SANDBOX_MODEL:-${G4F_MODEL:-}}"
    home="${SANDBOX_HOME:-$(real_home)}"
    provider="${SANDBOX_PROVIDER:-${G4F_PROVIDER:-}}"
    proxy="${SANDBOX_PROXY:-${G4F_PROXY:-}}"
    api_key="${SANDBOX_API_KEY:-${G4F_API_KEY:-}}"
    printf 'g4f sandbox settings — press Enter to keep the value in brackets.\n\n'
    printf 'Model passed to the g4f API (G4F_MODEL).\n'
    read -r -p "  model [$model]: " answer || true
    [ -n "$answer" ] && model="$answer"
    printf '\nAPI key the server requires and the launchers send (G4F_API_KEY).\n'
    printf 'Enter keeps it; clear it with: sandbox-cli settings --api-key ""\n'
    read -r -s -p "  API key [$([ -n "$api_key" ] && printf set || printf none)]: " answer || true
    printf '\n'
    [ -n "$answer" ] && api_key="$answer"
    printf '\nHOME used by every menu entry (agents keep their config here).\n'
    read -r -p "  HOME [$home]: " answer || true
    [ -n "$answer" ] && home="$answer"
    printf '\ng4f provider the g4f-aware launchers pin (G4F_PROVIDER).\n'
    printf 'It is sent as "<provider>:<model>"; Enter keeps it, clear it with:\n'
    printf '  sandbox-cli settings --provider ""\n'
    read -r -p "  provider [${provider:-none}]: " answer || true
    [ -n "$answer" ] && provider="$answer"
    printf '\nProxy used by every menu entry (HTTP_PROXY/HTTPS_PROXY and G4F_PROXY).\n'
    printf 'Point it at the Proxy Server entry, e.g. http://127.0.0.1:8081.\n'
    printf 'Enter keeps it, clear it with:\n'
    printf '  sandbox-cli settings --proxy ""\n'
    read -r -p "  proxy [${proxy:-none}]: " answer || true
    [ -n "$answer" ] && proxy="$answer"
    printf '\n'
    validate_sandbox_home "$home"
    save_sandbox_settings "$model" "$home" "$provider" "$proxy" "$api_key"
    load_sandbox_settings
    show_sandbox_settings
}

# The full-screen editor lives in a separate stdlib-only Python script so the
# line prompts below stay available on a terminal where curses is unavailable.
sandbox_settings_script() {
    local candidate
    if [ -n "${G4F_SANDBOX_SETTINGS_BIN:-}" ]; then
        [ -f "${G4F_SANDBOX_SETTINGS_BIN}" ] \
            && { printf '%s\n' "${G4F_SANDBOX_SETTINGS_BIN}"; return 0; }
        return 1
    fi
    candidate="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)/sandbox-settings"
    if [ -f "$candidate" ]; then
        printf '%s\n' "$candidate"
        return 0
    fi
    candidate="$(command -v sandbox-settings 2>/dev/null || true)"
    [ -n "$candidate" ] && { printf '%s\n' "$candidate"; return 0; }
    return 1
}

run_settings_tool() {
    local script
    script="$(sandbox_settings_script)" || return 127
    have python3 || return 127
    python3 "$script" "$@"
}

run_settings_tui() {
    [ -t 0 ] && [ -t 1 ] || return 2
    run_settings_tool
}

prompt_api_key() {
    local key="${SANDBOX_API_KEY:-${G4F_API_KEY:-}}" answer
    [ -t 0 ] || die "sandbox-cli settings --api-key needs a terminal"
    printf 'g4f API key — press Enter to keep the current value.\n\n'
    printf 'The server requires this key and the menu launchers send it (G4F_API_KEY).\n'
    printf 'Clear it with: sandbox-cli settings --api-key ""\n\n'
    read -r -s -p "  API key [$([ -n "$key" ] && printf set || printf none)]: " answer || true
    printf '\n'
    [ -n "$answer" ] && key="$answer"
    save_sandbox_settings \
        "${SANDBOX_MODEL:-${G4F_MODEL:-}}" \
        "${SANDBOX_HOME:-$(real_home)}" \
        "${SANDBOX_PROVIDER:-${G4F_PROVIDER:-}}" \
        "${SANDBOX_PROXY:-${G4F_PROXY:-}}" \
        "$key"
    load_sandbox_settings
    show_sandbox_settings
}

settings_usage() {
    cat <<'EOF'
usage: sandbox-cli settings                    open the interactive settings editor
       sandbox-cli settings --show             print the saved settings
       sandbox-cli settings --json             print saved/effective settings as JSON
       sandbox-cli settings --check            validate the settings file
       sandbox-cli settings --generate-key     print a new random API key (does not save it)
       sandbox-cli settings --restart-server   ask the running tray to restart the API
       sandbox-cli settings --model <id>       set the model (keeps the rest)
       sandbox-cli settings --api-key <key>    set the API key ("" clears it)
       sandbox-cli settings --api-key          prompt for just the API key
       sandbox-cli settings --home <dir>       set HOME (keeps the rest)
       sandbox-cli settings --provider <name>  set the g4f provider
       sandbox-cli settings --proxy <url>      set the HTTP(S) proxy
       sandbox-cli settings --unset            drop the file and use the defaults

The interactive editor validates every value before writing, shows whether a
value comes from the environment, the settings file or the built-in default,
masks the API key (with reveal and generate actions), and asks the tray to
restart the API after saving. Keys: up/down move, Enter edit, s save, t test
the saved connection, r restart the server, R reset, g generate a key,
v reveal, c clear a field, q quit.

HOME and PROXY apply to every menu entry. The g4f-aware launchers (OpenCode,
Copilot, Hermes, LibreChat, assistant-ui) additionally read the model, provider
and API key. The API key is exported as G4F_API_KEY, both to the tools and to
the server the tray starts, so one saved key authenticates both sides. The
environment variables G4F_SANDBOX_MODEL, G4F_SANDBOX_API_KEY,
G4F_SANDBOX_HOME, G4F_SANDBOX_PROVIDER and G4F_SANDBOX_PROXY override the
settings file. PROXY is exported as G4F_PROXY (for g4f) and as
HTTP_PROXY/HTTPS_PROXY/ALL_PROXY (for the agents), with loopback kept direct.
The launchers always register the g4f provider with the "auto" model; a saved
model is also registered and becomes the tool's default, while without one the
tool keeps its own default model. Copilot only applies its BYOK provider when a
model is set.
EOF
}

run_settings() {
    local model="" home="" provider="" proxy="" api_key=""
    local set_model=0 set_home=0 set_provider=0 set_proxy=0 set_api_key=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --show|show)
                show_sandbox_settings
                return 0 ;;
            --json|--check|--generate-key|--restart-server)
                run_settings_tool "$1"
                return $? ;;
            --unset|unset)
                rm -f "$SANDBOX_ENV_FILE" || die "cannot remove $SANDBOX_ENV_FILE"
                log "cleared $SANDBOX_ENV_FILE"
                return 0 ;;
            --model)
                [ $# -ge 2 ] || die "--model needs a value"
                model="$2"; set_model=1; shift 2 ;;
            --home)
                [ $# -ge 2 ] || die "--home needs a value"
                home="$2"; set_home=1; shift 2 ;;
            --provider)
                [ $# -ge 2 ] || die "--provider needs a value"
                provider="$2"; set_provider=1; shift 2 ;;
            --proxy)
                [ $# -ge 2 ] || die "--proxy needs a value"
                proxy="$2"; set_proxy=1; shift 2 ;;
            --api-key)
                if [ $# -lt 2 ]; then
                    prompt_api_key
                    return 0
                fi
                api_key="$2"; set_api_key=1; shift 2 ;;
            -h|--help|help)
                settings_usage
                return 0 ;;
            *)
                die "unknown settings option \"$1\" — run: sandbox-cli settings --help" ;;
        esac
    done
    if [ "$set_model" = 1 ] || [ "$set_home" = 1 ] || [ "$set_provider" = 1 ] \
        || [ "$set_proxy" = 1 ] || [ "$set_api_key" = 1 ]; then
        [ "$set_model" = 1 ] || model="${SANDBOX_MODEL:-${G4F_MODEL:-}}"
        [ "$set_home" = 1 ] || home="${SANDBOX_HOME:-$(real_home)}"
        [ "$set_provider" = 1 ] || provider="${SANDBOX_PROVIDER:-${G4F_PROVIDER:-}}"
        [ "$set_proxy" = 1 ] || proxy="${SANDBOX_PROXY:-${G4F_PROXY:-}}"
        [ "$set_api_key" = 1 ] || api_key="${SANDBOX_API_KEY:-${G4F_API_KEY:-}}"
        validate_sandbox_home "$home"
        save_sandbox_settings "$model" "$home" "$provider" "$proxy" "$api_key"
        load_sandbox_settings
        show_sandbox_settings
        return 0
    fi
    [ -t 0 ] || die "sandbox-cli settings needs a terminal (or use --model/--api-key/--home/--provider/--proxy/--show/--json)"
    run_settings_tui
    local rc=$?
    if [ "$rc" -eq 2 ] || [ "$rc" -eq 127 ]; then
        # No curses editor or no Python: keep the line-based prompt working.
        prompt_sandbox_settings
        return 0
    fi
    return "$rc"
}

reset_usage() {
    cat <<'EOF'
usage: sandbox-cli reset            clear the saved settings (asks first)
       sandbox-cli reset --yes      clear them without asking
       sandbox-cli reset --show     print what would be cleared

Drops the settings file written by "sandbox-cli settings", so the launchers
fall back to the ambient environment and the built-in defaults (G4F_MODEL,
G4F_API_KEY, $HOME, G4F_PROVIDER). Only that file is removed — installed tools,
agent configs and the g4f runtime are left alone.
EOF
}

run_reset() {
    local assume_yes=0
    while [ $# -gt 0 ]; do
        case "$1" in
            --yes|-y|yes)
                assume_yes=1; shift ;;
            --show|show)
                show_sandbox_settings
                return 0 ;;
            -h|--help|help)
                reset_usage
                return 0 ;;
            *)
                die "unknown reset option \"$1\" — run: sandbox-cli reset --help" ;;
        esac
    done
    if [ ! -f "$SANDBOX_ENV_FILE" ]; then
        log "no settings file at $SANDBOX_ENV_FILE — nothing to reset"
        hold_open
        return 0
    fi
    if [ "$assume_yes" = 0 ]; then
        [ -t 0 ] || die "sandbox-cli reset needs a terminal (or use --yes)"
        printf 'This removes %s and restores the default model, API key, HOME and provider.\n' \
            "$SANDBOX_ENV_FILE"
        local answer
        read -r -p "Reset the sandbox settings? [y/N]: " answer || true
        case "$answer" in
            y|Y|yes|YES) ;;
            *) log "kept $SANDBOX_ENV_FILE"; hold_open; return 0 ;;
        esac
    fi
    rm -f "$SANDBOX_ENV_FILE" || die "cannot remove $SANDBOX_ENV_FILE"
    load_sandbox_settings
    log "cleared $SANDBOX_ENV_FILE"
    show_sandbox_settings
    hold_open
}

load_sandbox_settings

# ---------------------------------------------------------------------------
# g4f package management
#
# The sandbox's Python runtime and g4f install are owned by g4f-go. The tray's
# "Update g4f package" and "Change g4f package version" items open this command
# in a terminal; the tray restarts the server when the terminal closes.
# ---------------------------------------------------------------------------
package_show() {
    if have g4f-go; then
        g4f-go --version 2>/dev/null || true
        g4f-go status 2>/dev/null || true
    else
        printf 'g4f-go: not installed\n'
    fi
    if have python3; then
        python3 - <<'PY' 2>/dev/null || true
import importlib.metadata

try:
    print("g4f package:", importlib.metadata.version("g4f"))
except importlib.metadata.PackageNotFoundError:
    print("g4f package: not installed")
PY
    fi
}

package_usage() {
    cat <<'EOF'
usage: sandbox-cli package                 ask for a version (Enter keeps it)
       sandbox-cli package --show          show the running g4f package
       sandbox-cli package --update        install the latest g4f release
       sandbox-cli package --version <v>   install exactly g4f <v>
       sandbox-cli package --version latest
                                           same as --update

The package is managed by g4f-go, which pins an exact version in
~/.g4f/python-embed/.g4f-runtime/.g4f-version. "latest" removes that pin and
tracks new g4f releases again. Restart the server from the tray (or Ctrl-C
and reopen the API) after the install finishes.
EOF
}

run_package() {
    local action="prompt" version=""
    while [ $# -gt 0 ]; do
        case "$1" in
            --show|show)
                package_show
                hold_open
                return 0 ;;
            --update|update)
                action="update"; shift ;;
            --version)
                [ $# -ge 2 ] || die "--version needs a version (or \"latest\")"
                version="$2"; action="version"; shift 2 ;;
            --latest)
                action="update"; shift ;;
            -h|--help|help)
                package_usage
                return 0 ;;
            *)
                die "unknown package option \"$1\" — run: sandbox-cli package --help" ;;
        esac
    done
    have g4f-go || die "g4f-go is not installed"
    if [ "$action" = "prompt" ]; then
        [ -t 0 ] || die "sandbox-cli package needs a terminal (or use --show/--update/--version)"
        package_show
        printf '\n'
        printf 'Enter a g4f version to install (for example 0.5.2), or\n'
        printf '"latest" to track the newest release. Enter keeps the current package.\n'
        read -r -p 'g4f version [keep]: ' version || true
        case "$version" in
            "") log "keeping the current g4f package"; hold_open; return 0 ;;
            latest|LATEST) action="update" ;;
            *) action="version" ;;
        esac
    fi
    if [ "$action" = "update" ] || [ "$version" = "latest" ]; then
        # The saved proxy/HOME also apply to the network install.
        apply_sandbox_home
        apply_sandbox_proxy
        log "installing the latest g4f package..."
        g4f-go install --upgrade g4f || die "g4f package update failed"
    else
        [ -n "$version" ] || die "no g4f version given"
        apply_sandbox_home
        apply_sandbox_proxy
        log "installing g4f $version..."
        g4f-go install "g4f==$version" || die "installing g4f $version failed"
    fi
    log "done; restart the server to load the new package (the tray does this when this window closes)"
    package_show
    hold_open
}

# ---------------------------------------------------------------------------
# g4f-go management
#
# sandbox-cli owns the desktop tools; g4f-go owns the launcher binary, its
# downloaded CPython runtime and the g4f package inside it. These subcommands
# forward to g4f-go so the sandbox can be updated or removed from the same CLI.
# ---------------------------------------------------------------------------
management_usage() {
    cat <<'EOF'
usage: sandbox-cli update [--check] [--force] [--version <tag>]
                                      update the g4f-go launcher itself
       sandbox-cli upgrade            update the g4f package in the runtime
       sandbox-cli uninstall [--keep-binary] [--keep-data] [--dry-run]
                                      remove g4f-go and its managed data

`update` replaces only the launcher binary; the runtime and the installed g4f
package are left in place. `upgrade` updates g4f in the runtime (the pinned
version, when one is set) and leaves the launcher alone. `uninstall` also
accepts a Python package name, for example `sandbox-cli uninstall g4f`.
EOF
}

run_management() { # <g4f-go command> [args...]
    local command="$1"
    shift
    case "${1:-}" in
        help|-h|--help)
            management_usage
            return 0 ;;
    esac
    have g4f-go || die "g4f-go is not installed"
    apply_sandbox_home
    apply_sandbox_proxy
    g4f-go "$command" "$@"
    local status=$?
    [ "$status" -eq 0 ] || die "g4f-go $command failed"
    hold_open
}

# HOME is a per-menu-entry setting, so it is applied for every tool, not just
# the g4f-aware ones: agents keep their config, credentials and caches under
# $HOME, and a scratch HOME gives a throwaway profile. The PATH entries added
# at the top of this script were derived from the original HOME, so they are
# re-prepended here.
apply_sandbox_home() {
    local home="${G4F_SANDBOX_HOME:-${SANDBOX_HOME:-}}"
    [ -n "$home" ] || return 0
    # A HOME saved before it existed (or created by root) would make every
    # agent fail on its first write, so make sure it is there and ours.
    if [ ! -d "$home" ] || [ ! -w "$home" ]; then
        validate_sandbox_home "$home"
    fi
    export HOME="$home"
    PATH="$HOME/.local/bin:$HOME/.opencode/bin:$HOME/.openclaw/bin:$HOME/.kimi-code/bin:$HOME/.qoder/bin:$PATH"
    export PATH
}

# The API key is shared by both sides of the g4f integration: the server the
# tray starts reads it from the same settings file, and the launchers send it
# as a bearer token. An explicit G4F_SANDBOX_API_KEY wins over the saved value.
apply_sandbox_api_key() {
    if [ -n "${G4F_SANDBOX_API_KEY+x}" ]; then
        if [ -n "$G4F_SANDBOX_API_KEY" ]; then
            export G4F_API_KEY="$G4F_SANDBOX_API_KEY"
        else
            unset G4F_API_KEY
        fi
        return 0
    fi
    if [ "$SANDBOX_API_KEY_SET" = 1 ]; then
        if [ -n "$SANDBOX_API_KEY" ]; then
            export G4F_API_KEY="$SANDBOX_API_KEY"
        else
            unset G4F_API_KEY
        fi
        return 0
    fi
    [ -n "${G4F_API_KEY:-}" ] && export G4F_API_KEY
    return 0
}

# mitmproxy signs the HTTPS traffic it intercepts with its own CA, which no
# tool trusts by default: without it every intercepted HTTPS request fails with
# "unable to verify the first certificate" (OpenCode logs exactly that for its
# update check and for models.opencode.ai). Point the tools at a bundle that
# holds the system CAs plus mitmproxy's, and install the CA into the system
# store so anything that reads it directly (curl, Python, Chrome) works too.
#
# The bundle is rebuilt on every launch: mitmproxy regenerates its CA when the
# confdir is recreated, and a stale bundle would silently break TLS again.
# Every failure here is non-fatal — a proxy that has not run yet simply has no
# CA to trust, and the launcher must still start the tool. as_root is
# deliberately not used: it dies (and waits for Enter) on failure, which would
# turn a missing CA into a launcher that never starts.
trust_proxy_ca() {
    [ -r "$PROXY_CA_CERT" ] || return 0
    local bundle="$PROXY_CA_BUNDLE"
    if ! { cat "$SYSTEM_CA_BUNDLE" 2>/dev/null || :; cat "$PROXY_CA_CERT"; } > "$bundle" 2>/dev/null; then
        log "could not write the proxy CA bundle at $bundle; HTTPS through the proxy may fail"
        return 0
    fi
    export NODE_EXTRA_CA_CERTS="$bundle"
    export SSL_CERT_FILE="$bundle" REQUESTS_CA_BUNDLE="$bundle" CURL_CA_BUNDLE="$bundle"
    export GIT_SSL_CAINFO="$bundle" AWS_CA_BUNDLE="$bundle"
    # The system store is what Chrome and the other GUI apps read. Installing
    # the CA there is idempotent: the anchor is overwritten in place and
    # update-ca-certificates rebuilds the store from the anchors, so repeated
    # launches do not pile up copies. update-ca-certificates lives in /usr/sbin,
    # which is not on the PATH the menu entries run with.
    local anchor="/usr/local/share/ca-certificates/g4f-sandbox-proxy.crt"
    [ -f "$anchor" ] && cmp -s "$PROXY_CA_CERT" "$anchor" && return 0
    if [ "$(id -u)" -eq 0 ]; then
        install -m 0644 "$PROXY_CA_CERT" "$anchor" 2>/dev/null || return 0
        /usr/sbin/update-ca-certificates >/dev/null 2>&1 || :
    elif sudo -n install -m 0644 "$PROXY_CA_CERT" "$anchor" 2>/dev/null; then
        sudo -n /usr/sbin/update-ca-certificates >/dev/null 2>&1 || :
    else
        log "could not install the proxy CA into the system store; GUI apps may not trust the proxy"
    fi
}

# PROXY is the other per-menu-entry setting that applies to every tool: it is
# exported both as G4F_PROXY, which g4f itself reads for its own requests, and
# as the conventional HTTP_PROXY/HTTPS_PROXY/ALL_PROXY variables, which the
# agents and the tools they shell out to honour. The typical value is the local
# "Proxy Server" entry (mitmproxy on 127.0.0.1:8081). Loopback is
# added to NO_PROXY so the launchers can still reach the g4f API and the other
# local servers directly instead of looping back through the proxy.
apply_sandbox_proxy() {
    local proxy="${G4F_SANDBOX_PROXY:-${SANDBOX_PROXY:-${G4F_PROXY:-}}}"
    [ -n "$proxy" ] || return 0
    export G4F_PROXY="$proxy"
    export HTTP_PROXY="$proxy" HTTPS_PROXY="$proxy" ALL_PROXY="$proxy"
    export http_proxy="$proxy" https_proxy="$proxy" all_proxy="$proxy"
    local no_proxy="${NO_PROXY:-${no_proxy:-127.0.0.1,localhost,::1}}"
    export NO_PROXY="$no_proxy" no_proxy="$no_proxy"
    trust_proxy_ca
}

ensure_node() {
    have npm && return 0
    log "installing Node.js + npm (first run)..."
    as_root apt-get update -qq || die "apt-get update failed"
    as_root apt-get install -y -qq nodejs npm || die "could not install nodejs/npm"
    have npm || die "npm not found after installing nodejs/npm"
}

# Some vendor installers (Kiro CLI) unpack a .zip; the image ships zip but not
# unzip, so pull it in on demand.
ensure_unzip() {
    have unzip && return 0
    log "installing unzip (needed by the installer)..."
    as_root apt-get update -qq || die "apt-get update failed"
    as_root apt-get install -y -qq unzip || die "could not install unzip"
    have unzip || die "unzip not found after installing it"
}

# Hermes clones its own checkout, so it needs git; the image ships none.
ensure_git() {
    have git && return 0
    log "installing git (needed by the installer)..."
    as_root apt-get update -qq || die "apt-get update failed"
    as_root apt-get install -y -qq git || die "could not install git"
    have git || die "git not found after installing it"
}

ensure_npm() { # <npm package> <binary>
    have "$2" && return 0
    ensure_node
    log "installing $1 (npm install -g)..."
    as_root npm install -g --no-fund --no-audit "$1" || die "npm install $1 failed"
    have "$2" || die "$2 not on PATH after installing $1"
}

ensure_pip() { # <pypi package> <binary>
    have "$2" && return 0
    log "installing $1 (pip install --user)..."
    python3 -m pip install --user --quiet "$1" || die "pip install $1 failed"
    have "$2" || die "$2 not on PATH after installing $1"
}

ensure_apt_soft() { # <apt package> <binary>; returns 1 instead of exiting
    have "$2" && return 0
    log "installing $1 (apt)..."
    # --no-install-recommends keeps menu installs small: thunar's recommends
    # alone pull tumbler -> gdal/opencv and dbus-user-session/systemd, which
    # turns a file manager into a multi-minute, session-touching install.
    # DEBIAN_FRONTEND keeps a package with a conffile prompt from wedging the
    # terminal that the menu entry opened.
    as_root env DEBIAN_FRONTEND=noninteractive apt-get update -qq || return 1
    as_root env DEBIAN_FRONTEND=noninteractive \
        apt-get install -y -qq --no-install-recommends "$1" || return 1
    have "$2"
}

ensure_apt() { # <apt package> <binary>
    ensure_apt_soft "$1" "$2" || die "could not install $1"
}

# uv is the only Python toolchain here that can pick an interpreter version:
# the image's embedded CPython is 3.15, which is too new for SWE-agent, whose
# pinned dependencies ship no wheels for it. uv downloads a matching CPython
# on demand.
ensure_uv() {
    have uv && return 0
    log "installing uv (needed to pick a Python version)..."
    curl -fsSL "$UV_INSTALLER" | sh || die "uv install failed"
    have uv || die "uv not on PATH after installing it"
}

# SWE-agent ships its config/, tools/ and trajectories/ directories next to the
# package and asserts they exist at import time, but its wheel does not carry
# them -- a plain `uv tool install` of the git checkout installs a package that
# crashes on start. An editable install of a real clone keeps the repo layout,
# so the assertions hold.
ensure_sweagent() {
    have sweagent && return 0
    ensure_uv
    ensure_git
    local repo="$HOME/.swe-agent"
    if [ ! -d "$repo/.git" ]; then
        log "cloning SWE-agent into $repo..."
        rm -rf "$repo"
        git clone --depth 1 https://github.com/SWE-agent/SWE-agent.git "$repo" ||
            die "could not clone SWE-agent"
    fi
    log "installing SWE-agent (uv tool install --editable)..."
    uv tool install --force --editable --python 3.13 "$repo" ||
        die "uv tool install SWE-agent failed"
    have sweagent || die "sweagent not on PATH after installing it"
}

# The real Chrome binary, ignoring the ~/.local/bin wrapper installed below
# (which would otherwise make `command -v google-chrome` succeed on its own).
chrome_real_bin() {
    local p
    for p in /usr/bin/google-chrome-stable /usr/bin/google-chrome \
             /opt/google/chrome/google-chrome; do
        [ -x "$p" ] && { printf '%s\n' "$p"; return 0; }
    done
    return 1
}

# Chrome refuses to start its sandbox inside the container: Docker's default
# seccomp profile blocks the user namespaces its zygote needs, so it dies with
# "Failed to move to new namespace ... Operation not permitted" even as the
# unprivileged `pn` user. Put a wrapper named `google-chrome` in the user's bin
# that always passes --no-sandbox. ~/.local/bin comes first on PATH, so the
# shell, start-browser and g4f's own browser lookup all pick it up, while the
# real binary stays untouched.
link_chrome_no_sandbox() {
    local real bin
    real="$(chrome_real_bin)" || die "google-chrome not found after install"
    bin="$HOME/.local/bin/google-chrome"
    mkdir -p "$HOME/.local/bin" || die "cannot create $HOME/.local/bin"
    cat > "$bin" <<EOF
#!/bin/sh
# Generated by sandbox-cli: Chrome's sandbox cannot run in this container.
exec "$real" --no-sandbox "\$@"
EOF
    chmod 0755 "$bin" || die "cannot make $bin executable"
    ln -sf google-chrome "$HOME/.local/bin/google-chrome-stable"
    log "linked $bin -> $real (--no-sandbox)"
}

ensure_chrome() {
    if ! chrome_real_bin >/dev/null; then
        install_chrome_deb
    fi
    link_chrome_no_sandbox
}

install_chrome_deb() {
    local arch deb_url
    arch="$(dpkg --print-architecture || true)"
    case "$arch" in
        amd64) deb_url="${CHROME_DEB_BASE}_amd64.deb" ;;
        arm64) deb_url="${CHROME_DEB_BASE}_arm64.deb" ;;
        aarch64) deb_url="${CHROME_DEB_BASE}_arm64.deb" ;;
        *) deb_url="${CHROME_DEB_BASE}_amd64.deb" ;;
    esac
    log "installing Google Chrome (apt install of ${deb_url##*/}) on first run..."
    local tmp
    tmp="$(mktemp --suffix=.deb)" || die "cannot create temp file for Chrome .deb"
    curl -fsSL -o "$tmp" "$deb_url" || die "failed to download Google Chrome .deb"
    as_root apt-get -qq update || die "apt-get update failed"
    as_root apt-get install -y -qq "$tmp" || die "failed to install Google Chrome .deb"
    rm -f "$tmp"
    chrome_real_bin >/dev/null || die "google-chrome not found after installing the .deb"
}

ensure_lightpanda() {
    have g4f-go || die "g4f-go is not installed"
    g4f-go browser path >/dev/null 2>&1 && return 0
    log "installing Lightpanda (g4f-go browser install)..."
    g4f-go browser install || die "Lightpanda install failed"
    g4f-go browser path >/dev/null 2>&1 || die "Lightpanda not installed after g4f-go browser install"
}

# Remote Desktop is a bundled g4f-go module (`g4f-go -m remote_desktop`):
# g4f-go extracts it into its own runtime and pip-installs the extra packages
# it needs there on first use -- qrcode (the pairing QR SVG) and pynput
# (pointer and keyboard injection). Installing them here up front makes a
# failure visible in this terminal instead of leaving a server that silently
# has no QR code and no input control. `python3` is the embedded interpreter,
# so the probe looks at the same site-packages g4f-go does (PYTHONNOUSERSITE
# mirrors its runtime env).
#
# pynput's wheel metadata pulls in `evdev` on Linux, a C extension that needs
# kernel headers and a compiler the slim image does not carry, so a plain
# `pip install pynput` fails to build it. The sandbox session is X11, where
# pynput talks to the server through python-xlib instead of evdev, so pynput is
# installed without its dependencies and python-xlib is pulled explicitly.
ensure_remote_desktop() {
    have g4f-go || die "g4f-go is not installed"
    if have python3 && PYTHONNOUSERSITE=1 python3 -c \
        'import importlib.util, sys; sys.exit(0 if all(importlib.util.find_spec(m) for m in ("qrcode", "pynput", "Xlib")) else 1)' \
        2>/dev/null; then
        return 0
    fi
    log "installing Remote Desktop dependencies (qrcode, python-xlib, pynput)..."
    g4f-go install qrcode python-xlib || die "could not install qrcode/python-xlib"
    g4f-go install --no-deps pynput || die "could not install pynput"
}

ensure_script() { # <install script url> <binary> <label>
    have "$2" && return 0
    log "installing $3..."
    curl -fsSL "$1" | bash || die "$3 install script failed"
    have "$2" || die "$2 not on PATH after the $3 install"
}

# Roomote's installer is a host installer: it runs as root, installs Docker if
# it is missing, writes /opt/roomote and starts a Compose stack. The sandbox
# image has no init system, so the systemd unit it writes is inert here and the
# stack is started directly instead. `--tls-mode internal` keeps Caddy on a
# self-signed certificate, so the install needs no public DNS or ACME round
# trip; the printed /setup link is reachable through the sandbox's own URLs.
ensure_roomote() {
    have roomote && return 0
    log "installing Roomote (self-hosted coding agent)..."
    curl -fsSL "$ROOMOTE_INSTALLER" | as_root bash -s -- --tls-mode internal \
        || die "Roomote install script failed"
    have roomote || die "roomote not on PATH after the Roomote install"
}

# Hermes installs into ~/.hermes/hermes-agent/.hermes/bin and only adds
# ~/.local/bin to the shell rc, so link the binary there for the next run.
ensure_hermes() {
    have hermes && return 0
    ensure_git
    log "installing Hermes Agent (Nous Research)..."
    curl -fsSL "$HERMES_INSTALLER" | bash -s -- --non-interactive \
        || die "Hermes Agent install script failed"
    local bin="$HOME/.hermes/hermes-agent/.hermes/bin/hermes"
    if [ -x "$bin" ]; then
        mkdir -p "$HOME/.local/bin"
        ln -sf "$bin" "$HOME/.local/bin/hermes"
    fi
    have hermes || die "hermes not on PATH after the Hermes Agent install"
}

# Oh My Hermes (OMH) is an npm launcher around a Python wheel; it needs Hermes
# itself, which the omh case installs first. `omh setup --yes` is the
# non-interactive form documented upstream: it installs the managed skills and
# registers them with Hermes without asking the user-scope question.
ensure_omh() {
    have omh && return 0
    ensure_npm "$OMH_PACKAGE" omh
}

# Hugging Face's standalone installer builds a private venv and writes the hf
# wrapper into ~/.local/bin, which sandbox-cli already puts on PATH.
ensure_hf() {
    have hf && return 0
    log "installing the Hugging Face CLI (hf)..."
    curl -fsSL "$HF_CLI_INSTALLER" \
        | bash -s -- --exclude-skill --no-modify-path \
        || die "the Hugging Face CLI install script failed"
    have hf || die "hf not on PATH after the Hugging Face CLI install"
}

# The Skills CLI understands each agent's skill layout, so let it decide where
# Humanizer goes instead of copying SKILL.md into one guessed directory.
ensure_humanizer() {
    [ -f "$HOME/.agents/skills/humanizer/SKILL.md" ] && return 0
    have npx || ensure_node
    log "installing the Humanizer skill for the installed agents..."
    npx -y skills add "$HUMANIZER_SKILL" --global --agent '*' -y \
        || die "could not install the Humanizer skill"
    [ -f "$HOME/.agents/skills/humanizer/SKILL.md" ] \
        || die "Humanizer was not installed under ~/.agents/skills"
}

# Install a Python CLI from a git checkout. Used by the detectors: neither is
# published on PyPI, and both ship their entry point in pyproject.toml.
ensure_pip_git() { # <repository url> <binary> <label>
    have "$2" && return 0
    ensure_git
    log "installing $3 (pip install --user from git)..."
    python3 -m pip install --user --quiet "git+$1" \
        || die "pip install $3 failed"
    have "$2" || die "$2 not on PATH after installing $3"
}

# Oh My Pi (OMP) is the host that pi-freeflow plugs into. The installer fetches
# a prebuilt binary into $PI_INSTALL_DIR (default ~/.local/bin, already first on
# PATH), so the sandbox needs no Bun toolchain of its own.
ensure_omp() {
    have omp && return 0
    log "installing Oh My Pi (OMP)..."
    curl -fsSL "$OMP_INSTALLER" | bash || die "the OMP install script failed"
    have omp || die "omp not on PATH after the OMP install"
}

# Pi's installer manages its own runtime under ~/.pi/agent/bin and only tells
# the shell to prepend that directory, so link the binary into ~/.local/bin
# (already on PATH here) for the next click and for the menu entries.
ensure_pi() {
    have pi && return 0
    log "installing Pi..."
    curl -fsSL "$PI_INSTALLER" | sh || die "the Pi install script failed"
    local bin="$HOME/.pi/agent/bin/pi"
    if [ -x "$bin" ]; then
        mkdir -p "$HOME/.local/bin"
        ln -sf "$bin" "$HOME/.local/bin/pi"
    fi
    have pi || die "pi not on PATH after the Pi install"
}

# pi-freeflow is a Pi/OMP extension rather than a CLI. On OMP it is a plugin;
# `omp plugin list` is the cheap probe -- installing it again succeeds, but
# re-downloads the npm tarball on every click. Listing needs no network, so an
# offline click still starts OMP.
ensure_pi_freeflow_omp() {
    have omp || die "omp is not installed"
    omp plugin list 2>/dev/null | grep -q "$PI_FREEFLOW_PLUGIN" && return 0
    log "installing the pi-freeflow plugin (35 free models)..."
    omp plugin install "$PI_FREEFLOW_PLUGIN" \
        || die "omp plugin install $PI_FREEFLOW_PLUGIN failed"
}

# On Pi the same package is a user package: `pi install` adds it to settings
# and keeps the checkout under ~/.pi/agent. It accepts the npm source form the
# project documents, and a second run is a no-op ("up to date").
ensure_pi_freeflow_pi() {
    have pi || die "pi is not installed"
    pi list 2>/dev/null | grep -q "$PI_FREEFLOW_PLUGIN" && return 0
    log "installing the pi-freeflow package (35 free models)..."
    pi install "npm:$PI_FREEFLOW_PLUGIN" \
        || die "pi install npm:$PI_FREEFLOW_PLUGIN failed"
}

# Where the installed extension's sources live. OMP plugins and Pi packages
# both land in a node_modules tree (hence the copy below, see
# start_freeflow_api); either one is enough to run the shared daemon.
pi_freeflow_package_dir() {
    local dir
    for dir in \
        "$HOME/.omp/plugins/node_modules/$PI_FREEFLOW_PLUGIN" \
        "$HOME/.pi/agent/npm/node_modules/$PI_FREEFLOW_PLUGIN"; do
        [ -f "$dir/src/daemon.ts" ] && { printf '%s\n' "$dir"; return 0; }
    done
    return 1
}

# The API entry only needs the extension sources, not a full agent host: reuse
# an OMP plugin or Pi package when one is already installed, otherwise install
# the lighter Pi package (npm, no extra binary download).
ensure_freeflow_api() {
    pi_freeflow_package_dir >/dev/null && return 0
    ensure_pi
    ensure_pi_freeflow_pi
}

# Print what a client needs: the OpenAI-compatible base URL and the bearer
# token the daemon writes next to its other data files.
show_freeflow_access() {
    local token_file="$HOME/.pi/agent/token" token
    token="$(cat "$token_file" 2>/dev/null || true)"
    printf '\n  Base URL: http://127.0.0.1:%s/v1\n' "$FREEFLOW_PORT"
    printf '  API key:  %s\n' "${token:-(not written yet: $token_file)}"
    printf '  Models:   curl -s -H "Authorization: Bearer $(cat %s)" http://127.0.0.1:%s/v1/models\n\n' \
        "$token_file" "$FREEFLOW_PORT"
}

# Keep a daemon another session started alive: attach this shell as a client
# and refresh the lease every ten seconds (the daemon drops a lease after 30s).
# Ctrl-C stops the refresh, and the daemon retires on its normal grace window.
hold_freeflow_lease() {
    local token_file="$HOME/.pi/agent/token" token id
    id="sandbox-api-$$"
    log "holding the daemon open as a client (Ctrl-C to stop)"
    while :; do
        token="$(cat "$token_file" 2>/dev/null || true)"
        if [ -n "$token" ]; then
            curl -fsS --max-time 2 \
                -H "Authorization: Bearer $token" \
                -H 'content-type: application/json' \
                -d "{\"id\":\"$id\"}" \
                "http://127.0.0.1:$FREEFLOW_PORT/_client/attach" >/dev/null 2>&1 || true
        fi
        sleep 10
    done
}

# Run the extension's own detached-daemon entry in the foreground so its log
# stays readable (Ctrl-C stops it). Node refuses to type-strip files under a
# node_modules path, so the package is copied to a normal directory first;
# undici is the one runtime dependency and is linked from next to the source.
# The daemon otherwise retires ~10s after its last client session ends, so the
# lease-less grace window is stretched: this entry *is* the client of record.
start_freeflow_api() {
    local src dir token_file pid i
    src="$(pi_freeflow_package_dir)" \
        || die "pi-freeflow is not installed; run: sandbox-cli install pi-freeflow"
    dir="$HOME/.local/share/pi-freeflow-daemon"
    rm -rf "$dir"
    mkdir -p "$dir" || die "cannot create $dir"
    cp -a "$src/." "$dir/" || die "could not copy pi-freeflow to $dir"
    mkdir -p "$dir/node_modules"
    [ -d "$(dirname "$src")/undici" ] \
        || die "undici is missing next to $src; reinstall pi-freeflow"
    ln -sfn "$(dirname "$src")/undici" "$dir/node_modules/undici" \
        || die "could not link undici into $dir"

    token_file="$HOME/.pi/agent/token"
    log "starting the pi-freeflow API on http://127.0.0.1:$FREEFLOW_PORT (Ctrl-C to stop)"
    PI_FREEFLOW_DAEMON_GRACE_MS=31536000000 \
        node --experimental-strip-types "$dir/src/daemon.ts" &
    pid=$!
    for i in $(seq 1 40); do
        [ -s "$token_file" ] && break
        kill -0 "$pid" 2>/dev/null || break
        sleep 0.5
    done
    if ! kill -0 "$pid" 2>/dev/null; then
        wait "$pid" 2>/dev/null || true
        die "the pi-freeflow daemon failed to start (see $HOME/.pi/agent/pi-freeflow.log)"
    fi
    show_freeflow_access
    wait "$pid"
}

# Ollama's installer needs a real init system or falls back to a manual start;
# it also wants `zstd` to unpack its payload, which the slim image lacks.
ensure_ollama() {
    have ollama && return 0
    have zstd || {
        log "installing zstd (needed by the Ollama installer)..."
        as_root apt-get update -qq || die "apt-get update failed"
        as_root apt-get install -y -qq zstd || die "could not install zstd"
    }
    log "installing Ollama..."
    curl -fsSL "$OLLAMA_INSTALLER" | sh || die "Ollama install script failed"
    have ollama || die "ollama not on PATH after the install"
}

# LM Studio's installer drops the `lms` CLI into ~/.lmstudio/bin (already on
# PATH above) and, on a machine without a desktop session, only the daemon.
ensure_lmstudio() {
    have lms && return 0
    log "installing LM Studio (lms CLI + daemon)..."
    curl -fsSL "$LMSTUDIO_INSTALLER" | sh || die "LM Studio install script failed"
    have lms || die "lms not on PATH after the LM Studio install"
}

# llama.cpp ships prebuilt tarballs per release build; the tag is resolved from
# the GitHub API so the entry does not pin a build that later disappears.
ensure_llamacpp() {
    have llama-server && return 0
    local arch tag url dir
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        arm64|aarch64) arch="arm64" ;;
        *) arch="x64" ;;
    esac
    tag="$(curl -fsSL "https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=1" \
        | grep -m1 '"tag_name"' | cut -d'"' -f4)"
    [ -n "$tag" ] || die "could not resolve the latest llama.cpp release"
    url="$LLAMACPP_RELEASE_BASE/$tag/llama-$tag-bin-ubuntu-$arch.tar.gz"
    log "installing llama.cpp ($tag, $arch)..."
    dir="$HOME/.llama.cpp"
    rm -rf "$dir"
    mkdir -p "$dir"
    curl -fsSL "$url" | tar -xz -C "$dir" --strip-components=1 \
        || die "could not download llama.cpp ($url)"
    mkdir -p "$HOME/.local/bin"
    ln -sf "$dir/llama-server" "$HOME/.local/bin/llama-server"
    ln -sf "$dir/llama-cli" "$HOME/.local/bin/llama-cli"
    have llama-server || die "llama-server not on PATH after the install"
}

# Jan is an Electron desktop app shipped as an AppImage; the tag is resolved
# from the GitHub API and the AppImage unpacked into ~/.local (no FUSE in the
# container, so it is extracted rather than mounted).
ensure_jan() {
    have jan && return 0
    local tag url dir
    tag="$(curl -fsSL "https://api.github.com/repos/janhq/jan/releases/latest" \
        | grep -m1 '"tag_name"' | cut -d'"' -f4)"
    [ -n "$tag" ] || die "could not resolve the latest Jan release"
    url="$JAN_RELEASE_BASE/$tag/Jan_${tag#v}_amd64.AppImage"
    log "installing Jan ($tag)..."
    dir="$HOME/.local/opt/jan"
    rm -rf "$dir"
    mkdir -p "$dir"
    curl -fsSL -o "$dir/jan.AppImage" "$url" || die "could not download Jan ($url)"
    chmod +x "$dir/jan.AppImage"
    ( cd "$dir" && ./jan.AppImage --appimage-extract >/dev/null 2>&1 ) \
        || die "could not unpack the Jan AppImage"
    mkdir -p "$HOME/.local/bin"
    ln -sf "$dir/squashfs-root/AppRun" "$HOME/.local/bin/jan"
    have jan || die "jan not on PATH after the install"
}

# Electron desktop apps (LobeChat, Msty Studio, Orca) link against NSS/NSPR,
# ALSA and X11 extensions, which the slim image does not carry. Chrome's .deb
# pulls the same libraries in, but these entries never install Chrome, so they
# are installed here on first click.
ensure_electron_libs() {
    have ldconfig || return 0
    if ldconfig -p 2>/dev/null | grep -q "libnss3.so" \
        && ldconfig -p 2>/dev/null | grep -q "libasound.so.2" \
        && ldconfig -p 2>/dev/null | grep -q "libxss.so.1" \
        && ldconfig -p 2>/dev/null | grep -q "libxtst.so.6"; then
        return 0
    fi
    log "installing the Electron runtime libraries (NSS, ALSA, X11)..."
    as_root apt-get update -qq || die "apt-get update failed"
    as_root apt-get install -y -qq libnss3 libnspr4 libasound2t64 \
        libxss1 libxtst6 \
        || die "could not install the Electron runtime libraries"
}

# Resolve the tag of a GitHub repository's latest release. Used by the
# AppImage entries, whose asset names embed the version in different ways.
latest_release_tag() { # <api url> <label>
    local tag
    tag="$(curl -fsSL "$1" | grep -m1 '"tag_name"' | cut -d'"' -f4)"
    [ -n "$tag" ] || die "could not resolve the latest $2 release"
    printf '%s\n' "$tag"
}

# Write a launcher for an unpacked AppImage. The wrapper pins APPDIR because
# some AppRuns mis-detect it, and adds --no-sandbox when the container cannot
# create unprivileged user namespaces (Electron ignores the flag elsewhere).
write_appimage_launcher() { # <opt dir name> <binary>
    local name="$1" bin="$2"
    mkdir -p "$HOME/.local/bin"
    cat > "$HOME/.local/bin/$bin" <<APPIMAGE
#!/bin/bash
# Generated by sandbox-cli: the $name AppImage is unpacked under ~/.local/opt
# because the container has no FUSE.
appdir="\$HOME/.local/opt/$name/squashfs-root"
export APPDIR="\$appdir"
no_sandbox=()
if ! unshare -Ur true 2>/dev/null; then
    no_sandbox=(--no-sandbox)
fi
exec "\$appdir/AppRun" "\${no_sandbox[@]}" "\$@"
APPIMAGE
    chmod 0755 "$HOME/.local/bin/$bin"
}

# Download an AppImage, unpack it into ~/.local/opt/<name> and install the
# launcher from write_appimage_launcher. Electron apps need NSS/NSPR and ALSA.
install_appimage() { # <label> <url> <opt dir name> <binary>
    local label="$1" url="$2" name="$3" bin="$4" dir
    ensure_electron_libs
    log "installing $label..."
    dir="$HOME/.local/opt/$name"
    rm -rf "$dir"
    mkdir -p "$dir"
    curl -fsSL --retry 3 --retry-delay 2 --connect-timeout 15 \
        -o "$dir/$name.AppImage" "$url" \
        || die "could not download $label ($url)"
    chmod +x "$dir/$name.AppImage"
    ( cd "$dir" && "./$name.AppImage" --appimage-extract >/dev/null 2>&1 ) \
        || die "could not unpack the $label AppImage"
    write_appimage_launcher "$name" "$bin"
    have "$bin" || die "$bin not on PATH after installing $label"
}

# LobeChat (lobehub) ships an Electron desktop app as an AppImage. Like Jan it
# is unpacked rather than mounted (no FUSE in the container) and the AppRun is
# symlinked onto PATH. The release only carries an x64 AppImage.
ensure_lobechat() {
    have lobechat && return 0
    local tag url dir
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        amd64|x86_64) ;;
        *) die "LobeChat publishes no Linux AppImage for $(dpkg --print-architecture); only x64 is available" ;;
    esac
    ensure_electron_libs
    tag="$(curl -fsSL "https://api.github.com/repos/lobehub/lobehub/releases/latest" \
        | grep -m1 '"tag_name"' | cut -d'"' -f4)"
    [ -n "$tag" ] || die "could not resolve the latest LobeChat release"
    url="$LOBECHAT_RELEASE_BASE/$tag/LobeHub-${tag#v}.AppImage"
    log "installing LobeChat ($tag)..."
    dir="$HOME/.local/opt/lobechat"
    rm -rf "$dir"
    mkdir -p "$dir"
    curl -fsSL -o "$dir/lobechat.AppImage" "$url" \
        || die "could not download LobeChat ($url)"
    chmod +x "$dir/lobechat.AppImage"
    ( cd "$dir" && ./lobechat.AppImage --appimage-extract >/dev/null 2>&1 ) \
        || die "could not unpack the LobeChat AppImage"
    mkdir -p "$HOME/.local/bin"
    ln -sf "$dir/squashfs-root/AppRun" "$HOME/.local/bin/lobechat"
    have lobechat || die "lobechat not on PATH after the install"
}

# Msty Studio is the current Msty desktop app (the old msty.app download URLs
# are dead). It serves a stable "latest" AppImage URL, so no release lookup is
# needed; x64 only, like LobeChat.
ensure_msty() {
    have msty && return 0
    local dir
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        amd64|x86_64) ;;
        *) die "Msty Studio publishes no Linux AppImage for $(dpkg --print-architecture); only x64 is available" ;;
    esac
    ensure_electron_libs
    log "installing Msty Studio..."
    dir="$HOME/.local/opt/msty"
    rm -rf "$dir"
    mkdir -p "$dir"
    curl -fsSL -o "$dir/msty.AppImage" "$MSTY_APPIMAGE_URL" \
        || die "could not download Msty Studio ($MSTY_APPIMAGE_URL)"
    chmod +x "$dir/msty.AppImage"
    ( cd "$dir" && ./msty.AppImage --appimage-extract >/dev/null 2>&1 ) \
        || die "could not unpack the Msty Studio AppImage"
    mkdir -p "$HOME/.local/bin"
    # Msty's AppRun cannot be used directly: its APPDIR detection walks up the
    # tree looking for a file named after the first argument, so passing any
    # argument resolves APPDIR to an empty string and it then tries to exec
    # "/mstystudio". It also lacks the --no-sandbox fail-safe that LobeChat's
    # AppRun has, which is needed where unprivileged user namespaces are
    # unavailable (containers, hardened hosts).
    cat > "$HOME/.local/bin/msty" <<'MSTY_WRAPPER'
#!/bin/bash
appdir="$HOME/.local/opt/msty/squashfs-root"
export APPDIR="$appdir"
no_sandbox=()
if ! unshare -Ur true 2>/dev/null; then
    no_sandbox=(--no-sandbox)
fi
exec "$appdir/AppRun" "${no_sandbox[@]}" "$@"
MSTY_WRAPPER
    chmod +x "$HOME/.local/bin/msty"
    have msty || die "msty not on PATH after the install"
}

# OpenBot is a local-first multi-agent desktop workspace (Codex, Claude,
# Grok). Its AppImage per architecture embeds the version in the asset name.
ensure_openbot() {
    have openbot && return 0
    local tag arch
    tag="$(latest_release_tag "$OPENBOT_RELEASE_API" OpenBot)"
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        amd64|x86_64) arch=x86_64 ;;
        arm64|aarch64) arch=arm64 ;;
        *) die "OpenBot publishes no AppImage for $(dpkg --print-architecture)" ;;
    esac
    install_appimage OpenBot \
        "$OPENBOT_RELEASE_BASE/$tag/OpenBot-${tag#v}-$arch.AppImage" \
        openbot openbot
}

# Orca's AppRun needs Chromium's GPU process, which is not usable on the
# sandbox's virtual display: it exits with "GPU process isn't usable. Goodbye."
# shortly after the window opens. Keep Chromium on the documented software
# rendering path and disable the GPU/compositor, which also avoids the Intel
# i915 SIGBUS crash. Orca's Linux CLI is called orca-ide (GNOME's screen reader
# owns `orca`), so install both names; the menu keeps using `orca`.
write_orca_launcher() {
    mkdir -p "$HOME/.local/bin"
    cat > "$HOME/.local/bin/orca" <<'ORCA_WRAPPER'
#!/bin/bash
# Generated by sandbox-cli: the Orca AppImage is unpacked under ~/.local/opt
# because the container has no FUSE.
appdir="$HOME/.local/opt/orca/squashfs-root"
export APPDIR="$appdir"
export LIBGL_ALWAYS_SOFTWARE=1
no_sandbox=()
if ! unshare -Ur true 2>/dev/null; then
    no_sandbox=(--no-sandbox)
fi
exec "$appdir/AppRun" "${no_sandbox[@]}" \
    --disable-gpu --disable-gpu-compositing --disable-dev-shm-usage "$@"
ORCA_WRAPPER
    chmod 0755 "$HOME/.local/bin/orca"
    ln -sf orca "$HOME/.local/bin/orca-ide"
}

# Orca is an IDE for parallel agentic development, shipped as an Electron
# AppImage (x64 and arm64 have different asset names). The asset names are
# stable, so use the latest-download redirect rather than the rate-limited API.
ensure_orca() {
    local asset
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        amd64|x86_64) asset=orca-linux.AppImage ;;
        arm64|aarch64) asset=orca-linux-arm64.AppImage ;;
        *) die "Orca publishes no AppImage for $(dpkg --print-architecture)" ;;
    esac
    if [ ! -x "$HOME/.local/opt/orca/squashfs-root/AppRun" ]; then
        install_appimage Orca "$ORCA_LATEST_BASE/$asset" orca orca
    fi
    write_orca_launcher
    have orca || die "orca not on PATH after installing Orca"
}

# VoiceStudio is an Electron voice studio (TTS/voice cloning). It publishes a
# Linux AppImage for x64 only.
ensure_voicestudio() {
    have voicestudio && return 0
    local tag
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        amd64|x86_64) ;;
        *) die "VoiceStudio publishes no Linux AppImage for $(dpkg --print-architecture); only x64 is available" ;;
    esac
    tag="$(latest_release_tag "$VOICESTUDIO_RELEASE_API" VoiceStudio)"
    install_appimage VoiceStudio \
        "$VOICESTUDIO_RELEASE_BASE/$tag/VoiceStudio-${tag#v}-linux-x64.AppImage" \
        voicestudio voicestudio
}

# ThinkRail's installer resolves the latest release, verifies its SHA-256 and
# writes ~/.local/bin/thinkrail. --no-modify-path leaves the shell rc alone;
# sandbox-cli already puts ~/.local/bin on PATH.
ensure_thinkrail() {
    have thinkrail && return 0
    log "installing ThinkRail..."
    curl -fsSL "$THINKRAIL_INSTALLER" | bash -s -- --no-modify-path \
        || die "the ThinkRail install script failed"
    have thinkrail || die "thinkrail not on PATH after the ThinkRail install"
}

ensure_hyperframes() {
    have hyperframes && return 0
    ensure_npm "$HYPERFRAMES_PACKAGE" hyperframes
}

# DeepTutor's [full] extra carries the web app; bare `deeptutor` is CLI only.
ensure_deeptutor() {
    have deeptutor && return 0
    ensure_pip "$DEEPTUTOR_PACKAGE" deeptutor
}

ensure_nanobot() {
    have nanobot && return 0
    ensure_pip "$NANOBOT_PACKAGE" nanobot
}

# Agent Reach is a uv-installed agent skill/CLI. uv tool install puts its
# wrapper in ~/.local/bin, which the script already prepends to PATH.
ensure_agent_reach() {
    have agent-reach && return 0
    ensure_uv
    log "installing Agent Reach..."
    uv tool install --force "$AGENT_REACH_ZIP" \
        || die "uv tool install Agent Reach failed"
    have agent-reach || die "agent-reach not on PATH after the install"
}

# ECC installs harness-native skills, rules and commands; for OpenCode the
# state file marks a completed run so a second click is instant.
ensure_ecc() {
    [ -f "$HOME/.config/opencode/ecc-install-state.json" ] && return 0
    have npx || ensure_node
    log "installing ECC (skills, rules and commands for OpenCode)..."
    npx -y "$ECC_PACKAGE" install --target opencode --profile core --no-hooks \
        || die "the ECC install failed"
    [ -f "$HOME/.config/opencode/ecc-install-state.json" ] \
        || die "ECC did not write its OpenCode install state"
}

# Superpowers is an OpenCode plugin; `opencode plugin add` records it in the
# global config and fetches the git checkout itself.
ensure_superpowers() {
    if [ -f "$HOME/.config/opencode/opencode.json" ] \
        && grep -Fq "$SUPERPOWERS_PLUGIN" "$HOME/.config/opencode/opencode.json"; then
        return 0
    fi
    ensure_script "$OPENCODE_INSTALLER" opencode "OpenCode"
    log "installing the Superpowers plugin for OpenCode..."
    opencode plugin add "$SUPERPOWERS_PLUGIN" \
        || die "opencode plugin add Superpowers failed"
    grep -Fq "$SUPERPOWERS_PLUGIN" "$HOME/.config/opencode/opencode.json" \
        || die "Superpowers was not added to the OpenCode config"
}

# Graphify ships a pip CLI that copies its skill into each supported agent's
# directory; --platform agents covers the shared ~/.agents/skills layout.
ensure_graphify() {
    [ -f "$HOME/.agents/skills/graphify/SKILL.md" ] && return 0
    ensure_pip "$GRAPHIFY_PACKAGE" graphify
    log "installing the Graphify skill for the installed agents..."
    graphify install --platform agents \
        || die "graphify install failed"
    [ -f "$HOME/.agents/skills/graphify/SKILL.md" ] \
        || die "Graphify was not installed under ~/.agents/skills"
}

# Claude-Mem installs an OpenCode plugin and needs Bun for its worker. Bun's
# installer unpacks a zip, so unzip has to be present first. --provider claude
# is the documented local mode; it runs memory on the user's own Claude plan.
# CLAUDE_MEM_ONLINE_OPTIN=false keeps the install local-only.
ensure_claude_mem() {
    [ -f "$HOME/.config/opencode/plugins/claude-mem.js" ] && return 0
    ensure_unzip
    have npx || ensure_node
    log "installing Claude-Mem for OpenCode..."
    CLAUDE_MEM_ONLINE_OPTIN=false npx -y "$CLAUDE_MEM_PACKAGE" install \
        --ide opencode --provider claude \
        || die "the Claude-Mem install failed"
    [ -f "$HOME/.config/opencode/plugins/claude-mem.js" ] \
        || die "Claude-Mem did not write its OpenCode plugin"
}

# Cindy's user installer unpacks the .deb with bsdtar (libarchive-tools on
# Debian); the image does not ship it because it is only needed here.
ensure_bsdtar() {
    have bsdtar && return 0
    log "installing libarchive-tools (needed by the Cindy installer)..."
    as_root apt-get update -qq || die "apt-get update failed"
    as_root apt-get install -y -qq libarchive-tools \
        || die "could not install libarchive-tools"
    have bsdtar || die "bsdtar not on PATH after installing libarchive-tools"
}

# Write a stable ~/.local/bin/cindy launcher. The packaged app runs on Electron
# and containers/hardened hosts without unprivileged user namespaces need
# --no-sandbox; the AppImage entries make the same fail-safe.
write_cindy_launcher() {
    mkdir -p "$HOME/.local/bin"
    {
        printf '#!/bin/bash\nprefix=%q\n' "$CINDY_PREFIX"
        cat <<'CINDY_LAUNCHER'
args=()
if ! unshare -Ur true 2>/dev/null; then
    args+=(--no-sandbox)
fi
exec "$prefix/launch" "${args[@]}" "$@"
CINDY_LAUNCHER
    } > "$HOME/.local/bin/cindy"
    chmod +x "$HOME/.local/bin/cindy"
}

# Install the official Linux package when there is no source checkout. The
# release's own install-user.sh extracts the .deb into a user-owned prefix (no
# apt/dpkg, no root) and writes $CINDY_PREFIX/launch.
ensure_cindy_release() {
    have cindy && return 0
    local arch tag url tmp sha
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        amd64|x86_64) arch=x64 ;;
        arm64|aarch64) arch=arm64 ;;
        *) die "Cindy publishes no Linux build for $(dpkg --print-architecture)" ;;
    esac
    case "$CINDY_REGION" in
        global|cn) ;;
        *) die "CINDY_REGION must be global or cn" ;;
    esac
    ensure_electron_libs
    ensure_bsdtar
    tag="$(curl -fsSL "https://api.github.com/repos/makecindy/cindy/releases/latest" \
        | grep -m1 '"tag_name"' | cut -d'"' -f4)"
    [ -n "$tag" ] || die "could not resolve the latest Cindy release"
    url="$CINDY_REPO/releases/download/$tag/cindy-${tag#v}-linux-$arch-$CINDY_REGION.deb"
    log "installing Cindy ($tag, $CINDY_REGION)..."
    tmp="$(mktemp -d)"
    if ! curl -fL --retry 2 -o "$tmp/cindy.deb" "$url"; then
        rm -rf "$tmp"
        die "could not download Cindy ($url)"
    fi
    sha="$(sha256sum "$tmp/cindy.deb" | cut -d' ' -f1)"
    if ! curl -fsSL -o "$tmp/install-user.sh" \
        "https://raw.githubusercontent.com/makecindy/cindy/$tag/apps/desktop/resources/linux/install-user.sh"; then
        rm -rf "$tmp"
        die "could not download the Cindy installer"
    fi
    if ! bash "$tmp/install-user.sh" --install "$tmp/cindy.deb" "$sha" "$CINDY_PREFIX"; then
        rm -rf "$tmp"
        die "Cindy installation failed"
    fi
    rm -rf "$tmp"
    write_cindy_launcher
    have cindy || die "cindy not on PATH after the install"
}

# Cindy keeps providers in its own SQLite database and has no CLI for adding
# one. The sandbox ships a local g4f API, so the app is opened once with a
# local-mode session, its database is seeded with a "g4f" custom provider and
# the app is closed again; the caller then launches it normally.
cindy_user_data_dir() {
    case "$CINDY_REGION" in
        cn) printf '%s\n' "$HOME/.config/Cindy" ;;
        *) printf '%s\n' "$HOME/.config/CindyGlobal" ;;
    esac
}

cindy_db_query() { # <db> <sql>
    CINDY_DB="$1" CINDY_SQL="$2" python3 - <<'CINDY_SQL'
import os, sqlite3, sys
try:
    con = sqlite3.connect("file:%s?mode=ro" % os.environ["CINDY_DB"], uri=True)
    con.execute(os.environ["CINDY_SQL"]).fetchall()
except Exception:
    sys.exit(1)
CINDY_SQL
}

provision_cindy() {
    local profile db base_url pid i
    profile="$(cindy_user_data_dir)"
    db="$profile/cindy-local-v1.db"
    mkdir -p "$profile"
    if [ ! -f "$profile/app-session.json" ]; then
        printf '{\n  "activeMode": "local"\n}\n' > "$profile/app-session.json"
    fi
    if cindy_db_query "$db" \
        "select 1 from custom_providers where id = 'g4f'"; then
        log "Cindy already has the local g4f provider"
        return 0
    fi
    configure_g4f
    base_url="$G4F_API_BASE_URL"
    log "adding the local g4f provider to Cindy..."
    nohup setsid cindy >/tmp/cindy-provision.log 2>&1 &
    pid=$!
    i=0
    while [ "$i" -lt 120 ]; do
        if cindy_db_query "$db" "select 1 from custom_providers limit 1"; then
            break
        fi
        sleep 1
        i=$((i + 1))
    done
    if [ "$i" -ge 120 ]; then
        log "Cindy's provider database did not appear; open Cindy once, then run this again"
        return 0
    fi
    if kill -0 "$pid" 2>/dev/null; then
        kill "$pid" 2>/dev/null || true
        for i in 1 2 3 4 5 6 7 8 9 10; do
            kill -0 "$pid" 2>/dev/null || break
            sleep 1
        done
        kill -9 "$pid" 2>/dev/null || true
    fi
    CINDY_DB="$db" CINDY_G4F_URL="$base_url" python3 - <<'CINDY_SQL' \
        || log "could not write Cindy's g4f provider; open Cindy once, then run this again"
import json, os, sqlite3, time
runtimes = {
    "codex": {
        "baseUrl": os.environ["CINDY_G4F_URL"],
        "models": [
            {"id": "default", "name": "g4f default", "defaultEnabled": True},
            {"id": "deepseek-v4-flash", "name": "DeepSeek V4 Flash", "defaultEnabled": True},
            {"id": "deepseek-v4.1-flash", "name": "DeepSeek V4.1 Flash", "defaultEnabled": True},
            {"id": "kimi-k3", "name": "Kimi K3", "defaultEnabled": True},
        ],
        "wireProtocol": "openai-chat",
        "modelsUrl": os.environ["CINDY_G4F_URL"] + "/models",
    }
}
now = int(time.time() * 1000)
con = sqlite3.connect(os.environ["CINDY_DB"])
con.execute(
    "insert or replace into custom_providers"
    " (id, name, runtimes, auth, sort_order, created_at, updated_at)"
    " values (?, ?, ?, ?, ?, ?, ?)",
    ("g4f", "g4f", json.dumps(runtimes), json.dumps({"method": "none"}), 0, now, now),
)
con.commit()
con.close()
CINDY_SQL
    log "Cindy now has the g4f provider ($base_url)"
}

# Kate is the KDE text editor and the one editor the menu installs from the
# distribution instead of a vendor script: Debian ships it as `kate`, and apt
# pulls its KDE/Qt runtime in with it. The binary lands in /usr/bin, already on
# PATH, so no link is needed.
ensure_kate() {
    have kate && return 0
    log "installing Kate (apt)..."
    as_root apt-get update -qq || die "apt-get update failed"
    as_root apt-get install -y -qq kate || die "could not install Kate"
    have kate || die "kate not on PATH after the install"
}

# Nemo is the Cinnamon file manager, but it runs fine under Xfce. Installing
# the bare package with --no-install-recommends leaves out the helpers that
# make it usable: gvfs-backends for trash/network mounts,
# libgdk-pixbuf2.0-bin for thumbnails (Nemo otherwise logs "Ignoring
# thumbnailer with missing binary") and xdg-user-dirs for the
# Desktop/Downloads folders. Install those with Nemo; they are small runtime
# pieces, not the Cinnamon desktop.
ensure_nemo() {
    local pkg missing=()
    for pkg in nemo gvfs-backends libgdk-pixbuf2.0-bin xdg-user-dirs; do
        dpkg -s "$pkg" >/dev/null 2>&1 || missing+=("$pkg")
    done
    [ ${#missing[@]} -eq 0 ] && return 0
    log "installing Nemo and its file-manager helpers (apt)..."
    as_root env DEBIAN_FRONTEND=noninteractive apt-get update -qq || return 1
    as_root env DEBIAN_FRONTEND=noninteractive \
        apt-get install -y -qq --no-install-recommends "${missing[@]}" || return 1
    have nemo
}

# LibreChat is not packaged: it is a Node monorepo that needs MongoDB and a
# full frontend build. The checkout lives in ~/.librechat, the database in
# ~/.librechat-data (started by ensure_librechat, kept running by run_tool).
LIBRECHAT_DIR_NAME=".librechat"
LIBRECHAT_DATA_DIR_NAME=".librechat-data"
LIBRECHAT_PORT="3080"

# MongoDB publishes no trixie build of mongodb-org-server, so the server is
# taken from the bookworm repository (the .deb is self-contained and runs on
# trixie) or, on arm64, from the ubuntu2404 tarball. The mongosh shell comes
# from the same repository and is only used to verify the server is up.
ensure_mongodb() {
    have mongod && return 0
    local arch dist
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        arm64|aarch64) arch="arm64" ;;
        *) arch="amd64" ;;
    esac
    if [ "$arch" = "amd64" ]; then
        log "installing MongoDB ($MONGODB_VERSION, apt)..."
        as_root mkdir -p /usr/share/keyrings
        # apt (>= 2.4, trixie ships 3.x) reads ASCII-armored keys directly, so
        # the .asc is used as-is; the image carries no gpg to dearmor it.
        curl -fsSL -o /tmp/mongodb-server-8.0.asc "$MONGODB_GPG_URL" \
            || die "could not download the MongoDB signing key"
        as_root install -m 0644 /tmp/mongodb-server-8.0.asc \
            /usr/share/keyrings/mongodb-server-8.0.asc \
            || die "could not install the MongoDB signing key"
        printf 'deb [ signed-by=/usr/share/keyrings/mongodb-server-8.0.asc ] %s %s main\n' \
            "$MONGODB_APT_BASE" "$MONGODB_APT_DIST" \
            | as_root tee /etc/apt/sources.list.d/mongodb-org-8.0.list >/dev/null
        as_root apt-get update -qq || die "apt-get update failed"
        as_root apt-get install -y -qq mongodb-org-server mongodb-mongosh \
            || die "could not install mongodb-org-server"
    else
        # fastdl serves no aarch64 debian12 tarball, so arm64 uses the
        # ubuntu2404 build; it is a static server binary and runs on trixie.
        log "installing MongoDB ($MONGODB_VERSION, tarball)..."
        dist="$HOME/.local/opt/mongodb"
        rm -rf "$dist"
        mkdir -p "$dist"
        curl -fsSL "$MONGODB_TARBALL_BASE/mongodb-linux-$arch-ubuntu2404-$MONGODB_VERSION.tgz" \
            | tar -xz -C "$dist" --strip-components=1 \
            || die "could not download the MongoDB tarball"
        mkdir -p "$HOME/.local/bin"
        ln -sf "$dist/bin/mongod" "$HOME/.local/bin/mongod"
    fi
    have mongod || die "mongod not on PATH after the install"
}

# Start the bundled MongoDB on 127.0.0.1:27017 if nothing answers there yet.
# The data directory lives outside the checkout so a re-clone keeps the data.
start_mongodb() {
    local data="$HOME/$LIBRECHAT_DATA_DIR_NAME"
    if tcp_open 27017; then
        log "MongoDB is already running on 127.0.0.1:27017"
        return 0
    fi
    mkdir -p "$data"
    log "starting MongoDB on 127.0.0.1:27017 (data in $data)"
    nohup setsid mongod --dbpath "$data" --bind_ip 127.0.0.1 --port 27017 \
        >/tmp/mongod.log 2>&1 &
    local i
    for i in $(seq 1 30); do
        tcp_open 27017 && return 0
        sleep 1
    done
    die "MongoDB did not start; see /tmp/mongod.log"
}

# LibreChat's .env: keep the upstream defaults and only pin what the sandbox
# needs (the local MongoDB, the port, and the g4f endpoint). CREDS_KEY/CREDS_IV
# are left blank on purpose -- LibreChat generates them into .env.temp on first
# start. G4F_MODEL is only written when a model was chosen in Sandbox Settings.
write_librechat_env() {
    local env="$HOME/$LIBRECHAT_DIR_NAME/.env"
    [ -f "$env" ] || cp "$HOME/$LIBRECHAT_DIR_NAME/.env.example" "$env" \
        || die "could not create the LibreChat .env"
    set_env_var "$env" MONGO_URI "mongodb://127.0.0.1:27017/LibreChat"
    set_env_var "$env" PORT "$LIBRECHAT_PORT"
    set_env_var "$env" HOST "127.0.0.1"
    # The sandbox runs exactly one LibreChat process, so the scheduler's
    # replica check can be asserted instead of leaving its write routes off.
    set_env_var "$env" SCHEDULES_SINGLE_PROCESS "true"
    set_env_var "$env" G4F_API_BASE_URL "$G4F_API_BASE_URL"
    set_env_var "$env" G4F_API_KEY "$G4F_API_KEY"
    if [ "$SANDBOX_MODEL_SET" = 1 ]; then
        set_env_var "$env" G4F_MODEL "$G4F_PROVIDER_MODEL"
    fi
}

# LibreChat reads custom OpenAI-compatible endpoints from librechat.yaml. The
# g4f endpoint is always written there (with model "auto") and pointed at the
# sandbox's g4f API, so it is available in LibreChat's model picker. A model
# chosen in Sandbox Settings is added to the list and put first; only then does
# it become LibreChat's default model.
write_librechat_yaml() {
    local dir yaml default_models title_model
    dir="$HOME/$LIBRECHAT_DIR_NAME"
    yaml="$dir/librechat.yaml"
    default_models="\"$G4F_AUTO_MODEL\""
    title_model="$G4F_AUTO_MODEL"
    if [ "$SANDBOX_MODEL_SET" = 1 ]; then
        title_model="$G4F_PROVIDER_MODEL"
        [ "$G4F_PROVIDER_MODEL" = "$G4F_AUTO_MODEL" ] \
            || default_models="\"$G4F_PROVIDER_MODEL\", \"$G4F_AUTO_MODEL\""
    fi
    cat >"$yaml" <<EOF
# Written by sandbox-cli from the Sandbox Settings model/provider.
version: 1.2.8
cache: true
endpoints:
  custom:
    - name: "g4f"
      apiKey: "${G4F_API_KEY}"
      baseURL: "${G4F_API_BASE_URL}"
      models:
        default: [${default_models}]
        fetch: false
      titleConvo: true
      titleModel: "${title_model}"
      modelDisplayLabel: "g4f"
EOF
}

ensure_librechat() {
    ensure_node
    ensure_git
    ensure_mongodb
    local dir="$HOME/$LIBRECHAT_DIR_NAME"
    if [ ! -d "$dir/.git" ]; then
        log "cloning LibreChat into $dir..."
        rm -rf "$dir"
        git clone --depth 1 "$LIBRECHAT_REPO" "$dir" \
            || die "could not clone LibreChat"
    fi
    if [ ! -d "$dir/node_modules" ] || [ ! -d "$dir/client/dist" ]; then
        # `npm run reinstall` builds the workspace packages and the frontend;
        # it is the documented first-run step and needs a large heap.
        log "building LibreChat (npm run reinstall, this takes a while)..."
        ( cd "$dir" && NODE_OPTIONS="--max-old-space-size=8192" npm run reinstall ) \
            || die "the LibreChat build failed"
    fi
    have mongod || die "mongod not on PATH after the install"
}

# Set a top-level scalar in SillyTavern's config.yaml, replacing the existing
# value (and any comment on that line) or appending the key when the config is
# from an older release and does not have it yet.
sillytavern_set_config() { # <file> <key> <value>
    if grep -q "^$2:" "$1"; then
        sed -i "s|^$2:.*|$2: $3|" "$1" || return 1
    else
        printf '%s: %s\n' "$2" "$3" >>"$1" || return 1
    fi
}

# SillyTavern is a Node web app with no upstream package: the checkout lives
# in ~/.sillytavern, `npm install` fetches the dependencies. If no config.yaml
# exists yet, the checkout's default/config.yaml is copied to the repo root --
# the path server.js reads (./config.yaml; the config/ directory also seen in
# older releases is not used) -- and the port there is moved off SillyTavern's
# default 8000 so a running Open WebUI Computer (also 8000) does not collide
# with it, and the startup browser launch is turned off: the launcher terminal
# is the console and nothing guarantees a desktop browser is installed.
#
# It is reached through the same public URLs as the other sandbox apps
# (http://sillytavern.g4f.local/ and http://8601.g4f.local/, or a cloudflared
# tunnel), so it has to accept connections from beyond localhost: listen mode
# is on and the IP/host whitelists are off. SillyTavern refuses to start in
# that configuration without securityOverride because it would otherwise warn
# about an unprotected remote instance; the sandbox is the trust boundary.
ensure_sillytavern() {
    ensure_node
    ensure_git
    local dir="$HOME/$SILLYTAVERN_DIR_NAME" config
    if [ ! -d "$dir/.git" ]; then
        log "cloning SillyTavern into $dir..."
        rm -rf "$dir"
        git clone --depth 1 "$SILLYTAVERN_REPO" "$dir" \
            || die "could not clone SillyTavern"
    fi
    # Use the same dependency set as SillyTavern's own start.sh: production
    # packages only, no install scripts. The full dev tree (webpack plugins,
    # test and lint tooling) is not needed to serve the app and is large enough
    # to make the install fail on the sandbox's disk; an interrupted install
    # leaves a partial node_modules behind, so clear it before retrying.
    if [ ! -f "$dir/node_modules/.package-lock.json" ]; then
        rm -rf "$dir/node_modules"
        log "installing SillyTavern's dependencies (npm install)..."
        ( cd "$dir" && NODE_ENV=production npm install --no-save --no-audit \
            --no-fund --loglevel=error --no-progress --omit=dev --ignore-scripts ) \
            || {
                rm -rf "$dir/node_modules"
                die "the SillyTavern npm install failed"
            }
    fi
    config="$dir/config.yaml"
    if [ ! -f "$config" ]; then
        cp "$dir/default/config.yaml" "$config" \
            || die "could not create the SillyTavern config.yaml"
    fi
    sillytavern_set_config "$config" port "$SILLYTAVERN_PORT" \
        || die "could not set the SillyTavern port"
    sillytavern_set_config "$config" listen true \
        || die "could not enable SillyTavern listen mode"
    sillytavern_set_config "$config" whitelistMode false \
        || die "could not disable SillyTavern's IP whitelist"
    sillytavern_set_config "$config" securityOverride true \
        || die "could not override SillyTavern's listen-mode security check"
    sed -i '/^hostWhitelist:/,/^[a-zA-Z]/{
        s/^  enabled: .*/  enabled: false/
        s/^  scan: .*/  scan: false/
    }' "$config" || die "could not disable SillyTavern's host whitelist"
    sed -i '/^browserLaunch:/,/^[a-zA-Z]/{s/^  enabled: .*/  enabled: false/}' \
        "$config" || die "could not disable SillyTavern's browser launch"
}

# SillyTavern's data directory: config.yaml names it (dataRoot, default
# ./data), relative paths are relative to the checkout.
sillytavern_data_dir() { # <checkout> <config>
    local dir="$1" config="$2" data_root
    data_root=$(grep -m1 '^dataRoot:' "$config" 2>/dev/null \
        | sed -e 's/^dataRoot:[[:space:]]*//' -e 's/[[:space:]]*#.*$//' \
            -e 's/^"//' -e 's/"$//' -e "s/^'//" -e "s/'\$//")
    [ -n "$data_root" ] || data_root="./data"
    case "$data_root" in
        "~/"*) data_root="$HOME/${data_root#\~/}" ;;
        /*) ;;
        *) data_root="$dir/${data_root#./}" ;;
    esac
    printf '%s/default-user\n' "${data_root%/}"
}

# Point SillyTavern at the sandbox's g4f API. The Custom (OpenAI-compatible)
# chat completion source is the supported way to use an arbitrary /v1 endpoint;
# the g4f "auto" model (or the model chosen in Sandbox Settings) is written as
# custom_model, and the sandbox API key, when set, goes into the same secrets
# store the UI writes (api_key_custom). Older SillyTavern releases kept these
# values at the top level of settings.json, newer ones under oai_settings, so
# both are written; the file is seeded from the checkout's default content
# when it does not exist yet.
configure_sillytavern_g4f() {
    local dir="$HOME/$SILLYTAVERN_DIR_NAME" config data_dir settings secrets tmp
    config="$dir/config.yaml"
    [ -f "$config" ] || die "SillyTavern is not installed; run: sandbox-cli install sillytavern"
    data_dir=$(sillytavern_data_dir "$dir" "$config") \
        || die "could not read SillyTavern's data directory"
    mkdir -p "$data_dir" || die "could not create $data_dir"
    settings="$data_dir/settings.json"
    if [ ! -f "$settings" ]; then
        if [ -f "$dir/default/content/settings.json" ]; then
            cp "$dir/default/content/settings.json" "$settings" \
                || die "could not create the SillyTavern settings.json"
        else
            printf '{}\n' >"$settings" \
                || die "could not create the SillyTavern settings.json"
        fi
    fi
    tmp="${settings}.tmp.$$"
    if ! jq --arg url "$G4F_API_BASE_URL" \
        --arg model "$G4F_PROVIDER_MODEL" '
        def connection:
            {
                chat_completion_source: "custom",
                custom_url: $url,
                custom_model: $model,
                reverse_proxy: "",
                proxy_password: ""
            };
        . as $root
        | ($root | connection) as $conn
        | $root
        | .main_api = "openai"
        | .oai_settings = ((.oai_settings // {}) + $conn)
        | . + $conn
    ' "$settings" >"$tmp"; then
        rm -f "$tmp"
        die "could not write the SillyTavern settings.json"
    fi
    mv "$tmp" "$settings" || {
        rm -f "$tmp"
        die "could not replace the SillyTavern settings.json"
    }
    # A configured g4f API key has to be available to the custom connection.
    # Leave secrets alone when no key is set: the endpoint is keyless then.
    if [ -n "$G4F_API_KEY" ]; then
        secrets="$data_dir/secrets.json"
        [ -f "$secrets" ] || printf '{}\n' >"$secrets" \
            || die "could not create the SillyTavern secrets.json"
        tmp="${secrets}.tmp.$$"
        if ! jq --arg key "$G4F_API_KEY" '
            .api_key_custom = (
                ((.api_key_custom // []) | map(. + {active: false}))
                | map(select((.id // "") != "g4f"))
                + [{id: "g4f", value: $key, label: "g4f", active: true}]
            )' "$secrets" >"$tmp"; then
            rm -f "$tmp"
            die "could not write the SillyTavern secrets.json"
        fi
        mv "$tmp" "$secrets" || {
            rm -f "$tmp"
            die "could not replace the SillyTavern secrets.json"
        }
    fi
    log "pointing SillyTavern at the g4f API ($G4F_API_BASE_URL, model $G4F_PROVIDER_MODEL)"
}

# Open WebUI is the pip-installable upstream chat interface; it is a browser
# front end for OpenAI-compatible providers, including the sandbox's g4f API.
ensure_openwebui() {
    ensure_pip open-webui open-webui
}

# Open WebUI Computer ("cptr") is pip-installable: one local server that
# serves files, terminal, git, editor and AI to any browser on port 8000 --
# the machine it runs on, not a copy of it.
ensure_cptr() {
    ensure_pip cptr cptr
}

# assistant-ui is installed with its own CLI, which creates the app instead of
# adding a package: `npx assistant-ui@latest create` downloads the starter
# template (a Next.js app with the AI SDK chat route) and installs it. The app
# lives in ~/.assistant-ui; the CLI runs through npx, so nothing is installed
# globally. The g4f endpoint is written into the app afterwards (see
# write_assistant_ui_env and write_assistant_ui_route).
ensure_assistant_ui() {
    ensure_node
    have npx || die "npx is not on PATH"
    local dir="$HOME/$ASSISTANT_UI_DIR_NAME"
    if [ ! -f "$dir/package.json" ]; then
        log "creating the assistant-ui app in $dir (npx assistant-ui@latest create)..."
        rm -rf "$dir"
        ( cd "$HOME" && npx --yes assistant-ui@latest create \
            "$ASSISTANT_UI_DIR_NAME" -t minimal --use-npm --no-skills ) \
            || die "could not create the assistant-ui app"
    fi
    [ -f "$dir/package.json" ] || die "the assistant-ui app was not created"
    if [ ! -d "$dir/node_modules" ]; then
        log "installing the assistant-ui dependencies (npm install, this takes a while)..."
        ( cd "$dir" && npm install --no-fund --no-audit ) \
            || die "the assistant-ui npm install failed"
    fi
}

# code-server (VS Code in the browser) ships its own vendor installer. Explicit
# --method standalone skips the deb/package-manager path (and its systemctl
# step): the release tarball is unarchived into $prefix/lib/code-server-* and
# symlinked into $prefix/bin, which ~/.local/bin already covers on PATH; the
# fallback link below catches an installer revision without the symlink.
ensure_code_server() {
    have code-server && return 0
    log "installing code-server (vendor install script, ~/.local)..."
    curl -fsSL "$CODE_SERVER_INSTALLER" | \
        bash -s -- --method standalone --prefix "$HOME/.local" \
        || die "the code-server install script failed"
    if ! have code-server; then
        local candidate found=""
        for candidate in "$HOME"/.local/lib/code-server-*/bin/code-server; do
            [ -x "$candidate" ] && found="$candidate" && break
        done
        [ -n "$found" ] || die "code-server not on PATH after the install"
        mkdir -p "$HOME/.local/bin"
        ln -sf "$found" "$HOME/.local/bin/code-server" \
            || die "cannot link code-server"
    fi
    have code-server || die "code-server not on PATH after the install"
}

# mitmproxy is the "Proxy Server" entry: a debugging HTTP(S)
# proxy installed with pip --user. The console binary is `mitmproxy` (the other
# two, mitmdump/mitmweb, come with the same package).
ensure_mitmproxy() {
    have mitmproxy && return 0
    ensure_pip mitmproxy mitmproxy
}

# The "Tunnel Client" entry is backed by Cloudflare's
# cloudflared. The static binary is architecture-specific, so append the dpkg
# arch (amd64/arm64) to the release prefix and drop it in ~/.local/bin, which
# is already first on PATH. `cloudflared tunnel --url <target>` then opens a
# public https://<random>.trycloudflare.com URL with no account or config.
ensure_cloudflared() {
    have cloudflared && return 0
    local arch
    case "$(dpkg --print-architecture 2>/dev/null || echo amd64)" in
        arm64|aarch64) arch="arm64" ;;
        *) arch="amd64" ;;
    esac
    local url="$CLOUDFLARED_RELEASE_BASE/cloudflared-linux-$arch"
    log "installing cloudflared ($arch, the tunnel client)..."
    mkdir -p "$HOME/.local/bin"
    curl -fsSL -o "$HOME/.local/bin/cloudflared" "$url" \
        || die "could not download cloudflared ($url)"
    chmod 0755 "$HOME/.local/bin/cloudflared" \
        || die "cannot make cloudflared executable"
    have cloudflared || die "cloudflared not on PATH after the install"
}

configure_g4f() {
    local base_url api_key port
    api_key="${G4F_API_KEY:-}"
    base_url="${G4F_API_BASE_URL:-}"
    if [ -z "$base_url" ]; then
        for port in 8080 9090; do
            if curl -fsS --connect-timeout 1 --max-time 2 \
                -H "Authorization: Bearer $api_key" \
                "http://127.0.0.1:$port/v1" >/dev/null 2>&1; then
                base_url="http://127.0.0.1:$port/v1"
                break
            fi
        done
        if [ -z "$base_url" ]; then
            base_url="http://127.0.0.1:8080/v1"
            log "g4f API was not detected; using $base_url (set G4F_API_BASE_URL to override)"
        fi
    fi
    base_url="${base_url%/}"
    export G4F_API_BASE_URL="$base_url"
    export G4F_API_KEY="$api_key"
    # Model and provider come from the settings file (menu → Sandbox Settings);
    # G4F_SANDBOX_MODEL / G4F_SANDBOX_PROVIDER override it. Only a model that
    # was actually chosen is exported and used as the tools' default; with none
    # set, the launchers register the g4f provider with G4F_AUTO_MODEL instead.
    if [ -n "${G4F_SANDBOX_MODEL:-}" ]; then
        SANDBOX_MODEL_SET=1
    fi
    if [ "$SANDBOX_MODEL_SET" = 1 ]; then
        export G4F_MODEL="${G4F_SANDBOX_MODEL:-${SANDBOX_MODEL:-${G4F_MODEL:-}}}"
    fi
    # g4f resolves the provider from the model name itself ("Provider:model"),
    # so every launcher carries it in the model id. G4F_PROVIDER is only read by
    # the g4f server process, so exporting it here would not select anything.
    # The provider is optional: with none set, g4f picks from its own rotation.
    local provider="${G4F_SANDBOX_PROVIDER:-${SANDBOX_PROVIDER:-${G4F_PROVIDER:-}}}"
    G4F_PROVIDER_MODEL="$G4F_AUTO_MODEL"
    if [ "$SANDBOX_MODEL_SET" = 1 ]; then
        G4F_PROVIDER_MODEL="$G4F_MODEL"
        if [ -n "$provider" ]; then
            case "$G4F_PROVIDER_MODEL" in
                "$provider":*) ;;
                *) G4F_PROVIDER_MODEL="$provider:$G4F_PROVIDER_MODEL" ;;
            esac
        fi
    fi
    export G4F_AUTO_MODEL G4F_PROVIDER_MODEL
}

configure_opencode_g4f() {
    # Always register the g4f provider so its "auto" model is available in the
    # OpenCode model picker. Only a model chosen in Sandbox Settings is added
    # to the model list and made the default; otherwise OpenCode keeps its own
    # default model.
    local previous config
    previous="${OPENCODE_CONFIG_CONTENT:-}"
    [ -n "$previous" ] || previous='{}'
    if ! config=$(jq -cn \
        --argjson previous "$previous" \
        --arg base_url "$G4F_API_BASE_URL" \
        --arg api_key "$G4F_API_KEY" \
        --arg auto "$G4F_AUTO_MODEL" \
        --arg model "$G4F_PROVIDER_MODEL" \
        --argjson selected "$SANDBOX_MODEL_SET" '
        def merge($left; $right):
            if ($left | type) == "object" and ($right | type) == "object" then
                reduce ($right | keys_unsorted[]) as $key ($left;
                    .[$key] = if has($key) then merge(.[$key]; $right[$key]) else $right[$key] end)
            else
                $right
            end;
        ({($auto): {"name": $auto}}
         + (if $selected == 1 then {($model): {"name": $model}} else {} end)) as $models |
        merge($previous; ({
            "$schema": "https://opencode.ai/config.json",
            "provider": {
                "g4f": {
                    "npm": "@ai-sdk/openai-compatible",
                    "name": "g4f",
                    "options": {
                        "baseURL": $base_url,
                        "apiKey": $api_key
                    },
                    "models": $models
                }
            }
        } + (if $selected == 1 then {"model": ("g4f/" + $model)} else {} end)))' 2>/dev/null); then
        die "OPENCODE_CONFIG_CONTENT must contain valid JSON to enable the g4f provider"
    fi
    export OPENCODE_CONFIG_CONTENT="$config"
}

# OpenCode does its network traffic in a persistent background server
# (`opencode serve --service`) that the CLI only talks to over loopback. The
# server is started once and reused by every later invocation, and it does not
# inherit the CLI's environment, so the proxy variables exported for the CLI
# never reach the process that actually makes the requests: the CLI looks
# proxied while the server keeps connecting directly. OpenCode stores the
# server's environment in its own service config, so the proxy and CA variables
# are written there and the server is restarted when they change. With no proxy
# configured the variables are removed again, so turning the setting off takes
# effect as well.
configure_opencode_service() {
    have opencode || return 0
    local before after name value
    before=$(opencode service get 2>/dev/null) || return 0
    local -a vars=(
        HTTP_PROXY HTTPS_PROXY NO_PROXY
        NODE_EXTRA_CA_CERTS SSL_CERT_FILE REQUESTS_CA_BUNDLE CURL_CA_BUNDLE
        GIT_SSL_CAINFO AWS_CA_BUNDLE
    )
    if [ -n "${G4F_PROXY:-}" ]; then
        for name in "${vars[@]}"; do
            value=$(printenv "$name" 2>/dev/null) || continue
            opencode service set env "$name" "$value" >/dev/null 2>&1 || true
        done
    else
        for name in "${vars[@]}"; do
            opencode service unset env "$name" >/dev/null 2>&1 || true
        done
    fi
    after=$(opencode service get 2>/dev/null) || return 0
    [ "$before" = "$after" ] && return 0
    log "restarting the OpenCode background server to apply the proxy settings"
    opencode service restart >/dev/null 2>&1 \
        || log "could not restart the OpenCode background server; it may still use the previous proxy settings"
}

configure_copilot_g4f() {
    # Copilot's BYOK variables only take effect together with COPILOT_MODEL, so
    # the g4f provider cannot be registered without also selecting a model.
    # Keep the whole set gated on a model chosen in Sandbox Settings; otherwise
    # Copilot keeps its own provider and default model.
    [ "$SANDBOX_MODEL_SET" = 1 ] || return 0
    export COPILOT_PROVIDER_TYPE=openai
    export COPILOT_PROVIDER_BASE_URL="$G4F_API_BASE_URL"
    export COPILOT_PROVIDER_API_KEY="$G4F_API_KEY"
    export COPILOT_PROVIDER_MODEL_ID="$G4F_PROVIDER_MODEL"
    export COPILOT_MODEL="$G4F_PROVIDER_MODEL"
}

# Hermes has no environment-variable provider override, so its config file is
# written through `hermes config set` (the supported path; the file is YAML and
# the image ships no YAML parser). A `model_aliases.g4f` alias always registers
# the g4f endpoint (model "auto") for the /model picker without changing the
# default. When a model has been chosen, the global `model.*` settings are
# pointed at g4f as well, so that model becomes the default. The provider
# travels in the model name ("Provider:model"), which g4f resolves itself; a
# request body `extra_body.provider` is ignored by the server, and the base URL
# stays at /v1 so model listing keeps working.
configure_hermes_g4f() {
    log "registering the g4f API with Hermes ($G4F_API_BASE_URL)"
    hermes config set model_aliases.g4f.model "$G4F_AUTO_MODEL" >/dev/null \
        || die "could not configure Hermes (model_aliases.g4f.model)"
    hermes config set model_aliases.g4f.provider custom >/dev/null \
        || die "could not configure Hermes (model_aliases.g4f.provider)"
    hermes config set model_aliases.g4f.base_url "$G4F_API_BASE_URL" >/dev/null \
        || die "could not configure Hermes (model_aliases.g4f.base_url)"
    hermes config set model_aliases.g4f.key_env G4F_API_KEY >/dev/null \
        || die "could not configure Hermes (model_aliases.g4f.key_env)"
    if [ "$SANDBOX_MODEL_SET" = 1 ]; then
        log "pointing Hermes at the g4f model $G4F_PROVIDER_MODEL"
        hermes config set model.provider custom >/dev/null \
            || die "could not configure Hermes (model.provider)"
        hermes config set model.base_url "$G4F_API_BASE_URL" >/dev/null \
            || die "could not configure Hermes (model.base_url)"
        hermes config set model.default "$G4F_PROVIDER_MODEL" >/dev/null \
            || die "could not configure Hermes (model.default)"
        hermes config set model.key_env G4F_API_KEY >/dev/null \
            || die "could not configure Hermes (model.key_env)"
    fi
    # Drop a provider left behind by an earlier version of this launcher, which
    # wrote it to extra_body where g4f never read it.
    hermes config unset model.extra_body.provider >/dev/null 2>&1 || true
}

# The assistant-ui app reads its model settings from .env.local. Only three
# values are needed: the g4f endpoint, the key the endpoint expects (the
# sandbox API does not check it) and the model. The model is the g4f "auto"
# model, which lets g4f pick the provider itself, unless a model was chosen
# in Sandbox Settings -- then that model (with its provider prefix) is used.
write_assistant_ui_env() {
    local env="$HOME/$ASSISTANT_UI_DIR_NAME/.env.local"
    [ -f "$env" ] || printf '# Written by sandbox-cli.\n' >"$env" \
        || die "could not create $env"
    set_env_var "$env" G4F_API_BASE_URL "$G4F_API_BASE_URL"
    set_env_var "$env" G4F_API_KEY "$G4F_API_KEY"
    set_env_var "$env" G4F_MODEL "$G4F_PROVIDER_MODEL"
}

# The chat route of the generated app is replaced with one that talks to the
# g4f API: the endpoint speaks the OpenAI protocol, so the default provider
# is a baseURL swap on createOpenAI. The route is written once -- a marker
# comment keeps later launches from overwriting edits made in the app.
write_assistant_ui_route() {
    local route="$HOME/$ASSISTANT_UI_DIR_NAME/app/api/chat/route.ts"
    mkdir -p "$(dirname "$route")" || die "could not create the chat route directory"
    if grep -q "sandbox-cli" "$route" 2>/dev/null; then
        return 0
    fi
    cat >"$route" <<'ROUTE'
// Written by sandbox-cli: the sandbox's g4f API is the model provider.
// https://www.assistant-ui.com/docs/installation
import { createOpenAI } from "@ai-sdk/openai";
import { frontendTools } from "@assistant-ui/ai-sdk";
import {
  type JSONSchema7,
  streamText,
  convertToModelMessages,
  type UIMessage,
} from "ai";

export const maxDuration = 30;

// The g4f API speaks the OpenAI protocol, so the default provider is a
// baseURL swap. It serves /v1/chat/completions, so the chat model is used
// (the provider's default model speaks the Responses API instead). The "auto"
// model lets g4f choose the provider and the model itself; G4F_MODEL in
// .env.local overrides it.
const g4f = createOpenAI({
  baseURL: process.env.G4F_API_BASE_URL || "http://127.0.0.1:8080/v1",
  apiKey: process.env.G4F_API_KEY || "g4f",
});

export async function POST(req: Request) {
  const {
    messages,
    system,
    tools,
  }: {
    messages: UIMessage[];
    system?: string;
    tools?: Record<string, { description?: string; parameters: JSONSchema7 }>;
  } = await req.json();

  const aiSDKTools = { ...frontendTools(tools ?? {}) };
  const result = streamText({
    abortSignal: req.signal,
    model: g4f.chat(process.env.G4F_MODEL || "auto"),
    messages: await convertToModelMessages(messages, { tools: aiSDKTools }),
    tools: aiSDKTools,
    ...(system === undefined ? {} : { system }),
  });

  return result.toUIMessageStreamResponse({
    onError: (error) =>
      error instanceof Error ? error.message : String(error),
  });
}
ROUTE
}

# Next.js blocks dev-only resources (_next chunks, the HMR websocket) whose
# Origin is not in allowedDevOrigins, which is what the browser sends when the
# app is opened through the host router (http://assistant-ui.g4f.local/) rather
# than through 127.0.0.1. Without the entry the page loads but every chunk and
# the HMR socket come back 403. The generated next.config.ts is replaced with
# one that lists the sandbox host names and keeps the withAui() wrapper the
# template ships; the marker comment keeps later launches from overwriting
# edits made in the app.
write_assistant_ui_next_config() {
    local config="$HOME/$ASSISTANT_UI_DIR_NAME/next.config.ts"
    [ -f "$config" ] || return 0
    grep -q "sandbox-cli" "$config" 2>/dev/null && return 0
    local suffix="${G4F_DOMAIN_SUFFIX:-g4f.local}"
    if grep -q "allowedDevOrigins" "$config" 2>/dev/null; then
        # A newer template (or the user) already sets it: leave the list alone
        # and only add the sandbox host names when they are missing.
        grep -q "assistant-ui\.$suffix" "$config" 2>/dev/null && return 0
        sed -i "s/allowedDevOrigins: *\[/allowedDevOrigins: [\"assistant-ui.$suffix\", \"*.$suffix\", /" \
            "$config" || die "could not add the sandbox host names to next.config.ts"
        return 0
    fi
    # Only rewrite the shape the template ships; anything else is left alone
    # rather than clobbered.
    grep -q "withAui" "$config" 2>/dev/null || return 0
    cat >"$config" <<CONFIG
// Written by sandbox-cli: the app is also served as assistant-ui.$suffix, and
// Next.js blocks dev resources (chunks, HMR) whose Origin is not listed here.
import { withAui } from "@assistant-ui/next";
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  allowedDevOrigins: ["assistant-ui.$suffix", "*.$suffix"],
};

export default withAui(nextConfig);
CONFIG
}

# The addresses the sandbox serves through the g4f API: every app is reachable
# by hostname (<app>.g4f.local, or the port form <port>.g4f.local) on the same
# g4f API port. Printed on demand by "sandbox-cli urls". The names are seeded
# into the container's /etc/hosts by the entrypoint
# (see g4f/api/domain_router.py); the host needs the same names, which
# "sandbox-cli hosts" prints.
public_urls() {
    printf '%s\n' "Public Urls (g4f API port, e.g. 8080):"
    printf '  %s\n' \
        "http://g4f.g4f.local/ (also http://8080.g4f.local/)" \
        "http://omniroute.g4f.local/ (also http://20128.g4f.local/)" \
        "http://ollama.g4f.local/ (also http://11434.g4f.local/)" \
        "http://litellm.g4f.local/ (also http://4000.g4f.local/)" \
        "http://librechat.g4f.local/ (also http://3080.g4f.local/)" \
        "http://sillytavern.g4f.local/ (also http://8601.g4f.local/)" \
        "http://open-webui.g4f.local/ (also http://8603.g4f.local/)" \
        "http://cptr.g4f.local/ (also http://8000.g4f.local/)" \
        "http://code-server.g4f.local/ (also http://8602.g4f.local/)" \
        "http://opencode.g4f.local/ (also http://$OPENCODE_PORT.g4f.local/)" \
        "http://assistant-ui.g4f.local/ (also http://$ASSISTANT_UI_PORT.g4f.local/)" \
        "http://llamacpp.g4f.local/ (also http://11435.g4f.local/)" \
        "http://lmstudio.g4f.local/ (also http://1234.g4f.local/)" \
        "http://remote-desktop.g4f.local/ (also http://8765.g4f.local/)" \
        "http://desktop.g4f.local/ (noVNC desktop)" \
        "http://pi-freeflow.g4f.local/ (also http://28180.g4f.local/)"
    printf '%s\n' "Run \"sandbox-cli hosts\" for the /etc/hosts entries these names need."
}

# The /etc/hosts entries the host needs so the *.g4f.local names resolve to the
# published g4f API port. Derived from the router's own registry when python3
# and g4f are importable, so the list cannot drift from the code.
hosts_entries() {
    local suffix="${G4F_DOMAIN_SUFFIX:-g4f.local}"
    if have python3; then
        python3 -c "
from g4f.api.domain_router import hosts_entries
print('\n'.join(hosts_entries()))
" 2>/dev/null && return 0
    fi
    printf '127.0.0.1 %s\n' \
        "g4f.$suffix" "omniroute.$suffix" "ollama.$suffix" "litellm.$suffix" \
        "librechat.$suffix" "sillytavern.$suffix" "open-webui.$suffix" \
        "cptr.$suffix" "code-server.$suffix" "opencode.$suffix" \
        "assistant-ui.$suffix" \
        "llamacpp.$suffix" "lmstudio.$suffix" \
        "remote-desktop.$suffix" "desktop.$suffix" "pi-freeflow.$suffix" \
        "1234.$suffix" "$ASSISTANT_UI_PORT.$suffix" "3080.$suffix" "4000.$suffix" \
        "4096.$suffix" "8000.$suffix" "8080.$suffix" "8601.$suffix" \
        "8602.$suffix" "8603.$suffix" "8765.$suffix" "11434.$suffix" \
        "11435.$suffix" "20128.$suffix" "28180.$suffix"
}

# Print the /etc/hosts block for the host, ready to paste (see the "Host Names"
# menu entry).
print_hosts() {
    local suffix="${G4F_DOMAIN_SUFFIX:-g4f.local}"
    printf '%s\n' "Host names for the *.${suffix} app domains"
    printf '\n%s\n' "Add these lines to /etc/hosts on the machine running the browser:"
    printf '\n'
    hosts_entries | sed 's/^/  /'
    printf '\n%s\n' "Or resolve the whole suffix with a wildcard resolver, e.g. dnsmasq:"
    printf '  %s\n' "address=/${suffix}/127.0.0.1"
    printf '\n%s\n' "The names point at 127.0.0.1, so use them on the host that"
    printf '%s\n' "publishes the g4f API port (docker compose port mapping)."
}

launch() { # <label> <command...>
    if [ "$INSTALL_ONLY" = 1 ]; then
        log "$1 is installed and ready"
        exit 0
    fi
    shift
    exec "$@"
}

# ---------------------------------------------------------------------------
# Polli sandboxes
#
# `polli sandbox` starts a hosted Debian VM (E2B) billed to the Pollinations
# account and reaches it as <id>.polli over ssh. The menu entry wraps its
# subcommands in one terminal so the ids and options are visible; it also
# offers to install this very menu inside a sandbox (see
# polli-sandbox-bootstrap.sh), which is the way to get the g4f desktop menu in
# a Polli VM. Polli's ssh config uses `polli sandbox proxy` as its
# ProxyCommand and needs ssh-keygen, so the OpenSSH client is installed on the
# first click.
# ---------------------------------------------------------------------------
polli_sandbox_usage() {
    cat <<'EOF'
usage: sandbox-cli polli-sandbox                     interactive menu
       sandbox-cli polli-sandbox create [template] [seconds]
       sandbox-cli polli-sandbox list
       sandbox-cli polli-sandbox connect <id>        ssh <id>.polli
       sandbox-cli polli-sandbox timeout <id> <seconds>
       sandbox-cli polli-sandbox pause <id>
       sandbox-cli polli-sandbox logs <id>
       sandbox-cli polli-sandbox kill [id...]
       sandbox-cli polli-sandbox ssh-config
       sandbox-cli polli-sandbox install-menu <id> [--desktop]

create starts a sandbox (default template "pollinations"); timeout 0 keeps it
running until it is paused or killed. The storage is billed to the Pollinations
account, and a sandbox pauses itself after a few minutes without an ssh
session. install-menu copies this menu into a Polli sandbox; --desktop also
installs Xfce + TigerVNC, g4f-go and the noVNC web desktop, then prints the
ssh tunnels for the browser desktop and a direct VNC client.
EOF
}

ensure_ssh_client() {
    have ssh && have ssh-keygen && return 0
    log "installing the OpenSSH client (needed by polli sandbox)..."
    as_root apt-get update -qq || die "apt-get update failed"
    as_root apt-get install -y -qq openssh-client \
        || die "could not install openssh-client"
    have ssh && have ssh-keygen \
        || die "ssh/ssh-keygen not found after installing openssh-client"
}

polli_sandbox_valid_id() {
    case "$1" in
        ''|*[!a-z0-9-]*) return 1 ;;
        *) return 0 ;;
    esac
}

# Print the sandbox list to the terminal and return the id typed by the user.
# Everything except the id goes to stderr so the caller can capture stdout.
polli_sandbox_pick_id() { # <prompt>
    local id=""
    printf 'Your Polli sandboxes:\n' >&2
    polli sandbox list >&2 2>&1 || true
    printf '\n' >&2
    read -r -p "$1: " id || true
    printf '%s' "$id"
}

polli_sandbox_prepare_ssh() {
    polli sandbox ssh-config >/dev/null 2>&1 \
        || log "could not write ~/.pollinations/ssh/config; ssh may not know <id>.polli"
}

polli_sandbox_connect() {
    local id="${1:-}"
    [ -n "$id" ] || id="$(polli_sandbox_pick_id "sandbox id")"
    [ -n "$id" ] || return 0
    polli_sandbox_valid_id "$id" || die "invalid sandbox id \"$id\""
    polli_sandbox_prepare_ssh
    log "connecting to ${id}.polli (leave the shell to return to the menu)..."
    ssh "${id}.polli" || log "ssh exited with status $?"
}

polli_sandbox_create() {
    local template="" timeout="" output id answer
    while [ $# -gt 0 ]; do
        case "$1" in
            --timeout)
                [ $# -ge 2 ] || die "--timeout needs a number of seconds"
                timeout="$2"; shift 2 ;;
            -h|--help)
                polli sandbox create --help; return 0 ;;
            -*)
                die "unknown create option \"$1\" — run: polli sandbox create --help" ;;
            *)
                if [ -z "$template" ]; then
                    template="$1"
                elif [ -z "$timeout" ]; then
                    timeout="$1"
                else
                    die "too many arguments for create"
                fi
                shift ;;
        esac
    done
    if [ -z "$template" ] && [ -t 0 ]; then
        printf 'Template: pollinations (default), pollinations-large (4 vCPU/8 GB),\n'
        printf 'or pollinations-azure / -azure-large / -azure-xl.\n'
        read -r -p "template [pollinations]: " template || true
    fi
    template="${template:-pollinations}"
    if [ -z "$timeout" ] && [ -t 0 ]; then
        read -r -p "timeout in seconds, 0 = never expire [600]: " timeout || true
    fi
    timeout="${timeout:-600}"
    case "$timeout" in
        ''|*[!0-9]*) die "timeout must be a whole number of seconds (0 = never expire)" ;;
    esac
    polli_sandbox_prepare_ssh
    log "creating a Polli sandbox ($template, timeout ${timeout}s)..."
    output="$(polli sandbox create --timeout "$timeout" "$template" 2>&1)" || {
        printf '%s\n' "$output"
        die "could not create the Polli sandbox"
    }
    printf '%s\n' "$output"
    id="$(printf '%s\n' "$output" \
        | sed -n 's/^Sandbox \([^ ]*\) created.*/\1/p' | head -n1)"
    if [ -z "$id" ]; then
        log "created; run 'polli sandbox list' to see its id"
        return 0
    fi
    polli_sandbox_prepare_ssh
    [ -t 0 ] || return 0
    read -r -p "Connect to $id now? [Y/n]: " answer || true
    case "$answer" in
        n|N|no|NO) ;;
        *) polli_sandbox_connect "$id" ;;
    esac
}

polli_sandbox_timeout() {
    local id="${1:-}" seconds="${2:-}"
    [ -n "$id" ] || id="$(polli_sandbox_pick_id "sandbox id")"
    [ -n "$id" ] || return 0
    polli_sandbox_valid_id "$id" || die "invalid sandbox id \"$id\""
    if [ -z "$seconds" ] && [ -t 0 ]; then
        read -r -p "keep running for how many seconds (0 = never expire): " seconds || true
    fi
    case "$seconds" in
        ''|*[!0-9]*) die "timeout must be a whole number of seconds (0 = never expire)" ;;
    esac
    polli sandbox timeout "$id" "$seconds" || die "could not set the timeout of $id"
}

polli_sandbox_pause() {
    local id="${1:-}"
    [ -n "$id" ] || id="$(polli_sandbox_pick_id "sandbox id")"
    [ -n "$id" ] || return 0
    polli_sandbox_valid_id "$id" || die "invalid sandbox id \"$id\""
    polli sandbox pause "$id" || die "could not pause $id"
}

polli_sandbox_logs() {
    local id="${1:-}"
    [ -n "$id" ] || id="$(polli_sandbox_pick_id "sandbox id")"
    [ -n "$id" ] || return 0
    polli_sandbox_valid_id "$id" || die "invalid sandbox id \"$id\""
    polli sandbox logs "$id" || die "could not read the logs of $id"
}

polli_sandbox_kill() {
    local ids=("$@") answer typed id
    if [ ${#ids[@]} -eq 0 ]; then
        typed="$(polli_sandbox_pick_id "sandbox id (space-separated for several)")"
        [ -n "$typed" ] || return 0
        read -r -a ids <<< "$typed"
    fi
    [ ${#ids[@]} -gt 0 ] || return 0
    for id in "${ids[@]}"; do
        polli_sandbox_valid_id "$id" || die "invalid sandbox id \"$id\""
    done
    log "This deletes ${ids[*]} and their files."
    read -r -p "Kill? [y/N]: " answer || true
    case "$answer" in
        y|Y|yes|YES) polli sandbox kill "${ids[@]}" || die "could not kill ${ids[*]}" ;;
        *) log "kept ${ids[*]}" ;;
    esac
}

# Copy the menu assets into a Polli sandbox and run the bootstrap there. The
# host tar writes the same paths the sandbox image uses, so the bootstrap can
# patch the Xfce menu and start the desktop without extra arguments.
polli_sandbox_install_menu() {
    local id="${1:-}" desktop="${2:-ask}" stage="" boot_src="" answer
    local files=() path self_dir
    [ -n "$id" ] || id="$(polli_sandbox_pick_id "sandbox id")"
    [ -n "$id" ] || return 0
    polli_sandbox_valid_id "$id" || die "invalid sandbox id \"$id\""
    if [ "$desktop" = ask ] && [ -t 0 ]; then
        read -r -p "Also install the Xfce desktop + noVNC + g4f-go (larger)? [y/N]: " answer || true
        case "$answer" in
            y|Y|yes|YES) desktop=1 ;;
            *) desktop=0 ;;
        esac
    fi
    case "$desktop" in
        1|--desktop|desktop|yes|YES) desktop=1 ;;
        *) desktop=0 ;;
    esac

    for path in \
        /usr/local/bin/sandbox-cli \
        /usr/local/bin/patch-xfce-menu.py \
        /usr/local/bin/polli-sandbox-bootstrap.sh \
        /usr/local/bin/sandbox-docs \
        /usr/local/bin/sandbox-discover \
        /usr/local/bin/start-browser \
        /usr/share/applications/g4f-sandbox-*.desktop \
        /usr/share/desktop-directories/g4f-sandbox-*.directory \
        /usr/share/icons/hicolor/*/apps/g4f-sandbox-*; do
        [ -e "$path" ] && files+=("${path#/}")
    done || true

    # The browser desktop needs the same g4f API + noVNC assets as the image:
    # g4f-go serves the API and, with G4F_VNC_PORT/G4F_NOVNC_DIR, the noVNC
    # client and its WebSocket bridge on the same port as the desktop.
    if [ "$desktop" = 1 ]; then
        for path in /usr/local/bin/g4f-go /usr/share/novnc; do
            [ -e "$path" ] && files+=("${path#/}")
        done || true
    fi

    if [ ! -f /usr/local/bin/polli-sandbox-bootstrap.sh ]; then
        self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
        boot_src="$self_dir/polli-sandbox-bootstrap.sh"
        [ -f "$boot_src" ] \
            || die "polli-sandbox-bootstrap.sh is missing (rebuild the sandbox image)"
        stage="$(mktemp -d)" || die "could not create a temporary directory"
        mkdir -p "$stage/usr/local/bin"
        cp "$boot_src" "$stage/usr/local/bin/" || die "could not stage the bootstrap script"
    fi
    [ ${#files[@]} -gt 0 ] || die "no sandbox menu assets found in this image"

    polli_sandbox_prepare_ssh
    log "copying the sandbox menu into ${id}.polli..."
    if [ -n "$stage" ]; then
        tar -C / -czf - "${files[@]}" \
            -C "$stage" usr/local/bin/polli-sandbox-bootstrap.sh \
            | ssh "$id.polli" 'sudo -n tar -C / -xzf -' \
            || die "could not copy the menu into $id (does the sandbox user have sudo?)"
        rm -rf "$stage"
    else
        tar -C / -czf - "${files[@]}" \
            | ssh "$id.polli" 'sudo -n tar -C / -xzf -' \
            || die "could not copy the menu into $id (does the sandbox user have sudo?)"
    fi

    log "installing the sandbox menu inside $id..."
    if [ "$desktop" = 1 ]; then
        ssh "$id.polli" 'sudo -n /usr/local/bin/polli-sandbox-bootstrap.sh --desktop' \
            || die "the menu bootstrap failed inside $id"
    else
        ssh "$id.polli" 'sudo -n /usr/local/bin/polli-sandbox-bootstrap.sh' \
            || die "the menu bootstrap failed inside $id"
    fi
}

polli_sandbox_menu() {
    local choice
    while :; do
        printf '\nPolli sandboxes — hosted E2B VMs billed to your Pollinations account\n'
        polli auth status 2>/dev/null | sed 's/^/  /' \
            || printf '  not logged in — run: polli auth login\n'
        cat <<'EOF'
  1) List sandboxes
  2) Create a sandbox
  3) Connect (ssh <id>.polli)
  4) Set a timeout
  5) Pause a sandbox
  6) Show logs
  7) Kill sandboxes
  8) Install this sandbox menu inside a Polli sandbox
  9) Show the ssh config
  q) Quit
EOF
        read -r -p "Choice: " choice || { printf '\n'; return 0; }
        case "$choice" in
            1) polli sandbox list || log "could not list the sandboxes" ;;
            2) polli_sandbox_create ;;
            3) polli_sandbox_connect ;;
            4) polli_sandbox_timeout ;;
            5) polli_sandbox_pause ;;
            6) polli_sandbox_logs ;;
            7) polli_sandbox_kill ;;
            8) polli_sandbox_install_menu ;;
            9) polli sandbox ssh-config ;;
            q|Q|quit|exit) return 0 ;;
            *) log "unknown choice: $choice" ;;
        esac
        printf '\n'
    done
}

polli_sandbox_dispatch() {
    case "${1:-}" in
        '') polli_sandbox_menu; hold_open ;;
        list) shift; polli sandbox list "$@"; hold_open ;;
        create) shift; polli_sandbox_create "$@" ;;
        connect|ssh) shift; polli_sandbox_connect "$@" ;;
        timeout) shift; polli_sandbox_timeout "$@" ;;
        pause) shift; polli_sandbox_pause "$@" ;;
        logs) shift; polli_sandbox_logs "$@"; hold_open ;;
        kill) shift; polli_sandbox_kill "$@" ;;
        ssh-config) shift; polli sandbox ssh-config "$@"; hold_open ;;
        install-menu|setup) shift; polli_sandbox_install_menu "$@" ;;
        -h|--help|help) polli_sandbox_usage ;;
        *) exec polli sandbox "$@" ;;
    esac
}

run_tool() {
    # The saved HOME, API key and PROXY apply to every entry, g4f-aware or not.
    apply_sandbox_home
    apply_sandbox_api_key
    apply_sandbox_proxy
    case "$1" in
        remote-desktop)
            ensure_remote_desktop
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Remote Desktop is installed and ready"
                exit 0
            fi
            # `g4f-go -m remote_desktop` runs the bundled server from its own
            # runtime and brings up the embedded STUN/TURN relay. The terminal
            # stays open as its console (Ctrl-C stops it); the banner prints
            # the host page and the phone URL. Open the host page on this
            # desktop, press Share, then scan the QR code from the phone.
            log "starting the Remote Desktop server (Ctrl-C to stop)"
            launch "Remote Desktop" g4f-go -m remote_desktop "${@:2}" ;;
        opencode)
            ensure_script "$OPENCODE_INSTALLER" opencode "OpenCode"
            if [ "$INSTALL_ONLY" = 0 ]; then
                configure_g4f
                configure_opencode_g4f
                configure_opencode_service
            fi
            launch OpenCode opencode "${@:2}" ;;
        opencode-server)
            # The headless API served at opencode.g4f.local. The host router
            # starts this entry on demand, so opening the hostname installs
            # OpenCode if needed and then leaves this server running in a
            # terminal window (closing the window stops it).
            ensure_script "$OPENCODE_INSTALLER" opencode "OpenCode"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "OpenCode is installed and ready (run: sandbox-cli opencode-server)"
                exit 0
            fi
            configure_g4f
            configure_opencode_g4f
            local suffix="${G4F_DOMAIN_SUFFIX:-g4f.local}"
            log "starting the OpenCode server on http://127.0.0.1:$OPENCODE_PORT (Ctrl-C to stop)"
            # The browser reaches the proxy as opencode.<suffix>; OpenCode's
            # CORS check sees that Origin, so allow the host and port names.
            launch "OpenCode server" opencode serve --hostname 127.0.0.1 \
                --port "$OPENCODE_PORT" \
                --cors "http://opencode.$suffix" \
                --cors "http://$OPENCODE_PORT.$suffix" "${@:2}" ;;
        omp)
            ensure_omp
            ensure_pi_freeflow_omp
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Oh My Pi is installed and ready (run: omp, then /model -> freeflow)"
                exit 0
            fi
            # pi-freeflow registers the free-model catalog as the "freeflow"
            # provider inside OMP; the model picker in the TUI is where a model
            # is chosen, so no --model is forced here.
            log "starting Oh My Pi (Ctrl-C to stop)"
            log "pick a free model with /model -> freeflow"
            launch "Oh My Pi" omp "${@:2}" ;;
        pi)
            ensure_pi
            ensure_pi_freeflow_pi
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Pi is installed and ready (run: pi, then /model -> freeflow)"
                exit 0
            fi
            log "starting Pi (Ctrl-C to stop)"
            log "pick a free model with /model -> freeflow"
            launch Pi pi "${@:2}" ;;
        thinkrail)
            ensure_thinkrail
            ensure_git
            launch ThinkRail thinkrail "${@:2}" ;;
        pi-freeflow)
            ensure_freeflow_api
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "pi-freeflow is installed and ready (run: sandbox-cli pi-freeflow)"
                exit 0
            fi
            # An OMP/Pi session may already own the shared port. Its daemon
            # serves the same catalog and token, so report it instead of
            # failing to bind a second one.
            if curl -fsS --max-time 2 "http://127.0.0.1:$FREEFLOW_PORT/health" 2>/dev/null \
                | grep -q '"service":"pi-freeflow"'; then
                log "pi-freeflow is already serving on http://127.0.0.1:$FREEFLOW_PORT/v1"
                show_freeflow_access
                hold_freeflow_lease
                exit 0
            fi
            start_freeflow_api ;;
        kilocode)
            ensure_npm @kilocode/cli kilo
            configure_g4f
            export OPENAI_API_BASE="$G4F_API_BASE_URL" OPENAI_BASE_URL="$G4F_API_BASE_URL" OPENAI_API_KEY="$G4F_API_KEY"
            launch "Kilo Code" kilo "${@:2}" ;;
        antigravity)
            ensure_script "$ANTIGRAVITY_INSTALLER" agy "Antigravity CLI"
            launch "Antigravity CLI" agy "${@:2}" ;;
        gemini-cli)
            ensure_npm @google/gemini-cli gemini
            launch "Gemini CLI" gemini "${@:2}" ;;
        qwencode)
            ensure_npm @qwen-code/qwen-code qwen
            launch "Qwen Code" qwen "${@:2}" ;;
        claude-code)
            ensure_npm @anthropic-ai/claude-code claude
            launch "Claude Code" claude "${@:2}" ;;
        codex)
            ensure_npm @openai/codex codex
            launch Codex codex "${@:2}" ;;
        cindy)
            ensure_cindy_release
            provision_cindy
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Cindy is installed and ready"
                exit 0
            fi
            log "opening Cindy on the desktop"
            nohup setsid cindy >/tmp/cindy.log 2>&1 &
            exit 0 ;;
        copilot)
            ensure_npm @github/copilot copilot
            if [ "$INSTALL_ONLY" = 0 ]; then
                configure_g4f
                configure_copilot_g4f
            fi
            launch "GitHub Copilot" copilot "${@:2}" ;;
        g4f-go)
            have g4f-go || die "g4f-go is not installed"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "g4f-go is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch "g4f-go" g4f-go "${@:2}"
            fi
            log "g4f-go is preinstalled. Run 'g4f-go help' to see available commands."
            exec bash -i ;;
        cline)
            ensure_npm cline cline
            launch Cline cline "${@:2}" ;;
        amp)
            ensure_npm @ampcode/cli amp
            launch Amp amp "${@:2}" ;;
        goose)
            ensure_script "$GOOSE_INSTALLER" goose "Goose"
            launch Goose goose "${@:2}" ;;
        aider)
            ensure_pip aider-chat aider
            if [ -n "${G4F_HARNESS:-}" ]; then
                # Aider has no model environment variable; select the g4f
                # model on the command line. OPENAI_API_BASE/KEY are exported
                # by the harness entry.
                launch Aider aider --model "openai/$G4F_PROVIDER_MODEL" "${@:2}"
            fi
            launch Aider aider "${@:2}" ;;
        cursor)
            ensure_script "$CURSOR_INSTALLER" cursor-agent "Cursor CLI"
            launch "Cursor CLI" cursor-agent "${@:2}" ;;
        chrome)
            ensure_chrome
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Google Chrome is installed and ready"
                exit 0
            fi
            # Chrome is a GUI app: open it on the desktop via start-browser
            # (debug port + port forward, --once = no relaunch loop), detached,
            # so the launcher terminal can close while the browser keeps running.
            log "opening Google Chrome on the desktop"
            nohup setsid /usr/local/bin/start-browser chrome --once \
                >/tmp/chrome.log 2>&1 &
            # start-browser exits as soon as Chrome does, so a crash would
            # otherwise close this terminal before the reason is readable.
            # Wait for the CDP port (or the process) and report the log.
            local waited=0
            while [ "$waited" -lt 20 ]; do
                port_open 57011 && break
                pgrep -f "remote-debugging-port=57011" >/dev/null 2>&1 || break
                sleep 1
                waited=$((waited + 1))
            done
            if port_open 57011; then
                log "Chrome is running on the desktop (CDP on 127.0.0.1:57011)"
                exit 0
            fi
            printf '\nChrome did not start. Last lines of /tmp/chrome.log:\n\n' >&2
            tail -n 20 /tmp/chrome.log >&2 2>/dev/null || true
            die "Chrome failed to start (see /tmp/chrome.log)" ;;
        lightpanda)
            ensure_lightpanda
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Lightpanda is installed and ready"
                exit 0
            fi
            # Not baked into the image: installed on first click, then served
            # here in the foreground. g4f-go browser serve prints the CDP
            # endpoint and keeps running, so the terminal stays open as the
            # server console (Ctrl-C stops it). g4f picks the endpoint up
            # automatically when it runs headless.
            log "starting the Lightpanda CDP server (Ctrl-C to stop)"
            launch Lightpanda g4f-go browser serve "${@:2}" ;;
        omniroute)
            ensure_npm omniroute omniroute
            launch OmniRoute omniroute "${@:2}" ;;
        proxy)
            ensure_mitmproxy
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "mitmproxy is installed and ready"
                exit 0
            fi
            # mitmproxy's console UI is the proxy itself; the terminal stays
            # open as its console (Ctrl-C stops it). Listen on 8081, not the
            # default 8080, so it does not collide with the g4f API. Point the
            # Sandbox Settings PROXY at http://127.0.0.1:8081 to route the menu
            # entries through it.
            #
            # confdir is pinned to PROXY_CA_DIR so the CA lands where
            # trust_proxy_ca looks for it; mitmproxy's default (~/.mitmproxy)
            # would leave the tools trusting a certificate that does not exist.
            log "starting mitmproxy on 127.0.0.1:8081 (Ctrl-C to stop)"
            launch "Proxy Server" mitmproxy \
                --set confdir="$PROXY_CA_DIR" \
                --listen-host 127.0.0.1 --listen-port 8081 "${@:2}" ;;
        tunnel)
            ensure_cloudflared
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "the tunnel client (cloudflared) is installed and ready"
                exit 0
            fi
            # One-click tunnel: expose a local port on a public HTTPS URL. The
            # target defaults to the g4f API (the docs' phone/GUI guide uses
            # it); override it with TUNNEL_TARGET or extra arguments, e.g.
            #   sandbox-cli tunnel --url http://127.0.0.1:3000
            local target="${TUNNEL_TARGET:-http://127.0.0.1:8080}"
            log "opening a public tunnel to $target (Ctrl-C to stop)"
            launch "Tunnel Client" cloudflared tunnel --url "$target" \
                --no-autoupdate "${@:2}" ;;
        ollama)
            ensure_ollama
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Ollama is installed and ready"
                exit 0
            fi
            # `ollama serve` is the daemon; the terminal stays open as its
            # console (Ctrl-C stops it). The API is on 127.0.0.1:11434 and g4f
            # reaches it through the ollama.g4f.local hostname.
            log "starting the Ollama server (Ctrl-C to stop)"
            launch Ollama ollama serve "${@:2}" ;;
        llamacpp)
            ensure_llamacpp
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "llama.cpp is installed and ready"
                exit 0
            fi
            # llama-server exposes an OpenAI-compatible API on 8080 by default;
            # move it to 11435 so it does not collide with the g4f API.
            log "starting llama-server on http://127.0.0.1:11435 (Ctrl-C to stop)"
            launch "llama.cpp" llama-server --port 11435 "${@:2}" ;;
        lmstudio)
            ensure_lmstudio
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "LM Studio is installed and ready"
                exit 0
            fi
            log "starting the LM Studio daemon (Ctrl-C to stop)"
            launch "LM Studio" lms server start "${@:2}" ;;
        jan)
            ensure_jan
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Jan is installed and ready"
                exit 0
            fi
            # Jan is a GUI app: open it on the desktop, detached, so the
            # launcher terminal can close while the app keeps running.
            log "opening Jan on the desktop"
            nohup setsid jan >/tmp/jan.log 2>&1 &
            exit 0 ;;
        librechat)
            ensure_librechat
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "LibreChat is installed and ready"
                exit 0
            fi
            # LibreChat is a web app: MongoDB first, then the Node server in
            # the foreground so the terminal stays open as its console.
            configure_g4f
            write_librechat_env
            write_librechat_yaml
            start_mongodb
            log "starting LibreChat on http://127.0.0.1:$LIBRECHAT_PORT (Ctrl-C to stop)"
            cd "$HOME/$LIBRECHAT_DIR_NAME" || die "LibreChat checkout is missing"
            launch LibreChat npm run backend "${@:2}" ;;
        lobechat)
            ensure_lobechat
            configure_g4f
            export OPENAI_API_BASE="$G4F_API_BASE_URL" OPENAI_BASE_URL="$G4F_API_BASE_URL" OPENAI_API_KEY="$G4F_API_KEY"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "LobeChat is installed and ready"
                exit 0
            fi
            # Electron GUI app: open it on the desktop, detached, so the
            # launcher terminal can close while the app keeps running.
            log "opening LobeChat on the desktop"
            nohup setsid lobechat >/tmp/lobechat.log 2>&1 &
            exit 0 ;;
        msty)
            ensure_msty
            configure_g4f
            export OPENAI_API_BASE="$G4F_API_BASE_URL" OPENAI_BASE_URL="$G4F_API_BASE_URL" OPENAI_API_KEY="$G4F_API_KEY"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Msty Studio is installed and ready"
                exit 0
            fi
            log "opening Msty Studio on the desktop"
            nohup setsid msty >/tmp/msty.log 2>&1 &
            exit 0 ;;
        openbot)
            ensure_openbot
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "OpenBot is installed and ready"
                exit 0
            fi
            log "opening OpenBot on the desktop"
            nohup setsid openbot >/tmp/openbot.log 2>&1 &
            exit 0 ;;
        orca)
            ensure_orca
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Orca is installed and ready"
                exit 0
            fi
            log "opening Orca on the desktop"
            nohup setsid orca >/tmp/orca.log 2>&1 &
            exit 0 ;;
        voicestudio)
            ensure_voicestudio
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "VoiceStudio is installed and ready"
                exit 0
            fi
            log "opening VoiceStudio on the desktop"
            nohup setsid voicestudio >/tmp/voicestudio.log 2>&1 &
            exit 0 ;;
        sillytavern)
            ensure_sillytavern
            configure_g4f
            configure_sillytavern_g4f
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "SillyTavern is installed and ready"
                exit 0
            fi
            # SillyTavern is a web app: the Node server runs in the foreground
            # so the terminal stays open as its console (Ctrl-C stops it).
            log "starting SillyTavern on http://127.0.0.1:$SILLYTAVERN_PORT (Ctrl-C to stop)"
            cd "$HOME/$SILLYTAVERN_DIR_NAME" || die "SillyTavern checkout is missing"
            launch SillyTavern node server.js "${@:2}" ;;
        cptr)
            ensure_cptr
            configure_g4f
            export OPENAI_API_BASE="$G4F_API_BASE_URL" OPENAI_BASE_URL="$G4F_API_BASE_URL" OPENAI_API_KEY="$G4F_API_KEY"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Open WebUI Computer is installed and ready"
                exit 0
            fi
            # cptr serves the machine it runs on — files, terminal, git,
            # editor and AI — to any browser; the terminal stays open as its
            # console (Ctrl-C stops it).
            log "starting Open WebUI Computer on http://127.0.0.1:$CPTR_PORT (Ctrl-C to stop)"
            launch "Open WebUI Computer" cptr run "${@:2}" ;;
        open-webui)
            ensure_openwebui
            configure_g4f
            export OPENAI_API_BASE_URL="$G4F_API_BASE_URL" OPENAI_API_KEY="$G4F_API_KEY"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Open WebUI is installed and ready"
                exit 0
            fi
            # Keep chats, accounts and settings in one directory under HOME.
            # The server also writes its session key there, so signing in
            # survives a restarted container.
            open_webui_dir="$HOME/.open-webui"
            mkdir -p "$open_webui_dir" || die "could not create $open_webui_dir"
            cd "$open_webui_dir" || die "could not enter $open_webui_dir"
            export DATA_DIR="$open_webui_dir"
            log "starting Open WebUI on http://127.0.0.1:$OPEN_WEBUI_PORT (Ctrl-C to stop)"
            launch "Open WebUI" open-webui serve --port "$OPEN_WEBUI_PORT" "${@:2}" ;;
        code-server)
            ensure_code_server
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "code-server is installed and ready"
                exit 0
            fi
            # code-server serves VS Code Web; the terminal stays open as its
            # console (Ctrl-C stops it). Listen on 8602, not the default
            # 8080, so it does not collide with the g4f API. --auth none keeps
            # the local loopback session free of a login prompt.
            log "starting code-server on http://127.0.0.1:$CODE_SERVER_PORT (Ctrl-C to stop)"
            launch "VS Code Web" code-server --auth none \
                --bind-addr "127.0.0.1:$CODE_SERVER_PORT" "${@:2}" ;;
        kate)
            ensure_kate
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Kate is installed and ready"
                exit 0
            fi
            # Kate is a GUI app: open it on the desktop, detached, so the
            # launcher terminal can close while the editor keeps running.
            log "opening Kate on the desktop"
            nohup setsid kate >/tmp/kate.log 2>&1 &
            exit 0 ;;
        openclaw)
            ensure_script "$OPENCLAW_INSTALLER" openclaw "OpenClaw"
            launch OpenClaw openclaw "${@:2}" ;;
        crush)
            ensure_npm @charmland/crush crush
            launch Crush crush "${@:2}" ;;
        continue-cli)
            ensure_npm @continuedev/cli cn
            launch "Continue CLI" cn "${@:2}" ;;
        grok)
            ensure_script "$GROK_INSTALLER" grok "Grok Build CLI"
            launch "Grok Build CLI" grok "${@:2}" ;;
        droid)
            ensure_script "$DROID_INSTALLER" droid "Factory Droid"
            launch "Factory Droid" droid "${@:2}" ;;
        openhands)
            ensure_script "$OPENHANDS_INSTALLER" openhands "OpenHands CLI"
            launch "OpenHands CLI" openhands "${@:2}" ;;
        gptme)
            ensure_pip gptme gptme
            launch gptme gptme "${@:2}" ;;
        plandex)
            ensure_script "$PLANDEX_INSTALLER" plandex "Plandex"
            launch Plandex plandex "${@:2}" ;;
        hermes)
            ensure_hermes
            if [ "$INSTALL_ONLY" = 0 ]; then
                configure_g4f
                configure_hermes_g4f
            fi
            launch "Hermes Agent" hermes "${@:2}" ;;
        omh)
            ensure_hermes
            ensure_omh
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Oh My Hermes is installed and ready"
                exit 0
            fi
            # OMH reads the same Hermes home the Hermes entry configures. Run
            # setup before pointing Hermes at g4f so a fresh install registers
            # its managed skills, then write the g4f alias last so setup cannot
            # overwrite it.
            configure_g4f
            log "preparing Oh My Hermes (omh setup --yes)..."
            omh setup --yes || die "omh setup failed"
            configure_hermes_g4f
            launch "Oh My Hermes" omh "${@:2}" ;;
        kiro)
            ensure_unzip
            ensure_script "$KIRO_INSTALLER" kiro-cli "Kiro CLI"
            launch "Kiro CLI" kiro-cli "${@:2}" ;;
        kimi)
            ensure_script "$KIMI_INSTALLER" kimi "Kimi Code"
            launch "Kimi Code" kimi "${@:2}" ;;
        roomote)
            ensure_roomote
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Roomote is installed and ready"
                exit 0
            fi
            # Roomote is a web app: the installer already started the Compose
            # stack, so print the first-admin setup link and keep the terminal
            # open as its console (`roomote logs` follows the services).
            log "Roomote setup link:"
            as_root roomote setup-url || true
            log "following the Roomote logs (Ctrl-C to stop)"
            as_root roomote logs "${@:2}" ;;
        auggie)
            ensure_npm @augmentcode/auggie auggie
            launch Auggie auggie "${@:2}" ;;
        qoder)
            ensure_script "$QODER_INSTALLER" qodercli "Qoder CLI"
            launch "Qoder CLI" qodercli "${@:2}" ;;
        iflow)
            ensure_npm @iflow-ai/iflow-cli iflow
            launch "iFlow CLI" iflow "${@:2}" ;;
        vibe)
            ensure_script "$VIBE_INSTALLER" vibe "Mistral Vibe"
            launch "Mistral Vibe" vibe "${@:2}" ;;
        sweagent)
            ensure_sweagent
            launch "SWE-agent" sweagent "${@:2}" ;;
        dsh)
            ensure_npm "$DEEPSEEK_HARNESS_PACKAGE" dsh
            # A bare `dsh` only prints help; the web profile is the entry point
            # the docs lead with.
            launch "DeepSeek Harness" dsh web "${@:2}" ;;
        paseo)
            ensure_npm "$PASEO_PACKAGE" paseo
            launch Paseo paseo "${@:2}" ;;
        t3code)
            ensure_script "$T3CODE_INSTALLER" t3 "T3 Code"
            launch "T3 Code" t3 "${@:2}" ;;
        fabric)
            ensure_script "$FABRIC_INSTALLER" fabric "Fabric"
            launch Fabric fabric "${@:2}" ;;
        litellm)
            ensure_pip 'litellm[proxy]' litellm
            launch "LiteLLM gateway" litellm "${@:2}" ;;
        langfuse)
            ensure_npm @langfuse/cli langfuse
            launch "Langfuse CLI" langfuse "${@:2}" ;;
        sgpt)
            ensure_pip shell-gpt sgpt
            launch ShellGPT sgpt "${@:2}" ;;
        llm)
            ensure_pip llm llm
            launch "LLM CLI" llm "${@:2}" ;;
        files|nemo)
            # GUI file managers: installed on first click, then opened on the
            # desktop detached, so the launcher terminal closes and the window
            # stays. An optional argument is the folder to open.
            # Thunar is the lightweight Xfce default; if its apt install fails
            # (stale mirror, missing package), fall back to Nemo rather than
            # leaving the entry dead. The Nemo entry tries Nemo first and falls
            # back to Thunar for the same reason.
            if [ "$1" = nemo ]; then
                if ensure_nemo; then
                    label="File Manager (Nemo)"
                    app=nemo
                else
                    log "Nemo could not be installed; falling back to Thunar"
                    ensure_apt thunar thunar
                    label="File Manager"
                    app=thunar
                fi
            elif ensure_apt_soft thunar thunar; then
                label="File Manager"
                app=thunar
            else
                log "Thunar could not be installed; falling back to Nemo"
                ensure_nemo || die "could not install a file manager"
                label="File Manager (Nemo)"
                app=nemo
            fi
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "$label is installed and ready"
                exit 0
            fi
            target="$HOME"
            [ $# -gt 1 ] && target="$2"
            log "opening $target in $label"
            nohup setsid "$app" "$target" >"/tmp/$app.log" 2>&1 &
            exit 0 ;;
        discover)
            # The scout asks OpenCode to find new/trending tools and edit this
            # launcher plus the desktop entries. OpenCode talks to the g4f API,
            # so register that provider first (the same config the OpenCode
            # entry writes); the agent then runs unattended with --auto.
            ensure_script "$OPENCODE_INSTALLER" opencode "OpenCode"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "OpenCode is installed and ready"
                exit 0
            fi
            configure_g4f
            configure_opencode_g4f
            status=0
            sandbox-discover "${@:2}" || status=$?
            [ "$status" = 0 ] || log "the scout exited with status $status"
            log "report: ${XDG_CACHE_HOME:-$HOME/.cache}/sandbox-discover/report.md"
            exec bash -i ;;
        glow)
            # The terminal docs reader: sandbox-docs downloads the markdown
            # behind https://g4f.dev/docs/, turns its images into chafa art and
            # opens the result in glow. Glow prints every link as a real URL
            # (Ctrl+click in xfce4-terminal) and its TUI copies the open page,
            # including code blocks, with `c` (see `?` for the keys).
            ensure_apt glow glow
            ensure_apt chafa chafa
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "glow, chafa and the g4f docs reader are installed and ready"
                exit 0
            fi
            exec /usr/local/bin/sandbox-docs open "${@:2}" ;;
        docs)
            # The browser entry: open the real docs site in Chrome so every
            # code block gets the site's own copy button and navigation stays
            # a normal web page.
            ensure_chrome
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Google Chrome is installed and ready"
                exit 0
            fi
            log "opening the g4f docs in Chrome..."
            nohup setsid /usr/local/bin/start-browser chrome --once \
                "$G4F_DOCS_URL" >/tmp/g4f-docs.log 2>&1 &
            local waited=0
            while [ "$waited" -lt 20 ]; do
                port_open 57011 && break
                pgrep -f "remote-debugging-port=57011" >/dev/null 2>&1 || break
                sleep 1
                waited=$((waited + 1))
            done
            if port_open 57011; then
                log "the g4f docs are open on the desktop"
                exit 0
            fi
            printf '\nChrome did not start. Last lines of /tmp/g4f-docs.log:\n\n' >&2
            tail -n 20 /tmp/g4f-docs.log >&2 2>/dev/null || true
            die "could not open the g4f docs (see /tmp/g4f-docs.log)" ;;
        polli)
            ensure_npm @pollinations/cli polli
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Polli is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch "Polli" polli "${@:2}"
            fi
            # Polli has no TUI of its own: a bare `polli` prints usage and
            # exits, which would close the terminal window. Start a shell with
            # the CLI on PATH instead and show the login state first.
            log "Polli is ready (https://gen.pollinations.ai). Try:"
            log "  polli gen text \"Say hello in one sentence\""
            log "  polli gen image \"a cat in space\" --output cat.png"
            polli auth status 2>/dev/null || true
            exec bash -i ;;
        polli-sandbox)
            ensure_npm @pollinations/cli polli
            ensure_ssh_client
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Polli and the OpenSSH client are installed and ready"
                exit 0
            fi
            polli_sandbox_dispatch "${@:2}" ;;
        hyperframes)
            ensure_hyperframes
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "HyperFrames is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch HyperFrames hyperframes "${@:2}"
            fi
            # HyperFrames is a one-shot CLI: bare `hyperframes` prints usage and
            # exits, so show the common commands and leave a shell open.
            log "HyperFrames is ready. Examples:"
            log "  hyperframes init my-video"
            log "  hyperframes preview"
            log "  hyperframes render"
            hyperframes --help 2>/dev/null || true
            exec bash -i ;;
        repomix)
            ensure_npm repomix repomix
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "repomix is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch repomix repomix "${@:2}"
            fi
            repomix --help 2>/dev/null || true
            exec bash -i ;;
        files-to-prompt)
            ensure_pip files-to-prompt files-to-prompt
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "files-to-prompt is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch files-to-prompt files-to-prompt "${@:2}"
            fi
            files-to-prompt --help 2>/dev/null || true
            exec bash -i ;;
        gitingest)
            ensure_pip gitingest gitingest
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "gitingest is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch gitingest gitingest "${@:2}"
            fi
            gitingest --help 2>/dev/null || true
            exec bash -i ;;
        assistant-ui)
            ensure_assistant_ui
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "assistant-ui is installed and ready"
                exit 0
            fi
            # The app is a web app: the Next.js dev server runs in the
            # foreground so the terminal stays open as its console. Its chat
            # route talks to the g4f API on the loopback interface, so the
            # chat needs no provider key.
            configure_g4f
            write_assistant_ui_env
            write_assistant_ui_route
            write_assistant_ui_next_config
            log "starting assistant-ui on http://127.0.0.1:$ASSISTANT_UI_PORT (Ctrl-C to stop)"
            cd "$HOME/$ASSISTANT_UI_DIR_NAME" || die "the assistant-ui app is missing"
            launch "assistant-ui" npm run dev -- \
                --port "$ASSISTANT_UI_PORT" --hostname 127.0.0.1 "${@:2}" ;;
        huggingface|hf)
            ensure_hf
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "the Hugging Face CLI (hf) is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch "Hugging Face Spaces" hf "${@:2}"
            fi
            # A bare entry lists trending Spaces, then leaves the shell open so
            # the CLI can be used for inspection and downloads. `hf spaces ls
            # <owner/name>` shows a Space's files and `hf spaces info` its
            # metadata; auth is only needed for private or gated repositories.
            log "trending Hugging Face Spaces:"
            hf spaces ls --limit 20 2>/dev/null || true
            hf auth whoami 2>/dev/null || log "not signed in (optional: hf auth login)"
            log "try: hf spaces info <owner/name>  or  hf download <owner/name> --repo-type space"
            exec bash -i ;;
        deeptutor)
            ensure_deeptutor
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "DeepTutor is installed and ready"
                exit 0
            fi
            # The web app runs in the foreground as the terminal's console; the
            # first run asks for the runtime home and a model provider.
            log "starting DeepTutor on http://127.0.0.1:3782 (Ctrl-C to stop)"
            launch DeepTutor deeptutor start "${@:2}" ;;
        nanobot)
            ensure_nanobot
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "nanobot is installed and ready"
                exit 0
            fi
            log "starting nanobot on http://127.0.0.1:$NANOBOT_PORT (Ctrl-C to stop)"
            launch nanobot nanobot webui --port "$NANOBOT_PORT" "${@:2}" ;;
        agent-reach)
            ensure_agent_reach
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Agent Reach is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch "Agent Reach" agent-reach "${@:2}"
            fi
            # setup is idempotent but slow: enable the default channels and
            # install the agent skill once, then show the CLI state and keep a
            # shell open for channel-specific commands.
            if [ ! -f "$HOME/.agent-reach/config.yaml" ]; then
                log "setting up Agent Reach (default channels + agent skill)..."
                agent-reach setup 2>/dev/null | tail -n 20 || true
            fi
            agent-reach version 2>/dev/null || true
            log "try: agent-reach channels  or  agent-reach read <url>"
            exec bash -i ;;
        humanizer)
            ensure_humanizer
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "the Humanizer skill is installed and ready"
                exit 0
            fi
            # The skill has no command of its own: each supported agent loads
            # it as /humanizer (or asks for it in plain language). Keep the
            # terminal open with a reminder of where it landed.
            log "Humanizer is installed for the agents under $HOME/.agents/skills/humanizer"
            log "use /humanizer in OpenCode, Codex, Claude Code or another skills-aware agent"
            exec bash -i ;;
        ecc)
            ensure_ecc
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "ECC is installed and ready"
                exit 0
            fi
            log "ECC is installed under $HOME/.config/opencode (skills, rules, commands)"
            log "restart OpenCode to load them"
            exec bash -i ;;
        superpowers)
            ensure_superpowers
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Superpowers is installed and ready"
                exit 0
            fi
            log "Superpowers is registered in $HOME/.config/opencode/opencode.json"
            log "restart OpenCode; the skills are available as /superpowers commands"
            exec bash -i ;;
        graphify)
            ensure_graphify
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Graphify is installed and ready"
                exit 0
            fi
            log "Graphify is installed under $HOME/.agents/skills/graphify"
            log "ask your agent to graphify a folder, or run: graphify --help"
            exec bash -i ;;
        claude-mem)
            ensure_claude_mem
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "Claude-Mem is installed and ready"
                exit 0
            fi
            log "Claude-Mem is installed: $HOME/.config/opencode/plugins/claude-mem.js"
            log "it captures memory automatically after OpenCode restarts"
            log "memory runs on your own Claude plan (--provider claude)"
            exec bash -i ;;
        ai-detect)
            ensure_pip_git "$AI_TEXT_DETECTOR_REPO" ai-detect "AI Text Detector"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "the AI Text Detector is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch "AI Text Detector" ai-detect "${@:2}"
            fi
            log "AI Text Detector is ready. Examples:"
            log "  ai-detect essay.txt"
            log "  cat draft.md | ai-detect --json"
            ai-detect --help 2>/dev/null || true
            exec bash -i ;;
        aidetect)
            ensure_pip_git "$AI_IMAGE_DETECTOR_REPO" aidetect "AI Image Detector"
            if [ "$INSTALL_ONLY" = 1 ]; then
                log "the AI Image Detector is installed and ready"
                exit 0
            fi
            if [ $# -gt 1 ]; then
                launch "AI Image Detector" aidetect "${@:2}"
            fi
            log "AI Image Detector is ready. Examples:"
            log "  aidetect detect image.jpg"
            log "  aidetect detect ./images --csv report.csv"
            aidetect --help 2>/dev/null || true
            exec bash -i ;;
        *)
            die "unknown tool \"$1\" — run: sandbox-cli list" ;;
    esac
}

# ---------------------------------------------------------------------------
# Coding harnesses on the g4f API
#
# A "harness" is a coding agent (OpenCode, Claude Code, Codex, Pi, ...) that
# this CLI can launch with the sandbox's g4f API as its model provider. The
# harness command lists the agents, reads the available models straight from
# the API's /v1/models endpoint and saves a per-agent model in the same
# settings file the rest of the menu uses (HARNESS_<AGENT>=<model>). Only that
# launch is affected: the global model in Sandbox Settings stays the default
# for every other tool. The model is passed to the launchers through
# G4F_SANDBOX_MODEL, which configure_g4f already gives precedence over the
# saved settings and which the launchers prefix with the optional provider.
# ---------------------------------------------------------------------------
HARNESS_AGENTS="opencode claude-code kilocode copilot hermes omh aider"

harness_usage() {
    cat <<'EOF'
usage: sandbox-cli harness                         interactive menu
       sandbox-cli harness list                    harnesses and their saved model
       sandbox-cli harness models [query]          models from the g4f API
       sandbox-cli harness model <agent> [model]   save a model for one harness
       sandbox-cli harness clear <agent>           use the global model again
       sandbox-cli harness <agent> [--provider P] [--model M]
                                                   launch it against the g4f API

models reads the running g4f API (http://127.0.0.1:8080/v1 by default, or
G4F_API_BASE_URL) and prints the ids from /v1/models. A saved harness model
overrides the global model from Sandbox Settings for that agent only; without
one the launchers keep using the global model or g4f's "auto".

Harnesses with a built-in g4f integration: opencode, claude-code, kilocode,
copilot, hermes, omh and aider. Every harness also gets the g4f endpoint as
OPENAI_API_BASE / ANTHROPIC_BASE_URL, so other agents can pick it up too.
EOF
}

harness_env_file() {
    printf '%s' "${G4F_SANDBOX_ENV:-$(real_home)/.config/g4f-sandbox/env}"
}

harness_model_key() { # <agent>
    printf 'HARNESS_%s' \
        "$(printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -c 'A-Z0-9' '_')"
}

harness_saved_model() { # <agent>
    local file key
    file="$(harness_env_file)"
    key="$(harness_model_key "$1")"
    [ -f "$file" ] || return 0
    sed -n "s/^${key}=//p" "$file" | tail -n 1
}

harness_save_model() { # <agent> <model>
    local file key tmp
    file="$(harness_env_file)"
    key="$(harness_model_key "$1")"
    mkdir -p "$(dirname "$file")" || die "cannot create $(dirname "$file")"
    tmp="${file}.tmp.$$"
    {
        grep -v "^${key}=" "$file" 2>/dev/null || true
        if [ -n "$2" ]; then
            printf '%s=%s\n' "$key" "$2"
        fi
    } > "$tmp" || die "cannot write $tmp"
    mv "$tmp" "$file" || die "cannot write $file"
    chmod 600 "$file" 2>/dev/null || true
}

harness_api_ready() {
    apply_sandbox_api_key
    configure_g4f
}

# Export the g4f endpoint in the shapes the harnesses read. OpenAI-compatible
# CLIs use OPENAI_API_BASE/OPENAI_BASE_URL and OPENAI_API_KEY; Claude Code
# speaks the Anthropic Messages API (/v1/messages on the same server) and wants
# the base URL without the /v1 suffix. G4F_HARNESS tells the agent branches in
# run_tool that a per-harness model is active, so they can pass it on the
# command line where the tool has no environment variable for the model.
harness_export_env() {
    export OPENAI_API_BASE="$G4F_API_BASE_URL"
    export OPENAI_BASE_URL="$G4F_API_BASE_URL"
    export OPENAI_API_KEY="$G4F_API_KEY"
    export OPENAI_MODEL="$G4F_PROVIDER_MODEL"
    export ANTHROPIC_BASE_URL="${G4F_API_BASE_URL%/v1}"
    export ANTHROPIC_API_KEY="$G4F_API_KEY"
    export ANTHROPIC_AUTH_TOKEN="$G4F_API_KEY"
    export ANTHROPIC_MODEL="$G4F_PROVIDER_MODEL"
    export ANTHROPIC_SMALL_FAST_MODEL="$G4F_PROVIDER_MODEL"
    export G4F_HARNESS=1
}

harness_models() { # [query]
    local query="${1:-}" json models
    harness_api_ready
    json="$(curl -fsS --connect-timeout 2 --max-time 15 \
        -H "Authorization: Bearer ${G4F_API_KEY:-}" \
        "$G4F_API_BASE_URL/models" 2>/dev/null)" \
        || die "could not read models from $G4F_API_BASE_URL — start the g4f API first"
    if have jq; then
        models="$(printf '%s' "$json" | jq -r '.data[]?.id // empty' 2>/dev/null)"
    else
        models="$(printf '%s' "$json" | python3 -c '
import json, sys
doc = json.load(sys.stdin)
items = doc.get("data", doc) if isinstance(doc, dict) else doc
for item in items or []:
    print(item.get("id", "") if isinstance(item, dict) else item)
' 2>/dev/null)"
    fi
    models="$(printf '%s\n' "$models" | sed '/^$/d')"
    if [ -n "$query" ]; then
        models="$(printf '%s\n' "$models" | grep -i -- "$query" || true)"
    fi
    [ -n "$models" ] \
        || die "the g4f API at $G4F_API_BASE_URL returned no models${query:+ matching \"$query\"}"
    printf '%s\n' "$models"
}

harness_pick_agent() { # <prompt> [default]
    local agent=""
    printf 'Harnesses: %s\n' "$HARNESS_AGENTS" >&2
    read -r -p "$1${2:+ [$2]}: " agent || true
    printf '%s' "${agent:-${2:-}}"
}

harness_pick_model() { # [query]
    local query="${1:-}" models choice
    models="$(harness_models "$query")" || return 1
    printf 'Models from %s:\n' "$G4F_API_BASE_URL" >&2
    printf '%s\n' "$models" | awk '{printf "  %3d) %s\n", NR, $0}' >&2
    read -r -p "Model number or id (blank to keep the current one): " choice || true
    [ -n "$choice" ] || return 0
    case "$choice" in
        *[!0-9]*) printf '%s' "$choice" ;;
        *)
            printf '%s\n' "$models" | sed -n "${choice}p" \
                | grep . || log "there is no model number $choice" >&2 ;;
    esac
}

harness_list() {
    local agent model
    harness_api_ready
    printf 'g4f API:      %s\n' "$G4F_API_BASE_URL"
    printf 'global model: %s\n\n' "${G4F_PROVIDER_MODEL:-auto}"
    printf '%-14s %s\n' HARNESS MODEL
    for agent in $HARNESS_AGENTS; do
        model="$(harness_saved_model "$agent")"
        printf '%-14s %s\n' "$agent" "${model:-(global/default)}"
    done
}

harness_set_model() { # [agent] [model]
    local agent="${1:-}" model="${2:-}"
    [ -n "$agent" ] || agent="$(harness_pick_agent "Harness" opencode)"
    [ -n "$agent" ] || return 0
    if [ -z "$model" ]; then
        model="$(harness_pick_model)" || return 1
    fi
    [ -n "$model" ] || return 0
    harness_save_model "$agent" "$model"
    log "$agent now uses the g4f model \"$model\" (saved in $(harness_env_file))."
}

harness_clear_model() { # [agent]
    local agent="${1:-}"
    [ -n "$agent" ] || agent="$(harness_pick_agent "Harness to clear" opencode)"
    [ -n "$agent" ] || return 0
    harness_save_model "$agent" ""
    log "$agent will use the global model again."
}

harness_launch() { # [agent] [--provider P] [--model M]
    local agent="${1:-}" provider="" model="" saved="" explicit_model=0 no_save=0
    if [ -z "$agent" ]; then
        agent="$(harness_pick_agent "Harness to launch" opencode)"
    else
        shift
    fi
    [ -n "$agent" ] || { harness_menu; return 0; }
    while [ $# -gt 0 ]; do
        case "$1" in
            --provider) provider="${2:-}"; shift 2 ;;
            --model) model="${2:-}"; explicit_model=1; shift 2 ;;
            --no-save) no_save=1; shift ;;
            *) shift ;;
        esac
    done
    saved="$(harness_saved_model "$agent")"
    [ -n "$model" ] || model="$saved"
    if [ -n "$model" ] && { [ "$explicit_model" = 1 ] || [ -z "${G4F_SANDBOX_MODEL+x}" ]; }; then
        export G4F_SANDBOX_MODEL="$model"
        if [ "$no_save" = 0 ] && [ "$model" != "$saved" ]; then
            harness_save_model "$agent" "$model"
        fi
    fi
    [ -n "$provider" ] && export G4F_SANDBOX_PROVIDER="$provider"
    harness_api_ready
    harness_export_env
    exec "$0" "$agent"
}

harness_menu() {
    local choice
    while :; do
        printf '\nCoding harnesses on the g4f API\n'
        harness_list
        cat <<'EOF'

  1) Set a harness model from the g4f API
  2) Launch a harness
  3) List the g4f models
  4) Clear a harness model
  5) Show the g4f endpoint and key
  q) Quit
EOF
        read -r -p "Choice: " choice || { printf '\n'; return 0; }
        case "$choice" in
            1) harness_set_model ;;
            2) harness_launch ;;
            3) harness_models | sed 's/^/  /' || true ;;
            4) harness_clear_model ;;
            5) harness_api_ready
               printf '  API: %s\n  key: %s\n' \
                   "$G4F_API_BASE_URL" "$(mask_secret "${G4F_API_KEY:-}")" ;;
            q|Q|quit|exit) return 0 ;;
            *) log "unknown choice: $choice" ;;
        esac
        printf '\n'
    done
}

harness_dispatch() {
    case "${1:-}" in
        '') harness_menu; hold_open ;;
        list) harness_list; hold_open ;;
        models) shift; harness_models "$@"; hold_open ;;
        model|set) shift; harness_set_model "$@"; hold_open ;;
        clear|reset) shift; harness_clear_model "$@"; hold_open ;;
        -h|--help|help) harness_usage ;;
        *) harness_launch "$@" ;;
    esac
}

run_configure() { # <agent>
    local agent="${1:-}"
    [ -n "$agent" ] || die "usage: sandbox-cli configure <agent>"
    configure_g4f
    case "$agent" in
        opencode) configure_opencode_g4f; configure_opencode_service ;;
        copilot) configure_copilot_g4f ;;
        hermes|omh) configure_hermes_g4f ;;
        assistant-ui) write_assistant_ui_env ;;
        librechat) write_librechat_env; write_librechat_yaml ;;
        sillytavern) configure_sillytavern_g4f ;;
        cindy) provision_cindy ;;
        lobechat) ;;
        msty) ;;
        cptr) ;;
        *) log "no agent-specific provider registration for '$agent'; g4f env configured" ;;
    esac
    log "g4f provider registered for $agent"
}

usage() {
    cat <<'EOF'
usage: sandbox-cli <tool> [args...]   install (first click) and run a desktop tool
       sandbox-cli install <tool>     install only, don't run it
       sandbox-cli settings           choose the g4f model, API key, HOME, provider and proxy
       sandbox-cli reset              clear the saved settings, back to the defaults
       sandbox-cli package            show, update or pin the g4f package
       sandbox-cli update             update the g4f-go launcher itself
       sandbox-cli upgrade            update the g4f package in the runtime
       sandbox-cli uninstall          remove g4f-go and its managed data
       sandbox-cli list               list the tools behind the desktop menu
       sandbox-cli urls               print the public URLs served by the g4f API
       sandbox-cli hosts              print the /etc/hosts entries for the app domains
       sandbox-cli harness            run a coding agent on the g4f API and models
       sandbox-cli polli-sandbox      create/connect/manage hosted Polli sandboxes
       sandbox-cli help               this help

Installs go to /usr/local (npm -g via sudo), ~/.local (pip --user and the
vendor install scripts) or wherever the installer puts them; the CLI then
runs in this terminal. The HOME and PROXY saved in the sandbox settings apply
to every entry.
EOF
}

list_tools() {
    # Grouped like the desktop menu (see docker/sandbox-menu/README.md).
    printf '%s\n' "g4f"
    printf '  %-15s %s\n' \
        g4f-go          "g4f Go launcher CLI (preinstalled)" \
        package         "show, update or pin the g4f package" \
        update          "update the g4f-go launcher itself" \
        upgrade         "update the g4f package in the runtime" \
        uninstall       "remove g4f-go and its managed data" \
        remote-desktop  "share this sandbox screen with a phone (WebRTC)" \
        glow            "open the g4f docs in glow (clickable links, copy code with c)" \
        docs            "open the g4f docs in the browser (clickable links, copyable code)" \
        discover        "find new/trending AI tools and add them to this menu" \
        settings        "g4f model, API key, HOME and provider for the launchers" \
        reset           "clear the saved settings, back to the defaults" \
        urls            "public URLs served by the g4f API" \
        hosts           "host /etc/hosts entries for the app domains"
    printf '%s\n' "Browsers"
    printf '  %-15s %s\n' \
        chrome      "Google Chrome browser" \
        lightpanda  "Lightpanda headless browser (CDP server)"
    printf '%s\n' "Files & Editors"
    printf '  %-15s %s\n' \
        files       "Thunar file manager" \
        nemo        "Nemo file manager (Cinnamon)" \
        code-server "VS Code Web (code-server)" \
        kate        "Kate text editor (KDE)"
    printf '%s\n' "Coding Agents"
    printf '  %-15s %s\n' \
        opencode    "OpenCode coding agent" \
        opencode-server "OpenCode headless API (opencode.g4f.local)" \
        cindy       "Cindy desktop AI agent" \
        openbot     "OpenBot multi-agent desktop workspace" \
        thinkrail   "ThinkRail agentic IDE by JetBrains" \
        orca        "Orca IDE for parallel agentic development" \
        omp         "Oh My Pi (OMP) coding agent" \
        pi          "Pi coding agent" \
        kilocode    "Kilo Code terminal agent" \
        antigravity "Antigravity CLI by Google" \
        gemini-cli  "Google Gemini CLI" \
        qwencode    "Qwen Code CLI" \
        claude-code "Anthropic Claude Code" \
        codex       "OpenAI Codex CLI" \
        copilot     "GitHub Copilot CLI" \
        cline       "Cline terminal agent" \
        amp         "Amp by Sourcegraph" \
        goose       "Goose by Block" \
        aider       "Aider pair-programming CLI" \
        cursor      "Cursor CLI" \
        crush       "Crush agent by Charm" \
        continue-cli "Continue CLI agent" \
        grok        "xAI Grok Build CLI" \
        droid       "Factory Droid agent" \
        openhands   "OpenHands coding agent" \
        plandex     "Plandex coding agent" \
        hermes      "Hermes Agent by Nous Research" \
        omh         "Oh My Hermes (OMH) harness for Hermes Agent" \
        kiro        "Kiro CLI by AWS" \
        kimi        "Kimi Code by Moonshot AI" \
        roomote     "Roomote self-hosted coding agent" \
        auggie      "Auggie by Augment Code" \
        qoder       "Qoder CLI" \
        iflow       "iFlow CLI" \
        vibe        "Mistral Vibe" \
        sweagent    "SWE-agent by Princeton" \
        dsh         "DeepSeek Harness (dsh) coding agent" \
        harness     "run a coding agent on the g4f API with a chosen model" \
        paseo       "Paseo coding-agent orchestrator" \
        t3code      "T3 Code agent control surface"
    printf '%s\n' "Agent Skills"
    printf '  %-15s %s\n' \
        ecc         "ECC skills, rules and commands for OpenCode" \
        superpowers "Superpowers skills plugin for OpenCode" \
        graphify    "Graphify codebase knowledge-graph skill" \
        claude-mem  "Claude-Mem cross-session memory for OpenCode"
    printf '%s\n' "Gateways"
    printf '  %-15s %s\n' \
        omniroute   "OmniRoute AI router/gateway" \
        litellm     "LiteLLM AI gateway" \
        langfuse    "Langfuse LLM observability CLI" \
        pi-freeflow "Freeflow API on port 28180 (35 free models)" \
        proxy       "mitmproxy HTTP(S) proxy server" \
        tunnel      "Tunnel client (public HTTPS URL via cloudflared)"
    printf '%s\n' "Local AI"
    printf '  %-15s %s\n' \
        ollama      "Ollama local model server" \
        llamacpp    "llama.cpp llama-server" \
        lmstudio    "LM Studio (lms CLI + daemon)" \
        jan         "Jan desktop app"
    printf '%s\n' "Chat UIs"
    printf '  %-15s %s\n' \
        librechat   "LibreChat web UI (Node + MongoDB)" \
        lobechat    "LobeChat desktop app" \
        msty        "Msty Studio desktop app" \
        sillytavern "SillyTavern web UI for role-play chats" \
        open-webui  "Open WebUI chat interface (backed by g4f)" \
        dsh         "DeepSeek Harness web UI (chat + coding)" \
        assistant-ui "assistant-ui web UI (Next.js, backed by the g4f API)"
    printf '%s\n' "Media"
    printf '  %-15s %s\n' \
        polli       "Pollinations CLI (text, image, audio, video, 3D)" \
        voicestudio "VoiceStudio voice cloning and TTS app" \
        hyperframes "HyperFrames HTML-to-video renderer"
    printf '%s\n' "Sandboxes"
    printf '  %-15s %s\n' \
        polli-sandbox "create, connect to and manage hosted Polli (E2B) sandboxes"
    printf '%s\n' "Writing"
    printf '  %-15s %s\n' \
        humanizer   "Humanizer skill for AI-sounding text" \
        ai-detect   "AI-generated text risk analyzer" \
        aidetect    "AI-generated image detector"
    printf '%s\n' "Assistants"
    printf '  %-15s %s\n' \
        cptr        "Open WebUI Computer (files + terminal + AI)" \
        huggingface "Hugging Face Spaces browser (hf CLI)" \
        deeptutor   "DeepTutor personalized learning web app" \
        nanobot     "nanobot personal AI assistant (WebUI)" \
        agent-reach "Agent Reach internet access for agents" \
        dsh         "DeepSeek Harness assistant + coding harness" \
        openclaw    "OpenClaw CLI" \
        fabric      "Fabric AI patterns CLI" \
        sgpt        "ShellGPT shell assistant" \
        gptme       "gptme terminal agent" \
        llm         "LLM CLI by Simon Willison"
}

case "${1:-}" in
    ""|help|-h|--help)
        usage ;;
    harness)
        shift
        harness_dispatch "$@" ;;
    list)
        list_tools ;;
    urls)
        public_urls
        hold_open ;;
    hosts)
        print_hosts
        hold_open ;;
    configure)
        shift
        run_configure "$@" ;;
    settings)
        shift
        run_settings "$@" ;;
    reset)
        shift
        run_reset "$@" ;;
    package)
        shift
        run_package "$@" ;;
    update|upgrade|uninstall)
        command="$1"
        shift
        run_management "$command" "$@" ;;
    install)
        [ $# -ge 2 ] || die "usage: sandbox-cli install <tool>"
        INSTALL_ONLY=1
        run_tool "$2" ;;
    *)
        tool="$1"
        shift
        run_tool "$tool" "$@" ;;
esac
