Tutorials · · 1,008 words · 5 min read
Claude Code from a one-paragraph spec: how do you build a CLI tool?
A method walkthrough for building a small CSV CLI with a coding agent: the spec, the prompts in order, the acceptance tests, and the checks that say it is done.
tutorial spec argparse acceptance tests claude code
This is a method walkthrough, not a transcript. No agent ran in this session. What ran here was Python 3.13.12 on Windows: the finished tool, its nine acceptance tests, and three command-line checks, all shown below with their real output. The prompts are the ones I would send, in the order I would send them, and they are labelled as such. If you want the version of this article where an agent actually drives, the dataset and runner in Time-to-green on ten small tasks are where those runs will land.
The tool is tally, a CSV column summariser. It is small enough to read in one sitting and large enough to have edge cases an agent will get wrong without a spec.
The spec, one paragraph plus six headings
The spec format is the one from Spec first: writing the task the agent will actually succeed at. The paragraph is what a colleague would say; the headings are what an agent needs and a colleague would have asked about.
Goal
tally: a Python CLI that summarises one numeric column of a CSV file or
prints its first rows. `tally stats FILE --col NAME` prints count, min,
max, mean and median of NAME. `tally head FILE [-n N]` prints the header
and the first N data rows (default 5).
Non-goals
No pandas, no third-party packages, no type inference across columns,
no locale handling, no output formats other than plain text.
Interfaces
Exit 0 on success. Exit 2 when the file cannot be read, the column is
not in the header, a cell is non-numeric, or the column has no numeric
values. Empty cells are skipped and reported as "blank: N". Errors go
to stderr prefixed "tally:". Stats output is seven "key: value" lines.
Files
tally.py (single file, argparse subcommands, main(argv, out, err) -> int)
tally_test.py (unittest, drives main() directly, no subprocess)
Verification
python -m unittest tally_test.py exits 0. Then three manual checks:
stats on a sample with a blank cell, stats on a missing column
(expect exit 2), head -n 2.
Budget
One session, at most 25 turns, at most 2 USD of API spend.
Two design decisions live in that spec and nowhere else. First, main(argv, out, err) takes its streams as parameters so tests can call it in-process. The unittest docs describe the framework as supporting "aggregation of tests into collections" and running from the command line with python -m unittest test_module1; in-process calls keep that fast. Second, the exit code for every failure is 2. That is the same value argparse itself uses when it rejects arguments, so callers see one code for "bad input" whatever the cause.
The prompts, in order
The Claude Code best-practices page lists a "Provide verification criteria" pattern whose example prompt names concrete test cases and ends with "run the tests after implementing". The sequence below is that pattern stretched over a whole feature. Each block is the prompt I would send; none of these were sent in this session.
Turn 1, the spec. Paste the spec verbatim, then:
Read the spec above. Before writing any code, list the edge cases you
see and any ambiguity. Do not implement yet.
The point of a no-code first turn is cheap disagreement. If the agent asks whether head -n 0 should print the header, that is a spec hole I would rather find now than in review. I would answer inline and let the spec text stand as the record.
Turn 2, tests first.
Write tally_test.py from the spec. Nine tests: five for stats (happy path
with a blank cell, integer column, missing column exits 2 and names the
available columns, non-numeric cell exits 2 with a line number, all-blank
column exits 2), one for a missing file, three for head (default, -n 2,
-n larger than the file). Write to a temp CSV inside each test. Do not
write tally.py yet. Run the suite and show me the failures.
Asking for the failing run is deliberate. Nine failing tests with ModuleNotFoundError is the red state that Tests first with an agent is built around; a suite that passes before the implementation exists is a suite that tests nothing.
Turn 3, implement.
Now write tally.py so the suite passes. Standard library only: argparse
with add_subparsers, csv.DictReader, statistics.fmean and median. Keep
main(argv, out, err) -> int. Run the tests and paste the output.
Turn 4, the manual checks. These are the three commands from the Verification section. In a permission mode that asks before shell commands, this is the turn where I would approve python tally.py ... once and let it repeat.
Turn 5, review. Not "looks good?" but the diff checklist from The review loop: any test edited after it first passed, any exception swallowed, any behaviour outside the spec.
What ran here
The shipped file build-a-cli-tool-with-an-agent-from-a-spec.py is the implementation I wrote by hand against that spec, and the _test.py beside it is the nine-test suite. This is the exact output from this session:
$ python -m unittest build-a-cli-tool-with-an-agent-from-a-spec_test.py
.........
----------------------------------------------------------------------
Ran 9 tests in 0.030s
OK
The three manual checks, on a four-row sample with one blank price and one blank quantity:
$ python build-a-cli-tool-with-an-agent-from-a-spec.py stats parts.csv --col price
column: price
count: 3
blank: 1
min: 0.25
max: 2
mean: 1.25
median: 1.5
exit=0
$ python build-a-cli-tool-with-an-agent-from-a-spec.py stats parts.csv --col cost
tally: no column named 'cost' (have: name, price, qty)
exit=2
$ python build-a-cli-tool-with-an-agent-from-a-spec.py head parts.csv -n 2
name,price,qty
bolt,1.5,10
nut,,4
exit=0
The core of the implementation is two functions. The parser is plain argparse; the docs describe add_subparsers() as returning "a special action object" whose add_parser() "takes a command name and any ArgumentParser constructor arguments, and returns an ArgumentParser object that can be modified as usual", which is all the subcommand plumbing needs.
def column_values(rows, col):
"""Numeric values of a column plus the number of blank cells skipped."""
values, blanks = [], 0
for i, row in enumerate(rows, start=2): # line 1 is the header
raw = (row.get(col) or "").strip()
if raw == "":
blanks += 1
continue
try:
values.append(float(raw))
except ValueError:
raise ValueError(f"line {i}: {raw!r} in column {col!r} is not a number") from None
return values, blanks
The start=2 is the kind of detail a spec cannot carry and a test can: the non-numeric test asserts that the error names line 3 for the second data row, so an off-by-one shows up as a red test rather than a confused user.
Where an agent would have gone wrong
I can only say where I nearly went wrong, since no agent ran. Three places.
The empty-cell rule interacts with csv.DictReader in a way the spec does not spell out: a short row yields None for missing trailing cells, not an empty string, which is why the code reads row.get(col) or "". A test with a trailing blank (screw,2, in the sample) catches an implementation that only checks for "".
The exit-code rule needs the file-not-found case to go through the same path. The first draft let open() raise, which exits 1 with a traceback. The test_missing_file_exits_2 test is what pins it.
Median on an even count is the mean of the two middle values, so statistics.median of three prices is the middle one and of four would be a midpoint. The spec says "median" and nothing more; the docs define the function, and the test on qty (values 10, 4, 100) expects 10, which only works if blanks are excluded before the median is taken.
Running it headless
If the whole loop were scripted rather than interactive, the Claude Code non-interactive page shows the shape: claude -p "..." --allowedTools "Bash,Read,Edit" runs without prompts for the named tools, and --output-format json returns a payload that "includes total_cost_usd and a per-model cost breakdown", which the same page calls client-side estimates. For a from-spec build I would still start interactive and switch to -p only once the spec had survived one human-driven run, because the no-code first turn is where most of the value is and a script cannot argue back.
Limits of this walkthrough
The tool is 130 lines and one file. A spec this tight works because there is nothing to discover in the codebase; on a real repository the "Files" section becomes the hard part and the exploration turn grows. And the checks here are the ones I chose, which means the suite proves the tool matches my reading of the spec, not that the spec is what a user wanted. The next step, which this article does not take, is to hand the same spec to an agent under the budget in the last section and record the result in the time-to-green dataset.
Code and data
- build-a-cli-tool-with-an-agent-from-a-spec.py — the complete listing used in this article.
Sources
- Python Software Foundation, "argparse — Parser for command-line options, arguments and subcommands" (Python 3 docs, read 2026-09-05)
- Python Software Foundation, "unittest — Unit testing framework" (Python 3 docs, read 2026-09-05)
- Anthropic, "Best practices for Claude Code" (Claude Code docs, read 2026-09-05)
- Anthropic, "Run Claude Code programmatically" (Claude Code docs, read 2026-09-05)
- Anthropic, "Choose a permission mode" (Claude Code docs, read 2026-09-05)