文件预览

openclaw-cloud-backup.sh

查看 Cloud Backup [S3 R2 B2 ..] 技能包中的文件内容。

文件内容

scripts/openclaw-cloud-backup.sh

#!/usr/bin/env bash

set -euo pipefail

SCRIPT_NAME="$(basename "$0")"
CONFIG_FILE="${OPENCLAW_BACKUP_CONFIG:-$HOME/.openclaw-cloud-backup.conf}"
OPENCLAW_CONFIG="${OPENCLAW_CONFIG:-$HOME/.openclaw/openclaw.json}"
LOCK_DIR="${TMPDIR:-/tmp}/openclaw-cloud-backup.lock"
SKILL_CONFIG_PATH="skills.entries.cloud-backup"

COLOR_RED=""
COLOR_GREEN=""
COLOR_YELLOW=""
COLOR_BLUE=""
COLOR_RESET=""

INCLUDE_PATHS=()
REMOTE_ARCHIVE_KEYS=()

init_colors() {
  if [ -t 1 ]; then
    COLOR_RED="$(printf '\033[0;31m')"
    COLOR_GREEN="$(printf '\033[0;32m')"
    COLOR_YELLOW="$(printf '\033[1;33m')"
    COLOR_BLUE="$(printf '\033[0;34m')"
    COLOR_RESET="$(printf '\033[0m')"
  fi
}

log_info() {
  printf "%s[INFO]%s %s\n" "$COLOR_GREEN" "$COLOR_RESET" "$*"
}

log_warn() {
  printf "%s[WARN]%s %s\n" "$COLOR_YELLOW" "$COLOR_RESET" "$*"
}

log_error() {
  printf "%s[ERROR]%s %s\n" "$COLOR_RED" "$COLOR_RESET" "$*" >&2
}

usage() {
  cat <<EOF
OpenClaw Cloud Backup

Usage:
  $SCRIPT_NAME backup [full|skills|settings]
  $SCRIPT_NAME list
  $SCRIPT_NAME restore <backup-name> [--dry-run] [--yes]
  $SCRIPT_NAME cleanup
  $SCRIPT_NAME status
  $SCRIPT_NAME setup
  $SCRIPT_NAME help

Environment:
  OPENCLAW_BACKUP_CONFIG  Override local config path (default: ~/.openclaw-cloud-backup.conf)
  OPENCLAW_CONFIG         Override OpenClaw config path (default: ~/.openclaw/openclaw.json)

Secrets are read from (in priority order):
  1. Environment variables (AWS_ACCESS_KEY_ID, etc.)
  2. OpenClaw config: skills.entries.cloud-backup.*
  3. Local config file (legacy/fallback)

Examples:
  $SCRIPT_NAME setup
  $SCRIPT_NAME backup full
  $SCRIPT_NAME list
  $SCRIPT_NAME restore openclaw_full_20260217_030001_host.tar.gz --dry-run
  $SCRIPT_NAME cleanup
EOF
}

normalize_bool() {
  case "${1:-}" in
    1|[Tt][Rr][Uu][Ee]|[Yy]|[Yy][Ee][Ss]|[Oo][Nn])
      printf "true\n"
      ;;
    *)
      printf "false\n"
      ;;
  esac
}

sanitize_int_or_default() {
  local value="${1:-}"
  local fallback="${2:-0}"
  case "$value" in
    ''|*[!0-9]*)
      printf "%s\n" "$fallback"
      ;;
    *)
      printf "%s\n" "$value"
      ;;
  esac
}

require_bin() {
  local name="$1"
  if ! command -v "$name" >/dev/null 2>&1; then
    log_error "Missing required binary: $name"
    exit 1
  fi
}

release_lock() {
  rm -rf "$LOCK_DIR"
}

acquire_lock() {
  if ! mkdir "$LOCK_DIR" 2>/dev/null; then
    log_error "Another backup process appears to be running (lock: $LOCK_DIR)"
    exit 1
  fi
  trap release_lock EXIT INT TERM
}

