pokecli: A Small, Typed CLI for PokeAPI

CLI Tool ยท

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.

Note GitHub repository: pokecliย โžก๏ธ

Technologies Used

LayerChoiceWhy it is in the stack
LanguagePython 3.12+Modern typing, structural pattern matching
CLI frameworkTyperComposable sub-apps, native type hints, clean context injection
Data modelsPydantic v2Strict validation where required, extra="ignore" on every model
HTTP clienthttpxClean context-manager lifecycle, future async path
Local cacheTinyDBOne JSON file, table per resource, human readable
Terminal UIRichTables, panels, JSON syntax highlighting, ASCII fallbacks
Packaginguv + uv_buildFast resolver, modern src/ layout, [project.scripts]
Testingpytesttmp_path cache fixture, one model test file per resource
Toolingruff, pre-commitFormatting, 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

CLIs grow by resource, and a monolithic main.py becomes the bottleneck for every change.
Every file under 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

Every command needs an HTTP client, but I did not want connection setup, teardown, and test wiring repeated across the entire command set.

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

Cache lookups, 404 messages, network failures, and exit codes would drift if every command handled them independently.

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

PokeAPI is deep, optional, and occasionally changes shape. The CLI should be resilient to new fields and loud about missing ones.
Every model uses 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 codeMeaning
0Success
1Expected failure (not found, network down)
2Schema failure (upstream response did not match the model)

A shell script wrapping pokecli can act on this.

5. Choosing TinyDB over SQLite or Redis

I wanted a local cache, no infrastructure, easy to inspect.
TinyDB gives me one JSON file I can 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

Terminal libraries leak into everything they touch. A renderer should never import the API client, and a model should never know it will be rendered.
Everything Rich-related lives under 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

AI-native tooling is often an afterthought pinned to a README.

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:

  1. the frontmatter is the trigger layer, with the skill name, a short description, and the allowed-tools boundary
  2. the SKILL.md body is the working command guide, organized around the same command groups the CLI already exposes
  3. references/api-fields.md holds 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

AreaWhat the repo shows
Python packagingsrc/ layout, uv_build, working [project.scripts] entry, importlib.resources to ship non-Python assets
CLI architectureTyper composition with one sub-app per resource, context-based dependency injection, a single cache-then-HTTP gateway
Domain modellingPydantic v2 as an anti-corruption layer, strict-required and extra="ignore", model_dump() as the single source of truth for JSON output
Error ergonomicsDistinct exit codes for expected failures and schema failures, human messages on stderr, machine-readable output on stdout
Pragmatic cachingZero-infrastructure TinyDB store, per-resource partitioning, opt-out via --no-cache
Terminal UXRich tables and panels, Unicode stat bars with ASCII fallback, consistent colour maps across resources
Testing disciplineOne model test file per resource, cache-store contract tests, a tmp_cache fixture, no brittle Rich assertions
AI-native designShipped 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.

OutcomeEvidence in the repo
Agent-ready in one commandAfter 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 hitThe 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 layerAdding 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 codesA 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 suitePydantic 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 infrastructureNo 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 APINine 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.

_ Using pokecli with Claude Code
You
Look up Pikachu’s stats
Agent
Agent
Agent

Here are Pikachu’s base stats:

StatValue
HP35
Attack55
Defense40
Sp. Attack50
Sp. Defense50
Speed90

Pikachu is a pure Electric type with decent Speed being its standout stat. Its abilities are Static (may paralyze on contact) and the hidden ability Lightning Rod (draws Electric moves, raises Sp. Atk).

You
Compare Thunderbolt and Flamethrower
Agent
Agent

They’re nearly identical moves โ€” just different types and status effects:

ThunderboltFlamethrower
TypeElectricFire
CategorySpecialSpecial
Power9090
Accuracy100%100%
PP1515
Effect10% Paralyze10% Burn

The only meaningful differences:

  • Paralysis (Thunderbolt) halves the target’s Speed and has a chance to skip turns โ€” generally considered the stronger status
  • Burn (Flamethrower) deals chip damage each turn and halves the target’s Attack (less useful against special attackers)

Both are among the most reliable moves in the game. Choice between them comes down to type coverage needed on your team.


Photo by Jay โžก๏ธ on Unsplash โžก๏ธ

Pokรฉmon and Pokรฉmon character names are trademarks of Nintendo.