diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
index af3d116..47c4068 100644
--- a/.github/workflows/docs.yaml
+++ b/.github/workflows/docs.yaml
@@ -6,6 +6,8 @@ on:
- main
paths:
- 'docs/**'
+ # the examples gallery embeds these files at build time
+ - 'examples/*.yaml'
- 'mkdocs.yaml'
- '.github/workflows/docs.yaml'
workflow_dispatch:
diff --git a/Makefile b/Makefile
index 740ec42..4c56b0d 100644
--- a/Makefile
+++ b/Makefile
@@ -39,6 +39,17 @@ lint:
docs-serve:
uv run --group docs mkdocs serve
+# docs/reference/*.md are generated from the click commands, the manifest models and examples/.
+docs-generate:
+ uv run python scripts/docs.py generate
+
+# Fails when the generated pages are stale or a manifest in docs/snippets does not pass its tests.
+docs-check:
+ uv run python scripts/docs.py check
+
+docs-build: docs-check
+ uv run --group docs mkdocs build --strict
+
# The example manifests declare requirements that are not cliffy dependencies. They have to be
# installed into the project environment (not a `uv run --with` overlay), because cliffy validates
# `requires` against `pip freeze`. `uv sync` in generate-cleanup removes them again.
@@ -57,5 +68,6 @@ generate-cleanup:
generate-schema:
uv run python -m cliffy.manifest --json-schema > examples/cliffy_schema.json
-.PHONY: check install sync test test-cov clean package publish format lint docs-serve \
+.PHONY: check install sync test test-cov clean package publish format lint \
+ docs-serve docs-generate docs-check docs-build \
generate-clis generate-all generate-cleanup generate-schema
diff --git a/README.md b/README.md
index e4f3120..f64ddf1 100644
--- a/README.md
+++ b/README.md
@@ -1,280 +1,121 @@
-
+
-[](https://github.com/jaykv/cliffy/actions)
+[](https://github.com/jaykv/cliffy/actions)
[](https://pypi.org/project/cliffy/)
[](https://pypi.org/project/cliffy/)
-
+
[](https://codecov.io/github/jaykv/cliffy)
-[](https://jaykv.github.io/cliffy)
+[](https://jaykv.github.io/cliffy/)
-Build feature-rich CLIs _quickly_.
+# cliffy
-## Features
-* โก Generate Python CLIs with a simple YAML manifest
-* ๐ฆ Package your CLI into a single executable that runs anywhere
-* ๐ Hot-reload changes instantly during development with built-in testing
-* ๐จ Mix Python and shell commands naturally with built-in scripting support
-* ๐ค AI-friendly with [manifest generation support](https://jaykv.github.io/cliffy/features#ai-features)
+Describe a command-line tool in YAML and get a real Python CLI.
-### Load
+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.
-1. Define a manifest
-```yaml
-# hello.yaml
-name: hello
-version: 0.1.0
-
-commands:
- shell:
- help: Print hello in shell
- run: $echo "hello from shell"
- python: print("hello from python")
-```
+**Documentation: **
-2. Load CLI
-```
-$ cli load hello.yaml
-```
-Parses `hello.yaml` to generate a Typer CLI and load it into the running Python environment.
+## Example
-3. Run CLI directly
+Install cliffy (Python 3.11 to 3.15):
-`hello -h`
-
-
-
-### Build
+```console
+$ pip install cliffy
+```
-Simple todo CLI with sqlite3 + tabulate.
+Write a manifest:
-1. Define the manifest
```yaml
-# todo.yaml
-name: todo
-version: 1.0.0
-requires:
- - tabulate # For pretty table output
- - rich # For colored terminal output
-imports: |
- import sqlite3
- from pathlib import Path
- from tabulate import tabulate
- from rich import print
+# hello.yaml
+name: hello
+version: 0.1.0
+help: Hello world!
commands:
- create:
- help: Create a new database with tasks table
+ shell: $ echo "hello from shell"
+ python: print("hello from python")
+ greet:
+ help: Greet someone by name
params:
- - name: str = typer.Option(..., prompt=True, confirmation_prompt=True)
- run: |
- db_path = Path(f"{name}.db")
- conn = sqlite3.connect(db_path)
- conn.execute("CREATE TABLE tasks (id INTEGER PRIMARY KEY, task TEXT NOT NULL, done BOOLEAN NOT NULL)")
-
- # insert example tasks
- conn.execute("INSERT INTO tasks (task, done) VALUES ('Fight for your right!', 0)")
- conn.execute("INSERT INTO tasks (task, done) VALUES ('To party!', 1)")
- conn.commit()
- conn.close()
- print(f"โจ Created database {db_path} with tasks table")
-
- tasks:
- help: List tasks in database
- params: [name: str!]
+ - name: str!
+ - --shout|-s: bool = False
run: |
- conn = sqlite3.connect(f"{name}.db")
- cursor = conn.execute("SELECT * FROM tasks")
- tasks = cursor.fetchall()
- conn.close()
- print(tabulate(tasks, headers=['ID', 'Task', 'Done'], tablefmt='grid'))
+ message = f"Hello, {name}!"
+ print(message.upper() if shout else message)
+```
- add:
- help: Add a new task
- params: [name: str!, task: str!]
- run: |
- conn = sqlite3.connect(f"{name}.db")
- conn.execute("INSERT INTO tasks (task, done) VALUES (?, 0)", (task,))
- conn.commit()
- conn.close()
- print(f"๐ Added task: {task}")
+Load it and use it:
- complete:
- help: Mark a task as complete
- params: [name: str!, id: int!]
- run: |
- conn = sqlite3.connect(f"{name}.db")
- conn.execute("UPDATE tasks SET done = 1 WHERE id = ?", (id,))
- conn.commit()
- conn.close()
- print(f"๐ Marked task {id} as complete")
+```console
+$ cli load hello.yaml
+โจ Generated hello CLI v0.1.0 โจ
+$ hello shell
+hello from shell
+$ hello greet Ada --shout
+HELLO, ADA!
```
-
+## What it does
-For more examples, check [examples](examples/) directory.
+- **Python and shell in one place.** Run scripts are Python; `$` lines become `subprocess` calls.
+- **Typed parameters with a short syntax.** `name: str!` is a required argument, `--count|-c: int = 1`
+ is an option.
+- **Groups, aliases and templates.** `db.create` nests a command, `delete|rm` adds an alias, command
+ templates share params between commands.
+- **Hot reload.** `cli dev hello.yaml` reloads the CLI every time you save the manifest.
+- **Built-in tests.** Keep assertions next to your commands and run them with `cli test`.
+- **Single-file executables.** `cli build hello.yaml` packs the CLI and its dependencies into one
+ zipapp.
-## Usage
-`cli `
+## Commands
| Command | Description |
|---|---|
-| `init `| Generate a template CLI manifest for a new CLI |
-| `load ` | Add a new CLI based on the manifest |
-| `render ` | View generated CLI script for a manifest |
-| `list, ls` | Output a list of loaded CLIs |
-| `update `| Reload a loaded CLI |
-| `remove , rm ` | Remove a loaded CLI |
-| `run -- \`| Runs a CLI manifest command in isolation|
-| `build ` | Build a CLI manifest or a loaded CLI into a self-contained zipapp |
-| `info ` | Display CLI metadata |
-| `dev ` | Start hot-reloader for a manifest for active development |
-| `test ` | Run tests defined in a manifest |
-| `validate ` | Validate the syntax and structure of a CLI manifest |
-| `docs ` | Generate documentation for a CLI |
-| `ai generate ` | Generate a CLI manifest based on a description. |
-| `ai ask ` | Ask a question about cliffy or a specific CLI manifest. |
-
-## How it works
-1. Define CLI manifests in YAML files
-2. Run `cli` commands to load, build, and manage CLIs
-3. When loaded, cliffy parses the manifest and generates a [Typer](https://github.com/tiangolo/typer) CLI that is deployed directly as a script
-4. Any code starting with `$` will translate to subprocess calls via [PyBash](https://github.com/jaykv/pybash)
-5. Run loaded CLIs straight from the terminal
-6. When ready to share, run `build` to generate portable zipapps built with [Shiv](https://github.com/linkedin/shiv)
-
-## Get started
-
-Cliffy can be installed using either pip or uv package managers.
-
-### With pip
-* `pip install "cliffy[rich]"` to include [rich-click](https://github.com/ewels/rich-click) for colorful CLI help output formatted with [rich](https://github.com/Textualize/rich).
-
-or
-
-* `pip install cliffy` to use the default help output.
-* `cli init mycli`
-
-### With uv
-* Init: `uvx cliffy init mycli`
-* Load: `uvx cliffy load mycli.yaml`
-* Run: `uvx --from cliffy mycli -h`
-
-## Manifest template
-Generated by `cli init`. For a barebones template, run `cli init --raw`
-
-```yaml
-manifestVersion: v3
-
-# The name of the CLI, used when invoking from command line.
-name: cliffy
-
-# CLI version
-version: 0.1.0
-
-# Brief description of the CLI
-help: A brief description of your CLI
-
-# List of Python package dependencies for the CLI.Supports requirements specifier syntax.
-requires: []
- # - requests>=2.25.1
- # - pyyaml~=5.4
+| `cli init ` | Write a manifest template |
+| `cli run -- ` | Run a manifest's CLI once, without installing it |
+| `cli load ` | Install the CLI as a command in the current environment |
+| `cli dev ` | Reload the CLI whenever the manifest changes |
+| `cli test ` | Run the tests defined in the manifest |
+| `cli build ` | Build a single-file executable |
+| `cli render ` | Print the generated Python code |
+| `cli validate ` | Check a manifest against the schema |
+| `cli docs ` | Generate documentation for the CLI |
+| `cli list`, `info`, `update`, `remove` | Manage loaded CLIs |
+| `cli ai generate`, `cli ai ask` | Draft manifests with an LLM (needs the `ai` extra) |
+
+Every command and option is described in the
+[CLI reference](https://jaykv.github.io/cliffy/reference/cli/).
+
+## Install options
+
+```console
+$ pip install cliffy # core
+$ pip install "cliffy[rich]" # rich-click help for the cli command, needed for the Click backend
+$ pip install "cliffy[ai]" # AI commands, built on pydantic-ai
+$ uvx cliffy run hello.yaml greet Ada # run without installing
+```
-# List of external CLI manifests to include.Performs a deep merge of manifests sequentially in the order given to assemble a merged manifest
-# and finally, deep merges the merged manifest with this manifest.
-includes: []
- # - path/to/other/manifest.yaml
+See [Installation](https://jaykv.github.io/cliffy/installation/) for details.
-# Mapping defining manifest variables that can be referenced in any other blocks
-# Environments variables can be used in this section with ${some_env_var} for dynamic parsing
-# Supports jinja2 formatted expressions as values
-# Interpolate defined vars in other blocks jinja2-styled {{ var_name }}.
-vars:
- data_file: "data.json"
- debug_mode: "{{ env['DEBUG'] or 'False' }}"
+## Learn more
-# String block or list of strings containing any module imports
-# These can be used to import any python modules that the CLI depends on.
-imports: |
- import json
- import os
- from pathlib import Path
+- [Quickstart](https://jaykv.github.io/cliffy/quickstart/): build a complete CLI step by step
+- [Guides](https://jaykv.github.io/cliffy/guides/manifest-basics/): params, groups, templates,
+ includes, vars, shell commands, testing, building
+- [Manifest reference](https://jaykv.github.io/cliffy/reference/manifest/): every key of the format
+- [Examples](https://jaykv.github.io/cliffy/reference/examples/): the manifests in
+ [`examples/`](examples/)
+- [Changelog](https://jaykv.github.io/cliffy/project/changelog/) and
+ [migration notes](https://jaykv.github.io/cliffy/project/migration/)
+- [Contributing](https://jaykv.github.io/cliffy/project/contributing/)
-# List of helper function definitions
-# These functions should be defined as strings that can be executed by the Python interpreter.
-functions:
- - |
- def load_data() -> dict:
- data_path = Path("{{ data_file }}")
- if data_path.exists():
- with data_path.open() as f:
- return json.load(f)
- return {}
- - |
- def save_data(data):
- with open("{{data_file}}", "w") as f:
- json.dump(data, f, indent=2)
-# A mapping containing any shared type definitions
-# These types can be referenced by name in the args section to provide type annotations for params and options defined in the args section.
-types:
- Filename: str = typer.Argument(..., help="Name of the file to process")
- Verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output")
+## Similar tools
-# Arguments applied to all commands
-global_params:
- - verbose: Verbose
+- [Bashly](https://github.com/DannyBen/bashly) generates Bash CLIs from YAML.
+- [Fire](https://github.com/google/python-fire) generates CLIs from Python objects.
-# Reusable command templates
-command_templates:
- with_confirmation:
- params:
- - "yes": bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt")
- pre_run: |
- if not yes:
- typer.confirm("Are you sure you want to proceed?", abort=True)
+## License
-# A mapping containing the command definitions for the CLI
-# Each command should have a unique key- which can be either a group command or nested subcommands
-# Nested subcommands are joined by '.' in between each level
-# Aliases for commands can be separated in the key by '|'
-# A special '(*)' wildcard can be used to spread the subcommand to all group-level commands
-commands:
- hello:
- help: Greet the user
- params:
- - name: str = typer.Option("World", "--name", "-n", help="Name to greet")
- run: |
- print(f"Hello, {name}!")
- $ echo "i can also mix-and-match this command script to run shell commands"
-
- file.process:
- help: Process a file
- params:
- - filename: Filename
- run: |
- data = load_data()
- print(f"Processing {filename}")
- if verbose:
- print("Verbose output enabled")
- data["processed"] = [filename]
- # Process the file here
- save_data(data)
-
- delete|rm:
- help: Delete a file
- template: with_confirmation
- params: [filename: Filename]
- run: |
- if verbose:
- print(f"Deleting {filename}")
- os.remove(filename)
- print("File deleted successfully")
-
-# Additional CLI configuration options
-cli_options:
- rich_help_panel: True
-
-# Test cases for commands
-tests:
- - hello --name Alice: assert 'Hello, Alice!' in result.output
- - file process test.txt: assert 'Processing test.txt' in result.output
-```
+[MIT](LICENSE)
diff --git a/cliffy/cli.py b/cliffy/cli.py
index c3313c6..c22d3be 100644
--- a/cliffy/cli.py
+++ b/cliffy/cli.py
@@ -283,7 +283,7 @@ def dev(manifest: str, run_cli: bool, run_cli_args: tuple[str], blueprint: str |
- cli dev examples/hello.yaml --run-cli -- -h
- - cli dev examples/hello.yaml --run-cli hello
+ - cli dev examples/hello.yaml --run-cli python
"""
out(f"๐ Watching {manifest} for changes...\n", fg="magenta")
if blueprint:
diff --git a/docs/ai.md b/docs/ai.md
new file mode 100644
index 0000000..7ca95fc
--- /dev/null
+++ b/docs/ai.md
@@ -0,0 +1,74 @@
+# 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.
diff --git a/docs/features.md b/docs/features.md
deleted file mode 100644
index 40c510e..0000000
--- a/docs/features.md
+++ /dev/null
@@ -1,287 +0,0 @@
-# Cliffy Features
-
-Cliffy provides a wide range of features to help you build powerful and flexible command-line interfaces. This guide will explore some of the key features with examples.
-
-!!! note
- Refer to [Typer docs](https://typer.tiangolo.com/tutorial/parameter-types/) for docs on crafting custom paramater types.
-
-## Dependencies
-
-You can specify Python package dependencies for the CLI using the `requires` section. For example:
-
-```yaml
-requires:
- - tabulate>=0.9.0
-```
-
-This ensures that the `tabulate` package is installed when the CLI is used. Built CLIs will automatically bundle these specified dependencies with the CLI zipapp, making it super easy to distribute CLIs.
-
-## Imports
-
-The `imports` section allows you to import Python modules. For example:
-
-```yaml
-imports: |
- import json
- from datetime import datetime
- from tabulate import tabulate
-```
-
-These modules can then be used in any of your command or function definitions.
-
-## Variables
-
-You can define variables in the `vars` section and use them throughout your manifest. For example:
-
-```yaml
-vars:
- data_file: "tasks.json"
-```
-
-Then, you can reference this variable using `{{data_file}}` in other parts of the manifest for dynamic injection on CLI load.
-
-## Functions
-
-The `functions` section allows you to define helper functions that can be used in your commands. For example:
-
-```yaml
-functions:
- - |
- def load_data():
- try:
- with open("{{data_file}}", "r") as f:
- return json.load(f)
- except FileNotFoundError:
- return {"projects": [], "tasks": []}
-```
-
-These functions can be used to encapsulate reusable logic.
-
-## Types
-
-You can define custom types in the `types` section and use them in your command arguments and options. For example:
-
-```yaml
-types:
- ProjectName: str = typer.Argument(..., help="Name of the project")
- TaskName: str = typer.Argument(..., help="Name of the task")
- DueDate: str = typer.Option(None, "--due", "-d", help="Due date (YYYY-MM-DD)")
- Priority: int = typer.Option(1, "--priority", "-p", help="Priority (1-5)", min=1, max=5)
-```
-
-This allows you to define type annotations and help messages for your arguments and options.
-
-## Commands
-
-The `commands` section defines the different commands that your CLI supports. For example:
-
-```yaml
-commands:
- project.add:
- params: [name: ProjectName]
- run: |
- data = load_data()
- if name not in data["projects"]:
- data["projects"].append(name)
- save_data(data)
- print(f"Project '{name}' added successfully.")
- else:
- print(f"Project '{name}' already exists.")
-```
-
-This defines a nested `project add` command that takes a `ProjectName` argument.
-
-## Shell Commands
-
-You can execute shell commands using the `$` prefix in the `run` section. For example:
-
-```yaml
-backup:
- help: Create a backup of the task data
- run: |
- $ cp {{data_file}} {{data_file}}.backup
- $ echo "Backup created: {{data_file}}.backup"
-```
-
-This allows you to integrate shell commands into your CLI. Internally, shell commands will get translated into python subprocess calls. You can always verify the generated CLI code using `cli render`.
-
-In special cases, you may want to trigger the unsafe `shell=True` in the subprocess calls. For those times, you can use the `>` prefix instead. [Beware!](https://docs.python.org/3.10/library/subprocess.html#security-considerations)
-
-## Global params
-
-Define `global_params` to add common arguments/options across ALL commands.
-
-```yaml
-global_params:
- - name: "--verbose"
- type: "bool"
- default: false
- help: "Enable verbose output"
-
-commands:
- hello:
- run: |
- if verbose:
- print("Verbose mode enabled")
- print("Hello!")
- goodbye:
- run: |
- if verbose:
- print("Verbose mode enabled")
- print("Goodbye!")
-```
-
-When either command is executed with `--verbose`, the corresponding `if verbose:` block within the run script will be executed.
-
-Note how the verbose variable is available directly within the command's script. No special handling is needed. It's treated like a normal parameter.
-
-## Command Templates
-
-You can define reusable command templates in the `command_templates` section. For example:
-
-```yaml
-command_templates:
- with_confirmation:
- params:
- - "yes": bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt")
- pre_run: |
- if not yes:
- typer.confirm("Are you sure you want to proceed?", abort=True)
-```
-
-Then, you can use this template in your commands:
-
-```yaml
-commands:
- delete|rm:
- help: Delete a file
- template: with_confirmation
- params: [filename: Filename]
- run: |
- if verbose:
- print(f"Deleting {{filename}}")
- os.remove(filename)
- print("File deleted successfully")
-```
-
-This allows you to reuse common argument definitions and pre/post run logic across multiple commands.
-
-!!! note
- Command script execution is performed in the following order:
-
- 1. Template pre-run
- 2. Command pre-run
- 3. Command run
- 4. Command post-run
- 5. Template post-run
-
-## Tests
-
-The `tests` section allows you to define test cases for your commands. For example:
-
-```yaml
-tests:
- - project add test1: assert result.exit_code == 0
- - project list: assert "test1" in result.output
-```
-
-These tests can then be run with `cli test`.
-
-## Hot-reload
-
-Use the `cli dev` command to actively monitor a manifest for changes and automatically reload. Highly recommended for CLI manifest development.
-
-!!! example
- - `cli dev examples/hello.yaml`
- - `cli dev examples/hello.yaml --run-cli hello` (reload on change and run `hello` command)
-
-## IDE Integration
-
-### Schema validation and autocomplete
-
-To get real-time feedback while developing your CLI, install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) and setup by either:
-
-a. Generating manifest with local json-schema: `cli init --json-schema`
-b. Referencing the latest remote json-schema in your manifest:
-
- ```yaml
- # yaml-language-server: $schema=https://raw.githubusercontent.com/jaykv/cliffy/refs/heads/main/examples/cliffy_schema.json
- ```
-
-### Embedded syntax highlighting
-
-Install the [YAML embedded languages extension](https://marketplace.visualstudio.com/items?itemName=harrydowning.yaml-embedded-languages) for proper syntax-highlighting for command run scripts.
-
-
-## AI Features
-
-Cliffy includes AI-powered commands to help you generate and understand CLI manifests. Access these features through the `cli ai` command group.
-
-!!! note
- Requires the `ai` extra: `pip install "cliffy[ai]"`. See [Installation](getting-started.md#installation).
- 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.
-
-### Manifest Generation
-
-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.
-
-### Interactive Help
-
-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.
diff --git a/docs/getting-started.md b/docs/getting-started.md
deleted file mode 100644
index 33c4fb8..0000000
--- a/docs/getting-started.md
+++ /dev/null
@@ -1,110 +0,0 @@
-# Getting Started with Cliffy
-
-This guide will walk you through the basics of setting up and using Cliffy to create your own CLI tools.
-
-## Installation
-
-To get started with Cliffy, you'll need to install it using pip:
-
-```bash
-pip install cliffy # cliffy[ai,rich] for rich CLI and AI features
-cli --help
-cli init mycli
-cli load mycli.yaml
-mycli -h
-```
-
-You can also use uv to run cliffy directly with:
-```bash
-uvx --with "cliffy[rich,ai]" cliffy --help
-uvx cliffy init mycli
-uvx cliffy load mycli.yaml
-uvx --from cliffy mycli -h
-```
-
-!!! tip
- Alternative setup for cliffy + uv with ai and rich groups.
- ```bash
- uv venv
- uv pip install "cliffy[ai,rich]"
- uv run cli -h
- uv run cli init mycli
- uv run cli load mycli.yaml
- uv run mycli -h
- ```
-
-## Creating Your First CLI
-
-Let's create a simple "hello" CLI with `$ cli init hello --raw`.
-
-You should now have a hello.yaml file. Open it and replace the manifest with:
-
-```yaml
-name: hello
-version: 0.1.0
-help: A simple CLI that greets the user.
-
-commands:
- hello:
- help: Greet the user
- params:
- - name: str = typer.Option("World", "--name", "-n", help="Name to greet")
- run: |
- print(f"Hello, {name}!")
-```
-
-This manifest defines a CLI named `hello` with a single command `hello` that takes an optional `--name` argument.
-
-## Running Your CLI
-
-To run your CLI, use the `cli run` command followed by the path to your manifest file:
-
-```bash
-cli run hello.yaml -- hello --name "Your Name"
-```
-
-This will output:
-
-```
-Hello, Your Name!
-```
-
-### Loading CLIs
-
-You can load the CLI using the `cli load` command to avoid needing to prefix `cli run hello.yaml` for each trigger:
-
-```bash
-cli load hello.yaml
-```
-
-This command loads the generated CLI into the current Python environment. You can then run the CLI directly from the terminal by its name:
-
-```bash
-hello -h
-```
-
-### Building CLIs
-
-To build a CLI into a portable zipapp, you can run the `cli build` command:
-
-```bash
-cli build hello.yaml -o dist
-```
-
-This command builds a portable zipapp containing the CLI and its package requirements, outputting it to the `dist` directory. You can then run the built CLI:
-
-```bash
-./dist/hello -h
-```
-
-## Next Steps
-
-This is just a basic example. Cliffy supports many more features, such as:
-
-- Defining types for arguments and options.
-- Using variables and functions in your manifest.
-- Creating complex command structures.
-- Running shell commands.
-- Testing your CLI.
-
-Explore the [Features](features.md) section to learn more about these capabilities.
\ No newline at end of file
diff --git a/docs/guides/backends.md b/docs/guides/backends.md
new file mode 100644
index 0000000..847ed7c
--- /dev/null
+++ b/docs/guides/backends.md
@@ -0,0 +1,66 @@
+# 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` |
+| Extra install | none | `pip install "cliffy[rich]"` |
+| Supported manifest features | all | a subset, 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"
+--8<-- "docs/snippets/click.yaml"
+```
+
+```console
+$ cli run click.yaml -- hello Ada --times 2
+hello Ada
+hello Ada
+$ 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 params: arguments (`name: str`) and options with a default (`--times: int = 1`)
+- Explicit params, which always become options. Write the `name` **without** `--`.
+- Generic params, written as Click decorators
+- `pre_run`, `post_run` and command templates
+- `vars`, `imports`, `functions`, `includes` and shell lines
+
+### What differs from Typer
+
+| Feature | With `use_click: true` |
+|---|---|
+| Boolean options | `--loud: bool = False` is a `--loud/--no-loud` switch, as with Typer. |
+| `types` | Not supported. Type names are passed to Click as written, so only real Python types work. |
+| `config` | Ignored. |
+| `--version` output | `, version ` instead of `, `. |
+
+### Building
+
+The generated module imports `rich_click`. `cli build` reads that 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.
diff --git a/docs/guides/building.md b/docs/guides/building.md
new file mode 100644
index 0000000..e61f37c
--- /dev/null
+++ b/docs/guides/building.md
@@ -0,0 +1,74 @@
+# Building executables
+
+`cli build` turns a manifest into one executable file that you can copy to another machine. The
+file is a Python [zipapp](https://docs.python.org/3/library/zipapp.html) built with
+[shiv](https://shiv.readthedocs.io/): it contains the generated CLI, the framework it imports (Typer,
+or rich-click with `use_click`) and the packages from `requires`. The target machine needs Python, and
+nothing else.
+
+## Build
+
+```console
+$ cli build notes.yaml -o dist
++ notes built ๐ฆ
+ dist/notes (single file, needs Python >=3.10)
+$ ./dist/notes --version
+notes, 0.1.0
+```
+
+The file is named after the CLI. Without `-o`, it is written to the current directory. Several
+manifests can be built in one call:
+
+```console
+$ cli build notes.yaml hello.yaml -o dist
+```
+
+You can also build a CLI that is already loaded, by name: `cli build notes -o dist`.
+
+## Dependencies
+
+Everything under `requires` is downloaded with pip and packed into the file:
+
+```yaml
+requires:
+ - tabulate>=0.9.0
+```
+
+Unlike `cli load`, building from a manifest does not need those packages to be installed in your
+environment. It does need network access, or a pip cache that has them.
+
+The framework is added for you, pinned to the version installed next to cliffy unless `requires` names
+it. Building the same manifest twice in the same environment gives an identical file. When a
+dependency cannot be installed, 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; the blueprint is applied at build time and is not shipped.
+
+## Choosing the interpreter
+
+The file starts with a `#!` line that selects the Python to run it. The default is
+`/usr/bin/env python3`. Change it with `--python`:
+
+```console
+$ cli build notes.yaml -o dist --python "/usr/bin/env python3.12"
+```
+
+## Compatibility
+
+- The executable does not need cliffy on the target machine.
+- 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.
+- 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.
+- On first run, shiv unpacks the bundled packages to `~/.shiv`. Set the `SHIV_ROOT` environment
+ variable to use another directory.
+- Values taken from `env` in `vars` are fixed at build time. See
+ [Vars and Jinja](vars-and-jinja.md#environment-variables).
+
+## Check before you ship
+
+```console
+$ cli test notes.yaml
+$ cli build notes.yaml -o dist
+$ ./dist/notes --help
+```
diff --git a/docs/guides/commands.md b/docs/guides/commands.md
new file mode 100644
index 0000000..f513fab
--- /dev/null
+++ b/docs/guides/commands.md
@@ -0,0 +1,152 @@
+# 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
+--8<-- "docs/snippets/commands.yaml:forms"
+```
+
+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
+--8<-- "docs/snippets/commands.yaml:groups"
+```
+
+```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
+--8<-- "docs/snippets/commands.yaml:aliases"
+```
+
+- `|` 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
+--8<-- "docs/snippets/commands.yaml:wildcard"
+```
+
+```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
+--8<-- "docs/snippets/commands.yaml:hooks"
+```
+
+```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
+--8<-- "docs/snippets/commands.yaml:docstring"
+```
+
+## Command settings
+
+`config` passes extra settings to Typer for one command.
+
+```yaml
+--8<-- "docs/snippets/commands.yaml:config"
+```
+
+| 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"
+--8<-- "docs/snippets/commands.yaml"
+```
diff --git a/docs/guides/dev-loop.md b/docs/guides/dev-loop.md
new file mode 100644
index 0000000..6641631
--- /dev/null
+++ b/docs/guides/dev-loop.md
@@ -0,0 +1,71 @@
+# 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.
+
+!!! 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).
+
+Loaded CLIs belong to the Python environment cliffy is installed in. They are available whenever
+that environment is active and disappear with it.
diff --git a/docs/guides/editor-setup.md b/docs/guides/editor-setup.md
new file mode 100644
index 0000000..5417488
--- /dev/null
+++ b/docs/guides/editor-setup.md
@@ -0,0 +1,56 @@
+# Editor setup
+
+cliffy publishes a [JSON schema](https://github.com/jaykv/cliffy/blob/main/examples/cliffy_schema.json)
+for manifests. An editor that understands it completes keys, shows their descriptions and marks
+mistakes while you type.
+
+## VS Code
+
+Install the
+[YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml), then point
+a manifest at the schema with a comment on its first line.
+
+=== "Schema from GitHub"
+
+ ```yaml
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/jaykv/cliffy/refs/heads/main/examples/cliffy_schema.json
+ name: mycli
+ version: 0.1.0
+ ```
+
+ This always uses the schema of the latest development version.
+
+=== "Local schema"
+
+ ```console
+ $ cli init mycli --raw --json-schema
+ + mycli.yaml
+ + cliffy_schema.json
+ ```
+
+ This writes `cliffy_schema.json`, which matches your installed version, next to a new manifest
+ that already has the comment:
+
+ ```yaml
+ # yaml-language-server: $schema=cliffy_schema.json
+ ```
+
+Other editors that use
+[yaml-language-server](https://github.com/redhat-developer/yaml-language-server), such as Neovim and
+Helix, read the same comment.
+
+## Highlighting Python inside YAML
+
+Run scripts are Python inside YAML strings, which editors show as plain text. The
+[YAML Embedded Languages](https://marketplace.visualstudio.com/items?itemName=harrydowning.yaml-embedded-languages)
+extension for VS Code adds syntax highlighting to those blocks.
+
+## Checking from the command line
+
+```console
+$ cli validate mycli.yaml
+Manifest mycli.yaml is valid
+```
+
+`cli validate` checks the same schema, plus the `requires` of the manifest. See the
+[CLI reference](../reference/cli.md#cli-validate).
diff --git a/docs/guides/generating-docs.md b/docs/guides/generating-docs.md
new file mode 100644
index 0000000..17b1962
--- /dev/null
+++ b/docs/guides/generating-docs.md
@@ -0,0 +1,78 @@
+# 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. It is only used for documentation.
+
+```yaml
+--8<-- "docs/snippets/notes.yaml:examples"
+```
+
+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`.
diff --git a/docs/guides/includes.md b/docs/guides/includes.md
new file mode 100644
index 0000000..b9408b5
--- /dev/null
+++ b/docs/guides/includes.md
@@ -0,0 +1,64 @@
+# 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"
+--8<-- "docs/snippets/shared/common.yaml"
+```
+
+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"
+--8<-- "docs/snippets/includes.yaml"
+```
+
+```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.
+
+## 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.
+- When two include files set the same key to different values, which one wins is not defined. Keep
+ shared files free of overlaps.
+
+## 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).
diff --git a/docs/guides/manifest-basics.md b/docs/guides/manifest-basics.md
new file mode 100644
index 0000000..ab81fce
--- /dev/null
+++ b/docs/guides/manifest-basics.md
@@ -0,0 +1,159 @@
+# 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 for 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
+```
+
+## 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 and the `requires`. |
+| `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).
diff --git a/docs/guides/params.md b/docs/guides/params.md
new file mode 100644
index 0000000..d67aec1
--- /dev/null
+++ b/docs/guides/params.md
@@ -0,0 +1,128 @@
+# 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
+--8<-- "docs/snippets/params.yaml:shorthand"
+```
+
+| 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
+--8<-- "docs/snippets/params.yaml:explicit"
+```
+
+| Key | Meaning |
+|---|---|
+| `name` | Param name. Start it with `--` to make an option. |
+| `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 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
+--8<-- "docs/snippets/params.yaml:types"
+```
+
+The 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/).
+
+```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
+--8<-- "docs/snippets/params.yaml:generic"
+```
+
+!!! 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 and behave differently in a
+ few places. See [Click and Typer backends](backends.md).
+
+## The complete example
+
+```yaml title="params.yaml"
+--8<-- "docs/snippets/params.yaml"
+```
diff --git a/docs/guides/shell-commands.md b/docs/guides/shell-commands.md
new file mode 100644
index 0000000..fba8e91
--- /dev/null
+++ b/docs/guides/shell-commands.md
@@ -0,0 +1,98 @@
+# 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
+--8<-- "docs/snippets/shell.yaml:basic"
+```
+
+`$ 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 `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, even when it contains spaces.
+
+```yaml
+--8<-- "docs/snippets/shell.yaml:interpolate"
+```
+
+```console
+$ cli run shell.yaml say "two words"
+two words 9
+```
+
+## Capturing output
+
+Assign a shell line to a variable, or wrap it in parentheses, to get its output as `bytes`:
+
+```yaml
+--8<-- "docs/snippets/shell.yaml:capture"
+```
+
+```console
+$ cli run shell.yaml upper
+CAPTURED
+```
+
+## Pipes and redirection
+
+`|`, `>`, `>>` and `<` are supported without a shell:
+
+```yaml
+--8<-- "docs/snippets/shell.yaml:pipes"
+```
+
+## When you need a real shell
+
+Glob patterns (`*.txt`), `&&`, environment variable expansion and other shell features need a shell.
+Start the line with `>` instead of `$`:
+
+```yaml
+--8<-- "docs/snippets/shell.yaml:shell"
+```
+
+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.
+
+!!! danger "Never build `>` lines from user input"
+ With `shell=True`, the text is interpreted by the shell. A parameter value such as
+ `; rm -rf ~` would be executed. Prefer `$`, which passes values as arguments. 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
+--8<-- "docs/snippets/shell.yaml:errors"
+```
+
+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.
+
+## 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.
+- pybash's `{{ }}` interpolation is not available in manifests, because double braces are handled
+ by [Jinja](vars-and-jinja.md). Use `f{...}`.
+
+## The complete example
+
+```yaml title="shell.yaml"
+--8<-- "docs/snippets/shell.yaml"
+```
diff --git a/docs/guides/templates-and-global-params.md b/docs/guides/templates-and-global-params.md
new file mode 100644
index 0000000..ea9595e
--- /dev/null
+++ b/docs/guides/templates-and-global-params.md
@@ -0,0 +1,92 @@
+# 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")
+
+--8<-- "docs/snippets/templates.yaml:global"
+```
+
+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
+--8<-- "docs/snippets/templates.yaml:template"
+```
+
+A command uses it with `template: `:
+
+```yaml
+--8<-- "docs/snippets/templates.yaml:commands"
+```
+
+```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"
+--8<-- "docs/snippets/templates.yaml"
+```
diff --git a/docs/guides/testing.md b/docs/guides/testing.md
new file mode 100644
index 0000000..62d59f9
--- /dev/null
+++ b/docs/guides/testing.md
@@ -0,0 +1,106 @@
+# 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
+--8<-- "docs/snippets/testing.yaml:tests"
+```
+
+```console
+$ cli test testing.yaml
+โจ Invoking tests โจ
+
+๐ช > counter show
+
+โ๏ธ >
+0
+
+
+โ
2 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"
+```
+
+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
+
+- **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 in the output counts steps too.** `โ
2 of 5` numbers every entry, including shell
+ steps, against the number of test cases, so it can read `6 of 5`. The final line is what counts.
+
+## The complete example
+
+```yaml title="testing.yaml"
+--8<-- "docs/snippets/testing.yaml"
+```
diff --git a/docs/guides/vars-and-jinja.md b/docs/guides/vars-and-jinja.md
new file mode 100644
index 0000000..d85d716
--- /dev/null
+++ b/docs/guides/vars-and-jinja.md
@@ -0,0 +1,117 @@
+# 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
+--8<-- "docs/snippets/vars.yaml:vars"
+```
+
+There are two 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. |
+| `retries: {"3"}` | The Python expression `3`, an integer. |
+| `workdir: {os.getcwd()}` | The Python expression `os.getcwd()`, evaluated each time the CLI starts. |
+
+String values must be YAML strings, so quote numbers (`port: "8080"`) and any value that starts with
+`{{`. Braces around a value turn it into a Python expression; quote it inside the braces when it
+would otherwise not be a string, as in `{"3"}`.
+
+## Using vars
+
+Every var is available in two ways.
+
+```yaml
+--8<-- "docs/snippets/vars.yaml:use"
+```
+
+```console
+$ cli run vars.yaml show
+app=shop region=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. Vars written with braces, like `retries` and `workdir`, are
+ 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
+app=shop retries=4 cwd=True
+```
+
+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
+--8<-- "docs/snippets/vars.yaml:loop"
+```
+
+```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
+--8<-- "docs/snippets/vars.yaml:raw"
+```
+
+Single braces, as used in f-strings (`f"{name}"`), need no escaping.
+
+## Limits
+
+- Placeholders are resolved in one pass. A var whose value uses a Jinja expression, such as `region`
+ above, should not be used inside the value of another var. Combine them in Python instead:
+ `print(f"{app} in {region}")`.
+- A string var is written into the generated code between single quotes, so its value cannot
+ contain a single quote.
+- Vars defined in an [include file](includes.md) can be used as placeholders only inside that file.
+
+## The complete example
+
+```yaml title="vars.yaml"
+--8<-- "docs/snippets/vars.yaml"
+```
diff --git a/docs/index.md b/docs/index.md
index 18963d7..2739657 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,116 +1,111 @@
-
-
-# Cliffy
-
-
-
-Build feature-rich Python CLIs _quickly_.
-
-!!! example "Simplest example"
- 1. Define a manifest
- ```yaml
- # hello.yaml
- name: hello
- version: 0.1.0
- help: Hello world!
-
- commands:
- shell: $echo "hello from shell"
- python: print("hello from python")
- ```
-
- 2. Load CLI
- ```
- $ cli load hello.yaml
- ```
- `hello.yaml` automatically gets parsed to generate a Typer CLI and gets loaded into the running Python environment.
-
- 3. Run CLI directly
-
- 
-
- For more examples, check [examples](examples/) directory.
-
-!!! example "Build into single-file executable"
- Simple todo CLI with sqlite3 + tabulate.
-
- ```yaml
- # todo.yaml
- name: todo
- version: 1.0.0
- requires:
- - tabulate # For pretty table output
- - rich # For colored terminal output
- imports: |
- import sqlite3
- from pathlib import Path
- from tabulate import tabulate
- from rich import print
-
- commands:
- create:
- help: Create a new database with tasks table
- params:
- - name: str = typer.Option(..., prompt=True, confirmation_prompt=True)
- run: |
- db_path = Path(f"{name}.db")
- conn = sqlite3.connect(db_path)
- conn.execute("CREATE TABLE tasks (id INTEGER PRIMARY KEY, task TEXT NOT NULL, done BOOLEAN NOT NULL)")
-
- # insert example tasks
- conn.execute("INSERT INTO tasks (task, done) VALUES ('Fight for your right!', 0)")
- conn.execute("INSERT INTO tasks (task, done) VALUES ('To party!', 1)")
- conn.commit()
- conn.close()
- print(f"โจ Created database {db_path} with tasks table")
-
- tasks:
- help: List tasks in database
- params: [name: str!]
- run: |
- conn = sqlite3.connect(f"{name}.db")
- cursor = conn.execute("SELECT * FROM tasks")
- tasks = cursor.fetchall()
- conn.close()
- print(tabulate(tasks, headers=['ID', 'Task', 'Done'], tablefmt='grid'))
-
- add:
- help: Add a new task
- params: [name: str!, task: str!]
- run: |
- conn = sqlite3.connect(f"{name}.db")
- conn.execute("INSERT INTO tasks (task, done) VALUES (?, 0)", (task,))
- conn.commit()
- conn.close()
- print(f"๐ Added task: {task}")
-
- complete:
- help: Mark a task as complete
- params: [name: str!, id: int!]
- run: |
- conn = sqlite3.connect(f"{name}.db")
- conn.execute("UPDATE tasks SET done = 1 WHERE id = ?", (id,))
- conn.commit()
- conn.close()
- print(f"๐ Marked task {id} as complete")
- ```
-
- 
-
-For more examples, check [examples](examples/) directory.
-
-## Why cliffy
-* Mix Python and shell commands naturally
-* Hot-reload changes as you develop
-* Package your CLI as a single executable with `cli build`
-* Test your CLIs with built-in test runner
-* Use Jinja2 template syntax for dynamic command generation
-* Reusable command templates
-* AI-friendly- see the supported [AI features](features#ai-features)
-
-**Similar frameworks**
-
-- [Bashly](https://github.com/DannyBen/bashly) - An awesome YAML to Bash CLI builder
-- [Fire](https://github.com/google/python-fire) - Python objects to CLI builder
\ No newline at end of file
+---
+title: cliffy
+hide:
+ - navigation
+---
+
+# cliffy
+
+{ width="220" }
+
+**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"
+--8<-- "docs/snippets/hello.yaml"
+```
+
+**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. 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.
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..d45d288
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,107 @@
+# Installation
+
+cliffy supports **Python 3.11, 3.12, 3.13, 3.14 and 3.15** on macOS and Linux.
+
+## Install
+
+Install cliffy into the virtual environment you want your CLIs to live in.
+
+=== "pip"
+
+ ```console
+ $ python -m venv .venv
+ $ source .venv/bin/activate
+ $ pip install cliffy
+ ```
+
+=== "uv"
+
+ ```console
+ $ uv venv
+ $ uv pip install cliffy
+ $ source .venv/bin/activate
+ ```
+
+Check the install:
+
+```console
+$ cli --help
+```
+
+The package provides two identical commands, `cli` and `cliffy`. The documentation uses `cli`.
+
+!!! note "Why a virtual environment?"
+ `cli load` writes a launcher for your CLI into the `bin` directory of the environment cliffy runs
+ in, and packages listed under `requires` in a manifest are looked up in that same environment.
+ Keeping cliffy, those packages and your loaded CLIs in one environment is the simplest setup.
+
+## Run without installing
+
+[`uvx`](https://docs.astral.sh/uv/guides/tools/) runs cliffy from a cached, isolated environment:
+
+```console
+$ uvx cliffy run hello.yaml greet Ada
+$ uvx cliffy build hello.yaml -o dist
+```
+
+A CLI loaded this way lives inside that cached environment. Call it through `uvx` as well:
+
+```console
+$ uvx cliffy load hello.yaml
+$ uvx --from cliffy hello greet Ada
+```
+
+## Install as a tool
+
+`pipx install cliffy` and `uv tool install cliffy` put `cli` on your `PATH` and keep cliffy in its
+own environment. This works well for `cli run`, `cli test`, `cli render` and `cli build`.
+
+Two things differ from a virtual environment install:
+
+- `cli load` writes the launcher into the tool's private `bin` directory, which is not on your
+ `PATH`. Use `cli run` or `cli build`, or add that directory to your `PATH`.
+- Packages a manifest `requires` must be installed into the tool environment, for example
+ `pipx inject cliffy tabulate` or `uv tool install cliffy --with tabulate`.
+
+## Extras
+
+| Extra | Install | Adds |
+|---|---|---|
+| `rich` | `pip install "cliffy[rich]"` | [rich-click](https://github.com/ewels/rich-click). Styled help for the `cli` command itself, and required for manifests that set `use_click: true`. |
+| `ai` | `pip install "cliffy[ai]"` | [pydantic-ai](https://ai.pydantic.dev/). Enables the `cli ai generate` and `cli ai ask` commands. |
+
+Install both with `pip install "cliffy[rich,ai]"`.
+
+[Rich](https://rich.readthedocs.io/) itself is always installed because Typer depends on it, so the
+CLIs you generate have styled help output with or without the `rich` extra.
+
+### AI commands
+
+With the `ai` extra installed, `cli ai generate` writes a manifest from a description and `cli ai ask`
+answers questions about cliffy. Models are named in pydantic-ai's `provider:model` form and need that
+provider's API key in the environment:
+
+```console
+$ export OPENAI_API_KEY=...
+$ cli ai generate mycli "a CLI that manages docker containers" --model openai:gpt-4o
+```
+
+Add `--preview` to print the prompt without calling a model. Model selection, validation of the
+generated manifest and `cli ai ask` are covered in [AI commands](ai.md); the options are listed in the
+[CLI reference](reference/cli.md#cli-ai).
+
+## What gets installed
+
+| Package | Used for |
+|---|---|
+| Typer, Click | The generated CLIs and the `cli` command |
+| pydantic | Validating manifests |
+| PyYAML, Jinja2, deepmerge | Reading, templating and merging manifests |
+| pybash | Translating `$` lines into `subprocess` calls |
+| shiv | `cli build` |
+| watchdog | `cli dev` |
+| packaging | Checking `requires` versions |
+
+## Next step
+
+Build your first CLI in the [Quickstart](quickstart.md).
diff --git a/docs/project/changelog.md b/docs/project/changelog.md
new file mode 100644
index 0000000..382aa71
--- /dev/null
+++ b/docs/project/changelog.md
@@ -0,0 +1,167 @@
+# Changelog
+
+Notable changes per release. Entries before 0.7.0 were reconstructed from the git history and tags,
+because no changelog was kept at the time; they list the main changes, not every commit. Release
+artifacts are on [PyPI](https://pypi.org/project/cliffy/#history).
+
+## 0.7.0 (unreleased)
+
+The first release after a long pause. It brings the project up to date with current Python and
+dependency versions. Read the [migration notes](migration.md#from-06x-to-070) before upgrading.
+
+### Changed
+
+- **Supported Python versions are now 3.11 to 3.15.** Python 3.9 and 3.10 are no longer supported.
+- Depends on `typer` instead of `typer-slim`, so `rich` and `shellingham` are always installed.
+- Minimum dependency versions were raised to current releases, among them `click>=8.2.1`,
+ `typer>=0.24.0`, `pydantic>=2.12.0`, `rich-click>=1.9.0` and `pydantic-ai>=2.0.0`.
+- The `ai` extra requires pydantic-ai 2. Model names need a provider prefix. Without `--model`, the
+ `CLIFFY_AI_MODEL` environment variable or a default for the provider whose API key is set is used,
+ and generated manifests are validated before they are written. See [AI commands](../ai.md).
+- `cli build` bundles the framework the generated code imports (Typer or rich-click), pinned to the
+ installed version, writes a reproducible file and reports the Python version it needs.
+- Commands exit with 1 on failure, among them `cli test` when any test fails, `cli build` when a build
+ fails and `cli validate` for an invalid manifest.
+- Include paths are looked up next to the including manifest first.
+- With the Click backend a `bool` option is a `--flag/--no-flag` switch, and short option names,
+ `global_params` and `cli test` work as they do with Typer.
+- `cli dev --run-cli` is a real flag. It no longer re-runs the CLI on every reload when the flag is
+ not given.
+- `cli build` prints the build output when a build fails.
+- The source distribution only contains the package, tests and examples.
+- Documentation: new site structure with a tutorial, task guides and reference pages generated from
+ the code. Every manifest shown in the guides is run by `make docs-check`.
+
+### Added
+
+- [Blueprints](../blueprints.md): custom YAML specs that map onto a CLI, selected with `--blueprint`
+ or a `blueprint:` key, and the `cli blueprint list` and `cli blueprint schema` commands.
+- [`callbacks`](../blueprints.md#callbacks) in manifests: code that runs before the commands of the
+ CLI or of a group, for both backends.
+
+### Security
+
+- Text from a manifest (help, names, defaults, versions) is escaped in the generated code and names
+ are validated, so manifest data cannot change the code that is generated.
+
+### Removed
+
+- The `cliffy[dev]` extra. Development dependencies are a dependency group (`uv sync`).
+
+### Fixed
+
+- Manifest file handles are closed after `cli update` and after loading a manifest.
+- Compatibility with Click 8.2 and later, rich-click 1.9 and Python 3.12 and later (deprecated APIs
+ replaced).
+
+## 0.6.1 (2025-02-05)
+
+- Changed the default model of the AI commands.
+- Documentation updates.
+
+## 0.6.0 (2025-02-02)
+
+- Added the `cli ai generate` and `cli ai ask` commands behind the new `ai` extra.
+- Added `cli docs` to generate Markdown, reStructuredText or HTML documentation for a CLI, and the
+ `examples` manifest key that feeds it.
+- Expanded the documentation site.
+
+## 0.5.3 (2025-01-24)
+
+- Added the Click backend (`use_click: true`) and refactored code generation into per-framework
+ commanders.
+- Moved the project to uv.
+- Added Python 3.13 to the test matrix and extended the test suite.
+
+## 0.5.2 (2025-01-16)
+
+- Reworked command parameter parsing.
+
+## 0.5.1 (2025-01-16)
+
+- Added the `todo` example and documentation updates.
+
+## 0.5.0 (2025-01-14)
+
+- **Manifest schema v3.** `args` was renamed to `params`; v2 manifests are rejected.
+- Added `global_params`.
+- Added the JSON schema for manifests and `cli init --json-schema`.
+
+## 0.4.0 (2025-01-11)
+
+- **Manifest schema v2**; v1 manifests are rejected.
+- Added command templates and the `pre_run` and `post_run` hooks.
+- First version of the documentation site.
+
+## 0.3.8 (2024-08-26)
+
+- Added `cli test` and the `tests` manifest key.
+- Added Python 3.12 to the test matrix.
+- Fixed path handling on Windows.
+
+## 0.3.7 (2024-08-07)
+
+- Added command aliases.
+
+## 0.3.6 (2024-08-02)
+
+- Added `cli dev` (hot reload) and `cli rm-all`.
+
+## 0.3.5 (2023-09-19)
+
+- Declared Click as an explicit dependency.
+
+## 0.3.4 (2023-08-06)
+
+- CLI metadata is stored inside the installed package.
+
+## 0.3.3 (2023-07-17)
+
+- Merged the `bundle` command into `cli build`.
+- Type-checking fixes.
+
+## 0.3.0 to 0.3.2 (2023-06)
+
+- Internal refactoring and a newer pybash.
+
+## 0.2.9 (2023-05-22)
+
+- Environment variables can be used in `vars`.
+
+## 0.2.8 (2023-05-21)
+
+- Added `cli run`.
+- Executables are built with shiv.
+
+## 0.2.7 (2023-05-02)
+
+- Rich became optional.
+- Windows fixes.
+
+## 0.2.6 (2023-04-27)
+
+- Added `requires`.
+
+## 0.2.5 (2023-04-16)
+
+- Added `vars` with Jinja2 templating.
+- Added `includes` with deep merging.
+
+## 0.2.3 (2023-04-03)
+
+- Added loading, unloading and listing of CLIs.
+
+## 0.2.2 (2023-03-15)
+
+- Added `cli init`.
+- Several manifests can be loaded in one call.
+- Added manifest versioning.
+
+## 0.2.0 and 0.2.1 (2023-03-09)
+
+- Renamed the project to cliffy.
+- Added `help`, `cli_options`, option aliases, custom imports and a shorter param syntax.
+
+## 0.1.0 (2023-01-29)
+
+- First release.
diff --git a/docs/project/contributing.md b/docs/project/contributing.md
new file mode 100644
index 0000000..9d12ed1
--- /dev/null
+++ b/docs/project/contributing.md
@@ -0,0 +1,129 @@
+# Contributing
+
+Bug reports, fixes and documentation improvements are welcome. Open an issue or a pull request on
+[GitHub](https://github.com/jaykv/cliffy).
+
+## Set up
+
+You need [uv](https://docs.astral.sh/uv/) and one of the supported Python versions (3.11 to 3.15).
+
+```console
+$ git clone https://github.com/jaykv/cliffy.git
+$ cd cliffy
+$ make install # uv sync --frozen --all-extras --group docs
+$ uv run cli --help
+```
+
+`make install` creates `.venv` with cliffy in editable mode, both extras, the test tools and the
+documentation tools. To work on another Python version, run `uv sync --all-extras --python 3.15`.
+
+On macOS with Python 3.14 or 3.15, some dependencies are built from source, which needs the Xcode
+command line tools.
+
+## Everyday commands
+
+| Command | What it does |
+|---|---|
+| `make test` | Runs the test suite in parallel with coverage. |
+| `make lint` | Checks formatting and lint rules with ruff, then types with mypy. |
+| `make format` | Formats the code and applies ruff's automatic fixes. |
+| `make check` | `format`, `lint`, `test` and `clean` in one go. |
+| `make docs-serve` | Serves the documentation at `http://127.0.0.1:8000` with live reload. |
+| `make docs-generate` | Regenerates the reference pages. |
+| `make docs-check` | Checks the reference pages and runs every documentation manifest. |
+| `make docs-build` | `docs-check`, then a strict documentation build. |
+| `make generate-schema` | Regenerates `examples/cliffy_schema.json` from the models. |
+| `make clean` | Removes build and cache directories. |
+
+Run a single test file or test with pytest directly:
+
+```console
+$ uv run pytest tests/test_manifest.py -v
+$ uv run pytest tests/test_manifest.py::test_validate_cli_requires -v
+```
+
+Run the tests through `uv run` so that the environment's `bin` directory is on the `PATH`; tests
+that load CLIs start them by name. Do not run two test sessions in the same checkout at once,
+because loaded CLIs are shared state.
+
+CI runs `make lint` and `make test` on Python 3.11 to 3.15 for every pull request.
+
+## How the code is organized
+
+A manifest goes through three stages:
+
+```text
+YAML file โโบ Transformer โโบ CLIManifest โโบ Commander โโบ Python source โโบ Loader / builder
+```
+
+| Module | Role |
+|---|---|
+| `cliffy/transformer.py` | Renders the manifest with Jinja2, resolves `includes`, validates it and checks `requires`. |
+| `cliffy/manifest.py` | The pydantic models that define the manifest format and the JSON schema. |
+| `cliffy/commander.py` | Builds groups, merges command templates and drives code generation. |
+| `cliffy/commanders/typer.py`, `click.py` | Emit Typer or Click code. |
+| `cliffy/parser.py` | Translates params and run scripts for the Typer backend. |
+| `cliffy/cli.py`, `cliffy/ai.py` | The `cli` command itself. |
+| `cliffy/loader.py`, `cliffy/homer.py` | Install generated CLIs and keep their metadata. |
+| `cliffy/builder.py` | `cli run` and `cli build`. |
+| `cliffy/reloader.py` | `cli dev`. |
+| `cliffy/tester.py` | `cli test`. |
+| `cliffy/doc.py` | `cli docs`. |
+
+When you add a manifest key, update the model in `cliffy/manifest.py`, handle it in the commanders,
+then run `make generate-schema` and `make docs-generate`.
+
+## Working on the documentation
+
+The site is built with [MkDocs Material](https://squidfunk.github.io/mkdocs-material/) from the
+`docs/` directory. The build is strict: a broken link or a missing file is an error.
+
+### Generated pages
+
+Three pages are written by `scripts/docs.py` and must not be edited by hand:
+
+| Page | Generated from |
+|---|---|
+| `docs/reference/cli.md` | The Click commands in `cliffy/cli.py` and `cliffy/ai.py` |
+| `docs/reference/manifest.md` | The models in `cliffy/manifest.py` |
+| `docs/reference/examples.md` | The manifests in `examples/` |
+
+After changing a command, an option, a model or an example, run `make docs-generate` and commit the
+result. The hand-written notes on those pages live in `scripts/docs.py`, next to the code that
+renders them. The script stops with a message when a new command, argument, model field or example
+has no description yet.
+
+### Manifests in the guides
+
+Guides do not contain copies of YAML. They embed files from `docs/snippets/` (or `examples/`) with
+the snippet syntax, optionally limited to a named section:
+
+```text
+;--8<-- "docs/snippets/params.yaml:shorthand"
+```
+
+A section is the part of the YAML file between a pair of marker comments that carry its name. Open
+any file in `docs/snippets/` to see them; the
+[snippets extension](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/#snippet-sections)
+documents the syntax.
+
+Every manifest in `docs/snippets/` should have a `tests` section. `make docs-check` loads each one,
+compiles the generated code and runs its tests, so a guide cannot show a manifest that does not
+work. When you add console output to a page, copy it from a real run.
+
+### Preview
+
+```console
+$ make docs-serve
+```
+
+## Releasing
+
+1. Set the version in `pyproject.toml` and run `uv lock`.
+2. Move the "unreleased" entry in `docs/project/changelog.md` under the new version and date, and
+ add [migration notes](migration.md) for breaking changes.
+3. Merge to `main`, then publish a GitHub release whose tag equals the version (`0.7.0` or
+ `v0.7.0`).
+
+The `publish.yaml` workflow builds the package, checks that the tag matches the version and uploads
+to PyPI through trusted publishing. Pushing documentation changes to `main` deploys the site.
diff --git a/docs/project/known-issues.md b/docs/project/known-issues.md
new file mode 100644
index 0000000..973e55d
--- /dev/null
+++ b/docs/project/known-issues.md
@@ -0,0 +1,43 @@
+# Known issues
+
+Behavior in cliffy 0.7.0 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
+
+### `cli validate` does not check the generated code
+
+It validates the YAML, the schema and the `requires`. A manifest can pass and still produce Python
+that fails to import or run.
+
+**Do this instead:** add a [`tests` section](../guides/testing.md) and run `cli test`, or call a
+command with `cli run`.
+
+### The `cli test` counter can exceed the total
+
+The progress counter also numbers shell steps, so it can show `6 of 5`. The final line and the exit
+code are what count; see [Testing manifests](../guides/testing.md#running-tests).
+
+### `cli --version` shows the wrong number with the `rich` extra
+
+When rich-click is installed, `cli --version` prints the version of rich-click.
+
+**Do this instead:** run `pip show cliffy`.
+
+## Manifests
+
+### `manifestVersion` is ignored
+
+Any value is accepted when a manifest file is loaded, including `v1` and `v2`. The key has no effect.
+
+### Vars cannot build on computed vars
+
+A var whose value contains a Jinja expression is not resolved when it is used inside another var's
+value. See [Vars and Jinja](../guides/vars-and-jinja.md#limits).
+
+## Click backend
+
+`use_click: true` supports fewer features than the default Typer backend: no `types` and no `config`.
+The details are in
+[Click and Typer backends](../guides/backends.md#what-differs-from-typer).
diff --git a/docs/project/migration.md b/docs/project/migration.md
new file mode 100644
index 0000000..6f262eb
--- /dev/null
+++ b/docs/project/migration.md
@@ -0,0 +1,74 @@
+# Migration notes
+
+What to change when you upgrade. Each section lists the breaking changes of one release and the
+action to take. The full list of changes is in the [changelog](changelog.md).
+
+## From 0.6.x to 0.7.0
+
+### Python 3.11 or newer is required
+
+cliffy 0.7.0 supports Python 3.11 to 3.15. On Python 3.9 or 3.10, pip keeps installing 0.6.1.
+
+**Action:** upgrade the interpreter of the environment cliffy is installed in, then reinstall:
+
+```console
+$ python --version
+$ pip install --upgrade cliffy
+```
+
+Loaded CLIs are tied to an environment. After moving to a new environment, load your manifests
+again with `cli load`.
+
+### `typer` replaces `typer-slim`
+
+cliffy now depends on the full `typer` package, which always installs `rich` and `shellingham`.
+Generated CLIs therefore always have Rich-formatted help.
+
+**Action:** none in most cases. If you pinned `typer-slim` next to cliffy in a requirements file,
+replace it with `typer`.
+
+### The `dev` extra is gone
+
+`pip install "cliffy[dev]"` no longer exists.
+
+**Action:** contributors set up the project with `uv sync --all-extras`. See
+[Contributing](contributing.md).
+
+### AI commands: model names need a provider
+
+The `ai` extra now requires pydantic-ai 2, which only accepts model names in `provider:model` form.
+
+| Before | Now |
+|---|---|
+| `--model gpt-4o` | `--model openai:gpt-4o` |
+| `--model gemini-2.0-flash` | `--model google:gemini-2.0-flash` |
+
+**Action:** add the provider prefix wherever you pass `--model` or `-m`. The default model is
+`google:gemini-2.0-flash`.
+
+### `cli dev --run-cli`
+
+In 0.6.x with Click 8.2 or later, the CLI was run after every reload even without `--run-cli`. Now
+it only runs when the flag is given.
+
+**Action:** add `--run-cli` to your `cli dev` command if you relied on the old behavior.
+
+### Manifests
+
+The manifest format did not change. Manifests written for 0.5.x and 0.6.x (schema v3) load without
+changes.
+
+## From 0.4.x to 0.5.0 (schema v3)
+
+The main change is that `args` on a command was renamed to `params`.
+
+**Action:** rename the key in every command, and set `manifestVersion: v3` or remove the line. Then
+run `cli validate` on the manifest to find anything else the schema no longer accepts.
+
+## From 0.3.x to 0.4.0 (schema v2)
+
+Manifests written for 0.3.x use the v1 format, which later releases do not read.
+
+**Action:** rewrite the manifest in the current format. The [Quickstart](../quickstart.md) and the
+[manifest reference](../reference/manifest.md) describe it, and `cli init mycli --raw` writes an empty
+skeleton to start from.
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..3a1205c
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,229 @@
+# 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"
+--8<-- "docs/snippets/notes-step1.yaml"
+```
+
+- `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"
+--8<-- "docs/snippets/notes.yaml:storage"
+```
+
+- `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"
+--8<-- "docs/snippets/notes.yaml:types"
+```
+
+Now replace the `add` command so that it uses those types and saves the note:
+
+```yaml title="notes.yaml"
+commands:
+--8<-- "docs/snippets/notes.yaml:add"
+```
+
+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"
+--8<-- "docs/snippets/notes.yaml:list"
+```
+
+- 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"
+--8<-- "docs/snippets/notes.yaml:remove"
+```
+
+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"
+--8<-- "docs/snippets/notes.yaml:tags"
+```
+
+```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"
+--8<-- "docs/snippets/notes.yaml:backup"
+```
+
+```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"
+--8<-- "docs/snippets/notes.yaml:tests"
+```
+
+- 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 --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. More in [Building executables](guides/building.md).
+
+## The finished manifest
+
+```yaml title="notes.yaml"
+--8<-- "docs/snippets/notes.yaml"
+```
+
+The `examples` section at the end 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.
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
new file mode 100644
index 0000000..3000938
--- /dev/null
+++ b/docs/reference/cli.md
@@ -0,0 +1,485 @@
+
+
+# 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. Requires 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 executable (zipapp) |
+| [`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 the syntax and structure of a CLI manifest |
+
+## 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. Requires the `ai` extra: pip install 'cliffy[ai]'
+
+```text
+cli ai [OPTIONS] COMMAND [ARGS]...
+```
+
+Only available when the `ai` extra is installed (`pip install "cliffy[ai]"`). Both commands send the
+manifest JSON schema to the model as part of the prompt and print token usage when they finish.
+
+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 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 executable (zipapp)
+
+```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. |
+| `-o`, `--output-dir` | directory | no | | Output dir |
+| `-p`, `--python` | text | no | `/usr/bin/env python3` | Python interpreter to set as the zipapp shebang. This gets added after the #! |
+| `-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 a single-file [zipapp](https://docs.python.org/3/library/zipapp.html) with
+[shiv](https://shiv.readthedocs.io/). The file contains the generated CLI, the framework it imports
+(Typer, or rich-click with `use_click`) and every package listed under `requires`. Without
+`--output-dir` it is written to the current directory.
+
+```console
+$ cli build hello.yaml -o dist
++ hello built ๐ฆ
+ dist/hello (single file, needs Python >=3.10)
+$ ./dist/hello greet Ada
+Hello, Ada!
+```
+
+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. 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 and no content. Without `--raw` the manifest is a
+commented tour of the format with example commands.
+
+`--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. |
+| `-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 the syntax and structure of a CLI manifest
+
+```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 and that its `requires` are installed.
+It does not check that the generated Python is valid; run `cli test` or `cli run` for that.
+
+```console
+$ cli validate hello.yaml
+Manifest hello.yaml is valid
+```
diff --git a/docs/reference/examples.md b/docs/reference/examples.md
new file mode 100644
index 0000000..6f9dc1f
--- /dev/null
+++ b/docs/reference/examples.md
@@ -0,0 +1,230 @@
+
+
+# Examples gallery
+
+The manifests in the [`examples/`](https://github.com/jaykv/cliffy/tree/main/examples) directory of the
+repository. Each entry shows the file as it is in the repository. The code cliffy generates for them is
+committed next to them in
+[`examples/generated/`](https://github.com/jaykv/cliffy/tree/main/examples/generated).
+
+To try one, clone the repository and run the command shown from its root.
+
+Files written in a custom spec, and the blueprints that turn them into CLIs, are in
+[`examples/blueprints/`](https://github.com/jaykv/cliffy/tree/main/examples/blueprints) and
+described in [Blueprints](../blueprints.md#examples).
+
+| Example | Backend | Requires | Has tests |
+|---|---|---|---|
+| [hello.yaml](#hello) | Typer | nothing | yes |
+| [nested-cli.yaml](#nested-cli) | Typer | nothing | no |
+| [town.yaml](#town) | Typer | nothing | no |
+| [template.yaml](#template) | Typer | nothing | no |
+| [environ.yaml](#environ) | Typer | nothing | no |
+| [db.yaml](#db) | Typer | `rich` | no |
+| [penv.yaml](#penv) | Typer | nothing | no |
+| [pydev.yaml](#pydev) | Typer | nothing | no |
+| [taskmaster.yaml](#taskmaster) | Typer | `tabulate>=0.9.0` | yes |
+| [todo.yaml](#todo) | Typer | `tabulate`, `rich` | no |
+| [requires.yaml](#requires) | Typer | `requests >= 2.30.0`, `six < 1.0.0` | no |
+| [callbacks.yaml](#callbacks) | Typer | nothing | yes |
+| [click_hello.yaml](#clickhello) | Click | nothing | no |
+| [aiprompt.yaml](#aiprompt) | Typer | `openai>=1.0.0`, `rich>=13.0.0`, `keyring>=24.0.0` | no |
+
+## hello
+
+The smallest useful manifest: one shell command, one Python command and a `tests` section.
+
+```console
+$ cli run examples/hello.yaml python
+```
+
+??? example "examples/hello.yaml"
+
+ ```yaml
+ --8<-- "examples/hello.yaml"
+ ```
+
+## nested-cli
+
+Command groups nested two levels deep with dotted keys.
+
+```console
+$ cli run examples/nested-cli.yaml group1 subgroup command1
+```
+
+??? example "examples/nested-cli.yaml"
+
+ ```yaml
+ --8<-- "examples/nested-cli.yaml"
+ ```
+
+## town
+
+A larger CLI: groups with help text, shared `types`, short option aliases, command aliases and a `(*).list` command that is added to every group.
+
+```console
+$ cli run examples/town.yaml land list
+```
+
+??? example "examples/town.yaml"
+
+ ```yaml
+ --8<-- "examples/town.yaml"
+ ```
+
+## template
+
+A command template that gives two commands the same params, a run script written as a list, and per-command `config`.
+
+```console
+$ cli run examples/template.yaml -- debug one --local-arg-2 two
+```
+
+??? example "examples/template.yaml"
+
+ ```yaml
+ --8<-- "examples/template.yaml"
+ ```
+
+## environ
+
+Environment variables read when the manifest is loaded (`vars` with `env`) and when a command runs.
+
+```console
+$ cli run examples/environ.yaml hello
+```
+
+??? example "examples/environ.yaml"
+
+ ```yaml
+ --8<-- "examples/environ.yaml"
+ ```
+
+## db
+
+Prompting options defined in `types`, YAML anchors to share params, `pre_run`, command aliases and `cli_options`.
+
+```console
+$ cli run examples/db.yaml -- mk --name demo
+```
+
+??? example "examples/db.yaml"
+
+ ```yaml
+ --8<-- "examples/db.yaml"
+ ```
+
+## penv
+
+A virtualenv manager: `vars` holding Python expressions, helper `functions` and shell lines that interpolate Python values with `f{...}`.
+
+```console
+$ cli run examples/penv.yaml -- --help
+```
+
+??? example "examples/penv.yaml"
+
+ ```yaml
+ --8<-- "examples/penv.yaml"
+ ```
+
+## pydev
+
+Developer chores as run-script lists that mix shell and Python lines, with helper functions.
+
+```console
+$ cli run examples/pydev.yaml -- --help
+```
+
+??? example "examples/pydev.yaml"
+
+ ```yaml
+ --8<-- "examples/pydev.yaml"
+ ```
+
+## taskmaster
+
+A task manager with JSON storage, a `vars` placeholder used inside functions, shared `types`, nested groups and a full `tests` section.
+
+```console
+$ cli test examples/taskmaster.yaml
+```
+
+??? example "examples/taskmaster.yaml"
+
+ ```yaml
+ --8<-- "examples/taskmaster.yaml"
+ ```
+
+## todo
+
+A sqlite-backed todo list that depends on `tabulate` and `rich`.
+
+```console
+$ cli run examples/todo.yaml -- --help
+```
+
+??? example "examples/todo.yaml"
+
+ ```yaml
+ --8<-- "examples/todo.yaml"
+ ```
+
+## requires
+
+Version specifiers in `requires`. Loading fails on purpose unless `requests>=2.30.0` and `six<1.0.0` are installed, which is what the test suite checks.
+
+```console
+$ cli validate examples/requires.yaml
+```
+
+??? example "examples/requires.yaml"
+
+ ```yaml
+ --8<-- "examples/requires.yaml"
+ ```
+
+## callbacks
+
+Callbacks: code that runs before every command of the CLI or of one group, with params that become options of the CLI itself and access to the context as `ctx`.
+
+```console
+$ cli run examples/callbacks.yaml -- --verbose db migrate
+```
+
+??? example "examples/callbacks.yaml"
+
+ ```yaml
+ --8<-- "examples/callbacks.yaml"
+ ```
+
+## clickhello
+
+The Click backend (`use_click: true`) with a command alias. Needs the `rich` extra.
+
+```console
+$ cli run examples/click_hello.yaml hello
+```
+
+??? example "examples/click_hello.yaml"
+
+ ```yaml
+ --8<-- "examples/click_hello.yaml"
+ ```
+
+## aiprompt
+
+An OpenAI client written by an LLM: explicit params with help text, `global_params` and nested groups. Needs `openai`, `rich` and `keyring`.
+
+```console
+$ cli render examples/aiprompt.yaml
+```
+
+??? example "examples/aiprompt.yaml"
+
+ ```yaml
+ --8<-- "examples/aiprompt.yaml"
+ ```
diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md
new file mode 100644
index 0000000..fc7d21a
--- /dev/null
+++ b/docs/reference/manifest.md
@@ -0,0 +1,201 @@
+
+
+# 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. |
+| `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. Keys set in this manifest win over included ones; lists are combined. |
+| `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. Values are Jinja2 templates; `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. |
+| `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 usages to display in generated 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` is accepted and ignored when a manifest file is loaded.
+- `vars` values must be strings (quote numbers) or a one-key mapping such as `{"3"}` or `{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. See [Params](#params) for the three accepted forms. |
+| `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 full Typer 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. `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 Typer's `command()`. The Click backend
+ignores `config`.
+
+## 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 only used 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).
diff --git a/docs/schema.md b/docs/schema.md
deleted file mode 100644
index cd811cf..0000000
--- a/docs/schema.md
+++ /dev/null
@@ -1,100 +0,0 @@
-# CLI Manifest Schema
-
-This document describes the schema for the CLI manifest.
-
-## CLIManifest
-
-The `CLIManifest` model defines the structure of a CLI manifest file. It includes fields for the CLI's name, version, dependencies, commands, and other configuration options.
-
-### Fields
-
-- `manifestVersion`: The version of the manifest schema.
-- `name`: The name of the CLI, used when invoking from the command line.
-- `version`: The CLI version.
-- `help`: A brief description of the CLI.
-- `requires`: A list of Python package dependencies for the CLI. Supports requirements specifier syntax.
-- `includes`: A list of external CLI manifests to include. Performs a deep merge of manifests sequentially in the order given to assemble a merged manifest, and finally, deep merges the merged manifest with this manifest.
-- `vars`: A mapping defining manifest variables that can be referenced in any other blocks. Environments variables can be used in this section with `${some_env_var}` for dynamic parsing. Supports jinja2 formatted expressions as values. Interpolate defined vars in other blocks jinja2-styled `{{ var_name }}`.
-- `imports`: A 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`: A string block or list of helper function definitions. These functions should be defined as strings that can be executed by the Python interpreter.
-- `types`: A mapping containing any shared type definitions. These types can be referenced by name in the args section to provide type annotations for params and options defined in the args section.
-- `global_params`: Parameters applied to all commands.
-- `command_templates`: Reusable command templates.
-- `commands`: A mapping containing the command definitions for the CLI. Each command should have a unique key- which can be either a group command or nested subcommands. Nested subcommands are joined by '.' in between each level. Aliases for commands can be separated in the key by '|'. A special '(*)' wildcard can be used to spread the subcommand to all group-level commands.
-- `cli_options`: Additional CLI configuration options.
-- `tests`: Test cases for commands.
-## Command
-
-The `Command` model defines a single command within the CLI. It specifies the command's execution logic, arguments, and configuration.
-
-### Fields
-
-- `run`: The command's execution logic, defined as a `RunBlock`. Can be a single command or a list of commands.
-- `help`: A description of the command.
-- `args`: A list of arguments for the command. Each argument can be:
- - A `SimpleCommandParam`: `{'name': 'str = "tester"'}` structure
- - A `CommandParam`: Full argument specification
- - A string: Type annotation string
-- `template`: A reference to a command template.
-- `pre_run`: A `PreRunBlock` to execute before the command.
-- `post_run`: A `PostRunBlock` to execute after the command.
-- `aliases`: A list of aliases for the command.
-- `name`: The name of the command.
-- `config`: An optional `CommandConfig` object.
-
-### CommandParam
-
-The `CommandParam` model defines the structure of a command parameter.
-
-#### Fields
-
-- `name`: The name of the parameter. Options must be prefixed with `--`.
-- `type`: The type of the argument (e.g., `str`, `int`, `bool`).
-- `default`: The default value.
-- `help`: Description of the argument.
-- `short`: Short alias (only valid when name is prefixed with `--` as an option/flag).
-- `required`: Whether the argument is required.
-
-### RunBlock Types
-
-Commands now use specialized blocks for execution:
-
-- `RunBlock`: Main command execution block
-- `PreRunBlock`: Pre-execution hook
-- `PostRunBlock`: Post-execution hook
-- `RunBlockList`: List of execution blocks
-
-!!! example
- ```yaml
- commands:
- deploy:
- pre_run: |
- if not confirm_deployment():
- raise typer.Abort()
- run:
- - print("Starting deployment...")
- - deploy_application()
- post_run: |
- notify_team("Deployment completed")
- ```
-
-## CommandConfig
-
-The `CommandConfig` model provides additional configuration options for a command.
-
-### Fields
-
-These directly correlate with params accepted by Typer `@app.command()`
-
-- `context_settings`: Additional context settings for the command.
-- `epilog`: Text to display after the help message.
-- `short_help`: A short description of the command.
-- `options_metavar`: The metavar to use for options in the help message.
-- `add_help_option`: Whether to add a help option to the command.
-- `no_args_is_help`: Whether to display the help message if no arguments are provided.
-- `hidden`: Whether to hide the command from the help message.
-- `deprecated`: Whether the command is deprecated.
-- `rich_help_panel`: An optional panel name for rich help output.
-
-
-For a deeper dive, check out the [manifest.py](https://github.com/jaykv/cliffy/blob/main/cliffy/manifest.py).
\ No newline at end of file
diff --git a/docs/snippets/click.yaml b/docs/snippets/click.yaml
new file mode 100644
index 0000000..ee4ca7b
--- /dev/null
+++ b/docs/snippets/click.yaml
@@ -0,0 +1,36 @@
+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 always become options
+ params:
+ - name: width
+ type: int
+ default: 800
+ help: Target width
+ run: print(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"
diff --git a/docs/snippets/commands.yaml b/docs/snippets/commands.yaml
new file mode 100644
index 0000000..cf7b0c0
--- /dev/null
+++ b/docs/snippets/commands.yaml
@@ -0,0 +1,115 @@
+name: shop
+version: 0.1.0
+help: Groups, aliases and command options
+
+cli_options:
+ add_completion: false
+ no_args_is_help: true
+
+commands:
+ # --8<-- [start:forms]
+ # 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}!")
+ # --8<-- [end:forms]
+
+ # --8<-- [start:groups]
+ # 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
+ # --8<-- [end:groups]
+
+ # --8<-- [start:aliases]
+ # `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")
+ # --8<-- [end:aliases]
+
+ # --8<-- [start:wildcard]
+ # (*) 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} {(*)}")
+ # --8<-- [end:wildcard]
+
+ # --8<-- [start:hooks]
+ deploy:
+ help: Deploy the shop
+ pre_run: print("1. pre_run")
+ run: print("2. run")
+ post_run: print("3. post_run")
+ # --8<-- [end:hooks]
+
+ # --8<-- [start:config]
+ migrate:
+ help: Run the old migration tool
+ run: print("migrating")
+ config:
+ deprecated: true
+ hidden: true
+ epilog: Use `shop deploy` instead.
+ # --8<-- [end:config]
+
+ # --8<-- [start:docstring]
+ about:
+ run: |
+ """Show what this CLI is for"""
+ print("A demo shop")
+ # --8<-- [end:docstring]
+
+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
diff --git a/docs/snippets/hello.yaml b/docs/snippets/hello.yaml
new file mode 100644
index 0000000..5461ec6
--- /dev/null
+++ b/docs/snippets/hello.yaml
@@ -0,0 +1,21 @@
+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
diff --git a/docs/snippets/includes.yaml b/docs/snippets/includes.yaml
new file mode 100644
index 0000000..73577a4
--- /dev/null
+++ b/docs/snippets/includes.yaml
@@ -0,0 +1,23 @@
+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
diff --git a/docs/snippets/notes-step1.yaml b/docs/snippets/notes-step1.yaml
new file mode 100644
index 0000000..baad99d
--- /dev/null
+++ b/docs/snippets/notes-step1.yaml
@@ -0,0 +1,14 @@
+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"
diff --git a/docs/snippets/notes.yaml b/docs/snippets/notes.yaml
new file mode 100644
index 0000000..3d903c7
--- /dev/null
+++ b/docs/snippets/notes.yaml
@@ -0,0 +1,131 @@
+# --8<-- [start:meta]
+name: notes
+version: 0.1.0
+help: Keep short notes in a JSON file
+# --8<-- [end:meta]
+
+# --8<-- [start:storage]
+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))
+# --8<-- [end:storage]
+
+# --8<-- [start:types]
+types:
+ Text: str = typer.Argument(..., help="Text of the note")
+ Tag: str = typer.Option("general", "--tag", "-t", help="Tag to file the note under")
+# --8<-- [end:types]
+
+commands:
+ # --8<-- [start:add]
+ 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}]")
+ # --8<-- [end:add]
+
+ # --8<-- [start:list]
+ 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']}")
+ # --8<-- [end:list]
+
+ # --8<-- [start:remove]
+ 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}")
+ # --8<-- [end:remove]
+
+ # --8<-- [start:tags]
+ 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}")
+ # --8<-- [end:tags]
+
+ # --8<-- [start:backup]
+ 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")
+ # --8<-- [end:backup]
+
+# --8<-- [start:tests]
+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
+# --8<-- [end:tests]
+
+# --8<-- [start:examples]
+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.
+# --8<-- [end:examples]
diff --git a/docs/snippets/params.yaml b/docs/snippets/params.yaml
new file mode 100644
index 0000000..ef3ef4a
--- /dev/null
+++ b/docs/snippets/params.yaml
@@ -0,0 +1,75 @@
+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:
+ # --8<-- [start:shorthand]
+ 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)
+ # --8<-- [end:shorthand]
+
+ # --8<-- [start:explicit]
+ 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)
+ # --8<-- [end:explicit]
+
+ # --8<-- [start:types]
+ pack:
+ help: Params that reuse the types section
+ params:
+ - source: Source
+ - level: Level
+ run: |
+ print(source, level)
+ # --8<-- [end:types]
+
+ # --8<-- [start:generic]
+ 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)
+ # --8<-- [end:generic]
+
+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"
diff --git a/docs/snippets/shared/common.yaml b/docs/snippets/shared/common.yaml
new file mode 100644
index 0000000..8470173
--- /dev/null
+++ b/docs/snippets/shared/common.yaml
@@ -0,0 +1,28 @@
+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
diff --git a/docs/snippets/shell.yaml b/docs/snippets/shell.yaml
new file mode 100644
index 0000000..ee3a691
--- /dev/null
+++ b/docs/snippets/shell.yaml
@@ -0,0 +1,91 @@
+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:
+ # --8<-- [start:basic]
+ 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"
+ # --8<-- [end:basic]
+
+ # --8<-- [start:interpolate]
+ say:
+ help: Pass a Python value to a shell command
+ params:
+ - message: str!
+ run: $ echo f{message} f{len(message)}
+ # --8<-- [end:interpolate]
+
+ # --8<-- [start:capture]
+ 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())
+ # --8<-- [end:capture]
+
+ # --8<-- [start:pipes]
+ 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
+ # --8<-- [end:pipes]
+
+ # --8<-- [start:shell]
+ texts:
+ help: Glob patterns need a real shell
+ run: "> ls *.txt"
+ # --8<-- [end:shell]
+
+ # --8<-- [start:errors]
+ 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)
+ # --8<-- [end:errors]
+
+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"
+ - sorted: assert result.exit_code == 0
+ - save: 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
diff --git a/docs/snippets/templates.yaml b/docs/snippets/templates.yaml
new file mode 100644
index 0000000..556cc21
--- /dev/null
+++ b/docs/snippets/templates.yaml
@@ -0,0 +1,54 @@
+name: files
+version: 0.1.0
+help: Command templates and global params
+
+types:
+ Verbose: bool = typer.Option(False, "--verbose", "-v", help="Print extra detail")
+
+# --8<-- [start:global]
+global_params:
+ - verbose: Verbose
+# --8<-- [end:global]
+
+# --8<-- [start:template]
+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.
+# --8<-- [end:template]
+
+# --8<-- [start:commands]
+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.
+# --8<-- [end:commands]
+
+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
diff --git a/docs/snippets/testing.yaml b/docs/snippets/testing.yaml
new file mode 100644
index 0000000..1dcb1e3
--- /dev/null
+++ b/docs/snippets/testing.yaml
@@ -0,0 +1,55 @@
+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")
+
+# --8<-- [start:tests]
+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.*"
+# --8<-- [end:tests]
diff --git a/docs/snippets/vars.yaml b/docs/snippets/vars.yaml
new file mode 100644
index 0000000..8ec49a3
--- /dev/null
+++ b/docs/snippets/vars.yaml
@@ -0,0 +1,45 @@
+name: deployer
+version: 0.1.0
+help: Variables and Jinja templating
+
+imports:
+ - import os
+
+# --8<-- [start:vars]
+vars:
+ app: shop
+ # Jinja expression, evaluated when the manifest is loaded
+ region: "{{ env['DEPLOY_REGION'] or 'eu-west-1' }}"
+ # A value in braces is a Python expression instead of a string
+ retries: {"3"}
+ workdir: {os.getcwd()}
+# --8<-- [end:vars]
+
+commands:
+ # --8<-- [start:use]
+ show:
+ help: Print the resolved variables
+ run: |
+ print("app={{ app }} region={{ region }}")
+ print(f"app={app} retries={retries + 1} cwd={workdir == os.getcwd()}")
+ # --8<-- [end:use]
+
+ # --8<-- [start:loop]
+ # {% for stage in ['staging', 'production'] %}
+ deploy.{{ stage }}:
+ help: Deploy {{ app }} to {{ stage }}
+ run: print("deploying {{ app }} to {{ stage }} in {{ region }}")
+ # {% endfor %}
+ # --8<-- [end:loop]
+
+ # --8<-- [start:raw]
+ braces:
+ help: Print literal double braces
+ run: print({% raw %}"{{ not a variable }}"{% endraw %})
+ # --8<-- [end:raw]
+
+tests:
+ - show: assert result.output == "app=shop region=eu-west-1\napp=shop retries=4 cwd=True\n"
+ - 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
diff --git a/mkdocs.yaml b/mkdocs.yaml
index e31a538..07f4be0 100644
--- a/mkdocs.yaml
+++ b/mkdocs.yaml
@@ -1,22 +1,64 @@
-site_name: ""
+site_name: cliffy
+site_description: Build Python CLIs from YAML manifests
+site_url: https://jaykv.github.io/cliffy/
+repo_url: https://github.com/jaykv/cliffy
+repo_name: jaykv/cliffy
+edit_uri: edit/main/docs/
+
+# Warnings fail the build, locally and in the deploy workflow.
+strict: true
+validation:
+ omitted_files: warn
+ absolute_links: warn
+ unrecognized_links: warn
+ anchors: warn
+
nav:
- - Cliffy: index.md
- - Getting Started: getting-started.md
- - Features: features.md
- - Blueprints: blueprints.md
- - Schema: schema.md
+ - Home: index.md
+ - Get started:
+ - Installation: installation.md
+ - Quickstart: quickstart.md
+ - Guides:
+ - Manifest basics: guides/manifest-basics.md
+ - Params and types: guides/params.md
+ - Commands, groups and aliases: guides/commands.md
+ - Command templates and global params: guides/templates-and-global-params.md
+ - Includes and merging: guides/includes.md
+ - Vars and Jinja: guides/vars-and-jinja.md
+ - Shell commands: guides/shell-commands.md
+ - Click and Typer backends: guides/backends.md
+ - Testing manifests: guides/testing.md
+ - Hot-reload dev loop: guides/dev-loop.md
+ - Building executables: guides/building.md
+ - Generating docs: guides/generating-docs.md
+ - Editor setup: guides/editor-setup.md
+ - Blueprints and callbacks: blueprints.md
+ - AI commands: ai.md
+ - Reference:
+ - CLI reference: reference/cli.md
+ - Manifest reference: reference/manifest.md
+ - Examples gallery: reference/examples.md
+ - Project:
+ - Changelog: project/changelog.md
+ - Contributing: project/contributing.md
+ - Migration notes: project/migration.md
+ - Known issues: project/known-issues.md
+
theme:
name: material
icon:
logo: fontawesome/solid/mountain
+ repo: fontawesome/brands/github
palette:
- - scheme: default
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
primary: brown
accent: indigo
toggle:
- icon: material/brightness-7
+ icon: material/brightness-7
name: Switch to dark mode
- - scheme: slate
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
primary: blue grey
accent: indigo
toggle:
@@ -26,17 +68,29 @@ theme:
text: Roboto
code: Roboto Mono
features:
+ - content.action.edit
- content.code.copy
+ - navigation.footer
+ - navigation.sections
+ - navigation.tabs
- navigation.top
- - navigation.indexes
+ - search.highlight
+ - search.suggest
+ - toc.follow
+
plugins:
- search
- - literate-nav
+ # Looks up page contributors through the GitHub API, so it only runs in CI.
- git-committers:
repository: jaykv/cliffy
branch: main
+ enabled: !ENV [CI, false]
+
markdown_extensions:
- admonition
+ - attr_list
+ - md_in_html
+ - tables
- pymdownx.details
- pymdownx.superfences
- pymdownx.highlight:
@@ -44,8 +98,16 @@ markdown_extensions:
line_spans: __span
pygments_lang_class: true
- pymdownx.inlinehilite
- - pymdownx.snippets
- - pymdownx.tabbed
+ # Guides embed the manifests in docs/snippets and examples/ instead of copying them.
+ # `make docs-check` runs those manifests, so the pages cannot show YAML that does not work.
+ - pymdownx.snippets:
+ base_path: ["."]
+ check_paths: true
+ - pymdownx.tabbed:
+ alternate_style: true
- toc:
permalink: true
-repo_url: https://github.com/jaykv/cliffy
\ No newline at end of file
+
+watch:
+ - examples
+ - scripts/docs.py
diff --git a/scripts/docs.py b/scripts/docs.py
new file mode 100644
index 0000000..2761516
--- /dev/null
+++ b/scripts/docs.py
@@ -0,0 +1,944 @@
+"""Generate and check the documentation pages that are derived from code.
+
+Three reference pages are generated so that they cannot drift from the code:
+
+* ``docs/reference/cli.md`` from the Click command tree in ``cliffy.cli``
+* ``docs/reference/manifest.md`` from the pydantic models in ``cliffy.manifest``
+* ``docs/reference/examples.md`` from the manifests in ``examples/``
+
+Usage (from the repository root, with all extras installed)::
+
+ uv run python scripts/docs.py generate # rewrite the generated pages
+ uv run python scripts/docs.py check # fail if they are stale, then test every docs snippet
+
+``check`` also loads every manifest in ``docs/snippets``, compiles the code cliffy generates for it and
+runs its ``tests`` section with ``cli test``, so the manifests shown in the guides are known to work.
+"""
+
+from __future__ import annotations
+
+import argparse
+import difflib
+import inspect
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+import types
+import typing
+from pathlib import Path
+from typing import Any
+
+import click
+import yaml
+from pydantic import BaseModel, RootModel
+from pydantic.fields import FieldInfo
+
+from cliffy import manifest as manifest_module
+from cliffy.cli import ALIASES
+from cliffy.cli import cli as cliffy_cli
+from cliffy.manifest import (
+ Callback,
+ CLIManifest,
+ Command,
+ CommandConfig,
+ CommandParam,
+ CommandTemplate,
+ Example,
+ IncludeManifest,
+)
+from cliffy.transformer import Transformer
+
+ROOT = Path(__file__).resolve().parent.parent
+DOCS = ROOT / "docs"
+SNIPPETS = DOCS / "snippets"
+EXAMPLES = ROOT / "examples"
+
+HEADER = """
+"""
+
+
+# --------------------------------------------------------------------------------------------------
+# CLI reference
+# --------------------------------------------------------------------------------------------------
+
+# Click arguments carry no help text, so each one is described here. Generation fails when a command
+# gains an argument that is missing from this table.
+ARGUMENT_HELP: dict[tuple[str, str], str] = {
+ ("load", "manifests"): "One or more manifest files to load.",
+ ("update", "cli_names"): "Names of loaded CLIs to regenerate from their manifests.",
+ ("render", "manifest"): "Manifest file to render.",
+ ("run", "manifest"): "Manifest file to run.",
+ ("run", "cli_args"): "Arguments passed to the generated CLI. Put `--` first when they include options.",
+ ("init", "cli_name"): "Name of the CLI. The manifest is written to `.yaml`.",
+ ("remove", "cli_names"): "Names of loaded CLIs to remove.",
+ ("build", "cli_or_manifests"): "Manifest files (paths ending in `yaml`) or names of loaded CLIs.",
+ ("info", "cli_name"): "Name of a loaded CLI.",
+ ("dev", "manifest"): "Manifest file to watch.",
+ ("dev", "run_cli_args"): "Arguments for the generated CLI when `--run-cli` is set.",
+ ("test", "manifest"): "Manifest file whose `tests` section should run.",
+ ("validate", "manifest"): "Manifest file to validate.",
+ ("docs", "cli_or_manifest"): "Manifest file (a path ending in `yaml`) or the name of a loaded CLI.",
+ ("blueprint schema", "blueprint"): "Blueprint reference: a `.py` file, `module:Class` or the name of an "
+ "installed blueprint.",
+ ("ai generate", "cli_name"): "Name of the CLI. The manifest is written to `.yaml`.",
+ ("ai generate", "description"): "Plain-language description of the CLI you want.",
+ ("ai ask", "prompt"): "Your question.",
+}
+
+# Options whose declaration has no help text.
+OPTION_HELP: dict[tuple[str, str], str] = {
+ ("docs", "format"): "Output format.",
+}
+
+# Hand-written notes and examples shown under each command. Every statement here was checked by
+# running the command.
+COMMAND_NOTES: dict[str, str] = {
+ "load": """
+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).
+""",
+ "update": """
+`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
+```
+""",
+ "render": """
+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
+```
+""",
+ "run": """
+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.
+""",
+ "init": """
+```console
+$ cli init mycli --raw
++ mycli.yaml
+```
+
+`--raw` writes a skeleton with every top-level key and no content. Without `--raw` the manifest is a
+commented tour of the format with example commands.
+
+`--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).
+""",
+ "list": """
+```console
+$ cli list
+โโโโโโโโโณโโโโโโโโโโณโโโโโโณโโโโโโโโโโโโโโโโโโโโโโโโโโ
+โ Name โ Version โ Age โ Manifest โ
+โกโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฉ
+โ hello โ 0.1.0 โ 5s โ /home/ada/hello.yaml โ
+โโโโโโโโโดโโโโโโโโโโดโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโ
+```
+""",
+ "remove": """
+Deletes the launcher script, the generated module and the stored metadata.
+
+```console
+$ cli remove hello
+~ hello removed ๐ฅ
+```
+""",
+ "remove-all": "",
+ "build": """
+Builds a single-file [zipapp](https://docs.python.org/3/library/zipapp.html) with
+[shiv](https://shiv.readthedocs.io/). The file contains the generated CLI, the framework it imports
+(Typer, or rich-click with `use_click`) and every package listed under `requires`. Without
+`--output-dir` it is written to the current directory.
+
+```console
+$ cli build hello.yaml -o dist
++ hello built ๐ฆ
+ dist/hello (single file, needs Python >=3.10)
+$ ./dist/hello greet Ada
+Hello, Ada!
+```
+
+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).
+""",
+ "info": """
+Prints the name, version, requirements, blueprint (when one was used), age and stored manifest of a
+loaded CLI.
+""",
+ "dev": """
+Nothing is loaded when the command starts. The CLI is loaded the first time the file changes and
+again on every later change. 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).
+""",
+ "test": """
+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).
+""",
+ "validate": """
+Checks that the file is valid YAML, matches the manifest schema and that its `requires` are installed.
+It does not check that the generated Python is valid; run `cli test` or `cli run` for that.
+
+```console
+$ cli validate hello.yaml
+Manifest hello.yaml is valid
+```
+""",
+ "docs": """
+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).
+""",
+ "ai": """
+Only available when the `ai` extra is installed (`pip install "cliffy[ai]"`). Both commands send the
+manifest JSON schema to the model as part of the prompt and print token usage when they finish.
+
+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).
+""",
+ "ai generate": """
+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.
+""",
+ "blueprint": """
+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).
+""",
+ "blueprint list": """
+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.
+```
+""",
+ "blueprint schema": """
+```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.
+""",
+ "ai ask": """
+`--preview` prints the prompt and exits without calling a model.
+""",
+}
+
+
+def _load_cli() -> click.Group:
+ """Return the ``cli`` group with the ``ai`` commands attached, as ``cliffy.run`` does."""
+ try:
+ from cliffy.ai import ai
+ except ImportError as e: # pragma: no cover - depends on the environment
+ raise SystemExit(f"The reference is generated with all extras installed (uv sync --all-extras): {e}") from e
+
+ if "ai" not in cliffy_cli.commands:
+ cliffy_cli.add_command(ai)
+ return cliffy_cli
+
+
+def _escape_cell(text: str) -> str:
+ return " ".join(str(text).split()).replace("|", "\\|")
+
+
+def _param_type(param: click.Parameter) -> str:
+ if isinstance(param, click.Option) and param.is_flag:
+ return "flag"
+ if isinstance(param.type, click.Choice):
+ return " \\| ".join(f"`{choice}`" for choice in param.type.choices)
+ names = {"text": "text", "integer": "integer", "filename": "file", "directory": "directory", "path": "path"}
+ return names.get(param.type.name, param.type.name)
+
+
+def _param_default(param: click.Parameter, ctx: click.Context) -> str:
+ if isinstance(param, click.Option) and param.is_flag:
+ return ""
+ default = param.get_default(ctx)
+ if default is None or default == () or type(default).__name__ in {"Sentinel", "_Unset"}:
+ return ""
+ if isinstance(default, Path):
+ return "`.`" if default == Path() else f"`{default}`"
+ return f"`{default}`"
+
+
+def _render_command(path: str, command: click.Command, parent: click.Context, level: int) -> list[str]:
+ ctx = click.Context(command, info_name=path.split()[-1], parent=parent)
+ lines = [f"{'#' * level} `cli {path}`", ""]
+
+ help_text = inspect.cleandoc(command.help or "")
+ if help_text:
+ lines += [help_text, ""]
+
+ usage = " ".join(["cli", path, *command.collect_usage_pieces(ctx)])
+ lines += ["```text", usage, "```", ""]
+
+ aliases = sorted(alias for alias, target in ALIASES.items() if target == path)
+ if aliases:
+ label = "Alias" if len(aliases) == 1 else "Aliases"
+ lines += [f"{label}: " + ", ".join(f"`cli {alias}`" for alias in aliases), ""]
+
+ rows = []
+ for param in command.get_params(ctx):
+ if isinstance(param, click.Argument):
+ assert param.name is not None
+ key = (path, param.name)
+ if key not in ARGUMENT_HELP:
+ raise SystemExit(f"scripts/docs.py: add a description for argument {key} to ARGUMENT_HELP")
+ name = f"`{param.make_metavar(ctx)}`"
+ required = "yes" if param.required else "no"
+ rows.append((name, _param_type(param), required, _param_default(param, ctx), ARGUMENT_HELP[key]))
+ elif isinstance(param, click.Option):
+ if param.name == "help":
+ continue
+ assert param.name is not None
+ help_text = param.help or OPTION_HELP.get((path, param.name), "")
+ if not help_text and not isinstance(command, click.Group):
+ raise SystemExit(f"scripts/docs.py: add a description for option {(path, param.name)} to OPTION_HELP")
+ name = ", ".join(f"`{opt}`" for opt in sorted(param.opts, key=len))
+ required = "yes" if param.required else "no"
+ rows.append((name, _param_type(param), required, _param_default(param, ctx), help_text))
+
+ rows.sort(key=lambda row: row[0].startswith("`-"))
+ if rows:
+ lines += ["| Argument or option | Type | Required | Default | Description |", "|---|---|---|---|---|"]
+ lines += [" | ".join(["| " + row[0], *row[1:4], _escape_cell(row[4]) + " |"]) for row in rows]
+ lines.append("")
+
+ if path not in COMMAND_NOTES:
+ raise SystemExit(f"scripts/docs.py: add an entry for command {path!r} to COMMAND_NOTES")
+ notes = COMMAND_NOTES[path].strip()
+ if notes:
+ lines += [notes, ""]
+
+ if isinstance(command, click.Group):
+ for name in sorted(command.commands):
+ sub = command.commands[name]
+ if not sub.hidden:
+ lines += _render_command(f"{path} {name}", sub, ctx, level + 1)
+ return lines
+
+
+def render_cli_reference() -> str:
+ group = _load_cli()
+ root = click.Context(group, info_name="cli")
+ visible = {name: cmd for name, cmd in sorted(group.commands.items()) if not cmd.hidden}
+
+ lines = [
+ HEADER,
+ "# 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 |",
+ "|---|---|",
+ ]
+ for name, cmd in visible.items():
+ summary = inspect.cleandoc(cmd.help or "").split("\n")[0]
+ anchor = f"cli-{name}"
+ lines.append(f"| [`cli {name}`](#{anchor}) | {_escape_cell(summary)} |")
+ lines += [
+ "",
+ "## 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 |",
+ "|---|---|",
+ ]
+ lines += [
+ f"| `cli {alias}` | [`cli {target}`](#cli-{target.replace(' ', '-')}) |" for alias, target in ALIASES.items()
+ ]
+ lines.append("")
+
+ for name, cmd in visible.items():
+ lines += _render_command(name, cmd, root, 2)
+ return "\n".join(lines).rstrip() + "\n"
+
+
+# --------------------------------------------------------------------------------------------------
+# Manifest reference
+# --------------------------------------------------------------------------------------------------
+
+# Descriptions for fields that have none in the model.
+FIELD_FALLBACK: dict[tuple[str, str], str] = {
+ ("CLIManifest", "manifestVersion"): "Manifest schema version. `v3` is the only version.",
+ ("Command", "run"): "The script to run. A string, or a list of strings that are joined with newlines. "
+ "Lines starting with `$` or `>` are shell commands.",
+ ("Example", "command"): "The command line to show.",
+ ("Example", "description"): "A sentence describing what the command does.",
+ ("IncludeManifest", "requires"): "Merged into the `requires` of the including manifest.",
+ ("IncludeManifest", "commands"): "Merged into the `commands` of the including manifest.",
+ ("IncludeManifest", "vars"): "Merged into `vars`. Available as Python variables, not as `{{ }}` placeholders.",
+ ("IncludeManifest", "imports"): "Merged into `imports`.",
+ ("IncludeManifest", "functions"): "Merged into `functions`. Must be a list here.",
+ ("IncludeManifest", "types"): "Merged into `types`.",
+ ("IncludeManifest", "cli_options"): "Merged into `cli_options`.",
+ ("IncludeManifest", "tests"): "Appended to `tests`.",
+}
+
+# Replacements for model descriptions that do not match what the code does. The model text also feeds
+# the JSON schema and the `cli init` template, so it is corrected here instead of in cliffy/manifest.py.
+FIELD_OVERRIDES: dict[tuple[str, str], str] = {
+ ("CLIManifest", "requires"): "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`.",
+ ("CLIManifest", "includes"): "Paths of other manifest files to merge into this one. Keys set in this manifest "
+ "win over included ones; lists are combined.",
+ ("CLIManifest", "vars"): "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. Values are "
+ "Jinja2 templates; `env` gives access to environment variables.",
+ ("CLIManifest", "commands"): "The commands of the CLI, keyed by name. In a key, `.` separates groups, `|` "
+ "separates aliases and a leading `(*)` adds the command to every group.",
+ ("Command", "params"): "The parameters of the command. See [Params](#params) for the three accepted forms.",
+ ("CommandConfig", "context_settings"): "Settings passed to the Click context of the command, for example "
+ "`help_option_names` or `max_content_width`.",
+ ("CommandConfig", "rich_help_panel"): "Name of the panel this command is listed under in rich help output.",
+}
+
+# Hand-written notes shown under each generated table. Every statement was checked by running it.
+MODEL_NOTES: dict[str, str] = {
+ "CLIManifest": """
+- `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` is accepted and ignored when a manifest file is loaded.
+- `vars` values must be strings (quote numbers) or a one-key mapping such as `{"3"}` or `{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": """
+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.
+""",
+ "CommandParam": """
+This is the explicit form. `short` is written with its dash (`-w`). A `str` default is quoted for you;
+other defaults are emitted as written.
+""",
+ "Callback": """
+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).
+""",
+ "CommandTemplate": """
+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": """
+Only the keys you set are passed on, as keyword arguments of Typer's `command()`. The Click backend
+ignores `config`.
+""",
+ "Example": """
+`examples` entries are only used by [`cli docs`](../guides/generating-docs.md). An entry can also be a
+plain string, which is shown without a description.
+""",
+ "IncludeManifest": """
+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).
+""",
+}
+
+PARAM_SECTION = """
+## 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 full Typer 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.
+"""
+
+DOCUMENTED_MODELS: list[type[BaseModel]] = [
+ CLIManifest,
+ Command,
+ CommandParam,
+ CommandTemplate,
+ CommandConfig,
+ Callback,
+ Example,
+ IncludeManifest,
+]
+
+# Module-level type aliases and root models, rendered by name instead of by structure.
+NAMED_TYPES: dict[str, str] = {
+ "ParamBlock": "[param](#params)",
+ "CommandBlock": "[Command](#command), string or list of strings",
+ "VarBlock": "string or `{expression}`",
+ "RunBlock": "string",
+ "PreRunBlock": "string",
+ "PostRunBlock": "string",
+ "RunBlockList": "list of strings",
+ "SimpleCommandParam": "shorthand param",
+ "GenericCommandParam": "string",
+}
+BUILTIN_NAMES = {str: "string", int: "integer", float: "number", bool: "boolean", type(None): "null"}
+
+
+def _alias_name(annotation: Any) -> str | None:
+ for name in ("ParamBlock", "CommandBlock", "VarBlock"):
+ if annotation == getattr(manifest_module, name):
+ return name
+ return None
+
+
+def _plural(name: str) -> str:
+ return {"string": "strings", "[param](#params)": "[params](#params)"}.get(name, name)
+
+
+def _group(name: str) -> str:
+ """Parenthesize a compound type so that it reads unambiguously inside a list or mapping."""
+ return f"({name})" if " or " in name or "," in name else name
+
+
+def _format_type(annotation: Any) -> str:
+ if (alias := _alias_name(annotation)) is not None:
+ return NAMED_TYPES[alias]
+ if annotation is Any:
+ return "any"
+ if annotation in BUILTIN_NAMES:
+ return BUILTIN_NAMES[annotation]
+ if inspect.isclass(annotation) and issubclass(annotation, RootModel):
+ return NAMED_TYPES[annotation.__name__]
+ if inspect.isclass(annotation) and issubclass(annotation, BaseModel):
+ return f"[{annotation.__name__}](#{annotation.__name__.lower()})"
+
+ origin = typing.get_origin(annotation)
+ args = typing.get_args(annotation)
+ if origin in (typing.Union, types.UnionType):
+ parts: list[str] = []
+ for arg in args:
+ formatted = _format_type(arg)
+ if formatted not in parts:
+ parts.append(formatted)
+ return " or ".join(parts)
+ if origin is list:
+ return f"list of {_group(_plural(_format_type(args[0])))}"
+ if origin is dict:
+ value = _format_type(args[1])
+ return "mapping" if value == "any" else f"mapping of name to {_group(value)}"
+ raise SystemExit(f"scripts/docs.py: cannot describe type {annotation!r}")
+
+
+def _format_default(field: FieldInfo) -> str:
+ if field.is_required():
+ return "**required**"
+ default = field.default_factory() if field.default_factory is not None else field.default # type: ignore[call-arg]
+ if isinstance(default, RootModel):
+ default = default.root
+ if default is None:
+ return "`null`"
+ if isinstance(default, bool):
+ return f"`{str(default).lower()}`"
+ if isinstance(default, str):
+ return f'`"{default}"`'
+ return f"`{default}`"
+
+
+def _render_model(model: type[BaseModel], title: str) -> list[str]:
+ lines = [f"## {title}", ""]
+ doc = inspect.cleandoc(model.__doc__ or "")
+ if model is CLIManifest:
+ doc = "The top level of a manifest file."
+ if doc:
+ lines += [" ".join(doc.split()), ""]
+
+ lines += ["| Key | Type | Default | Description |", "|---|---|---|---|"]
+ for name, field in model.model_fields.items():
+ key = (model.__name__, name)
+ description = FIELD_OVERRIDES.get(key) or field.description or FIELD_FALLBACK.get(key)
+ if not description:
+ raise SystemExit(f"scripts/docs.py: add a description for {model.__name__}.{name} to FIELD_FALLBACK")
+ lines.append(
+ f"| `{name}` | {_escape_cell(_format_type(field.annotation))} | {_format_default(field)} "
+ f"| {_escape_cell(description)} |"
+ )
+ lines.append("")
+
+ notes = MODEL_NOTES[model.__name__].strip()
+ if notes:
+ lines += [notes, ""]
+ return lines
+
+
+def render_manifest_reference() -> str:
+ lines = [
+ HEADER,
+ "# 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.",
+ "",
+ ]
+ lines += _render_model(CLIManifest, "Top-level keys")
+ lines += _render_model(Command, "Command")
+ lines += [*PARAM_SECTION.strip().split("\n"), ""]
+ lines += _render_model(CommandParam, "CommandParam")
+ lines += _render_model(CommandTemplate, "CommandTemplate")
+ lines += _render_model(CommandConfig, "CommandConfig")
+ lines += _render_model(Callback, "Callback")
+ lines += _render_model(Example, "Example")
+ lines += _render_model(IncludeManifest, "Include files")
+ return "\n".join(lines).rstrip() + "\n"
+
+
+# --------------------------------------------------------------------------------------------------
+# Examples gallery
+# --------------------------------------------------------------------------------------------------
+
+# What each example demonstrates and a command line that works. Generation fails when a manifest is
+# added to or removed from examples/ without updating this table.
+EXAMPLE_NOTES: dict[str, tuple[str, str]] = {
+ "hello.yaml": (
+ "The smallest useful manifest: one shell command, one Python command and a `tests` section.",
+ "cli run examples/hello.yaml python",
+ ),
+ "nested-cli.yaml": (
+ "Command groups nested two levels deep with dotted keys.",
+ "cli run examples/nested-cli.yaml group1 subgroup command1",
+ ),
+ "town.yaml": (
+ "A larger CLI: groups with help text, shared `types`, short option aliases, command aliases and a "
+ "`(*).list` command that is added to every group.",
+ "cli run examples/town.yaml land list",
+ ),
+ "template.yaml": (
+ "A command template that gives two commands the same params, a run script written as a list, "
+ "and per-command `config`.",
+ "cli run examples/template.yaml -- debug one --local-arg-2 two",
+ ),
+ "environ.yaml": (
+ "Environment variables read when the manifest is loaded (`vars` with `env`) and when a command runs.",
+ "cli run examples/environ.yaml hello",
+ ),
+ "db.yaml": (
+ "Prompting options defined in `types`, YAML anchors to share params, `pre_run`, command aliases "
+ "and `cli_options`.",
+ "cli run examples/db.yaml -- mk --name demo",
+ ),
+ "penv.yaml": (
+ "A virtualenv manager: `vars` holding Python expressions, helper `functions` and shell lines that "
+ "interpolate Python values with `f{...}`.",
+ "cli run examples/penv.yaml -- --help",
+ ),
+ "pydev.yaml": (
+ "Developer chores as run-script lists that mix shell and Python lines, with helper functions.",
+ "cli run examples/pydev.yaml -- --help",
+ ),
+ "taskmaster.yaml": (
+ "A task manager with JSON storage, a `vars` placeholder used inside functions, shared `types`, "
+ "nested groups and a full `tests` section.",
+ "cli test examples/taskmaster.yaml",
+ ),
+ "todo.yaml": (
+ "A sqlite-backed todo list that depends on `tabulate` and `rich`.",
+ "cli run examples/todo.yaml -- --help",
+ ),
+ "requires.yaml": (
+ "Version specifiers in `requires`. Loading fails on purpose unless `requests>=2.30.0` and `six<1.0.0` "
+ "are installed, which is what the test suite checks.",
+ "cli validate examples/requires.yaml",
+ ),
+ "callbacks.yaml": (
+ "Callbacks: code that runs before every command of the CLI or of one group, with params that "
+ "become options of the CLI itself and access to the context as `ctx`.",
+ "cli run examples/callbacks.yaml -- --verbose db migrate",
+ ),
+ "click_hello.yaml": (
+ "The Click backend (`use_click: true`) with a command alias. Needs the `rich` extra.",
+ "cli run examples/click_hello.yaml hello",
+ ),
+ "aiprompt.yaml": (
+ "An OpenAI client written by an LLM: explicit params with help text, `global_params` and nested "
+ "groups. Needs `openai`, `rich` and `keyring`.",
+ "cli render examples/aiprompt.yaml",
+ ),
+}
+
+
+def render_examples_gallery() -> str:
+ found = sorted(path.name for path in EXAMPLES.glob("*.yaml"))
+ if set(found) != set(EXAMPLE_NOTES):
+ missing = sorted(set(found) - set(EXAMPLE_NOTES))
+ extra = sorted(set(EXAMPLE_NOTES) - set(found))
+ raise SystemExit(f"scripts/docs.py: EXAMPLE_NOTES is out of date (missing {missing}, unknown {extra})")
+
+ lines = [
+ HEADER,
+ "# Examples gallery",
+ "",
+ "The manifests in the [`examples/`](https://github.com/jaykv/cliffy/tree/main/examples) directory of the",
+ "repository. Each entry shows the file as it is in the repository. The code cliffy generates for them is",
+ "committed next to them in",
+ "[`examples/generated/`](https://github.com/jaykv/cliffy/tree/main/examples/generated).",
+ "",
+ "To try one, clone the repository and run the command shown from its root.",
+ "",
+ "Files written in a custom spec, and the blueprints that turn them into CLIs, are in",
+ "[`examples/blueprints/`](https://github.com/jaykv/cliffy/tree/main/examples/blueprints) and",
+ "described in [Blueprints](../blueprints.md#examples).",
+ "",
+ "| Example | Backend | Requires | Has tests |",
+ "|---|---|---|---|",
+ ]
+ loaded: dict[str, dict[str, Any]] = {}
+ for name in EXAMPLE_NOTES:
+ data = yaml.safe_load((EXAMPLES / name).read_text())
+ loaded[name] = data
+ requires = ", ".join(f"`{req}`" for req in data.get("requires", [])) or "nothing"
+ backend = "Click" if data.get("use_click") else "Typer"
+ tests = "yes" if data.get("tests") else "no"
+ lines.append(f"| [{name}](#{data['name']}) | {backend} | {_escape_cell(requires)} | {tests} |")
+ lines.append("")
+
+ for name, (blurb, command) in EXAMPLE_NOTES.items():
+ data = loaded[name]
+ lines += [
+ f"## {data['name']}",
+ "",
+ blurb,
+ "",
+ "```console",
+ f"$ {command}",
+ "```",
+ "",
+ f'??? example "examples/{name}"',
+ "",
+ " ```yaml",
+ f' --8<-- "examples/{name}"',
+ " ```",
+ "",
+ ]
+ return "\n".join(lines).rstrip() + "\n"
+
+
+# --------------------------------------------------------------------------------------------------
+# Commands
+# --------------------------------------------------------------------------------------------------
+
+GENERATED = {
+ DOCS / "reference" / "cli.md": render_cli_reference,
+ DOCS / "reference" / "manifest.md": render_manifest_reference,
+ DOCS / "reference" / "examples.md": render_examples_gallery,
+}
+
+SNIPPET_REFERENCE = re.compile(r'--8<--\s+"([^":]+)(?::[^"]*)?"')
+
+
+def generate() -> int:
+ for path, render in GENERATED.items():
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(render())
+ print(f"wrote {path.relative_to(ROOT)}")
+ return 0
+
+
+def check_generated() -> list[str]:
+ errors = []
+ for path, render in GENERATED.items():
+ expected = render()
+ actual = path.read_text() if path.exists() else ""
+ if actual != expected:
+ diff = "".join(
+ difflib.unified_diff(
+ actual.splitlines(keepends=True),
+ expected.splitlines(keepends=True),
+ fromfile=f"{path.relative_to(ROOT)} (committed)",
+ tofile=f"{path.relative_to(ROOT)} (generated)",
+ n=1,
+ )
+ )
+ errors.append(f"{path.relative_to(ROOT)} is stale, run `make docs-generate`\n{diff}")
+ else:
+ print(f"ok {path.relative_to(ROOT)} is up to date")
+ return errors
+
+
+def check_snippet_references() -> list[str]:
+ """Every file pulled into a page with ``--8<--`` must exist."""
+ errors = []
+ for page in sorted(DOCS.rglob("*.md")):
+ for reference in SNIPPET_REFERENCE.findall(page.read_text()):
+ if not (ROOT / reference).is_file():
+ errors.append(f"{page.relative_to(ROOT)} includes missing file {reference}")
+ return errors
+
+
+def check_readme() -> list[str]:
+ """The manifest in README.md is a copy, so make sure it is still the start of the tested hello snippet."""
+ match = re.search(r"```yaml\n# hello\.yaml\n(.*?)```", (ROOT / "README.md").read_text(), re.DOTALL)
+ if not match:
+ return ["README.md: the hello.yaml example block was not found"]
+ if not (SNIPPETS / "hello.yaml").read_text().startswith(match.group(1)):
+ return ["README.md: the hello.yaml example differs from docs/snippets/hello.yaml"]
+ print("ok README.md example matches docs/snippets/hello.yaml")
+ return []
+
+
+def check_snippets() -> list[str]:
+ """Load, compile and test every manifest in docs/snippets from a scratch copy of that directory."""
+ errors = []
+ env = {**os.environ, "NO_COLOR": "1", "PYTHONWARNINGS": "ignore"}
+ with tempfile.TemporaryDirectory() as tmp:
+ workdir = Path(tmp) / "snippets"
+ shutil.copytree(SNIPPETS, workdir)
+ previous = Path.cwd()
+ os.chdir(workdir)
+ try:
+ for path in sorted(workdir.glob("*.yaml")):
+ name = f"docs/snippets/{path.name}"
+ try:
+ with path.open() as manifest_io:
+ transformer = Transformer(manifest_io)
+ compile(transformer.cli.code, path.name, "exec")
+ except (Exception, SystemExit) as e:
+ errors.append(f"{name} does not load: {e!r}")
+ continue
+
+ if not transformer.manifest.tests:
+ print(f"ok {name} loads and compiles (no tests section)")
+ continue
+
+ result = subprocess.run(
+ [sys.executable, "-m", "cliffy", "test", path.name],
+ capture_output=True,
+ encoding="utf-8",
+ env=env,
+ )
+ cases = sum(isinstance(test, dict) for test in transformer.manifest.tests)
+ if result.returncode != 0 or "All tests passed" not in result.stdout:
+ errors.append(f"{name} fails its tests:\n{result.stdout}\n{result.stderr}")
+ else:
+ print(f"ok {name} passes its {cases} tests")
+ finally:
+ os.chdir(previous)
+ return errors
+
+
+def check() -> int:
+ errors = check_generated() + check_snippet_references() + check_readme() + check_snippets()
+ for error in errors:
+ print(f"FAIL {error}", file=sys.stderr)
+ if errors:
+ print(f"\n{len(errors)} problem(s) found", file=sys.stderr)
+ return 1
+ print("\ndocs are in sync with the code")
+ return 0
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
+ parser.add_argument("command", choices=["generate", "check"])
+ args = parser.parse_args()
+ return generate() if args.command == "generate" else check()
+
+
+if __name__ == "__main__":
+ sys.exit(main())