# Read a value from OpenClaw config using jq
# Returns empty string if not found or jq unavailable
read_openclaw_config() {
  local key="$1"

  if ! command -v jq >/dev/null 2>&1; then
    printf ""
    return 0
  fi

  if [ ! -f "$OPENCLAW_CONFIG" ]; then
    printf ""
    return 0
  fi

  jq -r ".skills.entries[\"cloud-backup\"].${key} // empty" "$OPENCLAW_CONFIG" 2>/dev/null || printf ""
}

load_config() {
  # Load local config file first (lowest priority for secrets)
  if [ -f "$CONFIG_FILE" ]; then
    # shellcheck disable=SC1090
    . "$CONFIG_FILE"
  fi

  # Non-secret settings (from config file, with defaults)
  SOURCE_ROOT="${SOURCE_ROOT:-$HOME/.openclaw}"
  LOCAL_BACKUP_DIR="${LOCAL_BACKUP_DIR:-$HOME/openclaw-cloud-backups}"
  TMP_DIR="${TMP_DIR:-$HOME/.openclaw-cloud-backup/tmp}"
  PREFIX="${PREFIX:-openclaw-backups/$(hostname -s 2>/dev/null || hostname)/}"
  UPLOAD="$(normalize_bool "${UPLOAD:-true}")"
  ENCRYPT="$(normalize_bool "${ENCRYPT:-false}")"
  RETENTION_COUNT="$(sanitize_int_or_default "${RETENTION_COUNT:-10}" "10")"
  RETENTION_DAYS="$(sanitize_int_or_default "${RETENTION_DAYS:-30}" "30")"
  GPG_PASSPHRASE_FILE="${GPG_PASSPHRASE_FILE:-}"

  # Secret settings: env > openclaw config > config file
  # Bucket
  if [ -z "${BUCKET:-}" ]; then
    BUCKET="$(read_openclaw_config "bucket")"
  fi
  BUCKET="${BUCKET:-}"

  # Region
  if [ -z "${REGION:-}" ]; then
    REGION="$(read_openclaw_config "region")"
  fi
  REGION="${REGION:-us-east-1}"

  # Endpoint
  if [ -z "${ENDPOINT:-}" ]; then
    ENDPOINT="$(read_openclaw_config "endpoint")"
  fi
  ENDPOINT="${ENDPOINT:-}"

  # AWS credentials: env vars take precedence, then openclaw config
  if [ -z "${AWS_ACCESS_KEY_ID:-}" ]; then
    AWS_ACCESS_KEY_ID="$(read_openclaw_config "awsAccessKeyId")"
  fi

  if [ -z "${AWS_SECRET_ACCESS_KEY:-}" ]; then
    AWS_SECRET_ACCESS_KEY="$(read_openclaw_config "awsSecretAccessKey")"
  fi

  if [ -z "${AWS_SESSION_TOKEN:-}" ]; then
    AWS_SESSION_TOKEN="$(read_openclaw_config "awsSessionToken")"
  fi

  if [ -z "${AWS_PROFILE:-}" ]; then
    AWS_PROFILE="$(read_openclaw_config "awsProfile")"
  fi
  AWS_PROFILE="${AWS_PROFILE:-}"

  # GPG passphrase (for encryption)
  if [ -z "${GPG_PASSPHRASE:-}" ]; then
    GPG_PASSPHRASE="$(read_openclaw_config "gpgPassphrase")"
  fi
  GPG_PASSPHRASE="${GPG_PASSPHRASE:-}"

  # Normalize prefix
  PREFIX="${PREFIX#/}"
  if [ -n "$PREFIX" ]; then
    case "$PREFIX" in
      */) ;;
      *) PREFIX="${PREFIX}/" ;;
    esac
  fi

  mkdir -p "$LOCAL_BACKUP_DIR" "$TMP_DIR"
}

