DEV Community

Cover image for Fitz CLI builder: like typer, but in the language
Martin Palopoli
Martin Palopoli

Posted on

Fitz CLI builder: like typer, but in the language

Build native CLI tools in Fitz with @command, no library to install. Help auto-generated, type-coerced flags, positional args by convention, native binary out the door. The same language that powers HTTP services builds your scripts.

Why a CLI builder in the language?

Most CLI work in Python lives in typer, click, or argparse. They're all decent libraries; typer in particular is delightful. The Rust answer is clap. The Go answer is cobra or urfave/cli. The Node answer is commander.js or yargs. Every language has a CLI library. Every one of them is a library.

A library is fine until you remember that:

  • The library's conventions are imposed on the rest of your code (decorators, factory objects, builder DSLs).
  • The help text formatting is the library's decision, not the language's.
  • Distributing the result needs a packager (pyinstaller, pyox, Docker, etc.) on top.
  • Cross-platform behavior depends on the library's coverage, not the language's.
  • Adding a --flag is changing a function signature plus a decorator call plus maybe a config object.

What if the same compiler that produces your HTTP server also produces your CLI tools, with the same type checker, the same async/await, the same Result<T> for errors, the same native binary as output?

That's the Fitz CLI builder.

The full hello-world

// main.fitz
@command("greet", desc="Greet a person")
fn greet(name: Str, loud: Bool = false, count: Int = 1) -> Int {
    let n = count
    while n > 0 {
        if loud {
            print("HELLO, {name}!")
        } else {
            print("hello, {name}")
        }
        n = n - 1
    }
    return 0
}
Enter fullscreen mode Exit fullscreen mode

Run it locally during development:

$ fitz run main.fitz greet Ada
hello, Ada

$ fitz run main.fitz greet Ada --loud --count 3
HELLO, Ada!
HELLO, Ada!
HELLO, Ada!
Enter fullscreen mode Exit fullscreen mode

Compile to a self-contained binary:

$ fitz build
$ ls -lh ./greeter
-rwxr-xr-x  1  user  user   5.2M  Jun  5 14:00 ./greeter

$ ./greeter greet Ada --loud
HELLO, Ada!
Enter fullscreen mode Exit fullscreen mode

5 MB native binary, statically linked except libc. No pyinstaller. No pyz. No Python interpreter on the target machine.

The convention: no @arg/@flag decorators

typer and click ask you to mark every parameter with a decorator that says "this is a positional argument" or "this is a flag". Fitz uses a convention that covers the same ground without the verbosity:

  • Param without a default → positional argument required (mybin greet <name>).
  • Param with a default → flag (--name <value>, or --loud for Bool flags).
  • Bool = false → flag bool (--loud enables, no value).
  • Int = N, Float = X, Str = "..." → flag with value (--count 3).

The signature fn greet(name: Str, loud: Bool = false, count: Int = 1) -> Int tells the compiler exactly:

Param Convention CLI
name: Str no default → positional greet <name>
loud: Bool = false bool with default → flag --loud (presence = true)
count: Int = 1 int with default → flag with value --count 3

The trade-off: you can't have positional optional args. If you want one, declare it Str? (nullable) and match on it in the body — the shape stays right, the body handles the missing case explicitly.

I lived with this trade-off for a week before I started writing CLIs in Fitz instead of typer, and I haven't gone back. The signature is the CLI definition.

Help is auto-generated

$ ./mybin --help
USAGE:
    mybin <command> [ARGS] [OPTIONS]

COMMANDS:
    greet    Greet a person

$ ./mybin greet --help
USAGE:
    mybin greet <name> [OPTIONS]

ARGUMENTS:
    <name>     (required)

OPTIONS:
    --loud, -l           default: false
    --count, -c <count>  default: 1
    -h, --help           show this help
Enter fullscreen mode Exit fullscreen mode

Short flags (-l, -c) are auto-derived from the long flag's first letter. If two flags would collide on the same letter, the compiler tells you at build time (not at runtime, not when the user discovers it):

✗ @command("greet") short flag conflict: `--loud` and `--limit`
  share first letter `-l`. Rename one, or opt out one with
  `@flag(short=null)`.
