Can you build a working CLI application in 30 minutes using only Claude Code? I tried it. Here is exactly what happened — every prompt, every mistake, and the final result.
The goal was simple: start with an empty folder, talk to Claude Code in the terminal, and end up with a fully working todo app. No manual coding. Just prompts.
Spoiler: it took 35 minutes, not 30. But the result was better than expected.
What We’re Building
A command-line todo application with these features:
- Add todos with priority levels (high, medium, low)
- List all todos, sorted by priority
- Mark todos as complete
- Delete todos
- Search todos by keyword
- Due dates with overdue highlighting
- Persistent storage in a JSON file
- Colorful terminal output using the
richlibrary
Tech stack: Python, argparse, rich, JSON file storage, pytest
Prerequisites
- Claude Code installed and configured
- Python 3.10+ installed
- A terminal
The Build Session
Total time: 35 minutes. 6 prompts. Here is every single one.
Prompt 1: Project Setup and Core Features (Minute 0)
I opened Claude Code in an empty directory and typed this:
Create a Python CLI todo app with add, list, complete, and delete commands.
Use argparse for CLI parsing and store todos in a JSON file at ~/.todos.json.
Use the rich library for colorful terminal output. Each todo should have an
id, title, created date, and completed status. Make the list command show
todos in a nice table format.
What Claude generated:
Claude created three files in about 15 seconds:
todo.py— main application file (87 lines)requirements.txt— withrichas the only dependencyREADME.md— basic usage instructions
The core code looked like this:
import argparse
import json
import os
from datetime import datetime
from pathlib import Path
from rich.console import Console
from rich.table import Table
TODO_FILE = Path.home() / ".todos.json"
console = Console()
def load_todos():
if TODO_FILE.exists():
with open(TODO_FILE, "r") as f:
return json.load(f)
return []
def save_todos(todos):
with open(TODO_FILE, "w") as f:
json.dump(todos, f, indent=2)
def add_todo(title):
todos = load_todos()
todo = {
"id": len(todos) + 1,
"title": title,
"created": datetime.now().isoformat(),
"completed": False
}
todos.append(todo)
save_todos(todos)
console.print(f"[green]Added:[/green] {title}")
def list_todos():
todos = load_todos()
if not todos:
console.print("[yellow]No todos found. Add one with 'add' command.[/yellow]")
return
table = Table(title="My Todos")
table.add_column("ID", style="cyan", justify="center")
table.add_column("Title", style="white")
table.add_column("Status", justify="center")
table.add_column("Created", style="dim")
for todo in todos:
status = "[green]Done[/green]" if todo["completed"] else "[red]Pending[/red]"
table.add_row(
str(todo["id"]),
todo["title"],
status,
todo["created"][:10]
)
console.print(table)
My review: The basic structure was solid. The ID generation was wrong though — using len(todos) + 1 means if you delete a todo and add a new one, you get duplicate IDs. I noted this but moved on. I knew I would need to fix it later or ask Claude to fix it.
The argparse setup was clean:
def main():
parser = argparse.ArgumentParser(description="CLI Todo App")
subparsers = parser.add_subparsers(dest="command")
add_parser = subparsers.add_parser("add", help="Add a new todo")
add_parser.add_argument("title", help="Todo title")
subparsers.add_parser("list", help="List all todos")
complete_parser = subparsers.add_parser("complete", help="Complete a todo")
complete_parser.add_argument("id", type=int, help="Todo ID")
delete_parser = subparsers.add_parser("delete", help="Delete a todo")
delete_parser.add_argument("id", type=int, help="Todo ID")
args = parser.parse_args()
if args.command == "add":
add_todo(args.title)
elif args.command == "list":
list_todos()
elif args.command == "complete":
complete_todo(args.id)
elif args.command == "delete":
delete_todo(args.id)
else:
parser.print_help()
I ran it:
pip install rich
python todo.py add "Write first article"
python todo.py list
It worked on the first try. The table looked good in the terminal. Green and red status colors showed clearly.
Time spent: 4 minutes (including reading the code and testing)
Prompt 2: Add Priority Levels (Minute 4)
Add priority levels to the todo app. Each todo should have a priority: high,
medium, or low. Default to medium. The add command should accept --priority flag.
Sort the list by priority (high first, then medium, then low). Show priority
with color coding: high=red, medium=yellow, low=green.
What Claude generated:
Claude modified the existing todo.py file. It added the --priority argument, updated the add_todo function, and changed list_todos to sort by priority.
The sorting logic:
PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
PRIORITY_COLORS = {"high": "red", "medium": "yellow", "low": "green"}
def list_todos():
todos = load_todos()
if not todos:
console.print("[yellow]No todos found.[/yellow]")
return
todos.sort(key=lambda t: PRIORITY_ORDER.get(t.get("priority", "medium"), 1))
table = Table(title="My Todos")
table.add_column("ID", style="cyan", justify="center")
table.add_column("Priority", justify="center")
table.add_column("Title", style="white")
table.add_column("Status", justify="center")
table.add_column("Created", style="dim")
for todo in todos:
priority = todo.get("priority", "medium")
color = PRIORITY_COLORS.get(priority, "white")
status = "[green]Done[/green]" if todo["completed"] else "[red]Pending[/red]"
table.add_row(
str(todo["id"]),
f"[{color}]{priority.upper()}[/{color}]",
todo["title"],
status,
todo["created"][:10]
)
console.print(table)
I tested it:
python todo.py add "Urgent bug fix" --priority high
python todo.py add "Update docs" --priority low
python todo.py list
The high priority todo showed in red at the top. Low priority showed in green at the bottom. The default was medium (yellow) for todos added earlier without a priority flag.
One issue: The old todos in the JSON file did not have a priority field. Claude handled this with .get("priority", "medium") — a sensible default. Good.
Time spent: 3 minutes
Prompt 3: Add Search (Minute 7)
Add a search command that filters todos by keyword. It should search in the
title field. Case insensitive. Show results in the same table format.
What Claude generated:
A new search_todos function and a new subparser. Clean and simple:
def search_todos(keyword):
todos = load_todos()
results = [t for t in todos if keyword.lower() in t["title"].lower()]
if not results:
console.print(f"[yellow]No todos matching '{keyword}'[/yellow]")
return
table = Table(title=f"Search Results: '{keyword}'")
table.add_column("ID", style="cyan", justify="center")
table.add_column("Priority", justify="center")
table.add_column("Title", style="white")
table.add_column("Status", justify="center")
for todo in results:
priority = todo.get("priority", "medium")
color = PRIORITY_COLORS.get(priority, "white")
status = "[green]Done[/green]" if todo["completed"] else "[red]Pending[/red]"
table.add_row(
str(todo["id"]),
f"[{color}]{priority.upper()}[/{color}]",
todo["title"],
status
)
console.print(table)
This was straightforward. Worked perfectly on the first try.
Time spent: 2 minutes
Prompt 4: Add Due Dates (Minute 9)
Add due dates to todos. The add command should accept an optional --due flag
with a date in YYYY-MM-DD format. In the list view, show the due date column.
If a todo is overdue (past the due date and not completed), highlight it in
red and show "OVERDUE" next to the date. If due today, show "TODAY" in yellow.
What Claude generated:
This is where things got interesting. Claude added the due date logic but made a mistake in the date comparison:
from datetime import datetime, date
def format_due_date(due_str, completed):
if not due_str:
return "[dim]-[/dim]"
due = datetime.strptime(due_str, "%Y-%m-%d").date()
today = date.today()
if completed:
return f"[dim]{due_str}[/dim]"
elif due < today:
return f"[red]{due_str} OVERDUE[/red]"
elif due == today:
return f"[yellow]{due_str} TODAY[/yellow]"
else:
return f"[green]{due_str}[/green]"
The mistake: Claude did not validate the date format. If you typed --due tomorrow or --due 13/01/2026, the app crashed with an ugly ValueError. I asked Claude to fix this in a follow-up.
The --due flag crashes if the user enters an invalid date format. Add validation
that checks for YYYY-MM-DD format and shows a helpful error message if invalid.
Also fix the ID generation bug — using len(todos) + 1 causes duplicate IDs if
you delete a todo. Use max ID + 1 instead.
Claude fixed both issues:
def validate_date(date_str):
try:
datetime.strptime(date_str, "%Y-%m-%d")
return date_str
except ValueError:
raise argparse.ArgumentTypeError(
f"Invalid date format: '{date_str}'. Use YYYY-MM-DD."
)
def get_next_id(todos):
if not todos:
return 1
return max(t["id"] for t in todos) + 1
Now both issues were resolved. The date validation gave a clear error message. The ID generation used max(id) + 1 instead of len + 1.
Time spent: 5 minutes (including the fix)
Prompt 5: Write Tests (Minute 14)
Write tests for all commands using pytest. Test: adding a todo, listing todos,
completing a todo, deleting a todo, searching, priority sorting, due dates,
and overdue detection. Use a temporary file instead of ~/.todos.json for tests.
What Claude generated:
A test_todo.py file with 10 test functions. Claude used tmp_path from pytest and monkeypatched the TODO_FILE constant:
import pytest
from datetime import date, timedelta
from unittest.mock import patch
from pathlib import Path
import json
import todo
@pytest.fixture(autouse=True)
def temp_todo_file(tmp_path):
test_file = tmp_path / "test_todos.json"
with patch.object(todo, "TODO_FILE", test_file):
yield test_file
def test_add_todo():
todo.add_todo("Test task")
todos = todo.load_todos()
assert len(todos) == 1
assert todos[0]["title"] == "Test task"
assert todos[0]["completed"] is False
def test_add_todo_with_priority():
todo.add_todo("Urgent task", priority="high")
todos = todo.load_todos()
assert todos[0]["priority"] == "high"
def test_complete_todo():
todo.add_todo("Task to complete")
todo.complete_todo(1)
todos = todo.load_todos()
assert todos[0]["completed"] is True
def test_delete_todo():
todo.add_todo("Task to delete")
todo.delete_todo(1)
todos = todo.load_todos()
assert len(todos) == 0
def test_search_todos(capsys):
todo.add_todo("Buy groceries")
todo.add_todo("Write code")
todo.search_todos("buy")
# Verify search found the right todo
todos = todo.load_todos()
results = [t for t in todos if "buy" in t["title"].lower()]
assert len(results) == 1
def test_priority_sorting():
todo.add_todo("Low task", priority="low")
todo.add_todo("High task", priority="high")
todo.add_todo("Medium task", priority="medium")
todos = todo.load_todos()
todos.sort(key=lambda t: todo.PRIORITY_ORDER.get(t.get("priority", "medium"), 1))
assert todos[0]["title"] == "High task"
assert todos[1]["title"] == "Medium task"
assert todos[2]["title"] == "Low task"
def test_due_date():
tomorrow = (date.today() + timedelta(days=1)).isoformat()
todo.add_todo("Future task", due=tomorrow)
todos = todo.load_todos()
assert todos[0]["due"] == tomorrow
def test_overdue_detection():
yesterday = (date.today() - timedelta(days=1)).isoformat()
result = todo.format_due_date(yesterday, False)
assert "OVERDUE" in result
def test_today_detection():
today = date.today().isoformat()
result = todo.format_due_date(today, False)
assert "TODAY" in result
def test_id_generation():
todo.add_todo("First")
todo.add_todo("Second")
todo.delete_todo(1)
todo.add_todo("Third")
todos = todo.load_todos()
ids = [t["id"] for t in todos]
assert len(ids) == len(set(ids)), "IDs should be unique"
The problem: The test_add_todo_with_priority test failed. Claude’s test called add_todo("Urgent task", priority="high"), but the actual function signature was add_todo(title) — the priority was only passed through argparse, not as a function parameter.
This was a real issue. The function was tightly coupled to argparse. Claude had written tests assuming a cleaner interface.
Prompt 6: Fix the Interface (Minute 19)
The tests fail because add_todo only accepts a title. Refactor the functions
to accept all parameters directly (title, priority, due date) so they can be
tested independently from argparse. Keep argparse for CLI parsing but have it
call the refactored functions with keyword arguments.
Claude refactored the code. The functions now had clean signatures:
def add_todo(title, priority="medium", due=None):
todos = load_todos()
todo = {
"id": get_next_id(todos),
"title": title,
"priority": priority,
"due": due,
"created": datetime.now().isoformat(),
"completed": False
}
todos.append(todo)
save_todos(todos)
console.print(f"[green]Added:[/green] {title}")
def complete_todo(todo_id):
todos = load_todos()
for todo in todos:
if todo["id"] == todo_id:
todo["completed"] = True
save_todos(todos)
console.print(f"[green]Completed:[/green] {todo['title']}")
return
console.print(f"[red]Todo with ID {todo_id} not found.[/red]")
def delete_todo(todo_id):
todos = load_todos()
original_len = len(todos)
todos = [t for t in todos if t["id"] != todo_id]
if len(todos) == original_len:
console.print(f"[red]Todo with ID {todo_id} not found.[/red]")
return
save_todos(todos)
console.print("[green]Deleted.[/green]")
And the argparse section called them properly:
if args.command == "add":
add_todo(args.title, priority=args.priority, due=args.due)
I ran the tests:
pip install pytest
pytest test_todo.py -v
test_todo.py::test_add_todo PASSED
test_todo.py::test_add_todo_with_priority PASSED
test_todo.py::test_complete_todo PASSED
test_todo.py::test_delete_todo PASSED
test_todo.py::test_search_todos PASSED
test_todo.py::test_priority_sorting PASSED
test_todo.py::test_due_date PASSED
test_todo.py::test_overdue_detection PASSED
test_todo.py::test_today_detection PASSED
test_todo.py::test_id_generation PASSED
10 passed in 0.12s
All 10 tests passed. The refactoring made the code more testable and cleaner overall.
Time spent: 7 minutes
After the tests passed, I spent the remaining time testing the full CLI manually:
python todo.py add "Write blog post" --priority high --due 2026-08-12
python todo.py add "Review PR" --priority medium
python todo.py add "Clean desk" --priority low --due 2026-08-01
python todo.py list
python todo.py search "blog"
python todo.py complete 1
python todo.py list
python todo.py delete 3
python todo.py list
Everything worked. The overdue items showed in red. The priority sorting was correct. The search worked case-insensitively.
Total time: 35 minutes.
What Went Right
- Project scaffolding was instant. Claude created the full file structure, imports, and argparse setup in one prompt. This would have taken me 10-15 minutes manually.
- The
richlibrary integration was perfect. Tables, colors, and formatting all worked on the first try. Claude clearly knows therichlibrary well. - Test generation was mostly correct. 10 tests, and only one failed due to an interface issue — not a logic error.
- Iterative prompts worked well. Each prompt added a clean layer of functionality on top of the previous one.
What Went Wrong
1. The ID Generation Bug
Claude used len(todos) + 1 for ID generation. This is a classic bug: delete todo 2 from a list of 3, and the next todo also gets ID 3. I had to specifically ask for a fix.
Lesson: Always check ID/counter logic. AI often generates the simplest approach, not the correct one.
2. Tightly Coupled Functions
The initial code mixed CLI parsing and business logic. Functions like add_todo(title) only worked through argparse. This made testing impossible without the refactoring step.
Lesson: Ask for testable code upfront. Add “make functions independently testable” to your first prompt.
3. No Date Validation
Claude added the --due flag but forgot to validate the date format. An invalid date crashed the app with a raw Python traceback.
Lesson: Always test edge cases. AI tends to write the happy path first and skip validation.
4. Incomplete Error Handling
The complete_todo function silently did nothing if you passed a non-existent ID. No error message, no feedback. Claude only added proper error messages after the refactoring prompt.
Lesson: Ask for error messages explicitly. “Show helpful error if the ID does not exist.”
The Final Result
The final todo.py is 120 lines of clean Python. It supports 6 commands: add, list, complete, delete, search, and works with priorities and due dates.
Here is how to run it yourself:
# Install dependencies
pip install rich
# Basic usage
python todo.py add "Buy groceries" --priority high --due 2026-08-15
python todo.py add "Read a book" --priority low
python todo.py list
python todo.py search "groceries"
python todo.py complete 1
python todo.py delete 2
# Run tests
pip install pytest
pytest test_todo.py -v
Time and Cost
- Total time: 35 minutes
- Claude prompts: 6 (plus 1 fix)
- Estimated token usage: ~15,000 tokens
- Hosting cost: $0 (runs locally)
- Dependencies: 1 (rich)
Lessons Learned
1. Start with a detailed first prompt. My first prompt included the tech stack, storage format, library choice, and main features. This gave Claude enough context to generate a solid foundation. Vague first prompts lead to more corrections later.
2. Test between prompts. After every Claude response, I ran the app and tested manually. This caught the ID bug and the missing date validation early.
3. Ask for tests early. Writing tests revealed the coupling problem in the code. If I had asked for tests in the first prompt, the code structure would have been better from the start.
4. The “What Went Wrong” moments are where you learn. Claude does not write perfect code. But fixing its mistakes takes minutes, not hours. The ID bug, the validation issue, the coupling problem — each took one follow-up prompt to fix.
5. 30 minutes is realistic for simple CLI apps. For more complex projects, expect 1-2 hours. But the productivity gain is real: this would have been a 2-3 hour project without Claude.
How I Would Do It Differently Next Time
If I built this again from scratch, I would change my first prompt. Here is what I would write:
Create a Python CLI todo app. Requirements:
- Commands: add, list, complete, delete, search
- Use argparse with subparsers
- Store data in ~/.todos.json
- Use the rich library for terminal output (tables, colors)
- Each todo: id, title, priority (high/medium/low), due date (optional), created date, completed status
- ID generation: use max(existing_ids) + 1, not len(todos) + 1
- Make all functions independently testable (accept parameters, don't depend on argparse)
- Validate all user input (dates, priorities)
- Show helpful error messages for invalid IDs
This single prompt includes every lesson I learned across 6 prompts. The key additions: “independently testable”, “max(existing_ids) + 1”, and “validate all user input.” These three phrases would have eliminated three of the four bugs.
The takeaway: your first vibe coding session with any project type teaches you what to include in the prompt next time. Keep notes on what went wrong and fold them into your prompt templates.
Comparing Manual Coding vs Vibe Coding
For context, here is roughly how long each part would take manually vs with Claude:
| Task | Manual | With Claude |
|---|---|---|
| Project setup (files, imports, argparse) | 15 min | 2 min |
| Core CRUD functions | 30 min | 2 min |
| Rich library table formatting | 20 min | 1 min |
| Priority system | 15 min | 3 min |
| Due dates + overdue logic | 20 min | 5 min |
| Search | 10 min | 2 min |
| Tests | 30 min | 5 min |
| Bug fixes + refactoring | 20 min | 7 min |
| Total | ~2.5 hours | ~35 min |
The biggest time savings were in the boilerplate and the test generation. The bug-fixing phase was similar — you still need to think about what is wrong and how to fix it. Claude speeds up the typing, not the thinking.
Source Code
Full source code is available on GitHub: kemalcodes/vibe-coding-projects (branch: todo-cli-app)
Related Articles
- What is Vibe Coding? — The concept behind this series
- Claude Code Mastery — Advanced Claude Code patterns
- Vibe Coding Prompt Engineering — Write better prompts for better code
What’s Next?
In the next article, we build something bigger: a REST API with Claude — from zero to deployed in 1 hour. We will use FastAPI, SQLite, and deploy to a live URL. Same format: real prompts, real iterations, real mistakes.