Build a Todo CLI App: Master Bash Data Persistence with JSON
You’ve written Bash scripts that do things. But have you built one that remembers things? That stores data and lets you query it later?
That’s the jump from scripting to building actual applications.
Today, we’re building a Todo CLI - a command-line todo list manager with priorities, due dates, search, and JSON persistence. A tool you’ll actually want to use.
The Problem: Shell History Isn’t Task Management
Quick, what was that thing you needed to do? The one you thought of while SSH’d into a server at 2 AM?
Gone. Because shell history isn’t a task manager.
Our todo app will:
- Add, complete, and delete todos
- Support priorities (high, normal, low)
- Support due dates
- Search across todos
- Persist data in JSON
- Show statistics
The Toolkit: Data Management Commands
The Star: jq
jq is the JSON processor for the command line. If you’re not using it, you’re missing out.
# Install
sudo apt install jq # Debian/Ubuntu
brew install jq # macOS
Key jq Operations
| Operation | Command |
|---|---|
| Pretty print | jq '.' |
| Get field | jq '.name' |
| Array filter | jq '.[] | select(.done == true)' |
| Modify field | jq '.done = true' |
| Add to array | jq '. + [{"new": "item"}]' |
| Delete from array | jq 'map(select(.id != 5))' |
| Sort | jq 'sort_by(.created)' |
| Count | jq 'length' |
Other Tools
| Command | Purpose |
|---|---|
date -Iseconds | ISO timestamp |
[[ =~ ]] | Regex matching |
${var//pattern/replace} | String substitution |
The Walkthrough: Building Step by Step
Step 1: Initialize the Data File
TODO_FILE="${TODO_FILE:-$HOME/.todos.json}"
init_todo_file() {
if [[ ! -f "$TODO_FILE" ]]; then
echo '[]' > "$TODO_FILE"
fi
}
get_todos() {
cat "$TODO_FILE"
}
save_todos() {
echo "$1" > "$TODO_FILE"
}
get_next_id() {
local max_id=$(jq -r 'map(.id) | max // 0' "$TODO_FILE" 2>/dev/null || echo 0)
echo $((max_id + 1))
}
What’s happening:
${TODO_FILE:-default}- Use environment variable or default- Empty JSON array
[]as initial state max // 0- Return 0 if max is null (empty array)
Step 2: Add Todos with Special Syntax
Here’s where it gets interesting. We’ll support:
!highor!lowfor priority@2024-12-31for due date
add_todo() {
local title="$*"
if [[ -z "$title" ]]; then
echo "Error: Todo title is required"
return 1
fi
local id=$(get_next_id)
local created=$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S')
local priority="normal"
local due=""
# Check for priority flag: !high, !low, !normal
if [[ "$title" =~ ^!(high|low|normal)[[:space:]] ]]; then
priority="${BASH_REMATCH[1]}"
title="${title#!${priority} }" # Remove the flag from title
fi
# Check for due date: @YYYY-MM-DD
if [[ "$title" =~ @([0-9]{4}-[0-9]{2}-[0-9]{2}) ]]; then
due="${BASH_REMATCH[1]}"
title="${title/@${due}/}" # Remove date from title
title=$(echo "$title" | xargs) # Trim whitespace
fi
# Create JSON object
local new_todo=$(jq -n \
--argjson id "$id" \
--arg title "$title" \
--arg priority "$priority" \
--arg due "$due" \
--arg created "$created" \
'{id: $id, title: $title, done: false, priority: $priority, due: $due, created: $created}')
# Add to array
local todos=$(get_todos)
local updated=$(echo "$todos" | jq ". + [$new_todo]")
save_todos "$updated"
echo "Added todo #$id: $title"
}
Regex breakdown:
^!(high|low|normal)[[:space:]]- Starts with !, captures priority, followed by space${BASH_REMATCH[1]}- First capture group@([0-9]{4}-[0-9]{2}-[0-9]{2})- Date pattern YYYY-MM-DD
Usage:
./todo.sh add "Buy groceries"
./todo.sh add "!high Finish report"
./todo.sh add "Submit assignment @2024-12-31"
./todo.sh add "!high Call client @2024-01-15"
Step 3: List Todos with Filtering
list_todos() {
local filter="${1:-all}"
local todos=$(get_todos)
local count=$(echo "$todos" | jq 'length')
if [[ "$count" -eq 0 ]]; then
echo "No todos found. Add one with: todo add <title>"
return 0
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " TODO LIST"
echo "═══════════════════════════════════════════════════════════"
echo ""
# Build jq filter based on argument
local jq_filter='.'
local today=$(date +%Y-%m-%d)
case "$filter" in
done) jq_filter='[.[] | select(.done == true)]' ;;
pending) jq_filter='[.[] | select(.done == false)]' ;;
high) jq_filter='[.[] | select(.priority == "high")]' ;;
today) jq_filter="[.[] | select(.due == \"$today\")]" ;;
esac
# Sort: pending first, then by priority, then by date
local sorted=$(echo "$todos" | jq "$jq_filter | sort_by(.done, .priority == \"high\" | not, .created)")
# Display each todo
echo "$sorted" | jq -r '.[] | "\(.id)|\(.done)|\(.priority)|\(.due)|\(.title)"' | \
while IFS='|' read -r id done priority due title; do
local checkbox="○"
local priority_indicator=""
if [[ "$done" == "true" ]]; then
checkbox="${GREEN}●${NC}"
title="${DIM}${title}${NC}"
fi
case "$priority" in
high) priority_indicator="${RED}▲${NC} " ;;
low) priority_indicator="${DIM}▽${NC} " ;;
*) priority_indicator=" " ;;
esac
# Due date formatting
local due_info=""
if [[ -n "$due" && "$due" != "null" && "$due" != "" ]]; then
if [[ "$due" < "$today" && "$done" != "true" ]]; then
due_info=" ${RED}(overdue: $due)${NC}"
elif [[ "$due" == "$today" ]]; then
due_info=" ${YELLOW}(today)${NC}"
else
due_info=" ${DIM}(due: $due)${NC}"
fi
fi
printf " #%-3s %b %b%b%b\n" "$id" "$checkbox" "$priority_indicator" "$title" "$due_info"
done
# Summary
echo ""
echo "───────────────────────────────────────────────────────────"
local done_count=$(echo "$todos" | jq '[.[] | select(.done == true)] | length')
local pending_count=$(echo "$todos" | jq '[.[] | select(.done == false)] | length')
echo " ● Done: $done_count ○ Pending: $pending_count Total: $count"
}
jq magic:
select(.done == true)- Filter matching itemssort_by(.done, .priority)- Multi-field sorting.priority == "high" | not- Boolean inversion for sorting
Step 4: Complete and Delete
complete_todo() {
local id="$1"
if [[ -z "$id" ]]; then
echo "Error: Todo ID is required"
return 1
fi
local todos=$(get_todos)
local exists=$(echo "$todos" | jq ".[] | select(.id == $id)")
if [[ -z "$exists" ]]; then
echo "Error: Todo #$id not found"
return 1
fi
# Update the done field
local updated=$(echo "$todos" | jq "map(if .id == $id then .done = true else . end)")
save_todos "$updated"
local title=$(echo "$exists" | jq -r '.title')
echo "Completed: $title"
}
delete_todo() {
local id="$1"
local todos=$(get_todos)
local todo=$(echo "$todos" | jq ".[] | select(.id == $id)")
if [[ -z "$todo" ]]; then
echo "Error: Todo #$id not found"
return 1
fi
local title=$(echo "$todo" | jq -r '.title')
# Remove from array
local updated=$(echo "$todos" | jq "map(select(.id != $id))")
save_todos "$updated"
echo "Deleted: $title"
}
Key pattern:
map(if condition then change else . end)- Modify matching itemsmap(select(.id != $id))- Filter out matching items
Step 5: Search
search_todos() {
local query="$*"
if [[ -z "$query" ]]; then
echo "Error: Search query is required"
return 1
fi
local todos=$(get_todos)
# Case-insensitive search using jq's test()
local results=$(echo "$todos" | jq "[.[] | select(.title | test(\"$query\"; \"i\"))]")
local count=$(echo "$results" | jq 'length')
echo "Search results for: $query"
echo ""
if [[ "$count" -eq 0 ]]; then
echo "No todos found matching '$query'"
return 0
fi
echo "$results" | jq -r '.[] | "#\(.id) [\(if .done then "✓" else " " end)] \(.title)"'
}
jq regex:
test("pattern"; "i")- Case-insensitive regex match- String interpolation in jq:
"\(.field)"
Step 6: Statistics
show_stats() {
local todos=$(get_todos)
local total=$(echo "$todos" | jq 'length')
local done=$(echo "$todos" | jq '[.[] | select(.done == true)] | length')
local pending=$(echo "$todos" | jq '[.[] | select(.done == false)] | length')
local high=$(echo "$todos" | jq '[.[] | select(.priority == "high" and .done == false)] | length')
local today=$(date +%Y-%m-%d)
local overdue=$(echo "$todos" | jq --arg today "$today" \
'[.[] | select(.due != "" and .due < $today and .done == false)] | length')
echo ""
echo "Todo Statistics"
echo "─────────────────────"
printf " %-15s %d\n" "Total:" "$total"
printf " %-15s ${GREEN}%d${NC}\n" "Completed:" "$done"
printf " %-15s ${YELLOW}%d${NC}\n" "Pending:" "$pending"
printf " %-15s ${RED}%d${NC}\n" "High Priority:" "$high"
printf " %-15s ${RED}%d${NC}\n" "Overdue:" "$overdue"
if [[ $total -gt 0 ]]; then
local percent=$((done * 100 / total))
echo ""
echo " Progress: ${GREEN}$percent%${NC} complete"
fi
}
Step 7: Command Routing
main() {
# Check for jq dependency
if ! command -v jq &>/dev/null; then
echo "Error: jq is required but not installed"
echo "Install with: sudo apt install jq OR brew install jq"
exit 1
fi
init_todo_file
local command="${1:-list}"
shift 2>/dev/null || true
case "$command" in
add|a) add_todo "$@" ;;
list|ls|l) list_todos "$@" ;;
done|d) complete_todo "$@" ;;
undone|u) uncomplete_todo "$@" ;;
delete|rm) delete_todo "$@" ;;
edit|e) edit_todo "$@" ;;
search|s) search_todos "$@" ;;
clear|c) clear_done ;;
stats) show_stats ;;
help|h) show_help ;;
*)
echo "Unknown command: $command"
show_help
exit 1
;;
esac
}
main "$@"
Routing pattern:
- Default command if none provided:
${1:-list} shiftremoves first argument, passes rest to function- Short aliases:
add|a,list|ls|l
The JSON Data Format
Your todos are stored in ~/.todos.json:
[
{
"id": 1,
"title": "Finish project report",
"done": false,
"priority": "high",
"due": "2024-01-15",
"created": "2024-01-10T14:30:00"
},
{
"id": 2,
"title": "Buy groceries",
"done": true,
"priority": "normal",
"due": "",
"created": "2024-01-09T09:15:00"
}
]
Design decisions:
- Array of objects (easy to add/remove)
- ISO timestamps (sortable)
- Empty string for missing due date (not null)
Usage Examples
# Add todos
./todo.sh add "Review pull request"
./todo.sh add "!high Deploy to production"
./todo.sh add "Write documentation @2024-02-01"
# List and filter
./todo.sh list # All todos
./todo.sh list pending # Only incomplete
./todo.sh list high # High priority only
# Complete and delete
./todo.sh done 1
./todo.sh delete 3
# Search and stats
./todo.sh search "deploy"
./todo.sh stats
What You Just Learned
You built a full-featured CLI application. Along the way, you mastered:
- JSON manipulation with
jq- Queries, filters, transforms - Data persistence - File-based storage
- Regex parsing - Special syntax like
!highand@date - Command routing - Subcommand pattern
- Colored output - ANSI codes for visual feedback
- Date handling - Comparisons, ISO formatting
Your Challenge
The app works. Now make it your own:
- Add tags support - Parse
#tagsyntax - Add recurring todos - Daily, weekly, monthly
- Add projects - Group todos by project
- Sync to cloud - POST to a simple API
- Export to markdown - Generate a checklist
Building tools you actually use is the fastest way to learn.
Next in the series: Building an Interactive Calculator in Bash