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 / --quiet | Mutually exclusive log levels or silence |
🎨 Colored output | Use colorama to color priorities or status |
📅 Filter by date | Show overdue or due-today tasks |
📦 Turn into package | Use setuptools, upload to TestPyPI |
Add --edit command | Let user update title, tags, or due date |
Unit tests | pytest CLI behavior & file I/O |
Logging / error messages | Log 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 |
---|---|
title | Positional argument (required) |
--priority | Uses choices + default |
--tags | Uses nargs="*" → accepts 0 or more |
--due | Custom 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 loading | load_tasks() |
Simple ID tracking | tasks[-1]["id"] + 1 |
Clean fallback | or [] for empty tags |
Extendable logic | You could add created_at or user later |
Output clarity | Always 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 output | Use colorama to color priorities/status |
📅 Show due/overdue | Compare t["due"] to datetime.today() |
🔍 Filter by done/pending | Add a flag like --only-done or --pending |
📊 Sort tasks by due | Use 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 found | Check if ID existed before filtering |
🛡️ Confirm delete | Use input() for Are you sure? |
🕓 Soft delete / archive | Move deleted task to trash.json |
🔄 Undo feature | Save 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.