Skip to content

Solution — Menu-Driven CLI App

Full reference implementation. Study this after attempting the project yourself.

taskman.sh

#!/usr/bin/env bash
# =============================================================================
# taskman.sh — Interactive task manager with menu and direct CLI modes
#
# Usage:
#   taskman.sh                         Interactive menu
#   taskman.sh list                    List all tasks
#   taskman.sh add "Task desc"         Add a task
#   taskman.sh done ID                 Mark task done
#   taskman.sh delete ID               Delete task
# =============================================================================
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"
VERSION="1.0"

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

# ── ANSI colors (disabled when not a tty) ────────────────────────────────────
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

# ── DB helpers ────────────────────────────────────────────────────────────────
init_db() { [[ -f "$DB_FILE" ]] || touch "$DB_FILE"; }

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 ))
}

task_exists() {
    grep -q "^${1}:" "$DB_FILE" 2>/dev/null
}

# ── Commands ──────────────────────────────────────────────────────────────────
cmd_list() {
    init_db
    local count=0

    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" =~ ^[0-9]+$ ]] && continue
        [[ "$status" == "done" && "$SHOW_DONE" != "true" ]] && continue

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

        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" "$sym" "$priority" "$desc"
        (( count++ )) || true
    done < "$DB_FILE"

    (( count == 0 )) && echo "(no tasks — use 'add' to create one)"
}

cmd_add() {
    local desc="" priority="$DEFAULT_PRIORITY"
    while [[ "${1:-}" =~ ^- ]]; do
        case "$1" in
            --priority|-p) priority="${2:?--priority requires a value}"; shift ;;
            *) echo "Unknown option: $1" >&2; return 1 ;;
        esac
        shift
    done
    desc="${1:-}"
    if [[ -z "$desc" ]]; then
        printf "Task description: "
        read -r desc
    fi
    [[ -z "$desc" ]] && { echo "Error: description cannot be empty" >&2; return 1; }
    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"
}

cmd_done() {
    local id="${1:?done: task ID required}"
    task_exists "$id" || { echo "Error: task #$id not found" >&2; return 1; }
    local tmp
    tmp=$(mktemp)
    awk -F: -v OFS=: -v tid="$id" '$1==tid{$2="done"} 1' "$DB_FILE" > "$tmp"
    mv "$tmp" "$DB_FILE"
    printf "${GREEN}[OK]${RESET} Task #%s marked done\n" "$id"
}

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

cmd_settings() {
    echo ""
    echo "Settings:"
    printf "  %-20s %s\n" "DB file:"           "$DB_FILE"
    printf "  %-20s %s\n" "Default priority:"  "$DEFAULT_PRIORITY"
    printf "  %-20s %s\n" "Show done tasks:"   "$SHOW_DONE"
    printf "  %-20s %s\n" "Config file:"       "$CONFIG"
    echo ""
    echo "Edit $CONFIG to change settings."
}

# ── Interactive menu ──────────────────────────────────────────────────────────
show_header() {
    echo ""
    printf "${CYAN}${BOLD}╔══════════════════════════════╗${RESET}\n"
    printf "${CYAN}${BOLD}║   Task Manager  v%-10s  ║${RESET}\n" "$VERSION"
    printf "${CYAN}${BOLD}╚══════════════════════════════╝${RESET}\n"
}

interactive_menu() {
    show_header
    local options=("List tasks" "Add task" "Complete task" "Delete task" "Settings" "Quit")
    while true; do
        echo ""
        PS3=$'\n'"${BOLD}Choice: ${RESET}"
        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")           printf "\nGoodbye!\n"; exit 0 ;;
                *) echo "Invalid: enter a number 1–6" ;;
            esac
        done
    done
}

# ── main ──────────────────────────────────────────────────────────────────────
main() {
    case "${1:-}" 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)
            printf "Usage: %s [list|add|done|delete|settings]\n" "$(basename "$0")"
            echo "Run without arguments for interactive menu."
            ;;
        *) echo "Unknown command: ${1}" >&2; exit 1 ;;
    esac
}

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

taskman.conf

# taskman.conf — task manager configuration

# Path to the task database file
DB_FILE="${HOME}/projects/taskman/tasks.db"

# Default priority for new tasks: low | normal | high
DEFAULT_PRIORITY=normal

# Show completed tasks in list: true | false
SHOW_DONE=true

# Maximum tasks to display per page
MAX_DISPLAY=50

Sample Session

$ bash taskman.sh ╔══════════════════════════════╗ ║ Task Manager v1.0 ║ ╚══════════════════════════════╝ 1) List tasks 2) Add task 3) Complete task 4) Delete task 5) Settings 6) Quit Choice: 2 Task description: Buy groceries [OK] Task added: #1 Buy groceries [normal] Choice: 1 ID Status Priority Task ---- ------ -------- ---- 1 ○ normal Buy groceries Choice: 6 Goodbye!

Key Design Decisions

Decision Why
[[ -t 1 ]] for color detection Prevents ANSI garbage in pipes, cron output, log files
awk -F: -v OFS=: for updates Preserves all fields when changing one — safer than sed
mktemp for atomic writes Never leaves a half-written DB if the script is interrupted
PS3 for select prompt Customizes the default #? prompt to something user-friendly
BASH_SOURCE guard Allows source taskman.sh; cmd_list for testing without launching the menu
Flat file format Simple, grep-able, no dependencies — SQLite would be overkill here

enhancements | overview