# cliffy > cliffy generates Python command-line tools from YAML manifests. A manifest lists commands, their params and the Python or shell code each one runs; `cli load` turns it into a CLI (Typer by default, Click with `use_click: true`) that can be tested, documented and built into a single file. This is the documentation of cliffy 0.7.1, one section per page. Each section starts with a `# ` heading. Source and issues: https://github.com/jaykv/cliffy # cliffy **Describe a command-line tool in YAML. Get a real Python CLI.** cliffy reads a manifest that lists your commands, their parameters and the code each one runs, and generates a [Typer](https://typer.tiangolo.com/) application from it. Command bodies are plain Python, and any line that starts with `$` runs as a shell command, so a manifest can replace a folder of one-off scripts with a single CLI that has help text, typed arguments and subcommands. ## A CLI in 60 seconds **1. Install cliffy** (Python 3.11 to 3.15): ```console $ pip install cliffy ``` **2. Write a manifest** and save it as `hello.yaml`: ```yaml title="hello.yaml" name: hello version: 0.1.0 help: Hello world! commands: shell: $ echo "hello from shell" python: print("hello from python") greet: help: Greet someone by name params: - name: str! - --shout|-s: bool = False run: | message = f"Hello, {name}!" print(message.upper() if shout else message) tests: - shell: assert result.exit_code == 0 - python: assert result.output == "hello from python\n" - greet Ada: assert result.output == "Hello, Ada!\n" - greet Ada --shout: assert "HELLO, ADA!" in result.output ``` **3. Load it.** cliffy generates the CLI and puts a `hello` command into your Python environment: ```console $ cli load hello.yaml ✨ Generated hello CLI v0.1.0 ✨ $ hello -h ``` **4. Use it** like any other command: ```console $ hello shell hello from shell $ hello greet Ada --shout HELLO, ADA! $ hello --help Usage: hello [OPTIONS] COMMAND [ARGS]... Hello world! ╭─ Options ──────────────────────────────────────────────────────────────────╮ │ --version │ │ --install-completion Install completion for the current shell. │ │ --show-completion Show completion for the current shell, to │ │ copy it or customize the installation. │ │ --help -h Show this message and exit. │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭─ Commands ─────────────────────────────────────────────────────────────────╮ │ shell │ │ python │ │ greet Greet someone by name │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` The `tests` section at the bottom of the manifest is optional. `cli test hello.yaml` runs it. ## What you get - **Python and shell in one place.** Run scripts are Python; lines starting with `$` become `subprocess` calls. See [Shell commands](guides/shell-commands.md). - **Typed parameters with a short syntax.** `name: str!` is a required argument and `--count|-c: int = 1` is an option. See [Params and types](guides/params.md). - **Groups, aliases and templates.** `db.create` nests a command, `delete|rm` adds an alias and command templates share params between commands. See [Commands, groups and aliases](guides/commands.md). - **A fast edit loop.** `cli dev` reloads the CLI every time you save the manifest. See [Hot-reload dev loop](guides/dev-loop.md). - **Tests next to the commands.** `cli test` runs the assertions you keep in the manifest. See [Testing manifests](guides/testing.md). - **One file to ship.** `cli build` packs the CLI and its dependencies into a single executable zipapp, a single Python script or a native executable. See [Building executables](guides/building.md). ## How it works ```text manifest.yaml ──► validate ──► generate Python (Typer or Click) ──► load │ run │ build ``` 1. cliffy renders the manifest with [Jinja2](https://jinja.palletsprojects.com/), parses the YAML and validates it against the [manifest schema](reference/manifest.md). 2. It generates one Python module. `cli render` prints that module, so nothing about the result is hidden. 3. `cli load` installs the module as a command, `cli run` executes it once and `cli build` packages it. ## Where to go next | If you want to | Read | |---|---| | Install cliffy and its extras | [Installation](installation.md) | | Build a complete CLI step by step | [Quickstart](quickstart.md) | | Look up a manifest key | [Manifest reference](reference/manifest.md) | | Look up a `cli` command or flag | [CLI reference](reference/cli.md) | | Copy from working manifests | [Examples gallery](reference/examples.md) | | Upgrade from an older release | [Migration notes](project/migration.md) | ## Similar tools - [Bashly](https://github.com/DannyBen/bashly) generates Bash CLIs from YAML. - [Fire](https://github.com/google/python-fire) generates CLIs from Python objects. # Quickstart In this tutorial you build `notes`, a small CLI that stores notes in a JSON file. Along the way you use every core part of a manifest: commands, params, helper functions, groups, aliases, shell commands and tests. It takes about 15 minutes. You need cliffy installed in an active virtual environment. See [Installation](installation.md). The finished manifest is at the [end of this page](#the-finished-manifest). ## 1. A manifest with one command Create a file called `notes.yaml`: ```yaml title="notes.yaml" name: notes version: 0.1.0 help: Keep short notes in a JSON file commands: add: help: Add a note params: - text: str! run: | print(f"Added {text}") tests: - add "buy milk": assert result.output == "Added buy milk\n" ``` - `name` is what you will type to run the CLI. `version` is shown by `notes --version`. - Each key under `commands` is a command. `run` holds its Python code. - `text: str!` declares a parameter called `text` of type `str`. The `!` makes it required. Parameters are ordinary variables inside `run`. - `tests` is optional. You will come back to it in [step 7](#7-test-it). Run the command without installing anything: ```console $ cli run notes.yaml add "buy milk" Added buy milk ``` `cli run` generates the CLI, runs it once and throws it away. To see the Python it generated, use `cli render`: ```console $ cli render notes.yaml ``` ```python def add(text: str = typer.Argument(...)): print(f"Added {text}") cli.command("add", help="Add a note",)(add) ``` Your `run` script became the body of a Typer command function. ## 2. Store the notes The CLI needs to read and write a file. Add `imports`, `vars` and `functions` between `help` and `commands`: ```yaml title="notes.yaml" imports: | import json import os from collections import Counter from pathlib import Path vars: default_file: notes.json functions: - | def notes_path() -> Path: return Path(os.environ.get("NOTES_FILE", "{{ default_file }}")) - | def load_notes() -> list[dict]: path = notes_path() return json.loads(path.read_text()) if path.exists() else [] - | def save_notes(notes: list[dict]) -> None: notes_path().write_text(json.dumps(notes, indent=2)) ``` - `imports` is copied to the top of the generated module. - `functions` is a list of helper functions that every command can call. - `vars` defines manifest variables. `{{ default_file }}` is replaced with `notes.json` when the manifest is loaded. More in [Vars and Jinja](guides/vars-and-jinja.md). ## 3. Describe parameters once with types `text: str!` is short, but it has no help text. The `types` section lets you name a full [Typer parameter definition](https://typer.tiangolo.com/tutorial/arguments/) and reuse it. Add this after `functions`: ```yaml title="notes.yaml" types: Text: str = typer.Argument(..., help="Text of the note") Tag: str = typer.Option("general", "--tag", "-t", help="Tag to file the note under") ``` Now replace the `add` command so that it uses those types and saves the note: ```yaml title="notes.yaml" commands: add: help: Add a note params: - text: Text - tag: Tag run: | notes = load_notes() notes.append({"text": text, "tag": tag}) save_notes(notes) print(f"Added note {len(notes)} [{tag}]") ``` Try it: ```console $ cli run notes.yaml add "buy milk" Added note 1 [general] $ cli run notes.yaml -- add "book flights" --tag travel Added note 2 [travel] ``` !!! tip "The `--` separator" When the arguments for your CLI include options such as `--tag`, put `--` after the manifest path. Without it, `cli run` tries to interpret `--tag` as one of its own options. ## 4. Add options and an alias Add a second command under `commands`: ```yaml title="notes.yaml" list|ls: help: List notes, optionally filtered by tag params: - --tag|-t: str = "" run: | for number, note in enumerate(load_notes(), start=1): if not tag or note["tag"] == tag: print(f"{number}. [{note['tag']}] {note['text']}") ``` - The key `list|ls` creates the command `list` and the alias `ls`. - `--tag|-t: str = ""` is an option (the name starts with `--`) with the short form `-t` and an empty string as its default. ```console $ cli run notes.yaml list 1. [general] buy milk 2. [travel] book flights $ cli run notes.yaml -- ls -t travel 2. [travel] book flights ``` ## 5. Exit codes and groups Commands can stop with an error code by raising `typer.Exit`. Add `remove`: ```yaml title="notes.yaml" remove|rm: help: Remove a note by its number params: - number: int! run: | notes = load_notes() if not 1 <= number <= len(notes): print(f"No note {number}") raise typer.Exit(code=1) notes.pop(number - 1) save_notes(notes) print(f"Removed note {number}") ``` A dot in a command key creates a group. These two commands are run as `notes tags list` and `notes tags rename`. The entry `tags`, which has `help` and no `run`, sets the help text of the group: ```yaml title="notes.yaml" tags: help: Work with tags tags.list: help: Count notes per tag run: | for tag, count in sorted(Counter(note["tag"] for note in load_notes()).items()): print(f"{tag} ({count})") tags.rename: help: Rename a tag on every note params: - old: str! - new: str! run: | notes = load_notes() for note in notes: if note["tag"] == old: note["tag"] = new save_notes(notes) print(f"Renamed {old} to {new}") ``` ```console $ cli run notes.yaml tags list general (1) travel (1) $ cli run notes.yaml tags rename general home Renamed general to home $ cli run notes.yaml remove 7 No note 7 ``` ## 6. Call a shell command A line that starts with `$` is run as a shell command. `f{...}` inserts the value of a Python expression into it: ```yaml title="notes.yaml" backup: help: Copy the notes file next to itself run: | $ cp f{notes_path()} f{notes_path()}.bak print(f"Backed up to {notes_path()}.bak") ``` ```console $ cli run notes.yaml backup Backed up to notes.json.bak ``` More in [Shell commands](guides/shell-commands.md). ## 7. Test it Replace the `tests` section at the end of the file: ```yaml title="notes.yaml" tests: - $ rm -f notes.json notes.json.bak - add "buy milk" --tag home: assert result.output == "Added note 1 [home]\n" - add "ship release": assert "Added note 2 [general]" in result.output - list: | assert "1. [home] buy milk" in result.output assert "2. [general] ship release" in result.output - ls -t home: assert result_text == "1. [home] buy milk" - tags rename general work: assert result.exit_code == 0 - tags list: assert "work (1)" in result.output - backup: assert "Backed up" in result.output - remove 9: assert result.exit_code == 1 - rm 1: assert "Removed note 1" in result.output - $ rm -f notes.json notes.json.bak ``` - An entry that starts with `$` is a shell step. Here it removes leftover files before and after the run. - Every other entry maps a command line to Python assertions. `result` is the outcome of the command, with `result.output` and `result.exit_code`. `result_text` is the output with surrounding whitespace removed. ```console $ cli test notes.yaml ✨ Invoking tests ✨ ... 💚 All tests passed! ``` More in [Testing manifests](guides/testing.md). ## 8. Load it as a real command So far every call went through `cli run`. Load the manifest to get a `notes` command: ```console $ cli load notes.yaml ✨ Generated notes CLI v0.1.0 ✨ $ notes -h $ notes add --help Usage: notes add [OPTIONS] {text} Add a note ╭─ Arguments ────────────────────────────────────────────────────────────────╮ │ * text Text of the note [required] │ ╰────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ──────────────────────────────────────────────────────────────────╮ │ --tag -t Tag to file the note under [default: general] │ │ --help -h Show this message and exit. │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` `notes` stays available in this environment until you run `cli remove notes`. After you edit the manifest, run `cli update notes` to regenerate it, or let [`cli dev`](guides/dev-loop.md) do that on every save. ## 9. Ship it as one file ```console $ cli build notes.yaml -o dist + notes built 📦 dist/notes (single file, needs Python >=3.10, 1.8 MB) run it with: ./dist/notes $ ./dist/notes --version notes, 0.1.0 ``` `dist/notes` is a self-contained zipapp. It runs on any machine with a compatible Python 3 and does not need cliffy. With `--target script` you get a single Python script instead, and with `--target native` a compiled executable that does not even need Python. More in [Building executables](guides/building.md). ## The finished manifest ```yaml title="notes.yaml" name: notes version: 0.1.0 help: Keep short notes in a JSON file imports: | import json import os from collections import Counter from pathlib import Path vars: default_file: notes.json functions: - | def notes_path() -> Path: return Path(os.environ.get("NOTES_FILE", "{{ default_file }}")) - | def load_notes() -> list[dict]: path = notes_path() return json.loads(path.read_text()) if path.exists() else [] - | def save_notes(notes: list[dict]) -> None: notes_path().write_text(json.dumps(notes, indent=2)) types: Text: str = typer.Argument(..., help="Text of the note") Tag: str = typer.Option("general", "--tag", "-t", help="Tag to file the note under") commands: add: help: Add a note params: - text: Text - tag: Tag run: | notes = load_notes() notes.append({"text": text, "tag": tag}) save_notes(notes) print(f"Added note {len(notes)} [{tag}]") list|ls: help: List notes, optionally filtered by tag params: - --tag|-t: str = "" run: | for number, note in enumerate(load_notes(), start=1): if not tag or note["tag"] == tag: print(f"{number}. [{note['tag']}] {note['text']}") remove|rm: help: Remove a note by its number params: - number: int! run: | notes = load_notes() if not 1 <= number <= len(notes): print(f"No note {number}") raise typer.Exit(code=1) notes.pop(number - 1) save_notes(notes) print(f"Removed note {number}") tags: help: Work with tags tags.list: help: Count notes per tag run: | for tag, count in sorted(Counter(note["tag"] for note in load_notes()).items()): print(f"{tag} ({count})") tags.rename: help: Rename a tag on every note params: - old: str! - new: str! run: | notes = load_notes() for note in notes: if note["tag"] == old: note["tag"] = new save_notes(notes) print(f"Renamed {old} to {new}") backup: help: Copy the notes file next to itself run: | $ cp f{notes_path()} f{notes_path()}.bak print(f"Backed up to {notes_path()}.bak") tests: - $ rm -f notes.json notes.json.bak - add "buy milk" --tag home: assert result.output == "Added note 1 [home]\n" - add "ship release": assert "Added note 2 [general]" in result.output - list: | assert "1. [home] buy milk" in result.output assert "2. [general] ship release" in result.output - ls -t home: assert result_text == "1. [home] buy milk" - tags rename general work: assert result.exit_code == 0 - tags list: assert "work (1)" in result.output - backup: assert "Backed up" in result.output - remove 9: assert result.exit_code == 1 - rm 1: assert "Removed note 1" in result.output - $ rm -f notes.json notes.json.bak examples: - command: notes add "buy milk" --tag home description: Add a note tagged home. - command: notes ls --tag home description: Show only the notes tagged home. ``` The `examples` section at the end is shown at the end of `notes --help` and is used by [`cli docs`](guides/generating-docs.md). ## Next steps - [Manifest basics](guides/manifest-basics.md) explains how a manifest is put together. - [Params and types](guides/params.md) covers every way to declare a parameter. - [Command templates and global params](guides/templates-and-global-params.md) shows how to share params between commands. # Manifest basics A manifest is one YAML file that describes one CLI. This page explains its parts and how cliffy turns them into Python. For a hands-on introduction, start with the [Quickstart](../quickstart.md). ## The smallest manifest Three keys are required: `name`, `version` and `commands`. ```yaml title="hello.yaml" name: hello version: 0.1.0 commands: world: print("hello world") ``` ```console $ cli run hello.yaml world hello world ``` ## Top-level keys | Key | What it is for | Guide | |---|---|---| | `name` | The command people type. | | | `version` | Printed by `--version`. Must be a string, so write `"1.0"` with quotes. | | | `help` | Description shown at the top of `--help`. | | | `commands` | The commands and the code they run. | [Commands, groups and aliases](commands.md) | | `imports` | Python imports for the generated module. | [below](#imports-and-functions) | | `functions` | Helper functions shared by all commands. | [below](#imports-and-functions) | | `vars` | Variables you can reuse across the manifest. | [Vars and Jinja](vars-and-jinja.md) | | `types` | Named parameter definitions. | [Params and types](params.md) | | `global_params` | Params added to commands. | [Templates and global params](templates-and-global-params.md) | | `command_templates` | Reusable params, hooks and settings. | [Templates and global params](templates-and-global-params.md) | | `includes` | Other manifests to merge in. | [Includes and merging](includes.md) | | `requires` | Python packages the CLI depends on. | [below](#dependencies) | | `cli_options` | Options for the CLI application object. | [below](#cli-options) | | `use_click` | Generate Click code instead of Typer. | [Click and Typer backends](backends.md) | | `tests` | Commands to run and assertions to check. | [Testing manifests](testing.md) | | `examples` | Example command lines, shown in `--help` and in generated docs. | [Generating docs](generating-docs.md) | Keys that cliffy does not know are ignored. The [manifest reference](../reference/manifest.md) lists every key with its type and default. ## How a manifest becomes Python cliffy writes a single Python module from the manifest, in this order: 1. `import subprocess`, `import typer` and the lines from `imports` 2. one variable per entry in `vars` 3. the Typer application, with `--version` built in 4. the `functions` 5. one function per command, registered with Typer Each command's `run` script becomes the body of its function, and its params become the function's arguments. That is the whole model: anything you can write in a Python function, you can write in `run`. To read the generated module for any manifest: ```console $ cli render hello.yaml ``` The output is the module itself, so `cli render hello.yaml > hello.py` writes a file you can run. The closing `# Rendered ...` line goes to the error stream and does not end up in that file. ## Imports and functions ```yaml imports: | import json from pathlib import Path functions: - | def load(path: str) -> dict: return json.loads(Path(path).read_text()) - | def branch() -> str: out = $ git rev-parse --abbrev-ref HEAD return out.decode().strip() ``` `imports` is a block of text or a list of lines. `functions` is a list with one function per item. `subprocess` and `typer` are always imported, so `typer.Exit`, `typer.confirm` and friends are available everywhere. Write `functions` as a list when a function contains [shell lines](shell-commands.md). A single block of text is also accepted, but `$` lines inside it are not translated. ## Dependencies List the packages your code imports under `requires`, using the same specifiers as `pip`: ```yaml requires: - tabulate>=0.9.0 - rich ``` - `cli load`, `cli run`, `cli test` and `cli validate` stop with `missing requirement` when a listed package is not installed in the environment cliffy runs in. - `cli build` downloads the listed packages and bundles them into the executable. ## CLI options `cli_options` is passed to the Typer application as keyword arguments. Two common settings: ```yaml cli_options: add_completion: false # hide --install-completion and --show-completion no_args_is_help: true # print help when the CLI is called without a command ``` ## Check, run, load | Command | What it does | |---|---| | `cli validate hello.yaml` | Checks the YAML, the schema, the `requires` and that the generated code imports. | | `cli render hello.yaml` | Prints the generated Python. | | `cli run hello.yaml -- ` | Runs the CLI once without installing it. | | `cli test hello.yaml` | Runs the `tests` section. | | `cli load hello.yaml` | Installs the CLI as a command in the current environment. | All of them are described in the [CLI reference](../reference/cli.md). ## YAML pitfalls Manifests mix YAML, Jinja and Python, and each has its own special characters. **A colon followed by a space ends a YAML key.** This one-line command breaks, because of `: ` inside the string: ```yaml commands: status: print("status: ok") # YAML error ``` Use a block instead: ```yaml commands: status: | print("status: ok") ``` **Quote values that YAML would not read as text.** `version: 1.0` is a number and is rejected; `version: "1.0"` works. The same applies to values in `vars`. **Quote a one-line command that starts with `>`.** YAML treats a leading `>` as its own syntax, so write `run: "> ls *.txt"`. **Double braces belong to Jinja.** `{{ name }}` is replaced when the manifest is loaded. To get literal double braces into your Python, see [Vars and Jinja](vars-and-jinja.md#literal-braces). ## Editor support cliffy publishes a JSON schema for manifests, which gives you completion and validation while you type. See [Editor setup](editor-setup.md). # Params and types Params are the arguments and options of a command. Inside `run` they are ordinary Python variables. A `params` list accepts three forms, and you can mix them in one command. | Form | Looks like | Use it when | |---|---|---| | [Shorthand](#shorthand) | `- name: str!` | You want the shortest syntax. | | [Explicit](#explicit) | `- name: image` and more keys | You want help text. | | [Generic](#generic) | `- 'path: str = typer.Argument(...)'` | You need the full Typer API for one param. | To reuse one definition across commands, give it a name under [`types`](#types). ## Shorthand Each entry is `name: type`. ```yaml copy: help: Shorthand params params: - source: str! # required argument - target: str = "out.txt" # optional argument with a default - label: str # optional argument, None when omitted - --count|-c: int = 1 # option with a short alias and a default - --force|-f: bool = False # boolean flag - --owner: str! # required option - --dry-run: bool = False # hyphens become underscores in Python run: | print(source, target, label, count, force, owner, dry_run) ``` | You write | You get | |---|---| | `source: str!` | A required positional argument. | | `target: str = "out.txt"` | An optional argument with a default. | | `label: str` | An optional argument that is `None` when omitted. | | `--count|-c: int = 1` | The option `--count` with the short form `-c`. | | `--force|-f: bool = False` | A flag. `force` is `True` when `--force` or `-f` is passed. | | `--owner: str!` | A required option. | | `--dry-run: bool = False` | An option whose variable is `dry_run`. | Rules: - A name that starts with `--` is an option. Anything else is a positional argument. - `!` after the type makes the param required. - The text after `=` is a Python expression, so string defaults need quotes: `str = "out.txt"`. - Hyphens in a name become underscores in the variable. - Positional arguments are read from the command line in the order you list them, so put required ones first. ```console $ cli run params.yaml -- copy a.txt --owner me a.txt out.txt None 1 False me False $ cli run params.yaml -- copy a.txt b.txt c -c 3 -f --owner me --dry-run a.txt b.txt c 3 True me True ``` !!! note "Boolean options without a short form" `--dry-run: bool = False` is generated without explicit option names, so Typer also adds the negative form `--no-dry-run`. Options declared with a short form, such as `--force|-f`, get only the names you wrote. ## Explicit Each entry is a mapping. This is the only form with a `help` key. ```yaml resize: help: Explicit params params: - name: image type: str required: true help: Image to resize - name: --width type: int default: 800 short: -w help: Target width in pixels - name: --format type: str default: png help: Output format run: | print(image, width, format) ``` | Key | Meaning | |---|---| | `name` | Param name. Start it with `--` to make an option; without it the param is a positional argument. | | `type` | A Python type such as `str`, `int`, `float`, `bool`, or a name from `types`. | | `default` | Default value. String defaults are quoted for you when `type` is `str`. | | `help` | Text shown in `--help`. | | `short` | Short option name including the dash, such as `-w`. Options only. | | `required` | `true` to make the param required. | ```console $ cli run params.yaml -- resize cat.jpg -w 100 --format gif cat.jpg 100 gif ``` ## Types `types` gives a name to a type or to a complete Typer parameter definition. Use the name wherever a type is expected, in shorthand or explicit params. ```yaml types: Level: int = typer.Option(1, "--level", "-l", min=1, max=5, help="Compression level") Source: str = typer.Argument(..., help="File to read") ``` ```yaml pack: help: Params that reuse the types section params: - source: Source - level: Level run: | print(source, level) ``` A complete definition is everything that follows the param name in a Python signature: the type, then `typer.Argument(...)` or `typer.Option(...)`. This gives you help text, validation (`min`, `max`), prompts (`prompt=True`), environment variables (`envvar="NAME"`) and the rest of the [Typer parameter API](https://typer.tiangolo.com/tutorial/options/). It replaces everything the param entry says except its name, so a default or `help` written next to it is not used. A plain type is an alias: ```yaml types: Count: int = 1 commands: repeat: params: - --times: Count # the option --times, an int, default 1 - --retries: Count = 3 # a default on the param wins run: print(times, retries) ``` The param keeps what its own entry says: `--times` is an option because of its name, and an explicit param keeps its `help`. Plain types work with both backends. ```console $ cli run params.yaml -- pack data.csv data.csv 1 $ cli run params.yaml -- pack data.csv -l 9 ╭─ Error ────────────────────────────────────────────────────────────────────╮ │ Invalid value for '--level' / '-l': 9 is not in the range 1<=x<=5. │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` ## Generic A plain string is copied into the function signature exactly as written. Quote the whole entry so that YAML reads it as a string. ```yaml tail: help: Raw Typer signatures params: - 'path: str = typer.Argument(..., help="File to follow")' - 'lines: int = typer.Option(10, "--lines", "-n", min=1, help="Lines to show")' run: | print(path, lines) ``` !!! tip "A Typer definition also works in a shorthand entry" `- name: str = typer.Option("World", "--name")` is passed through as written. Use `types` when several commands share the definition. ## Where else params appear - `global_params` and `command_templates..params` accept the same three forms. See [Command templates and global params](templates-and-global-params.md). - With `use_click: true`, params are translated to Click decorators. Shorthand and explicit params mean the same; generic params and full definitions in `types` are written as Click decorators. See [Click and Typer backends](backends.md). ## The complete example ```yaml title="params.yaml" name: params version: 0.1.0 help: One command per way of declaring parameters types: Level: int = typer.Option(1, "--level", "-l", min=1, max=5, help="Compression level") Source: str = typer.Argument(..., help="File to read") commands: copy: help: Shorthand params params: - source: str! # required argument - target: str = "out.txt" # optional argument with a default - label: str # optional argument, None when omitted - --count|-c: int = 1 # option with a short alias and a default - --force|-f: bool = False # boolean flag - --owner: str! # required option - --dry-run: bool = False # hyphens become underscores in Python run: | print(source, target, label, count, force, owner, dry_run) resize: help: Explicit params params: - name: image type: str required: true help: Image to resize - name: --width type: int default: 800 short: -w help: Target width in pixels - name: --format type: str default: png help: Output format run: | print(image, width, format) pack: help: Params that reuse the types section params: - source: Source - level: Level run: | print(source, level) tail: help: Raw Typer signatures params: - 'path: str = typer.Argument(..., help="File to follow")' - 'lines: int = typer.Option(10, "--lines", "-n", min=1, help="Lines to show")' run: | print(path, lines) tests: - copy a.txt --owner me: assert result.output == "a.txt out.txt None 1 False me False\n" - copy a.txt b.txt c -c 3 -f --owner me --dry-run: assert result.output == "a.txt b.txt c 3 True me True\n" - copy a.txt: assert result.exit_code == 2 - resize cat.jpg: assert result.output == "cat.jpg 800 png\n" - resize cat.jpg -w 100 --format gif: assert result.output == "cat.jpg 100 gif\n" - pack data.csv: assert result.output == "data.csv 1\n" - pack data.csv -l 9: assert result.exit_code == 2 - tail app.log: assert result.output == "app.log 10\n" - tail app.log -n 2: assert result.output == "app.log 2\n" ``` # Commands, groups and aliases Everything under `commands` becomes a command of your CLI. The key decides where the command lives and what it is called; the value decides what it does. The snippets on this page come from one manifest, shown in full [at the end](#the-complete-example). ## Three ways to write a command ```yaml # 1. A string: the whole value is the run script ping: print("pong") # 2. A list: each item is a line of the run script status: - print("checking...") - $ echo "all good" # 3. A mapping: the full form greet: help: Greet a customer params: - name: str = "friend" run: | print(f"Welcome, {name}!") ``` Use the short forms for one-liners and the mapping when you need `help`, `params` or anything else. The keys of the mapping are listed in the [manifest reference](../reference/manifest.md#command). ```console $ cli run commands.yaml ping pong $ cli run commands.yaml greet Welcome, friend! ``` Command names may contain letters, digits, `-` and `_`. ## Groups A dot in the key puts the command in a group. Groups are created as needed and can be nested. ```yaml # A dot nests a command inside a group: `shop orders list` orders.create: help: Create an order params: - item: str! run: print(f"created order for {item}") orders.ship: help: Ship an order params: - number: int! run: print(f"shipped order {number}") # Groups nest as deep as you like: `shop stock warehouse count` stock.warehouse.count: print("42 items") # A command with help and no run script sets the help text of the group orders: help: Manage customer orders ``` ```console $ cli run commands.yaml orders create book created order for book $ cli run commands.yaml stock warehouse count 42 items $ cli run commands.yaml -- orders --help ... ╭─ Commands ─────────────────────────────────────────────────────────────────╮ │ create Create an order │ │ ship Ship an order │ │ list List all orders │ ╰────────────────────────────────────────────────────────────────────────────╯ ``` To describe a group, add an entry with the group's name that has `help` and no `run`, like `orders` above. A name is either a group or a command: if `orders` had a `run` script while `orders.create` exists, the group would take the name and the script could not be reached. ## Aliases ```yaml # `shop customers add` and `shop customers new` run the same code customers.add|new: help: Add a customer params: - name: str! run: print(f"added {name}") # The aliases field does the same without touching the key version-info: help: Show build details aliases: [vi] run: print("build 7") ``` - `|` in the key adds aliases to the last part of the name: `customers.add|new` gives `customers add` and `customers new`. - The `aliases` list does the same from inside the command. Aliases are hidden from `--help`. When a manifest defines aliases in its keys, the CLI gets an `--aliases` option that lists them: ```console $ cli run commands.yaml -- --aliases Command Aliases -------- -------- customers.add new ``` ## One command for every group A key that starts with `(*)` is added to every group. Inside that command, `{(*)}` is replaced with the group's name, in `help`, `run`, `pre_run`, `post_run` and params. ```yaml # (*) adds this command to every group; {(*)} is replaced by the group name (*).list: help: List all {(*)} params: - --limit|-l: int = 10 run: print(f"listing {limit} {(*)}") ``` ```console $ cli run commands.yaml orders list listing 10 orders $ cli run commands.yaml -- customers list -l 3 listing 3 customers ``` ## Run scripts `run` is Python. Write it as a block (`run: |`) or as a list of lines. Lines that start with `$` are shell commands, described in [Shell commands](shell-commands.md). Two hooks wrap the script. `pre_run` runs before `run` and `post_run` after it, in the same function, so all three share variables. ```yaml deploy: help: Deploy the shop pre_run: print("1. pre_run") run: print("2. run") post_run: print("3. post_run") ``` ```console $ cli run commands.yaml deploy 1. pre_run 2. run 3. post_run ``` Hooks are most useful together with [command templates](templates-and-global-params.md), which let several commands share them. To stop a command with an exit code, raise `typer.Exit`: ```yaml run: | if not ready: print("not ready") raise typer.Exit(code=1) ``` ## Help text `help` on a command is shown in the command list and at the top of the command's own `--help`. Because `run` is the body of a Python function, a docstring on its first line works as well: ```yaml about: run: | """Show what this CLI is for""" print("A demo shop") ``` ## Command settings `config` passes extra settings to Typer for one command. ```yaml migrate: help: Run the old migration tool run: print("migrating") config: deprecated: true hidden: true epilog: Use `shop deploy` instead. ``` | Key | Effect | |---|---| | `hidden` | Leave the command out of `--help`. | | `deprecated` | Mark the command as deprecated and print a warning when it runs. | | `epilog` | Text shown at the end of the command's help. | | `short_help` | Text for the command list, when it should differ from `help`. | | `no_args_is_help` | Show help when the command is called without arguments. | | `context_settings` | Click context settings, for example `help_option_names`. | All keys are in the [manifest reference](../reference/manifest.md#commandconfig). ## The complete example ```yaml title="commands.yaml" name: shop version: 0.1.0 help: Groups, aliases and command options cli_options: add_completion: false no_args_is_help: true commands: # 1. A string: the whole value is the run script ping: print("pong") # 2. A list: each item is a line of the run script status: - print("checking...") - $ echo "all good" # 3. A mapping: the full form greet: help: Greet a customer params: - name: str = "friend" run: | print(f"Welcome, {name}!") # A dot nests a command inside a group: `shop orders list` orders.create: help: Create an order params: - item: str! run: print(f"created order for {item}") orders.ship: help: Ship an order params: - number: int! run: print(f"shipped order {number}") # Groups nest as deep as you like: `shop stock warehouse count` stock.warehouse.count: print("42 items") # A command with help and no run script sets the help text of the group orders: help: Manage customer orders # `shop customers add` and `shop customers new` run the same code customers.add|new: help: Add a customer params: - name: str! run: print(f"added {name}") # The aliases field does the same without touching the key version-info: help: Show build details aliases: [vi] run: print("build 7") # (*) adds this command to every group; {(*)} is replaced by the group name (*).list: help: List all {(*)} params: - --limit|-l: int = 10 run: print(f"listing {limit} {(*)}") deploy: help: Deploy the shop pre_run: print("1. pre_run") run: print("2. run") post_run: print("3. post_run") migrate: help: Run the old migration tool run: print("migrating") config: deprecated: true hidden: true epilog: Use `shop deploy` instead. about: run: | """Show what this CLI is for""" print("A demo shop") tests: - ping: assert result.output == "pong\n" - greet: assert result.output == "Welcome, friend!\n" - orders create book: assert result.output == "created order for book\n" - orders ship 7: assert result.output == "shipped order 7\n" - stock warehouse count: assert result.output == "42 items\n" - orders --help: assert "Manage customer orders" in result.output - customers new Ada: assert result.output == "added Ada\n" - vi: assert result.output == "build 7\n" - orders list: assert result.output == "listing 10 orders\n" - customers list -l 3: assert result.output == "listing 3 customers\n" - orders list --help: assert "List all orders" in result.output - deploy: assert result.output == "1. pre_run\n2. run\n3. post_run\n" - migrate: assert "migrating" in result.output - --help: assert "migrate" not in result.output - about --help: assert "Show what this CLI is for" in result.output - --aliases: assert "customers.add" in result.output ``` # Command templates and global params Two features keep a manifest free of repetition. **Global params** add the same params to your commands. **Command templates** bundle params, hooks and settings under a name that commands opt into. ## Global params `global_params` is a list of [params](params.md), in any of the three forms. ```yaml types: Verbose: bool = typer.Option(False, "--verbose", "-v", help="Print extra detail") global_params: - verbose: Verbose ``` The param is passed to the command itself, so it goes after the command name (`files delete notes.txt --verbose`) and is available in `run` as a normal variable. Global params are added to every command, whether or not it has params of its own. ## Command templates A template can hold `params`, `pre_run`, `post_run` and `config`. ```yaml command_templates: destructive: params: - --yes|-y: bool = False pre_run: | if not yes: typer.confirm("Are you sure?", abort=True) post_run: print("done") config: epilog: This command changes files on disk. ``` A command uses it with `template: `: ```yaml commands: delete|rm: help: Delete a file template: destructive params: - path: str! pre_run: | if verbose: print(f"about to delete {path}") run: print(f"deleted {path}") post_run: print("cleaning up") wipe: help: Delete everything template: destructive run: print("wiped") config: epilog: There is no undo. ``` ```console $ cli run templates.yaml -- delete notes.txt --yes --verbose about to delete notes.txt deleted notes.txt cleaning up done $ cli run templates.yaml delete notes.txt Are you sure? [y/N]: n Aborted. ``` ### How a template is merged into a command | Part | Result | |---|---| | `params` | Template params first, then the command's own. | | `pre_run` | Template `pre_run`, then the command's. | | `post_run` | The command's `post_run`, then the template's. | | `config` | Combined. A key set on the command overrides the same key from the template. | Together with global params, a command's arguments are ordered: global params, template params, command params. The scripts therefore always run in this order: 1. template `pre_run` 2. command `pre_run` 3. command `run` 4. command `post_run` 5. template `post_run` All five are placed in one function, so a variable set in `pre_run` can be used in `run`. In the example, `wipe` sets its own `epilog`, which replaces the one from the template, while `delete` keeps the template's: ```console $ cli run templates.yaml -- delete --help ... ╭─ Options ──────────────────────────────────────────────────────────────────╮ │ --verbose -v Print extra detail │ │ --yes -y │ │ --help -h Show this message and exit. │ ╰────────────────────────────────────────────────────────────────────────────╯ This command changes files on disk. ``` A command can use one template. Naming a template that does not exist is an error when the manifest is loaded. ## The complete example ```yaml title="templates.yaml" name: files version: 0.1.0 help: Command templates and global params types: Verbose: bool = typer.Option(False, "--verbose", "-v", help="Print extra detail") global_params: - verbose: Verbose command_templates: destructive: params: - --yes|-y: bool = False pre_run: | if not yes: typer.confirm("Are you sure?", abort=True) post_run: print("done") config: epilog: This command changes files on disk. commands: delete|rm: help: Delete a file template: destructive params: - path: str! pre_run: | if verbose: print(f"about to delete {path}") run: print(f"deleted {path}") post_run: print("cleaning up") wipe: help: Delete everything template: destructive run: print("wiped") config: epilog: There is no undo. tests: - delete notes.txt --yes: assert result.output == "deleted notes.txt\ncleaning up\ndone\n" - rm notes.txt -y -v: assert result.output == "about to delete notes.txt\ndeleted notes.txt\ncleaning up\ndone\n" - wipe -y: assert result.output == "wiped\ndone\n" - wipe: assert result.exit_code == 1 - delete --help: assert "This command changes files on disk." in result.output - wipe --help: assert "There is no undo." in result.output - wipe --help: assert "--verbose" in result.output ``` # Includes and merging `includes` merges other YAML files into a manifest. Use it to share functions, types and commands between several CLIs. ## An include file An include file looks like a manifest without `name` and `version`: ```yaml title="shared/common.yaml" imports: - import json vars: team: platform functions: - | def as_json(data: dict) -> str: return json.dumps(data, sort_keys=True) types: Service: str = typer.Argument(..., help="Name of the service") commands: health: help: Report service health params: - service: Service run: | print(as_json({"service": service, "status": "ok"})) whoami: help: Show the owning team run: print(team) tests: - health api: assert '"ok"' in result.output ``` It may contain `commands`, `functions`, `imports`, `types`, `vars`, `requires`, `cli_options` and `tests`. In an include file, `functions` must be a list. ## Using it ```yaml title="ops.yaml" name: ops version: 0.1.0 help: Commands shared between CLIs through includes includes: - shared/common.yaml commands: restart: help: Restart a service params: - service: Service run: | print(as_json({"service": service, "action": "restart"})) # Same key as in the include: the keys set here win, the rest is inherited whoami: help: Show who owns this CLI tests: - restart api: assert '"restart"' in result.output - whoami: assert result.output == "platform\n" - whoami --help: assert "Show who owns this CLI" in result.output ``` ```console $ cli run ops.yaml health api {"service": "api", "status": "ok"} $ cli run ops.yaml restart api {"action": "restart", "service": "api"} $ cli run ops.yaml whoami platform ``` `restart` is defined in `ops.yaml` and uses the `Service` type and the `as_json` function from the include. `health` comes entirely from the include. !!! note "Paths are relative to the manifest" `shared/common.yaml` is looked up next to the manifest that includes it first, and in the current working directory after that. A manifest that includes itself, directly or through another file, is reported as a circular include. `cli dev` watches included files too. ## How files are merged All included files are merged together first. The result is then merged into your manifest. | Kind of value | Rule | Example | |---|---|---| | Mapping | Merged key by key, at every level. | `commands`, `types`, `vars`, and the keys of a single command | | List | Items from the include are added, without duplicates. | `requires`, `tests`, `functions` and `imports` when both are lists | | Anything else | The value in your manifest wins. | a command's `help` or `run` | In the example, both files define `whoami`. The command keeps its `run` script from the include and takes `help` from `ops.yaml`. Two details to keep in mind: - When the same key holds a list in one file and a block of text in the other, your manifest's value is kept and the include's is dropped. Write `imports` as a list in both files if both need imports. - Include files are merged in the order they are listed. When two of them set the same key to different values, the one listed first wins. ## Variables in include files Each file is rendered on its own. `{{ team }}` works inside `shared/common.yaml`, where `team` is defined, but not inside `ops.yaml`. The Python variable `team` is available to every command in the merged CLI, which is why `whoami` can print it. See [Vars and Jinja](vars-and-jinja.md). # Vars and Jinja A manifest is rendered as a [Jinja2](https://jinja.palletsprojects.com/) template before it is read as YAML. `vars` supplies the values. Use them for anything you would otherwise repeat: file names, URLs, defaults. ## Defining vars ```yaml vars: app: shop # Jinja expression, evaluated when the manifest is loaded region: "{{ env['DEPLOY_REGION'] or 'eu-west-1' }}" # A var can build on other vars image: "registry.example.com/{{ app }}:{{ region }}" # Numbers and booleans keep their type retries: 3 # A value in braces is a Python expression instead of a string workdir: {os.getcwd()} ``` There are three kinds of value: | You write | The var is | |---|---| | `app: shop` | The string `shop`. | | `region: "{{ env['DEPLOY_REGION'] or 'eu-west-1' }}"` | A string computed by a Jinja expression. | | `image: "registry.example.com/{{ app }}:{{ region }}"` | A string built from other vars. | | `retries: 3` | The integer `3`. Numbers and booleans keep their type. | | `workdir: {os.getcwd()}` | The Python expression `os.getcwd()`, evaluated each time the CLI starts. | Quote a string value that starts with `{{`, and quote a number that should stay text (`zip_code: "02134"`). Braces around a value turn it into a Python expression. A var name becomes a Python variable, so it has to be a valid identifier: `data_file`, not `data-file`. Names the generated code uses itself, such as `cli` and `typer`, are rejected too. Vars can use each other in any order. cliffy resolves them before the rest of the file, and reports vars that refer to each other in a circle: ```text ~ error loading manifest: vars.yaml: vars reference each other in a circle: a -> b -> a. A var cannot be built from itself. ``` ## Using vars Every var is available in two ways. ```yaml show: help: Print the resolved variables run: | print("app={{ app }} region={{ region }} image={{ image }}") print(f"app={app} retries={retries + 1} cwd={workdir == os.getcwd()}") ``` ```console $ cli run vars.yaml show app=shop region=eu-west-1 image=registry.example.com/shop:eu-west-1 app=shop retries=4 cwd=True ``` 1. **As a placeholder.** `{{ app }}` is replaced with the value when the manifest is loaded. This works anywhere in the file: in scripts, help text, params, even in command names. 2. **As a Python variable.** The generated module defines `app = 'shop'`, so `run` scripts and functions can use `app` directly, and `retries = 3` as a number. A var written with braces, like `workdir`, is only useful this way. A placeholder for a name that is not defined is replaced with nothing, without an error. If output is unexpectedly empty, check the spelling with `cli render`. ## Environment variables `env` holds the environment of the process that loads the manifest. ```yaml vars: region: "{{ env['DEPLOY_REGION'] or 'eu-west-1' }}" ``` ```console $ DEPLOY_REGION=us-east-2 cli run vars.yaml show app=shop region=us-east-2 image=registry.example.com/shop:us-east-2 app=shop retries=4 cwd=True ``` `env` can be used in `vars` and anywhere else in the file. The value is read **when the manifest is loaded or built** and then fixed in the generated code. A CLI installed with `cli load` keeps the value it saw at load time. To read the environment each time a command runs, use Python instead: ```yaml imports: - import os commands: region: print(os.environ.get("DEPLOY_REGION", "eu-west-1")) ``` ## Loops and conditions Jinja statements such as `{% for %}` and `{% if %}` work as long as the file is still valid YAML before rendering. Put them in YAML comments: ```yaml # {% for stage in ['staging', 'production'] %} deploy.{{ stage }}: help: Deploy {{ app }} to {{ stage }} run: print("deploying {{ app }} to {{ stage }} in {{ region }}") # {% endfor %} ``` ```console $ cli run vars.yaml deploy staging deploying shop to staging in eu-west-1 $ cli run vars.yaml deploy production deploying shop to production in eu-west-1 ``` The loop produces one `deploy.` command per item. ## Literal braces To keep double braces in your Python code, wrap them in `{% raw %}`: ```yaml braces: help: Print literal double braces run: print({% raw %}"{{ not a variable }}"{% endraw %}) ``` Single braces, as used in f-strings (`f"{name}"`), need no escaping. ## Comments are part of the template The whole file is rendered, YAML comments included. That is what makes the loop above possible, and it means that double braces in a comment are read as a placeholder too. A comment such as `# use {{ }} for placeholders` is a Jinja syntax error, which is reported with its line: ```text ~ error loading manifest: vars.yaml, line 12: Jinja syntax error: Expected an expression, got 'end of print statement' # use {{ }} for placeholders The whole file is a Jinja template, comments included. To keep braces as text, wrap them in {% raw %} and {% endraw %}. ``` Write the comment without double braces, or wrap them in `{% raw %}`. ## Limits - Vars defined in an [include file](includes.md) can be used as placeholders only inside that file. ## The complete example ```yaml title="vars.yaml" name: deployer version: 0.1.0 help: Variables and Jinja templating imports: - import os vars: app: shop # Jinja expression, evaluated when the manifest is loaded region: "{{ env['DEPLOY_REGION'] or 'eu-west-1' }}" # A var can build on other vars image: "registry.example.com/{{ app }}:{{ region }}" # Numbers and booleans keep their type retries: 3 # A value in braces is a Python expression instead of a string workdir: {os.getcwd()} commands: show: help: Print the resolved variables run: | print("app={{ app }} region={{ region }} image={{ image }}") print(f"app={app} retries={retries + 1} cwd={workdir == os.getcwd()}") # {% for stage in ['staging', 'production'] %} deploy.{{ stage }}: help: Deploy {{ app }} to {{ stage }} run: print("deploying {{ app }} to {{ stage }} in {{ region }}") # {% endfor %} braces: help: Print literal double braces run: print({% raw %}"{{ not a variable }}"{% endraw %}) tests: - show: | assert result.output.splitlines() == [ "app=shop region=eu-west-1 image=registry.example.com/shop:eu-west-1", "app=shop retries=4 cwd=True", ] - deploy staging: assert result.output == "deploying shop to staging in eu-west-1\n" - deploy production --help: assert "Deploy shop to production" in result.output - braces: assert "not a variable" in result.output ``` # Shell commands In any script, a line that starts with `$` is run as an external command. cliffy uses [pybash](https://github.com/jaykv/pybash) to translate these lines into `subprocess` calls, so you can move between Python and shell freely. ## The basics ```yaml hello: $ echo "hello from the shell" mixed: help: Mix Python and shell lines params: - name: str! run: | print("python first") $ echo "then shell" if name == "ada": $ echo "shell lines can be indented" ``` `$ echo "then shell"` becomes `subprocess.run(["echo", "then shell"])`. The command is split into arguments the way a shell would split it, and is then started directly, without a shell in between. Shell lines follow Python indentation, so they can sit inside `if` blocks and loops. `$` lines work in `run`, `pre_run`, `post_run`, in callbacks, in `functions` written as a list, and as steps in [`tests`](testing.md#shell-steps). Run `cli render` to see the translation. ## Using Python values `f{expression}` inserts the value of a Python expression into the command. The value is passed as a single argument, whatever it contains: spaces, quotes and `;` are just text. ```yaml say: help: Pass a Python value to a shell command params: - message: str! run: $ echo f{message} f{len(message)} ``` ```console $ cli run shell.yaml say "two words" two words 9 ``` Everything an f-string accepts after the expression works too, and `f{ }` can be used inside quotes: ```yaml price: help: f{ } takes f-string format specs, also inside quotes params: - amount: float! run: | out = $ echo "total: f{amount:,.2f}" print(out.decode().strip()) ``` ### Double braces are Jinja's pybash also has a `{{ expression }}` form, which inserts `str(expression)`. In a manifest, cliffy renders [Jinja](vars-and-jinja.md) first, so `{{ name }}` is replaced with a *var* when the manifest is loaded and never reaches pybash. That is what you want for vars: ```yaml vars: data_file: tasks.json commands: backup: $ cp {{ data_file }} {{ data_file }}.backup ``` For a value that only exists when the command runs, such as a param, use `f{ }`. If you prefer the double-brace form, put the lines in a Jinja `raw` block so they are left alone: ```yaml whoami: help: Double braces belong to Jinja unless they are inside a raw block params: - user: str! run: | {% raw %} out = $ echo {{ user.title() }} {% endraw %} print(out.decode().strip()) ``` To write the markers themselves as text, escape them: `\f{` and, inside a `raw` block, `\{{`. ## Capturing output Assign a shell line to a variable, or wrap it in parentheses, to get its output as `bytes`. `$(command)` does the same in the middle of an expression: ```yaml upper: help: Capture shell output in Python run: | out = $ echo captured print(out.decode().strip().upper()) inline: help: Capture inline with parentheses run: print(($ echo inline).decode().strip()) words: help: Capture in the middle of an expression with $( ) run: print(len($(echo one two three).split()), "words") ``` ```console $ cli run shell.yaml upper CAPTURED $ cli run shell.yaml words 3 words ``` A capture that is not wrapped in brackets runs to the end of the line. ## Pipes and redirection Pipes and redirects are carried out by the generated CLI itself, without a shell: ```yaml sorted: help: Pipes work without a shell run: $ printf 'b\na\n' | sort save: help: So does redirection run: | $ echo "saved" > shell-demo.txt $ cat shell-demo.txt errors-to-file: help: Redirect stderr on its own, together with stdout, or into a pipe run: | $ ls no-such-file 2> shell-demo.txt $ ls . no-such-file &> shell-demo.txt $ ls . no-such-file > shell-demo.txt 2>&1 lines = $ ls no-such-file |& wc -l print(int(lines), "line of errors") ``` | Operator | Meaning | |---|---| | `a | b` | Pipe the output of `a` into `b` | | `a |& b` | Pipe output and errors | | `< file` | Read input from a file | | `> file`, `>> file` | Write or append output | | `2> file`, `2>> file` | Write or append errors | | `&> file`, `&>> file` | Write or append both | | `2>&1`, `>&2` | Send errors to the output, or the output to the errors | ## Chaining commands `&&` runs the next command when the previous one succeeded, `||` when it failed, and `;` in either case: ```yaml chain: help: "&&, || and ; decide what runs next, like in a shell" run: | out = $ true && echo "first worked" || echo "first failed" print(out.decode().strip()) out = $ false || echo "fallback" ; echo "always" print(out.decode().split()) ``` Capturing a chain captures the output of every command that ran. ## Long commands and comments A `$` or `>` line that ends with a backslash continues on the next line. An unquoted `#` starts a comment that runs to the end of the line: ```yaml longest: help: "A trailing backslash continues a command, a # starts a comment" run: | $ printf '%s\n' pear fig banana \ | sort \ | tail -n 1 > shell-demo.txt # the last one in alphabetical order $ echo "a # inside quotes is text" >> shell-demo.txt print(open("shell-demo.txt").read()) ``` A captured command (`out = $ ...`) has to fit on one line. ## When you need a real shell `$` lines never expand glob patterns (`*.txt`), `~` or environment variables (`$HOME`), and they do not support background jobs (`&`) or here-documents (`<<`). For those, start the line with `>` instead of `$`. The line is handed to the shell as written: ```yaml texts: help: Glob patterns need a real shell run: "> ls *.txt" ``` This becomes `subprocess.run("ls *.txt", shell=True)`. A one-line command that starts with `>` must be quoted, because `>` also has a meaning in YAML. Values inserted with `f{ }` are quoted for the place they appear in, so a value is always one word of data and never shell syntax. Here `name` can be `; rm -rf ~` and nothing is removed: ```yaml find: help: A value in a > line is quoted for the shell params: - name: str! run: | > ls | grep -c f{name} || echo "nothing is called" f{name} glob: help: Splice a value in as shell syntax, here a glob pattern params: - pattern: str! run: | {% raw %} > ls {{! pattern }} {% endraw %} ``` `{{! expression }}` is the exception: it splices the value in unquoted, so that it can be a glob pattern or several arguments. Like every double-brace form it has to sit in a `raw` block. !!! danger "Never pass user input to `{{! }}`" A spliced value is interpreted by the shell. A parameter value such as `; rm -rf ~` would be executed. Use `f{ }` for anything that comes from a user, and prefer `$` lines, which have no shell to inject into. See the Python documentation on [subprocess security](https://docs.python.org/3/library/subprocess.html#security-considerations). ## Errors A shell line that fails does not stop the script. Check the result yourself when it matters: ```yaml careless: help: A failing command does not stop the script run: | $ false print("still running") careful: help: Check the exit status yourself run: | result = subprocess.run(["false"]) if result.returncode != 0: print("command failed") raise typer.Exit(code=result.returncode) ``` Two exceptions: - A command that does not exist raises `FileNotFoundError`. - A captured command (`out = $ ...`) raises `subprocess.CalledProcessError` when it exits with a non-zero status. A shell line that cannot be translated is reported when the manifest is loaded, with the command it is in, the line and the column: ```console $ cli load deploy.yaml ~ error in deploy.yaml: command `ship`, run, line 2, column 11: running in the background with & is not supported in a $ command; use a > shell command $ sleep 5 & ^ 💔 ``` ## Things to know - Output of `$` lines goes straight to the terminal. It is not part of `result.output` in [manifest tests](testing.md); capture it and `print` it if you want to assert on it. - A CLI that only uses plain commands imports nothing but `subprocess`. Pipes, redirects, chains and values in `>` lines go through a small helper, `pybash.runtime`, which the generated code imports as `_pybash`. `cli build` copies that helper into the file it builds, so a built CLI does not need pybash. - Quoting follows POSIX shell rules: single quotes are literal, double quotes allow `\"` and `\\`, and a backslash escapes the next character. Quote or escape an operator to pass it as text: `$ echo 'a && b' \| c`. ## The complete example ```yaml title="shell.yaml" name: sh version: 0.1.0 help: Shell commands in run scripts functions: - | def current_branch() -> str: out = $ git rev-parse --abbrev-ref HEAD return out.decode().strip() commands: hello: $ echo "hello from the shell" mixed: help: Mix Python and shell lines params: - name: str! run: | print("python first") $ echo "then shell" if name == "ada": $ echo "shell lines can be indented" say: help: Pass a Python value to a shell command params: - message: str! run: $ echo f{message} f{len(message)} price: help: f{ } takes f-string format specs, also inside quotes params: - amount: float! run: | out = $ echo "total: f{amount:,.2f}" print(out.decode().strip()) whoami: help: Double braces belong to Jinja unless they are inside a raw block params: - user: str! run: | {% raw %} out = $ echo {{ user.title() }} {% endraw %} print(out.decode().strip()) upper: help: Capture shell output in Python run: | out = $ echo captured print(out.decode().strip().upper()) inline: help: Capture inline with parentheses run: print(($ echo inline).decode().strip()) words: help: Capture in the middle of an expression with $( ) run: print(len($(echo one two three).split()), "words") sorted: help: Pipes work without a shell run: $ printf 'b\na\n' | sort save: help: So does redirection run: | $ echo "saved" > shell-demo.txt $ cat shell-demo.txt errors-to-file: help: Redirect stderr on its own, together with stdout, or into a pipe run: | $ ls no-such-file 2> shell-demo.txt $ ls . no-such-file &> shell-demo.txt $ ls . no-such-file > shell-demo.txt 2>&1 lines = $ ls no-such-file |& wc -l print(int(lines), "line of errors") chain: help: "&&, || and ; decide what runs next, like in a shell" run: | out = $ true && echo "first worked" || echo "first failed" print(out.decode().strip()) out = $ false || echo "fallback" ; echo "always" print(out.decode().split()) longest: help: "A trailing backslash continues a command, a # starts a comment" run: | $ printf '%s\n' pear fig banana \ | sort \ | tail -n 1 > shell-demo.txt # the last one in alphabetical order $ echo "a # inside quotes is text" >> shell-demo.txt print(open("shell-demo.txt").read()) texts: help: Glob patterns need a real shell run: "> ls *.txt" find: help: A value in a > line is quoted for the shell params: - name: str! run: | > ls | grep -c f{name} || echo "nothing is called" f{name} glob: help: Splice a value in as shell syntax, here a glob pattern params: - pattern: str! run: | {% raw %} > ls {{! pattern }} {% endraw %} careless: help: A failing command does not stop the script run: | $ false print("still running") careful: help: Check the exit status yourself run: | result = subprocess.run(["false"]) if result.returncode != 0: print("command failed") raise typer.Exit(code=result.returncode) tests: - hello: assert result.exit_code == 0 - mixed ada: assert "python first" in result.output - say hi: assert result.exit_code == 0 - upper: assert result.output == "CAPTURED\n" - inline: assert result.output == "inline\n" - words: assert result.output == "3 words\n" - price 1234.5: 'assert result.output == "total: 1,234.50\n"' - whoami ada: assert result.output == "Ada\n" - sorted: assert result.exit_code == 0 - save: assert result.exit_code == 0 - errors-to-file: assert result.output == "1 line of errors\n" - chain: assert result.output == "first worked\n['fallback', 'always']\n" - longest: assert result.output.splitlines()[0] == "pear" and "inside quotes" in result.output - find "; echo injected": assert result.exit_code == 0 - glob "*.yaml": assert result.exit_code == 0 - texts: assert result.exit_code == 0 - careless: assert result.output == "still running\n" - careful: assert result.exit_code == 1 - $ rm -f shell-demo.txt ``` # Click and Typer backends cliffy can generate two kinds of code from the same manifest. | | Typer (default) | Click | |---|---|---| | Enable with | nothing | `use_click: true` | | Generated code uses | `typer` | `rich_click`, or plain `click` when rich-click is not installed | | Extra install | none | none. `pip install "cliffy[rich]"` adds rich-formatted help | | Supported manifest features | all | all, with the differences listed below | **Use Typer unless you have a specific reason to generate Click code.** Typer is the backend the rest of this documentation describes. ## The Click backend ```yaml title="click.yaml" name: clicky version: 0.1.0 help: The same manifest format, rendered with Click use_click: true commands: hello|hi: help: Say hello params: - name: str! - --times: int = 1 run: | for _ in range(times): print(f"hello {name}") resize: help: Explicit params work like in Typer params: - name: image type: str required: true help: File to resize - name: --width type: int short: -w default: 800 help: Target width run: print(image, width) tag: help: Raw Click decorators params: - "@click.option('--loud', is_flag=True, help='Shout the tags')" - "@click.argument('tags', nargs=-1)" run: | for tag in tags: print(tag.upper() if loud else tag) db.migrate: help: Groups work the same way run: $ echo "migrating" tests: - hello Ada --times 2: assert result.output == "hello Ada\nhello Ada\n" - hi Ada: assert result.output == "hello Ada\n" - resize cat.jpg -w 100: assert result.output == "cat.jpg 100\n" - resize: assert result.exit_code == 2 - tag --loud a b: assert result.output == "A\nB\n" - db migrate: assert result.exit_code == 0 ``` ```console $ cli run click.yaml -- hello Ada --times 2 hello Ada hello Ada $ cli run click.yaml -- resize cat.jpg -w 100 cat.jpg 100 $ cli run click.yaml -- tag --loud a b A B $ cli run click.yaml db migrate migrating ``` ### What works - Commands, groups (`db.migrate`) and aliases (`hello|hi`) - Shorthand and explicit params with the same meaning as for Typer: a name with `--` is an option, anything else is a positional argument, `!` or `required: true` makes it required, and `short`, `default` and `help` are passed on - `types`, command `config`, `global_params`, `callbacks`, `pre_run`, `post_run` and command templates - Generic params, written as Click decorators - `vars`, `imports`, `functions`, `includes`, shell lines and `cli test` ### What differs from Typer | Feature | With `use_click: true` | |---|---| | Generic params | A string param is a Click decorator such as `"@click.option('--loud', is_flag=True)"`. A Typer signature entry is rejected. | | `types` | A plain type (`Count: int`, `Count: int = 1`) works with both backends. A full definition is a Click decorator, `Loud: "@click.option('--loud', is_flag=True)"`; a `typer.Option(...)` definition is rejected with an error that names the command. | | Inline `typer.Option(...)` in a shorthand param | Rejected. Use a Click decorator. | | List types | `--tag: list[str]` can be given several times and arrives as a tuple, not a list. | | Boolean options | `--loud: bool = False` is a `--loud/--no-loud` switch, also when it has a short form. | | `config.rich_help_panel` | Passed to rich-click as `panel`. The generated code then requires rich-click. | | `--version` output | `, version ` instead of `, `. | A manifest that uses something the Click backend cannot express stops with an error such as: ```text ~ error in tools.yaml: command `pack`: param `level` uses types.Level, which is a Typer definition (...) ``` ### rich-click is optional The generated module starts with: ```python try: import rich_click as click except ImportError: # plain help output when rich-click is not installed import click ``` With rich-click installed the help is formatted with Rich. Without it the same CLI runs on plain Click, which cliffy always installs. `cli build` reads this import and ships rich-click with the built CLI. ### Building `cli build` reads the import of `rich_click` from the generated code and bundles rich-click instead of Typer, so the manifest does not have to list it under `requires`. ## Typer notes - Typer depends on Rich, so generated CLIs print styled help and tracebacks without extra setup. - Typer adds `--install-completion` and `--show-completion` to every CLI. Turn them off with: ```yaml cli_options: add_completion: false ``` - Anything from the [Typer documentation](https://typer.tiangolo.com/) that fits in a function signature or body can be used in `types`, generic params and `run` scripts. # Testing manifests A manifest can carry its own tests. Each test runs a command of your CLI and checks the result with Python assertions. `cli test` runs them without loading or building anything. ## Writing tests ```yaml tests: # A string starting with $ or > is a shell step, handy for setup and cleanup - $ rm -f counter.txt # A mapping is a test case: the command line, then the assertion to run - show: assert result.output == "0\n" - bump: assert result.exit_code == 0 # Use a block when you need several statements - bump --by 2: | assert result.exit_code == 0 assert result_text == "3" # Usage errors exit with code 2 - bump --by nope: assert result.exit_code == 2 # An uncaught exception is stored on the result - crash: | assert result.exit_code == 1 assert isinstance(result.exception, ValueError) - "> rm -f counter.*" ``` ```console $ cli test testing.yaml ✨ Invoking tests ✨ 🪄 > counter show ⚗️ > 0 ✅ 1 of 5 ... 💚 All tests passed! ``` ### Test cases A test case is a one-key mapping: ```yaml - : ``` The command line is everything you would type after the CLI's name. It is split like a shell would split it, so quotes work: `add "buy milk" --tag home`. The assertion code is Python. Two variables are available: | Variable | What it is | |---|---| | `result` | The outcome of the command. Use `result.output` (everything the command printed), `result.exit_code` and `result.exception`. | | `result_text` | `result.output` with leading and trailing whitespace removed. | Use `assert` statements. A test passes when the code runs without raising. ### Shell steps An entry that is a plain string is a setup or cleanup step, not a test case. It must start with `$` or `>` and follows the rules of [shell commands](shell-commands.md): `$` runs the command directly, `>` runs it through a shell (needed for glob patterns such as `counter.*`). Quote an entry that starts with `>`. Entries run from top to bottom, so order the steps and cases accordingly. ## Running tests ```console $ cli test testing.yaml $ cli test testing.yaml --exitfirst ``` `--exitfirst` (or `-x`) stops at the first failing test. A failing assertion is reported with its line: ```text 💔 AssertionError: (line 1) > assert result.output == "1\n" ``` So is an assertion that raises something else, or that is not valid Python: ```text 💔 NameError: name 'reslt' is not defined (line 1) > assert reslt.exit_code == 0 ``` When the command itself raises, the exception is reported against the manifest: the command, the exception, and the line of the `run`, `pre_run` or `post_run` block it was raised on. ```text 💥 command `add` raised KeyError: 'notes' run, line 2: data["notes"].append(text) ``` No Python traceback is printed. The assertions still run, so a test can expect the exception with `assert isinstance(result.exception, KeyError)`. To see the full traceback of the command as well: ```console $ cli test testing.yaml --traceback ``` The run ends with one of three messages: | Last line | Meaning | Exit code | |---|---|---| | `💚 All tests passed!` | Every test case passed. | 0 | | `💛 Some tests failed :(` | At least one passed and at least one failed. | 1 | | `All tests failed 💔` | No test case passed. | 1 | The exit code is 0 only when every test case passed, so a CI job can run `cli test mycli.yaml` as a step of its own. ## Things to know - **Colors and styles are removed from the output** before your assertions run, so a test gives the same result in a terminal and on a CI system. - **Tests run in the current directory** and are not isolated from each other. A command that writes a file really writes it. Clean up with shell steps, as the example does. - **Output of `$` lines is not captured.** It goes to the terminal and is not in `result.output`. Assert on the exit code, or capture the output in Python and `print` it. See [Shell commands](shell-commands.md#capturing-output). - **Keep `: ` out of one-line assertions.** YAML reads a colon followed by a space as the end of a key, so `- show: assert result.output == "count: 0\n"` is a YAML error. Use a block: ```yaml - show: | assert result.output == "count: 0\n" ``` - **The counter counts test cases.** `✅ 2 of 5` is the second test case of five. Shell steps are run but not counted, and a failed case keeps its number. - **Shell steps are not checked.** A step that exits with an error does not fail the run. Assert on the state it should have produced in the next test case. A step whose program cannot be started at all (it does not exist) ends the run with an error. ## The complete example ```yaml title="testing.yaml" name: counter version: 0.1.0 help: A counter stored in a file, with tests imports: | from pathlib import Path functions: - | def read_count() -> int: path = Path("counter.txt") return int(path.read_text()) if path.exists() else 0 commands: show: help: Print the counter run: print(read_count()) bump: help: Increase the counter params: - --by: int = 1 run: | total = read_count() + by Path("counter.txt").write_text(str(total)) print(total) crash: help: Always fails run: raise ValueError("boom") tests: # A string starting with $ or > is a shell step, handy for setup and cleanup - $ rm -f counter.txt # A mapping is a test case: the command line, then the assertion to run - show: assert result.output == "0\n" - bump: assert result.exit_code == 0 # Use a block when you need several statements - bump --by 2: | assert result.exit_code == 0 assert result_text == "3" # Usage errors exit with code 2 - bump --by nope: assert result.exit_code == 2 # An uncaught exception is stored on the result - crash: | assert result.exit_code == 1 assert isinstance(result.exception, ValueError) - "> rm -f counter.*" ``` # Hot-reload dev loop While you are writing a manifest, there are three ways to try your changes. They differ in how much they install. | Command | Installs the CLI | Use it for | |---|---|---| | `cli run notes.yaml -- ` | no | A quick one-off call. | | `cli load notes.yaml`, then `cli update notes` | yes | Using the CLI by name; reload by hand. | | `cli dev notes.yaml` | yes | Reloading automatically on every save. | ## `cli dev` ```console $ cli dev notes.yaml 🔄 Watching notes.yaml for changes... ``` Leave this running and edit the manifest. Each time you save, cliffy regenerates and reloads the CLI: ```console ✨ Reloaded notes CLI v0.1.0 ✨ ``` In a second terminal, with the same environment active, the `notes` command always reflects the last save. Stop the watcher with Ctrl+C. The watcher follows every file the CLI is generated from: the manifest, the manifests it lists under [`includes`](includes.md) and, for a file written in a [blueprint](../blueprints.md)'s spec, the blueprint's Python file. Saving any of them reloads the CLI. !!! note Nothing is loaded until the first change. Save the file once, or run `cli load notes.yaml` before starting, to get the command right away. ### Run a command on every reload `--run-cli` runs your CLI after each reload with the arguments that follow. It is a quick way to re-check one command while you work on it: ```console $ cli dev notes.yaml --run-cli tags list 🔄 Watching notes.yaml for changes... ✨ Reloaded notes CLI v0.1.0 ✨ general (1) travel (1) ``` Put `--` before the arguments when they include options: ```console $ cli dev notes.yaml --run-cli -- list --tag home $ cli dev notes.yaml --run-cli -- --help ``` A mistake in the manifest prints an error in the watcher's terminal. Fix the file and save again; the watcher keeps running. ## Managing loaded CLIs ```console $ cli list # what is loaded, from which manifest, and how long ago $ cli info notes # version, requirements and the stored manifest $ cli update notes # regenerate from the manifest path recorded at load time $ cli remove notes # uninstall $ cli remove-all # uninstall everything cliffy loaded ``` `cli ls`, `cli reload`, `cli rm` and `cli rm-all` are aliases for these. Details are in the [CLI reference](../reference/cli.md). Loading writes a small launcher script named after the CLI into the `bin` directory of the environment. cliffy only replaces or deletes that file while it is exactly the launcher it wrote. If a file with the name of your CLI is already there (`pip`, say), `cli load` stops and asks you to rename the CLI. If you edited the launcher, `cli remove` removes nothing, tells you which file is in the way and leaves the CLI loaded and working; move or delete that file and remove the CLI again. While `cli dev` runs, reloads happen one at a time and each one reads the manifest when it starts, so after a burst of saves the loaded CLI is the one of the last save. Loaded CLIs belong to the Python environment cliffy is installed in. They are available whenever that environment is active and disappear with it. # Building executables `cli build` turns a manifest into one file that you can hand to someone else. The manifest stays the same; `--target` chooses what kind of file you get. | | `zipapp` (default) | `script` | `native` | |---|---|---|---| | What you get | `notes`, a Python [zipapp](https://docs.python.org/3/library/zipapp.html) with the dependencies inside | `notes.py`, readable Python with the dependencies declared at the top | `notes`, a compiled executable with its own Python inside | | The other machine needs | A compatible Python | [uv](https://docs.astral.sh/uv/), or Python with the dependencies installed | Nothing. Same operating system and CPU as the build machine | | Network on the other machine | No | The first run, to download the dependencies | No | | The build machine needs | Network or a pip cache for the dependencies | Nothing | [Rust](https://rustup.rs) (`cargo`), uv and network | | Build time | 2 s | Under 1 s | 1 min | | File size | 1.8 MB | 1 to 12 kB | 30 MB | | First start | 0.2 s | 0.2 s with the dependencies in uv's cache | 1.5 s | | Later starts | 0.05 s | 0.05 s | 0.05 s | | Written to disk on the other machine | 8 MB in `~/.shiv` | uv's cache | 110 MB in the user's data and cache directories | The numbers were measured on an Apple Silicon Mac with Python 3.11 for a CLI whose only dependency is Typer. They show the proportions; yours will differ. Whatever the target, the file holds the generated CLI and does not need cliffy. `$` lines with pipes, redirects or `&&` use a small helper from pybash; `cli build` copies it into the file, so pybash is not needed either. ## Build ```console $ cli build notes.yaml -o dist + notes built 📦 dist/notes (single file, needs Python >=3.10, 1.8 MB) run it with: ./dist/notes $ ./dist/notes --version notes, 0.1.0 ``` Without `-o`, the file is written to the current directory. Several manifests can be built in one call, and a CLI that is already loaded can be built by name: ```console $ cli build notes.yaml hello.yaml -o dist $ cli build notes -o dist --target script ``` When a build fails, the reason is printed and `cli build` exits with 1. A file written in a [blueprint](../blueprints.md#building-a-single-file-executable)'s spec is built the same way for every target; the blueprint is applied at build time and is not shipped. ## Dependencies Every target ships the packages under `requires`: ```yaml requires: - tabulate>=0.9.0 ``` Unlike `cli load`, building from a manifest does not need those packages to be installed in your environment. The framework the generated code imports (Typer, or rich-click with `use_click`) is added for you, pinned to the version installed next to cliffy unless `requires` names it. ## zipapp The default. A zipapp built with [shiv](https://shiv.readthedocs.io/) contains the CLI and its dependencies, downloaded with pip at build time. The other machine needs Python and nothing else. - The line printed after a build says which Python the file needs. The file checks this when it starts and stops with a readable message on an unsupported interpreter. - The file starts with a `#!` line that selects the Python to run it, `/usr/bin/env python3` by default. Change it with `--python`: ```console $ cli build notes.yaml -o dist --python "/usr/bin/env python3.12" ``` - Packages with compiled code are bundled for the platform and Python version you build on. Build on the same kind of system you will run on, or stick to pure-Python dependencies. - Building the same manifest twice in the same environment gives an identical file. - On first run, shiv unpacks the bundled packages to `~/.shiv`. Set the `SHIV_ROOT` environment variable to use another directory. ## script ```console $ cli build notes.yaml -o dist --target script + notes built 📦 dist/notes.py (single file, needs uv, or Python >=3.11 with typer==0.27.3 installed, 3.6 kB) run it with: ./dist/notes.py (or: uv run dist/notes.py) uv downloads the dependencies the first time the script runs ``` The result is the Python that `cli render` shows, with three things in front of it: a `#!` line, the dependencies as [inline script metadata](https://packaging.python.org/en/latest/specifications/inline-script-metadata/) and a check of the Python version. ```python #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.11" # dependencies = [ # "typer==0.27.3", # ] # /// ``` - With uv installed, `./dist/notes.py` and `uv run dist/notes.py` both work. uv creates an environment with exactly the listed dependencies, downloads them on the first run and reuses them afterwards. - Without uv, install the dependencies yourself and run `python dist/notes.py`. - Nothing is downloaded at build time, so the build works offline and gives the same file every time. - The file is plain text: it can be read, reviewed in a diff and edited. This is the target to pick when the reader should see what they run. - `--python` replaces the `#!` line, for example `--python "/usr/bin/env python3"` for machines without uv. ## native ```console $ cli build notes.yaml -o dist --target native + notes built 📦 dist/notes (single file, needs nothing installed, runs on macOS arm64, 30.1 MB) run it with: ./dist/notes the first run unpacks its Python 3.11 (75.0 MB) into the user's data directory, without network access; later runs start right away ``` The result is one compiled executable. It contains a complete Python with the CLI and its dependencies already installed, so the other machine needs neither Python nor network access. ### What the build machine needs - **Rust.** `cargo` compiles the launcher. Install it from [rustup.rs](https://rustup.rs). - **uv.** It downloads the standalone Python and installs the CLI into it. See [Installing uv](https://docs.astral.sh/uv/getting-started/installation/). - **Network access**, for that Python, the dependencies and the source of the launcher. - About a minute, and 700 MB of temporary disk space that is freed when the build ends. When `cargo` or `uv` is missing, `cli build` says so and how to install it before it starts. ### How it is built 1. uv downloads a [standalone Python](https://github.com/astral-sh/python-build-standalone) of the version cliffy runs on. 2. The generated CLI is packaged as a wheel and installed into that Python together with its dependencies. 3. The result is embedded in [PyApp](https://ofek.dev/pyapp/), a launcher written in Rust, which is compiled into the executable. ### What happens on the other machine - The executable runs on the operating system and CPU architecture it was built on. Build on each platform you want to support. The target is available on macOS and Linux. - The first run unpacks the embedded Python, which takes a second or two, and then starts the CLI. Nothing is downloaded and nothing is installed with pip. Later runs only check that the directory exists. - The Python is unpacked to `pyapp/` in the user's data directory (`~/Library/Application Support` on macOS, `~/.local/share` on Linux), and a copy of the archive is kept in the cache directory (`~/Library/Caches/pyapp`, `~/.cache/pyapp`). Together that is about 110 MB for a small CLI. Set `PYAPP_INSTALL_DIR_`, with the name of the CLI in upper case, to unpack somewhere else. Delete the directory to reclaim the space; the next run unpacks it again. - Every argument goes to your CLI. PyApp's own `self` commands are turned off. `--python` does not apply to this target. ## Things that hold for every target - Values taken from `env` in `vars` are fixed at build time. See [Vars and Jinja](vars-and-jinja.md#environment-variables). - External commands that the CLI runs with `$` lines are not bundled. They have to exist on the other machine. ## Check before you ship ```console $ cli test notes.yaml $ cli build notes.yaml -o dist $ ./dist/notes --help ``` # Generating docs `cli docs` writes a reference page for your CLI from its manifest: the description, every command with its params and aliases, and the examples you provide. ## Add examples `examples` is a list of command lines with a description each. They are part of the documentation `cli docs` writes, and the generated CLI shows them at the end of its own `--help`: ```text Examples: $ notes add "buy milk" --tag home # Add a note tagged home. $ notes ls --tag home # Show only the notes tagged home. ``` Write an example as you would type it, starting with the name of the CLI. It is shown as written: quotes, brackets and backslashes are not interpreted. The help of a single command does not repeat the examples. ```yaml examples: - command: notes add "buy milk" --tag home description: Add a note tagged home. - command: notes ls --tag home description: Show only the notes tagged home. ``` Give every example a `description`. A plain string is accepted too, but is rendered with `None` in place of the description. ## Generate ```console $ cli docs notes.yaml + notes.md ``` | Option | Values | Default | |---|---|---| | `--format`, `-f` | `md`, `rst`, `html` | `md` | | `--output-dir`, `-o` | an existing directory | the current directory | ```console $ mkdir -p site $ cli docs notes.yaml --format html --output-dir site + notes.html ``` ## What the page contains For the `notes` manifest from the [Quickstart](../quickstart.md), the Markdown output starts like this: ````markdown # notes v0.1.0 Keep short notes in a JSON file ## Commands ### add Add a note Parameters: - text: Text - tag: Tag ### list|ls List notes, optionally filtered by tag Parameters: - --tag: str (default: "") Aliases: ls ```` and ends with the examples: ````markdown ## Examples ```bash notes add "buy milk" --tag home ``` Add a note tagged home. ```` - Commands are listed in manifest order, under the key they have in the manifest. - Shorthand and explicit params are listed with their type; explicit params also show their help text. Generic params (plain strings) are left out. - Pass a manifest path or the name of a loaded CLI. A loaded CLI is documented from the manifest path recorded by `cli load`. # Blueprints A cliffy manifest describes a CLI in cliffy's own vocabulary: `commands`, `params`, `run`. A **blueprint** lets you bring your own vocabulary instead. You define the YAML schema that fits your domain and tell cliffy how it maps onto a CLI. Files written in that schema then work with `cli load`, `cli run`, `cli render`, `cli test`, `cli dev`, `cli docs` and `cli build`, which turns them into a single-file executable. ``` your YAML spec ──▶ Blueprint (validate, to_manifest) ──▶ cliffy manifest ──▶ Typer or Click code ──▶ one executable file ``` ## Writing a blueprint A blueprint is a [pydantic](https://docs.pydantic.dev) model with one required method. The fields are your schema, and `to_manifest` returns the cliffy manifest for a validated file. ```python title="tasks.py" from pydantic import BaseModel, Field from cliffy.blueprint import Blueprint from cliffy.manifest import CLIManifest, Command, RunBlock class Task(BaseModel): run: str | list[str] = Field(description="Shell line(s) to run, in order.") help: str = Field(default="", description="What the task does, shown in --help.") class Tasks(Blueprint): """Task runner: every entry under `tasks` becomes a command that runs shell lines""" name: str = Field(description="Name of the CLI.") version: str = Field(default="0.1.0", description="Version of the CLI.") help: str = Field(default="", description="Description of the CLI.") tasks: dict[str, str | list[str] | Task] = Field(min_length=1, description="Tasks by name.") def to_manifest(self) -> CLIManifest: commands = {} for name, entry in self.tasks.items(): task = entry if isinstance(entry, Task) else Task(run=entry) lines = [task.run] if isinstance(task.run, str) else task.run script = "\n".join(f"subprocess.run({line!r}, shell=True, check=True)" for line in lines) commands[name] = Command(help=task.help, run=RunBlock(script)) return CLIManifest(name=self.name, version=self.version, help=self.help, commands=commands) ``` A file written in that spec names its blueprint with a top-level `blueprint:` key: ```yaml title="tasks.yaml" blueprint: tasks.py name: tasks help: Project tasks tasks: greet: echo "hello from tasks" check: - echo "linting" - echo "testing" release: help: Check, then tag the release run: - echo "linting" - echo "tagging" ``` ``` $ cli run tasks.yaml -- greet hello from tasks $ cli load tasks.yaml $ tasks check linting testing ``` `to_manifest` may return a `CLIManifest` or a plain mapping that validates as one. Because the result is an ordinary manifest, everything cliffy does with manifests applies: both the Typer and the Click generator (`use_click`), `requires`, `tests`, command aliases, callbacks and so on. The whole file is rendered with Jinja2 before the blueprint sees it, exactly like a manifest. ## Selecting a blueprint Either in the file, or on the command line. The command line wins. ```yaml blueprint: tasks.py ``` ``` cli run jobs.yaml --blueprint tasks.py -- greet ``` `--blueprint` / `-b` is available on `load`, `run`, `render`, `build`, `dev`, `test`, `validate` and `docs`. `cli update` reuses the blueprint a CLI was loaded with, and `cli info` shows it. A blueprint reference is one of: | Reference | Meaning | |---|---| | `tasks.py`, `./bp/tasks.py:Tasks` | A Python file. In a YAML file the path is relative to that file; on the command line it is relative to the working directory. Add `:ClassName` when the file defines several blueprints. | | `mypackage.blueprints:Tasks` | An importable class. `mypackage.blueprints` alone works when the module defines exactly one blueprint. | | `tasks` | A blueprint provided by an installed package (see [Sharing blueprints](#sharing-blueprints)). | Cliffy reports a reference it cannot use and says why: the file or module does not exist, the name is provided by more than one package, the module defines no blueprint or more than one, or the target is not a blueprint. ## Validation errors Unknown keys are rejected by default, so typos are caught. Errors are reported against your spec, with the path to the offending key: ``` $ cli validate server.yaml ~ server.yaml is not a valid MCPTools spec tools[0].name: Value error, `serve` is a reserved name (got 'serve') tools[1].parameters[0].type: Input should be 'string', 'integer', 'number', 'boolean' or 'array' (got 'strng') tols: Extra inputs are not permitted 💔 ``` Use pydantic validators for rules that go beyond types. To accept unknown keys, set `model_config = ConfigDict(extra="ignore")` on your blueprint. ## Hooks Three optional hooks run around `to_manifest`: ``` pre_validate(raw) ──▶ validation ──▶ to_manifest() ──▶ post_manifest(manifest) ──▶ code generation ──▶ post_codegen(code) ``` | Hook | Receives | Use it to | |---|---|---| | `pre_validate(cls, raw)` (classmethod) | The YAML mapping, without the `blueprint` key | Accept shorthand forms, set defaults, rename legacy keys | | `post_manifest(self, manifest)` | The `CLIManifest` | Adjust the manifest, i.e. add commands every CLI of this kind gets | | `post_codegen(self, code)` | The generated Python source | Adjust the generated code, i.e. prepend a license header | ```python class Tasks(Blueprint): ... @classmethod def pre_validate(cls, raw): # allow `jobs:` as an older name for `tasks:` if "jobs" in raw: raw = {**raw, "tasks": raw.pop("jobs")} return raw def post_codegen(self, code): return "# generated from a Tasks spec, do not edit\n" + code ``` ## Editor completion `cli blueprint schema` prints the JSON schema of a blueprint's spec. ``` cli blueprint schema tasks.py -o tasks.schema.json ``` Reference it from your YAML files to get validation and completion from the YAML language server: ```yaml # yaml-language-server: $schema=tasks.schema.json blueprint: tasks.py ``` Field descriptions (`Field(description=...)`) show up as hover documentation. ## Building a single-file executable `cli build` applies the blueprint at build time and bundles the generated CLI with its dependencies into one executable [zipapp](https://docs.python.org/3/library/zipapp.html). With `--target script` or `--target native` the same file becomes a single Python script or a compiled executable instead; see [Building executables](guides/building.md). ``` $ cli build tasks.yaml -o dist + tasks built 📦 dist/tasks (single file, needs Python >=3.10, 1.8 MB) run it with: ./dist/tasks $ ./dist/tasks greet hello from tasks ``` The file runs on any machine with a matching Python. It does not need cliffy, the blueprint, pydantic or the YAML file. * **Dependencies.** Whatever the manifest lists under `requires` is bundled. Let your blueprint set `requires` for the packages its generated code imports. The CLI framework (typer, or rich-click with `use_click`) is added automatically, pinned to the version installed next to cliffy unless `requires` names it. * **Interpreter.** `--python` / `-p` sets the shebang, by default `/usr/bin/env python3`. The build reports which Python versions the bundle supports. A bundle of pure-Python packages runs on every version its packages accept. A bundle with compiled extensions only runs on the Python minor version it was built with, so build with the version you deploy to. Started with an unsupported Python, the executable exits with a message that says so. * **Reproducible.** Building the same input twice with the same package versions gives a byte-identical file. * **Errors.** When a dependency cannot be installed, the build prints pip's reason and writes no file. On first run the executable unpacks its packages into `~/.shiv` (set `SHIV_ROOT` to change the location). ## Sharing blueprints A package can register blueprints under the `cliffy.blueprints` entry-point group: ```toml title="pyproject.toml" [project.entry-points."cliffy.blueprints"] tasks = "mypackage.blueprints:Tasks" ``` Once the package is installed, the blueprint is available by name: ```yaml blueprint: tasks ``` ``` $ cli blueprints ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Name ┃ Blueprint ┃ Package ┃ Description ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ tasks │ mypackage.blueprints:Tasks │ mypackage │ Task runner: every entry under... │ └───────┴────────────────────────────┴───────────┴───────────────────────────────────┘ ``` `cli blueprints` is short for `cli blueprint list`. The description is the first line of the class docstring. ## Examples Two worked examples live in [`examples/blueprints`](https://github.com/jaykv/cliffy/tree/main/examples/blueprints): * `tasks.py` with `tasks.yaml`: the task runner from this page. * `mcp_tools.py` with `text-tools.yaml`: an [MCP](https://modelcontextprotocol.io) tool server. The spec lists tools with typed parameters and a Python body. The generated CLI has one command per tool, a `tools` command that prints the tool definitions, and a `serve` command that speaks MCP over stdio without extra dependencies. `cli build examples/blueprints/text-tools.yaml` gives a single file you can register as an MCP server. ## Callbacks Callbacks are a manifest feature that blueprints often need: code that runs before every command of the CLI, or of one command group. They work with both Typer and Click, and the built-in `--version` and `--aliases` options stay in place. ```yaml callbacks: # no `group`: runs before every command, its params become options of the CLI itself - params: - name: --verbose type: bool default: false short: -v help: Print what is about to run invoke_without_command: true run: | ctx.obj = {"verbose": verbose} if ctx.invoked_subcommand is None: print("no command given, try --help") elif verbose: print(f"running {ctx.invoked_subcommand}") # runs before every command of the `db` group - group: db run: print("connecting to db") commands: hello: print("hello") db.migrate: print("migrating") ``` ``` $ mycli --verbose db migrate running db connecting to db migrating ``` | Key | Meaning | |---|---| | `run` | Code to run. The Typer or Click context is available as `ctx`. | | `group` | Command group the callback belongs to, i.e. `db` or `db.tables`. Leave it out for the CLI itself. | | `params` | Options given before the command name, in the same syntax as command params. | | `invoke_without_command` | Also run when no command is given, instead of showing the help. | Several callbacks for the same target run in the order given. A manifest with only callbacks and no `commands` is valid, which makes a single-purpose CLI. # AI commands Cliffy includes AI-powered commands that generate manifests and answer questions about cliffy. They live in the `cli ai` command group; every option is listed in the [CLI reference](reference/cli.md#cli-ai). The AI commands are a convenience. Nothing else in cliffy needs a model: a manifest written by hand, or by an agent from the [manifest schema](reference/manifest.md), works the same way. !!! note Requires the `ai` extra: `pip install "cliffy[ai]"`. See [Installation](installation.md#extras). Without it, `cli ai` commands exit with an install hint; the rest of cliffy is unaffected. ## Choosing a model The AI commands are built on [Pydantic AI](https://pydantic.dev/docs/ai/models/overview/) and work with any model it supports. Models are named `provider:model`, and each provider reads its API key from its usual environment variable. The model is picked in this order: 1. the `--model` / `-m` option 2. the `CLIFFY_AI_MODEL` environment variable 3. a default for the first provider that has an API key set: | API key | Default model | |---|---| | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) | `google:gemini-flash-latest` | | `ANTHROPIC_API_KEY` | `anthropic:claude-sonnet-5-5` | | `OPENAI_API_KEY` | `openai:gpt-5.4-mini` | ```bash # use the default model of the provider whose key is set export ANTHROPIC_API_KEY='your-api-key' cli ai generate mycli "Create a todo app" # pick a model for one command cli ai generate mycli "Create a todo app" --model openai:gpt-5.5 # or for the whole shell session export CLIFFY_AI_MODEL=google:gemini-2.5-pro ``` !!! tip Use `--preview` to print the fully constructed prompt instead of sending it. This needs neither a model nor an API key. ## Generating a manifest Generate complete CLI manifests from natural language descriptions: ```bash cli ai generate mycli "Create a CLI for managing docker containers with commands to list, start, stop and remove containers" cli load mycli.yaml ``` The model's reply is validated before anything is written: it has to be valid YAML, match the manifest schema, and produce a CLI that is valid Python. A reply that fails is sent back to the model together with the reason, up to `--retries` times (default 2). If no valid manifest comes back, the command fails and writes nothing. `mycli.yaml` is written to the current directory, or to `--output-dir`. An existing file is only replaced with `--force`. !!! warning Validation checks that the manifest loads, not that the commands do what you asked. Read the generated run blocks before running the CLI. ## Asking questions Get AI assistance for understanding and working with cliffy: ```bash cli ai ask "How do I create nested subcommands?" ``` The question is answered from a reference built from your installed cliffy version: its command list, the annotated `cli init` template and the manifest JSON schema. You can also get targeted help about specific CLIs by referencing a manifest or a loaded CLI: ```bash cli ai ask --cli mycli.yaml "How do I add input validation to the start command?" ``` The answer is printed to stdout and token usage to stderr, so the answer can be redirected to a file. ## Giving cliffy's documentation to an agent `cli ai llms-txt` prints cliffy's documentation as a single text file in the [llms.txt](https://llmstxt.org/) layout: the quickstart, the guides and the CLI and manifest references. ```bash cli ai llms-txt > cliffy-llms.txt ``` The file is bundled with the installed package, so it needs no network access, no model and no `ai` extra, and it always describes the version you have installed. Put it in the context of a coding agent and it can write and fix manifests without looking anything up. The same file is published with the documentation at [`/llms.txt`](llms.txt). # CLI reference Installing cliffy adds two identical entry points, `cli` and `cliffy`. This page uses `cli`. It is generated from the command definitions in `cliffy/cli.py` and `cliffy/ai.py`, so the arguments, options and defaults below are the ones the installed version accepts. ```text cli [OPTIONS] COMMAND [ARGS]... ``` ## Commands at a glance | Command | Description | |---|---| | [`cli ai`](#cli-ai) | AI-powered commands. `generate` and `ask` require the `ai` extra: pip install 'cliffy[ai]' | | [`cli blueprint`](#cli-blueprint) | Work with blueprints: custom YAML specs that map onto a CLI | | [`cli build`](#cli-build) | Build a CLI manifest or a loaded CLI into a single file to hand to someone else | | [`cli dev`](#cli-dev) | Start hot-reloader for a manifest for active development. | | [`cli docs`](#cli-docs) | Generate documentation for a CLI | | [`cli info`](#cli-info) | Display CLI info | | [`cli init`](#cli-init) | Generate a CLI manifest template | | [`cli list`](#cli-list) | List all CLIs loaded | | [`cli load`](#cli-load) | Load CLI for given manifest(s) | | [`cli remove`](#cli-remove) | Remove a loaded CLI by name | | [`cli remove-all`](#cli-remove-all) | Remove all loaded CLIs | | [`cli render`](#cli-render) | Render the CLI manifest generation as code | | [`cli run`](#cli-run) | Run CLI for a manifest | | [`cli test`](#cli-test) | Run tests defined in a manifest | | [`cli update`](#cli-update) | Reloads CLI by name | | [`cli validate`](#cli-validate) | Validate a CLI manifest: its structure, its requirements and the code generated from it | ## Global options | Option | Description | |---|---| | `--version` | Print the version and exit. | | `--aliases` | List the command aliases and exit. | | `-h`, `--help` | Show help. Every command accepts this option. | ## Aliases Aliases are hidden from `cli --help`. `cli --aliases` prints this list. | Alias | Command | |---|---| | `cli ls` | [`cli list`](#cli-list) | | `cli add` | [`cli load`](#cli-load) | | `cli reload` | [`cli update`](#cli-update) | | `cli rm` | [`cli remove`](#cli-remove) | | `cli rm-all` | [`cli remove-all`](#cli-remove-all) | | `cli rmall` | [`cli remove-all`](#cli-remove-all) | | `cli blueprints` | [`cli blueprint list`](#cli-blueprint-list) | ## `cli ai` AI-powered commands. `generate` and `ask` require the `ai` extra: pip install 'cliffy[ai]' ```text cli ai [OPTIONS] COMMAND [ARGS]... ``` `generate` and `ask` need the `ai` extra (`pip install "cliffy[ai]"`). Both send the manifest JSON schema to the model as part of the prompt and print token usage when they finish. `llms-txt` needs no extra and calls no model. Model names use the pydantic-ai `provider:model` form, and the API key of that provider must be set in the environment. Without `--model`, the `CLIFFY_AI_MODEL` environment variable is used, then a default for the first provider that has an API key set. See [AI commands](../ai.md). ### `cli ai ask` Ask a question about cliffy or a specific CLI manifest. ```text cli ai ask [OPTIONS] PROMPT ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `PROMPT` | text | yes | | Your question. | | `--preview` | flag | no | | Print the prompt instead of sending it. Needs no model. | | `--max-tokens` | integer range | no | | Maximum number of tokens in a model response. | | `-m`, `--model` | text | no | | Model to use, as `provider:model`. See https://pydantic.dev/docs/ai/models/overview/. Defaults to a model of the first provider with an API key set, else google:gemini-flash-latest. | | `--cli` | file | no | | Loaded CLI or manifest to include in the prompt as reference. | `--preview` prints the prompt and exits without calling a model. ### `cli ai generate` Generate a CLI manifest based on a description. ```text cli ai generate [OPTIONS] CLI_NAME DESCRIPTION ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `CLI_NAME` | text | yes | | Name of the CLI. The manifest is written to `.yaml`. | | `DESCRIPTION` | text | yes | | Plain-language description of the CLI you want. | | `--preview` | flag | no | | Print the prompt instead of sending it. Needs no model. | | `--max-tokens` | integer range | no | | Maximum number of tokens in a model response. | | `-m`, `--model` | text | no | | Model to use, as `provider:model`. See https://pydantic.dev/docs/ai/models/overview/. Defaults to a model of the first provider with an API key set, else google:gemini-flash-latest. | | `--retries` | integer range | no | `2` | How often to send an invalid manifest back to the model to be fixed. | | `-f`, `--force` | flag | no | | Overwrite the manifest file if it exists. | | `-o`, `--output-dir` | directory | no | `.` | Output directory | The reply of the model is validated before the manifest is written, and sent back with the reason when it is not a manifest cliffy can load. `--preview` prints the prompt and exits without calling a model. ### `cli ai llms-txt` Print the bundled cliffy documentation as llms.txt. Needs no model. ```text cli ai llms-txt [OPTIONS] ``` Prints the documentation that is bundled with the installed cliffy as one text file, in the [llms.txt](https://llmstxt.org/) layout. It needs no model, no API key and no `ai` extra, so it works offline and always matches the installed version. ```console $ cli ai llms-txt > cliffy-llms.txt $ cli ai llms-txt | pbcopy ``` Give the output to a coding agent, or add it to a project's context, so that it can write manifests without looking anything up. ## `cli blueprint` Work with blueprints: custom YAML specs that map onto a CLI ```text cli blueprint [OPTIONS] COMMAND [ARGS]... ``` A blueprint lets a YAML file use its own keys instead of `commands` and `params`, and maps them onto a CLI. See [Blueprints](../blueprints.md). ### `cli blueprint list` List the blueprints provided by installed packages ```text cli blueprint list [OPTIONS] ``` Alias: `cli blueprints` Lists the blueprints that installed packages register under the `cliffy.blueprints` entry point group. A blueprint in a `.py` file or referenced as `module:Class` works without being listed. ```console $ cli blueprint list No blueprints installed. Use a .py file or module:Class reference, or install a blueprint package. ``` ### `cli blueprint schema` Print the JSON schema of a blueprint's spec, for validation and editor completion ```text cli blueprint schema [OPTIONS] BLUEPRINT ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `BLUEPRINT` | text | yes | | Blueprint reference: a `.py` file, `module:Class` or the name of an installed blueprint. | | `-o`, `--output` | file | no | | Write the schema to a file | ```console $ cli blueprint schema examples/blueprints/tasks.py -o tasks.schema.json + tasks.schema.json ``` Point a `# yaml-language-server: $schema=` comment at the file to get completion and validation for files written in the blueprint's spec. ## `cli build` Build a CLI manifest or a loaded CLI into a single file to hand to someone else ```text cli build [OPTIONS] [CLI_OR_MANIFESTS]... ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `[CLI_OR_MANIFESTS]...` | file | no | | Manifest files (paths ending in `yaml`) or names of loaded CLIs. | | `-t`, `--target` | `zipapp` \| `script` \| `native` | no | `zipapp` | What to build: `zipapp` is a zipapp with its dependencies bundled, which needs a compatible Python; `script` is a .py script that declares its dependencies inline, which needs uv or an environment with them; `native` is a compiled executable that brings its own Python and needs nothing installed (built with cargo and uv). | | `-o`, `--output-dir` | directory | no | | Output dir | | `-p`, `--python` | text | no | | Interpreter for the #! line of a zipapp or a script, added after the #!. [default: /usr/bin/env python3 for a zipapp, /usr/bin/env -S uv run --script for a script] | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Builds one file to hand to someone else. `--target` chooses what kind: | Target | What you get | Needs on the other machine | |---|---|---| | `zipapp` | `hello`, a [zipapp](https://docs.python.org/3/library/zipapp.html) built with [shiv](https://shiv.readthedocs.io/) that contains the dependencies | A compatible Python | | `script` | `hello.py`, a script with its dependencies declared inline | [uv](https://docs.astral.sh/uv/), or Python with the dependencies installed | | `native` | `hello`, a compiled executable with its own Python | Nothing | Every target ships the generated CLI, the framework it imports (Typer, or rich-click with `use_click`) and every package listed under `requires`. Without `--output-dir` the file is written to the current directory. ```console $ cli build hello.yaml -o dist + hello built 📦 dist/hello (single file, needs Python >=3.10, 1.8 MB) run it with: ./dist/hello $ ./dist/hello greet Ada Hello, Ada! $ cli build hello.yaml -o dist --target script + hello built 📦 dist/hello.py (single file, needs uv, or Python >=3.11 with typer==0.27.3 installed, 1.3 kB) run it with: ./dist/hello.py (or: uv run dist/hello.py) uv downloads the dependencies the first time the script runs ``` The `native` target needs `cargo` and `uv` on the machine that builds. When a build fails, the reason is printed and the exit code is `1`. With `--blueprint`, or a `blueprint:` key in the file, the blueprint is applied at build time and is not needed to run the result. See [Building executables](../guides/building.md). ## `cli dev` Start hot-reloader for a manifest for active development. Examples: - cli dev examples/hello.yaml - cli dev examples/hello.yaml --run-cli -- -h - cli dev examples/hello.yaml --run-cli python ```text cli dev [OPTIONS] MANIFEST [RUN_CLI_ARGS]... ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `MANIFEST` | file | yes | | Manifest file to watch. | | `[RUN_CLI_ARGS]...` | text | no | | Arguments for the generated CLI when `--run-cli` is set. | | `--run-cli` | flag | no | | If passed, runs CLI on each reload. Useful for syntax checks or testing command execution on each reload | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Nothing is loaded when the command starts. The CLI is loaded the first time the file changes and again on every later change. The manifests it includes and the Python file of its blueprint are watched too. Stop with Ctrl+C. With `--run-cli`, the remaining arguments are passed to the freshly generated CLI after each reload. Put `--` before them when they include options. See [Hot-reload dev loop](../guides/dev-loop.md). ## `cli docs` Generate documentation for a CLI ```text cli docs [OPTIONS] CLI_OR_MANIFEST ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `CLI_OR_MANIFEST` | file | yes | | Manifest file (a path ending in `yaml`) or the name of a loaded CLI. | | `-f`, `--format` | `md` \| `rst` \| `html` | no | `md` | Output format. | | `-o`, `--output-dir` | directory | no | | Output directory | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Writes `.md`, `.rst` or `.html` to the output directory, or to the current directory when `--output-dir` is not given. The output directory must exist. A loaded CLI is documented from the manifest path recorded by `cli load`. See [Generating docs](../guides/generating-docs.md). ## `cli info` Display CLI info ```text cli info [OPTIONS] CLI_NAME ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `CLI_NAME` | text | yes | | Name of a loaded CLI. | Prints the name, version, requirements, blueprint (when one was used), age and stored manifest of a loaded CLI. ## `cli init` Generate a CLI manifest template ```text cli init [OPTIONS] [CLI_NAME] ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `[CLI_NAME]` | text | no | `mycli` | Name of the CLI. The manifest is written to `.yaml`. | | `--render` | flag | no | | Print template to terminal directly | | `--raw` | flag | no | | Raw template without boilerplate helpers and examples. | | `--json-schema` | flag | no | | Write JSON schema (cliffy_schema.json) file to the current directory. Useful for development. | ```console $ cli init mycli --raw + mycli.yaml ``` `--raw` writes a skeleton with every top-level key, one `hello` command and one test. Without `--raw` the manifest is a commented tour of the format with example commands. Both run and pass `cli test` as written. `--json-schema` also writes `cliffy_schema.json` and adds a `# yaml-language-server` comment that points at it. See [Editor setup](../guides/editor-setup.md). ## `cli list` List all CLIs loaded ```text cli list [OPTIONS] ``` Alias: `cli ls` ```console $ cli list ┏━━━━━━━┳━━━━━━━━━┳━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Name ┃ Version ┃ Age ┃ Manifest ┃ ┡━━━━━━━╇━━━━━━━━━╇━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ hello │ 0.1.0 │ 5s │ /home/ada/hello.yaml │ └───────┴─────────┴─────┴─────────────────────────┘ ``` ## `cli load` Load CLI for given manifest(s) ```text cli load [OPTIONS] [MANIFESTS]... ``` Alias: `cli add` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `[MANIFESTS]...` | file | no | | One or more manifest files to load. | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Loading does three things: 1. generates the CLI code and writes it to `cliffy/clis/.py` inside the installed cliffy package, 2. writes a launcher script called `` into the `bin` directory of the Python environment cliffy is installed in, 3. records the manifest path and contents so that `cli list`, `cli info` and `cli update` can find it. The CLI is therefore available wherever that environment is active. Packages listed under `requires` must already be installed, otherwise loading stops with `missing requirement`. A file written in a [blueprint](../blueprints.md)'s spec is loaded the same way. The blueprint comes from `--blueprint` or from the file's `blueprint:` key and is remembered, so `cli update` applies it again. ```console $ cli load hello.yaml ✨ Generated hello CLI v0.1.0 ✨ $ hello -h ``` See [Hot-reload dev loop](../guides/dev-loop.md). ## `cli remove` Remove a loaded CLI by name ```text cli remove [OPTIONS] [CLI_NAMES]... ``` Alias: `cli rm` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `[CLI_NAMES]...` | text | no | | Names of loaded CLIs to remove. | Deletes the launcher script, the generated module and the stored metadata. ```console $ cli remove hello ~ hello removed 💥 ``` ## `cli remove-all` Remove all loaded CLIs ```text cli remove-all [OPTIONS] ``` Aliases: `cli rm-all`, `cli rmall` ## `cli render` Render the CLI manifest generation as code ```text cli render [OPTIONS] MANIFEST ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `MANIFEST` | file | yes | | Manifest file to render. | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Prints the Python module cliffy generates, without loading or running it. Use it to see exactly how params, shell lines and templates are translated. ```console $ cli render hello.yaml ``` ## `cli run` Run CLI for a manifest ```text cli run [OPTIONS] MANIFEST [CLI_ARGS]... ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `MANIFEST` | file | yes | | Manifest file to run. | | `[CLI_ARGS]...` | text | no | | Arguments passed to the generated CLI. Put `--` first when they include options. | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Generates the CLI in a temporary file and runs it once, without loading it. Options meant for your CLI must come after `--`, otherwise `cli run` tries to parse them itself: ```console $ cli run hello.yaml greet Ada Hello, Ada! $ cli run hello.yaml -- greet Ada --shout HELLO, ADA! $ cli run hello.yaml -- --help ``` Help output shows the temporary file name instead of the CLI name. ## `cli test` Run tests defined in a manifest ```text cli test [OPTIONS] MANIFEST ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `MANIFEST` | file | yes | | Manifest file whose `tests` section should run. | | `-x`, `--exitfirst` | flag | no | | exit instantly on first error or failed test. | | `--traceback` | flag | no | | Also print the full Python traceback of a command that raises. | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Runs the `tests` section of a manifest and prints each command, its output and the result. The exit code is `0` only when every test passes. It is `1` when a test fails or when the manifest has no tests. See [Testing manifests](../guides/testing.md). ## `cli update` Reloads CLI by name ```text cli update [OPTIONS] [CLI_NAMES]... ``` Alias: `cli reload` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `[CLI_NAMES]...` | text | no | | Names of loaded CLIs to regenerate from their manifests. | `update` re-reads the manifest from the path recorded by `cli load`, so it only works while the manifest is still at that location. ```console $ cli update hello ✨ Reloaded hello CLI v0.1.0 ✨ $ hello -h ``` ## `cli validate` Validate a CLI manifest: its structure, its requirements and the code generated from it ```text cli validate [OPTIONS] MANIFEST ``` | Argument or option | Type | Required | Default | Description | |---|---|---|---|---| | `MANIFEST` | file | yes | | Manifest file to validate. | | `-b`, `--blueprint` | text | no | | Blueprint that maps a custom YAML spec onto a CLI: a .py file, module:Class or an installed blueprint name. Overrides the `blueprint:` key of the file. | Checks that the file is valid YAML, matches the manifest schema, that its `requires` are installed and that the generated code works. Scripts are checked while the code is generated, as they are by every other command. `validate` then compiles and imports the generated code, which defines the commands without running any of them. The code in `imports` and `functions` and the expressions in `vars` do run. ```console $ cli validate hello.yaml Manifest hello.yaml is valid ``` An error in a script is reported with its command, line and column. A problem that only shows when the code is imported, such as a type that does not exist, is reported against the command it comes from: ```console $ cli validate broken.yaml ~ error in broken.yaml: command `db.create`, run, line 2, column 7: not valid Python: '(' was never closed print(( ^ 💔 $ cli validate other.yaml Manifest other.yaml is invalid: the generated code does not work command `db.create`: NameError: name 'Table' is not defined def db_create(table: Table = typer.Argument(...)): 💔 ``` # Manifest reference Every key a manifest accepts. The tables are generated from the pydantic models in `cliffy/manifest.py`, the same models that produce the [JSON schema](https://github.com/jaykv/cliffy/blob/main/examples/cliffy_schema.json) used for [editor completion](../guides/editor-setup.md). The notes under each table are written by hand. ## Top-level keys The top level of a manifest file. | Key | Type | Default | Description | |---|---|---|---| | `manifestVersion` | string | `"v3"` | Manifest schema version. `v3` is the only version that is read; any other value is an error. | | `name` | string | **required** | The name of the CLI, used when invoking from command line. | | `version` | string | **required** | CLI version | | `help` | string | `""` | Brief description of the CLI | | `use_click` | boolean | `false` | Generate CLI to Click instead of Typer | | `requires` | list of strings | `[]` | Python packages the CLI needs, as requirement specifiers such as `rich` or `tabulate>=0.9.0`. Checked when the manifest is loaded and bundled by `cli build`. | | `includes` | list of strings | `[]` | Paths of other manifest files to merge into this one, relative to this file. Keys set in this manifest win over included ones and lists are combined. When two included files set the same key, the first one listed wins. | | `vars` | mapping of name to (string or `{expression}`) | `{}` | Variables for the manifest. Each one can be used anywhere in the file as `{{ name }}` and is also defined as a module-level Python variable in the generated CLI. A value is a string, a number, a boolean, or a Python expression written as `{expression}`. String values are Jinja2 templates: they can use other vars, and `env` gives access to environment variables. | | `imports` | string or list of strings | `[]` | String block or list of strings containing any module imports. These can be used to import any python modules that the CLI depends on. | | `functions` | string or list of strings | `[]` | String block or list of helper function definitions. These functions should be defined as strings that can be executed by the Python interpreter. | | `types` | mapping of name to string | `{}` | A mapping containing any shared type definitions. These types can be referenced by name in the params section to provide type annotations for args and options defined in the params section. A value is a plain type such as `int`, optionally with a default (`int = 5`), or a complete definition: `str = typer.Option(...)` for Typer, a decorator such as `@click.option(...)` for Click. | | `global_params` | list of [params](#params) | `[]` | Parameters applied to all commands | | `command_templates` | mapping of name to [CommandTemplate](#commandtemplate) | `{}` | Reusable command templates | | `commands` | mapping of name to ([Command](#command), string or list of strings) or list of [Command](#command) | `{}` | The commands of the CLI, keyed by name. In a key, `.` separates groups, `\|` separates aliases and a leading `(*)` adds the command to every group. | | `callbacks` | list of [Callback](#callback) | `[]` | Code that runs before any command of the CLI, or of one command group. Callbacks for the same target run in the order given and their params are combined. The built-in --version and --aliases options are kept. | | `cli_options` | mapping | `{}` | Additional CLI configuration options | | `tests` | list of (string or mapping of name to string) | `[]` | Test cases for commands | | `examples` | list of (string or [Example](#example)) | `[]` | Example command lines of the CLI, with an optional description each. Shown at the end of the `--help` of the generated CLI and in the pages written by `cli docs`. | - `name` and `version` are the only required keys. `version` must be a string, so quote values such as `"1.0"`. `commands` can be left out for a CLI that only has `callbacks`. - Unknown top-level keys are ignored. - `manifestVersion` can be left out. A value other than `v3` stops the load with an error that says what to change. This also applies to include files and to files written in a blueprint's spec. - `vars` keys must be valid Python identifiers. A value is a string, a number, a boolean, or a one-key mapping such as `{os.getcwd()}`, which is emitted as a Python expression. See [Vars and Jinja](../guides/vars-and-jinja.md). - `cli_options` is passed as keyword arguments to `typer.Typer(...)`, or to `click.group(...)` when `use_click` is set. Useful Typer values are `add_completion: false` and `no_args_is_help: true`. - `commands` is usually a mapping. A list of [Command](#command) mappings that each set `name` is also accepted. - Each `tests` entry is either a string starting with `$` or `>` (a shell step) or a one-key mapping of command line to assertion code. See [Testing manifests](../guides/testing.md). ## Command Defines a single command within the CLI. It specifies the command's execution logic, parameters, and configuration. | Key | Type | Default | Description | |---|---|---|---| | `run` | string or list of strings | `""` | The script to run. A string, or a list of strings that are joined with newlines. Lines starting with `$` or `>` are shell commands. | | `help` | string | `""` | A description of the command, displayed in the help output. | | `params` | list of [params](#params) | `[]` | The parameters of the command. Each entry takes one of three forms: a shorthand mapping of the param name to its type, such as `name: str!` or `--count: int = 1`; an explicit mapping with the keys `name` and `type` and optionally `default`, `help`, `short` and `required`; or a string that is copied into the generated code as written (a signature entry for Typer, a decorator for Click). | | `template` | string | `""` | A reference to a command template defined in the `command_templates` section of the manifest. This allows for reusable command definitions. | | `pre_run` | string | `""` | Script to run before the command's run block. This can be used for setup tasks or preconditions. | | `post_run` | string | `""` | Script to run after the command's run block. This can be used for cleanup tasks or post-processing. | | `aliases` | list of strings | `[]` | A list of aliases for the command. These aliases can be used to invoke the command with a different name. | | `name` | string | `""` | The name of the command. This is generally derived from the key in the `commands` section of the manifest, but can be explicitly set here. | | `config` | [CommandConfig](#commandconfig) or null | `null` | An optional `CommandConfig` object that provides additional configuration options for the command, such as context settings, help text customization, and visibility. | A command value can also be just a string or a list of strings, which is shorthand for a mapping with only `run`. The key of the command carries meaning: `.` separates groups (`db.create`), `|` separates aliases (`delete|rm`) and a leading `(*)` adds the command to every group. See [Commands, groups and aliases](../guides/commands.md). A command that has `help` and no `run`, and whose key is the name of a group, sets the help text of that group. ## Params Every entry of a `params` list (on a command, a template or `global_params`) takes one of three forms. ### Shorthand A one-key mapping: the key is the name, the value is the type. | Part | Meaning | Example | |---|---|---| | `name` | Positional argument | `source: str` | | `--name` | Option | `--count: int` | | `--name|-n` | Option with a short alias | `--count|-c: int` | | `type!` | Required | `source: str!` | | `type = value` | Default value, written as a Python expression | `target: str = "out.txt"` | | a name from `types` | Reuse a type or a full definition | `level: Level` | Hyphens in a name become underscores in the Python variable (`--dry-run` is `dry_run`). ### Explicit A mapping with the keys of [CommandParam](#commandparam), which adds `help` text. ### Generic A plain string that is copied into the generated function signature as written, for example `'lines: int = typer.Option(10, "--lines", "-n", min=1)'`. With the Click backend the string is a decorator such as `"@click.option('--loud', is_flag=True)"`. See [Params and types](../guides/params.md) for worked examples of all three. ## CommandParam Defines the structure of a command parameter. It is used within the `params` field of a `Command` object. By default, parameters are treated as positional arguments. To specify an option, prefix the name with `--` to indicate flag. | Key | Type | Default | Description | |---|---|---|---| | `name` | string | **required** | Parameter name. Prefix with `--` to indicate an option. | | `type` | string | **required** | Parameter type (e.g., 'str', 'int', 'bool', or a custom type defined in the manifest's 'types' section). | | `default` | any | `null` | Default parameter value. | | `help` | string | `""` | Parameter description. | | `short` | string | `""` | Short option alias. i.e. '-v' for verbose. | | `required` | boolean | `false` | Whether the parameter is required. | This is the explicit form. A `name` without `--` is a positional argument, with both backends. `short` is written with its dash (`-w`). A `str` default is quoted for you; other defaults are emitted as written. ## CommandTemplate Defines a reusable template for command definitions. Templates allow you to define common parameters, pre-run/post-run scripts, and configuration options that can be applied to multiple commands. | Key | Type | Default | Description | |---|---|---|---| | `params` | list of [params](#params) | `[]` | A list of parameters for the command template. These parameters will be applied to any command that uses this template. | | `pre_run` | string | `""` | Script to run before the command's run and pre-run block. This script will be applied to any command that uses this template. | | `post_run` | string | `""` | Script to run after the command's run and post-run block. This script will be applied to any command that uses this template. | | `config` | [CommandConfig](#commandconfig) or null | `null` | Additional configuration options for commands using this template. This allows customization of help text, context settings, and other Typer command parameters. | Template `params` are placed before the command's own params. Scripts run in this order: template `pre_run`, command `pre_run`, command `run`, command `post_run`, template `post_run`. `config` values from the command override the template's. See [Command templates and global params](../guides/templates-and-global-params.md). ## CommandConfig Configuration options for a Cliffy command. | Key | Type | Default | Description | |---|---|---|---| | `context_settings` | mapping | `{}` | Settings passed to the Click context of the command, for example `help_option_names` or `max_content_width`. | | `epilog` | string | `""` | Text displayed after the help message. | | `short_help` | string | `""` | Short one-line help message displayed in help overviews. | | `options_metavar` | string | `"[OPTIONS]"` | Placeholder text displayed for options in help messages. | | `add_help_option` | boolean | `true` | Whether to add the `--help` option automatically. | | `no_args_is_help` | boolean | `false` | If True, invoking the command without any arguments displays the help message. | | `hidden` | boolean | `false` | If True, the command is hidden from help messages and command lists. | | `deprecated` | boolean | `false` | If True, the command is marked as deprecated in help messages. | | `rich_help_panel` | string | `""` | Name of the panel this command is listed under in rich help output. | Only the keys you set are passed on, as keyword arguments of the `command()` decorator of Typer or Click. With the Click backend `rich_help_panel` needs rich-click. ## Callback Code that runs before the commands of the CLI, or of one command group. A callback can declare its own params, which become options of the CLI (or group) itself, i.e. `mycli --verbose command`. The Typer/Click context is available in the run block as `ctx`. | Key | Type | Default | Description | |---|---|---|---| | `run` | string | **required** | Code to run before the invoked command. `ctx` holds the context. | | `group` | string | `""` | Command group the callback belongs to, i.e. `db` or `db.tables`. Leave empty for the CLI itself. | | `params` | list of [params](#params) | `[]` | Params of the CLI (or group) itself, given before the command name. Same syntax as command params. | | `invoke_without_command` | boolean | `false` | Also run the callback when the CLI (or group) is invoked without a command, instead of showing the help. | Callbacks for the same target run in the order given. Their params are options of the CLI or group itself, so they go before the command name: `mycli --verbose db migrate`. A `group` must be the name of an existing command group. See [Callbacks](../blueprints.md#callbacks). ## Example | Key | Type | Default | Description | |---|---|---|---| | `command` | string | **required** | The command line to show. | | `description` | string or null | `null` | A sentence describing what the command does. | `examples` entries are shown at the end of the `--help` of the generated CLI, as `$ # `, and in the pages written by [`cli docs`](../guides/generating-docs.md). An entry can also be a plain string, which is shown without a description. ## Include files Special model specifically to define the allowed named objects that can be merged with other manifests. | Key | Type | Default | Description | |---|---|---|---| | `requires` | list of strings | `[]` | Merged into the `requires` of the including manifest. | | `commands` | mapping of name to ([Command](#command), string or list of strings) | `{}` | Merged into the `commands` of the including manifest. | | `vars` | mapping of name to (string or `{expression}`) | `{}` | Merged into `vars`. Available as Python variables, not as `{{ }}` placeholders. | | `imports` | string or list of strings | `[]` | Merged into `imports`. | | `functions` | list of strings | `[]` | Merged into `functions`. Must be a list here. | | `types` | mapping of name to string | `{}` | Merged into `types`. | | `cli_options` | mapping of name to string | `{}` | Merged into `cli_options`. | | `tests` | list of (string or mapping of name to string) | `[]` | Appended to `tests`. | Files listed under `includes` are validated against this smaller model. A relative path is looked up next to the including manifest first, then in the directory you run `cli` from. See [Includes and merging](../guides/includes.md). # Known issues Behavior in cliffy 0.7.1 that differs from what you might expect, with the way around each. Every item was reproduced against this release. The guides already recommend the forms that work; this page explains why. ## Commands ### Some problems are only found by `cli validate` and `cli test` Every command checks the scripts of a manifest and reports a syntax error with its command, line and column. A problem that only shows when the generated code is imported is not found by `cli load`, `cli render` or `cli build`: a type name that does not exist, or params that do not fit together in one signature. The CLI is then loaded or built and fails when it is started. **Do this instead:** run `cli validate` first. It imports the generated code and reports such a problem against the command it comes from. ### `cli test` does not check shell steps A `$` or `>` step that exits with an error does not fail the run. A step whose program does not exist ends the run with an error message. See [Testing manifests](../guides/testing.md#things-to-know). ### Examples are shown for the CLI, not per command `examples` belongs to the manifest. The generated CLI shows all of them at the end of its own `--help`; the help of a single command has no examples section, because a command has no `examples` key. With the Typer backend an example that contains an emoji code such as `:rocket:` is shown as the emoji, like the rest of the help text. ### `cli remove` refuses when the launcher was changed The launcher of a loaded CLI is only deleted while it is exactly the file cliffy wrote. After an edit, or when another program replaced it, `cli remove` and `cli remove-all` remove nothing for that CLI and exit with 1. **Do this instead:** move or delete the file named in the message, then run `cli remove` again. ### A file can still appear between cliffy's check and its write cliffy checks a launcher right before it replaces or deletes it, never follows a symbolic link and creates a new launcher only when nothing has the name. Replacing or deleting an existing launcher is a check followed by a rename or an unlink: a file that another process puts at exactly that path in between is replaced or removed. This needs write access to the environment's `bin` directory at that moment. ## Manifests ### Includes fall back to the working directory An include path is looked up next to the including manifest first. When it is not found there, it is looked up in the directory `cli` is run from, so the same manifest can pick up a different file depending on where you are. **Do this instead:** keep include files next to (or below) the manifest that lists them. ### A full Typer definition in `types` replaces the param's own settings A `types` entry with `typer.Option(...)` or `typer.Argument(...)` is the complete definition. A default, `help` or `short` written on a param that uses it is ignored, and `cli validate` warns about it. Plain types such as `Count: int` do not have this effect. See [Params and types](../guides/params.md#types). ## Click backend `use_click: true` supports every manifest feature. Generic params and full definitions in `types` are written as Click decorators instead of Typer expressions. The details are in [Click and Typer backends](../guides/backends.md#what-differs-from-typer).