export_aws_env_if_present() {
  if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
    export AWS_ACCESS_KEY_ID
  fi
  if [ -n "${AWS_SECRET_ACCESS_KEY:-}" ]; then
    export AWS_SECRET_ACCESS_KEY
  fi
  if [ -n "${AWS_SESSION_TOKEN:-}" ]; then
    export AWS_SESSION_TOKEN
  fi
}

aws_cli() {
  local -a cmd
  cmd=(aws)

  if [ -n "$AWS_PROFILE" ]; then
    cmd+=(--profile "$AWS_PROFILE")
  fi
  if [ -n "$REGION" ]; then
    cmd+=(--region "$REGION")
  fi
  if [ -n "$ENDPOINT" ]; then
    cmd+=(--endpoint-url "$ENDPOINT")
  fi

  cmd+=("$@")
  "${cmd[@]}"
}

require_cloud_config() {
  if [ -z "$BUCKET" ]; then
    log_error "BUCKET is not configured."
    log_error "Set it in OpenClaw config: skills.entries.cloud-backup.bucket"
    log_error "Or run: $SCRIPT_NAME setup"
    exit 1
  fi
}

checksum_create() {
  local file_path="$1"
  local file_dir file_name
  file_dir="$(dirname "$file_path")"
  file_name="$(basename "$file_path")"

  if command -v sha256sum >/dev/null 2>&1; then
    (cd "$file_dir" && sha256sum "$file_name" > "${file_name}.sha256")
  elif command -v shasum >/dev/null 2>&1; then
    (cd "$file_dir" && shasum -a 256 "$file_name" > "${file_name}.sha256")
  else
    log_error "Need sha256sum or shasum for checksum operations."
    exit 1
  fi
}

checksum_verify() {
  local file_path="$1"
  local file_dir file_name
  local checksum_file="${file_path}.sha256"

  if [ ! -f "$checksum_file" ]; then
    log_error "Checksum file missing: $checksum_file"
    exit 1
  fi

  file_dir="$(dirname "$file_path")"
  file_name="$(basename "$file_path")"

  if command -v sha256sum >/dev/null 2>&1; then
    (cd "$file_dir" && sha256sum -c "${file_name}.sha256" >/dev/null)
  elif command -v shasum >/dev/null 2>&1; then
    (cd "$file_dir" && shasum -a 256 -c "${file_name}.sha256" >/dev/null)
  else
    log_error "Need sha256sum or shasum for checksum verification."
    exit 1
  fi
}

validate_tar_paths() {
  local archive="$1"
  local bad_entries

  bad_entries="$(tar -tzf "$archive" 2>/dev/null | grep -E '^/|/\.\.(/|$)|^\.\.(\/|$)' || true)"

  if [ -n "$bad_entries" ]; then
    log_error "Archive contains unsafe paths (absolute or path traversal):"
    printf "%s\n" "$bad_entries" >&2
    exit 1
  fi
}

encrypt_file() {
  local input_file="$1"
  local output_file="${input_file}.gpg"

  if [ -n "$GPG_PASSPHRASE_FILE" ]; then
    gpg --batch --yes --pinentry-mode loopback \
      --passphrase-file "$GPG_PASSPHRASE_FILE" \
      --symmetric --cipher-algo AES256 \
      -o "$output_file" "$input_file"
  elif [ -n "$GPG_PASSPHRASE" ]; then
    gpg --batch --yes --pinentry-mode loopback \
      --passphrase "$GPG_PASSPHRASE" \
      --symmetric --cipher-algo AES256 \
      -o "$output_file" "$input_file"
  else
    gpg --symmetric --cipher-algo AES256 -o "$output_file" "$input_file"
  fi

  printf "%s\n" "$output_file"
}

decrypt_file() {
  local input_file="$1"
  local output_file="${input_file%.gpg}"

  if [ -n "$GPG_PASSPHRASE_FILE" ]; then
    gpg --batch --yes --pinentry-mode loopback \
      --passphrase-file "$GPG_PASSPHRASE_FILE" \
      -o "$output_file" -d "$input_file"
  elif [ -n "$GPG_PASSPHRASE" ]; then
    gpg --batch --yes --pinentry-mode loopback \
      --passphrase "$GPG_PASSPHRASE" \
      -o "$output_file" -d "$input_file"
  else
    gpg -o "$output_file" -d "$input_file"
  fi

  printf "%s\n" "$output_file"
}

