Files
gniza4linux/bin/gniza
shuki 0eb1aeb7e6 Split CLI help into short (--help) and full (--full-help/--docs)
Short help shows a compact command list. Full help includes detailed
usage for every command with descriptions and config paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 05:20:35 +02:00

704 lines
28 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# ── Resolve GNIZA_DIR ────────────────────────────────────────
GNIZA_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
export GNIZA_DIR
# ── Source libraries in dependency order ─────────────────────
source "$GNIZA_DIR/lib/constants.sh"
source "$GNIZA_DIR/lib/utils.sh"
detect_mode
source "$GNIZA_DIR/lib/logging.sh"
source "$GNIZA_DIR/lib/config.sh"
source "$GNIZA_DIR/lib/locking.sh"
source "$GNIZA_DIR/lib/targets.sh"
source "$GNIZA_DIR/lib/remotes.sh"
source "$GNIZA_DIR/lib/backup.sh"
source "$GNIZA_DIR/lib/mysql.sh"
source "$GNIZA_DIR/lib/restore.sh"
source "$GNIZA_DIR/lib/retention.sh"
source "$GNIZA_DIR/lib/schedule.sh"
source "$GNIZA_DIR/lib/notify.sh"
source "$GNIZA_DIR/lib/ssh.sh"
source "$GNIZA_DIR/lib/rclone.sh"
source "$GNIZA_DIR/lib/snapshot.sh"
source "$GNIZA_DIR/lib/transfer.sh"
source "$GNIZA_DIR/lib/snaplog.sh"
source "$GNIZA_DIR/lib/source.sh"
# ── Help ─────────────────────────────────────────────────────
show_help() {
cat <<EOF
gniza v${GNIZA4LINUX_VERSION} - Linux Backup Manager
Usage: gniza [OPTIONS] [COMMAND]
Options:
--cli Force CLI mode (no TUI)
--debug Enable debug logging
--config=FILE Override config file path
--help Show this help (use --full-help for detailed docs)
--full-help Show full documentation
--version Show version
Commands:
targets Manage sources (list, add, delete, show)
remotes Manage destinations (list, add, delete, show, test)
backup Run backup
restore Restore from a snapshot
snapshots List or browse snapshots
retention Enforce retention policies
schedule Manage cron schedules (install, show, remove)
logs View backup logs
web Web dashboard (start, install-service, status)
uninstall Uninstall gniza
Run 'gniza --full-help' for detailed usage of all commands.
If no command is given, the TUI is launched.
EOF
}
show_full_help() {
cat <<EOF
gniza v${GNIZA4LINUX_VERSION} - Linux Backup Manager
Usage: gniza [OPTIONS] [COMMAND]
Options:
--cli Force CLI mode (no TUI)
--debug Enable debug logging
--config=FILE Override config file path
--help Show short help
--full-help Show this full documentation
--version Show version
Sources (what to back up):
targets list List all configured sources
targets add --name=NAME --folders=PATHS
Create a new source
targets delete --name=NAME Delete a source
targets show --name=NAME Show full source configuration
Destinations (where to store backups):
remotes list List all configured destinations
remotes add --name=NAME Create a new destination (edit config manually or via TUI)
remotes delete --name=NAME Delete a destination
remotes show --name=NAME Show full destination configuration
remotes test --name=NAME Validate destination connectivity
remotes disk-info-short --name=NAME Show destination disk usage (used/total/free)
Operations:
backup [--target=NAME] [--remote=NAME] [--all]
Run backup. If no source is specified, all sources are backed up.
Use --target=a,b,c to back up multiple sources.
Use --remote=NAME to target a specific destination.
restore --target=NAME --snapshot=TS [--remote=NAME] [--dest=DIR]
[--folder=PATH] [--skip-mysql]
Restore from a snapshot. Use --dest for custom restore location.
Use --folder to restore a single directory from the snapshot.
Use --skip-mysql to skip MySQL database restore.
retention [--target=NAME] [--remote=NAME] [--all]
Enforce snapshot retention policies. Deletes snapshots beyond
the configured retention count (oldest first, pinned preserved).
Snapshots:
snapshots list [--target=NAME] [--remote=NAME]
List available snapshots. Filter by source and/or destination.
snapshots browse --target=NAME --snapshot=TS [--remote=NAME]
List all files in a specific snapshot.
Scheduling:
schedule install Install cron entries for all active schedules
schedule show Show current gniza cron entries
schedule remove Remove all gniza cron entries from crontab
Logs & Info:
logs List all log files
logs --last Show the most recent backup log
logs --last --tail=N Show last N lines of the most recent log
version Show version
Web Dashboard:
web start [--port=PORT] [--host=HOST]
Start the web dashboard (default: port 2323, host 0.0.0.0).
Serves the full TUI in a browser with HTTP Basic Auth.
web install-service Install as a systemd service (auto-starts on boot)
web remove-service Stop and remove the systemd service
web status Show service status
System:
uninstall Run the uninstall script
Configuration:
Sources: \$CONFIG_DIR/targets.d/<name>.conf
Destinations: \$CONFIG_DIR/remotes.d/<name>.conf
Schedules: \$CONFIG_DIR/schedules.d/<name>.conf
Settings: \$CONFIG_DIR/gniza.conf
Root mode: /etc/gniza/
User mode: ~/.config/gniza/
If no command is given, the TUI is launched.
EOF
}
# ── CLI argument parsing ─────────────────────────────────────
FORCE_CLI=false
CONFIG_FILE=""
SUBCOMMAND=""
declare -a SUBCMD_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--cli)
FORCE_CLI=true
shift
;;
--debug)
export GNIZA4LINUX_DEBUG="true"
shift
;;
--config=*)
CONFIG_FILE="${1#--config=}"
shift
;;
--help|-h)
show_help
exit 0
;;
--full-help|--docs)
show_full_help
exit 0
;;
--version)
echo "gniza v${GNIZA4LINUX_VERSION}"
exit 0
;;
-*)
# Unknown flags before subcommand are errors
die "Unknown option: $1 (see --help)"
;;
*)
SUBCOMMAND="$1"
shift
SUBCMD_ARGS=("$@")
break
;;
esac
done
# ── Initialise ───────────────────────────────────────────────
ensure_dirs
# Load config if it exists
if [[ -n "$CONFIG_FILE" ]]; then
load_config "$CONFIG_FILE"
elif [[ -f "$CONFIG_DIR/gniza.conf" ]]; then
load_config
fi
# Only create log files for operations that produce meaningful output
case "${SUBCOMMAND:-}" in
backup|restore|retention|scheduled-run) init_logging ;;
esac
# ── Parse subcommand flags helper ────────────────────────────
_parse_flag() {
local flag="$1"
shift
local arg
for arg in "$@"; do
if [[ "$arg" == "${flag}="* ]]; then
echo "${arg#"${flag}="}"
return 0
fi
done
return 1
}
_has_flag() {
local flag="$1"
shift
local arg
for arg in "$@"; do
[[ "$arg" == "$flag" ]] && return 0
done
return 1
}
# ── CLI subcommand dispatch ──────────────────────────────────
run_cli() {
case "${SUBCOMMAND:-}" in
backup)
local target="" remote="" all=false
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true
trap release_all_target_locks EXIT
if [[ "$all" == "true" || -z "$target" ]]; then
backup_all_targets "$remote"
elif [[ "$target" == *,* ]]; then
# Comma-separated targets (e.g. from schedule --target=web,db)
local IFS=','
local names
read -ra names <<< "$target"
local rc=0
for t in "${names[@]}"; do
t="${t#"${t%%[![:space:]]*}"}"
t="${t%"${t##*[![:space:]]}"}"
[[ -z "$t" ]] && continue
backup_target "$t" "$remote" || rc=$?
done
exit "$rc"
else
backup_target "$target" "$remote"
fi
;;
restore)
local target="" snapshot="" remote="" dest="" folder=""
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
snapshot=$(_parse_flag "--snapshot" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
dest=$(_parse_flag "--dest" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
folder=$(_parse_flag "--folder" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
# Check for --skip-mysql flag
local skip_mysql=""
local arg
for arg in "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}"; do
[[ "$arg" == "--skip-mysql" ]] && skip_mysql="yes"
done
[[ -n "$skip_mysql" ]] && export SKIP_MYSQL_RESTORE="yes"
[[ -z "$target" ]] && die "restore requires --target=NAME"
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No destinations configured"
fi
if [[ -n "$folder" ]]; then
restore_folder "$target" "$folder" "${snapshot:-latest}" "$remote" "$dest"
else
restore_target "$target" "${snapshot:-latest}" "$remote" "$dest"
fi
;;
targets)
local action="${SUBCMD_ARGS[0]:-list}"
local name="" folders=""
name=$(_parse_flag "--name" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
folders=$(_parse_flag "--folders" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
case "$action" in
list)
local targets; targets=$(list_targets)
if [[ -z "$targets" ]]; then
echo "No sources configured."
else
echo "$targets"
fi
;;
add)
[[ -z "$name" ]] && die "sources add requires --name=NAME"
[[ -z "$folders" ]] && die "sources add requires --folders=PATHS"
create_target "$name" "$folders"
;;
delete)
[[ -z "$name" ]] && die "sources delete requires --name=NAME"
delete_target "$name"
;;
show)
[[ -z "$name" ]] && die "sources show requires --name=NAME"
load_target "$name"
echo "=== Source: $TARGET_NAME ==="
echo ""
echo "Name: $TARGET_NAME"
echo "Enabled: $TARGET_ENABLED"
echo "Folders: $TARGET_FOLDERS"
echo "Exclude: ${TARGET_EXCLUDE:-(none)}"
echo "Include: ${TARGET_INCLUDE:-(none)}"
echo "Destination: ${TARGET_REMOTE:-(all)}"
echo "Retention: ${TARGET_RETENTION:-(default)}"
echo "Pre-hook: ${TARGET_PRE_HOOK:-(none)}"
echo "Post-hook: ${TARGET_POST_HOOK:-(none)}"
echo ""
echo "--- Source Type ---"
echo "Type: $TARGET_SOURCE_TYPE"
case "$TARGET_SOURCE_TYPE" in
ssh)
echo "Host: $TARGET_SOURCE_HOST"
echo "Port: $TARGET_SOURCE_PORT"
echo "User: $TARGET_SOURCE_USER"
echo "Auth method: $TARGET_SOURCE_AUTH_METHOD"
if [[ "$TARGET_SOURCE_AUTH_METHOD" == "key" ]]; then
echo "Key: ${TARGET_SOURCE_KEY:-(default)}"
else
echo "Password: ****"
fi
;;
s3)
echo "Bucket: $TARGET_SOURCE_S3_BUCKET"
echo "Region: $TARGET_SOURCE_S3_REGION"
echo "Endpoint: ${TARGET_SOURCE_S3_ENDPOINT:-(default)}"
echo "Access Key: ${TARGET_SOURCE_S3_ACCESS_KEY_ID:+****}"
echo "Secret Key: ${TARGET_SOURCE_S3_SECRET_ACCESS_KEY:+****}"
;;
gdrive)
echo "SA File: $TARGET_SOURCE_GDRIVE_SERVICE_ACCOUNT_FILE"
echo "Root Folder ID: ${TARGET_SOURCE_GDRIVE_ROOT_FOLDER_ID:-(root)}"
;;
esac
if [[ "$TARGET_MYSQL_ENABLED" == "yes" ]]; then
echo ""
echo "--- MySQL ---"
echo "Enabled: yes"
echo "Mode: $TARGET_MYSQL_MODE"
echo "Databases: ${TARGET_MYSQL_DATABASES:-(all)}"
echo "Exclude: ${TARGET_MYSQL_EXCLUDE:-(none)}"
echo "User: ${TARGET_MYSQL_USER:-(current)}"
echo "Password: ${TARGET_MYSQL_PASSWORD:+****}"
echo "Host: $TARGET_MYSQL_HOST"
echo "Port: $TARGET_MYSQL_PORT"
echo "Extra opts: $TARGET_MYSQL_EXTRA_OPTS"
fi
;;
*)
die "Unknown sources action: $action (expected list|add|delete|show)"
;;
esac
;;
remotes)
local action="${SUBCMD_ARGS[0]:-list}"
local name=""
name=$(_parse_flag "--name" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
case "$action" in
list)
local remotes; remotes=$(list_remotes)
if [[ -z "$remotes" ]]; then
echo "No destinations configured."
else
echo "$remotes"
fi
;;
add)
[[ -z "$name" ]] && die "destinations add requires --name=NAME"
echo "Use the TUI or manually create $CONFIG_DIR/remotes.d/${name}.conf to configure the destination."
;;
delete)
[[ -z "$name" ]] && die "destinations delete requires --name=NAME"
local conf="$CONFIG_DIR/remotes.d/${name}.conf"
[[ ! -f "$conf" ]] && die "Destination config not found: $conf"
rm -f "$conf"
log_info "Deleted destination config: $conf"
;;
show)
[[ -z "$name" ]] && die "destinations show requires --name=NAME"
load_remote "$name"
echo "=== Destination: $name ==="
echo ""
echo "Type: $REMOTE_TYPE"
case "$REMOTE_TYPE" in
ssh)
echo "Host: $REMOTE_HOST"
echo "Port: $REMOTE_PORT"
echo "User: $REMOTE_USER"
echo "Auth method: $REMOTE_AUTH_METHOD"
if [[ "$REMOTE_AUTH_METHOD" == "key" ]]; then
echo "Key: ${REMOTE_KEY:-(default)}"
else
echo "Password: ****"
fi
echo "Base path: $REMOTE_BASE"
;;
local)
echo "Base path: $REMOTE_BASE"
;;
s3)
echo "Bucket: ${S3_BUCKET:-}"
echo "Region: ${S3_REGION:-}"
echo "Endpoint: ${S3_ENDPOINT:-(default)}"
echo "Access Key: ${S3_ACCESS_KEY_ID:+****}"
echo "Secret Key: ${S3_SECRET_ACCESS_KEY:+****}"
echo "Base path: $REMOTE_BASE"
;;
gdrive)
echo "SA File: ${GDRIVE_SERVICE_ACCOUNT_FILE:-}"
echo "Root Folder ID: ${GDRIVE_ROOT_FOLDER_ID:-(root)}"
echo "Base path: $REMOTE_BASE"
;;
esac
echo "Bandwidth: ${BWLIMIT:-0} KB/s"
echo "Retention: $RETENTION_COUNT snapshots"
;;
test)
[[ -z "$name" ]] && die "destinations test requires --name=NAME"
validate_remote "$name"
echo "Destination '$name' is valid."
;;
disk-info-short)
[[ -z "$name" ]] && die "destinations disk-info-short requires --name=NAME"
load_remote "$name"
remote_disk_info_short
;;
*)
die "Unknown destinations action: $action (expected list|add|delete|show|test|disk-info-short)"
;;
esac
;;
snapshots)
local action="${SUBCMD_ARGS[0]:-list}"
local target="" remote=""
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
case "$action" in
list)
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No destinations configured"
fi
_save_remote_globals
load_remote "$remote" || die "Failed to load destination: $remote"
if [[ -n "$target" ]]; then
list_remote_snapshots "$target"
else
local targets; targets=$(list_targets)
while IFS= read -r t; do
[[ -z "$t" ]] && continue
echo "=== $t ==="
list_remote_snapshots "$t" || true
done <<< "$targets"
fi
_restore_remote_globals
;;
browse)
[[ -z "$target" ]] && die "browse requires --target=NAME"
local snapshot=""
snapshot=$(_parse_flag "--snapshot" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
[[ -z "$snapshot" ]] && die "browse requires --snapshot=TS"
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No destinations configured"
fi
list_snapshot_contents "$target" "$snapshot" "$remote"
;;
*)
die "Unknown snapshots action: $action (expected list|browse)"
;;
esac
;;
retention)
local target="" remote="" all=false
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_has_flag "--all" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && all=true
if [[ -z "$remote" ]]; then
remote=$(list_remotes | head -1)
[[ -z "$remote" ]] && die "No destinations configured"
fi
_save_remote_globals
load_remote "$remote" || die "Failed to load destination: $remote"
if [[ "$all" == "true" || -z "$target" ]]; then
local targets; targets=$(list_targets)
while IFS= read -r t; do
[[ -z "$t" ]] && continue
enforce_retention "$t"
done <<< "$targets"
else
enforce_retention "$target"
fi
_restore_remote_globals
;;
schedule)
local action="${SUBCMD_ARGS[0]:-show}"
case "$action" in
install) install_schedules ;;
show) show_schedules ;;
remove) remove_schedules ;;
*) die "Unknown schedule action: $action (expected install|show|remove)" ;;
esac
;;
logs)
local last=false tail_n=""
_has_flag "--last" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}" && last=true
tail_n=$(_parse_flag "--tail" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
local log_dir="${LOG_DIR}"
if [[ "$last" == "true" ]]; then
local latest; latest=$(ls -t "$log_dir"/gniza-*.log 2>/dev/null | head -1)
if [[ -z "$latest" ]]; then
echo "No log files found."
else
if [[ -n "$tail_n" ]]; then
tail -n "$tail_n" "$latest"
else
cat "$latest"
fi
fi
else
ls -lt "$log_dir"/gniza-*.log 2>/dev/null || echo "No log files found."
fi
;;
scheduled-run)
local sched_name="" target="" remote=""
sched_name=$(_parse_flag "--schedule" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
target=$(_parse_flag "--target" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
remote=$(_parse_flag "--remote" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
[[ -z "$sched_name" ]] && die "scheduled-run requires --schedule=NAME"
trap release_all_target_locks EXIT
local rc=0
if [[ -n "$target" && "$target" == *,* ]]; then
local IFS=','
local names
read -ra names <<< "$target"
for t in "${names[@]}"; do
t="${t#"${t%%[![:space:]]*}"}"
t="${t%"${t##*[![:space:]]}"}"
[[ -z "$t" ]] && continue
backup_target "$t" "$remote" || rc=$?
done
elif [[ -n "$target" ]]; then
backup_target "$target" "$remote" || rc=$?
else
backup_all_targets "$remote" || rc=$?
fi
# Stamp LAST_RUN on success
if (( rc == 0 )); then
local conf="$CONFIG_DIR/schedules.d/${sched_name}.conf"
if [[ -f "$conf" ]]; then
sed -i '/^LAST_RUN=/d' "$conf"
echo "LAST_RUN=\"$(date '+%Y-%m-%d %H:%M')\"" >> "$conf"
fi
fi
exit "$rc"
;;
version)
echo "gniza v${GNIZA4LINUX_VERSION}"
;;
"")
show_help
;;
*)
die "Unknown command: $SUBCOMMAND (see --help)"
;;
esac
}
# ── Mode selection ───────────────────────────────────────────
if [[ "$SUBCOMMAND" == "web" ]]; then
_web_action="${SUBCMD_ARGS[0]:-start}"
case "$_web_action" in
start)
_web_port="" _web_host=""
_web_port=$(_parse_flag "--port" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_web_host=$(_parse_flag "--host" "${SUBCMD_ARGS[@]+"${SUBCMD_ARGS[@]}"}") || true
_web_args=(--web)
[[ -n "$_web_port" ]] && _web_args+=(--port="$_web_port")
[[ -n "$_web_host" ]] && _web_args+=(--host="$_web_host")
PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui "${_web_args[@]}"
;;
install-service)
if [[ $EUID -eq 0 ]]; then
_service_src="$GNIZA_DIR/etc/gniza-web.service"
_service_dst="/etc/systemd/system/gniza-web.service"
if [[ ! -f "$_service_src" ]]; then
die "Service file not found: $_service_src"
fi
cp "$_service_src" "$_service_dst"
systemctl daemon-reload
systemctl enable gniza-web
systemctl restart gniza-web
else
_user_service_dir="$HOME/.config/systemd/user"
mkdir -p "$_user_service_dir"
cat > "$_user_service_dir/gniza-web.service" <<SVCEOF
[Unit]
Description=GNIZA Web Dashboard
After=network.target
[Service]
Type=simple
ExecStart=$(command -v python3) -m tui --web --host 0.0.0.0 --port 2323
WorkingDirectory=$GNIZA_DIR
Environment=GNIZA_DIR=$GNIZA_DIR
Environment=PYTHONPATH=$GNIZA_DIR
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
SVCEOF
systemctl --user daemon-reload
systemctl --user enable gniza-web
systemctl --user restart gniza-web
fi
echo "GNIZA web service installed and started."
echo "Access the dashboard at http://$(hostname -I | awk '{print $1}'):2323"
;;
remove-service)
if [[ $EUID -eq 0 ]]; then
systemctl stop gniza-web 2>/dev/null || true
systemctl disable gniza-web 2>/dev/null || true
rm -f /etc/systemd/system/gniza-web.service
systemctl daemon-reload
else
systemctl --user stop gniza-web 2>/dev/null || true
systemctl --user disable gniza-web 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/gniza-web.service"
systemctl --user daemon-reload 2>/dev/null || true
fi
echo "GNIZA web service removed."
;;
status)
if [[ $EUID -eq 0 ]]; then
systemctl status gniza-web 2>/dev/null || echo "GNIZA web service is not installed."
else
systemctl --user status gniza-web 2>/dev/null || echo "GNIZA web service is not installed."
fi
;;
*) die "Unknown web action: $_web_action (expected start|install-service|remove-service|status)" ;;
esac
elif [[ "$SUBCOMMAND" == "uninstall" ]]; then
_uninstall_script="$GNIZA_DIR/scripts/uninstall.sh"
if [[ -f "$_uninstall_script" ]]; then
exec bash "$_uninstall_script"
else
die "Uninstall script not found: $_uninstall_script"
fi
elif [[ -n "$SUBCOMMAND" ]]; then
# Explicit subcommand: always CLI
run_cli
elif [[ "$FORCE_CLI" == "true" ]]; then
run_cli
elif python3 -c "import textual" 2>/dev/null && [[ -t 1 ]]; then
# Python Textual TUI mode
PYTHONPATH="$GNIZA_DIR:${PYTHONPATH:-}" exec python3 -m tui "$@"
else
# Fallback to CLI help
run_cli
fi