Skip to main content

SmartNotes CLI Capstone

If you're new to programming

This is a timed challenge: aim for 25 minutes, but take as long as you need. The goal is combining concepts, not speed. You have every piece from Lessons 1-5. One new concept: making smartnotes add work as a system command (not python -m smartnotes add). The <details> blocks have hints if you get stuck.

If you've coded before

[project.scripts] entry point, uv pip install -e . for editable install. Full TDG on a multi-subcommand CLI. Test with subprocess.run against the installed command, not Python imports.

Emma sets a timer. "You have every piece. argparse, subcommands, exit codes, stderr, pipes. One thing remains: making smartnotes work as a real system command, not python -m smartnotes. That means a [project.scripts] entry point."

She opens James's pyproject.toml. "This file already defines your package. You need one new section. Twenty-five minutes."


The Problem

Right now, James runs SmartNotes like this:

python -m smartnotes add "Python Tips" "My first note"

After this capstone, he will run it like this:

smartnotes add "Python Tips" "My first note"

The difference: a [project.scripts] entry in pyproject.toml tells Python where to find the main() function. After installation, smartnotes becomes a command available anywhere on the system.

The entry point syntax:

[project.scripts]
smartnotes = "smartnotes.cli:main"

This means: when the user types smartnotes, Python calls the main() function in smartnotes/cli.py.

Your deliverables:

FilePurpose
smartnotes/cli.pyCLI module with main() entry point
tests/test_cli.pyEnd-to-end tests using subprocess.run
pyproject.tomlUpdated with [project.scripts] section

Start the timer.


Step 1: Specify (5 minutes)

Open smartnotes/cli.py. Design the module.

The main() function is the entry point. It sets up the argument parser, dispatches to subcommands, and handles top-level errors. Every subcommand you built in Lessons 2-5 becomes a function in this module.

Hint: cli.py structure
"""SmartNotes CLI: a composable command-line note manager."""

import argparse
import sys
from pathlib import Path


def cmd_add(args: argparse.Namespace) -> None:
"""Add a new note."""
...


def cmd_search(args: argparse.Namespace) -> None:
"""Search notes by keyword."""
...


def cmd_list(args: argparse.Namespace) -> None:
"""List all notes."""
...


def cmd_export(args: argparse.Namespace) -> None:
"""Export notes in JSON or CSV format."""
...


def cmd_import(args: argparse.Namespace) -> None:
"""Import notes from a file or stdin."""
...


