10 min read

Task CLI Pro — A Real-World CLI Project

Task CLI Pro — A Real-World CLI Project

Welcome to Task CLI Pro, your first full-featured command-line application built entirely with Python’s argparse.

This isn’t a demo. This is a real, working tool — built step by step from scratch.

Whether you want to automate your daily tasks, learn to manage input safely, or prepare for real-world backend development, this project gives you everything you need to master:

  • Subcommands (add, list, complete, delete)
  • Safe input handling with choices, nargs, type, and custom validation
  • JSON file storage and real output formatting
  • Clean, extensible structure using real CLI design patterns

Project Steps

This post breaks the project into focused learning sections. Each one explains why it exists, how it works, and what you can build with it later.

Here’s what you’ll learn:

  • main() + subparsers — The CLI engine
  • add_task() — Input handling, ID generation, saving
  • list_tasks() — Filtering, formatting, output modes
  • complete_task() — Updating task status safely
  • delete_task() + save_tasks() — File I/O with JSON
  • parse_due_date() — Input validation using custom types

Every section includes real explanations, sample usage, and code breakdowns.
You’ll finish this post with a complete CLI project — ready to run, expand, and reuse.

Code Walkthrough: Section by Section

main() + Subparsers
Creates the main CLI engine, sets up subcommands like add, list, etc. Uses set_defaults(func=...) to bind logic.

add_task()
Handles user input, assigns unique IDs, creates the task dictionary, and saves to tasks.json.

list_tasks()
Loads and optionally filters tasks by tag. Supports --format table or --format json.

complete_task()
Marks a task done: True by matching ID. Saves the updated list with confirmation.

delete_task()
Uses list comprehension to filter out the task and overwrite the task file with the new list.

load_tasks() + save_tasks()
Basic file I/O for persistent task storage using JSON.

parse_due_date()
Validates --due input using datetime.strptime(). Errors if format is wrong.

🧠 TaskCLI Pro — Starter Code (v1)

# taskcli.py

import argparse
import json
import os
from datetime import datetime

TASKS_FILE = "tasks.json"

def load_tasks():
    if not os.path.exists(TASKS_FILE):
        return []
    with open(TASKS_FILE, "r") as f:
        return json.load(f)

def save_tasks(tasks):
    with open(TASKS_FILE, "w") as f:
        json.dump(tasks, f, indent=2)

def parse_due_date(value):
    try:
        return datetime.strptime(value, "%Y-%m-%d").strftime("%Y-%m-%d")
    except ValueError:
        raise argparse.ArgumentTypeError("Due date must be in YYYY-MM-DD format.")

def add_task(args):
    tasks = load_tasks()
    new_id = tasks[-1]["id"] + 1 if tasks else 1
    task = {
        "id": new_id,
        "title": args.title,
        "priority": args.priority,
        "tags": args.tags or [],
        "due": args.due,
        "done": False
    }
    tasks.append(task)
    save_tasks(tasks)
    print(f"✅ Added task #{new_id}: {args.title}")

def complete_task(args):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == args.task_id:
            task["done"] = True
            save_tasks(tasks)
            print(f"✅ Task #{args.task_id} marked complete")
            return
    print("❌ Task not found.")

def delete_task(args):
    tasks = load_tasks()
    tasks = [task for task in tasks if task["id"] != args.task_id]
    save_tasks(tasks)
    print(f"🗑️ Task #{args.task_id} deleted.")

def list_tasks(args):
    tasks = load_tasks()
    if args.filter_tag:
        tasks = [t for t in tasks if args.filter_tag in t["tags"]]
    if args.format == "json":
        print(json.dumps(tasks, indent=2))
    else:
        for t in tasks:
            status = "✅" if t["done"] else "⏳"
            print(f"#{t['id']} {status} {t['title']} [{t['priority']}] (tags: {', '.join(t['tags'])})")

