Skip to main content

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() or manifest() — they're invisible defaults
  • Yield to named subcommands (dispatch checks registry first)
  • Delegate --help to 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

Metadata

Release files for fncli 0.1.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for fncli 0.1.4
File Size Uploaded Provenance
fncli-0.1.4.tar.gz 33.9 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for fncli 0.1.4
File Interpreter ABI Platform
fncli-0.1.4-py3-none-any.whl Python 3 none any Details

Total release size: 47.8 kB

Release files / fncli-0.1.4.tar.gz

Download URL fncli-0.1.4.tar.gz
Size 33.9 kB
Tags Source
SHA-256 checksum
How to use checksums
58794da76ca958d297129a32bea71a82affbd08d6f70d83a73a99f77a138767f
BLAKE2b-256 checksum
How to use checksums
c4663591809b4228ba2327292db1b2dbb42bcef05e00d584983de06ffeba94de
Upload date
Uploaded using Trusted Publishing?
What is 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}

Release files / fncli-0.1.4-py3-none-any.whl

Download URL fncli-0.1.4-py3-none-any.whl
Size 13.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d36d76457cc0a4c94a51c710b3e365d547cbacef31738cc84a967e8a29740afd
BLAKE2b-256 checksum
How to use checksums
077f6a4c9544c01415fb3c68128e56fcaa20c62df6733789e2ab3ae88f69f31c
Upload date
Uploaded using Trusted Publishing?
What is 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}

Security for fncli 0.1.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page