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

OperationCommand
Pretty printjq '.'
Get fieldjq '.name'
Array filterjq '.[] | select(.done == true)'
Modify fieldjq '.done = true'
Add to arrayjq '. + [{"new": "item"}]'
Delete from arrayjq 'map(select(.id != 5))'
Sortjq 'sort_by(.created)'
Countjq 'length'

Other Tools

CommandPurpose
date -IsecondsISO 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:

  • !high or !low for priority
  • @2024-12-31 for 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 items
  • sort_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 items
  • map(select(.id != $id)) - Filter out matching items
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}
  • shift removes 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 !high and @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:

  1. Add tags support - Parse #tag syntax
  2. Add recurring todos - Daily, weekly, monthly
  3. Add projects - Group todos by project
  4. Sync to cloud - POST to a simple API
  5. 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