A command-line task manager that stores tasks in a local JSON file, supports adding, listing, completing, and deleting tasks, and uses only the Python standard library.

Prerequisites

  • Python 3.10 or later
  • uv 0.4 or later (curl -LsSf https://astral.sh/uv/install.sh | sh)
  • A Unix-like terminal (macOS, Linux, or WSL on Windows)
  • make installed (pre-installed on macOS and most Linux distributions)

Step 1: Set up the project structure

Create the file

uv init cli-task-manager
cd cli-task-manager
rm main.py README.md
uv add --dev pytest

Add the code: .gitignore

__pycache__/
*.pyc
.venv/
.pytest_cache/
*.egg-info/
dist/
build/
.DS_Store
*.log
tmp/
tasks.json

Detailed breakdown

  • uv init cli-task-manager scaffolds a new Python project with a pyproject.toml, a main.py sample file, and a .python-version file.
  • rm main.py README.md removes the placeholder files since the project will use its own package structure.
  • uv add --dev pytest installs pytest and records it under [dependency-groups] in pyproject.toml.
  • __pycache__/ and *.pyc exclude Python bytecode files generated at runtime.
  • .venv/ excludes the virtual environment that uv manages locally.
  • tasks.json is excluded because it is runtime data, not source code. Each user generates their own task file.
  • .DS_Store and tmp/ cover common OS and temporary artifacts.

Step 2: Create the task storage module

This module handles all persistence — reading and writing tasks to a JSON file on disk.

Create the file

mkdir -p taskm
touch taskm/__init__.py
touch taskm/store.py

Add the code: taskm/store.py

"""Persistent task storage backed by a JSON file."""

import json
from pathlib import Path

DEFAULT_PATH = Path("tasks.json")


def _resolve(path: Path | None) -> Path:
    return path if path is not None else DEFAULT_PATH


def _load(path: Path | None = None) -> list[dict]:
    p = _resolve(path)
    if not p.exists():
        return []
    text = p.read_text(encoding="utf-8")
    if not text.strip():
        return []
    return json.loads(text)


def _save(tasks: list[dict], path: Path | None = None) -> None:
    p = _resolve(path)
    p.write_text(json.dumps(tasks, indent=2) + "\n", encoding="utf-8")


def _next_id(tasks: list[dict]) -> int:
    if not tasks:
        return 1
    return max(t["id"] for t in tasks) + 1


def add_task(description: str, path: Path | None = None) -> dict:
    tasks = _load(path)
    task = {"id": _next_id(tasks), "description": description, "done": False}
    tasks.append(task)
    _save(tasks, path)
    return task


def list_tasks(path: Path | None = None) -> list[dict]:
    return _load(path)


def complete_task(task_id: int, path: Path | None = None) -> dict:
    tasks = _load(path)
    for task in tasks:
        if task["id"] == task_id:
            task["done"] = True
            _save(tasks, path)
            return task
    raise ValueError(f"Task {task_id} not found")


def delete_task(task_id: int, path: Path | None = None) -> dict:
    tasks = _load(path)
    for i, task in enumerate(tasks):
        if task["id"] == task_id:
            removed = tasks.pop(i)
            _save(tasks, path)
            return removed
    raise ValueError(f"Task {task_id} not found")

Detailed breakdown

  • _resolve: Converts a None default into the module-level DEFAULT_PATH at call time. This is critical for testability — Python evaluates default arguments once at function definition time, so using path: Path = DEFAULT_PATH would capture the original value and ignore any monkeypatching. The None-sentinel pattern ensures monkeypatch.setattr("taskm.store.DEFAULT_PATH", ...) works correctly in tests.
  • _load / _save: Private helpers that serialize tasks as a JSON array. _load returns an empty list if the file is missing or empty, so the CLI works on first run without setup.
  • _next_id: Generates a monotonically increasing integer ID by taking the max existing ID plus one. This avoids ID collisions even after deletions.
  • add_task: Appends a new task dict with id, description, and done: False, then persists immediately.
  • complete_task / delete_task: Locate a task by ID, mutate or remove it, and persist. Both raise ValueError when the ID does not exist, giving the CLI a clear error path.
  • Every public function accepts an optional path parameter. This makes the module testable — tests can pass a temporary file instead of writing to the real tasks.json.

Step 3: Build the CLI entry point

The CLI uses argparse with subcommands so each operation (add, list, done, delete) is its own verb.

Create the file

touch taskm/cli.py

Add the code: taskm/cli.py

"""Command-line interface for the task manager."""

import argparse
import sys

from taskm.store import add_task, complete_task, delete_task, list_tasks


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="taskm",
        description="A simple CLI task manager.",
    )
    sub = parser.add_subparsers(dest="command")

    add_p = sub.add_parser("add", help="Add a new task")
    add_p.add_argument("description", help="Task description")

    sub.add_parser("list", help="List all tasks")

    done_p = sub.add_parser("done", help="Mark a task as completed")
    done_p.add_argument("id", type=int, help="Task ID to complete")

    del_p = sub.add_parser("delete", help="Delete a task")
    del_p.add_argument("id", type=int, help="Task ID to delete")

    return parser


