Skip to content

Implementation — Menu-Driven CLI App

Build the task manager step by step. Each step adds a working feature.

Step 1 — Scaffold and Config

#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONFIG="${SCRIPT_DIR}/taskman.conf"
DB_FILE="${SCRIPT_DIR}/tasks.db"
DEFAULT_PRIORITY="normal"
SHOW_DONE=true

[[ -f "$CONFIG" ]] && source "$CONFIG"

# ANSI colors (disabled when not a terminal)
if [[ -t 1 ]]; then
    RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
    CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; RESET='\033[0m'
else
    RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; DIM=''; RESET=''
fi

Step 2 — Initialize the Database

init_db() {
    [[ -f "$DB_FILE" ]] && return 0
    touch "$DB_FILE"
}

Step 3 — Next ID

next_id() {
    local max=0
    while IFS=: read -r id _rest; do
        [[ "$id" =~ ^[0-9]+$ ]] && (( id > max )) && max=$id
    done < "$DB_FILE"
    echo $(( max + 1 ))
}

Step 4 — List Tasks

cmd_list() {
    init_db

    local has_tasks=false
    printf "${BOLD}%-4s  %-6s  %-8s  %s${RESET}\n" "ID" "Status" "Priority" "Task"
    printf "%s\n" "----  ------  --------  ----"

    while IFS=: read -r id status priority desc; do
        [[ -z "$id" || "$id" =~ ^# ]] && continue
        [[ "$status" == "done" && "$SHOW_DONE" != "true" ]] && continue

        local status_sym color
        if [[ "$status" == "done" ]]; then
            status_sym="${GREEN}${RESET}"
            color="$DIM"
        else
            status_sym="${YELLOW}${RESET}"
            color=""
        fi

        local pri_color=""
        [[ "$priority" == "high" ]] && pri_color="$RED"
        [[ "$priority" == "low"  ]] && pri_color="$DIM"

        printf "${color}%-4s  %b      ${pri_color}%-8s${RESET}${color}  %s${RESET}\n" \
            "$id" "$status_sym" "$priority" "$desc"
        has_tasks=true
    done < "$DB_FILE"

    [[ "$has_tasks" == false ]] && echo "(no tasks yet — use 'add' to create one)"
}

Step 5 — Add Task

cmd_add() {
    local desc="${1:-}"
    local priority="${DEFAULT_PRIORITY}"

    # Parse --priority flag
    shift || true
    while [[ "${1:-}" =~ ^- ]]; do
        case "$1" in
            --priority|-p) priority="${2:?--priority requires low|normal|high}"; shift ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
        shift
    done

    # Prompt if no description given
    if [[ -z "$desc" ]]; then
        printf "Task description: "
        read -r desc
    fi
    [[ -z "$desc" ]] && { echo "Error: description cannot be empty" >&2; return 1; }

    # Validate priority
    case "$priority" in
        low|normal|high) ;;
        *) echo "Error: priority must be low, normal, or high" >&2; return 1 ;;
    esac

    init_db
    local id
    id=$(next_id)
    echo "${id}:pending:${priority}:${desc}" >> "$DB_FILE"
    printf "${GREEN}[OK]${RESET} Task added: #%s  %s  [%s]\n" "$id" "$desc" "$priority"
}

Step 6 — Mark Done and Delete

cmd_done() {
    local id="${1:?done requires a task ID}"
    _update_task "$id" "done"
}

cmd_delete() {
    local id="${1:?delete requires a task ID}"
    local tmp
    tmp=$(mktemp)
    if grep -v "^${id}:" "$DB_FILE" > "$tmp"; then
        mv "$tmp" "$DB_FILE"
        printf "${GREEN}[OK]${RESET} Task #%s deleted\n" "$id"
    else
        rm -f "$tmp"
        echo "Error: task #$id not found" >&2
        return 1
    fi
}

_update_task() {
    local id="$1" new_status="$2"
    local tmp found=false
    tmp=$(mktemp)
    while IFS=: read -r tid status priority desc; do
        if [[ "$tid" == "$id" ]]; then
            echo "${tid}:${new_status}:${priority}:${desc}" >> "$tmp"
            found=true
        else
            echo "${tid}:${status}:${priority}:${desc}" >> "$tmp"
        fi
    done < "$DB_FILE"
    if [[ "$found" == true ]]; then
        mv "$tmp" "$DB_FILE"
        printf "${GREEN}[OK]${RESET} Task #%s marked %s\n" "$id" "$new_status"
    else
        rm -f "$tmp"
        echo "Error: task #$id not found" >&2
        return 1
    fi
}

Step 7 — Interactive Menu

show_header() {
    echo ""
    printf "${CYAN}${BOLD}╔══════════════════════════════╗${RESET}\n"
    printf "${CYAN}${BOLD}║       Task Manager v1.0      ║${RESET}\n"
    printf "${CYAN}${BOLD}╚══════════════════════════════╝${RESET}\n"
    echo ""
}

interactive_menu() {
    show_header
    while true; do
        echo ""
        PS3=$'\n'"${BOLD}Choice:${RESET} "
        local options=("List tasks" "Add task" "Complete task" "Delete task" "Settings" "Quit")
        select choice in "${options[@]}"; do
            case "$choice" in
                "List tasks")     cmd_list; break ;;
                "Add task")       cmd_add; break ;;
                "Complete task")
                    printf "Task ID to complete: "
                    read -r id
                    cmd_done "$id"
                    break ;;
                "Delete task")
                    printf "Task ID to delete: "
                    read -r id
                    cmd_delete "$id"
                    break ;;
                "Settings")       cmd_settings; break ;;
                "Quit")           echo "Goodbye!"; exit 0 ;;
                *) echo "Invalid choice: $REPLY" ;;
            esac
        done
    done
}

Step 8 — Settings and main()

cmd_settings() {
    echo ""
    echo "Current settings:"
    echo "  DB file:          $DB_FILE"
    echo "  Default priority: $DEFAULT_PRIORITY"
    echo "  Show done tasks:  $SHOW_DONE"
    echo ""
    echo "(Edit $CONFIG to change settings)"
}

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

    case "$command" in
        "")         interactive_menu ;;
        list)       shift; cmd_list "$@" ;;
        add)        shift; cmd_add "$@" ;;
        done)       shift; cmd_done "$@" ;;
        delete|rm)  shift; cmd_delete "$@" ;;
        settings)   cmd_settings ;;
        --help|-h)
            echo "Usage: $(basename "$0") [list|add|done|delete|settings]"
            echo "Run without arguments for interactive menu."
            ;;
        *) echo "Unknown command: $command" >&2; exit 1 ;;
    esac
}

[[ "${BASH_SOURCE[0]}" == "$0" ]] && main "$@"

setup | testing