def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser with all subcommands."""
parser = argparse.ArgumentParser(
prog="smartnotes",
description="A composable command-line note manager",
)
subparsers = parser.add_subparsers(dest="command")

# add
add_parser = subparsers.add_parser("add", help="Add a new note")
add_parser.add_argument("title", help="Note title")
add_parser.add_argument("body", help="Note body")

# search
search_parser = subparsers.add_parser("search", help="Search notes")
search_parser.add_argument("query", help="Search term")

# list
subparsers.add_parser("list", help="List all notes")

# export
export_parser = subparsers.add_parser("export", help="Export notes")
export_parser.add_argument(
"--format", choices=["json", "csv"], default="json", help="Output format"
)

# import
import_parser = subparsers.add_parser("import", help="Import notes")
import_parser.add_argument("file", nargs="?", help="JSON file (or pipe to stdin)")

return parser


def main() -> None:
"""Entry point for the SmartNotes CLI."""
parser = build_parser()
args = parser.parse_args()

if args.command is None:
parser.print_help()
sys.exit(1)

commands = {
"add": cmd_add,
"search": cmd_search,
"list": cmd_list,
"export": cmd_export,
"import": cmd_import,
}

try:
commands[args.command](args)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

Run uv run pyright smartnotes/cli.py. Fix any type issues before writing tests.

Then update pyproject.toml:

[project.scripts]
smartnotes = "smartnotes.cli:main"
Hint: where in pyproject.toml

Add it after the [project] section. If your file already has [project] with name, version, and dependencies, add [project.scripts] below it:

[project]
name = "smartnotes"
version = "0.1.0"
# ... other fields ...

[project.scripts]
smartnotes = "smartnotes.cli:main"

Step 2: Test (7 minutes)

Open tests/test_cli.py. Write end-to-end tests using subprocess.run.

These tests run the actual CLI command as a subprocess, the same way a user would run it in a terminal. This tests the full stack: argument parsing, subcommand dispatch, business logic, and output formatting.

import subprocess


def run_cli(*args: str) -> subprocess.CompletedProcess[str]:
"""Run the smartnotes CLI and capture output."""
return subprocess.run(
["smartnotes", *args],
capture_output=True,
text=True,
)

Think about what to test:

TestWhat it verifies
smartnotes --helpHelp text contains "smartnotes" and lists subcommands
smartnotes add "Title" "Body"Exit code 0, confirmation message on stdout
smartnotes listLists previously added notes
smartnotes search "Title"Finds matching notes
smartnotes export --format jsonValid JSON on stdout
smartnotes (no args)Exit code 1, help text on stdout or stderr
smartnotes search (missing query)Exit code non-zero, error message
Hint: test structure
import os
import subprocess
import json
from pathlib import Path

import pytest


def run_cli(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
"""Run the smartnotes CLI and capture output."""
full_env = {**os.environ, **(env or {})}
return subprocess.run(
["smartnotes", *args],
capture_output=True,
text=True,
env=full_env,
)


def test_help_shows_subcommands() -> None:
result = run_cli("--help")
assert result.returncode == 0
assert "add" in result.stdout
assert "search" in result.stdout
assert "list" in result.stdout
assert "export" in result.stdout


def test_no_args_exits_nonzero() -> None:
result = run_cli()
assert result.returncode != 0


def test_add_and_list(tmp_path: Path) -> None:
env = {"SMARTNOTES_DATA_DIR": str(tmp_path)}
run_cli("add", "Test Note", "This is the body", env=env)
result = run_cli("list", env=env)
assert "Test Note" in result.stdout


def test_export_json(tmp_path: Path) -> None:
env = {"SMARTNOTES_DATA_DIR": str(tmp_path)}
run_cli("add", "Export Test", "Body here", env=env)
result = run_cli("export", "--format", "json", env=env)
data = json.loads(result.stdout)
assert len(data) >= 1

Run uv run pytest tests/test_cli.py -v. Every test should FAIL. The command smartnotes does not exist yet because you have not installed the package.


Step 3: Generate (3 minutes)

First, install the package in editable mode so the smartnotes command becomes available:

uv pip install -e .

This reads your pyproject.toml, finds the [project.scripts] section, and creates the smartnotes command that points to smartnotes.cli:main.

Verify the command exists:

smartnotes --help

Output:

usage: smartnotes [-h] {add,search,list,export,import} ...

A composable command-line note manager

positional arguments:
{add,search,list,export,import}
add Add a new note
search Search notes
list List all notes
export Export notes
import Import notes

options:
-h, --help show this help message and exit

If help works but subcommands do not (because the stubs return ...), prompt Claude Code:

Implement all subcommand functions in smartnotes/cli.py
so that every test in tests/test_cli.py passes.
Do not modify the test file. Use the existing SmartNotes
modules for the business logic.

Step 4: Verify (3 minutes)

Run the full verification stack:

uv run ruff check smartnotes/cli.py
uv run pyright smartnotes/cli.py
uv run pytest tests/test_cli.py -v

Then test the installed command manually:

smartnotes add "First Real Note" "Built with my own CLI"
smartnotes list
smartnotes search "Real"
smartnotes export --format json
smartnotes export --format json | python -c "import sys, json; print(len(json.loads(sys.stdin.read())), 'notes')"
OutcomeWhat to do
All GREENMove to Step 6
Some REDMove to Step 5
Command not foundRe-run uv pip install -e .

Step 5: Debug (5 minutes)

Common issues with CLI entry points:

ProblemCauseFix
smartnotes: command not foundPackage not installedRun uv pip install -e . again
ModuleNotFoundError: No module named 'smartnotes'Missing __init__.py in package directoryCreate smartnotes/__init__.py (can be empty)
AttributeError: module has no attribute 'main'main() function not defined or not named correctlyCheck function name matches pyproject.toml: smartnotes.cli:main
Tests pass but manual run failsTests use a different data directoryCheck SMARTNOTES_DATA_DIR environment variable handling
Import errors between modulesRelative vs absolute importsUse absolute imports: from smartnotes.storage import save_notes

If tests fail, apply the debugging loop. Type /debug in Claude Code to walk through each step:

  1. Read the failure message. Which subcommand failed? What exit code?
  2. Run the failing command manually with verbose output.
  3. Check that smartnotes.cli:main matches your actual function path.
  4. Re-prompt with the failure output or fix manually.
  5. Verify again.
Hint: re-install after changes

After editing cli.py, you do not need to re-install for editable installs. The -e flag means Python reads from your source directory directly. But if you change pyproject.toml (adding a new script or dependency), re-run:

uv pip install -e .

Step 6: Read (2 minutes)

All tests pass. The command is installed. Apply PRIMM: review the generated code before calling it done.

Check these specifics:

  1. Does build_parser() set prog="smartnotes" so help text shows the right command name?
  2. Does main() exit with code 1 when no subcommand is given?
  3. Does the error handler in main() write to stderr (not stdout)?
  4. Does the import subcommand check sys.stdin.isatty() for pipe support?
  5. Are there any hardcoded paths? Everything should use SMARTNOTES_DATA_DIR or a default.

Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Review the Complete CLI

Here is my SmartNotes CLI module:

[paste smartnotes/cli.py]

And my pyproject.toml entry point:

[paste the [project.scripts] section]

Review the complete CLI. Check for:
1. Consistent error handling across all subcommands
2. Missing edge cases (empty arguments, special characters)
3. Whether the help text is clear for a first-time user

What you're learning: A complete CLI review covers more than logic. Help text quality, error message consistency, and argument validation are what separate a script from a tool other people can use.

Prompt 2: Suggest Improvements

My SmartNotes CLI has 5 subcommands: add, search, list,
export, import. What features would make it more useful?

Suggest 3 improvements ranked by effort (low/medium/high).
For each, show the argparse changes needed and estimate
how many tests I would add.

What you're learning: Feature planning for CLI tools follows the same pattern as any product: identify user needs, estimate effort, prioritize. The AI suggests features you have not considered, like --verbose flags, output coloring, or tab completion.

Prompt 3: Rate My TDG Cycle

I just completed a CLI capstone using the TDG cycle:

1. Designed cli.py stub with build_parser() and 5 subcommand functions
2. Added [project.scripts] to pyproject.toml
3. Wrote [N] tests using subprocess.run
4. Installed with uv pip install -e .
5. Generated implementation: [passed first try / needed N iterations]
6. Manual verification: tested all 5 subcommands + pipe chain

Rate my TDG cycle. What should I do differently next time?

What you're learning: You are evaluating your own development process. The AI assesses the quality of your specifications, tests, and verification, not just the final code. Building this habit of self-assessment makes each cycle faster.


James installs the CLI. He opens a fresh terminal, navigates to his home directory, and types:

smartnotes add "First Real Note" "Built with my own CLI"

It works. Not python -m smartnotes. Not python smartnotes/cli.py. Just smartnotes.

"It went from a Python library to a real command," he says.

He runs the full chain:

smartnotes add "Warehouse Log" "Sorting efficiency improved by 12%"
smartnotes list
smartnotes export --format json | python count_words.py

Three commands. The last one pipes export into a standalone tool. Everything connects.

Emma was watching the timer. "Eighteen minutes. Under budget."

"The entry point was the easy part," James says. "One line in pyproject.toml. The hard part was everything before it: the argument parser, the subcommands, the error handling, the pipe support. All that infrastructure from Lessons 1 through 5."

"That is the pattern," Emma says. "The visible feature is the tip of the iceberg. The entry point is one line. The argument parsing, error handling, exit codes, and composability underneath it are four lessons of work." She pauses. "But it is still a local tool. It runs on your machine, stores data in local files."

"What if someone else needs to use it? A mobile app, a browser, a chatbot?"

"They would need a way to call SmartNotes over the network. An API."

"But SmartNotes processes one command at a time. What if two people call it at once?"

Emma smiles. "That is exactly the problem. Your export command writes files sequentially. What about writing three files at once? What about handling ten requests at the same time? That is concurrency. Chapter 66 introduces it."