build_include_paths() {
  local mode="$1"
  local candidates=()
  local rel

  INCLUDE_PATHS=()

  case "$mode" in
    full)
      candidates=(
        "openclaw.json"
        "settings.json"
        "settings.local.json"
        "projects.json"
        "skills"
        "commands"
        "mcp"
        "contexts"
        "templates"
        "modules"
        "workspace"
      )
      ;;
    skills)
      candidates=("skills" "commands")
      ;;
    settings)
      candidates=("openclaw.json" "settings.json" "settings.local.json" "projects.json" "mcp")
      ;;
    *)
      log_error "Invalid backup mode: $mode"
      exit 1
      ;;
  esac

  for rel in "${candidates[@]}"; do
    if [ -e "$SOURCE_ROOT/$rel" ]; then
      INCLUDE_PATHS+=("$rel")
    fi
  done

  if [ "${#INCLUDE_PATHS[@]}" -eq 0 ]; then
    log_error "No files found to back up under $SOURCE_ROOT for mode '$mode'."
    exit 1
  fi
}

create_archive() {
  local mode="$1"
  local timestamp host archive_name archive_path

  timestamp="$(date +%Y%m%d_%H%M%S)"
  host="$(hostname -s 2>/dev/null || hostname)"
  host="$(printf "%s" "$host" | tr -c '[:alnum:]._-' '_')"

  archive_name="openclaw_${mode}_${timestamp}_${host}.tar.gz"
  archive_path="$LOCAL_BACKUP_DIR/$archive_name"

  tar -czf "$archive_path" -C "$SOURCE_ROOT" "${INCLUDE_PATHS[@]}"
  printf "%s\n" "$archive_path"
}

upload_artifact() {
  local artifact_path="$1"
  local object_name object_key

  object_name="$(basename "$artifact_path")"
  object_key="${PREFIX}${object_name}"

  export_aws_env_if_present
  aws_cli s3 cp "$artifact_path" "s3://$BUCKET/$object_key"
  aws_cli s3 cp "${artifact_path}.sha256" "s3://$BUCKET/$object_key.sha256"
}

cmd_backup() {
  local mode="${1:-full}"
  local archive_path payload_path

  case "$mode" in
    full|skills|settings) ;;
    *)
      log_error "backup mode must be one of: full, skills, settings"
      exit 1
      ;;
  esac

  acquire_lock
  require_bin tar

  if [ ! -d "$SOURCE_ROOT" ]; then
    log_error "SOURCE_ROOT does not exist: $SOURCE_ROOT"
    exit 1
  fi

  build_include_paths "$mode"
  log_info "Creating $mode backup from $SOURCE_ROOT"
  for path in "${INCLUDE_PATHS[@]}"; do
    printf "  - %s\n" "$path"
  done

  archive_path="$(create_archive "$mode")"
  payload_path="$archive_path"

  if [ "$ENCRYPT" = "true" ]; then
    require_bin gpg
    log_info "Encrypting archive with GPG"
    payload_path="$(encrypt_file "$archive_path")"
  fi

  checksum_create "$payload_path"

  if [ "$UPLOAD" = "true" ]; then
    require_bin aws
    require_cloud_config
    log_info "Uploading backup to s3://$BUCKET/$PREFIX"
    upload_artifact "$payload_path"
    log_info "Upload complete"
  else
    log_warn "UPLOAD=false, cloud upload skipped."
  fi

  log_info "Backup created: $payload_path"
  log_info "Checksum: ${payload_path}.sha256"
}

cmd_list() {
  require_bin aws
  require_cloud_config
  export_aws_env_if_present

  log_info "Listing backups under s3://$BUCKET/$PREFIX"
  aws_cli s3 ls "s3://$BUCKET/$PREFIX" --recursive
}