def main(argv: list[str] | None = None) -> None:
    parser = _build_parser()
    args = parser.parse_args(argv)

    if args.command is None:
        parser.print_help()
        return

    if args.command == "add":
        task = add_task(args.description)
        print(f"Added task {task['id']}: {task['description']}")

    elif args.command == "list":
        tasks = list_tasks()
        if not tasks:
            print("No tasks.")
            return
        for t in tasks:
            status = "x" if t["done"] else " "
            print(f"  [{status}] {t['id']}: {t['description']}")

    elif args.command == "done":
        try:
            task = complete_task(args.id)
            print(f"Completed task {task['id']}: {task['description']}")
        except ValueError as exc:
            print(f"Error: {exc}", file=sys.stderr)
            sys.exit(1)

    elif args.command == "delete":
        try:
            task = delete_task(args.id)
            print(f"Deleted task {task['id']}: {task['description']}")
        except ValueError as exc:
            print(f"Error: {exc}", file=sys.stderr)
            sys.exit(1)


if __name__ == "__main__":
    main()

Detailed breakdown

  • _build_parser: Constructs an ArgumentParser with four subcommands. Each subcommand declares only the arguments it needs — add takes a description string, done and delete take an integer ID, and list takes nothing.
  • main: Parses arguments, dispatches to the appropriate store function, and formats output. When no subcommand is given, it prints help and returns. Using return instead of sys.exit(0) keeps the function testable — sys.exit raises SystemExit, which would require tests to catch the exception. ValueError exceptions from the store layer are caught and printed to stderr with a non-zero exit code.
  • argv parameter: Accepts an optional argument list so tests can call main(["add", "Buy milk"]) directly instead of mocking sys.argv.

Step 4: Add the package runner

A __main__.py file lets the package run with python -m taskm.

Create the file

touch taskm/__main__.py

Add the code: taskm/__main__.py

"""Allow running the package with: python -m taskm"""

from taskm.cli import main

main()

Detailed breakdown

  • This file is the entry point Python invokes when you run uv run python -m taskm. It simply delegates to main().
  • Keeping the actual logic in cli.py rather than here ensures the CLI is importable and testable without triggering execution.

Step 5: Add a Makefile

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

.PHONY: help run test clean

help: ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-12s %s\n", $$1, $$2}'

run: ## Run the task manager (usage: make run ARGS="add Buy milk")
	uv run python -m taskm $(ARGS)

test: ## Run the test suite
	uv run pytest tests/ -v

clean: ## Remove generated files
	rm -rf __pycache__ taskm/__pycache__ tests/__pycache__ .pytest_cache
	rm -f tasks.json

Detailed breakdown

  • help as default target: Running make with no arguments prints a formatted list of targets by scanning for ## comments. This satisfies the Makefile default target rule.
  • run: Invokes the CLI via uv run python -m taskm, using the uv-managed virtual environment. Additional arguments are passed through the ARGS variable (e.g., make run ARGS="add Buy milk").
  • test: Runs pytest with verbose output against the tests/ directory.
  • clean: Removes Python cache directories and the tasks.json data file.

Step 6: Write tests

Create the file

mkdir -p tests
touch tests/__init__.py
touch tests/test_store.py
touch tests/test_cli.py

Add the code: tests/test_store.py

"""Tests for the task storage module."""

from pathlib import Path

from taskm.store import add_task, complete_task, delete_task, list_tasks

import pytest


def test_add_and_list(tmp_path: Path):
    db = tmp_path / "tasks.json"
    add_task("First task", path=db)
    add_task("Second task", path=db)
    tasks = list_tasks(path=db)
    assert len(tasks) == 2
    assert tasks[0]["description"] == "First task"
    assert tasks[1]["description"] == "Second task"
    assert tasks[0]["id"] == 1
    assert tasks[1]["id"] == 2


def test_complete_task(tmp_path: Path):
    db = tmp_path / "tasks.json"
    add_task("Do laundry", path=db)
    result = complete_task(1, path=db)
    assert result["done"] is True
    tasks = list_tasks(path=db)
    assert tasks[0]["done"] is True


def test_complete_missing_task(tmp_path: Path):
    db = tmp_path / "tasks.json"
    with pytest.raises(ValueError, match="Task 99 not found"):
        complete_task(99, path=db)


def test_delete_task(tmp_path: Path):
    db = tmp_path / "tasks.json"
    add_task("Temporary", path=db)
    deleted = delete_task(1, path=db)
    assert deleted["description"] == "Temporary"
    assert list_tasks(path=db) == []


def test_delete_missing_task(tmp_path: Path):
    db = tmp_path / "tasks.json"
    with pytest.raises(ValueError, match="Task 42 not found"):
        delete_task(42, path=db)


def test_ids_increment_after_delete(tmp_path: Path):
    db = tmp_path / "tasks.json"
    add_task("A", path=db)
    add_task("B", path=db)
    delete_task(1, path=db)
    task = add_task("C", path=db)
    assert task["id"] == 3

