Files
wehub-resource-sync 9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:33 +08:00

4694 lines
180 KiB
Bash
Executable File

#!/usr/bin/env bash
# ods-cli - Command line interface for ODS
# Mission: M5 (Clonable ODS Setup Server)
# Version: 2.0.0 — Registry-driven service resolution
set -euo pipefail
# Require Bash 4+ (associative arrays used by service registry and ods-cli)
if (( BASH_VERSINFO[0] < 4 )); then
echo -e "\033[0;31m✗\033[0m ods-cli requires Bash 4.0+ (you have $BASH_VERSION)" >&2
echo " macOS ships Bash 3.2 due to licensing. Install a modern version:" >&2
echo " brew install bash" >&2
echo " Then re-run with: /opt/homebrew/bin/bash $0 $*" >&2
exit 1
fi
#=============================================================================
# Configuration
#=============================================================================
_source="${BASH_SOURCE[0]}"
while [[ -L "$_source" ]]; do
_dir="$(cd "$(dirname "$_source")" && pwd)"
_source="$(readlink "$_source")"
[[ "$_source" != /* ]] && _source="$_dir/$_source"
done
SCRIPT_DIR="$(cd "$(dirname "$_source")" && pwd)"
INSTALL_DIR="${ODS_HOME:-$HOME/ods}"
VERSION="2.0.0"
if [[ -f "$INSTALL_DIR/.env" ]]; then
_v=$(awk -F= '/^ODS_VERSION=/{gsub(/^["'"'"']|["'"'"']$/,"",$2); print $2; exit}' "$INSTALL_DIR/.env" 2>/dev/null) || true
[[ -n "${_v:-}" ]] && VERSION="$_v"
fi
# Colors — use ANSI-C quoting ($'\033') so the variables hold real ESC bytes
# at assignment time. Single-quoted strings store the backslash literally,
# which breaks unquoted heredocs (cmd_help) that never re-interpret escapes.
# Guard on TTY + NO_COLOR so redirected / piped output stays clean.
# Per no-color.org, NO_COLOR disables colors when it is present (set to any
# value, including empty), so we check set-ness with ${NO_COLOR+x} — which
# expands to "x" whenever NO_COLOR is set and to "" only when truly unset —
# rather than ${NO_COLOR:-} which would treat NO_COLOR="" as unset.
if [[ -t 1 ]] && [[ -z "${NO_COLOR+x}" ]]; then
RED=$'\033[0;31m'
GREEN=$'\033[0;32m'
YELLOW=$'\033[1;33m'
BLUE=$'\033[0;34m'
CYAN=$'\033[0;36m'
NC=$'\033[0m'
else
RED=''
GREEN=''
YELLOW=''
BLUE=''
CYAN=''
NC=''
fi
#=============================================================================
# Helpers
#=============================================================================
log() { echo -e "${CYAN}[ods]${NC} $1" >&2; }
success() { echo -e "${GREEN}${NC} $1"; }
warn() { echo -e "${YELLOW}${NC} $1" >&2; }
error() { echo -e "${RED}${NC} $1" >&2; exit 1; }
log_error() { echo -e "${RED}${NC} $1" >&2; }
# Map cmd_list status string to a colour. Returns an empty string for unknown
# statuses so future state names don't break formatting.
_status_color() {
case "$1" in
enabled|always-on) echo "$GREEN" ;;
disabled|stopped) echo "$YELLOW" ;;
unhealthy|error) echo "$RED" ;;
*) echo "" ;;
esac
}
_json_escape() {
local s="${1-}"
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\b'/\\b}
s=${s//$'\f'/\\f}
s=${s//$'\n'/\\n}
s=${s//$'\r'/\\r}
s=${s//$'\t'/\\t}
printf '%s' "$s"
}
# Print a separator of repeated characters filling the given width.
# Usage: hr <width> [<char=─>]
hr() {
local w="${1:-10}" c="${2:-}" out=''
printf -v out '%*s' "$w" ''
printf '%s' "${out// /$c}"
}
# Update or add a key=value in .env
_env_set() {
local key="$1" val="$2" file="$INSTALL_DIR/.env"
if grep -q "^${key}=" "$file" 2>/dev/null; then
# Use awk to avoid sed delimiter collisions with values containing | / or =
awk -v k="$key" -v v="$val" '{
if (index($0, k "=") == 1) print k "=" v; else print
}' "$file" > "${file}.tmp" && cat "${file}.tmp" > "$file" && rm -f "${file}.tmp"
else
echo "${key}=${val}" >> "$file"
fi
}
_env_unset() {
local key="$1" file="$INSTALL_DIR/.env"
[[ -f "$file" ]] || return 0
awk -v k="$key" 'index($0, k "=") != 1 { print }' "$file" > "${file}.tmp" \
&& cat "${file}.tmp" > "$file" \
&& rm -f "${file}.tmp"
}
_env_get_raw() {
local key="$1" file="$INSTALL_DIR/.env"
[[ -f "$file" ]] || { echo ""; return 0; }
grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true
}
_ods_cli_bootstrap_status() {
local status_file="$INSTALL_DIR/data/bootstrap-status.json"
[[ -f "$status_file" ]] || return 0
grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$status_file" \
| sed -n '1p' \
| sed 's/.*"status"[[:space:]]*:[[:space:]]*"//' \
| sed 's/"//' \
|| true
}
_ods_cli_wait_for_bootstrap_compose_safe() {
local action="${1:-service operation}"
local status_file="$INSTALL_DIR/data/bootstrap-status.json"
[[ -f "$status_file" ]] || return 0
local max_wait="${ODS_CLI_BOOTSTRAP_COMPOSE_WAIT_SECONDS:-900}"
local interval="${ODS_CLI_BOOTSTRAP_COMPOSE_WAIT_INTERVAL:-5}"
if ! [[ "$max_wait" =~ ^[0-9]+$ ]] || (( max_wait < 0 )); then
max_wait=900
fi
if ! [[ "$interval" =~ ^[0-9]+$ ]] || (( interval < 1 )); then
interval=5
fi
local waited=0 announced=false status=""
while true; do
status="$(_ods_cli_bootstrap_status)"
case "$status" in
starting|verifying|swapping)
if [[ "$announced" == "false" ]]; then
log " Model Upgrade: $status; waiting before $action touches llama-server"
announced=true
fi
if (( waited >= max_wait )); then
warn "Model upgrade is still $status after ${max_wait}s; refusing to run $action against an active hot-swap."
return 1
fi
sleep "$interval"
waited=$(( waited + interval ))
;;
*)
if [[ "$announced" == "true" ]]; then
log " Model Upgrade: ${status:-idle}; continuing with $action"
fi
return 0
;;
esac
done
}
_get_host_logical_cpus() {
local cores
cores=$(nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo 2>/dev/null || echo "1")
if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then
echo "$cores"
else
echo "1"
fi
}
_get_docker_available_cpus() {
local cores=""
if command -v docker &>/dev/null; then
cores=$(docker info --format '{{.NCPU}}' 2>/dev/null || true)
cores="${cores//[!0-9]/}"
fi
if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then
echo "$cores"
return 0
fi
_get_host_logical_cpus
}
_calculate_llama_cpu_budget() {
local backend="${1:-cpu}"
local available="${2:-$(_get_docker_available_cpus)}"
local desired_limit=8
local desired_reservation=1
case "$backend" in
amd)
desired_limit=16
desired_reservation=4
;;
nvidia|intel|sycl)
desired_limit=16
desired_reservation=2
;;
apple)
desired_limit=8
desired_reservation=2
;;
esac
if ! [[ "$available" =~ ^[0-9]+$ ]] || [[ "$available" -lt 1 ]]; then
available=1
fi
local limit="$desired_limit"
local reservation="$desired_reservation"
[[ "$available" -lt "$limit" ]] && limit="$available"
[[ "$reservation" -gt "$limit" ]] && reservation="$limit"
echo "$limit $reservation $available"
}
_select_auto_cpu_value() {
local existing="$1" detected="$2"
if [[ "$existing" =~ ^[0-9]+([.][0-9]+)?$ ]] && LC_ALL=C awk "BEGIN { exit !($existing > 0 && $existing <= $detected) }"; then
echo "$existing"
else
echo "$detected"
fi
}
_cap_cpu_value() {
local desired="$1" ceiling="$2"
LC_ALL=C awk -v desired="$desired" -v ceiling="$ceiling" '
BEGIN {
if (ceiling <= 0) ceiling = 1
value = desired
if (value > ceiling) value = ceiling
if (value < 0.01) value = 0.01
printf "%.1f", value
}'
}
_ensure_service_cpu_pair() {
local available="$1" name="$2" desired_limit="$3" desired_reservation="$4"
local limit_key="${name}_CPU_LIMIT"
local reservation_key="${name}_CPU_RESERVATION"
local detected_limit detected_reservation current_limit current_reservation final_limit final_reservation
detected_limit="$(_cap_cpu_value "$desired_limit" "$available")"
current_limit="$(_env_get_raw "$limit_key")"
final_limit="$(_select_auto_cpu_value "$current_limit" "$detected_limit")"
detected_reservation="$(_cap_cpu_value "$desired_reservation" "$final_limit")"
current_reservation="$(_env_get_raw "$reservation_key")"
final_reservation="$(_select_auto_cpu_value "$current_reservation" "$detected_reservation")"
if LC_ALL=C awk "BEGIN { exit !($final_reservation > $final_limit) }"; then
final_reservation="$final_limit"
fi
if [[ "$current_limit" != "$final_limit" ]]; then
_env_set "$limit_key" "$final_limit"
SERVICE_CPU_BUDGET_CHANGED=true
fi
if [[ "$current_reservation" != "$final_reservation" ]]; then
_env_set "$reservation_key" "$final_reservation"
SERVICE_CPU_BUDGET_CHANGED=true
fi
}
ensure_llama_cpu_budget() {
local env_file="$INSTALL_DIR/.env"
[[ -f "$env_file" ]] || return 0
local backend
backend="$(_env_get_raw "GPU_BACKEND")"
backend=$(echo "${backend:-cpu}" | tr '[:upper:]' '[:lower:]')
[[ "$backend" == "none" ]] && backend="cpu"
local limit_raw reservation_raw available
read -r limit_raw reservation_raw available <<< "$(_calculate_llama_cpu_budget "$backend")"
local detected_limit="${limit_raw}.0"
local detected_reservation="${reservation_raw}.0"
local current_limit current_reservation final_limit final_reservation
current_limit="$(_env_get_raw "LLAMA_CPU_LIMIT")"
current_reservation="$(_env_get_raw "LLAMA_CPU_RESERVATION")"
final_limit="$(_select_auto_cpu_value "$current_limit" "$detected_limit")"
final_reservation="$(_select_auto_cpu_value "$current_reservation" "$detected_reservation")"
if LC_ALL=C awk "BEGIN { exit !($final_reservation > $final_limit) }"; then
final_reservation="$final_limit"
fi
local changed=false
if [[ "$current_limit" != "$final_limit" ]]; then
_env_set "LLAMA_CPU_LIMIT" "$final_limit"
changed=true
fi
if [[ "$current_reservation" != "$final_reservation" ]]; then
_env_set "LLAMA_CPU_RESERVATION" "$final_reservation"
changed=true
fi
if [[ "$changed" == "true" ]]; then
log "Auto-adjusted llama-server CPU budget: limit=${final_limit}, reservation=${final_reservation} (Docker CPUs: ${available})"
fi
SERVICE_CPU_BUDGET_CHANGED=false
_ensure_service_cpu_pair "$available" "TTS" "8.0" "2.0"
_ensure_service_cpu_pair "$available" "WHISPER" "4.0" "1.0"
_ensure_service_cpu_pair "$available" "HERMES" "4.0" "0.5"
_ensure_service_cpu_pair "$available" "COMFYUI" "16.0" "2.0"
if [[ "$SERVICE_CPU_BUDGET_CHANGED" == "true" ]]; then
log "Auto-adjusted bundled service CPU budgets (Docker CPUs: ${available})"
fi
unset SERVICE_CPU_BUDGET_CHANGED
}
check_install() {
if [[ ! -d "$INSTALL_DIR" ]]; then
error "ODS not found at $INSTALL_DIR. Set ODS_HOME or run installer first."
fi
if [[ ! -f "$INSTALL_DIR/docker-compose.base.yml" ]]; then
# Backward compat: check for monolithic docker-compose.yml
if [[ ! -f "$INSTALL_DIR/docker-compose.yml" ]]; then
error "docker-compose.base.yml not found in $INSTALL_DIR"
fi
fi
}
# Extract the major component of a semver string (e.g. "2.1.0" → "2").
# Used only for counting major-version jumps; not for ordering comparisons.
_semver_major() {
echo "${1%%.*}"
}
# Full 3-component semver less-than: returns 0 (true) if v1 < v2.
# Strips any leading "v" and ignores pre-release suffixes.
_semver_lt() {
local v1="${1#v}" v2="${2#v}"
local IFS='.'
local -a a=($v1) b=($v2)
local i
for i in 0 1 2; do
local n1="${a[$i]:-0}" n2="${b[$i]:-0}"
n1="${n1%%[!0-9]*}" n2="${n2%%[!0-9]*}"
if (( n1 < n2 )); then return 0; fi
if (( n1 > n2 )); then return 1; fi
done
return 1 # equal → not less-than
}
# Read a top-level or nested string field from a JSON file.
# Usage: _manifest_field <file> <field> e.g. _manifest_field manifest.json ods_version
# Logs a warning to stderr on parse failure rather than silently returning empty.
_manifest_field() {
local file="$1" field="$2" value=""
if [[ ! -f "$file" ]]; then
warn "_manifest_field: file not found: ${file}" >&2
return 1
fi
if command -v jq &>/dev/null; then
if ! value=$(jq -r --arg f "$field" '.[$f] // .release.version // empty' "$file"); then
warn "_manifest_field: jq failed to parse ${file}" >&2
fi
fi
if [[ -z "$value" ]] && command -v python3 &>/dev/null; then
# Pass file and field as argv to avoid shell-injection via string interpolation.
if ! value=$(python3 -c '
import json, sys
try:
d = json.load(open(sys.argv[1]))
f = sys.argv[2]
print(d.get(f) or d.get("release", {}).get("version") or "")
except Exception as e:
sys.stderr.write("_manifest_field: " + str(e) + "\n")
' "$file" "$field"); then
warn "_manifest_field: python3 failed to parse ${file}" >&2
fi
fi
echo "$value"
}
# Version compatibility check for ods update.
# Returns 0 (ok) or 1 (user aborted / hard-blocked).
# Sets _COMPAT_INSTALLED_VER and _COMPAT_TARGET_VER as side-effects.
#
# Version source precedence (single source of truth policy):
# installed: ODS_VERSION in .env > .version file > manifest.json ods_version
# target: manifest.json ods_version (post-pull reflects the incoming release)
_check_version_compat() {
local force="${1:-false}"
# 1. Installed version
# `|| true` preserves the tolerant fallback semantics required by
# the maintainer audit on PR #998: under `set -euo pipefail`,
# a fresh install whose .env lacks ODS_VERSION must fall back
# to .version / manifest.json (handled below), not exit out of
# the surrounding command. Landed on main via #1008.
_COMPAT_INSTALLED_VER=$(grep '^ODS_VERSION=' "$INSTALL_DIR/.env" 2>/dev/null \
| sed -n '1p' | cut -d= -f2 | tr -d '[:space:]' || true)
# Fall back to .version file (written by ods-update.sh / PR #349 convention)
if [[ -z "$_COMPAT_INSTALLED_VER" && -f "$INSTALL_DIR/.version" ]]; then
_COMPAT_INSTALLED_VER=$(jq -r '.version // empty' "$INSTALL_DIR/.version" 2>/dev/null \
|| grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \
"$INSTALL_DIR/.version" 2>/dev/null | cut -d'"' -f4 \
|| true)
fi
if [[ -z "$_COMPAT_INSTALLED_VER" && -f "$INSTALL_DIR/manifest.json" ]]; then
_COMPAT_INSTALLED_VER=$(_manifest_field "$INSTALL_DIR/manifest.json" "ods_version")
fi
[[ -z "$_COMPAT_INSTALLED_VER" ]] && _COMPAT_INSTALLED_VER="$VERSION"
# 2. Target version — from manifest.json (reflects the incoming release after pull)
_COMPAT_TARGET_VER=""
if [[ -f "$INSTALL_DIR/manifest.json" ]]; then
_COMPAT_TARGET_VER=$(_manifest_field "$INSTALL_DIR/manifest.json" "ods_version")
fi
# 3. min_compatible_ods_version — read via _manifest_field to avoid 2>/dev/null + injection
local min_compat_ver=""
if [[ -f "$INSTALL_DIR/manifest.json" ]]; then
min_compat_ver=$(_manifest_field "$INSTALL_DIR/manifest.json" "min_compatible_ods_version")
fi
# Skip checks if we can't determine versions
if [[ -z "$_COMPAT_INSTALLED_VER" || -z "$_COMPAT_TARGET_VER" ]]; then
return 0
fi
# Skip if installed == target (no actual update)
[[ "$_COMPAT_INSTALLED_VER" == "$_COMPAT_TARGET_VER" ]] && return 0
local cur_major new_major
cur_major=$(_semver_major "$_COMPAT_INSTALLED_VER")
new_major=$(_semver_major "$_COMPAT_TARGET_VER")
# major_jump may be negative (downgrade); use arithmetic not regex for sign-aware logic
local major_jump=$(( new_major - cur_major ))
# 4. Downgrade detection — warn and confirm before proceeding
if (( major_jump < 0 )); then
echo ""
echo -e "${YELLOW}┌──────────────────────────────────────────────────────────┐${NC}"
echo -e "${YELLOW}│ Downgrade detected │${NC}"
echo -e "${YELLOW}└──────────────────────────────────────────────────────────┘${NC}"
echo ""
warn "Installed : v${_COMPAT_INSTALLED_VER} (major ${cur_major})"
warn "Target : v${_COMPAT_TARGET_VER} (major ${new_major})"
echo ""
log "Downgrading may corrupt data or break your installation."
log "Consider restoring a backup instead: ods rollback"
echo ""
if [[ "$force" == "true" ]]; then
warn "--force specified: proceeding with downgrade."
else
read -p " Proceed with downgrade? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log "Update cancelled."
return 1
fi
fi
echo ""
fi
# 5. Hard block: installed version is below min_compatible_ods_version.
# Uses full 3-component comparison — major-only was insufficient
# (e.g. 1.2.0 vs min 1.5.0 both have major 1 but 1.2.0 < 1.5.0).
if [[ -n "$min_compat_ver" ]] && _semver_lt "$_COMPAT_INSTALLED_VER" "$min_compat_ver"; then
echo ""
echo -e "${RED}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${RED}║ UPGRADE BLOCKED — installed version is too old ║${NC}"
echo -e "${RED}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
warn "Installed : v${_COMPAT_INSTALLED_VER}"
warn "Target : v${_COMPAT_TARGET_VER}"
warn "Minimum : v${min_compat_ver} (min_compatible_ods_version)"
echo ""
log "Your installation is below the minimum supported upgrade version."
log "Upgrade incrementally: first update to v${min_compat_ver}, then re-run."
log "See CHANGELOG.md for migration steps."
echo ""
if [[ "$force" == "true" ]]; then
warn "--force specified: proceeding despite compatibility block. DATA LOSS RISK."
else
log "Use 'ods update --force' to override (not recommended)."
return 1
fi
fi
# 6. Warn: major version jump > 1
if (( major_jump > 1 )); then
echo ""
echo -e "${YELLOW}┌──────────────────────────────────────────────────────────┐${NC}"
echo -e "${YELLOW}│ Major version jump detected │${NC}"
echo -e "${YELLOW}└──────────────────────────────────────────────────────────┘${NC}"
echo ""
warn "Installed : v${_COMPAT_INSTALLED_VER} (major ${cur_major})"
warn "Target : v${_COMPAT_TARGET_VER} (major ${new_major})"
warn "Jump : ${major_jump} major versions"
echo ""
log "Skipping more than 1 major version may introduce breaking changes."
log "Recommended: upgrade step-by-step (v${cur_major} → v$((cur_major+1)) → … → v${new_major})."
log "Review CHANGELOG.md for each major version before proceeding."
echo ""
if [[ "$force" == "true" ]]; then
warn "--force specified: skipping confirmation."
else
read -p " Proceed with v${_COMPAT_INSTALLED_VER} → v${_COMPAT_TARGET_VER} anyway? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log "Update cancelled."
return 1
fi
warn "Proceeding — back up your data first: ods backup"
fi
echo ""
fi
return 0
}
#=============================================================================
# Safe .env loading (no eval; use lib/safe-env.sh)
#=============================================================================
[[ -f "$SCRIPT_DIR/lib/safe-env.sh" ]] && . "$SCRIPT_DIR/lib/safe-env.sh"
load_env() {
[[ -f "$INSTALL_DIR/.env" ]] || { warn ".env not found at $INSTALL_DIR — run installer first"; return 1; }
load_env_file "$INSTALL_DIR/.env"
}
#=============================================================================
# Service Registry
#=============================================================================
. "$SCRIPT_DIR/lib/service-registry.sh"
# Resolve a user-provided service name to a compose service ID
resolve_service() {
local resolved
resolved=$(sr_resolve "$1")
echo "$resolved"
}
# Regenerate .compose-flags after enable/disable
_regenerate_compose_flags() {
if [[ -x "$INSTALL_DIR/scripts/resolve-compose-stack.sh" ]]; then
# --gpu-count gates the multigpu-{backend}.yml overlay; without it,
# toggling any extension would silently strip multi-GPU plumbing
# from the persisted cache.
"$INSTALL_DIR/scripts/resolve-compose-stack.sh" \
--script-dir "$INSTALL_DIR" --tier "${TIER:-1}" --gpu-backend "${GPU_BACKEND:-nvidia}" \
--gpu-count "${GPU_COUNT:-1}" --ods-mode "${ODS_MODE:-local}" \
> "$INSTALL_DIR/.compose-flags" 2>/dev/null || rm -f "$INSTALL_DIR/.compose-flags"
else
rm -f "$INSTALL_DIR/.compose-flags"
fi
}
# Run a lifecycle hook for a service (reads manifest directly via Python)
_run_hook() {
local service_id="$1" hook_name="$2"
local ext_dir="$INSTALL_DIR/extensions/services/$service_id"
# Also check user-extensions
[[ -d "$ext_dir" ]] || ext_dir="$INSTALL_DIR/data/user-extensions/$service_id"
[[ -d "$ext_dir" ]] || return 0
local hook_path
hook_path=$(python3 - "$ext_dir" "$hook_name" <<'PYEOF'
import yaml, sys
from pathlib import Path
ext_dir = Path(sys.argv[1])
hook_name = sys.argv[2]
manifest_path = None
for name in ("manifest.yaml", "manifest.yml"):
candidate = ext_dir / name
if candidate.exists():
manifest_path = candidate
break
if manifest_path is None:
sys.exit(0)
with open(manifest_path) as f:
m = yaml.safe_load(f)
if not isinstance(m, dict):
sys.exit(0)
service = m.get("service", {})
if not isinstance(service, dict):
sys.exit(0)
# Check hooks map first
hooks = service.get("hooks", {})
hook_script = ""
if isinstance(hooks, dict):
hook_script = hooks.get(hook_name, "")
# Fallback: setup_hook -> post_install only
if not hook_script and hook_name == "post_install":
hook_script = service.get("setup_hook", "")
if not hook_script:
sys.exit(0)
# Validate path containment
hook_path = (ext_dir / hook_script).resolve()
try:
hook_path.relative_to(ext_dir.resolve())
except ValueError:
print(f"ERROR: hook path escapes extension directory: {hook_script}", file=sys.stderr)
sys.exit(1)
if not hook_path.is_file():
sys.exit(0)
print(str(hook_path))
PYEOF
) || return 0
[[ -z "$hook_path" || ! -f "$hook_path" ]] && return 0
log "Running $hook_name hook for $service_id..."
SERVICE_ID="$service_id" \
SERVICE_PORT="${SERVICE_PORTS[$service_id]:-0}" \
SERVICE_DATA_DIR="$INSTALL_DIR/data/$service_id" \
ODS_VERSION="$VERSION" \
GPU_BACKEND="${GPU_BACKEND:-}" \
HOOK_NAME="$hook_name" \
bash "$hook_path" "$INSTALL_DIR" "${GPU_BACKEND:-}" \
|| warn "$hook_name hook failed for $service_id (non-fatal)"
}
# Build full compose flags: base + GPU overlay + enabled extensions
get_compose_flags() {
# Fast path: use saved flags from installer if available
if [[ -f "$INSTALL_DIR/.compose-flags" ]]; then
local cached
cached=$(< "$INSTALL_DIR/.compose-flags")
if [[ "$cached" == -f\ * ]]; then
# Validate that every referenced compose file still exists.
# Protects against renames across upgrades (e.g. multigpu.yml →
# multigpu-nvidia.yml) where the cache would otherwise hand docker
# compose a path that no longer resolves.
local stale=0 prev="" tok
for tok in $cached; do
if [[ "$prev" == "-f" ]]; then
if [[ "$tok" = /* ]]; then
[[ -f "$tok" ]] || { stale=1; break; }
else
[[ -f "$INSTALL_DIR/$tok" ]] || { stale=1; break; }
fi
fi
prev="$tok"
done
local _ods_mode="${ODS_MODE:-local}"
local _lemonade_external="${LEMONADE_EXTERNAL:-false}"
local _amd_runtime="${AMD_INFERENCE_RUNTIME:-}"
local _amd_managed="${AMD_INFERENCE_MANAGED:-true}"
_lemonade_external="${_lemonade_external,,}"
_amd_runtime="${_amd_runtime,,}"
_amd_managed="${_amd_managed,,}"
local _external_lemonade_active="false"
if [[ "$_ods_mode" == "lemonade" ]]; then
if [[ "$_lemonade_external" =~ ^(1|true|yes|on)$ ]] || \
{ [[ "$_amd_runtime" == "lemonade" ]] && [[ "$_amd_managed" =~ ^(0|false|no|off)$ ]]; }; then
_external_lemonade_active="true"
fi
fi
if [[ "$_ods_mode" == "cloud" ]] && \
{ [[ "$cached" == *"docker-compose.cpu.yml"* ]] || [[ "$cached" == *"compose.local.yaml"* ]] || [[ "$cached" != *"docker-compose.cloud.yml"* ]]; }; then
stale=1
fi
if [[ "$_external_lemonade_active" == "true" ]] && \
{ [[ "$cached" == *"compose.local.yaml"* ]] || [[ "$cached" == *"docker-compose.amd.yml"* ]] || [[ "$cached" != *"docker-compose.cloud.yml"* ]] || [[ "$cached" != *"docker-compose.lemonade-external.yml"* ]]; }; then
stale=1
fi
if [[ "$_ods_mode" != "cloud" ]] && [[ "$_external_lemonade_active" != "true" ]] && \
[[ "$cached" == *"docker-compose.cloud.yml"* ]]; then
stale=1
fi
if [[ "$stale" == "0" ]]; then
echo "$cached"
return
fi
fi
# Stale or corrupt — remove and fall through to dynamic resolution
rm -f "$INSTALL_DIR/.compose-flags"
fi
# Fallback: resolve dynamically
local base_flags
if [[ -x "$INSTALL_DIR/scripts/resolve-compose-stack.sh" ]]; then
# Resolver handles base + GPU overlay + extension compose files.
# --gpu-count gates the multigpu-{backend}.yml overlay — without it,
# this fallback path resolves to a single-GPU stack on multi-GPU hosts.
base_flags=$("$INSTALL_DIR/scripts/resolve-compose-stack.sh" \
--script-dir "$INSTALL_DIR" --tier "${TIER:-1}" --gpu-backend "${GPU_BACKEND:-nvidia}" \
--gpu-count "${GPU_COUNT:-1}" --ods-mode "${ODS_MODE:-local}")
echo "$base_flags"
elif [[ -f "$INSTALL_DIR/docker-compose.base.yml" ]]; then
base_flags="-f docker-compose.base.yml"
local ext_flags
ext_flags=$(sr_compose_flags)
echo "$base_flags $ext_flags"
else
base_flags="-f docker-compose.yml"
local ext_flags
ext_flags=$(sr_compose_flags)
echo "$base_flags $ext_flags"
fi
}
# Run `docker compose <args...>` with a compact summary on success and a
# surfaced error banner on failure.
#
# Usage: _compose_run_with_summary <verb> <compose_args...>
# <verb> Human-readable gerund phrase, e.g. "Restarting all services"
# <compose_args> Everything that goes after `docker compose`, including
# any `-f` flags resolved by get_compose_flags.
#
# Behavior:
# - Runs `docker compose --progress quiet <compose_args>`, capturing
# stdout+stderr to a mktemp log file.
# - On success: prints "<verb> — done" and removes the log file.
# - On failure: prints an error banner, surfaces up to 20 lines matching
# /error|unhealthy|failed|dependency/, preserves the full
# log file for inspection, and returns the compose exit
# code so the caller (under `set -e`) aborts with it.
#
# The summary grep pipeline can legitimately produce zero matches if the
# failing compose output has no error-keyword hits. `upstream/main` today
# runs under `set -e` only (no `pipefail`), so grep's exit 1 is absorbed
# by the pipeline's final-stage exit and the function continues to the
# log-path surface below. When pipefail is eventually adopted (sibling
# nounset/exit-code audit change), grep's no-match or a SIGPIPE from
# `head -20` on >20 matches would abort this function before the caller
# sees the compose log path or the compose exit code. `|| warn "..."`
# is the project-blessed form (per CLAUDE.md) for "tolerate this specific
# non-match and log why the summary is empty" — it costs nothing today
# and keeps the function correct under future pipefail.
_compose_run_with_summary() {
local _verb="$1"; shift
log "${_verb}..."
_check_docker_access
local _compose_log
_compose_log=$(mktemp)
trap 'rm -f "$_compose_log"' INT TERM
local _is_compose_up=false _arg
for _arg in "$@"; do
[[ "$_arg" == "up" ]] && _is_compose_up=true
done
_compose_remove_conflicting_ods_containers() {
local _log_path="$1"
local _removed=false
local _name _id
while read -r _name _id; do
[[ -n "${_name:-}" && -n "${_id:-}" ]] || continue
case "$_name" in
/ods-*|ods-*)
docker rm -f "$_id" >>"$_log_path" 2>&1 || return 1
_removed=true
;;
*)
warn "Refusing to remove conflicting non-ODS container $_name."
return 1
;;
esac
done < <(sed -nE 's/.*container name "([^"]+)".*already in use by container "([0-9a-fA-F]+)".*/\1 \2/p' "$_log_path" | awk '!seen[$0]++')
$_removed
}
local _rc=0
docker compose --progress quiet "$@" >"$_compose_log" 2>&1 || _rc=$?
if (( _rc != 0 )) && $_is_compose_up \
&& grep -Eiq 'port is already allocated|Bind for .* failed: port is already allocated' "$_compose_log"; then
warn "Docker reported a port allocation race; waiting briefly and retrying once."
sleep "${ODS_COMPOSE_PORT_RETRY_DELAY:-10}"
: >"$_compose_log"
_rc=0
docker compose --progress quiet "$@" >"$_compose_log" 2>&1 || _rc=$?
fi
local _startup_max_attempts="${ODS_COMPOSE_STARTUP_RETRY_ATTEMPTS:-3}"
if ! [[ "$_startup_max_attempts" =~ ^[0-9]+$ ]] || (( _startup_max_attempts < 1 )); then
_startup_max_attempts=3
fi
local _startup_attempt=1
local _startup_retries=$(( _startup_max_attempts - 1 ))
local _name_conflict_retries="${ODS_COMPOSE_NAME_CONFLICT_RETRY_ATTEMPTS:-1}"
if ! [[ "$_name_conflict_retries" =~ ^[0-9]+$ ]] || (( _name_conflict_retries < 0 )); then
_name_conflict_retries=1
fi
local _name_conflict_attempt=0
while (( _rc != 0 )) && $_is_compose_up; do
if grep -Eiq 'container name "/?ods-[^"]+" is already in use by container "[0-9a-fA-F]+"' "$_compose_log"; then
if (( _name_conflict_attempt >= _name_conflict_retries )); then
break
fi
_name_conflict_attempt=$(( _name_conflict_attempt + 1 ))
warn "Docker reported a stale ODS container-name conflict; removing the conflicting container and retrying (${_name_conflict_attempt}/${_name_conflict_retries})."
_compose_remove_conflicting_ods_containers "$_compose_log" || break
sleep "${ODS_COMPOSE_NAME_CONFLICT_RETRY_DELAY:-3}"
elif (( _startup_attempt < _startup_max_attempts )) \
&& grep -Eiq 'dependency failed to start|No such container|container .* (exited|is unhealthy)|service .* failed to start|failed to start.*container' "$_compose_log"; then
warn "Docker reported a transient service startup failure; waiting briefly and retrying (${_startup_attempt}/${_startup_retries})."
sleep "${ODS_COMPOSE_STARTUP_RETRY_DELAY:-15}"
_startup_attempt=$(( _startup_attempt + 1 ))
else
break
fi
: >"$_compose_log"
_rc=0
docker compose --progress quiet "$@" >"$_compose_log" 2>&1 || _rc=$?
done
if (( _rc == 0 )); then
success "${_verb} — done"
rm -f "$_compose_log"
trap - INT TERM
return 0
fi
log_error "${_verb} failed:"
local _surfaced
_surfaced=$(grep -iE 'error|unhealthy|failed|dependency|permission denied|docker API|docker daemon|docker\.sock|/var/run/docker\.sock' "$_compose_log" \
| sed 's/^/ /' \
| head -20 || true)
if [[ -n "$_surfaced" ]]; then
printf '%s\n' "$_surfaced"
else
warn "(no error keywords matched in compose log)"
fi
if grep -qiE 'permission denied.*(docker API|docker daemon|docker\.sock|/var/run/docker\.sock)|/var/run/docker\.sock.*permission denied' "$_compose_log"; then
warn "Docker socket permission denied. Add your user to the docker group, then start a new login shell:"
warn " sudo usermod -aG docker \"\$USER\""
warn " newgrp docker # or log out and back in"
warn "Then verify with: docker ps"
fi
echo ""
log "Full compose output: $_compose_log"
trap - INT TERM
return "$_rc"
}
#=============================================================================
# Warn (don't exit) when the current user can't reach the Docker daemon on
# Linux. Phase 05 of the installer sets DOCKER_CMD="sudo docker" for users
# not in the docker group, but ods-cli calls bare `docker compose` and
# can't see that. Without this check, --rebuild-images fails with an opaque
# "permission denied while trying to connect to the Docker daemon socket"
# and users have no idea what to do next. macOS Docker Desktop grants
# socket access to the logged-in user, so the check is Linux-only.
#=============================================================================
_check_docker_access() {
[[ "$(uname -s)" == Linux* ]] || return 0
local _docker_ps_output
if _docker_ps_output=$(docker ps 2>&1); then
return 0
fi
if grep -qiE 'permission denied.*(docker API|docker daemon|docker\.sock|/var/run/docker\.sock)|/var/run/docker\.sock.*permission denied' <<<"$_docker_ps_output"; then
warn "Current user cannot reach the Docker daemon: Docker socket permission denied."
warn "Run: sudo usermod -aG docker \"\$USER\""
warn "Then run: newgrp docker (or log out and back in) and retry."
else
warn "Current user cannot reach the Docker daemon. Start Docker and verify with: docker ps"
fi
}
#=============================================================================
# Rebuild local-built images (opt-in via --rebuild-images on restart/start/update).
# Always rebuilds: dashboard, dashboard-api, ape, token-spy, privacy-shield.
# Conditionally rebuilds based on .env state:
# - comfyui (ENABLE_COMFYUI=true)
# - llama-server (GPU_BACKEND=amd, e.g. AMD Lemonade)
# Mirrors the conditional logic in installers/phases/11-services.sh. Unlike that
# phase's image-inspect fast-path, --rebuild-images is an explicit opt-in so we
# always rebuild every applicable service from source.
#=============================================================================
_ods_cli_rebuild_images() {
local flags=("$@")
local svcs=(dashboard dashboard-api ape token-spy privacy-shield)
# Mirror phases/11-services.sh conditional builds: ComfyUI when enabled
# and llama-server on AMD (Lemonade builds the binary in the container).
# Other GPU backends pull pre-built images.
[[ "${ENABLE_COMFYUI:-}" == "true" ]] && svcs+=(comfyui)
[[ "${GPU_BACKEND:-}" == "amd" ]] && svcs+=(llama-server)
_check_docker_access
log "Rebuilding local-built images (--rebuild-images)..."
if ! docker compose "${flags[@]}" build --no-cache "${svcs[@]}"; then
warn "One or more images failed to rebuild (continuing — see compose output above)"
fi
}
#=============================================================================
# Commands
#=============================================================================
cmd_status() {
local json_mode="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--json) json_mode="true"; shift ;;
*) error "Unknown argument to 'ods status': $1" ;;
esac
done
if [[ "$json_mode" == "true" ]]; then
# Subshell isolates cmd_status_json's RETURN trap so it can't leak
# into this caller's frame and re-fire with an unbound $tmp under set -u.
( cmd_status_json )
return $?
fi
check_install
cd "$INSTALL_DIR"
load_env
sr_load
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
echo -e "${BLUE}━━━ ODS Status ━━━${NC}"
echo ""
# Container status
docker compose "${flags[@]}" ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>/dev/null || docker-compose ps
echo ""
# Registry-driven health checks (parallel)
echo -e "${BLUE}━━━ Health Checks ━━━${NC}"
# Create temp directory for health check results
local tmpdir
tmpdir=$(mktemp -d)
trap "rm -rf '$tmpdir'" RETURN
# Array to track background job PIDs
local -a pids=()
# Launch parallel health checks
for sid in "${SERVICE_IDS[@]}"; do
local health="${SERVICE_HEALTH[$sid]}"
local port_env="${SERVICE_PORT_ENVS[$sid]}"
local default_port="${SERVICE_PORTS[$sid]}"
local name="${SERVICE_NAMES[$sid]:-$sid}"
local cat="${SERVICE_CATEGORIES[$sid]}"
# Skip services with no health endpoint or port
[[ -z "$health" || "$default_port" == "0" ]] && continue
# Resolve port from env var or default
local port="$default_port"
if [[ -n "$port_env" ]]; then
port="${!port_env:-$default_port}"
fi
# For non-core services, only check if container is running
if [[ "$cat" != "core" ]]; then
if ! docker compose "${flags[@]}" ps --format "{{.Name}}" 2>/dev/null | grep -qE "ods-${sid}|${sid}"; then
continue # Not running, skip silently
fi
fi
# Launch health check in background
(
url="http://127.0.0.1:${port}${health}"
timeout="${SERVICE_HEALTH_TIMEOUTS[$sid]:-5}"
if curl -sf --max-time "$timeout" "$url" > /dev/null 2>&1; then
echo "healthy|$name" > "$tmpdir/$sid"
else
echo "unhealthy|$name" > "$tmpdir/$sid"
fi
) &
pids+=($!)
done
# Wait for all health checks to complete
for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || true
done
# Display results in SERVICE_IDS order
for sid in "${SERVICE_IDS[@]}"; do
if [[ -f "$tmpdir/$sid" ]]; then
local result
result=$(cat "$tmpdir/$sid")
local status="${result%%|*}"
local name="${result#*|}"
if [[ "$status" == "healthy" ]]; then
success "$name: healthy"
else
warn "$name: not responding"
fi
fi
done
echo ""
# GPU status if available
if command -v nvidia-smi &> /dev/null; then
echo -e "${BLUE}━━━ GPU Status ━━━${NC}"
nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader,nounits | \
awk -F', ' '{printf " %s: %s%% GPU | %sMB/%sMB VRAM | %s°C\n", $1, $2, $3, $4, $5}' \
|| warn "nvidia-smi failed"
fi
# Bootstrap status. This file may be mid-write, so missing fields should
# not abort `ods status` under pipefail.
local bootstrap_file="$INSTALL_DIR/data/bootstrap-status.json"
if [[ -f "$bootstrap_file" ]]; then
local bs_status
bs_status="$(_ods_cli_bootstrap_status)"
if [[ "$bs_status" == "downloading" || "$bs_status" == "starting" || "$bs_status" == "verifying" || "$bs_status" == "swapping" ]]; then
local bs_model bs_percent
bs_model=$(grep -o '"model"[[:space:]]*:[[:space:]]*"[^"]*"' "$bootstrap_file" | sed -n '1p' | sed 's/.*"model"[[:space:]]*:[[:space:]]*"//' | sed 's/"//' || true)
bs_percent=$(grep -o '"percent"[[:space:]]*:[[:space:]]*[0-9.]*' "$bootstrap_file" | sed -n '1p' | sed 's/.*:[[:space:]]*//' || true)
log ""
log " Model Upgrade: $bs_model (${bs_percent:-?}% downloaded)"
fi
fi
}
cmd_status_json() {
check_install
cd "$INSTALL_DIR"
sr_load
load_env
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
# Capture running services for optional-service checks (best-effort)
local running_services=""
if docker compose "${flags[@]}" ps --format "{{.Service}}" >/dev/null 2>&1; then
running_services=$(docker compose "${flags[@]}" ps --format "{{.Service}}")
elif command -v docker-compose >/dev/null 2>&1; then
running_services=$(docker-compose ps --services --filter "status=running" 2>/dev/null || true)
fi
# Temp file to accumulate per-service JSON objects
local tmp
tmp=$(mktemp)
trap 'rm -f "$tmp"' RETURN
for sid in "${SERVICE_IDS[@]}"; do
local cat="${SERVICE_CATEGORIES[$sid]}"
local health="${SERVICE_HEALTH[$sid]}"
local port_env="${SERVICE_PORT_ENVS[$sid]}"
local default_port="${SERVICE_PORTS[$sid]}"
local name="${SERVICE_NAMES[$sid]:-$sid}"
# Skip services with no health endpoint or port configured
[[ -z "$health" || "$default_port" == "0" ]] && continue
# Resolve port from env var or default
local port="$default_port"
if [[ -n "$port_env" ]]; then
port="${!port_env:-$default_port}"
fi
local container_status="unknown"
local status="unknown"
# Determine container status (running/stopped) when we have ps output
if [[ -n "$running_services" ]]; then
if grep -qE "(^|[[:space:]])${sid}([[:space:]]|$)" <<<"$running_services"; then
container_status="running"
else
container_status="stopped"
fi
fi
# For non-core services that are clearly stopped, avoid HTTP probe
if [[ "$cat" != "core" && "$container_status" == "stopped" ]]; then
status="stopped"
else
local url="http://127.0.0.1:${port}${health}"
if curl -sf --max-time 5 "$url" > /dev/null 2>&1; then
status="healthy"
else
status="unhealthy"
fi
fi
jq -n \
--arg id "$sid" \
--arg name "$name" \
--arg category "$cat" \
--arg port "$port" \
--arg status "$status" \
--arg containerStatus "$container_status" \
'{id: $id, name: $name, category: $category, port: $port, status: $status, containerStatus: $containerStatus}' >> "$tmp"
done
# GPU summary (optional)
local gpu_summary_json="null"
if [[ "${GPU_BACKEND:-}" == "apple" ]]; then
# Apple Silicon: integrated GPU with unified memory. Surface the same
# fields as `ods gpu status` so consumers of status-json can render
# non-null GPU info on macOS.
local _chip _total_mem_gb _gpu_cores
_chip="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo 'Apple Silicon')"
_total_mem_gb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 0) / 1024 / 1024 / 1024 ))
if command -v jq >/dev/null 2>&1; then
_gpu_cores=$(system_profiler SPDisplaysDataType -json 2>/dev/null \
| jq -r '.SPDisplaysDataType[0].sppci_cores // "?"' 2>/dev/null)
else
_gpu_cores="?"
fi
gpu_summary_json=$(jq -n \
--arg backend "apple" \
--arg chip "$_chip" \
--argjson unified_memory_gb "$_total_mem_gb" \
--argjson gpu_cores "$([[ "$_gpu_cores" =~ ^[0-9]+$ ]] && printf '%s' "$_gpu_cores" || printf 'null')" \
'{backend: $backend, chip: $chip, unified_memory_gb: $unified_memory_gb, gpu_cores: $gpu_cores}')
elif command -v nvidia-smi >/dev/null 2>&1; then
# Represent each GPU line as raw strings in an array
gpu_summary_json=$(nvidia-smi --query-gpu=name,utilization.gpu,memory.used,memory.total,temperature.gpu \
--format=csv,noheader,nounits 2>/dev/null | jq -R -s 'split("\n") | map(select(length > 0))')
fi
# Aggregate into a single document
jq -s \
--arg installDir "$INSTALL_DIR" \
--arg composeFlags "$flags_str" \
--arg mode "${ODS_MODE:-local}" \
--arg tier "${TIER:-}" \
--arg currentModel "${LLM_MODEL:-}" \
--argjson gpu "$gpu_summary_json" \
'{installDir: $installDir,
composeFlags: $composeFlags,
mode: $mode,
tier: $tier,
currentModel: $currentModel,
gpu: $gpu,
services: .}' "$tmp"
}
cmd_logs() {
check_install
cd "$INSTALL_DIR"
load_env
local service="${1:-}"
local lines="${2:-100}"
if [[ -z "$service" ]]; then
log "Usage: ods logs <service> [lines]"
log "Run 'ods list' to see available services."
exit 1
fi
service=$(resolve_service "$service")
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
docker compose "${flags[@]}" logs -f --tail "$lines" "$service"
}
cmd_restart() {
check_install
cd "$INSTALL_DIR"
load_env
ensure_llama_cpu_budget
# Parse flags vs positional service name
local rebuild_images="false"
local service=""
while [[ $# -gt 0 ]]; do
case "$1" in
--rebuild-images) rebuild_images="true"; shift ;;
--) shift; break ;;
-*) error "Unknown option: $1" ;;
*) service="$1"; shift ;;
esac
done
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
[[ "$rebuild_images" == "true" ]] && _ods_cli_rebuild_images "${flags[@]}"
local resolved_service="$service"
if [[ -n "$resolved_service" ]]; then
resolved_service=$(resolve_service "$resolved_service")
fi
local refresh_soul="false"
if [[ -z "$resolved_service" || "$resolved_service" == "hermes" ]]; then
refresh_soul="true"
# Must run before compose as well as after it. If a previous failed
# compose created data/persona/SOUL.md as a directory, Docker will fail
# the Hermes file bind mount before the post-start refresh can repair it.
_ods_cli_refresh_soul || true
fi
if [[ -z "$resolved_service" || "$resolved_service" == "llama-server" ]]; then
_ods_cli_wait_for_bootstrap_compose_safe "restart"
fi
if [[ -z "$resolved_service" ]]; then
_compose_run_with_summary "Restarting all services" "${flags[@]}" up -d
else
_compose_run_with_summary "Restarting $resolved_service" "${flags[@]}" up -d "$resolved_service"
fi
# Refresh SOUL.md after compose has ensured Hermes is running, so the
# generated install context can be copied into /opt/data/SOUL.md without
# needing the Docker Desktop-hostile nested bind mount.
if [[ "$refresh_soul" == "true" ]]; then
_ods_cli_refresh_soul || true
fi
if [[ -z "$resolved_service" || "$resolved_service" == "llama-server" ]]; then
_ods_cli_maybe_resume_bootstrap_upgrade || true
fi
}
_ods_cli_maybe_resume_bootstrap_upgrade() {
local status_file="$INSTALL_DIR/data/bootstrap-status.json"
local args_file="$INSTALL_DIR/data/bootstrap-upgrade.args"
[[ -f "$status_file" && -f "$args_file" && -f "$INSTALL_DIR/scripts/bootstrap-upgrade.sh" ]] || return 0
local bs_status
bs_status=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$status_file" | sed -n '1p' | sed 's/.*"status"[[:space:]]*:[[:space:]]*"//' | sed 's/"//' || true)
[[ "$bs_status" == "failed" ]] || return 0
if command -v pgrep >/dev/null 2>&1 && pgrep -f "bootstrap-upgrade.sh.*$INSTALL_DIR" >/dev/null 2>&1; then
log " Model Upgrade: retry already running"
return 0
fi
local full_gguf_file full_gguf_url full_gguf_sha256 full_llm_model full_max_context bootstrap_gguf_file
full_gguf_file=$(sed -n '1p' "$args_file")
full_gguf_url=$(sed -n '2p' "$args_file")
full_gguf_sha256=$(sed -n '3p' "$args_file")
full_llm_model=$(sed -n '4p' "$args_file")
full_max_context=$(sed -n '5p' "$args_file")
bootstrap_gguf_file=$(sed -n '6p' "$args_file")
if [[ -z "$full_gguf_file" || -z "$full_gguf_url" || -z "$full_llm_model" || -z "$full_max_context" || -z "$bootstrap_gguf_file" ]]; then
warn "Bootstrap model upgrade failed previously, but retry metadata is incomplete."
return 0
fi
mkdir -p "$INSTALL_DIR/logs"
nohup bash "$INSTALL_DIR/scripts/bootstrap-upgrade.sh" \
"$INSTALL_DIR" "$full_gguf_file" "$full_gguf_url" \
"$full_gguf_sha256" "$full_llm_model" "$full_max_context" \
"$bootstrap_gguf_file" \
> "$INSTALL_DIR/logs/model-upgrade.log" 2>&1 &
log " Model Upgrade: previous download failed; retrying in background ($full_llm_model)"
log " Check progress: tail -f $INSTALL_DIR/logs/model-upgrade.log"
}
# Refresh data/hermes/SOUL.md from the static template + detected install
# state. Idempotent. Called automatically by cmd_restart (when restarting
# hermes or the whole stack) and invocable manually by an operator.
_ods_cli_refresh_soul() {
local script="$INSTALL_DIR/scripts/build-installation-context.py"
[[ -x "$script" ]] || [[ -f "$script" ]] || return 0
local py
py=$(command -v python3 || command -v python || true)
[[ -n "$py" ]] || return 0
"$py" "$script" >/dev/null 2>&1 || return 1
# Push the updated assembled SOUL.md into the running container's
# data dir, where Hermes actually reads from at session.create
# time. The bind-mount target (/opt/hermes/docker/SOUL.md) is just
# the upstream-default that Hermes's entrypoint copies to
# /opt/data/SOUL.md ONLY on first start; the per-install file is
# what's read after that. macOS Docker Desktop's virtiofs refuses
# to bind-mount /opt/data/SOUL.md directly (nested-mount limit),
# so we use docker exec for the in-container copy — works on
# Linux + macOS uniformly. Container has to be running for this
# to matter; if it's not, the entrypoint will pick up the docker/
# default on next start anyway.
if command -v docker >/dev/null 2>&1; then
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^ods-hermes$'; then
docker exec ods-hermes cp /opt/hermes/docker/SOUL.md /opt/data/SOUL.md \
>/dev/null 2>&1 || true
fi
fi
return 0
}
cmd_stop() {
check_install
cd "$INSTALL_DIR"
load_env
local service="${1:-}"
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
if [[ -z "$service" ]]; then
_compose_run_with_summary "Stopping all services" "${flags[@]}" down
else
service=$(resolve_service "$service")
_compose_run_with_summary "Stopping $service" "${flags[@]}" stop "$service"
fi
}
cmd_start() {
check_install
cd "$INSTALL_DIR"
load_env
ensure_llama_cpu_budget
sr_load
# Parse flags vs positional service name
local rebuild_images="false"
local service=""
while [[ $# -gt 0 ]]; do
case "$1" in
--rebuild-images) rebuild_images="true"; shift ;;
--) shift; break ;;
-*) error "Unknown option: $1" ;;
*) service="$1"; shift ;;
esac
done
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
[[ "$rebuild_images" == "true" ]] && _ods_cli_rebuild_images "${flags[@]}"
local resolved_service="$service"
if [[ -n "$resolved_service" ]]; then
resolved_service=$(resolve_service "$resolved_service")
fi
local refresh_soul="false"
if [[ -z "$resolved_service" || "$resolved_service" == "hermes" ]]; then
refresh_soul="true"
_ods_cli_refresh_soul || true
fi
if [[ -z "$resolved_service" || "$resolved_service" == "llama-server" ]]; then
_ods_cli_wait_for_bootstrap_compose_safe "start"
fi
if [[ -z "$resolved_service" ]]; then
_compose_run_with_summary "Starting all services" "${flags[@]}" up -d
else
_run_hook "$resolved_service" "pre_start"
_compose_run_with_summary "Starting $resolved_service" "${flags[@]}" up -d "$resolved_service"
_run_hook "$resolved_service" "post_start"
fi
if [[ "$refresh_soul" == "true" ]]; then
_ods_cli_refresh_soul || true
fi
if [[ -z "$resolved_service" || "$resolved_service" == "llama-server" ]]; then
_ods_cli_maybe_resume_bootstrap_upgrade || true
fi
}
cmd_dry_run() {
check_install
cd "$INSTALL_DIR"
load_env
echo -e "${BLUE}━━━ ODS Update — Dry Run ━━━${NC}"
echo "Preview only. No changes will be applied."
echo ""
# ── version ──────────────────────────────────────────────────────────────
local cur_ver="0.0.0"
if [[ -f "$INSTALL_DIR/.env" ]]; then
local _v
_v=$(grep '^ODS_VERSION=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]' || true)
[[ -n "$_v" ]] && cur_ver="$_v"
fi
if [[ "$cur_ver" == "0.0.0" && -f "$INSTALL_DIR/.version" ]]; then
local _vf
_vf=$(jq -r '.version // empty' "$INSTALL_DIR/.version" 2>/dev/null || true)
[[ -n "$_vf" ]] && cur_ver="$_vf"
fi
# Try dashboard API first; fall back to direct GitHub query.
local api_json=""
local dashboard_port="${DASHBOARD_PORT:-3002}"
local api_key=""
api_key=$(grep '^DASHBOARD_API_KEY=' "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]' || true)
if [[ -n "$api_key" ]]; then
api_json=$(curl -sf --max-time 5 \
-H "X-API-Key: ${api_key}" \
"http://127.0.0.1:${dashboard_port}/api/update/dry-run" 2>/dev/null || true)
fi
local latest_ver="" changelog_url="" update_status=""
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.current_version' >/dev/null 2>&1; then
latest_ver=$(echo "$api_json" | jq -r '.latest_version // empty')
changelog_url=$(echo "$api_json" | jq -r '.changelog_url // empty')
local api_available
api_available=$(echo "$api_json" | jq -r '.update_available // false')
[[ "$api_available" == "true" ]] && update_status="update available" || update_status="up to date"
else
# Direct GitHub fallback
local gh_resp
gh_resp=$(curl -sf --max-time 8 \
"https://api.github.com/repos/Light-Heart-Labs/ODS/releases/latest" 2>/dev/null || true)
if [[ -n "$gh_resp" ]] && command -v jq >/dev/null 2>&1; then
latest_ver=$(echo "$gh_resp" | jq -r '.tag_name // empty' 2>/dev/null | sed 's/^v//' || true)
changelog_url=$(echo "$gh_resp" | jq -r '.html_url // empty' 2>/dev/null || true)
fi
if [[ -n "$latest_ver" ]]; then
if _semver_lt "$cur_ver" "$latest_ver"; then
update_status="update available"
else
update_status="up to date"
fi
fi
fi
echo -e "${BLUE}━━━ Version ━━━${NC}"
echo " Installed : v${cur_ver}"
if [[ -n "$latest_ver" ]]; then
echo " Available : v${latest_ver}"
if [[ "$update_status" == "update available" ]]; then
echo -e " Status : ${YELLOW}${update_status}${NC}"
else
echo -e " Status : ${GREEN}${update_status}${NC}"
fi
[[ -n "$changelog_url" ]] && echo " Changelog : ${changelog_url}"
else
echo " Available : (could not reach GitHub)"
fi
echo ""
# ── image tags ────────────────────────────────────────────────────────────
echo -e "${BLUE}━━━ Image tags (would be pulled) ━━━${NC}"
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
# Prefer API response; fall back to parsing compose files directly.
# Use process substitution so flags like images_shown update in this shell (not a pipe subshell).
local images_shown=false
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.images | length > 0' >/dev/null 2>&1; then
local _img_line _any_api=false
while IFS= read -r _img_line || [[ -n "$_img_line" ]]; do
[[ -z "$_img_line" ]] && continue
echo " ${_img_line}"
_any_api=true
done < <(echo "$api_json" | jq -r '.images[]' | sort -u)
[[ "$_any_api" == "true" ]] && images_shown=true
fi
if [[ "$images_shown" == "false" ]]; then
local _img_line _any_compose=false
while IFS= read -r _img_line || [[ -n "$_img_line" ]]; do
[[ -z "$_img_line" ]] && continue
echo " ${_img_line}"
_any_compose=true
done < <(docker compose "${flags[@]}" config 2>/dev/null \
| grep -E '^\s+image:' | sed 's/.*image:\s*//' | sort -u)
[[ "$_any_compose" == "true" ]] && images_shown=true
fi
[[ "$images_shown" == "false" ]] && echo " (could not resolve compose config)"
echo ""
echo -e "${BLUE}━━━ Running image digests ━━━${NC}"
docker compose "${flags[@]}" images 2>/dev/null \
| awk 'NR>1 {printf " %-30s %s\n", $1, $4}' \
|| echo " (services not running)"
echo ""
# ── model / GGUF ─────────────────────────────────────────────────────────
echo -e "${BLUE}━━━ Model configuration (.env) ━━━${NC}"
local -a model_keys=(TIER LLM_MODEL GGUF_FILE CTX_SIZE GPU_BACKEND N_GPU_LAYERS)
local key
for key in "${model_keys[@]}"; do
local val
val=$(grep "^${key}=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]' || true)
printf " %-16s %s\n" "${key}:" "${val:-(not set)}"
done
echo ""
# ── .env keys the update path reads/writes ────────────────────────────────
echo -e "${BLUE}━━━ .env keys (read/write) ━━━${NC}"
local -a update_keys=(ODS_VERSION TIER LLM_MODEL GGUF_FILE CTX_SIZE GPU_BACKEND N_GPU_LAYERS)
if [[ -n "$api_json" ]] && echo "$api_json" | jq -e '.env_keys' >/dev/null 2>&1; then
echo "$api_json" | jq -r '.env_keys | to_entries[] | " \(.key): \(.value)"' 2>/dev/null \
|| true
else
for key in "${update_keys[@]}"; do
local val
val=$(grep "^${key}=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]' || true)
printf " %-16s %s\n" "${key}:" "${val:-(not set)}"
done
fi
echo ""
# ── summary ───────────────────────────────────────────────────────────────
echo "To apply this update: ods update"
echo ""
warn "Dry run complete. Nothing was changed."
}
cmd_update() {
check_install
cd "$INSTALL_DIR"
load_env
# Parse flags
local dry_run="false"
local force_flag="false"
local rebuild_images="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run|-n) dry_run="true"; shift ;;
--force|-f) force_flag="true"; shift ;;
--rebuild-images) rebuild_images="true"; shift ;;
--*) error "Unknown option: $1" ;;
-*) error "Unknown option: $1" ;;
*) error "Unexpected argument: $1" ;;
esac
done
if [[ "$dry_run" == "true" ]]; then
cmd_dry_run
return 0
fi
ensure_llama_cpu_budget
# Upsert SHIELD_API_KEY when missing (pre-PR-#1069 upgrade path).
# Without it the dashboard Privacy Shield stats panel fails after
# update because dashboard-api can no longer authenticate its
# proxied /stats call. Mirrors the env-generator.sh upsert pattern.
if [[ -z "$(_env_get_raw "SHIELD_API_KEY")" ]]; then
_env_set "SHIELD_API_KEY" "$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p | tr -d '\n')"
fi
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
# Version compatibility check — warn on major version jumps before touching anything
if ! _check_version_compat "$force_flag"; then
exit 0
fi
# Show what we're updating from/to (if versions were resolved)
if [[ -n "${_COMPAT_INSTALLED_VER:-}" && -n "${_COMPAT_TARGET_VER:-}" \
&& "$_COMPAT_INSTALLED_VER" != "$_COMPAT_TARGET_VER" ]]; then
log "Updating: v${_COMPAT_INSTALLED_VER} → v${_COMPAT_TARGET_VER}"
fi
# Pre-update snapshot — safety net before any images are pulled.
# Delegates to ods-update.sh (which writes to data/backups/ and includes
# extension configs) when available; falls back to ods-backup.sh.
local _snap_label="pre-update-$(date +%Y%m%d-%H%M%S)"
if [[ -x "$INSTALL_DIR/ods-update.sh" ]]; then
log "Creating pre-update snapshot (${_snap_label})..."
if ! bash "$INSTALL_DIR/ods-update.sh" backup "$_snap_label"; then
warn "Pre-update snapshot failed; proceeding without safety net."
fi
elif [[ -x "$INSTALL_DIR/ods-backup.sh" ]]; then
log "Creating pre-update backup (${_snap_label})..."
if ! "$INSTALL_DIR/ods-backup.sh" "$_snap_label"; then
warn "Pre-update backup failed; proceeding without safety net."
fi
else
warn "ods-update.sh and ods-backup.sh not found; skipping pre-update snapshot."
fi
if ! _compose_run_with_summary "Pulling latest images" "${flags[@]}" pull; then
error "Failed to pull latest images"
fi
[[ "$rebuild_images" == "true" ]] && _ods_cli_rebuild_images "${flags[@]}"
if ! _compose_run_with_summary "Recreating containers with new images" "${flags[@]}" up -d --force-recreate; then
error "Failed to recreate containers. Run 'ods rollback' to restore previous state."
fi
log "Waiting for services to stabilize..."
sleep 10
# Verify update was successful
log "Verifying update..."
local failed_services=0
local total_services=0
# Check each service health
while IFS= read -r service; do
[[ -z "$service" ]] && continue
((total_services++))
local container
container=$(sr_container "$service" 2>/dev/null || echo "")
if [[ -n "$container" ]]; then
if ! docker ps --filter "name=$container" --filter "status=running" --format "{{.Names}}" | grep -q "^$container$"; then
warn "Service $service ($container) is not running"
((failed_services++))
fi
fi
done < <(sr_list_enabled)
if [[ $failed_services -gt 0 ]]; then
log_error "Update verification failed: $failed_services/$total_services services are not running"
log_error "Run 'ods rollback' to restore previous state"
log_error "Or run 'ods status' to check service health"
exit 1
fi
# Persist the new installed version in .env so future runs know what's installed
if [[ -n "${_COMPAT_TARGET_VER:-}" ]]; then
_env_set "ODS_VERSION" "$_COMPAT_TARGET_VER"
fi
# Restart host agent so new endpoints / code changes take effect
log "Restarting host agent..."
cmd_agent restart || warn "Host agent restart failed (non-fatal)"
success "Update complete"
cmd_status
}
cmd_shell() {
check_install
cd "$INSTALL_DIR"
# Load the service registry so SERVICE_IDS is populated in our shell.
sr_load
# Resolve aliases (e.g. "llm" -> "llama-server").
local service
service=$(sr_resolve "${1:-llm}")
# Validate against registry before doing anything.
local _found=false
for _sid in "${SERVICE_IDS[@]}"; do
if [[ "$_sid" == "$service" ]]; then
_found=true
break
fi
done
if ! $_found; then
error "Unknown service: $service"
fi
local container
container=$(sr_container "$service")
# Distinguish "daemon down" from "container stopped" — the container-running
# check below uses `docker ps`, which also exits non-zero when the daemon
# is unreachable. Without this preflight a user whose Docker is stopped
# sees "Container X is not running" and tries `ods start`, which also
# fails, creating a confusing loop. The explicit check lets the error
# reflect the actual cause.
#
# `docker info` can hang for 20+ seconds when Docker Desktop is mid-boot
# (socket exists, daemon not yet accepting RPCs). `timeout` isn't stock
# macOS, but `perl alarm` is (perl ships with macOS ≥ 10.5 and every
# Linux/WSL2 distro we target). `alarm` is inherited across `exec`, so
# the docker process receives SIGALRM after 3s if it hasn't returned.
if ! perl -e 'alarm 3; exec "docker", "info"' >/dev/null 2>&1; then
error "Docker is not running (or not responding within 3s). Start Docker Desktop / dockerd and retry."
fi
# Confirm the container is running before trying to exec.
if ! docker ps 2>/dev/null --format '{{.Names}}' | grep -qx "$container"; then
error "Container $container is not running. Start it with: ods start $service"
fi
log "Opening shell in $container..."
# Distinguish "no bash binary" from "exec failed" by probing first.
if docker exec "$container" test -x /bin/bash >/dev/null 2>&1; then
docker exec -it "$container" /bin/bash
else
docker exec -it "$container" /bin/sh
fi
}
# File-scope helpers used by `ods config show` and `ods preset diff` to
# mask secret values. `.env.schema.json` is the authoritative source; keys
# marked `"secret": true` are treated as secrets. A keyword fallback covers
# schema gaps (e.g. ANTHROPIC_API_KEY is currently secret:false) and the case
# where the schema file or jq is unavailable. Callers must invoke
# _cmd_config_load_secret_schema once (after check_install, so INSTALL_DIR is
# set) before calling _cmd_config_is_secret.
_cmd_config_secret_keys=()
_cmd_config_schema_loaded=0
_cmd_config_load_secret_schema() {
local _schema_path="$INSTALL_DIR/.env.schema.json"
_cmd_config_secret_keys=()
_cmd_config_schema_loaded=0
[[ -f "$_schema_path" ]] || return 0
# Prefer jq when available — fast, single-process, schema-typed.
if command -v jq >/dev/null 2>&1; then
mapfile -t _cmd_config_secret_keys < <(jq -r '.properties | to_entries[] | select(.value.secret == true) | .key' "$_schema_path" 2>/dev/null)
_cmd_config_schema_loaded=1
return 0
fi
# python3 fallback for environments without jq (Git Bash on Windows
# is the common one). The installer guarantees python3, so this
# path closes the gap where N8N_USER / LANGFUSE_INIT_USER_EMAIL etc.
# would otherwise leak through the keyword fallback.
if command -v python3 >/dev/null 2>&1; then
mapfile -t _cmd_config_secret_keys < <(python3 - "$_schema_path" <<'PYEOF' 2>/dev/null
import json, sys
try:
with open(sys.argv[1]) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
sys.exit(0)
for key, spec in (data.get("properties") or {}).items():
if isinstance(spec, dict) and spec.get("secret") is True:
print(key)
PYEOF
)
_cmd_config_schema_loaded=1
return 0
fi
# Neither jq nor python3 — leave _cmd_config_schema_loaded=0 so
# _cmd_config_is_secret falls through to the keyword regex (which
# covers user/email keys explicitly to avoid leaks in this case).
}
_cmd_config_is_secret() {
local _k="$1" _s _kl
if (( _cmd_config_schema_loaded == 1 )); then
for _s in "${_cmd_config_secret_keys[@]}"; do
_s="${_s%$'\r'}"
[[ "$_k" == "$_s" ]] && return 0
done
# Fall through to keyword match — defense in depth against schema gaps
# and against malformed schemas where jq/python returns zero secret keys.
fi
_kl="${_k,,}"
# `*user*` and `*email*` cover N8N_USER, LANGFUSE_INIT_USER_EMAIL, etc.
# in environments where neither jq nor python3 was available to read
# `.env.schema.json` (Git Bash without jq). Risks light over-masking
# on operational keys like USER_HOME, but `cat .env` remains the
# escape hatch — `ods config show` defaults to over-mask on
# purpose.
case "$_kl" in
*secret*|*password*|*pass*|*token*|*key*|*salt*|*bearer*|*user*|*email*) return 0 ;;
esac
return 1
}
cmd_config() {
check_install
local action="${1:-show}"
case "$action" in
show)
echo -e "${BLUE}━━━ Configuration ━━━${NC}"
echo "Install dir: $INSTALL_DIR"
echo ""
echo -e "${CYAN}.env contents:${NC}"
_cmd_config_load_secret_schema
local _line _key
while IFS= read -r _line; do
[[ -z "$_line" || "$_line" =~ ^[[:space:]]*# ]] && continue
_key="${_line%%=*}"
if _cmd_config_is_secret "$_key"; then
echo " ${_key}=***"
else
echo " $_line"
fi
done < "$INSTALL_DIR/.env"
;;
edit)
${EDITOR:-nano} "$INSTALL_DIR/.env"
warn "Restart services for changes to take effect: ods restart"
;;
validate)
cd "$INSTALL_DIR"
if [[ -x "$INSTALL_DIR/scripts/validate-env.sh" ]]; then
# Invoke through "$BASH" (the currently-running shell, guaranteed
# Bash 4+ by the version check at the top of this file). The
# target script uses associative arrays (declare -A), which
# crash under macOS's system /bin/bash (3.2).
"$BASH" "$INSTALL_DIR/scripts/validate-env.sh" "$INSTALL_DIR/.env" "$INSTALL_DIR/.env.schema.json"
else
warn "validate-env.sh not found at $INSTALL_DIR/scripts/validate-env.sh"
warn "Make sure you're on a recent ODS release."
fi
echo ""
if [[ -x "$INSTALL_DIR/scripts/validate-manifests.sh" ]]; then
log "Validating extension manifests and compatibility..."
if "$BASH" "$INSTALL_DIR/scripts/validate-manifests.sh"; then
success "Extension manifests validated"
else
warn "Extension manifest validation reported issues. See output above."
fi
else
warn "validate-manifests.sh not found at $INSTALL_DIR/scripts/validate-manifests.sh"
fi
;;
*)
log "Usage: ods config [show|edit|validate]"
;;
esac
}
cmd_chat() {
check_install
load_env # B6 fix: use safe env loading function
local message="${1:-Hello}"
local model="${2:-}"
# Resolve llama-server port once, regardless of how the model is provided
local _llm_port="${SERVICE_PORTS[llama-server]:-11434}"
_llm_port="${OLLAMA_PORT:-${LLAMA_SERVER_PORT:-$_llm_port}}"
local _probe
_probe=$(curl --silent --show-error --fail --max-time 3 \
"http://127.0.0.1:${_llm_port}/v1/models" 2>&1) || {
error "llama-server not reachable at http://127.0.0.1:${_llm_port} - is 'ods status' showing it healthy?"
}
if [[ -z "$model" ]]; then
model=$(printf '%s' "$_probe" | jq -er '.data[0].id' 2>/dev/null || echo "local")
fi
log "Sending to $model..."
# Use jq to safely construct JSON payload (prevents injection)
local payload
payload=$(jq -n --arg model "$model" --arg msg "$message" \
'{model: $model, messages: [{role: "user", content: $msg}], max_tokens: 500}')
local _resp
_resp=$(curl --silent --show-error --max-time 30 \
"http://127.0.0.1:${_llm_port}/v1/chat/completions" \
-H "Content-Type: application/json" \
-d "$payload" 2>&1) || {
error "LLM request failed: $_resp"
}
[[ -z "$_resp" ]] && error "LLM returned empty response"
local _content
_content=$(printf '%s' "$_resp" | jq -er '.choices[0].message.content') || {
local _err
_err=$(printf '%s' "$_resp" | jq -r '.error.message // "unknown error"' 2>/dev/null || true)
error "LLM error: ${_err:-unparseable response}"
}
printf '%s\n' "$_content"
}
cmd_benchmark() {
check_install
load_env # B6 fix: use safe env loading function
log "Running quick benchmark..."
local start=$(date +%s)
local response
if ! response=$(cmd_chat "Say exactly: Hello World" 2>&1); then
error "Benchmark failed: LLM unreachable or error - see 'ods chat' for details"
fi
local end=$(date +%s)
local duration=$(( end - start ))
echo ""
echo -e "${BLUE}━━━ Benchmark Results ━━━${NC}"
echo " Response time: ${duration}s"
echo " Response: $response"
if [[ $duration -lt 2 ]]; then
success "Performance: Excellent (<2s)"
elif [[ $duration -lt 5 ]]; then
success "Performance: Good (<5s)"
else
warn "Performance: Slow (>5s) - check GPU/model"
fi
}
cmd_doctor() {
check_install
cd "$INSTALL_DIR"
local json_mode="false"
local report_file="/tmp/ods-doctor-report.json"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--json)
json_mode="true"
shift
;;
--report)
[[ -n "${2:-}" ]] || { echo "Error: --report requires a path argument" >&2; return 1; }
report_file="$2"
shift 2
;;
*)
report_file="$1"
shift
;;
esac
done
if [[ ! -x "$INSTALL_DIR/scripts/ods-doctor.sh" ]]; then
error "ods-doctor script not found at $INSTALL_DIR/scripts/ods-doctor.sh"
fi
# Run doctor script. If it fails to generate a report, show the script output
# to make failures actionable for operators.
local doctor_out
doctor_out=$(mktemp)
"$INSTALL_DIR/scripts/ods-doctor.sh" "$report_file" >"$doctor_out" 2>&1 || true
if [[ ! -f "$report_file" ]]; then
cat "$doctor_out" >&2
rm -f "$doctor_out"
error "Doctor report not generated at $report_file"
fi
rm -f "$doctor_out"
# JSON mode: just output the report
if [[ "$json_mode" == "true" ]]; then
cat "$report_file"
return 0
fi
# If python3 is unavailable, fall back to printing the JSON report.
if ! command -v python3 >/dev/null 2>&1; then
warn "python3 not found; printing raw JSON report (use --json to suppress this warning)"
cat "$report_file"
return 1
fi
# Parse and display operator-friendly output
echo -e "${BLUE}━━━ ODS Diagnostics ━━━${NC}"
echo ""
# Use Python to parse JSON and display results
python3 - "$report_file" <<'PY'
import json
import sys
RED = '\033[0;31m'
GREEN = '\033[0;32m'
YELLOW = '\033[1;33m'
BLUE = '\033[0;34m'
CYAN = '\033[0;36m'
NC = '\033[0m'
report_file = sys.argv[1]
try:
with open(report_file, 'r') as f:
report = json.load(f)
except Exception as e:
print(f"{RED}✗{NC} Failed to parse report: {e}")
sys.exit(1)
# Runtime checks
runtime = report.get('runtime', {})
print(f"{CYAN}Runtime Environment:{NC}")
checks = [
('Docker CLI', runtime.get('docker_cli', False)),
('Docker Daemon', runtime.get('docker_daemon', False)),
('Docker Compose', runtime.get('compose_cli', False)),
('Dashboard HTTP', runtime.get('dashboard_http', False)),
('WebUI HTTP', runtime.get('webui_http', False)),
]
has_failures = False
for name, status in checks:
if status:
print(f" {GREEN}✓{NC} {name}")
else:
print(f" {RED}✗{NC} {name}")
has_failures = True
dgx_check = runtime.get('dgx_spark_cuda_arch_check', {})
dgx_status = dgx_check.get('status')
dgx_message = dgx_check.get('message', '')
if dgx_status == 'pass':
print(f" {GREEN}✓{NC} DGX Spark llama-server CUDA arch")
elif dgx_status == 'warn':
print(f" {YELLOW}⚠{NC} DGX Spark llama-server CUDA arch: {dgx_message}")
amd_runtime = runtime.get('amd_runtime', {})
if amd_runtime.get('available'):
status = amd_runtime.get('health', 'unknown')
symbol = GREEN + '✓' + NC if status == 'reachable' else YELLOW + '⚠' + NC
print(
f" {symbol} AMD runtime: "
f"{amd_runtime.get('runtime')} / {amd_runtime.get('selectedBackend')} / "
f"{amd_runtime.get('location')} / {status}"
)
for warning in amd_runtime.get('warnings', [])[:3]:
print(f" {YELLOW}⚠{NC} {warning}")
elif amd_runtime.get('reason') and amd_runtime.get('reason') != 'not_amd':
print(f" {YELLOW}⚠{NC} AMD runtime: {amd_runtime.get('reason')}")
inference_contract = runtime.get('inference_contract') or {}
if inference_contract:
counts = inference_contract.get('issue_counts') or {}
blockers = counts.get('blockers', 0)
warnings = counts.get('warnings', 0)
symbol = GREEN + '✓' + NC if blockers == 0 and warnings == 0 else (RED + '✗' + NC if blockers else YELLOW + '⚠' + NC)
print(
f" {symbol} Inference contract: "
f"mode={inference_contract.get('ods_mode', 'unknown')}, "
f"owner={inference_contract.get('expected_inference_owner', 'unknown')}, "
f"gateway={inference_contract.get('expected_gateway', 'unknown')}"
)
for issue in (inference_contract.get('issues') or [])[:3]:
print(f" {YELLOW if issue.get('severity') == 'warn' else RED}⚠{NC} {issue.get('id')}: {issue.get('detail')}")
print()
# Voice checks
stt_cached = runtime.get('stt_model_cached', 'unknown')
stt_model = runtime.get('stt_model_name') or '<not configured>'
tts_http = runtime.get('tts_http', 'unknown')
if stt_cached != 'unknown' or tts_http != 'unknown':
print(f"{CYAN}Voice Readiness:{NC}")
if stt_cached == 'true':
print(f" {GREEN}✓{NC} Whisper STT model cached: {stt_model}")
elif stt_cached == 'false':
print(f" {YELLOW}⚠{NC} Whisper STT model missing: {stt_model}")
has_failures = True
elif stt_cached == 'service_down':
print(f" {RED}✗{NC} Whisper STT service not responding")
has_failures = True
elif stt_cached == 'disabled':
print(f" {YELLOW}?{NC} Whisper STT disabled")
else:
print(f" {YELLOW}?{NC} Whisper STT model cache unknown")
if tts_http == 'true':
print(f" {GREEN}✓{NC} Kokoro TTS responding")
elif tts_http == 'false':
print(f" {RED}✗{NC} Kokoro TTS not responding")
has_failures = True
elif tts_http == 'disabled':
print(f" {YELLOW}?{NC} Kokoro TTS disabled")
else:
print(f" {YELLOW}?{NC} Kokoro TTS status unknown")
print()
# Preflight checks
preflight = report.get('preflight', {})
pf_checks = preflight.get('checks', [])
blockers = []
warnings = []
if pf_checks:
print(f"{CYAN}Preflight Checks:{NC}")
for check in pf_checks:
name = check.get('name', check.get('id', 'Unknown'))
status = check.get('status', 'unknown')
message = check.get('message', '')
if status == 'pass':
print(f" {GREEN}✓{NC} {name}")
elif status == 'warn':
print(f" {YELLOW}⚠{NC} {name}: {message}")
warnings.append((name, message))
elif status == 'blocker':
print(f" {RED}✗{NC} {name}: {message}")
blockers.append((name, message))
has_failures = True
else:
print(f" {YELLOW}?{NC} {name}: {message}")
print()
# Evidence-ranked diagnoses
diagnoses = report.get('diagnoses', [])
if diagnoses:
print(f"{CYAN}Diagnoses:{NC}")
severity_colors = {
'blocker': RED,
'warn': YELLOW,
'info': CYAN,
}
for diagnosis in diagnoses[:5]:
severity = diagnosis.get('severity', 'info')
color = severity_colors.get(severity, CYAN)
diag_id = diagnosis.get('id', 'ODS-UNKNOWN')
confidence = diagnosis.get('confidence', 'unknown')
title = diagnosis.get('title', 'Unknown diagnosis')
print(f" {color}{severity.upper()}{NC} {diag_id}: {title} ({confidence} confidence)")
evidence = diagnosis.get('evidence') or []
if evidence:
first = evidence[0]
print(f" evidence: {first.get('source', 'unknown')} — {first.get('detail', '')}")
next_steps = diagnosis.get('next_steps') or []
if next_steps:
print(f" next: {next_steps[0]}")
if severity == 'blocker':
has_failures = True
if len(diagnoses) > 5:
print(f" ... {len(diagnoses) - 5} more diagnosis item(s) in the JSON report")
print()
# Summary
summary = report.get('summary', {})
blocker_count = summary.get('preflight_blockers', 0)
warning_count = summary.get('preflight_warnings', 0)
runtime_warning_count = summary.get('runtime_warnings', 0)
diagnosis_blocker_count = summary.get('diagnoses_blockers', 0)
diagnosis_warning_count = summary.get('diagnoses_warnings', 0)
runtime_ready = summary.get('runtime_ready', False)
print(f"{CYAN}Summary:{NC}")
if blocker_count > 0:
print(f" {RED}✗{NC} {blocker_count} blocker(s) found")
if warning_count > 0:
print(f" {YELLOW}⚠{NC} {warning_count} warning(s) found")
if runtime_warning_count > 0:
print(f" {YELLOW}⚠{NC} {runtime_warning_count} runtime warning(s) found")
if diagnosis_blocker_count > 0:
print(f" {RED}✗{NC} {diagnosis_blocker_count} diagnosis blocker(s) found")
if diagnosis_warning_count > 0:
print(f" {YELLOW}⚠{NC} {diagnosis_warning_count} diagnosis warning(s) found")
if blocker_count == 0 and warning_count == 0 and runtime_warning_count == 0 and diagnosis_blocker_count == 0 and diagnosis_warning_count == 0 and runtime_ready:
print(f" {GREEN}✓{NC} All checks passed")
print()
# Autofix hints
hints = report.get('autofix_hints', [])
if hints:
print(f"{CYAN}Suggested Fixes:{NC}")
for i, hint in enumerate(hints[:6], 1):
print(f" {i}. {hint}")
print()
# Exit code: 1 if blockers or runtime failures, 0 otherwise
if has_failures or blocker_count > 0:
sys.exit(1)
else:
sys.exit(0)
PY
return $?
}
cmd_audit() {
check_install
sr_load
local -a script_args=()
local scope="${1:-}"
if [[ "$scope" == "extensions" || "$scope" == "extension" ]]; then
shift
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--json|--strict)
script_args+=("$1")
shift
;;
--help|-h)
python3 "$INSTALL_DIR/scripts/audit-extensions.py" --help
return 0
;;
*)
script_args+=("$(resolve_service "$1")")
shift
;;
esac
done
if [[ ! -f "$INSTALL_DIR/scripts/audit-extensions.py" ]]; then
error "audit-extensions.py not found at $INSTALL_DIR/scripts/audit-extensions.py"
fi
python3 "$INSTALL_DIR/scripts/audit-extensions.py" --project-dir "$INSTALL_DIR" "${script_args[@]}"
}
#=============================================================================
# Extension Management Commands
#=============================================================================
# Track visited services during recursive enable to prevent circular deps
declare -a _ENABLE_VISITED=()
cmd_enable() {
check_install
sr_load
load_env 2>/dev/null || true
local input="${1:-}"
[[ -z "$input" ]] && { log "Usage: ods enable <service>"; exit 1; }
local service_id
service_id=$(sr_resolve "$input")
# Circular dependency guard
for _visited in "${_ENABLE_VISITED[@]}"; do
if [[ "$_visited" == "$service_id" ]]; then
warn "Circular dependency detected: $service_id already being enabled. Skipping."
return 0
fi
done
_ENABLE_VISITED+=("$service_id")
# Check built-in extensions first, then dashboard-installed user extensions
local ext_dir="$INSTALL_DIR/extensions/services/$service_id"
[[ ! -d "$ext_dir" ]] && ext_dir="$INSTALL_DIR/data/user-extensions/$service_id"
[[ -d "$ext_dir" ]] || error "Unknown service: $input"
local cf="$ext_dir/compose.yaml"
local disabled="$ext_dir/compose.yaml.disabled"
# Check it's not a core service
local cat="${SERVICE_CATEGORIES[$service_id]:-optional}"
[[ "$cat" == "core" ]] && { success "$service_id is a core service (always enabled)."; return 0; }
# Check GPU backend compatibility
local gpu_backends="${SERVICE_GPU_BACKENDS[$service_id]:-}"
if [[ -n "$gpu_backends" ]]; then
# Load current GPU_BACKEND from .env
local current_backend="${GPU_BACKEND:-nvidia}"
if [[ -f "$INSTALL_DIR/.env" ]]; then
local env_backend
env_backend=$(grep "^GPU_BACKEND=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 | tr -d '"' || true)
if [[ -n "$env_backend" ]]; then
current_backend="$env_backend"
else
warn "Could not read GPU_BACKEND from .env, using default: nvidia"
fi
fi
# Handle "all" sentinel - services that work on any backend
if [[ " $gpu_backends " =~ " all " ]]; then
# Service works on all backends, skip compatibility check
:
# Apple Silicon exemption - all Docker services work on macOS regardless of gpu_backends
elif [[ "$current_backend" == "apple" ]]; then
# All Docker services run on macOS regardless of gpu_backends declaration
:
# Check if current backend is in the supported list
elif [[ ! " $gpu_backends " =~ " $current_backend " ]]; then
warn "$service_id may not work with GPU backend: $current_backend"
warn " Supported backends: $gpu_backends"
warn " Current backend: $current_backend"
read -p " Continue anyway? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log "Cancelled."
return 1
fi
fi
fi
# Check inter-extension dependencies
local deps="${SERVICE_DEPENDS[$service_id]:-}"
if [[ -n "$deps" ]]; then
local missing=()
for dep in $deps; do
local dep_cf="$INSTALL_DIR/extensions/services/$dep/compose.yaml"
local dep_cat="${SERVICE_CATEGORIES[$dep]:-optional}"
# Core services are always available; check extension deps
if [[ "$dep_cat" != "core" && ! -f "$dep_cf" ]]; then
missing+=("$dep")
fi
done
if [[ ${#missing[@]} -gt 0 ]]; then
warn "$service_id depends on disabled services: ${missing[*]}"
read -p " Enable them too? [Y/n] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
for dep in "${missing[@]}"; do
cmd_enable "$dep"
done
else
warn "Proceeding without dependencies — $service_id may not start correctly."
fi
fi
fi
if [[ -f "$cf" ]]; then
success "$service_id is already enabled."
elif [[ -f "$disabled" ]]; then
mv "$disabled" "$cf"
_regenerate_compose_flags
# Check if service needs a Docker build (has Dockerfile but no image)
if { [[ -f "$ext_dir/Dockerfile" ]] || [[ -f "$ext_dir/Dockerfile.rust" ]]; } && \
! docker image inspect "ods-${service_id}:latest" &>/dev/null; then
success "$service_id enabled. Image needs building first:"
echo " docker compose build $service_id && ods start $service_id"
else
success "$service_id enabled. Run 'ods start $service_id' to launch."
fi
else
error "$service_id has no compose fragment (core service? already enabled?)"
fi
}
cmd_disable() {
check_install
cd "$INSTALL_DIR"
load_env
sr_load
load_env 2>/dev/null || true
local input="${1:-}"
[[ -z "$input" ]] && { log "Usage: ods disable <service>"; exit 1; }
local service_id
service_id=$(sr_resolve "$input")
# Check built-in extensions first, then dashboard-installed user extensions
local ext_dir="$INSTALL_DIR/extensions/services/$service_id"
[[ ! -d "$ext_dir" ]] && ext_dir="$INSTALL_DIR/data/user-extensions/$service_id"
[[ -d "$ext_dir" ]] || error "Unknown service: $input"
local cf="$ext_dir/compose.yaml"
# Check it's not a core service
local cat="${SERVICE_CATEGORIES[$service_id]:-optional}"
[[ "$cat" == "core" ]] && error "Cannot disable core service: $service_id"
# Check for reverse dependents
local dependents=()
for sid in "${SERVICE_IDS[@]}"; do
[[ "$sid" == "$service_id" ]] && continue
local dep_cf="$INSTALL_DIR/extensions/services/$sid/compose.yaml"
[[ ! -f "$dep_cf" ]] && continue
local deps="${SERVICE_DEPENDS[$sid]:-}"
for dep in $deps; do
if [[ "$dep" == "$service_id" ]]; then
dependents+=("$sid")
break
fi
done
done
if [[ ${#dependents[@]} -gt 0 ]]; then
warn "These enabled extensions depend on $service_id: ${dependents[*]}"
read -p " Continue anyway? [y/N] " -n 1 -r
echo
[[ ! $REPLY =~ ^[Yy]$ ]] && { log "Cancelled."; return 1; }
fi
# Stop if running, then rename
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
docker compose "${flags[@]}" stop "$service_id" 2>/dev/null || true
[[ -f "$cf" ]] && mv "$cf" "${cf}.disabled"
_regenerate_compose_flags
local _data_dir="$INSTALL_DIR/data/$service_id"
if [[ -d "$_data_dir" ]]; then
local _data_size
_data_size=$(du -sh "$_data_dir" 2>/dev/null | cut -f1 || true)
success "$service_id disabled. Data preserved (${_data_size} in data/$service_id). Use 'ods purge $service_id' to delete."
else
success "$service_id disabled."
fi
}
cmd_purge() {
check_install
sr_load
if [[ $# -lt 1 ]]; then
error "Usage: ods purge <service>"
fi
local service_id
service_id=$(sr_resolve "$1")
# Validate against known service IDs
local _found=false
for _sid in "${SERVICE_IDS[@]}"; do
if [[ "$_sid" == "$service_id" ]]; then
_found=true
break
fi
done
if ! $_found; then
error "Unknown service: $service_id"
fi
# Block core services
local _cat="${SERVICE_CATEGORIES[$service_id]:-optional}"
if [[ "$_cat" == "core" ]]; then
error "Cannot purge core service data: $service_id"
fi
# Check if service is still enabled (compose.yaml exists = enabled)
local _cf="$INSTALL_DIR/extensions/services/$service_id/compose.yaml"
if [[ -f "$_cf" ]]; then
error "$service_id is still enabled. Run 'ods disable $service_id' first."
fi
# Check if container is still running (disable may have failed to stop it)
local _container="${SERVICE_CONTAINERS[$service_id]:-ods-$service_id}"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${_container}$"; then
error "$service_id container is still running. Run 'ods stop $service_id' first."
fi
# Check data directory exists
local _data_dir="$INSTALL_DIR/data/$service_id"
if [[ ! -d "$_data_dir" ]]; then
log "No data directory found for $service_id."
return 0
fi
# Show size and confirm
local _data_size
_data_size=$(du -sh "$_data_dir" 2>/dev/null | cut -f1 || true)
warn "This will permanently delete all data for $service_id (${_data_size})."
warn "Directory: $_data_dir"
log ""
local _confirm
read -p " Type '$service_id' to confirm deletion: " -r _confirm
if [[ "$_confirm" != "$service_id" ]]; then
log "Purge cancelled."
return 0
fi
# Delete data — try direct rm first, fall back to docker for root-owned files
rm -rf "$_data_dir" 2>/dev/null || true
if [[ -d "$_data_dir" ]]; then
log "Some files are owned by root (created by Docker). Removing via container..."
if command -v docker &>/dev/null; then
docker run --rm -v "$_data_dir:/purge-target" alpine \
sh -c 'rm -rf /purge-target/* /purge-target/.[!.]* 2>/dev/null; true' 2>/dev/null \
|| warn "Docker cleanup failed (non-fatal)"
rm -rf "$_data_dir" 2>/dev/null || true
fi
if [[ -d "$_data_dir" ]]; then
error "Could not fully remove $_data_dir. Try: sudo rm -rf $_data_dir"
fi
fi
success "Purged $service_id data (${_data_size} freed)."
}
cmd_list() {
local json_mode="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--json) json_mode="true"; shift ;;
*) error "Unknown argument to 'ods list': $1" ;;
esac
done
sr_load
load_env 2>/dev/null || true
local active_flags
active_flags=$(get_compose_flags)
if [[ "$json_mode" == "true" ]]; then
# JSON output: array of {id, category, status}
local _first=1
printf '['
for sid in "${SERVICE_IDS[@]}"; do
local cat="${SERVICE_CATEGORIES[$sid]}"
local cf="${SERVICE_COMPOSE[$sid]}"
local status
if [[ "$cat" == "core" ]]; then
status="always-on"
elif [[ -n "$cf" && -f "$cf" && "$active_flags" == *"${cf#"$INSTALL_DIR/"}"* ]]; then
status="enabled"
else
status="disabled"
fi
(( _first == 1 )) || printf ','
printf '{"id":"%s","category":"%s","status":"%s"}' \
"$(_json_escape "$sid")" \
"$(_json_escape "$cat")" \
"$(_json_escape "$status")"
_first=0
done
printf ']\n'
return 0
fi
echo -e "${BLUE}━━━ Available Services ━━━${NC}"
printf "%-20s %-12s %-10s\n" "SERVICE" "CATEGORY" "STATUS"
printf "%-20s %-12s %-10s\n" "$(hr 20)" "$(hr 12)" "$(hr 10)"
for sid in "${SERVICE_IDS[@]}"; do
local cat="${SERVICE_CATEGORIES[$sid]}"
local cf="${SERVICE_COMPOSE[$sid]}"
local status
if [[ "$cat" == "core" ]]; then
status="always-on"
elif [[ -n "$cf" && -f "$cf" && "$active_flags" == *"${cf#"$INSTALL_DIR/"}"* ]]; then
status="enabled"
else
status="disabled"
fi
# Colour vars are blanked to "" off-TTY (lines 42-56), so no extra
# `[[ -t 1 ]]` guard is needed; padding stays consistent because ANSI
# escapes have zero display width.
local color
color=$(_status_color "$status")
printf "%-20s %-12s %s%-10s%s\n" "$sid" "$cat" "$color" "$status" "$NC"
done
}
#=============================================================================
# Preset Commands
#=============================================================================
PRESETS_DIR="${INSTALL_DIR}/presets"
# Validate preset compatibility with current system
validate_preset_compatibility() {
local preset_dir="$1"
local preset_name="$(basename "$preset_dir")"
# Check required files
[[ -f "$preset_dir/meta.txt" ]] || { error "Invalid preset: missing meta.txt"; return 1; }
[[ -f "$preset_dir/extensions.list" ]] || { error "Invalid preset: missing extensions.list"; return 1; }
[[ -f "$preset_dir/env" ]] || { warn "Preset missing .env file (will not restore config)"; }
# Check if referenced extensions still exist
local missing_exts=()
while IFS=: read -r state sid; do
[[ -z "$sid" ]] && continue
local ext_dir="$INSTALL_DIR/extensions/services/$sid"
if [[ ! -d "$ext_dir" ]]; then
missing_exts+=("$sid")
fi
done < "$preset_dir/extensions.list"
if [[ ${#missing_exts[@]} -gt 0 ]]; then
log "⚠️ Preset references extensions that are no longer available:"
for ext in "${missing_exts[@]}"; do
log " - $ext"
done
log "These extensions will be skipped during restore."
fi
return 0
}
cmd_preset() {
check_install
sr_load
local action="${1:-list}"
local name="${2:-}"
case "$action" in
save|s)
[[ -z "$name" ]] && { log "Usage: ods preset save <name>"; exit 1; }
local preset_dir="${PRESETS_DIR}/${name}"
mkdir -p "$preset_dir"
# Save .env (contains mode, model, and all config)
if [[ -f "$INSTALL_DIR/.env" ]]; then
cp "$INSTALL_DIR/.env" "$preset_dir/env"
fi
# Save enabled/disabled extension state
local state_file="$preset_dir/extensions.list"
: > "$state_file"
for sid in "${SERVICE_IDS[@]}"; do
local cat="${SERVICE_CATEGORIES[$sid]}"
[[ "$cat" == "core" ]] && continue
local cf="${SERVICE_COMPOSE[$sid]}"
if [[ -n "$cf" && -f "$cf" ]]; then
echo "enabled:$sid" >> "$state_file"
else
echo "disabled:$sid" >> "$state_file"
fi
done
# Save metadata
cat > "$preset_dir/meta.txt" <<META
name=$name
created=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
gpu_backend=${GPU_BACKEND:-unknown}
tier=${TIER:-unknown}
META
success "Preset '${name}' saved to presets/${name}/"
log "Contains: .env, mode, profiles, extension state"
;;
load|l)
[[ -z "$name" ]] && { log "Usage: ods preset load <name>"; exit 1; }
local preset_dir="${PRESETS_DIR}/${name}"
[[ -d "$preset_dir" ]] || error "Preset not found: $name"
echo -e "${BLUE}━━━ Loading Preset: ${name} ━━━${NC}"
# Validate preset compatibility
if ! validate_preset_compatibility "$preset_dir"; then
return 1
fi
# Show what will be restored
if [[ -f "$preset_dir/meta.txt" ]]; then
echo ""
while IFS='=' read -r key value; do
[[ -z "$key" || "$key" =~ ^# ]] && continue
echo " $key: $value"
done < "$preset_dir/meta.txt"
echo ""
fi
read -p "Restore this preset? This will overwrite current .env. [y/N] " -n 1 -r
echo ""
[[ $REPLY =~ ^[Yy]$ ]] || { log "Cancelled."; return 0; }
# Restore .env (contains mode, model, and all config)
if [[ -f "$preset_dir/env" ]]; then
cp "$preset_dir/env" "$INSTALL_DIR/.env"
local restored_mode
restored_mode=$(grep "^ODS_MODE=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2 || true)
success "Restored .env (mode: ${restored_mode:-local})"
fi
# Restore extension states
if [[ -f "$preset_dir/extensions.list" ]]; then
local enabled=0 disabled=0
while IFS=: read -r state sid; do
local ext_dir="$INSTALL_DIR/extensions/services/$sid"
[[ -d "$ext_dir" ]] || continue
local cf="$ext_dir/compose.yaml"
local cf_dis="$ext_dir/compose.yaml.disabled"
if [[ "$state" == "enabled" ]]; then
if [[ -f "$cf_dis" && ! -f "$cf" ]]; then
mv "$cf_dis" "$cf"
((enabled++))
fi
elif [[ "$state" == "disabled" ]]; then
if [[ -f "$cf" ]]; then
mv "$cf" "$cf_dis"
((disabled++))
fi
fi
done < "$preset_dir/extensions.list"
success "Extensions: $enabled enabled, $disabled disabled"
fi
echo ""
success "Preset '${name}' loaded."
log "Run 'ods start' to apply changes."
;;
list|ls)
echo -e "${BLUE}━━━ Saved Presets ━━━${NC}"
if [[ ! -d "$PRESETS_DIR" ]] || [[ -z "$(ls -A "$PRESETS_DIR" 2>/dev/null)" ]]; then
echo " No presets saved yet."
echo ""
echo " Create one with: ods preset save <name>"
return 0
fi
printf " %-20s %-22s %-10s\n" "NAME" "CREATED" "BACKEND"
printf " %-20s %-22s %-10s\n" "$(hr 20)" "$(hr 22)" "$(hr 10)"
for dir in "$PRESETS_DIR"/*/; do
[[ -d "$dir" ]] || continue
local pname
pname=$(basename "$dir")
local created="" backend=""
if [[ -f "$dir/meta.txt" ]]; then
# A malformed meta.txt should not abort listing the rest
# of the presets under pipefail.
created=$(grep "^created=" "$dir/meta.txt" 2>/dev/null | cut -d= -f2 | cut -dT -f1 || true)
backend=$(grep "^gpu_backend=" "$dir/meta.txt" 2>/dev/null | cut -d= -f2 || true)
fi
printf " %-20s %-22s %-10s\n" "$pname" "${created:-unknown}" "${backend:-unknown}"
done
;;
delete|rm)
[[ -z "$name" ]] && { log "Usage: ods preset delete <name>"; exit 1; }
local preset_dir="${PRESETS_DIR}/${name}"
[[ -d "$preset_dir" ]] || error "Preset not found: $name"
read -p "Delete preset '${name}'? [y/N] " -n 1 -r
echo ""
[[ $REPLY =~ ^[Yy]$ ]] || { log "Cancelled."; return 0; }
rm -rf "$preset_dir"
success "Preset '${name}' deleted."
;;
export|e)
[[ -z "$name" ]] && { log "Usage: ods preset export <name> <output.tar.gz>"; exit 1; }
local output="${3:-}"
[[ -z "$output" ]] && { log "Usage: ods preset export <name> <output.tar.gz>"; exit 1; }
local preset_dir="${PRESETS_DIR}/${name}"
[[ -d "$preset_dir" ]] || error "Preset not found: $name"
# Validate preset has required files
[[ -f "$preset_dir/meta.txt" ]] || error "Invalid preset: missing meta.txt"
[[ -f "$preset_dir/extensions.list" ]] || error "Invalid preset: missing extensions.list"
log "Exporting preset '${name}'..."
# Resolve relative output path before cd changes the working directory
if [[ "$output" != /* ]]; then
output="$(pwd)/$output"
fi
# Create archive (relative to presets dir to avoid absolute paths)
cd "$PRESETS_DIR"
if tar czf "$output" "$name" 2>/dev/null; then
local size
size=$(du -h "$output" 2>/dev/null | cut -f1 || true)
success "Preset exported to: $output ($size)"
else
error "Failed to create archive"
fi
cd "$INSTALL_DIR"
;;
import|i)
local archive="${2:-}"
[[ -z "$archive" ]] && { log "Usage: ods preset import <preset.tar.gz>"; exit 1; }
[[ -f "$archive" ]] || error "Archive not found: $archive"
log "Validating archive..."
# Security: Check for path traversal
if tar tzf "$archive" 2>/dev/null | grep -qE '(^/|\.\./|\.\.\\)'; then
error "Archive contains unsafe paths (absolute or ../) — refusing to import"
fi
# Validate archive structure
local archive_name
archive_name=$(tar tzf "$archive" 2>/dev/null | sed -n '1p' | cut -d/ -f1)
[[ -z "$archive_name" ]] && error "Invalid archive structure"
# Check if preset already exists
if [[ -d "${PRESETS_DIR}/${archive_name}" ]]; then
warn "Preset '${archive_name}' already exists"
read -p "Overwrite? [y/N] " -n 1 -r
echo ""
[[ $REPLY =~ ^[Yy]$ ]] || { log "Cancelled."; return 0; }
rm -rf "${PRESETS_DIR}/${archive_name}"
fi
# Extract archive
mkdir -p "$PRESETS_DIR"
cd "$PRESETS_DIR"
if tar xzf "$archive" 2>/dev/null; then
# Validate extracted preset
if [[ ! -f "${archive_name}/meta.txt" ]]; then
rm -rf "${archive_name}"
error "Invalid preset: missing meta.txt"
fi
if [[ ! -f "${archive_name}/extensions.list" ]]; then
rm -rf "${archive_name}"
error "Invalid preset: missing extensions.list"
fi
success "Preset '${archive_name}' imported successfully"
log "Use 'ods preset load ${archive_name}' to apply it"
else
error "Failed to extract archive"
fi
cd "$INSTALL_DIR"
;;
diff|d)
local preset1="${2:-}"
local preset2="${3:-}"
# Validate arguments
if [[ -z "$preset1" ]] || [[ -z "$preset2" ]]; then
error "Usage: ods preset diff <preset1> <preset2>"
fi
local dir1="$PRESETS_DIR/$preset1"
local dir2="$PRESETS_DIR/$preset2"
# Check both presets exist
[[ -d "$dir1" ]] || error "Preset '$preset1' not found"
[[ -d "$dir2" ]] || error "Preset '$preset2' not found"
log "Comparing presets: ${CYAN}$preset1${NC} vs ${CYAN}$preset2${NC}"
echo ""
# Compare metadata
echo -e "${BLUE}━━━ Metadata ━━━${NC}"
if [[ -f "$dir1/meta.txt" ]] && [[ -f "$dir2/meta.txt" ]]; then
diff -u "$dir1/meta.txt" "$dir2/meta.txt" | tail -n +4 | sed "s/^-/$(printf "${RED}-${NC}")/" | sed "s/^+/$(printf "${GREEN}+${NC}")/" || true
fi
echo ""
# Compare extensions
echo -e "${BLUE}━━━ Service State ━━━${NC}"
if [[ -f "$dir1/extensions.list" ]] && [[ -f "$dir2/extensions.list" ]]; then
# Parse both files into associative arrays
declare -A ext1 ext2
while IFS=: read -r state sid; do
[[ -n "$sid" ]] && ext1["$sid"]="$state"
done < "$dir1/extensions.list"
while IFS=: read -r state sid; do
[[ -n "$sid" ]] && ext2["$sid"]="$state"
done < "$dir2/extensions.list"
# Find all unique service IDs
local all_sids=()
for sid in "${!ext1[@]}"; do all_sids+=("$sid"); done
for sid in "${!ext2[@]}"; do
[[ -z "${ext1[$sid]:-}" ]] && all_sids+=("$sid")
done
# Sort and compare
local has_diff=false
for sid in $(printf '%s\n' "${all_sids[@]}" | sort); do
local state1="${ext1[$sid]:-missing}"
local state2="${ext2[$sid]:-missing}"
if [[ "$state1" != "$state2" ]]; then
has_diff=true
if [[ "$state1" == "missing" ]]; then
echo -e " ${GREEN}+${NC} $sid: $state2"
elif [[ "$state2" == "missing" ]]; then
echo -e " ${RED}-${NC} $sid: $state1"
else
echo -e " ${YELLOW}~${NC} $sid: $state1$state2"
fi
fi
done
[[ "$has_diff" == "false" ]] && echo " (no differences)"
fi
echo ""
# Compare environment variables
echo -e "${BLUE}━━━ Environment Variables ━━━${NC}"
if [[ -f "$dir1/env" ]] && [[ -f "$dir2/env" ]]; then
# Load secret schema so `_cmd_config_is_secret` below can consult
# .env.schema.json instead of the narrow regex the old version
# used (which missed _PASS, _SALT, email admin fields, etc.).
_cmd_config_load_secret_schema
# Parse both env files
declare -A env1 env2
while IFS='=' read -r key value; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "$key" ]] && continue
env1["$key"]="$value"
done < "$dir1/env"
while IFS='=' read -r key value; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "$key" ]] && continue
env2["$key"]="$value"
done < "$dir2/env"
# Find all unique keys
local all_keys=()
for key in "${!env1[@]}"; do all_keys+=("$key"); done
for key in "${!env2[@]}"; do
[[ -z "${env1[$key]:-}" ]] && all_keys+=("$key")
done
# Sort and compare (mask secrets)
local has_diff=false
for key in $(printf '%s\n' "${all_keys[@]}" | sort); do
local val1="${env1[$key]:-}"
local val2="${env2[$key]:-}"
# Check if values differ BEFORE masking
if [[ "$val1" != "$val2" ]]; then
has_diff=true
# Mask sensitive values for display
if _cmd_config_is_secret "$key"; then
[[ -n "$val1" ]] && val1="***"
[[ -n "$val2" ]] && val2="***"
fi
if [[ -z "$val1" ]]; then
echo -e " ${GREEN}+${NC} $key=$val2"
elif [[ -z "$val2" ]]; then
echo -e " ${RED}-${NC} $key=$val1"
else
echo -e " ${YELLOW}~${NC} $key: $val1$val2"
fi
fi
done
[[ "$has_diff" == "false" ]] && echo " (no differences)"
fi
echo ""
;;
*)
cat <<EOF
Usage: ods preset <command> [args]
Commands:
save <name> Save current configuration as a preset
load <name> Load a preset (restores .env and service state)
list List all saved presets
delete <name> Delete a preset
export <name> <file> Export preset to .tar.gz archive
import <file> Import preset from .tar.gz archive
diff <preset1> <preset2> Compare two presets and show differences
Examples:
ods preset save gaming
ods preset load gaming
ods preset diff gaming production
ods preset export gaming ~/gaming-preset.tar.gz
EOF
;;
esac
}
#=============================================================================
# Mode Switch Commands (M1 Zero-Cloud Phase 3)
#=============================================================================
cmd_mode() {
check_install; cd "$INSTALL_DIR"
load_env || true
local mode="${1:-}"
if [[ -z "$mode" ]]; then
# Show current mode
local current=$(grep "^ODS_MODE=" .env 2>/dev/null | cut -d= -f2 || true)
current="${current:-local}"
local api_url=$(grep "^LLM_API_URL=" .env 2>/dev/null | cut -d= -f2 || true)
local model=$(grep "^LLM_MODEL=" .env 2>/dev/null | cut -d= -f2 || true)
local tier=$(grep "^TIER=" .env 2>/dev/null | cut -d= -f2 || true)
echo -e "${BLUE}━━━ ODS Mode ━━━${NC}"
echo ""
echo -e "Current mode: ${GREEN}${current}${NC}"
[[ -n "$api_url" ]] && echo "LLM_API_URL: $api_url"
[[ -n "$model" ]] && echo "Model: $model"
[[ -n "$tier" ]] && echo "Tier: $tier"
echo ""
case "$current" in
cloud)
echo " LLM: LiteLLM → Cloud APIs (Claude, GPT-4, etc.)"
echo " Cost: ~\$0.003-0.06/1K tokens"
;;
local)
echo " LLM: Local llama-server"
echo " Cost: \$0 (electricity only)"
;;
hybrid)
echo " LLM: Local llama-server → Cloud fallback on failure"
echo " Cost: \$0 when local works, cloud rates on fallback"
;;
esac
echo ""
echo -e "${CYAN}Available modes:${NC}"
echo " local — Local inference via llama-server (requires GPU/CPU)"
echo " cloud — Cloud APIs via LiteLLM (requires API keys)"
echo " hybrid — Local primary, cloud fallback"
echo ""
echo "Usage: ods mode <local|cloud|hybrid>"
return 0
fi
case "$mode" in
local|cloud|hybrid) ;;
*) error "Unknown mode: $mode. Use: local, cloud, hybrid" ;;
esac
# Update .env
_env_set "ODS_MODE" "$mode"
local api_url
if [[ "$mode" == "local" ]]; then
api_url="http://llama-server:8080"
_env_set "LLM_API_URL" "$api_url"
_env_set "HERMES_LLM_BASE_URL" "http://llama-server:8080/v1"
_env_set "HERMES_LLM_API_KEY" "sk-ods-hermes-local"
else
api_url="http://litellm:4000"
_env_set "LLM_API_URL" "$api_url"
_env_set "HERMES_LLM_BASE_URL" "http://litellm:4000/v1"
if [[ -n "${LITELLM_KEY:-}" ]]; then
_env_set "HERMES_LLM_API_KEY" "$LITELLM_KEY"
fi
# Auto-enable litellm
local litellm_cf="$INSTALL_DIR/extensions/services/litellm/compose.yaml"
local litellm_disabled="${litellm_cf}.disabled"
if [[ -f "$litellm_disabled" && ! -f "$litellm_cf" ]]; then
mv "$litellm_disabled" "$litellm_cf"
success "Auto-enabled litellm for $mode mode"
fi
# Check for API keys
local has_keys=false
grep -q "^ANTHROPIC_API_KEY=." .env 2>/dev/null && has_keys=true
grep -q "^OPENAI_API_KEY=." .env 2>/dev/null && has_keys=true
if [[ "$has_keys" == "false" ]]; then
warn "No API keys found in .env — add ANTHROPIC_API_KEY or OPENAI_API_KEY"
fi
fi
echo -e "${BLUE}━━━ Mode updated ━━━${NC}"
echo " ODS_MODE = $mode"
echo " LLM_API_URL = $api_url"
load_env || true
_regenerate_compose_flags
success "Switched to $mode mode. Run 'ods restart' to apply."
}
cmd_model() {
check_install; cd "$INSTALL_DIR"
local subcmd="${1:-current}"
case "$subcmd" in
current)
local model=$(grep "^LLM_MODEL=" .env 2>/dev/null | cut -d= -f2 || true)
local tier=$(grep "^TIER=" .env 2>/dev/null | cut -d= -f2 || true)
echo -e "Current model: ${GREEN}${model:-<not set>}${NC}"
[[ -n "$tier" ]] && echo "Current tier: $tier"
return 0
;;
list)
echo -e "${BLUE}━━━ Available Tiers ━━━${NC}"
echo " T0 — qwen3.5-2b (< 8GB RAM, any GPU)"
echo " T1 — qwen3.5-9b (<12GB VRAM)"
echo " T2 — qwen3.5-9b (12-19GB, larger context)"
echo " T3 — qwen3-30b-a3b (20-47GB)"
echo " T4 — qwen3-30b-a3b (48GB+)"
echo " SH — qwen3-30b-a3b (Strix Halo unified)"
echo " SH_LARGE — qwen3-coder-next (90GB+ unified)"
echo " NV_ULTRA — qwen3-coder-next (amd64) / qwen3.6-35b-a3b (arm64 Spark)"
echo ""
echo "Usage: ods model swap <tier>"
;;
swap)
local tier="${2:-}"
[[ -z "$tier" ]] && error "Usage: ods model swap <T0|T1|T2|T3|T4|SH|SH_LARGE|NV_ULTRA>"
tier=$(echo "$tier" | tr '[:lower:]' '[:upper:]')
# Source tier-map and resolve full config (model, GGUF, context)
. "$INSTALL_DIR/installers/lib/tier-map.sh"
local model
model=$(tier_to_model "$tier")
[[ -z "$model" ]] && error "Unknown tier: $tier"
# Normalize aliases for resolve_tier_config (T0→0, T1→1, SH→SH_COMPACT)
TIER="$tier"
[[ "$TIER" =~ ^T([0-9]+)$ ]] && TIER="${BASH_REMATCH[1]}"
[[ "$TIER" == "SH" ]] && TIER="SH_COMPACT"
resolve_tier_config
_env_set "LLM_MODEL" "$model"
_env_set "TIER" "$TIER"
_env_set "GGUF_FILE" "$GGUF_FILE"
_env_set "GGUF_URL" "$GGUF_URL"
_env_set "CTX_SIZE" "$MAX_CONTEXT"
_env_set "MAX_CONTEXT" "$MAX_CONTEXT"
success "Model set to $model (tier $tier, ctx=$MAX_CONTEXT). Run 'ods restart llama-server' to apply."
;;
*)
error "Usage: ods model <current|list|swap>"
;;
esac
}
cmd_stt() {
check_install; cd "$INSTALL_DIR"
local subcmd="${1:-status}"
# Resolve model and port from .env (with fallback for older installs).
local model port model_encoded url
model=$(_env_get_raw AUDIO_STT_MODEL)
[[ -z "$model" ]] && model="Systran/faster-whisper-base"
port=$(_env_get_raw WHISPER_PORT)
[[ -z "$port" ]] && port="9000"
model_encoded="${model//\//%2F}"
url="http://127.0.0.1:${port}"
case "$subcmd" in
current)
echo "STT model: ${model}"
echo "Whisper URL: ${url}"
return 0
;;
status)
if ! curl -sf --max-time 3 "${url}/v1/models" >/dev/null 2>&1; then
warn "Whisper service not reachable at ${url}"
echo " Is voice enabled and the stack running?"
return 1
fi
local target_encoded="$model_encoded"
local target_model="$model"
if [[ -n "${2:-}" ]]; then
target_model="$2"
target_encoded="${target_model//\//%2F}"
fi
if curl -sf --max-time 5 "${url}/v1/models/${target_encoded}" >/dev/null 2>&1; then
success "Cached: ${target_model}"
else
warn "Not cached: ${target_model}"
echo " Run: ods stt download${2:+ $2}"
fi
;;
download)
local target_model="${2:-$model}"
local target_encoded="${target_model//\//%2F}"
# Wait briefly for the models API to be ready (max 15s).
local ready=false
for _i in $(seq 1 15); do
if curl -sf --max-time 2 "${url}/v1/models" >/dev/null 2>&1; then
ready=true
break
fi
sleep 1
done
if ! $ready; then
error "Whisper models API not reachable at ${url}. Is voice enabled and the stack running?"
fi
# Skip if already cached.
if curl -sf --max-time 5 "${url}/v1/models/${target_encoded}" >/dev/null 2>&1; then
success "Already cached: ${target_model}"
return 0
fi
echo "Downloading ${target_model}..."
if ! curl -s --max-time 3600 -X POST "${url}/v1/models/${target_encoded}"; then
error "Download request failed. Check Whisper logs: ods logs whisper"
fi
# Verify the download actually cached.
if curl -sf --max-time 10 "${url}/v1/models/${target_encoded}" >/dev/null 2>&1; then
success "Downloaded and cached: ${target_model}"
else
error "Download returned but model is not cached. Check Whisper logs: ods logs whisper"
fi
;;
*)
error "Usage: ods stt <current|status|download> [MODEL]"
;;
esac
}
cmd_backup() {
check_install
cd "$INSTALL_DIR"
if [[ ! -x "$INSTALL_DIR/ods-backup.sh" ]]; then
error "ods-backup.sh not found or not executable"
fi
local action="${1:-}"
if [[ "$action" == "verify" ]]; then
shift
local id="${1:-}"
[[ -z "$id" ]] && error "Usage: ods backup verify <backup_id|backup_id.tar.gz>"
"$INSTALL_DIR/ods-backup.sh" verify "$id"
return
fi
# Default: create backup (pass all args through)
"$INSTALL_DIR/ods-backup.sh" "$@"
}
cmd_restore() {
check_install
cd "$INSTALL_DIR"
if [[ ! -x "$INSTALL_DIR/ods-restore.sh" ]]; then
error "ods-restore.sh not found or not executable"
fi
# Pass all arguments to ods-restore.sh
"$INSTALL_DIR/ods-restore.sh" "$@"
}
cmd_rollback() {
check_install
cd "$INSTALL_DIR"
load_env
if [[ ! -f "$INSTALL_DIR/.last-backup-id" ]]; then
error "No rollback point found. Rollback is only available after a failed update."
fi
local backup_id
backup_id=$(cat "$INSTALL_DIR/.last-backup-id")
if [[ -z "$backup_id" ]]; then
error "Invalid rollback point (empty backup ID)"
fi
log "Rolling back to pre-update state (backup: $backup_id)..."
echo ""
warn "This will restore configuration from before the last update."
read -p "Continue with rollback? [y/N] " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log "Rollback cancelled"
return 0
fi
# Stop services before rollback
log "Stopping services..."
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
docker compose "${flags[@]}" down 2>/dev/null || true
# Restore from backup
if "$INSTALL_DIR/ods-restore.sh" "$backup_id"; then
success "Configuration restored from backup: $backup_id"
# Restart services with restored configuration
log "Restarting services with restored configuration..."
if docker compose "${flags[@]}" up -d; then
success "Rollback complete"
rm -f "$INSTALL_DIR/.last-backup-id"
log "Checking service health..."
sleep 5
cmd_status
else
error "Failed to restart services after rollback. Check 'docker compose logs' for details."
fi
else
error "Failed to restore from backup. Your system may be in an inconsistent state."
fi
}
cmd_agent() {
check_install
load_env 2>/dev/null || true
local action="${1:-status}"
local port="${ODS_AGENT_PORT:-7710}"
local pid_file="$INSTALL_DIR/data/ods-host-agent.pid"
local log_file="$INSTALL_DIR/data/ods-host-agent.log"
local agent_script="$INSTALL_DIR/bin/ods-host-agent.py"
# Detect daemon type
local daemon_type="none"
case "$(uname -s)" in
Darwin) daemon_type="launchd" ;;
Linux)
if systemctl status >/dev/null 2>&1 || [[ -d /run/systemd/system ]]; then
daemon_type="systemd"
fi
;;
esac
case "$action" in
status)
local bind_addr
bind_addr="$(grep '^ODS_AGENT_BIND=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2 | tr -d '[:space:]' || true)"
bind_addr="${bind_addr:-127.0.0.1}"
if curl -sf --max-time 2 "http://${bind_addr}:${port}/health" > /dev/null 2>&1; then
success "ODS host agent: running (port ${port}, ${daemon_type})"
else
warn "ODS host agent: not responding (port ${port})"
fi
;;
start)
case "$daemon_type" in
launchd)
launchctl bootstrap "gui/$(id -u)" \
"$HOME/Library/LaunchAgents/com.ods.host-agent.plist" 2>/dev/null && \
success "Agent started (LaunchAgent)" || warn "Agent start failed"
;;
systemd)
sudo systemctl start ods-host-agent.service 2>/dev/null && \
success "Agent started (systemd)" || warn "Agent start failed"
;;
*)
# Fallback: nohup + PID file
if [[ -f "$pid_file" ]]; then
kill "$(cat "$pid_file")" 2>/dev/null || true
rm -f "$pid_file"
fi
if ! command -v python3 &>/dev/null; then
error "python3 not found in PATH"
return 1
fi
nohup python3 "$agent_script" --pid-file "$pid_file" \
>> "$log_file" 2>&1 &disown
success "Agent started (background, PID $!)"
;;
esac
;;
stop)
case "$daemon_type" in
launchd)
launchctl bootout "gui/$(id -u)/com.ods.host-agent" 2>/dev/null && \
success "Agent stopped" || warn "Agent not running"
;;
systemd)
sudo systemctl stop ods-host-agent.service 2>/dev/null && \
success "Agent stopped" || warn "Agent not running"
;;
*)
if [[ -f "$pid_file" ]]; then
kill "$(cat "$pid_file")" 2>/dev/null && \
success "Agent stopped" || warn "Agent process not found"
rm -f "$pid_file"
else
warn "Agent not running (no PID file)"
fi
;;
esac
;;
restart)
cmd_agent stop
sleep 1
cmd_agent start
;;
logs)
if [[ -f "$log_file" ]]; then
tail -f "$log_file"
else
warn "No log file at $log_file"
return 1
fi
;;
*)
log "Usage: ods agent [status|start|stop|restart|logs]"
;;
esac
}
#=============================================================================
# cmd_gpu — Multi-GPU inspection and management
#=============================================================================
_gpu_status() {
check_install
load_env
if [[ "${GPU_BACKEND:-}" == "apple" ]]; then
echo -e "${BLUE}━━━ GPU Status (1 integrated GPU) ━━━${NC}"
echo ""
else
local gpu_count
gpu_count=$(nvidia-smi --list-gpus 2>/dev/null | wc -l | tr -d ' ') || gpu_count=0
echo -e "${BLUE}━━━ GPU Status (${gpu_count} GPU$([ "$gpu_count" -ne 1 ] && echo s)) ━━━${NC}"
echo ""
fi
if [[ "${GPU_BACKEND:-nvidia}" == "nvidia" ]] && command -v nvidia-smi &>/dev/null && [ "$gpu_count" -gt 0 ]; then
local output
output=$(nvidia-smi \
--query-gpu=index,name,memory.used,memory.total,utilization.gpu,temperature.gpu,power.draw \
--format=csv,noheader,nounits 2>/dev/null) || { warn "nvidia-smi failed"; return; }
[[ -z "$output" ]] && { warn "No NVIDIA GPU data returned"; return; }
echo "$output" | awk -F', ' -v blue="${BLUE}" -v nc="${NC}" '
BEGIN {
printf " %-4s %-22s %-22s %-7s %-7s %s\n", \
"#", "Name", "VRAM Used/Total", "Util", "Temp", "Power"
printf " %-4s %-22s %-22s %-7s %-7s %s\n", \
"─", "──────────────────────", "──────────────────────", "───────", "───────", "─────"
n=0; total_u=0; total_t=0; util_sum=0; max_temp=0; pw_sum=0; pw_count=0
}
{
idx=$1; name=$2; mem_used=$3; mem_total=$4; util=$5; temp=$6; pw=$7
gsub(/^ +| +$/, "", idx); gsub(/^ +| +$/, "", name)
gsub(/^ +| +$/, "", mem_used); gsub(/^ +| +$/, "", mem_total)
gsub(/^ +| +$/, "", util); gsub(/^ +| +$/, "", temp); gsub(/^ +| +$/, "", pw)
vram_str = sprintf("%.1f / %.1f GB", mem_used/1024, mem_total/1024)
if (pw ~ /^[0-9.]+$/) {
pw_str = sprintf("%.0fW", pw); pw_sum += pw; pw_count++
} else {
pw_str = "N/A"
}
printf " %-4s %-22s %-22s %3d%% %3d°C %s\n", \
idx, substr(name,1,22), vram_str, util, temp, pw_str
total_u += mem_used; total_t += mem_total
util_sum += util
if (temp+0 > max_temp+0) max_temp = temp
n++
}
END {
if (n > 1) {
printf " ──────────────────────\n"
pw_total_str = (pw_count > 0) ? sprintf("%.0fW", pw_sum) : "N/A"
vram_str = sprintf("%.1f / %.1f GB", total_u/1024, total_t/1024)
printf " %-4s %-22s %-22s %3d%% %3d°C %s\n", \
"Σ", "", vram_str, int(util_sum/n), max_temp, pw_total_str
}
}'
echo ""
# Compact assignment summary (if configured)
local assignment_b64="${GPU_ASSIGNMENT_JSON_B64:-}"
if [[ -n "$assignment_b64" ]] && command -v jq &>/dev/null; then
assignment_b64=$(echo "$assignment_b64" | tr -d '\r' | tr -d '[:space:]')
local assignment_json
assignment_json=$(echo "$assignment_b64" | base64 -d 2>/dev/null) || assignment_json=""
if [[ -n "$assignment_json" ]]; then
local uuid_map
uuid_map=$(nvidia-smi --query-gpu=index,uuid --format=csv,noheader,nounits 2>/dev/null) || uuid_map=""
echo " GPU Assignments:"
echo "$assignment_json" | jq -r '.gpu_assignment.services | to_entries[] | [.key, (.value.gpus | join(","))] | @tsv' \
| while IFS=$'\t' read -r svc gpus; do
local labels=""
while IFS= read -r uuid; do
[[ -z "$uuid" ]] && continue
local idx
idx=$(echo "$uuid_map" | awk -F', ' -v u="$uuid" '$2==u{print $1; exit}' | tr -d ' ')
labels="${labels:+$labels, }${idx:+GPU${idx}}"
done < <(echo "$gpus" | tr ',' '\n')
printf " %-20s %s\n" "$svc" "${labels:-$gpus}"
done
echo ""
fi
fi
elif [[ "${GPU_BACKEND:-}" == "amd" ]]; then
printf " %-4s %-22s %-22s %-7s %-7s %s\n" \
"#" "Name" "VRAM Used/Total" "Util" "Temp" "Power"
printf " %-4s %-22s %-22s %-7s %-7s %s\n" \
"─" "──────────────────────" "──────────────────────" "───────" "───────" "─────"
local _amd_idx=0 _amd_total_u=0 _amd_total_t=0 _amd_util_sum=0 _amd_max_temp=0 _amd_pw_sum=0
for card_dir in /sys/class/drm/card*/device; do
[[ -f "$card_dir/vendor" ]] || continue
local vendor
vendor=$(cat "$card_dir/vendor" 2>/dev/null)
[[ "$vendor" == "0x1002" ]] || continue
local name vram_total vram_used busy temp_c pw_w
name=$(cat "$card_dir/product_name" 2>/dev/null || echo "AMD Radeon")
[[ -z "$name" || "$name" == "(null)" ]] && name="AMD Radeon"
vram_total=$(cat "$card_dir/mem_info_vram_total" 2>/dev/null || echo "0")
vram_used=$(cat "$card_dir/mem_info_vram_used" 2>/dev/null || echo "0")
busy=$(cat "$card_dir/gpu_busy_percent" 2>/dev/null || echo "0")
# Temperature: find junction sensor via hwmon labels
temp_c="N/A"
for hwmon_dir in "$card_dir"/hwmon/hwmon*; do
[[ -d "$hwmon_dir" ]] || continue
for label_file in "$hwmon_dir"/temp*_label; do
[[ -f "$label_file" ]] || continue
local label_val
label_val=$(cat "$label_file" 2>/dev/null)
if [[ "$label_val" == "junction" || "$label_val" == "edge" ]]; then
local input_file="${label_file/_label/_input}"
if [[ -f "$input_file" ]]; then
local millic
millic=$(cat "$input_file" 2>/dev/null) || millic=0
temp_c=$((millic / 1000))
break 2
fi
fi
done
# Fallback: try temp1_input if no labels found
if [[ "$temp_c" == "N/A" && -f "$hwmon_dir/temp1_input" ]]; then
local millic
millic=$(cat "$hwmon_dir/temp1_input" 2>/dev/null) || millic=0
temp_c=$((millic / 1000))
fi
done
# Power: try power1_average then power1_input (microwatts)
pw_w="N/A"
for hwmon_dir in "$card_dir"/hwmon/hwmon*; do
[[ -d "$hwmon_dir" ]] || continue
local pw_uw=0
if [[ -f "$hwmon_dir/power1_average" ]]; then
pw_uw=$(cat "$hwmon_dir/power1_average" 2>/dev/null) || pw_uw=0
fi
if [[ ! "$pw_uw" =~ ^[0-9]+$ || "$pw_uw" -eq 0 ]] && [[ -f "$hwmon_dir/power1_input" ]]; then
pw_uw=$(cat "$hwmon_dir/power1_input" 2>/dev/null) || pw_uw=0
fi
if [[ "$pw_uw" =~ ^[0-9]+$ && "$pw_uw" -gt 0 ]]; then
pw_w=$((pw_uw / 1000000))
break
fi
done
local vram_str
vram_str=$(awk "BEGIN{printf \"%.1f / %.1f GB\", $vram_used/1073741824, $vram_total/1073741824}")
local temp_str pw_str
[[ "$temp_c" != "N/A" ]] && temp_str="${temp_c}°C" || temp_str="N/A"
[[ "$pw_w" != "N/A" ]] && pw_str="${pw_w}W" || pw_str="N/A"
printf " %-4s %-22s %-22s %3s%% %-7s %s\n" \
"$_amd_idx" "${name:0:22}" "$vram_str" "$busy" "$temp_str" "$pw_str"
_amd_total_u=$((vram_used + _amd_total_u))
_amd_total_t=$((vram_total + _amd_total_t))
_amd_util_sum=$((busy + _amd_util_sum))
[[ "$temp_c" != "N/A" && "$temp_c" -gt "$_amd_max_temp" ]] && _amd_max_temp=$temp_c
[[ "$pw_w" != "N/A" ]] && _amd_pw_sum=$((_amd_pw_sum + pw_w))
_amd_idx=$((_amd_idx + 1))
done
if [[ $_amd_idx -gt 1 ]]; then
printf " ──────────────────────\n"
local vram_str
vram_str=$(awk "BEGIN{printf \"%.1f / %.1f GB\", $_amd_total_u/1073741824, $_amd_total_t/1073741824}")
printf " %-4s %-22s %-22s %3d%% %3d°C %dW\n" \
"Σ" "" "$vram_str" $((_amd_util_sum / _amd_idx)) "$_amd_max_temp" "$_amd_pw_sum"
fi
echo ""
# Assignment summary (same logic as NVIDIA, but map UUIDs via topology JSON)
local assignment_b64="${GPU_ASSIGNMENT_JSON_B64:-}"
if [[ -n "$assignment_b64" ]] && command -v jq &>/dev/null; then
assignment_b64=$(echo "$assignment_b64" | tr -d '\r' | tr -d '[:space:]')
local assignment_json
assignment_json=$(echo "$assignment_b64" | base64 -d 2>/dev/null) || assignment_json=""
if [[ -n "$assignment_json" ]]; then
local topo_file="$INSTALL_DIR/config/gpu-topology.json"
echo " GPU Assignments:"
echo "$assignment_json" | jq -r '.gpu_assignment.services | to_entries[] | [.key, (.value.gpus | join(","))] | @tsv' \
| while IFS=$'\t' read -r svc gpus; do
local labels=""
while IFS= read -r uuid; do
[[ -z "$uuid" ]] && continue
local idx=""
# Look up UUID in topology JSON for index
if [[ -f "$topo_file" ]]; then
idx=$(jq -r --arg u "$uuid" '.gpus[] | select(.uuid == $u) | .index' "$topo_file" 2>/dev/null)
fi
labels="${labels:+$labels, }${idx:+GPU${idx}}"
done < <(echo "$gpus" | tr ',' '\n')
printf " %-20s %s\n" "$svc" "${labels:-$gpus}"
done
echo ""
fi
fi
elif [[ "${GPU_BACKEND:-}" == "apple" ]]; then
local _chip _total_mem_gb _gpu_cores
_chip="$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo 'Apple Silicon')"
_total_mem_gb=$(( $(sysctl -n hw.memsize 2>/dev/null || echo 0) / 1024 / 1024 / 1024 ))
if command -v jq >/dev/null 2>&1; then
_gpu_cores=$(system_profiler SPDisplaysDataType -json 2>/dev/null \
| jq -r '.SPDisplaysDataType[0].sppci_cores // "?"' 2>/dev/null)
else
_gpu_cores="?"
fi
echo ""
echo " Chip: $_chip"
echo " Unified memory: ${_total_mem_gb} GB"
echo " GPU cores: $_gpu_cores"
echo " (Apple Silicon integrated GPU — unified memory shared with CPU)"
else
warn "GPU status unavailable: nvidia-smi not found and GPU_BACKEND is not amd"
fi
}
_gpu_topology() {
local force=0
while [[ $# -gt 0 ]]; do
case "$1" in --force|-f) force=1 ;; esac
shift
done
check_install
load_env
echo -e "${BLUE}━━━ GPU Topology ━━━${NC}"
echo ""
if [[ "${GPU_BACKEND:-}" == "apple" ]]; then
echo " Single integrated GPU (Apple Silicon, unified memory) — no multi-GPU topology to display."
return 0
fi
if ! command -v jq &>/dev/null; then
warn "jq not found — required for topology display"
return
fi
local backend="${GPU_BACKEND:-nvidia}"
local topo_lib topo_detect_fn
if [[ "$backend" == "amd" ]]; then
topo_lib="$SCRIPT_DIR/installers/lib/amd-topo.sh"
topo_detect_fn="detect_amd_topo"
else
topo_lib="$SCRIPT_DIR/installers/lib/nvidia-topo.sh"
topo_detect_fn="detect_nvidia_topo"
if ! command -v nvidia-smi &>/dev/null; then
warn "nvidia-smi not found — topology unavailable"
return
fi
fi
if [[ ! -f "$topo_lib" ]]; then
warn "Topology library not found: $topo_lib"
return
fi
local topo_file="$INSTALL_DIR/config/gpu-topology.json"
local topo_json
if [[ "$force" -eq 0 && -f "$topo_file" ]]; then
topo_json=$(cat "$topo_file")
else
[[ "$force" -eq 1 && -f "$topo_file" ]] && log "Re-detecting topology (--force)..."
. "$topo_lib"
topo_json=$($topo_detect_fn 2>&1) || { warn "Failed to detect GPU topology"; return; }
echo "$topo_json" > "$topo_file"
chmod 644 "$topo_file"
log "Topology saved to $topo_file"
fi
local gpu_count driver
gpu_count=$(echo "$topo_json" | jq -r '.gpu_count // 0')
driver=$(echo "$topo_json" | jq -r '.driver_version // "unknown"')
if [[ "$backend" == "amd" ]]; then
local vendor_label
vendor_label=$(echo "$topo_json" | jq -r '.vendor // "amd"')
echo " GPUs: ${gpu_count} Driver: ROCm ${driver} Backend: ${vendor_label}"
else
local mig_enabled
mig_enabled=$(echo "$topo_json" | jq -r '.mig_enabled // false')
echo " GPUs: ${gpu_count} Driver: ${driver} MIG: ${mig_enabled}"
fi
echo ""
# Build a fixed-width link_type matrix from the JSON links array
echo "$topo_json" | jq -r '
def pad(w): . + (" " * w) | .[:w];
.gpu_count as $n |
.links as $links |
([" ", ([range($n) | "GPU\(.)" | pad(6)] | .[])] | join("")),
([range($n) | . as $a |
(["GPU\($a)" | pad(6)] + [range($n) | . as $b |
if $a == $b then "X"
else
([$links[] | select(
(.gpu_a == $a and .gpu_b == $b) or
(.gpu_a == $b and .gpu_b == $a)
) | .link_type] | first // "?")
end | pad(6)
] | join(""))
] | .[])
' | sed 's/^/ /'
echo ""
local numa_nodes
numa_nodes=$(echo "$topo_json" | jq -r '.numa.nodes // 1')
echo " NUMA nodes: ${numa_nodes}"
# AMD-specific: show gfx versions and memory types
if [[ "$backend" == "amd" ]]; then
local has_gfx
has_gfx=$(echo "$topo_json" | jq -r '[.gpus[].gfx_version // empty] | length')
if [[ "$has_gfx" -gt 0 ]]; then
echo ""
echo " GPU Details:"
echo "$topo_json" | jq -r '
.gpus[] |
" GPU\(.index): \(.name) — gfx\(.gfx_version // "?") \(.memory_type // "discrete") \(.memory_gb)GB renderD\(.render_node // "?")"
' 2>/dev/null
fi
fi
}
_gpu_assignment() {
check_install
load_env
echo -e "${BLUE}━━━ GPU Assignment ━━━${NC}"
echo ""
local assignment_b64="${GPU_ASSIGNMENT_JSON_B64:-}"
if [[ -z "$assignment_b64" ]]; then
echo " Single GPU — no multi-GPU assignment configured."
return
fi
if ! command -v jq &>/dev/null; then
warn "jq not found — required for assignment display"
return
fi
# Strip CR and trailing whitespace (CRLF .env or editor artifacts break base64 -d)
assignment_b64=$(echo "$assignment_b64" | tr -d '\r' | tr -d '[:space:]')
local assignment_json
assignment_json=$(echo "$assignment_b64" | base64 -d 2>/dev/null) || {
warn "Failed to decode GPU_ASSIGNMENT_JSON_B64 — try running 'ods gpu reassign'"
return
}
local strategy
strategy=$(echo "$assignment_json" | jq -r '.gpu_assignment.strategy // "unknown"')
echo " Strategy: ${strategy}"
echo ""
# Build UUID → index map for readable display
local uuid_map="" backend="${GPU_BACKEND:-nvidia}"
if [[ "$backend" == "amd" ]]; then
# For AMD, use topology JSON which has index + uuid per GPU
local topo_file="$INSTALL_DIR/config/gpu-topology.json"
if [[ -f "$topo_file" ]] && command -v jq &>/dev/null; then
uuid_map=$(jq -r '.gpus[] | "\(.index), \(.uuid)"' "$topo_file" 2>/dev/null) || uuid_map=""
fi
elif command -v nvidia-smi &>/dev/null; then
uuid_map=$(nvidia-smi --query-gpu=index,uuid --format=csv,noheader,nounits 2>/dev/null) || uuid_map=""
fi
printf " %-16s %-24s %-10s %-4s %-4s\n" "Service" "GPUs" "Mode" "TP" "PP"
printf " %-16s %-24s %-10s %-4s %-4s\n" "───────────────" "───────────────────────" "─────────" "──" "──"
echo "$assignment_json" | jq -r '.gpu_assignment.services | to_entries[] |
[.key,
(.value.gpus | join(",")),
(.value.parallelism.mode // "—"),
(.value.parallelism.tensor_parallel_size // "—" | tostring),
(.value.parallelism.pipeline_parallel_size // "—" | tostring)] |
@tsv' | while IFS=$'\t' read -r service gpus mode tp pp; do
# Map UUIDs to GPU indices when possible
local display_gpus="$gpus"
if [[ -n "$uuid_map" ]]; then
local gpu_labels=""
while IFS= read -r uuid; do
[[ -z "$uuid" ]] && continue
local idx
idx=$(echo "$uuid_map" | awk -F', ' -v u="$uuid" '$2==u{print $1; exit}' | tr -d ' ')
if [[ -n "$idx" ]]; then
gpu_labels="${gpu_labels:+$gpu_labels, }GPU${idx}"
fi
done < <(echo "$gpus" | tr ',' '\n')
[[ -n "$gpu_labels" ]] && display_gpus="$gpu_labels"
fi
printf " %-16s %-24s %-10s %-4s %-4s\n" "$service" "${display_gpus:0:24}" "$mode" "$tp" "$pp"
done
}
_gpu_validate() {
check_install
load_env
echo -e "${BLUE}━━━ GPU Validate ━━━${NC}"
echo ""
# Apple Silicon is a single integrated GPU with unified memory —
# GPU_COUNT / multi-GPU assignment / split-mode checks do not apply.
# Report a clean skip rather than emitting false-positive failures.
if [[ "${GPU_BACKEND:-}" == "apple" ]]; then
echo " Apple Silicon: single integrated GPU (unified memory) — no multi-GPU validation needed."
echo ""
echo " Result: 0 check(s) passed, 0 failed"
return 0
fi
local pass=0 fail=0
# Check 1: GPU_COUNT matches actual
local env_count="${GPU_COUNT:-1}"
local actual_count=1
local backend="${GPU_BACKEND:-nvidia}"
if [[ "$backend" == "amd" ]]; then
# Count AMD GPUs via sysfs vendor ID
actual_count=0
for card_dir in /sys/class/drm/card*/device; do
[[ -d "$card_dir" ]] || continue
local vendor
vendor=$(cat "$card_dir/vendor" 2>/dev/null) || continue
[[ "$vendor" == "0x1002" ]] && ((actual_count++)) || true
done
[[ "$actual_count" -eq 0 ]] && actual_count=1
elif command -v nvidia-smi &>/dev/null; then
actual_count=$(nvidia-smi --list-gpus 2>/dev/null | wc -l | tr -d ' ' || true)
actual_count="${actual_count:-0}"
fi
if [[ -z "${GPU_COUNT:-}" ]]; then
warn "GPU_COUNT not set in .env — defaulting to 1 (re-run installer or set manually)"
((fail++)) || true
elif [[ "$env_count" -eq "$actual_count" ]]; then
success "GPU_COUNT=${env_count} matches actual GPU count"
((pass++)) || true
else
warn "GPU_COUNT=${env_count} in .env does not match actual count=${actual_count}"
echo " Fix: set GPU_COUNT=${actual_count} in .env"
((fail++)) || true
fi
# Check 2: Assigned UUIDs still present in system
local assignment_b64="${GPU_ASSIGNMENT_JSON_B64:-}"
if [[ -z "$assignment_b64" ]]; then
echo " (No multi-GPU assignment — single GPU setup)"
elif command -v jq &>/dev/null; then
local assignment_json
if assignment_json=$(echo "$assignment_b64" | base64 -d 2>/dev/null); then
local live_uuids=""
if [[ "$backend" == "amd" ]]; then
# For AMD, rebuild UUID list from topology JSON or sysfs
local topo_file="$INSTALL_DIR/config/gpu-topology.json"
if [[ -f "$topo_file" ]]; then
live_uuids=$(jq -r '.gpus[].uuid' "$topo_file" 2>/dev/null | tr -d ' ' || true)
fi
elif command -v nvidia-smi &>/dev/null; then
live_uuids=$(nvidia-smi --query-gpu=uuid --format=csv,noheader,nounits 2>/dev/null | tr -d ' ' || true)
fi
live_uuids="${live_uuids:-}"
local missing=0
while IFS= read -r uuid; do
[[ -z "$uuid" ]] && continue
if ! echo "$live_uuids" | grep -qF "$uuid"; then
warn "UUID ${uuid} not found in current system"
echo " Fix: run 'ods gpu reassign'"
((missing++)) || true
((fail++)) || true
fi
done < <(echo "$assignment_json" | jq -r '.gpu_assignment.services[].gpus[]' 2>/dev/null)
if [[ "$missing" -eq 0 ]]; then
success "All assigned GPU UUIDs are present in system"
((pass++)) || true
fi
else
warn "Failed to decode GPU_ASSIGNMENT_JSON_B64"
((fail++)) || true
fi
fi
# Check 3: LLAMA_ARG_SPLIT_MODE vs assigned GPU count
local split_mode="${LLAMA_ARG_SPLIT_MODE:-}"
local llama_uuids="${LLAMA_SERVER_GPU_UUIDS:-}"
if [[ -n "$split_mode" && "$split_mode" != "none" ]]; then
local llama_count
llama_count=$(echo "$llama_uuids" | tr ',' '\n' | grep -c . 2>/dev/null) || llama_count=1
if [[ "$llama_count" -gt 1 ]]; then
success "LLAMA_ARG_SPLIT_MODE=${split_mode} is consistent with ${llama_count} llama GPUs"
((pass++)) || true
else
warn "LLAMA_ARG_SPLIT_MODE=${split_mode} is set but only 1 GPU assigned to llama-server"
echo " Fix: run 'ods gpu reassign' or set LLAMA_ARG_SPLIT_MODE=none in .env"
((fail++)) || true
fi
fi
echo ""
echo " Result: ${pass} check(s) passed, ${fail} failed"
}
# Helper: write AMD-specific env vars from topology JSON for manual assignment
_gpu_reassign_write_amd_env() {
local topo_json="$1" indices_csv="$2"
_env_set "LLAMA_SERVER_GPU_INDICES" "$indices_csv"
_env_set "ROCR_VISIBLE_DEVICES" "$indices_csv"
_gpu_reassign_update_amd_arch_env "$topo_json" "$indices_csv"
local video_gid render_gid
video_gid=$(getent group video 2>/dev/null | cut -d: -f3) || video_gid="44"
render_gid=$(getent group render 2>/dev/null | cut -d: -f3) || render_gid="992"
_env_set "VIDEO_GID" "$video_gid"
_env_set "RENDER_GID" "$render_gid"
}
_gpu_reassign_update_amd_arch_env() {
local topo_json="$1" indices_csv="$2"
local unique_gfx gfx_ver
unique_gfx=$(echo "$topo_json" | jq -r --arg csv "$indices_csv" '
($csv | split(",") | map(gsub("^[[:space:]]+|[[:space:]]+$"; "")) | map(select(length > 0))) as $idxs |
[.gpus[] | select((.index | tostring) as $i | $idxs | index($i)) | .gfx_version // empty] |
map(select(. != "" and . != "null" and . != "unknown")) |
unique
' 2>/dev/null) || unique_gfx="[]"
if [[ "$(echo "$unique_gfx" | jq 'length' 2>/dev/null || echo 0)" -eq 1 ]]; then
gfx_ver=$(echo "$unique_gfx" | jq -r '.[0]')
else
gfx_ver=""
fi
if [[ "$gfx_ver" == "gfx1151" ]]; then
_env_set "HSA_OVERRIDE_GFX_VERSION" "11.5.1"
_env_set "LEMONADE_LLAMACPP_ROCM_BIN" "/opt/llama-custom/llama-server"
else
_env_unset "HSA_OVERRIDE_GFX_VERSION"
_env_unset "LEMONADE_LLAMACPP_ROCM_BIN"
fi
}
_gpu_reassign() {
local auto_mode=0 dry_run=0 manual_mode=0
while [[ $# -gt 0 ]]; do
case "$1" in
--auto) auto_mode=1 ;;
--dry-run) dry_run=1 ;;
--manual) manual_mode=1 ;;
esac
shift
done
check_install
load_env
echo -e "${BLUE}━━━ GPU Reassign ━━━${NC}"
echo ""
if [[ "${GPU_BACKEND:-}" == "apple" ]]; then
warn "GPU reassignment is not applicable on Apple Silicon (single integrated GPU)."
return 1
fi
if ! command -v jq &>/dev/null; then
warn "jq not found — required for GPU reassignment"
return 1
fi
local backend="${GPU_BACKEND:-nvidia}"
local topo_lib topo_detect_fn
if [[ "$backend" == "amd" ]]; then
topo_lib="$SCRIPT_DIR/installers/lib/amd-topo.sh"
topo_detect_fn="detect_amd_topo"
else
topo_lib="$SCRIPT_DIR/installers/lib/nvidia-topo.sh"
topo_detect_fn="detect_nvidia_topo"
if ! command -v nvidia-smi &>/dev/null; then
warn "nvidia-smi not found — GPU reassignment unavailable"
return
fi
fi
local assign_script="$SCRIPT_DIR/scripts/assign_gpus.py"
[[ -f "$topo_lib" ]] || { warn "Topology library not found: $topo_lib"; return 1; }
[[ -f "$assign_script" ]] || { warn "Assignment script not found: $assign_script"; return 1; }
# warn and err are already defined; source is safe
. "$topo_lib"
log "Detecting GPU topology..."
local topo_json
topo_json=$($topo_detect_fn) || error "Failed to detect GPU topology"
# Persist topology for the dashboard API
mkdir -p "$INSTALL_DIR/config"
echo "$topo_json" > "$INSTALL_DIR/config/gpu-topology.json"
chmod 644 "$INSTALL_DIR/config/gpu-topology.json"
# Determine model size
local model_size_mb="${LLM_MODEL_SIZE_MB:-}"
if [[ -z "$model_size_mb" ]]; then
local gguf_file="${GGUF_FILE:-}"
local gguf_path="$INSTALL_DIR/data/models/${gguf_file}"
if [[ -n "$gguf_file" && -f "$gguf_path" ]]; then
model_size_mb=$(du -m "$gguf_path" | awk '{print $1}')
log "Estimated model size from file: ${model_size_mb} MB"
else
echo -n " Model size not found in .env. Enter size in MB (e.g. 40000 for a 40 GB model): "
read -r model_size_mb
[[ "$model_size_mb" =~ ^[0-9]+$ ]] || error "Invalid model size: $model_size_mb"
fi
fi
# Write topology to tmpfile and run assignment
local tmpdir
tmpdir=$(mktemp -d)
trap "rm -rf '$tmpdir'" RETURN
echo "$topo_json" > "$tmpdir/topo.json"
local enabled_services="${ENABLED_SERVICES:-llama_server,whisper,comfyui,embeddings}"
# ── Manual assignment mode ──
if [[ "$manual_mode" -eq 1 ]]; then
echo ""
echo " Available GPUs:"
if [[ "$backend" == "amd" ]]; then
# List AMD GPUs from topology JSON
echo "$topo_json" | jq -r '.gpus[] |
" GPU\(.index): \(.name) (\(.memory_gb) GB \(.memory_type // "discrete"))"
' 2>/dev/null
else
local gpu_list_smi
gpu_list_smi=$(nvidia-smi --query-gpu=index,name,memory.total \
--format=csv,noheader,nounits 2>/dev/null) || { warn "nvidia-smi failed"; return; }
while IFS= read -r gline; do
[[ -z "$gline" ]] && continue
local idx gname gmem
idx=$(echo "$gline" | awk -F', ' '{gsub(/ /,"",$1); print $1}')
gname=$(echo "$gline" | awk -F', ' '{gsub(/^ +/,"",$2); print $2}')
gmem=$(echo "$gline" | awk -F', ' '{gsub(/ /,"",$3); printf "%.0f GB", $3/1024}')
echo " GPU${idx}: ${gname} (${gmem})"
done <<<"$gpu_list_smi"
fi
echo ""
local _manual_llama _manual_whisper _manual_comfyui
echo -n " GPU(s) for llama-server (comma-separated indices, e.g. 0,1,2,3): "
read -r _manual_llama
echo -n " GPU for whisper (index, or blank to skip): "
read -r _manual_whisper
echo -n " GPU for comfyui (index, or blank to skip): "
read -r _manual_comfyui
# Build UUID lists from indices
local uuid_map
if [[ "$backend" == "amd" ]]; then
uuid_map=$(echo "$topo_json" | jq -r '.gpus[] | "\(.index), \(.uuid)"' 2>/dev/null)
else
uuid_map=$(nvidia-smi --query-gpu=index,uuid --format=csv,noheader,nounits 2>/dev/null)
fi
_get_uuid() {
echo "$uuid_map" | awk -F', ' -v i="$1" '{gsub(/ /,"",$1); if($1==i){gsub(/ /,"",$2); print $2; exit}}'
}
local llama_uuid_list=""
for _idx in $(echo "$_manual_llama" | tr ',' ' '); do
_idx=$(echo "$_idx" | tr -d ' ')
local _u; _u=$(_get_uuid "$_idx")
[[ -n "$_u" ]] && llama_uuid_list="${llama_uuid_list:+$llama_uuid_list,}$_u"
done
local whisper_uuid_m="" comfyui_uuid_m=""
[[ -n "$_manual_whisper" ]] && whisper_uuid_m=$(_get_uuid "$(echo "$_manual_whisper" | tr -d ' ')")
[[ -n "$_manual_comfyui" ]] && comfyui_uuid_m=$(_get_uuid "$(echo "$_manual_comfyui" | tr -d ' ')")
# How many llama GPUs?
local llama_gpu_count
llama_gpu_count=$(echo "$_manual_llama" | tr ',' '\n' | grep -c '[0-9]' || true)
local split_mode_m="none"
if [[ "$llama_gpu_count" -gt 1 ]]; then
echo -n " Parallelism mode for llama-server (tensor/pipeline/none) [tensor]: "
read -r _para
case "${_para:-tensor}" in
tensor|hybrid) split_mode_m="row" ;;
pipeline) split_mode_m="layer" ;;
*) split_mode_m="none" ;;
esac
fi
if [[ "$dry_run" -eq 1 ]]; then
echo ""
echo " Dry run — manual assignment:"
echo " llama-server: $(echo "$_manual_llama" | tr ',' ' ' | sed 's/[^ ]*/GPU&/g')"
[[ -n "$_manual_whisper" ]] && echo " whisper: GPU${_manual_whisper}"
[[ -n "$_manual_comfyui" ]] && echo " comfyui: GPU${_manual_comfyui}"
echo " split_mode: ${split_mode_m}"
return
fi
log "Updating .env with manual assignment..."
[[ -n "$llama_uuid_list" ]] && _env_set "LLAMA_SERVER_GPU_UUIDS" "$llama_uuid_list"
_env_set "LLAMA_ARG_SPLIT_MODE" "$split_mode_m"
[[ -n "$whisper_uuid_m" ]] && _env_set "WHISPER_GPU_UUID" "$whisper_uuid_m"
[[ -n "$comfyui_uuid_m" ]] && _env_set "COMFYUI_GPU_UUID" "$comfyui_uuid_m"
# AMD-specific: write render node and ROCR env vars
if [[ "$backend" == "amd" ]]; then
_gpu_reassign_write_amd_env "$topo_json" "$_manual_llama"
fi
success "Manual GPU assignment saved"
echo ""
echo -n " Apply changes now? [Y/n] "
read -r _yn
case "${_yn:-y}" in
y|Y|"")
local _flags_str _flags=()
_flags_str=$(get_compose_flags)
read -ra _flags <<< "$_flags_str"
cd "$INSTALL_DIR"
log "Recreating all containers with new GPU assignment..."
docker compose "${_flags[@]}" down --remove-orphans
docker compose "${_flags[@]}" up -d
success "All services recreated with new GPU assignment"
;;
*) echo " Run 'ods restart' when ready." ;;
esac
return
fi
log "Running assignment algorithm (model: ${model_size_mb} MB)..."
local assignment_json
assignment_json=$(python3 "$assign_script" \
--topology "$tmpdir/topo.json" \
--model-size "$model_size_mb" \
--enabled-services "$enabled_services") || error "GPU assignment failed"
if [[ "$dry_run" -eq 1 ]]; then
echo ""
echo " Dry run — proposed assignment:"
echo "$assignment_json" | jq '.gpu_assignment' 2>/dev/null || echo "$assignment_json"
return
fi
# Extract individual env vars from assignment JSON
local strategy llama_uuids split_mode tensor_split
local whisper_uuid comfyui_uuid embeddings_uuid
strategy=$(echo "$assignment_json" | jq -r '.gpu_assignment.strategy // "unknown"')
llama_uuids=$(echo "$assignment_json" | jq -r '(.gpu_assignment.services.llama_server.gpus // []) | join(",")' 2>/dev/null || echo "")
local _para_mode
_para_mode=$(echo "$assignment_json" | jq -r '.gpu_assignment.services.llama_server.parallelism.mode // "none"')
case "$_para_mode" in
tensor|hybrid) split_mode="row" ;;
pipeline) split_mode="layer" ;;
*) split_mode="none" ;;
esac
tensor_split=$(echo "$assignment_json" | jq -r '
(.gpu_assignment.services.llama_server.parallelism.tensor_split // []) as $ts |
if ($ts | length) > 0 then $ts | map(tostring) | join(",")
else ""
end' 2>/dev/null || echo "")
whisper_uuid=$(echo "$assignment_json" | jq -r '.gpu_assignment.services.whisper.gpus[0]? // ""' 2>/dev/null || echo "")
comfyui_uuid=$(echo "$assignment_json" | jq -r '.gpu_assignment.services.comfyui.gpus[0]? // ""' 2>/dev/null || echo "")
embeddings_uuid=$(echo "$assignment_json" | jq -r '.gpu_assignment.services.embeddings.gpus[0]? // ""' 2>/dev/null || echo "")
# base64-encode assignment JSON for .env storage (cross-platform)
local assignment_b64
if [[ "$(uname)" == "Linux" ]]; then
assignment_b64=$(echo "$assignment_json" | jq -c '.' | base64 -w0)
else
assignment_b64=$(echo "$assignment_json" | jq -c '.' | base64)
fi
# Write to .env
log "Updating .env..."
[[ -n "$llama_uuids" ]] && _env_set "LLAMA_SERVER_GPU_UUIDS" "$llama_uuids"
_env_set "LLAMA_ARG_SPLIT_MODE" "$split_mode"
[[ -n "$tensor_split" ]] && _env_set "LLAMA_ARG_TENSOR_SPLIT" "$tensor_split"
[[ -n "$whisper_uuid" ]] && _env_set "WHISPER_GPU_UUID" "$whisper_uuid"
[[ -n "$comfyui_uuid" ]] && _env_set "COMFYUI_GPU_UUID" "$comfyui_uuid"
[[ -n "$embeddings_uuid" ]] && _env_set "EMBEDDINGS_GPU_UUID" "$embeddings_uuid"
_env_set "GPU_ASSIGNMENT_JSON_B64" "$assignment_b64"
# AMD-specific: write render node, ROCR_VISIBLE_DEVICES, gfx overrides
if [[ "$backend" == "amd" ]]; then
# Extract llama GPU indices from assignment by matching UUIDs to topology
local llama_idx_csv=""
while IFS= read -r _uuid; do
[[ -z "$_uuid" ]] && continue
local _idx
_idx=$(echo "$topo_json" | jq -r --arg u "$_uuid" '.gpus[] | select(.uuid == $u) | .index')
llama_idx_csv="${llama_idx_csv:+$llama_idx_csv,}${_idx}"
done < <(echo "$assignment_json" | jq -r '.gpu_assignment.services.llama_server.gpus[]' 2>/dev/null)
[[ -n "$llama_idx_csv" ]] && _env_set "LLAMA_SERVER_GPU_INDICES" "$llama_idx_csv"
_env_set "ROCR_VISIBLE_DEVICES" "$llama_idx_csv"
_gpu_reassign_update_amd_arch_env "$topo_json" "$llama_idx_csv"
# Write GPU index for auxiliary services (whisper, comfyui, embeddings)
_amd_svc_env() {
local svc_prefix="$1" svc_uuid="$2"
[[ -z "$svc_uuid" ]] && return
local _si
_si=$(echo "$topo_json" | jq -r --arg u "$svc_uuid" '.gpus[] | select(.uuid == $u) | .index')
[[ -n "$_si" && "$_si" != "null" ]] && _env_set "${svc_prefix}_GPU_INDEX" "$_si"
}
_amd_svc_env "WHISPER" "$whisper_uuid"
_amd_svc_env "COMFYUI" "$comfyui_uuid"
_amd_svc_env "EMBEDDINGS" "$embeddings_uuid"
# Detect and persist GID values for AMD container group_add
local video_gid render_gid
video_gid=$(getent group video 2>/dev/null | cut -d: -f3) || video_gid="44"
render_gid=$(getent group render 2>/dev/null | cut -d: -f3) || render_gid="992"
_env_set "VIDEO_GID" "$video_gid"
_env_set "RENDER_GID" "$render_gid"
fi
success "GPU assignment updated (strategy: ${strategy})"
echo ""
echo -n " Apply changes now? [Y/n] "
read -r _yn
case "${_yn:-y}" in
y|Y|"")
local _flags_str _flags=()
_flags_str=$(get_compose_flags)
read -ra _flags <<< "$_flags_str"
cd "$INSTALL_DIR"
log "Recreating all containers with new GPU assignment..."
docker compose "${_flags[@]}" down --remove-orphans
docker compose "${_flags[@]}" up -d
success "All services recreated with new GPU assignment"
;;
*) echo " Run 'ods restart' when ready." ;;
esac
}
cmd_gpu() {
local subcommand="${1:-status}"
shift 2>/dev/null || true
case "$subcommand" in
status|s) _gpu_status "$@" ;;
topology|topo|t) _gpu_topology "$@" ;;
assignment|assign|a) _gpu_assignment "$@" ;;
validate|v) _gpu_validate "$@" ;;
reassign) _gpu_reassign "$@" ;;
help|--help|-h)
echo "Usage: ods gpu [subcommand]"
echo ""
echo "Subcommands:"
echo " status Per-GPU metrics table with VRAM, util, temp, power"
echo " topology Show GPU interconnect topology (NVLink, PCIe, NUMA)"
echo " assignment Show current service→GPU mapping from .env"
echo " validate Check GPU config is consistent with live system"
echo " reassign Re-run GPU assignment algorithm and update .env"
echo ""
echo "Options for reassign:"
echo " --auto Run automatic assignment without interactive prompts"
echo " --dry-run Show proposed assignment without writing .env"
echo " --manual Manually specify GPU assignment interactively"
;;
*) error "Unknown gpu subcommand: '${subcommand}'. Run 'ods gpu help'" ;;
esac
}
cmd_repair() {
check_install
cd "$INSTALL_DIR"
load_env
sr_load
local target="${1:-voice}"
case "$target" in
hermes-workers|slash-workers)
local script="$INSTALL_DIR/scripts/prune-hermes-slash-workers.sh"
[[ -x "$script" ]] || error "Hermes slash worker repair script not found or not executable: $script"
"$script" --force
;;
voice|stt|tts)
local flags_str
flags_str=$(get_compose_flags)
local -a flags
read -ra flags <<< "$flags_str"
local services
services=$(docker compose "${flags[@]}" config --services 2>/dev/null || true)
local missing=()
grep -qx "whisper" <<< "$services" || missing+=("whisper")
grep -qx "tts" <<< "$services" || missing+=("tts")
if (( ${#missing[@]} > 0 )); then
log_error "Voice services are not in this compose stack: ${missing[*]}"
echo " Enable voice, or add the whisper/tts extension compose files, then retry."
return 1
fi
_compose_run_with_summary "Starting voice services" "${flags[@]}" up -d whisper tts
local model port url tts_port tts_url
model=$(_env_get_raw AUDIO_STT_MODEL)
[[ -z "$model" ]] && model="Systran/faster-whisper-base"
port=$(_env_get_raw WHISPER_PORT)
[[ -z "$port" ]] && port="9000"
url="http://127.0.0.1:${port}"
tts_port=$(_env_get_raw TTS_PORT)
[[ -z "$tts_port" ]] && tts_port="8880"
tts_url="http://127.0.0.1:${tts_port}"
local ready=false
for _i in $(seq 1 45); do
if curl -sf --max-time 2 "${url}/health" >/dev/null 2>&1; then
ready=true
break
fi
sleep 2
done
if ! $ready; then
log_error "Whisper STT is still not responding. Check: ods logs whisper"
return 1
fi
cmd_stt download "$model"
local tts_ready=false
for _i in $(seq 1 45); do
if curl -sf --max-time 2 "${tts_url}/health" >/dev/null 2>&1; then
tts_ready=true
break
fi
sleep 2
done
if ! $tts_ready; then
log_error "Kokoro TTS is still not responding. Check: ods logs tts"
return 1
fi
success "Voice repair complete"
;;
*)
log "Usage: ods repair [voice|hermes-workers]"
log_error "Unknown repair target: $target"
return 1
;;
esac
}
#=============================================================================
# Templates
#=============================================================================
_template_list() {
local templates_dir="$INSTALL_DIR/templates"
if [[ ! -d "$templates_dir" ]]; then
warn "No templates directory found at $templates_dir"
return 0
fi
# First pass: collect template rows and compute max ID width so the
# column fits IDs like "personal-knowledge-base" (23) without overflow.
local -a rows=()
local max_id_len=2 # at minimum, wide enough for the "ID" header
for f in "$templates_dir"/*.yaml "$templates_dir"/*.yml; do
[[ -f "$f" ]] || continue
local info
info=$(python3 - "$f" <<'PYEOF'
import yaml, sys
with open(sys.argv[1]) as fh:
d = yaml.safe_load(fh)
if not isinstance(d, dict) or d.get("schema_version") != "ods.templates.v1":
sys.exit(0)
t = d.get("template", {})
tid = t.get("id", "")
name = t.get("name", "")
tier = t.get("tier_minimum", "-")
svcs = ", ".join(t.get("services", []))
if tid:
print(f"{tid}\t{name}\t{tier}\t{svcs}")
PYEOF
) || continue
[[ -z "$info" ]] && continue
rows+=("$info")
local this_id="${info%%$'\t'*}"
(( ${#this_id} > max_id_len )) && max_id_len=${#this_id}
done
if [[ ${#rows[@]} -eq 0 ]]; then
log "No templates found."
return 0
fi
# Second pass: emit header, full-width separator, and rows using the
# dynamic ID column width. `hr` produces separators that span each
# column exactly, replacing the old hard-coded "----" dashes that
# left visible gaps under %-Ns padding.
printf "${CYAN}%-*s %-30s %-6s %s${NC}\n" "$max_id_len" "ID" "NAME" "TIER" "SERVICES"
printf "%-*s %-30s %-6s %s\n" \
"$max_id_len" "$(hr "$max_id_len")" "$(hr 30)" "$(hr 6)" "$(hr 8)"
local row tid tname ttier tsvcs
for row in "${rows[@]}"; do
IFS=$'\t' read -r tid tname ttier tsvcs <<< "$row"
printf "%-*s %-30s %-6s %s\n" "$max_id_len" "$tid" "$tname" "$ttier" "$tsvcs"
done
}
_template_preview() {
local template_id="${1:-}"
[[ -z "$template_id" ]] && { log "Usage: ods template preview <template-id>"; exit 1; }
local templates_dir="$INSTALL_DIR/templates"
local tmpl_file=""
for f in "$templates_dir"/*.yaml "$templates_dir"/*.yml; do
[[ -f "$f" ]] || continue
local tid
tid=$(python3 - "$f" <<'PYEOF'
import yaml, sys
with open(sys.argv[1]) as fh:
d = yaml.safe_load(fh)
if not isinstance(d, dict) or d.get("schema_version") != "ods.templates.v1":
sys.exit(0)
t = d.get("template", {})
print(t.get("id", ""))
PYEOF
) || continue
if [[ "$tid" == "$template_id" ]]; then
tmpl_file="$f"
break
fi
done
[[ -z "$tmpl_file" ]] && error "Template not found: $template_id"
python3 - "$tmpl_file" "$INSTALL_DIR" <<'PYEOF'
import yaml, sys
from pathlib import Path
with open(sys.argv[1]) as fh:
d = yaml.safe_load(fh)
t = d.get("template", {})
install_dir = Path(sys.argv[2])
print(f"Template: {t.get('name', t.get('id', ''))}")
print(f"Description: {t.get('description', '-')}")
if t.get("estimated_disk_gb"):
print(f"Estimated disk: ~{t['estimated_disk_gb']}GB")
print()
services = t.get("services", [])
ext_dir = install_dir / "extensions" / "services"
user_ext_dir = install_dir / "data" / "user-extensions"
to_enable = []
already = []
for svc in services:
cf = ext_dir / svc / "compose.yaml"
ucf = user_ext_dir / svc / "compose.yaml"
if cf.exists() or ucf.exists():
already.append(svc)
else:
to_enable.append(svc)
if already:
print(f"Already enabled: {', '.join(already)}")
if to_enable:
print(f"Will enable: {', '.join(to_enable)}")
if not to_enable:
print("Nothing to change — all services already enabled.")
PYEOF
}
_template_apply() {
local template_id="${1:-}"
[[ -z "$template_id" ]] && { log "Usage: ods template apply <template-id>"; exit 1; }
local templates_dir="$INSTALL_DIR/templates"
local services
services=$(python3 - "$templates_dir" "$template_id" <<'PYEOF'
import yaml, sys
from pathlib import Path
templates_dir = Path(sys.argv[1])
target_id = sys.argv[2]
for f in sorted(templates_dir.glob("*.yaml")) + sorted(templates_dir.glob("*.yml")):
with open(f) as fh:
d = yaml.safe_load(fh)
if not isinstance(d, dict) or d.get("schema_version") != "ods.templates.v1":
continue
t = d.get("template", {})
if t.get("id") == target_id:
print(" ".join(t.get("services", [])))
sys.exit(0)
print("", file=sys.stderr)
sys.exit(1)
PYEOF
) || error "Template not found: $template_id"
[[ -z "$services" ]] && { warn "Template has no services."; return 0; }
log "Applying template: $template_id"
local svc
for svc in $services; do
# Skip core services (no compose toggle needed)
local cat="${SERVICE_CATEGORIES[$svc]:-optional}"
[[ "$cat" == "core" ]] && continue
local cf="$INSTALL_DIR/extensions/services/$svc/compose.yaml"
local ucf="$INSTALL_DIR/data/user-extensions/$svc/compose.yaml"
if [[ -f "$cf" || -f "$ucf" ]]; then
log " $svc — already enabled"
continue
fi
log " Enabling $svc..."
( cmd_enable "$svc" ) || warn " Failed to enable $svc (continuing)"
done
success "Template applied: $template_id"
}
cmd_template() {
check_install
sr_load
load_env 2>/dev/null || true
local subcmd="${1:-list}"
shift || true
case "$subcmd" in
list) _template_list ;;
preview) _template_preview "$@" ;;
apply) _template_apply "$@" ;;
*) log "Usage: ods template [list|preview|apply] [template-id]"; exit 1 ;;
esac
}
cmd_help() {
sr_load
cat << EOF
${BLUE}ODS CLI v${VERSION}${NC}
Usage: ods <command> [options]
${CYAN}Commands:${NC}
gpu [status|topology|assignment|validate|reassign]
Inspect and manage multi-GPU configuration
status [--json] Show service health and GPU status (--json = machine-readable)
status-json Alias for 'status --json' (kept for back-compat)
list [--json] List all services and their status (--json = machine-readable)
enable <service> Enable an extension service
disable <service> Disable an extension service
purge <service> Permanently delete service data
preset <action> Save/load/list/delete/export/import presets
mode [local|cloud|hybrid]
Switch between local/cloud/hybrid modes
model [current|list|swap]
View or change the local LLM model tier
stt [current|status|download] [MODEL]
View Whisper STT model, check cache, or trigger download
backup [options] Create a backup of user data and config
backup verify <id> Verify checksum integrity for a backup
restore [backup_id] Restore from a backup
rollback Rollback to pre-update state (after failed update)
logs <service> Tail logs for a service
restart [service] [--rebuild-images]
Restart services (all if no service specified)
start [service] [--rebuild-images]
Start services
stop [service] Stop services
update [--force] [--rebuild-images]
Pull latest images and restart (--force skips version-compat confirmation;
--rebuild-images rebuilds locally-built images so contributor edits deploy)
shell <service> Open shell in container
config [show|edit|validate]
View, edit, or validate configuration
chat "<message>" Quick chat with the LLM
benchmark Run a quick performance test
doctor [report|--json] Run diagnostics (--json writes JSON to stdout)
repair|fix [voice|hermes-workers]
Repair voice services or prune leaked Hermes slash workers
template [action] Apply pre-built service templates (list|preview|apply)
audit [extensions] Audit extension manifests and compose contracts
agent [action] Manage ods host agent (status|start|stop|restart|logs)
help Show this help
${CYAN}Preset Commands:${NC}
preset save <name> Snapshot current config (env, mode, extensions)
preset load <name> Restore a saved preset
preset list Show all saved presets
preset delete <name> Delete a saved preset
preset export <name> <file.tar.gz>
Export preset to shareable archive
preset import <file.tar.gz>
Import preset from archive
${CYAN}Mode Commands:${NC}
mode Show current mode
mode local Switch to local mode (llama-server)
mode cloud Switch to cloud mode (LiteLLM + API keys)
mode hybrid Switch to hybrid mode (local + cloud fallback)
${CYAN}Model Commands:${NC}
model current Show current model
model list List available tiers
model swap <tier> Switch to a different model tier
${CYAN}Service aliases:${NC}
EOF
# Dynamic alias listing from registry
for sid in "${SERVICE_IDS[@]}"; do
local aliases=""
# Collect aliases for this service
for alias in "${!SERVICE_ALIASES[@]}"; do
if [[ "${SERVICE_ALIASES[$alias]}" == "$sid" && "$alias" != "$sid" ]]; then
[[ -n "$aliases" ]] && aliases="$aliases, "
aliases="$aliases$alias"
fi
done
if [[ -n "$aliases" ]]; then
printf " %-24s%s\n" "$sid" "also: $aliases"
fi
done
cat << EOF
${CYAN}Examples:${NC}
ods gpu status # Per-GPU metrics (VRAM, util, temp, power)
ods gpu topology # Show GPU interconnect topology
ods gpu assignment # Show service→GPU mapping
ods gpu validate # Validate GPU configuration
ods gpu reassign --auto # Auto-reassign GPUs after hardware change
ods status # Check all services
ods status-json # JSON summary (mode/tier/model + services)
ods list # See all available services
ods enable n8n # Enable the n8n extension
ods disable whisper # Disable Whisper STT
ods mode local # Switch to local mode
ods preset save my-setup # Snapshot your config
ods preset load my-setup # Restore it later
ods preset export my-setup my-setup.tar.gz
# Export preset for sharing
ods preset import shared.tar.gz
# Import preset from file
ods update --dry-run # Preview changes without applying
ods backup # Create a backup
ods backup -c # Create compressed backup
ods backup -l # List all backups
ods backup verify <id> # Verify backup integrity
ods restore # Interactive restore
ods restore 20260309-120000 # Restore specific backup
ods rollback # Rollback after failed update
ods logs llm # Watch llama-server logs (via alias)
ods restart stt # Restart Whisper (via alias)
ods chat "What is 2+2?" # Quick LLM test
ods config edit # Edit .env file
ods template list # List available templates
ods template preview creative-studio
# Preview what a template will change
ods template apply chat-playground
# Apply a template (enables services)
ods audit # Audit every extension contract
ods audit --json whisper # Audit one service as JSON
ods agent status # Check host agent health
ods agent restart # Restart host agent
ods repair voice # Start voice services and cache STT model
ods repair hermes-workers # Prune leaked Hermes slash_worker children
${CYAN}Environment:${NC}
ODS_HOME Installation directory (default: ~/ods)
EOF
}
#=============================================================================
# Main
#=============================================================================
case "${1:-help}" in
gpu|g) shift; cmd_gpu "$@" ;;
status|s) shift; cmd_status "$@" ;;
status-json) cmd_status_json ;;
list|ls) shift; cmd_list "$@" ;;
enable) shift; cmd_enable "$@" ;;
disable) shift; cmd_disable "$@" ;;
purge) shift; cmd_purge "$@" ;;
preset|p) shift; cmd_preset "$@" ;;
mode|m) shift; cmd_mode "$@" ;;
model) shift; cmd_model "$@" ;;
stt) shift; cmd_stt "$@" ;;
backup) shift; cmd_backup "$@" ;;
restore) shift; cmd_restore "$@" ;;
rollback) cmd_rollback ;;
logs|log|l) shift; cmd_logs "$@" ;;
restart|r) shift; cmd_restart "$@" ;;
repair|fix) shift; cmd_repair "$@" ;;
start) shift; cmd_start "$@" ;;
stop) shift; cmd_stop "$@" ;;
update|u) shift; cmd_update "$@" ;;
shell|sh) shift; cmd_shell "$@" ;;
config|cfg) shift; cmd_config "$@" ;;
chat|c) shift; cmd_chat "$@" ;;
benchmark|bench|b) cmd_benchmark ;;
doctor|diag|d) shift; cmd_doctor "$@" ;;
audit) shift; cmd_audit "$@" ;;
template|tmpl) shift; cmd_template "$@" ;;
agent) shift; cmd_agent "$@" ;;
help|h|--help|-h) cmd_help ;;
version|v|--version|-v) echo "ods-cli v${VERSION}" ;;
*) error "Unknown command: $1. Run 'ods help' for usage." ;;
esac