extract_timestamp_from_key() {
  local key="$1"
  local base
  base="$(basename "$key")"

  printf "%s\n" "$base" | sed -n 's/.*_\([0-9]\{8\}_[0-9]\{6\}\)_.*/\1/p'
}

is_timestamp_older_than_days() {
  local timestamp="$1"
  local days="$2"

  if ! command -v python3 >/dev/null 2>&1; then
    printf "0\n"
    return 0
  fi

  python3 - "$timestamp" "$days" <<'PY'
from datetime import datetime, timedelta
import sys

stamp = sys.argv[1]
days = int(sys.argv[2])

try:
    dt = datetime.strptime(stamp, "%Y%m%d_%H%M%S")
except ValueError:
    print("0")
    raise SystemExit(0)

cutoff = datetime.now() - timedelta(days=days)
print("1" if dt < cutoff else "0")
PY
}

delete_remote_archive_and_checksum() {
  local key="$1"
  aws_cli s3 rm "s3://$BUCKET/$key"
  aws_cli s3 rm "s3://$BUCKET/$key.sha256" >/dev/null 2>&1 || true
}

fetch_remote_archive_keys() {
  local list_file key
  REMOTE_ARCHIVE_KEYS=()

  list_file="$TMP_DIR/remote-listing-$$.txt"
  aws_cli s3 ls "s3://$BUCKET/$PREFIX" --recursive > "$list_file"

  while IFS= read -r line; do
    key="$(printf "%s\n" "$line" | awk '{print $4}')"
    if [ -z "$key" ]; then
      continue
    fi

    case "$key" in
      *.tar.gz|*.tar.gz.gpg)
        REMOTE_ARCHIVE_KEYS+=("$key")
        ;;
      *)
        ;;
    esac
  done < "$list_file"

  rm -f "$list_file"

  if [ "${#REMOTE_ARCHIVE_KEYS[@]}" -gt 0 ]; then
    IFS=$'\n' REMOTE_ARCHIVE_KEYS=($(printf "%s\n" "${REMOTE_ARCHIVE_KEYS[@]}" | sort))
    unset IFS
  fi
}

cmd_cleanup() {
  local total to_delete i key ts older deleted_count start_index

  acquire_lock
  require_bin aws
  require_cloud_config
  export_aws_env_if_present

  fetch_remote_archive_keys
  total="${#REMOTE_ARCHIVE_KEYS[@]}"
  deleted_count=0
  start_index=0

  if [ "$total" -eq 0 ]; then
    log_info "No remote archives found to clean up."
    return 0
  fi

  log_info "Found $total remote archive(s)."

  if [ "$total" -gt "$RETENTION_COUNT" ]; then
    to_delete=$((total - RETENTION_COUNT))
    log_info "Deleting $to_delete archive(s) by RETENTION_COUNT=$RETENTION_COUNT"
    i=0
    while [ "$i" -lt "$to_delete" ]; do
      key="${REMOTE_ARCHIVE_KEYS[$i]}"
      delete_remote_archive_and_checksum "$key"
      deleted_count=$((deleted_count + 1))
      i=$((i + 1))
    done
    start_index="$to_delete"
  fi

  if [ "$RETENTION_DAYS" -gt 0 ]; then
    if ! command -v python3 >/dev/null 2>&1; then
      log_warn "python3 not found; skipping RETENTION_DAYS cleanup."
    else
      log_info "Applying RETENTION_DAYS=$RETENTION_DAYS"
      i="$start_index"
      while [ "$i" -lt "${#REMOTE_ARCHIVE_KEYS[@]}" ]; do
        key="${REMOTE_ARCHIVE_KEYS[$i]}"
        ts="$(extract_timestamp_from_key "$key")"
        if [ -z "$ts" ]; then
          i=$((i + 1))
          continue
        fi
        older="$(is_timestamp_older_than_days "$ts" "$RETENTION_DAYS")"
        if [ "$older" = "1" ]; then
          delete_remote_archive_and_checksum "$key"
          deleted_count=$((deleted_count + 1))
        fi
        i=$((i + 1))
      done
    fi
  fi

  log_info "Cleanup complete. Deleted $deleted_count remote artifact set(s)."
}

