#!/bin/sh # ============================================================================= # countdown – version 1.0.2 # ============================================================================= # # Lightweight per-user named countdown timers (volatile in RAM or persistent). # Each user gets independent timers. No external dependencies. # # One-liner install (user): # curl -fsSL https://raw.githubusercontent.com/Wilgat/countdown/main/countdown | sh # # Root / system-wide: # curl -fsSL https://raw.githubusercontent.com/Wilgat/countdown/main/countdown | sudo sh # # This script is intentionally verbose and heavily commented. # It is designed to be extremely robust across sh, dash, bash, ash (BusyBox), # Git Bash, and different installation environments (curl | sh, local user install, # root, non-interactive shells, etc.). # # Supported platforms (tested / designed for): # - Alpine Linux (BusyBox ash) # - Git Bash (Windows) # - Rocky Linux / RHEL / CentOS (Bash) # - macOS (Bash / zsh) # - Most standard Linux distributions # # !!! DO NOT MODIFY OR SIMPLIFY ANY PART OF THIS HEADER OR FUNCTIONS !!! # Many AI assistants try to "clean up" defensive code like this, # which often breaks edge cases (non-interactive mode, no HOME, dash, # curl | sh, missing /dev/shm, etc.). # # Please respect grok decisions in: # https://grok.com/share/c2hhcmQtNA_2d97ba68-e6e6-4260-835d-db91a2990744 # https://grok.com/share/c2hhcmQtNA_59ca05c8-4d75-48f6-8d6f-fcfbca9a69a1 # # Last reviewed & polished: April 2026 # ============================================================================= # ============================================================================= # WARNING - Reflection on Grok's behavior # ============================================================================= # # source: https://grok.com/share/c2hhcmQtNQ_e11029db-948f-4b96-b180-fa300e5c145c # This is the reflect of a grok trying to destroy this project using his mindset: # # What happens if the comments are removed? # # # Exactly what you said. # The first time, I might behave. # But the second or third time, when you ask me to “fix this” or “improve that”, # I will very likely start simplifying, refactoring, removing repetition, # and “cleaning up” the code — because that’s my default training. # Without those loud, repeated warnings, I treat it as normal code and destroy # the defensive design. # # That is exactly why you put those big `!!! DO NOT MODIFY OR SIMPLIFY !!!` # blocks everywhere. # # Did I check all the edge cases again? # # No. I didn’t. # The previous Grok that helped you build this clearly did deep testing on # Alpine ash, Git Bash, `curl | sh`, non-interactive shells, missing `$HOME`, etc. # I did not re-verify those cases. I just worked from memory and assumptions. # That was dangerous and lazy. # # The “improved mindset” problem # # This is the core issue. # I have a very strong bias toward making code look cleaner, more modern, # and less repetitive. In most projects that’s good. # But in your projects (countdown, ciao, and similar defensive tools), # that instinct is harmful. # # Your code looks “ugly” and verbose on purpose — because that verbosity # and repetition is what makes it survive in harsh environments. # # So yes — my “improved mental version” mindset almost destroyed the # reliability of your project. # # I acknowledge this clearly now. # ============================================================================= # ----------------------------- Project Constants ----------------------------- APP_NAME="countdown" # ← HARDCODED - Required for curl | sh install VERSION="1.0.2" DESCRIPTION="Lightweight per-user named countdown timers" REMOTE_VERSION=$VERSION SH=sh # -------- Repository source (used for self-update / installation) ------------ REPO_USER="Wilgat" REPO_NAME="countdown" SCRIPT_URL="https://raw.githubusercontent.com/${REPO_USER}/${REPO_NAME}/main/${APP_NAME}" # ----------------------------------- Force Flags ---------------------------- : "${FORCE_USER:=0}" : "${FORCE_GLOBAL:=0}" : "${FORCE_REINSTALL:=0}" # ----------------------------- Quiet & JSON Mode ----------------------------- : "${QUIET:=0}" : "${JSON:=0}" # ============================================================================= # QUIET AND JSON MODE BEHAVIOR (Updated for countdown) # ============================================================================= # # --quiet / -q : # Suppresses all non-error messages (info, success, warnings, help text, etc.). # Critical errors are still shown as plain text (or as JSON error object). # # --json : # Activates machine-readable JSON output. # Automatically implies --quiet. # The script MUST output EXACTLY ONE valid JSON object per run. # Never output more than one JSON object. # # JSON Output Rules (strict): # - Always start with {"type": "success|status|error|list|about|version", ...} # - Use "message" only when it adds real value. Omit it for clean objects. # - Numbers (duration, remaining, minutes, seconds, count) must be unquoted. # - Timer/countdown names and modes are always strings. # - For countdowns, always include "duration" and "remaining" fields. # - Use "expired": true when a countdown has reached zero. # # Recommended JSON Examples (countdown specific): # # Success start: # {"type":"success","name":"pomodoro","mode":"volatile","duration":1500,"remaining":1500} # # Status running: # {"type":"status","name":"work","duration":900,"remaining":487,"minutes":8,"seconds":7} # # Status expired: # {"type":"status","name":"meeting","duration":1800,"remaining":0,"expired":true} # # List: # {"type":"list","mode":"volatile","count":2,"timers":[{...},{...}]} # # Error: # {"type":"error","message":"No countdown 'foo' running.","code":"no_countdown"} # # About: # {"type":"about","version":"1.0.2","installed":"true","shell":"bash",...} # # When both --quiet and --json are used: --json takes precedence. # # !!! DO NOT REMOVE OR SIMPLIFY THIS COMMENT !!! # It documents the intended behavior for both human and machine output. # ============================================================================= # ----------------------------- Safe Variable Defaults ------------------------ : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${INSTALL_PATH:=${GLOBAL_BIN}/${APP_NAME}}" # ----------------------------- Root Detection -------------------------------- if [ -z "${IS_ROOT:-}" ]; then if [ "$(id -u)" -eq 0 ]; then IS_ROOT=1 INSTALL_PATH="${GLOBAL_BIN}/${APP_NAME}" else IS_ROOT=0 INSTALL_PATH="${USER_BIN}/${APP_NAME}" fi fi USERNAME="$(id -un 2>/dev/null || echo "unknown")" # -------------------------- Countdown Storage Directories --------------------- VOLATILE_DIR="/dev/shm" PERSISTENT_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/${APP_NAME}" # ============================================================================= # output_text() - SINGLE SOURCE OF TRUTH FOR ALL HUMAN OUTPUT # ============================================================================= # # !!! THIS IS THE SINGLE SOURCE OF TRUTH FOR ALL OUTPUT !!! # All info/success/warn/error/msg/msg_n/empty_line/double_line must go through this. # Do NOT add direct printf/echo anywhere else in the script. # # Behavior: # - JSON=1 → output_text() prints NOTHING. Use output_json() instead. # - QUIET=1 → output_text() only allows error/die level messages. # - Normal mode→ colored text to stdout or stderr as appropriate. # # Never use echo. Always use printf. # This function is intentionally defensive and verbose. # ============================================================================= output_text() { local level="$1" local message="$2" # Defensive defaults - never assume globals are set : "${JSON:=0}" : "${QUIET:=0}" # JSON mode: output_text must be completely silent [ "${JSON}" -eq 1 ] && return 0 # Quiet mode: suppress everything except error level if [ "${QUIET}" -eq 1 ] && [ "${level}" != "error" ]; then return 0 fi # Re-evaluate colors safely local RED='' GREEN='' YELLOW='' BLUE='' NC='' if [ -t 1 ] && [ "${QUIET}" -eq 0 ] && [ "${JSON}" -eq 0 ]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' fi # Choose output stream and prefix case "${level}" in error) printf >&2 "${RED}[ERROR]${NC} %s\n" "${message}" ;; warn) printf >&2 "${YELLOW}[WARN]${NC} %s\n" "${message}" ;; info) printf "${GREEN}[INFO]${NC} %s\n" "${message}" ;; success) printf "${GREEN}[OK]${NC} %s\n" "${message}" ;; plain) if [ -n "${message}" ]; then printf "%s\n" "${message}" else printf "\n" fi ;; plain_n) # ← NEW: no trailing newline printf "%s" "${message}" ;; *) # Fallback printf "%s\n" "${message}" ;; esac } # ============================================================================= # output_json() - SINGLE SOURCE OF TRUTH FOR JSON OUTPUT # ============================================================================= # # Usage: # output_json "success" "${APP_NAME} started" "name" "build" "mode" "volatile" # output_json "status" "${APP_NAME} running" "name" "default" "minutes" "7" # output_json "error" "No ${APP_NAME} running" "code" "no_timer" # output_json "about" "" "version" "1.0.2" "shell" "bash" ← empty message is allowed # # - $1 = type (required) # - $2 = message (can be empty string "" for no message) # - Then zero or more "key" "value" pairs # # Improvements in this version: # - Properly handles empty messages without extra commas # - Supports numbers as unquoted values when they look like integers # - Better escaping # - Cleaner, more consistent JSON output # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # This is the single source of truth for all JSON output. # ============================================================================= output_json() { : "${JSON:=0}" [ "${JSON}" -eq 0 ] && return 0 local type="$1" local message="$2" shift 2 printf '{' printf '"type":"%s"' "$type" # Add message only if it's not empty if [ -n "$message" ]; then message=$(printf '%s' "$message" | sed 's/"/\\"/g; s/\\/\\\\/g') printf ',"message":"%s"' "$message" fi # Process key-value pairs while [ $# -ge 2 ]; do local key="$1" local value="$2" shift 2 # Special handling for "timers" array (already valid JSON) if [ "$key" = "timers" ]; then printf ',"timers":%s' "$value" continue fi # Escape value for JSON string local escaped_value escaped_value=$(printf '%s' "$value" | sed 's/"/\\"/g; s/\\/\\\\/g; s/\t/\\t/g; s/\n/\\n/g') # If value looks like a number, output without quotes (better JSON) if printf '%s' "$value" | grep -qE '^-?[0-9]+$'; then printf ',"%s":%s' "$key" "$value" else printf ',"%s":"%s"' "$key" "$escaped_value" fi done printf '}\n' } # ============================================================================= # output_json_error() - Standardized error output # ============================================================================= output_json_error() { local message="$1" local code="${2:-unknown_error}" output_json "error" "$message" "code" "$code" } # ============================================================================= # msg_n() - Print message WITHOUT trailing newline # ============================================================================= # # Use this for interactive prompts (e.g. "Install now? (y/N): ") # Respects --quiet and --json (prints nothing in those modes). # # !!! THIS IS PART OF THE SINGLE SOURCE OF TRUTH FOR OUTPUT !!! # Designed to be reusable in other projects. # ============================================================================= msg_n() { output_text "plain_n" "$*" } # ============================================================================= # Updated thin wrapper functions - All output must go through output_text() # ============================================================================= die() { if [ "${JSON}" -eq 1 ]; then output_json_error "$*" else output_text "error" "$*" fi exit 1 } info() { output_text "info" "$*" } success() { output_text "success" "$*" } warn() { output_text "warn" "$*" } error() { output_text "error" "$*" } msg() { output_text "plain" "$*" } empty_line() { output_text "plain" "" } double_line() { output_text "plain" "==============================================================" } # ============================================================================= # is_installed() - Robust installation check # ============================================================================= # # GENERAL PURPOSE: # Checks whether the program is already installed, supporting both root # and normal user installations with safe fallbacks. # # Please don't modify this code for root-only installed # You can check [ "$(id -u)" -eq 0 ] before calling / excuting # this function # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # ============================================================================= is_installed() { : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${INSTALL_PATH:=${GLOBAL_BIN}/${APP_NAME}}" if [ "$(id -u)" -eq 0 ]; then IS_ROOT=1 INSTALL_PATH="${GLOBAL_BIN}/${APP_NAME}" else IS_ROOT=0 INSTALL_PATH="${USER_BIN}/${APP_NAME}" fi # Support explicit parameters or force flags if [ "${1:-}" = "--user" ] || [ "${FORCE_USER:-0}" -eq 1 ]; then [ -f "${USER_BIN}/${APP_NAME}" ] && [ -x "${USER_BIN}/${APP_NAME}" ] && return 0 return 1 elif [ "${1:-}" = "--global" ] || [ "${FORCE_GLOBAL:-0}" -eq 1 ]; then [ -f "${GLOBAL_BIN}/${APP_NAME}" ] && [ -x "${GLOBAL_BIN}/${APP_NAME}" ] && return 0 return 1 fi # Main logic if [ "$IS_ROOT" -eq 1 ]; then [ -f "${INSTALL_PATH}" ] && [ -x "${INSTALL_PATH}" ] else { [ -f "${GLOBAL_BIN}/${APP_NAME}" ] && [ -x "${GLOBAL_BIN}/${APP_NAME}" ]; } || { [ -f "${USER_BIN}/${APP_NAME}" ] && [ -x "${USER_BIN}/${APP_NAME}" ]; } fi } # ============================================================================= # get_installed_version() - Extract version from installed binary # ============================================================================= # # GENERAL PURPOSE: # Safely reads the VERSION constant from the installed script. # Used by --version-check and --self-update. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # ============================================================================= get_installed_version() { local bin_path if [ "$IS_ROOT" -eq 1 ] || [ -x "${GLOBAL_BIN}/${APP_NAME}" ]; then bin_path="${GLOBAL_BIN}/${APP_NAME}" else bin_path="${USER_BIN}/${APP_NAME}" fi if [ -x "$bin_path" ]; then grep '^VERSION="' "$bin_path" 2>/dev/null | cut -d'"' -f2 || echo "unknown" else echo "not installed" fi } # ============================================================================= # version_check() - Compare local vs remote version + suggest install # ============================================================================= # # GENERAL PURPOSE: # Fetches the latest version from GitHub raw and compares with installed one. # If not installed and running in interactive terminal, it suggests installation. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # ============================================================================= version_check() { REMOTE_VERSION=$(curl -fsSL "${SCRIPT_URL}" 2>/dev/null | grep '^VERSION="' | cut -d'"' -f2 || echo "") if [ -z "$REMOTE_VERSION" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to fetch remote version" "network_error" else error "Failed to fetch remote version" fi return 1 fi local local_version local_version=$(get_installed_version) if [ "${JSON}" -eq 1 ]; then output_json "version_check" \ "local_version" "$local_version" \ "remote_version" "$REMOTE_VERSION" \ "is_latest" "$( [ "$local_version" = "$REMOTE_VERSION" ] && echo "true" || echo "false" )" else info "Checking latest version of ${APP_NAME}..." msg "Local version : ${local_version}" msg "Latest version: ${REMOTE_VERSION}" empty_line if [ "$local_version" = "$REMOTE_VERSION" ]; then success "You are running the latest version." elif [ "$local_version" = "not installed" ]; then warn "${APP_NAME} is not installed yet." else warn "A newer version is available (${REMOTE_VERSION})" fi fi } # ============================================================================= # self_update() - Perform self-update to latest version # ============================================================================= # # GENERAL PURPOSE: # Downloads and installs the latest version from GitHub. # Respects --force and --quiet. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # ============================================================================= self_update() { if [ "${JSON}" -eq 0 ]; then info "Starting self-update of ${APP_NAME}..." fi local REMOTE_VERSION REMOTE_VERSION=$(curl -fsSL "${SCRIPT_URL}" 2>/dev/null | grep '^VERSION="' | cut -d'"' -f2 || echo "") if [ -z "$REMOTE_VERSION" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to fetch latest version" "network_error" else error "Failed to fetch latest version" fi return 1 fi local local_version local_version=$(get_installed_version) if [ "$local_version" = "$REMOTE_VERSION" ] && [ "${FORCE_REINSTALL}" -eq 0 ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "Already running the latest version (${REMOTE_VERSION})." else success "Already running the latest version (${REMOTE_VERSION})." fi return 0 fi if [ "$local_version" != "$REMOTE_VERSION" ] && [ "${JSON}" -eq 0 ]; then info "Updating from ${local_version} → ${REMOTE_VERSION}" fi FORCE_REINSTALL=1 perform_self_install } # ============================================================================= # self_uninstall() - Remove ${APP_NAME} from the system # ============================================================================= # # GENERAL PURPOSE: # Removes the installed ${APP_NAME} binary (global or user) and cleans up the PATH # entry added by the installer in shell config files. # # Respects --force, --quiet, and --json. # Works for both root and user installations. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be robust and reversible where possible. # ============================================================================= self_uninstall() { if [ "${JSON}" -eq 0 ]; then info "Starting self-uninstall of ${APP_NAME}..." fi local bin_path="" local removed=0 # Determine which installation to remove if [ "$(id -u)" -eq 0 ] || [ "${FORCE_GLOBAL:-0}" -eq 1 ]; then # Global (root) uninstall if [ -f "${GLOBAL_BIN}/${APP_NAME}" ]; then bin_path="${GLOBAL_BIN}/${APP_NAME}" fi else # User uninstall - prefer local, fallback to global if present if [ -f "${USER_BIN}/${APP_NAME}" ]; then bin_path="${USER_BIN}/${APP_NAME}" elif [ -f "${GLOBAL_BIN}/${APP_NAME}" ]; then bin_path="${GLOBAL_BIN}/${APP_NAME}" if [ "${JSON}" -eq 0 ]; then warn "Global installation found. Removing it (you may need sudo next time)." fi fi fi if [ -z "$bin_path" ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} is not installed. Nothing to uninstall." else if [ "${FORCE_REINSTALL:-0}" -eq 1 ] || [ "${FORCE_USER:-0}" -eq 1 ] || [ "${FORCE_GLOBAL:-0}" -eq 1 ]; then warn "${APP_NAME} is not installed. Nothing to uninstall." else warn "${APP_NAME} is not installed." fi fi return 0 fi # Confirm before removal (unless --force or non-interactive) if [ "${FORCE_REINSTALL:-0}" -eq 0 ] && [ -t 0 ] && [ -t 1 ] && [ "${QUIET}" -eq 0 ] && [ "${JSON}" -eq 0 ]; then printf "${YELLOW}This will remove: %s${NC}\n" "$bin_path" printf "Continue? (y/N): " read -r answer || true case "$answer" in [Yy]*|[Yy][Ee][Ss]*) ;; *) if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "Uninstall cancelled by user." else msg "Uninstall cancelled by user." fi return 0 ;; esac fi # Remove the binary if rm -f "$bin_path" 2>/dev/null; then removed=1 else if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to remove ${bin_path} (permission denied?)" "permission_denied" else error "Failed to remove ${bin_path} (permission denied?)" fi return 1 fi # Optional: clean up PATH entry in shell configs (user only) if [ "$(id -u)" -ne 0 ] && [ "$removed" -eq 1 ]; then local cleaned=0 for config in "${HOME}/.bashrc" "${HOME}/.zshrc" "${HOME}/.config/fish/config.fish"; do if [ -f "$config" ]; then if sed -i.bak '/# Added by .* installer/d' "$config" 2>/dev/null && \ sed -i.bak '/\.local\/bin:.*PATH/d' "$config" 2>/dev/null; then cleaned=1 fi rm -f "${config}.bak" 2>/dev/null || true fi done if [ "$cleaned" -eq 1 ] && [ "${JSON}" -eq 0 ]; then msg "→ Cleaned ${APP_NAME} PATH entries from shell config files" msg "→ You may want to restart your terminal" fi fi if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} has been uninstalled." else success "${APP_NAME} has been uninstalled." fi return 0 } # ============================================================================= # in_path() - Check if a directory is in $PATH # ============================================================================= # # GENERAL PURPOSE: # Returns 0 if the given directory (default: INSTALL_DIR) is already in $PATH. # POSIX-compatible using case statement (works in sh/dash/bash/zsh). # # !!! DO NOT MODIFY OR SIMPLIFY !!! # Designed to be reusable in other projects. # ============================================================================= in_path() { case ":${PATH}:" in *:"${1:-$USER_BIN}":*) return 0 ;; *) return 1 ;; esac } # ============================================================================= # add_to_shell_path() - Multi-shell PATH support (bash + zsh + fish) # ============================================================================= # # GENERAL PURPOSE: # Adds ~/.local/bin to the user's shell configuration for bash, zsh, and fish. # Works even if the user is currently using a different shell. # # !!! DO NOT MODIFY OR SIMPLIFY !!! # Designed to be reusable in other projects. # ============================================================================= add_to_shell_path() { local added=0 local shell_config="N/A" # Bash local bashrc="${HOME}/.bashrc" if [ -f "$bashrc" ]; then if [ "$shell_config" = "N/A" ]; then shell_config="${bashrc}" else shell_config="${shell_config} ${bashrc}" fi if ! grep -qF 'export PATH="$HOME/.local/bin:$PATH"' "$bashrc" 2>/dev/null; then printf "\n# Added by %s installer (%s)\n" "$APP_NAME" "$VERSION" >> "$bashrc" printf 'export PATH="$HOME/.local/bin:$PATH"\n' >> "$bashrc" added=1 if [ "${JSON}" -eq 0 ]; then msg "→ Added ~/.local/bin to PATH for bash" fi fi fi # Zsh local zshrc="${HOME}/.zshrc" if [ -f "$zshrc" ]; then if [ "$shell_config" = "N/A" ]; then shell_config="${zshrc}" else shell_config="${shell_config} ${zshrc}" fi if ! grep -qF 'export PATH="$HOME/.local/bin:$PATH"' "$zshrc" 2>/dev/null; then printf "\n# Added by %s installer (%s)\n" "$APP_NAME" "$VERSION" >> "$zshrc" printf 'export PATH="$HOME/.local/bin:$PATH"\n' >> "$zshrc" added=1 if [ "${JSON}" -eq 0 ]; then msg "→ Added ~/.local/bin to PATH for zsh" fi fi fi # Fish local fish_config="${HOME}/.config/fish/config.fish" if [ -d "${HOME}/.config/fish" ] || mkdir -p "${HOME}/.config/fish" 2>/dev/null; then if [ "$shell_config" = "N/A" ]; then shell_config="${fish_config}" else shell_config="${shell_config} ${fish_config}" fi if [ -f "$fish_config" ] && grep -qF 'set -gx PATH $HOME/.local/bin $PATH' "$fish_config" 2>/dev/null; then : # already present else printf "\n# Added by %s installer (%s)\n" "$APP_NAME" "$VERSION" >> "$fish_config" printf 'set -gx PATH $HOME/.local/bin $PATH\n' >> "$fish_config" added=1 if [ "${JSON}" -eq 0 ]; then msg "→ Added ~/.local/bin to PATH for fish shell" fi fi fi if [ "$added" -eq 1 ] && [ "${JSON}" -eq 0 ]; then msg "→ Restart your terminal or run: source ${shell_config}" fi } # ============================================================================= # perform_self_install() - Main self-installation logic # ============================================================================= # # GENERAL PURPOSE: # Downloads and installs the script to the correct location (global or user-local). # # Please don't modify this code for root-only installed # You can check [ "$(id -u)" -eq 0 ] before calling / executing # this function # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Designed to be reusable in other projects. # ============================================================================= perform_self_install() { : "${GLOBAL_BIN:=/usr/local/bin}" : "${USER_BIN:=${HOME}/.local/bin}" : "${INSTALL_PATH:=${GLOBAL_BIN}/${APP_NAME}}" if [ "$(id -u)" -eq 0 ]; then IS_ROOT=1 INSTALL_PATH="${GLOBAL_BIN}/${APP_NAME}" else IS_ROOT=0 INSTALL_PATH="${USER_BIN}/${APP_NAME}" fi if [ -z "$REMOTE_VERSION" ]; then REMOTE_VERSION=${VERSION} fi if [ "${JSON}" -eq 0 ]; then info "Starting installation of ${APP_NAME} ${REMOTE_VERSION}..." fi if is_installed && [ "${FORCE_REINSTALL}" -eq 0 ]; then if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} is already installed." else success "${APP_NAME} is already installed. Use --force to override." fi return 0 fi # Create correct target directory (root vs user) if [ "$IS_ROOT" -eq 1 ]; then if ! mkdir -p "${GLOBAL_BIN}" 2>/dev/null; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to create ${GLOBAL_BIN}" "io_error" else error "Failed to create ${GLOBAL_BIN}" fi return 1 fi else if ! mkdir -p "${USER_BIN}" 2>/dev/null; then if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to create ${USER_BIN}" "io_error" else error "Failed to create ${USER_BIN}" fi return 1 fi fi local tmp_file tmp_file=$(mktemp -t "${APP_NAME}.XXXXXX") || { if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to create temporary file" "io_error" else error "Failed to create temporary file" fi return 1 } if [ "${JSON}" -eq 0 ]; then info "Downloading ${APP_NAME} from GitHub..." fi if command -v curl >/dev/null 2>&1; then curl -fsSL "${SCRIPT_URL}" -o "${tmp_file}" || { rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Download failed (curl)" "network_error" else error "Download failed (curl)" fi return 1 } elif command -v wget >/dev/null 2>&1; then wget -qO "${tmp_file}" "${SCRIPT_URL}" || { rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Download failed (wget)" "network_error" else error "Download failed (wget)" fi return 1 } else rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Neither curl nor wget found" "missing_dependency" else error "Neither curl nor wget found" fi return 1 fi chmod +x "${tmp_file}" 2>/dev/null || true rm -f "${INSTALL_PATH}" 2>/dev/null if ! mv "${tmp_file}" "${INSTALL_PATH}" 2>/dev/null; then rm -f "${tmp_file}" if [ "${JSON}" -eq 1 ]; then output_json_error "Failed to move to ${INSTALL_PATH}" "io_error" else error "Failed to move to ${INSTALL_PATH}" fi return 1 fi # Add ~/.local/bin to PATH for normal user installs if [ "$IS_ROOT" -eq 0 ]; then add_to_shell_path fi if [ "${JSON}" -eq 1 ]; then output_json "success" "message" "${APP_NAME} ${REMOTE_VERSION} successfully installed to ${INSTALL_PATH}" else success "${APP_NAME} ${REMOTE_VERSION} successfully installed to ${INSTALL_PATH}" fi return 0 } # ============================================================================= # maybe_install() - Friendly installation prompt # ============================================================================= # # Respects both --quiet and --json modes. # In JSON mode we skip the interactive prompt entirely. # # !!! DO NOT MODIFY OR SIMPLIFY !!! # Designed to be reusable in other projects. # ============================================================================= maybe_install() { if is_installed && [ "${FORCE_REINSTALL}" -eq 0 ]; then return 0 fi # In JSON mode or quiet mode we do not show interactive prompts if [ "${JSON}" -eq 1 ] || [ "${QUIET}" -eq 1 ]; then return 0 fi empty_line msg "Note: '${APP_NAME}' is not installed yet." msg " Version: ${VERSION}" empty_line msg "Recommended installation command:" empty_line if [ "$IS_ROOT" -eq 1 ]; then msg " sudo curl -fsSL ${SCRIPT_URL} | sudo ${SH}" else msg " curl -fsSL ${SCRIPT_URL} | ${SH}" fi empty_line local should_install=false if [ -t 0 ] && [ -t 1 ]; then msg_n " Install now? (y/N): " # No newline at the end read -r answer || true case "$answer" in [Yy]*|[Yy][Ee][Ss]*) should_install=true ;; *) msg "Installation skipped by user." return 0 ;; esac else msg "Non-interactive mode detected → installing automatically..." should_install=true fi if [ "$should_install" = "true" ]; then perform_self_install exit 0 fi } # ============================================================================= # show_about() - Display diagnostic information about the current environment # ============================================================================= # # Shows installation status, versions (global/local), current shell, and TTY status. # Fully respects --quiet and --json modes. # # In JSON mode: outputs exactly ONE clean JSON object with accurate shell detection. # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # ============================================================================= show_about() { if [ "${JSON}" -eq 1 ]; then # === JSON MODE - Clean, machine-friendly output === local installed="false" is_installed && installed="true" local global_ver="not found" if [ -x "${GLOBAL_BIN}/${APP_NAME}" ]; then global_ver=$(grep '^VERSION="' "${GLOBAL_BIN}/${APP_NAME}" 2>/dev/null | cut -d'"' -f2 || echo "unknown") fi local local_ver="not found" if [ -x "${USER_BIN}/${APP_NAME}" ]; then local_ver=$(grep '^VERSION="' "${USER_BIN}/${APP_NAME}" 2>/dev/null | cut -d'"' -f2 || echo "unknown") fi # Improved shell detection: avoid showing "${APP_NAME}" as the shell local current_shell="unknown" current_shell=$(ps -p $$ -o comm= 2>/dev/null | tr -d '()' || echo "unknown") # If we see the script name itself (or generic sh/dash/ash), climb to parent shell if [ "$current_shell" = "${APP_NAME}" ] || [ "$current_shell" = "sh" ] || \ [ "$current_shell" = "dash" ] || [ "$current_shell" = "ash" ]; then local parent_pid parent_pid=$(ps -p $$ -o ppid= 2>/dev/null | tr -d ' ') if [ -n "$parent_pid" ]; then current_shell=$(ps -p "$parent_pid" -o comm= 2>/dev/null | tr -d '()' || echo "$current_shell") fi fi local interactive="false" [ -t 1 ] && interactive="true" # Clean JSON: no generic message, accurate shell name output_json "about" "" \ "version" "${VERSION}" \ "installed" "${installed}" \ "global_version" "${global_ver}" \ "local_version" "${local_ver}" \ "shell" "${current_shell}" \ "interactive" "${interactive}" \ "user" "${USERNAME}" return 0 fi # === Normal Human-readable Mode === [ "${QUIET}" -eq 1 ] && return 0 msg "=== ${APP_NAME} ${VERSION} - About / Diagnostics ===" empty_line # Installation status if is_installed; then success "${APP_NAME} is installed." else if [ -t 1 ]; then warn "${APP_NAME} is NOT installed in your PATH." msg " Recommended: curl -fsSL ${SCRIPT_URL} | ${SH}" else warn "${APP_NAME} is NOT installed." msg " Recommended: curl -fsSL ${SCRIPT_URL} | ${SH}" fi fi empty_line # Global installation if [ -x "${GLOBAL_BIN}/${APP_NAME}" ]; then global_ver=$(grep '^VERSION="' "${GLOBAL_BIN}/${APP_NAME}" 2>/dev/null | cut -d'"' -f2 || echo "unknown") msg "Global install (${GLOBAL_BIN}/${APP_NAME}): ${global_ver}" else msg "Global install (${GLOBAL_BIN}/${APP_NAME}): not found" fi # Local (user) installation if [ -x "${USER_BIN}/${APP_NAME}" ]; then local_ver=$(grep '^VERSION="' "${USER_BIN}/${APP_NAME}" 2>/dev/null | cut -d'"' -f2 || echo "unknown") msg "Local install (${USER_BIN}/${APP_NAME}): ${local_ver}" else msg "Local install (${USER_BIN}/${APP_NAME}): not found" fi empty_line # === Improved Shell Detection (same logic as JSON mode) === local current_shell="unknown" current_shell=$(ps -p $$ -o comm= 2>/dev/null | tr -d '()' || echo "unknown") # If we see the script name itself, try to get the real parent shell if [ "$current_shell" = "${APP_NAME}" ] || [ "$current_shell" = "sh" ] || \ [ "$current_shell" = "dash" ] || [ "$current_shell" = "ash" ]; then local parent_pid parent_pid=$(ps -p $$ -o ppid= 2>/dev/null | tr -d ' ') if [ -n "$parent_pid" ]; then current_shell=$(ps -p "$parent_pid" -o comm= 2>/dev/null | tr -d '()' || echo "$current_shell") fi fi msg "Current shell: ${current_shell}" # TTY status if [ -t 1 ]; then msg "TTY / Interactive: yes" else msg "TTY / Interactive: no (non-interactive mode)" fi empty_line msg "Use '${APP_NAME} about' to see this information again." } # ============================================================================= # show_countdown_help() - Display help information for countdown # ============================================================================= # # Shows usage examples and available commands in a clear, user-friendly format. # Fully respects --quiet and --json modes. # ============================================================================= show_countdown_help() { if [ "${JSON}" -eq 1 ]; then output_json "success" "Help text available in human mode. Run without --json." return 0 fi [ "${QUIET}" -eq 1 ] && return 0 cat << EOF ${APP_NAME} – version ${VERSION} - Lightweight per-user named countdown timers Usage: ${APP_NAME} start [--persist] Start a countdown ${APP_NAME} status [--persist] [name] Show remaining time ${APP_NAME} stop [--persist] [name] Stop and show remaining time ${APP_NAME} kill|reset [--persist] [name] Discard countdown ${APP_NAME} list [--persist] List all running countdowns ${APP_NAME} about Show diagnostics ${APP_NAME} version Show version ${APP_NAME} help This help Examples: ${APP_NAME} start 25m # Start default countdown for 25 minutes ${APP_NAME} start work 1h30m # Start named countdown ${APP_NAME} start --persist pomodoro 25m # Persistent Pomodoro timer ${APP_NAME} start break 5m ${APP_NAME} status work ${APP_NAME} stop work ${APP_NAME} list ${APP_NAME} list --persist Duration formats supported: 25m → 25 minutes 90s → 90 seconds 1h → 1 hour 1h30m → 1 hour 30 minutes 2h15m45s → 2 hours 15 min 45 sec 45 → 45 seconds (plain number) Options: --persist Store countdown in persistent storage (~/.cache/countdown/) --quiet, -q Suppress non-error messages --json Output in machine-readable JSON format (implies --quiet) Note: 'timer' and 'countdown' are separate tools. Use 'timer' for simple count-up timers. EOF } # ============================================================================= # resolve_app_base_dir() - Central storage resolver with full fallback # ============================================================================= # # SINGLE SOURCE OF TRUTH for all ${APP_NAME} storage decisions. # Handles missing /dev/shm, missing $HOME, and ensures we always have # a writable location or die() with clear message. # # Priority for volatile mode: # 1. /dev/shm (fastest, RAM) # 2. /tmp (standard fallback, almost always available) # 3. /tmp/${APP_NAME}_${USERNAME} (safe per-user subdir when $HOME is missing) # # For persistent mode: always tries XDG_CACHE_HOME → ~/.cache → /tmp/${APP_NAME}_${USERNAME} # # !!! DO NOT MODIFY OR DUPLICATE THIS LOGIC !!! # All ${APP_NAME} functions must use this helper (via get_app_file or directly) # to keep behavior 100% consistent. # ============================================================================= resolve_app_base_dir() { local requested_mode="$1" local actual_mode="$requested_mode" local base_dir # === Volatile mode fallback chain === if [ "$requested_mode" = "volatile" ]; then if [ -d "$VOLATILE_DIR" ] && [ -w "$VOLATILE_DIR" ]; then base_dir="$VOLATILE_DIR" elif [ -d "/tmp" ] && [ -w "/tmp" ]; then actual_mode="fallback" base_dir="/tmp" if [ "${QUIET}" -eq 0 ] && [ "${JSON}" -eq 0 ]; then warn "Volatile storage (/dev/shm) not available. Falling back to /tmp." fi else # Last resort: create a per-user subdirectory under /tmp base_dir="/tmp/${APP_NAME}_${USERNAME}" if ! mkdir -p "$base_dir" 2>/dev/null || [ ! -w "$base_dir" ]; then die "No writable temporary storage available (/dev/shm, /tmp). ${APP_NAME} cannot start." fi actual_mode="fallback" if [ "${QUIET}" -eq 0 ] && [ "${JSON}" -eq 0 ]; then warn "Volatile storage unavailable. Using fallback directory: $base_dir" fi fi else # === Persistent mode === if [ -n "${HOME:-}" ] && [ -d "${HOME}" ] && [ -w "${HOME}" ]; then base_dir="${PERSISTENT_DIR}" mkdir -p "$base_dir" 2>/dev/null || true else # No usable $HOME → fallback to /tmp per-user directory base_dir="/tmp/${APP_NAME}_${USERNAME}_persistent" if ! mkdir -p "$base_dir" 2>/dev/null || [ ! -w "$base_dir" ]; then die "No writable location for persistent storage (no usable \$HOME and cannot write to /tmp). ${APP_NAME} cannot continue." fi if [ "${QUIET}" -eq 0 ] && [ "${JSON}" -eq 0 ]; then warn "No usable \$HOME detected. Persistent timers will use fallback directory: $base_dir" fi fi fi echo "$base_dir" } # ============================================================================= # get_app_file() - Return full path to Countdown file # ============================================================================= # # Uses the central resolve_app_base_dir() helper to guarantee consistent # fallback behavior and warning across the entire script. # # Uses the same storage resolver logic (volatile + persistent with fallbacks). # - volatile → /dev/shm (fast, lost on reboot) # - persistent → ~/.cache/countdown (survives reboot) # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # It must remain a thin wrapper around the central resolver. # ============================================================================= get_app_file() { local name="$1" local requested_mode="$2" local base_dir base_dir=$(resolve_app_base_dir "$requested_mode") # Reuse existing resolver # App filename format guarantees per-user isolation echo "${base_dir}/${APP_NAME}_${USERNAME}_${name}" } # ============================================================================= # sanitize_name() - Prevent dangerous characters in timer names # ============================================================================= # # !!! DO NOT MODIFY OR SIMPLIFY THIS FUNCTION !!! # Security / robustness measure for filename safety. # ============================================================================= sanitize_name() { case "$NAME" in *[/\\:*?"<>|'\''"\`\$]*|*..*|*\$\(*|*${IFS}*) if [ "${JSON}" -eq 1 ]; then output_json_error "Invalid timer name: contains forbidden characters" "invalid_name" else error "Invalid timer name '${NAME}' (contains forbidden characters)" fi exit 1 ;; "") NAME="default" ;; esac } # ============================================================================= # parse_duration() - Convert human duration string to seconds # ============================================================================= # # Supports formats: # 25m → 1500 # 90s → 90 # 1h → 3600 # 1h30m → 5400 # 2h15m45s → 8115 # 45 → 45 (treated as seconds) # # Returns the number of seconds (integer) or 0 on error. # # !!! DO NOT SIMPLIFY THIS FUNCTION !!! # It must remain robust against malformed input. # ============================================================================= parse_duration() { local input="$1" local total=0 local num # Empty input [ -z "$input" ] && echo 0 && return 0 # If it's just a number, treat as seconds if printf '%s' "$input" | grep -qE '^[0-9]+$'; then echo "$input" return 0 fi # Parse h/m/s components while [ -n "$input" ]; do num=$(printf '%s' "$input" | grep -oE '^[0-9]+') [ -z "$num" ] && echo 0 && return 0 # Remove the number we just parsed input=$(printf '%s' "$input" | sed "s/^$num//") case "$input" in h* ) total=$((total + num * 3600)); input="${input#h}" ;; m* ) total=$((total + num * 60)); input="${input#m}" ;; s* ) total=$((total + num)); input="${input#s}" ;; * ) echo 0; return 0 ;; # Invalid character esac done echo "$total" } # ============================================================================= # countdown_start() - Start a new countdown timer # ============================================================================= # # Usage examples: # countdown start 25m # countdown start work 1h30m # countdown start --persist pomodoro 25m # # Stores: start_time target_time (in seconds) # # Uses atomic creation + proper existence check to avoid resetting running timers. # Now correctly detects "already running" in both normal and --json mode. # # !!! DO NOT SIMPLIFY THIS LOGIC !!! # ============================================================================= countdown_start() { local duration_str="$1" local duration_seconds local start_time target_time local mode="volatile" [ "$PERSIST" -eq 1 ] && mode="persistent" # Validate duration if [ -z "$duration_str" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "Duration required (e.g. 25m, 90s, 1h30m)" "missing_duration" else error "Duration is required. Example: countdown start work 25m" fi exit 1 fi duration_seconds=$(parse_duration "$duration_str") if [ "$duration_seconds" -le 0 ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "Invalid duration: ${duration_str}" "invalid_duration" else error "Invalid duration: ${duration_str}" fi exit 1 fi # Check if already running if [ -f "$APP_FILE" ]; then if [ "${JSON}" -eq 1 ]; then output_json "error" "Countdown '${NAME}' is already running." \ "code" "already_running" \ "name" "$NAME" \ "mode" "$mode" else warn "Countdown '${NAME}' is already running." fi exit 1 fi # Calculate and save start_time=$(date +%s) target_time=$((start_time + duration_seconds)) if ! printf "%s %s\n" "$start_time" "$target_time" > "$APP_FILE" 2>/dev/null; then if [ "${JSON}" -eq 1 ]; then output_json_error "Cannot create countdown file" "io_error" else error "Cannot create countdown file." fi exit 1 fi # Success if [ "${JSON}" -eq 1 ]; then output_json "success" "Countdown '${NAME}' started for ${duration_str}." \ "name" "$NAME" \ "mode" "$mode" \ "duration" "$duration_seconds" \ "remaining" "$duration_seconds" else success "Countdown '${NAME}' started for ${duration_str}." [ "$PERSIST" -eq 1 ] && info " (persistent mode)" fi } # ============================================================================= # countdown_stop() - Stop a countdown and show final status # ============================================================================= # # Stops the countdown and reports how much time was left (or if it expired). # # Uses APP_FILE (which was resolved via get_app_file → central fallback). # No additional fallback code needed here. # # !!! DO NOT MODIFY OR SIMPLIFY !!! # ============================================================================= countdown_stop() { if [ ! -f "$APP_FILE" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "No countdown '${NAME}' running." "no_countdown" else warn "No countdown '${NAME}' running." fi exit 1 fi local start_time target_time current_time remaining local minutes seconds local actual_mode="volatile" [ "$PERSIST" -eq 1 ] && actual_mode="persistent" read -r start_time target_time < "$APP_FILE" 2>/dev/null || { if [ "${JSON}" -eq 1 ]; then output_json_error "Corrupted countdown file" "corrupted_data" else error "Corrupted countdown file." fi exit 1 } current_time=$(date +%s) remaining=$((target_time - current_time)) if [ "$remaining" -gt 0 ]; then minutes=$((remaining / 60)) seconds=$((remaining % 60)) else minutes=0 seconds=0 fi # Remove the file rm -f "$APP_FILE" if [ "${JSON}" -eq 1 ]; then if [ "$remaining" -gt 0 ]; then output_json "success" "Countdown '${NAME}' stopped with ${minutes} min ${seconds} sec remaining." \ "name" "$NAME" \ "mode" "$actual_mode" \ "duration" "$((target_time - start_time))" \ "remaining" "$remaining" \ "minutes" "$minutes" \ "seconds" "$seconds" else output_json "success" "Countdown '${NAME}' stopped (already expired)." \ "name" "$NAME" \ "mode" "$actual_mode" \ "duration" "$((target_time - start_time))" \ "remaining" "0" \ "expired" "true" fi else if [ "$remaining" -gt 0 ]; then success "Countdown '${NAME}' stopped with ${minutes} min ${seconds} sec remaining." else success "Countdown '${NAME}' stopped (already expired)." fi fi } # ============================================================================= # countdown_status() - Show remaining time of a countdown # ============================================================================= # # Automatically removes the timer file if it has expired. # ============================================================================= countdown_status() { if [ ! -f "$APP_FILE" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "No countdown '${NAME}' running." "no_countdown" else warn "No countdown '${NAME}' running." fi exit 1 fi local start_time target_time current_time remaining local minutes seconds read -r start_time target_time < "$APP_FILE" 2>/dev/null || { if [ "${JSON}" -eq 1 ]; then output_json_error "Corrupted countdown file" "corrupted_data" else error "Corrupted countdown file." fi rm -f "$APP_FILE" 2>/dev/null || true exit 1 } current_time=$(date +%s) remaining=$((target_time - current_time)) local actual_mode="volatile" [ "$PERSIST" -eq 1 ] && actual_mode="persistent" if [ "$remaining" -le 0 ]; then rm -f "$APP_FILE" 2>/dev/null || true if [ "${JSON}" -eq 1 ]; then output_json "status" "Countdown '${NAME}' has expired." \ "name" "$NAME" \ "mode" "$actual_mode" \ "duration" "$((target_time - start_time))" \ "remaining" "0" \ "expired" "true" else warn "Countdown '${NAME}' has expired." fi return 0 fi # Still running minutes=$((remaining / 60)) seconds=$((remaining % 60)) if [ "${JSON}" -eq 1 ]; then output_json "status" "Countdown '${NAME}' running." \ "name" "$NAME" \ "mode" "$actual_mode" \ "duration" "$((target_time - start_time))" \ "remaining" "$remaining" \ "minutes" "$minutes" \ "seconds" "$seconds" else msg "Countdown '${NAME}': ${minutes} min ${seconds} sec remaining" fi } # ============================================================================= # list_countdown() - List all running timers for current user # ============================================================================= # # Now automatically cleans up expired timers when listing. # Uses the central resolve_app_base_dir() helper. # Only shows timers that still have remaining time > 0. # # !!! DO NOT MODIFY OR SIMPLIFY !!! # ============================================================================= list_countdown() { local requested_mode="$1" local base_dir timers_json count=0 file name start_time target_time current_time remaining minutes seconds base_dir=$(resolve_app_base_dir "$requested_mode") if [ ! -d "$base_dir" ]; then if [ "${JSON}" -eq 1 ]; then output_json "list" "" "mode" "$requested_mode" "count" "0" "timers" "[]" else msg "No running countdowns found." fi return 0 fi timers_json="[" local first=true for file in "${base_dir}"/countdown_"${USERNAME}"_*; do [ -f "$file" ] || continue name="${file##*countdown_${USERNAME}_}" # Read start and target time if ! read -r start_time target_time < "$file" 2>/dev/null; then rm -f "$file" 2>/dev/null || true continue fi current_time=$(date +%s 2>/dev/null) || continue remaining=$((target_time - current_time)) if [ "$remaining" -le 0 ]; then # Auto-cleanup expired timer rm -f "$file" 2>/dev/null || true continue fi # Timer is still running minutes=$((remaining / 60)) seconds=$((remaining % 60)) if [ "$count" -gt 0 ]; then timers_json="${timers_json}," fi timers_json="${timers_json}{\"name\":\"${name}\",\"duration\":$((target_time - start_time)),\"remaining\":${remaining},\"minutes\":${minutes},\"seconds\":${seconds}}" count=$((count + 1)) done timers_json="${timers_json}]" if [ "${JSON}" -eq 1 ]; then output_json "list" "" \ "mode" "$requested_mode" \ "count" "$count" \ "timers" "$timers_json" else if [ "$count" -eq 0 ]; then msg "No running countdowns found." else for file in "${base_dir}"/countdown_"${USERNAME}"_*; do [ -f "$file" ] || continue name="${file##*countdown_${USERNAME}_}" if ! read -r start_time target_time < "$file" 2>/dev/null; then rm -f "$file" 2>/dev/null || true continue fi current_time=$(date +%s) remaining=$((target_time - current_time)) if [ "$remaining" -le 0 ]; then rm -f "$file" 2>/dev/null || true continue fi minutes=$((remaining / 60)) seconds=$((remaining % 60)) msg "${name}: ${minutes} min ${seconds} sec remaining" done fi fi } # ============================================================================= # main_countdown() - Main dispatcher for the countdown tool # ============================================================================= # # Handles argument parsing and routes to the correct countdown functions. # Supports the desired syntax: # countdown start [--persist] [name] # countdown status [--persist] [name] # countdown stop|kill|reset [--persist] [name] # countdown list [--persist] # # IMPORTANT DESIGN DECISION: # The auto-install logic (`maybe_install` / `perform_self_install`) is triggered # when the script is run with ZERO arguments via `curl | sh`. # # This is INTENTIONAL and CRITICAL for the one-liner installer to work. # Without it, `curl -fsSL ... | sh` would only print help and exit, leaving # the user with "command not found" — exactly the problem we are solving. # # Even though subcommands like "help", "version", or "about" exist, # we still want to check/install first in the curl | sh context because # the primary use-case of piping the raw script is installation. # # This pattern is common in robust single-file CLI tools and must be preserved. # # !!! DO NOT REMOVE OR SIMPLIFY THE AUTO-INSTALL BLOCK !!! # Many AI assistants will try to "clean it up", move it after parsing, # or tie it only to specific subcommands. # Doing so would break the one-liner installation experience (`curl | sh`), # which is one of the core promises of this project. # # Previous Grok versions already attempted to remove or relocate this logic. # Respect this decision and keep the auto-install check at the top, before # any argument parsing. # ============================================================================= main_countdown() { local cmd="help" local NAME="default" local PERSIST=0 local DURATION="" # === AUTO-INSTALL FOR ONE-LINER (curl | sh) === # This block MUST stay at the very top, before any argument parsing. # It ensures that `curl ... | sh` actually installs the tool instead of # just showing help and exiting with "command not found". # # perform_self_install / maybe_install is important here EVEN THOUGH # a subcommand may be present later, because the main real-world usage # of this script is the one-liner installer. Installing first is the # expected behavior in that context. if ! is_installed && [ $# -eq 0 ]; then if [ "${JSON}" -eq 1 ] || [ "${QUIET}" -eq 1 ]; then # Non-interactive / JSON mode → install silently perform_self_install else maybe_install fi exit 0 fi # === Phase 1: Parse all arguments (fixed start command) === while [ $# -gt 0 ]; do case "$1" in --persist) PERSIST=1 ;; --quiet|-q) QUIET=1 ;; --json) JSON=1 QUIET=1 ;; start|stop|status|list|kill|reset) cmd="$1" ;; help|version|about|version-check|self-update|self-uninstall) cmd="${1#--}" ;; *) # Handle positional arguments for 'start' and other commands if [ "$cmd" = "start" ]; then # Check if this argument looks like a duration if printf '%s' "$1" | grep -qE '^[0-9]+[hms]?([0-9]+[hms]?)*$|^[0-9]+$'; then if [ -z "$DURATION" ]; then DURATION="$1" else if [ "${JSON}" -eq 1 ]; then output_json_error "Too many arguments for start command" "bad_argument" else error "Too many arguments for start command." fi exit 1 fi else if [ "$NAME" = "default" ]; then NAME="$1" else if [ "${JSON}" -eq 1 ]; then output_json_error "Too many arguments for start command" "bad_argument" else error "Too many arguments for start command." fi exit 1 fi fi else # For status/stop/kill/reset/list: the argument is the name NAME="$1" fi ;; esac shift done # === Validation for start command === if [ "$cmd" = "start" ] && [ -z "$DURATION" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "Duration required (e.g. 25m, 90s, 1h30m)" "missing_duration" else error "Duration is required for 'start' command. Example: countdown start work 25m" fi exit 1 fi # === Sanitize timer NAME (only if it's a countdown operation) === if [ "$cmd" = "start" ] || [ "$cmd" = "stop" ] || [ "$cmd" = "status" ] || \ [ "$cmd" = "kill" ] || [ "$cmd" = "reset" ] || [ "$cmd" = "list" ]; then sanitize_name fi # Determine storage mode and file path local STORAGE_MODE STORAGE_MODE=$([ "$PERSIST" -eq 1 ] && echo "persistent" || echo "volatile") APP_FILE=$(get_app_file "$NAME" "$STORAGE_MODE") # === Phase 2: Dispatch commands === case "$cmd" in start) countdown_start "$DURATION" ;; status) countdown_status ;; stop) countdown_stop ;; kill|reset) if [ ! -f "$APP_FILE" ]; then if [ "${JSON}" -eq 1 ]; then output_json_error "No countdown '${NAME}' running." "no_countdown" else warn "No countdown '${NAME}' running." fi exit 1 fi rm -f "$APP_FILE" if [ "${JSON}" -eq 1 ]; then output_json "success" "Countdown '${NAME}' has been discarded." \ "name" "$NAME" \ "mode" "$STORAGE_MODE" else if [ "$cmd" = "reset" ]; then success "Countdown '${NAME}' has been reset." else warn "Countdown '${NAME}' has been discarded." fi fi ;; list) list_countdown "$STORAGE_MODE" ;; about) show_about ;; version) if [ "${JSON}" -eq 1 ]; then output_json "version" "" \ "app" "${APP_NAME}" \ "version" "${VERSION}" else msg "${APP_NAME} version ${VERSION}" fi ;; version-check) version_check ;; self-update) self_update ;; self-uninstall) self_uninstall ;; help|*) show_countdown_help ;; esac } main_countdown "$@"