Detailed breakdown

  • tmp_path fixture: Each test gets a unique temporary directory from pytest, so tests never share state or touch the real filesystem.
  • test_add_and_list: Verifies that adding two tasks produces sequential IDs and both appear in the list.
  • test_complete_task: Confirms the done flag is set to True and persisted.
  • test_complete_missing_task / test_delete_missing_task: Verify that operations on non-existent IDs raise ValueError with the expected message.
  • test_ids_increment_after_delete: Ensures IDs are never reused — after deleting task 1, the next task gets ID 3 (max of remaining ID 2, plus 1).

Add the code: tests/test_cli.py

"""Tests for the CLI interface."""

import os
from pathlib import Path

from taskm.cli import main


def test_add_via_cli(tmp_path: Path, capsys, monkeypatch):
    db = tmp_path / "tasks.json"
    monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
    main(["add", "Buy groceries"])
    captured = capsys.readouterr()
    assert "Added task 1: Buy groceries" in captured.out


def test_list_empty(tmp_path: Path, capsys, monkeypatch):
    db = tmp_path / "tasks.json"
    monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
    main(["list"])
    captured = capsys.readouterr()
    assert "No tasks." in captured.out


def test_list_shows_tasks(tmp_path: Path, capsys, monkeypatch):
    db = tmp_path / "tasks.json"
    monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
    main(["add", "Task one"])
    main(["add", "Task two"])
    capsys.readouterr()  # discard add output
    main(["list"])
    captured = capsys.readouterr()
    assert "[ ] 1: Task one" in captured.out
    assert "[ ] 2: Task two" in captured.out


def test_done_via_cli(tmp_path: Path, capsys, monkeypatch):
    db = tmp_path / "tasks.json"
    monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
    main(["add", "Finish report"])
    capsys.readouterr()
    main(["done", "1"])
    captured = capsys.readouterr()
    assert "Completed task 1" in captured.out


def test_delete_via_cli(tmp_path: Path, capsys, monkeypatch):
    db = tmp_path / "tasks.json"
    monkeypatch.setattr("taskm.store.DEFAULT_PATH", db)
    main(["add", "Old task"])
    capsys.readouterr()
    main(["delete", "1"])
    captured = capsys.readouterr()
    assert "Deleted task 1" in captured.out


def test_no_command_prints_help(capsys):
    main([])
    captured = capsys.readouterr()
    assert "usage:" in captured.out.lower() or "taskm" in captured.out.lower()

Detailed breakdown

  • monkeypatch.setattr: Redirects DEFAULT_PATH in the store module to a temp file so the CLI tests use isolated storage without modifying any real files.
  • capsys: Captures stdout/stderr so assertions can check CLI output strings.
  • test_no_command_prints_help: Confirms that calling the CLI with no arguments prints help text instead of crashing.
  • The tests exercise the full stack — main() calls the store, which writes to disk, and the output is verified. This catches integration issues between the CLI parsing and storage layers.

Step 7: Run and validate

Run the full test suite from the cli-task-manager/ directory:

cd cli-task-manager
make test

Expected output:

tests/test_store.py::test_add_and_list PASSED
tests/test_store.py::test_complete_task PASSED
tests/test_store.py::test_complete_missing_task PASSED
tests/test_store.py::test_delete_task PASSED
tests/test_store.py::test_delete_missing_task PASSED
tests/test_store.py::test_ids_increment_after_delete PASSED
tests/test_cli.py::test_add_via_cli PASSED
tests/test_cli.py::test_list_empty PASSED
tests/test_cli.py::test_list_shows_tasks PASSED
tests/test_cli.py::test_done_via_cli PASSED
tests/test_cli.py::test_delete_via_cli PASSED
tests/test_cli.py::test_no_command_prints_help PASSED

Verify the default Makefile target:

make

Expected output:

  help         Show this help screen
  run          Run the task manager (usage: make run ARGS="add Buy milk")
  test         Run the test suite
  clean        Remove generated files

Try the CLI manually:

uv run python -m taskm add "Write CLI task manager article"
uv run python -m taskm add "Review the article"
uv run python -m taskm list
uv run python -m taskm done 1
uv run python -m taskm list
uv run python -m taskm delete 2
uv run python -m taskm list

Troubleshooting

  • ModuleNotFoundError: No module named 'taskm': Make sure you run commands from the cli-task-manager/ project root so Python can find the taskm package.
  • uv: command not found: Install uv with curl -LsSf https://astral.sh/uv/install.sh | sh and restart your shell.
  • Tasks persist unexpectedly between runs: The CLI writes to tasks.json in the current directory. Run make clean to reset.

Recap

This article built a CLI task manager with four operations (add, list, done, delete) backed by a JSON file. The project uses uv for dependency management, the Python standard library for the application code, argparse subcommands for a clean verb-based interface, and pytest with tmp_path for isolated tests. The storage layer accepts an injectable file path, keeping the module testable without mocks.

Next improvements to consider:

  • Add due dates and priority levels to tasks
  • Add a search subcommand with substring matching
  • Replace JSON with SQLite via the sqlite3 standard library module for concurrent access