cmd_restore() {
  local backup_name="$1"
  local dry_run="${2:-false}"
  local assume_yes="${3:-false}"
  local key download_dir local_artifact local_extract_source answer

  acquire_lock
  require_bin aws
  require_bin tar
  require_cloud_config
  export_aws_env_if_present

  if [ -z "$backup_name" ]; then
    log_error "restore requires a backup name."
    exit 1
  fi

  if [ "${backup_name#*/}" = "$backup_name" ]; then
    key="${PREFIX}${backup_name}"
  else
    key="$backup_name"
  fi

  download_dir="$TMP_DIR/restore-$(date +%Y%m%d_%H%M%S)"
  mkdir -p "$download_dir"

  local_artifact="$download_dir/$(basename "$key")"
  log_info "Downloading s3://$BUCKET/$key"
  aws_cli s3 cp "s3://$BUCKET/$key" "$local_artifact"
  aws_cli s3 cp "s3://$BUCKET/$key.sha256" "${local_artifact}.sha256"

  log_info "Verifying checksum"
  checksum_verify "$local_artifact"

  local_extract_source="$local_artifact"
  case "$local_artifact" in
    *.gpg)
      require_bin gpg
      log_info "Decrypting backup artifact"
      local_extract_source="$(decrypt_file "$local_artifact")"
      ;;
    *)
      ;;
  esac

  log_info "Validating archive paths"
  validate_tar_paths "$local_extract_source"

  if [ "$dry_run" = "true" ]; then
    log_info "Dry-run mode: listing archive contents"
    tar -tzf "$local_extract_source"
    return 0
  fi

  if [ "$assume_yes" != "true" ]; then
    if [ -t 0 ]; then
      printf "%sThis will overwrite files in %s. Continue? (y/N): %s" "$COLOR_YELLOW" "$SOURCE_ROOT" "$COLOR_RESET"
      read -r answer
      case "$answer" in
        [Yy]|[Yy][Ee][Ss]) ;;
        *)
          log_warn "Restore cancelled."
          return 0
          ;;
      esac
    else
      log_error "Non-interactive restore requires --yes"
      exit 1
    fi
  fi

  mkdir -p "$SOURCE_ROOT"
  tar -xzf "$local_extract_source" -C "$SOURCE_ROOT" --no-same-owner --no-same-permissions
  log_info "Restore complete into $SOURCE_ROOT"
}

