
pokecli: A Small, Typed CLI for PokeAPI
A typed, script-friendly Python CLI for PokeAPI. Installed with uv, cached locally with TinyDB, and agent-ready through a shipped Claude Code skill.
Project Description
pokecli is an open source command-line tool I built to query Pokemon, Berries, Items, Moves, Abilities, Natures, Types, evolution chains, and species data from PokeAPI. It is deliberately small, and it is one of the reference implementations I point to when I talk about how I design a command line tool. The project also doubles as my implementation example for AI-native tooling: pokecli ships with a SKILL.md that an agent like Claude Code or Copilot can load to learn the command set without re-reading --help on every task.

Technologies Used
| Layer | Choice | Why it is in the stack |
|---|---|---|
| Language | Python 3.12+ | Modern typing, structural pattern matching |
| CLI framework | Typer | Composable sub-apps, native type hints, clean context injection |
| Data models | Pydantic v2 | Strict validation where required, extra="ignore" on every model |
| HTTP client | httpx | Clean context-manager lifecycle, future async path |
| Local cache | TinyDB | One JSON file, table per resource, human readable |
| Terminal UI | Rich | Tables, panels, JSON syntax highlighting, ASCII fallbacks |
| Packaging | uv + uv_build | Fast resolver, modern src/ layout, [project.scripts] |
| Testing | pytest | tmp_path cache fixture, one model test file per resource |
| Tooling | ruff, pre-commit | Formatting, linting, commit hygiene |
Architecture
pokecli is organised as five layers that each know about exactly one thing. Commands know Typer. The API client knows httpx. The cache knows TinyDB. The models know Pydantic. The display layer knows Rich. Nothing crosses its lane.
flowchart LR
subgraph CLI["Typer composition"]
MAIN["main.py\nroot app"]
DOMAIN_CMDS["commands/\npokemon.py\nberry.py\nimage.py\n..."]
CACHE_CMD["commands/cache.py"]
end
subgraph CORE["Shared core"]
UTILS["commands/_utils.py"]
CLIENT["api/client.py"]
STORE["cache/store.py"]
end
subgraph DATA["Domain and presentation"]
MODELS["models/*.py"]
RENDER["display/*.py"]
end
MAIN --> DOMAIN_CMDS
MAIN --> CACHE_CMD
DOMAIN_CMDS --> UTILS
DOMAIN_CMDS --> MODELS
DOMAIN_CMDS --> RENDER
UTILS --> CLIENT
UTILS --> STORE
CACHE_CMD --> STORE
Every get command follows the same short pipeline. The module boundaries enforce it, not convention.
flowchart LR
A([Request]) --> B{Cache hit?}
B -- yes --> C[Cached data]
B -- no --> D[API fetch]
D --> E[Update cache]
C --> F[Transform response]
E --> F
F --> G{Output mode}
G -- table --> H[Table view]
G -- json --> I[JSON view]
Package layout at a glance
src/pokecli/
โโโ main.py # root Typer app, sub-apps registered here
โโโ config.py # POKEAPI_BASE_URL, DEFAULT_LIMIT, CACHE_DB_PATH
โโโ api/client.py # PokeAPIClient (httpx + context manager)
โโโ cache/store.py # CacheStore (TinyDB, table per resource)
โโโ commands/
โ โโโ _utils.py # fetch_resource, fetch_list
โ โโโ pokemon.py # get / moves / species / evolution / list
โ โโโ berry.py item.py move.py ability.py nature.py type.py
โ โโโ image.py # download (sprite variants)
โ โโโ cache.py # stats / clear
โ โโโ install.py # install --skills
โโโ display/ # Rich renderers, one file per resource
โโโ models/ # Pydantic v2 models, one file per resource
โโโ skills/pokecli/
โโโ SKILL.md
โโโ references/api-fields.md
Key Features
These features show the feature set of pokecli and the kind of command-line experience I aim to build: small, typed, scriptable, and clear at a glance.
Resource queries with typed output
Every resource (pokemon, berry, item, move, ability, nature, type) has parallel get and list commands with shared flags (--no-cache, --format). Specialised Pokemon commands add moves, species, and evolution.

Sprite downloads
pokecli image download pokemon <name_or_id> -o <path> saves sprites locally. The --variant flag selects between six well-known views.
Local cache with per-resource control
First call goes to the network, every call after that is served from ~/.pokecli/cache.json. Users can inspect or clear the cache at resource granularity.