Enter fullscreen mode Exit fullscreen mode

The output formatting follows clap conventions because clap already optimized this; no point in reinventing.

Multi-command dispatch

@command("greet", desc="Greet a person")
fn greet(name: Str) -> Int { print("hello, {name}"); return 0 }

@command("add", desc="Sum two numbers")
fn add(a: Int, b: Int) -> Int { print("{a + b}"); return 0 }

@command("status", desc="Check service status")
async fn status(url: Str = "http://localhost:8080") -> Int {
    // ... HTTP call to the URL ...
    return 0
}
Enter fullscreen mode Exit fullscreen mode
$ ./mybin --help
COMMANDS:
    greet     Greet a person
    add       Sum two numbers
    status    Check service status

$ ./mybin add 21 21
42

$ ./mybin status --url http://prod.example.com
✓ healthy
Enter fullscreen mode Exit fullscreen mode

The dispatch is generated at build time. There's no runtime command lookup table to maintain.

Native async, because why not

The status command above is async. The compiler detects this and wraps the dispatch in #[tokio::main] automatically:

@command("fetch", desc="Fetch a URL and print the body")
async fn fetch(url: Str) -> Int {
    // Suppose `http.get` were a built-in (it's not yet; this is illustrative).
    let body = http.get(url).await?
    print(body)
    return 0
}
Enter fullscreen mode Exit fullscreen mode

The same async/await you use in HTTP handlers works in CLI commands. No asyncio.run() boilerplate. No "this command is async, that one isn't, please don't mix". Just async fn when you want it.

Exit codes

CLI tools live and die by exit codes. The convention is POSIX:

  • 0 — success.
  • 1+ — returned by the handler explicitly.
  • 2 — CLI parsing error (unknown flag, bad type, missing positional).
@command("validate", desc="Check a file")
fn validate(path: Str) -> Int {
    let contents = read_file(path)
    if (contents.starts_with("FAIL")) {
        print("validation failed")
        return 1   // distinct from internal error
    }
    return 0
}
Enter fullscreen mode Exit fullscreen mode
$ ./mybin validate ok.txt
$ echo $?
0

$ ./mybin validate fail.txt
validation failed
$ echo $?
1

$ ./mybin validate
✗ greet: missing positional argument `<path>`
$ echo $?
2
Enter fullscreen mode Exit fullscreen mode

The CLI parser handles the parsing errors. Your code handles the business errors. Clean.

The full power of the language

This is where the Fitz approach pays off in a way that typer can't: you have the entire language at your disposal in CLI tools, not just functions and prints.

CLI tools with the ORM

You need a database admin CLI? It's the same ORM as the HTTP service:

@command("list-users", desc="Print all users")
async fn list_users(db: DbConn) -> Int {
    let users = User.all(db).await
    for u in users {
        print("#{u.id}\t{u.email}\t{u.role}")
    }
    return 0
}

@command("delete-user", desc="Delete a user by email")
async fn delete_user(db: DbConn, email: Str, force: Bool = false) -> Int {
    if not force {
        print("Use --force to actually delete")
        return 1
    }
    User.where(fn(u) => u.email == email).delete(db).await
    print("deleted")
    return 0
}
Enter fullscreen mode Exit fullscreen mode

DbConn injection works the same way it does in HTTP handlers. Closure-to-SQL in .where(...) works. Eager loading works. The migrations from fitz db diff/migrate work. The CLI tool has the same access to your data layer as the API server, with the same type safety.

CLI with HTTP calls

You want a CLI that talks to your own API? Same language, no separate SDK:

@command("ping-prod", desc="Hit the prod /healthz")
async fn ping_prod() -> Int {
    let url = env_or("PROD_URL", "https://api.example.com")
    // ... use the standard library or `from python import requests` etc ...
    return 0
}
Enter fullscreen mode Exit fullscreen mode

CLI as a wrapper around Python tools

Need to wrap a Python tool but with better UX? Python interop is in Fitz:

from python import subprocess

