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 rich library

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 — with rich as the only dependency
  • README.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 rich library integration was perfect. Tables, colors, and formatting all worked on the first try. Claude clearly knows the rich library 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:

TaskManualWith Claude
Project setup (files, imports, argparse)15 min2 min
Core CRUD functions30 min2 min
Rich library table formatting20 min1 min
Priority system15 min3 min
Due dates + overdue logic20 min5 min
Search10 min2 min
Tests30 min5 min
Bug fixes + refactoring20 min7 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)

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.