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 |