@command("backup-db", desc="Run pg_dump and upload to S3")
async fn backup_db(db_url: Str, bucket: Str) -> Int {
    let dump_cmd = "pg_dump {db_url} > backup.sql"
    subprocess.run(dump_cmd, shell=true).await?
    // ... upload to S3 ...
    return 0
}
Enter fullscreen mode Exit fullscreen mode

The CLI is in Fitz. The pg_dump call is shell. The S3 upload could be Python with boto3 (from python import boto3). One binary, three ecosystems composed.

CLI with config + secrets

The same Secret<T> and config(...) from Part 3 work in CLI tools:

@command("deploy", desc="Push a release")
async fn deploy(env: Str = "staging") -> Int {
    let api_key: Secret<Str> = secret("DEPLOY_API_KEY")
    let endpoint: Str = config("DEPLOY_ENDPOINT", "https://deploy.internal")
    // ... use api_key.expose() in the request ...
    return 0
}
Enter fullscreen mode Exit fullscreen mode

api_key never leaks to logs even if the deploy command's error path prints the config map. The same redaction rules from HTTP services apply here.

Boilerplate cli-tool

The repo has a boilerplate for CLI projects at boilerplates/cli-tool/. It ships three commands as a starter:

  • report — generates a summary from data.
  • count — counts items.
  • regions — lists categories.
fitz new my-cli --template cli-tool
cd my-cli
fitz dev
Enter fullscreen mode Exit fullscreen mode

Hot reload works in CLI mode too — fitz dev watches the source, kills and respawns when you save.

What's not in the box

  • Interactive prompts (think inquirer / click.prompt). Fitz doesn't have read_line style stdin readers in the core yet. For now, accept a flag and feed it via env or piped stdin.
  • TUI (terminal UI with cursor control). Not in scope.
  • Shell completion generation (bash/zsh/fish). Not generated automatically yet. The handwritten _complete file approach still works.
  • --no-foo for Bool = true flags. Bools default to false; if you want a flag that defaults to true and you opt out, the convention is to invert the boolean in your business logic for now. Tracked as a minor debt.

If any of these block you, file an issue. They're either small enough to add or already in progress.

Why this matters

Every developer who builds web services also ends up writing CLI tools. Migrations to run, ad-hoc imports, smoke tests, deploy scripts, admin actions, data exports. The choice today in most languages is:

  • Same language as the service, with a separate CLI library (Python: typer).
  • Bash / shell script that calls your service via HTTP (lose all type safety).
  • A separate language for ops (Go binaries next to a Python service — fine, but two languages to maintain).

Fitz proposes a fourth: same compiler, same type checker, same ORM, same async, same binary format. Your CLI tools live in the same repo, share the same type User, hit the same Postgres with the same connection logic.

You can write mybin migrate and mybin reset-test-db in the same .fitz file as your @get("/users") handlers if you want. Or split into separate commands per binary. The choice is yours, not the library's.

Try it:

# Linux / macOS / WSL
curl -sSf https://thegreekman76.github.io/fitz/install.sh | sh

# Windows (PowerShell)
irm https://thegreekman76.github.io/fitz/install.ps1 | iex

# Reopen the terminal, then:
fitz new mycli --template cli-tool
cd mycli
fitz run main.fitz greet --help
fitz build
./mycli greet Ada --loud --count 3
Enter fullscreen mode Exit fullscreen mode

For VSCode (recommended for editing — hover with types, autocomplete, signature help): grab the fitz-lang-<platform>.vsix from the releases page and code --install-extension fitz-lang-<platform>.vsix --force. The Language Server is bundled.

You'll have a self-contained native binary in your pwd in under five minutes.

Repo: github.com/Thegreekman76/fitz
cli-tool boilerplate: github.com/Thegreekman76/fitz/tree/main/boilerplates/cli-tool
Docs and course: thegreekman76.github.io/fitz
Guide chapter on CLI: thegreekman76.github.io/fitz/guide/#33-cli
Roadmap: docs/roadmap.md
CHANGELOG: CHANGELOG.md
Issues: github.com/Thegreekman76/fitz/issues

Until the next one.

Top comments (0)