cmd_status() {
  local missing=0
  local creds_source="none"

  printf "%sOpenClaw Cloud Backup status%s\n\n" "$COLOR_BLUE" "$COLOR_RESET"

  printf "%sConfig sources:%s\n" "$COLOR_BLUE" "$COLOR_RESET"
  printf "  OpenClaw config: %s\n" "$OPENCLAW_CONFIG"
  if [ -f "$OPENCLAW_CONFIG" ]; then
    printf "    (exists)\n"
  else
    printf "    (not found)\n"
  fi
  printf "  Local config: %s\n" "$CONFIG_FILE"
  if [ -f "$CONFIG_FILE" ]; then
    printf "    (exists)\n"
  else
    printf "    (not found)\n"
  fi

  printf "\n%sCloud settings:%s\n" "$COLOR_BLUE" "$COLOR_RESET"
  printf "  BUCKET: %s\n" "${BUCKET:-<not set>}"
  printf "  REGION: %s\n" "$REGION"
  printf "  ENDPOINT: %s\n" "${ENDPOINT:-<aws-default>}"
  printf "  PREFIX: %s\n" "$PREFIX"

  printf "\n%sCredentials:%s\n" "$COLOR_BLUE" "$COLOR_RESET"
  if [ -n "${AWS_PROFILE:-}" ]; then
    printf "  Using AWS_PROFILE: %s\n" "$AWS_PROFILE"
    creds_source="profile"
  elif [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
    printf "  Using access key: %s...%s\n" "${AWS_ACCESS_KEY_ID:0:4}" "${AWS_ACCESS_KEY_ID: -4}"
    creds_source="keys"
  else
    printf "  No credentials configured\n"
  fi

  printf "\n%sLocal settings:%s\n" "$COLOR_BLUE" "$COLOR_RESET"
  printf "  SOURCE_ROOT: %s\n" "$SOURCE_ROOT"
  printf "  LOCAL_BACKUP_DIR: %s\n" "$LOCAL_BACKUP_DIR"
  printf "  UPLOAD: %s\n" "$UPLOAD"
  printf "  ENCRYPT: %s\n" "$ENCRYPT"
  printf "  RETENTION_COUNT: %s\n" "$RETENTION_COUNT"
  printf "  RETENTION_DAYS: %s\n" "$RETENTION_DAYS"

  printf "\n%sDependencies:%s\n" "$COLOR_BLUE" "$COLOR_RESET"
  for bin_name in bash tar jq; do
    if command -v "$bin_name" >/dev/null 2>&1; then
      printf "  %-8s: found\n" "$bin_name"
    else
      printf "  %-8s: missing\n" "$bin_name"
      missing=1
    fi
  done

  # aws is optional for local-only backups
  if command -v aws >/dev/null 2>&1; then
    printf "  %-8s: found\n" "aws"
  else
    if [ "$UPLOAD" = "true" ]; then
      printf "  %-8s: missing (required for UPLOAD=true)\n" "aws"
      missing=1
    else
      printf "  %-8s: not found (optional, needed for cloud sync)\n" "aws"
    fi
  fi

  if [ "$ENCRYPT" = "true" ]; then
    if command -v gpg >/dev/null 2>&1; then
      printf "  %-8s: found (required by ENCRYPT=true)\n" "gpg"
    else
      printf "  %-8s: missing (required by ENCRYPT=true)\n" "gpg"
      missing=1
    fi
  fi

  if ! command -v jq >/dev/null 2>&1; then
    printf "\n%s[WARN]%s jq not found. Cannot read secrets from OpenClaw config.\n" "$COLOR_YELLOW" "$COLOR_RESET"
    printf "       Install jq or use environment variables for credentials.\n"
  fi

  if [ "$missing" -eq 1 ]; then
    printf "\n%s[WARN]%s One or more required binaries are missing.\n" "$COLOR_YELLOW" "$COLOR_RESET"
  fi

  if [ "$UPLOAD" = "true" ]; then
    if [ -z "$BUCKET" ]; then
      printf "\n%s[WARN]%s BUCKET not configured. Run: %s setup\n" "$COLOR_YELLOW" "$COLOR_RESET" "$SCRIPT_NAME"
    elif [ "$creds_source" = "none" ]; then
      printf "\n%s[WARN]%s No credentials configured. Run: %s setup\n" "$COLOR_YELLOW" "$COLOR_RESET" "$SCRIPT_NAME"
    fi
  else
    printf "\n%s[INFO]%s UPLOAD=false. Local backups only (no cloud sync).\n" "$COLOR_BLUE" "$COLOR_RESET"
  fi
}

cmd_setup() {
  local test_result

  printf "%sOpenClaw Cloud Backup Setup%s\n\n" "$COLOR_BLUE" "$COLOR_RESET"

  if ! command -v jq >/dev/null 2>&1; then
    log_error "jq is required for setup. Install it first:"
    printf "  macOS:  brew install jq\n"
    printf "  Debian: sudo apt install jq\n"
    printf "  Arch:   sudo pacman -S jq\n"
    exit 1
  fi

  printf "This skill stores secrets in OpenClaw config:\n"
  printf "  %s\n" "$OPENCLAW_CONFIG"
  printf "  Path: %s.*\n\n" "$SKILL_CONFIG_PATH"

  printf "Required settings:\n"
  printf "  • bucket          - S3 bucket name\n"
  printf "  • region          - AWS region (default: us-east-1)\n"
  printf "  • awsAccessKeyId  - Access key ID\n"
  printf "  • awsSecretAccessKey - Secret access key\n"
  printf "\nOptional settings:\n"
  printf "  • endpoint        - Custom endpoint for non-AWS providers\n"
  printf "  • awsProfile      - Use named AWS profile instead of keys\n"
  printf "  • gpgPassphrase   - For client-side encryption\n"

  printf "\n%sTo configure, ask your OpenClaw agent:%s\n" "$COLOR_GREEN" "$COLOR_RESET"
  printf "  \"Set up cloud-backup with bucket X and these credentials...\"\n"
  printf "\nOr manually patch config:\n"
  printf "  openclaw config patch 'skills.entries.cloud-backup.bucket=\"my-bucket\"'\n"
  printf "  openclaw config patch 'skills.entries.cloud-backup.awsAccessKeyId=\"AKIA...\"'\n"
  printf "  openclaw config patch 'skills.entries.cloud-backup.awsSecretAccessKey=\"...\"'\n"

  # Check current config
  printf "\n%sCurrent configuration:%s\n" "$COLOR_BLUE" "$COLOR_RESET"
  if [ -n "$BUCKET" ]; then
    printf "  bucket: %s\n" "$BUCKET"
  else
    printf "  bucket: <not set>\n"
  fi
  if [ -n "$REGION" ]; then
    printf "  region: %s\n" "$REGION"
  fi
  if [ -n "$ENDPOINT" ]; then
    printf "  endpoint: %s\n" "$ENDPOINT"
  fi
  if [ -n "${AWS_ACCESS_KEY_ID:-}" ]; then
    printf "  credentials: configured (key: %s...)\n" "${AWS_ACCESS_KEY_ID:0:4}"
  elif [ -n "${AWS_PROFILE:-}" ]; then
    printf "  credentials: using profile '%s'\n" "$AWS_PROFILE"
  else
    printf "  credentials: <not set>\n"
  fi

  # Test connection if configured
  if [ -n "$BUCKET" ] && { [ -n "${AWS_ACCESS_KEY_ID:-}" ] || [ -n "${AWS_PROFILE:-}" ]; }; then
    printf "\n%sTesting connection...%s\n" "$COLOR_BLUE" "$COLOR_RESET"
    export_aws_env_if_present
    if aws_cli s3 ls "s3://$BUCKET/" --max-items 1 >/dev/null 2>&1; then
      printf "%s✓ Successfully connected to bucket%s\n" "$COLOR_GREEN" "$COLOR_RESET"
      printf "\nYou're ready to run:\n"
      printf "  %s backup full\n" "$SCRIPT_NAME"
    else
      printf "%s✗ Failed to connect to bucket%s\n" "$COLOR_RED" "$COLOR_RESET"
      printf "Check credentials and bucket permissions.\n"
      printf "See: references/provider-setup.md\n"
    fi
  fi
}

main() {
  local command="${1:-help}"

  init_colors
  load_config

  case "$command" in
    backup)
      shift
      cmd_backup "${1:-full}"
      ;;
    list)
      cmd_list
      ;;
    restore)
      shift
      if [ "$#" -lt 1 ]; then
        log_error "restore requires <backup-name>"
        usage
        exit 1
      fi
      backup_name="$1"
      shift

      dry_run_flag="false"
      yes_flag="false"
      while [ "$#" -gt 0 ]; do
        case "$1" in
          --dry-run)
            dry_run_flag="true"
            ;;
          --yes)
            yes_flag="true"
            ;;
          *)
            log_error "Unknown restore option: $1"
            exit 1
            ;;
        esac
        shift
      done

      cmd_restore "$backup_name" "$dry_run_flag" "$yes_flag"
      ;;
    cleanup)
      cmd_cleanup
      ;;
    status)
      cmd_status
      ;;
    setup)
      cmd_setup
      ;;
    help|-h|--help)
      usage
      ;;
    *)
      log_error "Unknown command: $command"
      usage
      exit 1
      ;;
  esac
}

main "$@"