def main():
    parser = argparse.ArgumentParser(description="Task CLI Pro")

    subparsers = parser.add_subparsers(dest="command", required=True)

   # Add command
    add = subparsers.add_parser("add", help="Add a new task")
    add.add_argument("title", help="Task title")
    add.add_argument("--priority", choices=["low", "medium", "high"], default="medium", help="Priority level")
    add.add_argument("--tags", nargs="*", help="Optional tags")
    add.add_argument("--due", type=parse_due_date, help="Due date (YYYY-MM-DD)")
    add.set_defaults(func=add_task)

    # Complete command
    complete = subparsers.add_parser("complete", help="Mark a task as complete")
    complete.add_argument("task_id", type=int)
    complete.set_defaults(func=complete_task)

    # Delete command
    delete = subparsers.add_parser("delete", help="Delete a task")
    delete.add_argument("task_id", type=int)
    delete.set_defaults(func=delete_task)

    # List command
    list_cmd = subparsers.add_parser("list", help="List tasks")
    list_cmd.add_argument("--format", choices=["table", "json"], default="table")
    list_cmd.add_argument("--filter-tag", help="Filter tasks by tag")
    list_cmd.set_defaults(func=list_tasks)

    args = parser.parse_args()
    args.func(args)

if __name__ == "__main__":
    main()
Try These Commands

. python taskcli.py add "Wash dishes" --priority high --tags home kitchen
  --due 2025-08-01
. python taskcli.py list
. python taskcli.py list --format json
. python taskcli.py complete 1
. python taskcli.py delete 1

🧪 What’s Finished Right Now (Yes )

  • ✅ Fully working CLI interface using argparse
  • ✅ Subcommands: add, complete, delete, list
  • ✅ Input validation:
    • --priority uses choices
    • --tags uses nargs="*"
    • --due uses a custom type parser (validates date format)
  • ✅ Tasks stored in a local tasks.json file
  • ✅ List output: table format (basic) or --format json
  • ✅ Filter by tag using --filter-tag
  • ✅ Code is organized, readable, and extensible

🛠️ What’s Not Done Yet (Optional Phase 2 Upgrades)

These are totally up to you — only if you want to level it up:

Feature Description
🔊 --debug / --quietMutually exclusive log levels or silence
🎨 Colored outputUse colorama to color priorities or status
📅 Filter by dateShow overdue or due-today tasks
📦 Turn into packageUse setuptools, upload to TestPyPI
Add --edit commandLet user update title, tags, or due date
Unit testspytest CLI behavior & file I/O
Logging / error messagesLog parsing issues, save crash reports
🔑 Auth (if you’re wild)Add user field & per-user tasks

SECTION 1: The main() Function — Your CLI Command Center

Let’s start with this:

