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
--flagis 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
}
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!
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!
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--loudforBoolflags). -
Bool = false→ flag bool (--loudenables, 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
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)`.
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
}
$ ./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
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
}
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
}
$ ./mybin validate ok.txt
$ echo $?
0
$ ./mybin validate fail.txt
validation failed
$ echo $?
1
$ ./mybin validate
✗ greet: missing positional argument `<path>`
$ echo $?
2
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
}
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
}
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
}
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
}
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
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 haveread_linestyle 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_completefile approach still works. -
--no-fooforBool = trueflags. Bools default tofalse; 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
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)