fncli
One decorator. Function signature is the CLI spec.
from fncli import cli
@cli("myapp")
def deploy(target: str, force: bool = False):
"""ship it"""
print(f"deploying to {target}")
$ myapp deploy prod --force
deploying to prod
Signature → parser. Docstring → help. Types → validation. One file, no dependencies.
Install
pip install fncli
Type mapping
| annotation | CLI behavior |
|---|---|
name: str |
required positional |
n: int |
required positional, coerced to int |
verbose: bool = False |
--verbose flag |
count: int = 10 |
--count 10 optional |
tags: list[str] |
positional varargs |
tags: list[str] = [] |
--tags a b c optional |
filter: str | None = None |
--filter optional |
Naming: dry_run → --dry-run. list_all → list-all. Trailing _ stripped: type_ → --type.
Subcommands
First argument to @cli() is the parent namespace.
@cli()
def version(): ... # → "version"
@cli("myapp")
def status(): ... # → "myapp status"
@cli("myapp server")
def start(port: int = 8080): ... # → "myapp server start"
Dispatch is longest-match. myapp server --help auto-lists subcommands.
Bare namespaces
When a namespace has one obvious default action, use bare=True. The namespace itself becomes the command — no subcommand needed.
@cli("ledger", bare=True, flags={"domain": ["-d"]}, required=["domain"])
def insight(content: str, domain: str | None = None):
"""log an insight"""
...
@cli("ledger insight")
def close(ref: str):
"""close an insight"""
...
$ ledger insight "auth is fragile" -d security # bare — namespace IS the verb
$ ledger insight close i/abc123 # named subcommand
$ ledger insight --help
usage: ledger insight <content> [-d DOMAIN]
log an insight
or: ledger insight <command> [args]
commands:
close close an insight
How it works: @cli("ledger", bare=True) on function insight registers a bare handler for namespace "ledger insight" (parent + fn name). Bare handlers:
- Accept full positional and flag arguments (same parsing as
@cli) - Don't appear in
commands()ormanifest()— they're invisible defaults - Yield to named subcommands (dispatch checks registry first)
- Delegate
--helpto namespace help (shows subcommands + bare usage)
When to use bare vs named: If the action is a lifecycle verb agents should know (propose, commit, approve), name it. If the action is just "the thing this namespace does" (create/add), make it bare.
Stacking bare + named for commands that are both the default AND a lifecycle verb:
@cli("ledger", name="decision", bare=True, required=["why"])
@cli("ledger decision", required=["why"])
def propose(content: str, why: str | None = None):
"""propose a decision"""
...
Both ledger decision "X" --why "Y" and ledger decision propose "X" --why "Y" work. Keep decorator params in sync — they're adjacent so drift is visible.
@cli() options
@cli(
"myapp", # parent namespace
name="st", # override command name (default: fn.__name__)
description="...", # override help text (default: fn.__doc__)
flags={...}, # custom flag names or positional-optional
help={...}, # per-param help: {"param": "description"}
required=["param"], # force a defaulted param to be required
aliases=["s"], # additional dispatch keys
default=True, # run when parent is invoked with no subcommand match
bare=True, # register as bare handler for parent + fn_name namespace
readonly=True, # metadata tag; query with readonly()
meta={...}, # arbitrary metadata; query with meta() / where()
)
Flags
@cli("myapp", flags={"output": ["-o", "--output"], "target": []})
def build(target: str | None = None, output: str = "dist"): ...
["-o", "--output"]— custom short + long flags[]— positional-optional: consumed by position
Aliases
@cli("myapp", aliases=["s", "stat"])
def status(): ...
# myapp s, myapp stat, myapp status all work
fncli.alias("myapp server tail", "myapp tail")
fncli.alias_namespace("myapp log", "myapp add")
Error handling
from fncli import UsageError, StateError
@cli("myapp")
def deploy(env: str):
if env not in ("staging", "prod"):
raise UsageError(f"unknown env: {env}") # stderr + exit 1 + "Run --help"
UsageError → appends "Run --help for usage." StateError → does not (correct syntax, wrong state).
Return an int for exit code. None / no return → exit 0.
Entrypoint
fncli.run(["myapp", *sys.argv[1:]]) # dispatch + sys.exit
fncli.dispatch(argv) # returns exit code
fncli.try_dispatch(argv) # returns None on miss
autodiscover scans for @cli( and auto-imports — no routing table:
fncli.autodiscover(Path(__file__).parent, "myapp")
fncli.run(["myapp", *sys.argv[1:]])
Unknown commands get fuzzy suggestions:
$ myapp strat
Unknown command: strat. Did you mean: start, status?
Testing
result = fncli.invoke(["myapp", "deploy", "prod"])
assert result.exit_code == 0
assert "deploying to prod" in result.stdout
invoke() captures stdout, stderr, and exit code. Traps SystemExit.
Shell completions
eval "$(myapp completions zsh)" # bash, zsh, fish
Selftest
$ myapp selftest # smoke-test --help on all commands
$ myapp selftest --live # also run readonly no-arg commands
Introspection
fncli.commands() # sorted list of all registered keys
fncli.entries() # [(key, fn, params), ...]
fncli.manifest() # structured dict — for agent consumption
fncli.meta(key) # metadata dict
fncli.where(**kwargs) # keys matching metadata predicates
fncli.readonly(key) # True if readonly=True
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fncli-0.1.4.tar.gz.
File metadata
- Download URL: fncli-0.1.4.tar.gz
- Upload date:
- Size: 33.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
58794da76ca958d297129a32bea71a82affbd08d6f70d83a73a99f77a138767f
|
|
| MD5 |
e3b5ed46ea2cbdd2b1c1dbac7b05bfa8
|
|
| BLAKE2b-256 |
c4663591809b4228ba2327292db1b2dbb42bcef05e00d584983de06ffeba94de
|
File details
Details for the file fncli-0.1.4-py3-none-any.whl.
File metadata
- Download URL: fncli-0.1.4-py3-none-any.whl
- Upload date:
- Size: 13.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.12.2 {"installer":{"name":"uv","version":"0.12.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d36d76457cc0a4c94a51c710b3e365d547cbacef31738cc84a967e8a29740afd
|
|
| MD5 |
bfd70054e28887ce9293928afb7ae063
|
|
| BLAKE2b-256 |
077f6a4c9544c01415fb3c68128e56fcaa20c62df6733789e2ab3ae88f69f31c
|