def main():

    parser = argparse.ArgumentParser(description="Task CLI Pro")

    subparsers = parser.add_subparsers(dest="command", required=True

💡 What’s Happening Here?

  • argparse.ArgumentParser() creates the main parser.
  • add_subparsers() allows you to create multiple command-specific logic blocks — like add, delete, complete.

This is what gives your CLI verbs, like:

python taskcli.py add

python taskcli.py list

dest="command" means: store the subcommand name in args.command
required=True ensures the user must provide a command (no blank runs).

🧩 SECTION 2: Subcommands — Your CLI's Superpowers

Each subcommand is like its own mini-parser. Let's look at one:

add = subparsers.add_parser("add", help="Add a new task")

add.add_argument("title", help="Task title")

add.add_argument("--priority", choices=["low", "medium", "high"], default="medium", help="Priority level")

add.add_argument("--tags", nargs="*", help="Optional tags")

add.add_argument("--due", type=parse_due_date, help="Due date (YYYY-MM-DD)")

add.set_defaults(func=add_task)

🚀 Key Points:

Line Feature
titlePositional argument (required)
--priorityUses choices + default
--tagsUses nargs="*" → accepts 0 or more
--dueCustom type function for validation
set_defaults(...)Binds this command to the function

So now you can run:

python taskcli.py add "Buy milk" --priority low --tags grocery --due 2025-08-01

SECTION 3: Running the Right Function

args = parser.parse_args()
args.func(args)

That line is a secret weapon. Because we bound a function like this:

add.set_defaults(func=add_task)

We can later call:

args.func(args)

And it will magically execute the correct one: add_task, list_tasks, complete_task, etc.

This is the heart of a professional subcommand CLI — it’s not a bunch of if/else spaghetti.

SECTION 4: Custom Type Parser for Dates

def parse_due_date(value):
    try:
        return datetime.strptime(value, "%Y-%m-%d").strftime("%Y-%m-%d")
    except ValueError:
        raise argparse.ArgumentTypeError("Due date must be in YYYY-MM-DD format.")
This means if someone gives you:
--due bananas

error: argument --due: Due date must be in YYYY-MM-DD format.

✅ Safe
✅ Clean
✅ Professional

🧩 SECTION 5: JSON-Based Storage

No database needed. It uses:

  • load_tasks() to load from tasks.json
  • save_tasks() to write changes

This keeps everything:

  • Easy to test
  • Easy to read
  • No dependencies

You can upgrade to SQLite later if you feel wild.

SECTION 6: add_task() — Building & Saving a New Task

Here's the full function again:

def add_task(args):
    tasks = load_tasks()
    new_id = tasks[-1]["id"] + 1 if tasks else 1
    task = {
        "id": new_id,
        "title": args.title,
        "priority": args.priority,
        "tags": args.tags or [],
        "due": args.due,
        "done": False
    }
    tasks.append(task)
    save_tasks(tasks)
    print(f"✅ Added task #{new_id}: {args.title}")

🔍 Line-by-Line Breakdown

tasks = load_tasks()

  • Loads existing tasks from tasks.json.
  • If the file doesn’t exist, it returns an empty list.

new_id = tasks[-1]["id"] + 1 if tasks else 1

  • Dynamically finds the next task ID.
  • If the list is empty, id = 1.
  • If tasks exist, it gets the last task’s ID and adds 1.

💡 No duplicates, and it stays sequential even after deletions. Simple but effective.

Building the Task Dictionary

task = {
    "id": new_id,
    "title": args.title,
    "priority": args.priority,
    "tags": args.tags or [],
    "due": args.due,
    "done": False
}

Each key is:

  • Pulled from args — set by argparse.
  • tags uses or [] to avoid storing None.
  • done defaults to False because the task is just created.

tasks.append(task)

  • Adds the new task to the list.

save_tasks(tasks)

  • Writes the updated list to tasks.json, formatted nicely with indent=2.

print(f" Added task #{new_id}: {args.title}")

  • Friendly output confirming success.

🧠 Summary: Why It’s Clean

Concept Feature in Code
Safe file loadingload_tasks()
Simple ID trackingtasks[-1]["id"] + 1
Clean fallbackor [] for empty tags
Extendable logicYou could add created_at or user later
Output clarityAlways tells you what was saved

SECTION 7: list_tasks() — Filtering, Formatting, and Output

Here’s the function:

def list_tasks(args):
    tasks = load_tasks()
    if args.filter_tag:
        tasks = [t for t in tasks if args.filter_tag in t["tags"]]
    if args.format == "json":
        print(json.dumps(tasks, indent=2))
    else:
        for t in tasks:
            status = "✅" if t["done"] else "⏳"
            print(f"#{t['id']} {status} {t['title']} [{t['priority']}] (tags: {', '.join(t['tags'])})")

🔍 Line-by-Line Breakdown

tasks = load_tasks()

  • Loads all tasks from the JSON file.

if args.filter_tag:

  • If user passes --filter-tag, we only keep tasks that include that tag.
python taskcli.py list --filter-tag home

So this line filters with a list comprehension:

tasks = [t for t in tasks if args.filter_tag in t["tags"]]

✅ Super fast
✅ Easy to understand
✅ Works even if tags are missing (since we store [] by default)

if args.format == "json":

  • Pretty-prints the full task list in raw JSON.

python taskcli.py list --format json

✅ Good for piping into other tools, like:

python taskcli.py list --format json | jq '.[] | .title'

The “table” format (default):

for t in tasks:
    status = "✅" if t["done"] else "⏳"
    print(f"#{t['id']} {status} {t['title']} [{t['priority']}] (tags: {', '.join(t['tags'])})")
  • status shows ✅ if complete, if not ⏳
  • Prints ID, title, priority, and tags all inline

Sample output:

#1 ⏳ Feed the dogs [high] (tags: home, pet)

#2 ✅ Buy milk [medium] (tags: grocery)

🔥 Ideas for Expansion Later:

Feature How to Add
🖍️ Colored outputUse colorama to color priorities/status
📅 Show due/overdueCompare t["due"] to datetime.today()
🔍 Filter by done/pendingAdd a flag like --only-done or --pending
📊 Sort tasks by dueUse sorted() + key=lambda

Summary: Why This Works

Feature Done well? ✅
Filters cleanly
Handles formats
Simple logic
Easy to customize

SECTION 8: complete_task() — Marking Tasks as Done

Here’s the function:

def complete_task(args):
    tasks = load_tasks()
    for task in tasks:
        if task["id"] == args.task_id:
            task["done"] = True
            save_tasks(tasks)
            print(f"✅ Task #{args.task_id} marked complete")
            return
    print("❌ Task not found.")

What’s Going On?

tasks = load_tasks()

  • Loads all tasks from the JSON file.

Loop + Check:

for task in tasks:

 if task["id"] == args.task_id:
  • Iterates through each task to find one with matching ID.
  • This is a linear search — totally fine for a small list (under 1000 tasks).
  • IDs are integers (set at creation), so comparison is safe.

If found:

task["done"] = True
save_tasks(tasks)
print(...)
  • Marks the task as complete (True)
  • Saves the whole task list back to file
  • Prints a confirmation like:

✅ Task #3 marked complete

If not found:

print("❌ Task not found.")

This could happen if:

  • The user typed the wrong ID
  • The task was deleted before
  • They’re lying 😈

🧩 SECTION 9: delete_task() — Merciless Task Removal

Here’s the code:

def delete_task(args):
    tasks = load_tasks()
    tasks = [task for task in tasks if task["id"] != args.task_id]
    save_tasks(tasks)
    print(f"🗑️ Task #{args.task_id} deleted.")

Breakdown

tasks = load_tasks()

  • You know the drill — get all tasks from tasks.json.

Filter out the doomed task:

tasks = [task for task in tasks if task["id"] != args.task_id]

This is a:

  • List comprehension
  • That removes any task with a matching ID.

🔥 Simple, powerful, no loop mutations, no side effects.

save_tasks(tasks)

  • Saves the new filtered list to file.

Even if the ID didn’t exist? Yup. It’ll just silently keep everything as-is.

Print Confirmation

print(f"🗑️ Task #{args.task_id} deleted.")

It always prints this, even if nothing was actually deleted — which is both good and bad.

⚠️ Optional Fixes You Might Want Later:

Feature Add This
🧠 Warn if task not foundCheck if ID existed before filtering
🛡️ Confirm deleteUse input() for Are you sure?
🕓 Soft delete / archiveMove deleted task to trash.json
🔄 Undo featureSave deleted task temporarily

Summary: Why It Works

Feature Done well? ✅
Clean filtering
Easy to expand
Simple, safe✅ (no mutation during iteration)
Feedback always printed

Core Commands Recap

Command Status
add✅ Done
list✅ Done
complete✅ Done
delete✅ Done

Piiiiiist!

You don’t really know JSON?
Me neither. But we’re faking it pretty well, right?

Still… we’re gonna fix that — fast.


Next Lesson: JSON Basics for CLI Projects

No bloated theory. No web junk.
Just the good stuff:

  • What JSON actually is (and why it rules)
  • How to read/write it in Python
  • Pretty-printing and parsing like a pro
  • Real CLI examples: config files, task lists, log dumps

You’ll be storing and loading data like a backend wizard 🧙‍♀️ before your tea gets cold.