Technical challenges
This section shows the engineering tradeoffs behind pokecli. Each card highlights a problem I had to solve, the design choice I made, and what that choice says about how I build command-line tools.
1. Keeping each resource a self-contained Typer app
main.py becomes the bottleneck for every change.commands/ exposes its own app = typer.Typer(...). main.py is ten lines of imports and app.add_typer(...) calls. Adding Abilities, Natures, and Types later cost one file and one line each.2. Using the Typer context as a dependency-injection seam
The root callback attaches the HTTP client as a managed resource on typer.Context:
@app.callback()
def root(ctx: typer.Context) -> None:
ctx.ensure_object(dict)
ctx.obj["client"] = ctx.with_resource(PokeAPIClient())
ctx.with_resource closes the client cleanly when Typer tears down the command. In tests, swapping the client for a fake is a one-liner.
3. Centralising the cache-then-HTTP flow in one utility
commands/_utils.py is the one gateway. Every command calls fetch_resource or fetch_list. If I add backoff, rate limiting, or a trace flag later, I touch exactly one function.
def fetch_resource(client, resource, name_or_id, no_cache, err_console):
with CacheStore() as cache:
key = name_or_id.lower()
data = None if no_cache else cache.get(resource, key)
if data is None:
try:
data = client.get_resource(resource, name_or_id)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
err_console.print(f"[red]Not found: '{name_or_id}'[/red]")
else:
err_console.print(f"[red]API error: {e.response.status_code}[/red]")
raise typer.Exit(1)
except (httpx.ConnectError, httpx.TimeoutException):
err_console.print("[red]Network error: could not reach PokeAPI[/red]")
raise typer.Exit(1)
cache.set(resource, key, data)
return data
4. Treating Pydantic as the contract with the upstream API
ConfigDict(extra="ignore"). Required fields are strict. A missing required field raises ValidationError, which the command translates into a friendly message and typer.Exit(2). Exit codes say which layer failed.| Exit code | Meaning |
|---|---|
0 | Success |
1 | Expected failure (not found, network down) |
2 | Schema failure (upstream response did not match the model) |
A shell script wrapping pokecli can act on this.
5. Choosing TinyDB over SQLite or Redis
cat, rm, or diff. I would not reach for it in a server. For a CLI the cost is right, and the per-resource table layout lets pokecli cache clear --resource pokemon act on a slice.6. Isolating Rich in the display layer
display/. A shared display/common.py hosts METHOD_COLORS, TYPE_COLORS, a uses_unicode sniffer, and a get_chars helper with ASCII fallbacks. render_json and render_list are the shared renderers. No renderer calls the API, no model imports Rich.7. Shipping the SKILL.md inside the package
SKILL.md and its references/api-fields.md live inside the wheel under pokecli/skills/pokecli/. install.py uses importlib.resources to copy them into ~/.claude/skills/pokecli/, so the agent-facing layer ships with the CLI instead of living as a separate doc.
I kept the skill small on purpose and split it into three layers an agent can load cheaply:
- the frontmatter is the trigger layer, with the skill name, a short description, and the
allowed-toolsboundary - the
SKILL.mdbody is the working command guide, organized around the same command groups the CLI already exposes references/api-fields.mdholds the field-level detail that only matters when an agent needs more depth
That structure makes the implementation AI-native without turning it into a second interface. The packaged skill mirrors the real command surface, installs with one command, and gives Claude Code or Copilot enough context to act on pokecli without re-reading --help on every task.
What this project demonstrates
| Area | What the repo shows |
|---|---|
| Python packaging | src/ layout, uv_build, working [project.scripts] entry, importlib.resources to ship non-Python assets |
| CLI architecture | Typer composition with one sub-app per resource, context-based dependency injection, a single cache-then-HTTP gateway |
| Domain modelling | Pydantic v2 as an anti-corruption layer, strict-required and extra="ignore", model_dump() as the single source of truth for JSON output |
| Error ergonomics | Distinct exit codes for expected failures and schema failures, human messages on stderr, machine-readable output on stdout |
| Pragmatic caching | Zero-infrastructure TinyDB store, per-resource partitioning, opt-out via --no-cache |
| Terminal UX | Rich tables and panels, Unicode stat bars with ASCII fallback, consistent colour maps across resources |
| Testing discipline | One model test file per resource, cache-store contract tests, a tmp_cache fixture, no brittle Rich assertions |
| AI-native design | Shipped SKILL.md, one-command install into ~/.claude/skills/pokecli/, frontmatter-driven triggering |
Taken together, these choices show why pokecli works well as a reference project: the packaging is clean, the boundaries are explicit, the failure modes are intentional, and the tool remains small enough to understand without losing rigor.
Results
pokecli is the smallest piece of code I can point to that still shows every habit I apply to a production service with a command line interface.
| Outcome | Evidence in the repo |
|---|---|
| Agent-ready in one command | After uv tool install from the repo, or uv tool install git+https://github.com/jebucaro/PokeCli, pokecli install --skills copies SKILL.md into ~/.claude/skills/pokecli/. Claude Code picks up every subcommand on the next session, no prompt tuning required. |
| Sub-second interaction after the first hit | The TinyDB cache turns a warm pokemon get charizard from a network round trip into a local JSON read, and works fully offline once populated. |
| One resource equals one file per layer | Adding a new PokeAPI resource is a three-file pattern: commands/<resource>.py, models/<resource>.py, display/<resource>.py. No central registry, no hidden wiring. |
| Script-friendly exit codes | A documented 0 / 1 / 2 contract lets shell scripts and CI pipelines consume pokecli without parsing stderr. Expected failures and schema failures are distinguishable. |
| Deterministic test suite | Pydantic models and the cache store are covered end to end, with a tmp_path fixture so the suite never touches ~/.pokecli/ or the live API. |
| Only the required infrastructure | No database server, no config daemon, no cloud dependency. uv sync inside a clone, is the entire setup on Linux, macOS, and Windows. |
| Typed contract with an external API | Nine resources (pokemon, berry, item, move, ability, nature, type, image, cache) validated through Pydantic v2, insulating callers from upstream schema drift. |
The product read is just as important as the code read. pokecli turns a public API into a calm, scriptable tool, and the shipped SKILL.md makes that CLI AI-native by teaching an agent how to use it even when the model was never trained on pokecli itself. That keeps the footprint small for a human contributor and still legible to an agent in the next session.
Photo by Jay โก๏ธ on Unsplash โก๏ธ
Pokรฉmon and Pokรฉmon character names are trademarks of Nintendo.

