From 227ad851b68803c10f893551e195d1cce83b6d79 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 06:16:32 -0400 Subject: [PATCH 1/7] Add tested manifests for the documentation to embed Every manifest shown in the new guides lives in docs/snippets and carries its own tests section, so the pages can include it instead of copying YAML that nobody runs. Co-Authored-By: Claude Opus 5.5 --- docs/features.md | 255 ------------------------------- docs/getting-started.md | 110 ------------- docs/schema.md | 100 ------------ docs/snippets/click.yaml | 40 +++++ docs/snippets/commands.yaml | 115 ++++++++++++++ docs/snippets/hello.yaml | 21 +++ docs/snippets/includes.yaml | 23 +++ docs/snippets/notes-step1.yaml | 14 ++ docs/snippets/notes.yaml | 131 ++++++++++++++++ docs/snippets/params.yaml | 75 +++++++++ docs/snippets/shared/common.yaml | 28 ++++ docs/snippets/shell.yaml | 91 +++++++++++ docs/snippets/templates.yaml | 54 +++++++ docs/snippets/testing.yaml | 55 +++++++ docs/snippets/vars.yaml | 45 ++++++ 15 files changed, 692 insertions(+), 465 deletions(-) delete mode 100644 docs/features.md delete mode 100644 docs/getting-started.md delete mode 100644 docs/schema.md create mode 100644 docs/snippets/click.yaml create mode 100644 docs/snippets/commands.yaml create mode 100644 docs/snippets/hello.yaml create mode 100644 docs/snippets/includes.yaml create mode 100644 docs/snippets/notes-step1.yaml create mode 100644 docs/snippets/notes.yaml create mode 100644 docs/snippets/params.yaml create mode 100644 docs/snippets/shared/common.yaml create mode 100644 docs/snippets/shell.yaml create mode 100644 docs/snippets/templates.yaml create mode 100644 docs/snippets/testing.yaml create mode 100644 docs/snippets/vars.yaml diff --git a/docs/features.md b/docs/features.md deleted file mode 100644 index 0339c01..0000000 --- a/docs/features.md +++ /dev/null @@ -1,255 +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 `cliffy[ai]` dependency group to be installed. See [Installation](getting-started.md#installation) - -!!! tip - For a list of the models supported, see [https://ai.pydantic.dev/models/](https://ai.pydantic.dev/models/). You can specify the model to use for the AI commands with `--model` or `-m` option. - - ```bash - # Example using a specific model - export OPENAI_API_KEY='your-api-key' - cli ai generate mycli "Create a todo app" --model gpt-4o - ``` - -!!! tip - Use `--preview` to output the fully constructed prompt for debugging or review. - -### 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" -``` - -### Interactive Help - -Get AI assistance for understanding and working with cliffy: - -```bash -cli ai ask "How do I create nested subcommands?" -``` - -You can also get targeted help about specific CLIs by referencing them: - -```bash -cli ai ask --cli mycli.yaml "How do I add input validation to the start command?" -``` 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/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..a51f637 --- /dev/null +++ b/docs/snippets/click.yaml @@ -0,0 +1,40 @@ +name: clicky +version: 0.1.0 +help: The same manifest format, rendered with Click +use_click: true + +# The generated code imports rich_click, so builds must bundle it +requires: + - rich-click + +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 From 04d9563a1995f0e48a4e03e7be75a60f020c7887 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 06:16:32 -0400 Subject: [PATCH 2/7] Generate the CLI, manifest and examples reference pages scripts/docs.py renders docs/reference/cli.md from the click command tree, manifest.md from the pydantic models and examples.md from examples/*.yaml. Its check mode fails when those pages are stale, and loads, compiles and tests every manifest in docs/snippets. New Makefile targets: docs-generate, docs-check, docs-build. Co-Authored-By: Claude Opus 5.5 --- Makefile | 14 +- docs/reference/cli.md | 411 +++++++++++++++++ docs/reference/examples.md | 211 +++++++++ docs/reference/manifest.md | 184 ++++++++ scripts/docs.py | 888 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1707 insertions(+), 1 deletion(-) create mode 100644 docs/reference/cli.md create mode 100644 docs/reference/examples.md create mode 100644 docs/reference/manifest.md create mode 100644 scripts/docs.py 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/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 0000000..f5f6c6a --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,411 @@ + + +# 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 | +| [`cli build`](#cli-build) | Build a CLI manifest or a loaded CLI into a 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 ai` + +AI-powered commands + +```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, for example `openai:gpt-4o` or +`google:gemini-2.5-flash`, and the API key of that provider must be set in the environment. + +### `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 | | Display the generated prompt before sending the request. | +| `--max-tokens` | integer | no | | The maximum number of tokens to generate before stopping. | +| `-m`, `--model` | text | no | `google:gemini-2.0-flash` | LLM model to use. | +| `--cli` | file | no | | Loaded CLI or manifest to include in 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 | | Display the generated prompt before sending the request. | +| `--max-tokens` | integer | no | | The maximum number of tokens to generate before stopping. | +| `-m`, `--model` | text | no | `google:gemini-2.0-flash` | AI model to use. See https://pydantic.dev/docs/ai/models/overview/ for supported models. | +| `-o`, `--output-dir` | directory | no | `.` | Output directory | + +`--preview` prints the prompt and exits without calling a model. + +## `cli build` + +Build a CLI manifest or a loaded CLI into a 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 #! | + +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, Typer 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 greet Ada +Hello, Ada! +``` + +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 | + +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`). | +| `-f`, `--format` | `md` \| `rst` \| `html` | no | `md` | Output format. | +| `-o`, `--output-dir` | directory | no | | Output directory | + +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. + +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, 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, which is the recommended starting +point. Without `--raw` the manifest is a commented tour of the format; see +[Known issues](../project/known-issues.md) before using that one as a starting point. + +`--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. | + +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 active Python environment, +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`. + +```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. | + +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. | + +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. | + +Runs the `tests` section of a manifest and prints each command, its output and the result. The exit +code is `1` when every test fails or when the manifest has no tests, and `0` otherwise, so check the +last line of the output (`All tests passed!`) in scripts. + +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. | + +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..f557ecd --- /dev/null +++ b/docs/reference/examples.md @@ -0,0 +1,211 @@ + + +# 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. + +| 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 | +| [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" + ``` + +## 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..d78b9bc --- /dev/null +++ b/docs/reference/manifest.md @@ -0,0 +1,184 @@ + + +# 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) | **required** | The commands of the CLI, keyed by name. In a key, `.` separates groups, `\|` separates aliases and a leading `(*)` adds the command to every group. | +| `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 besides `commands`. `version` must be a string, so + quote values such as `"1.0"`. +- 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`). With the Typer backend 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`. + +## 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. Paths are resolved relative to +the directory you run `cli` from. See [Includes and merging](../guides/includes.md). diff --git a/scripts/docs.py b/scripts/docs.py new file mode 100644 index 0000000..517b8c8 --- /dev/null +++ b/scripts/docs.py @@ -0,0 +1,888 @@ +"""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 ( + 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`).", + ("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 active Python environment, +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`. + +```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, which is the recommended starting +point. Without `--raw` the manifest is a commented tour of the format; see +[Known issues](../project/known-issues.md) before using that one as a starting point. + +`--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, Typer 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 greet Ada +Hello, Ada! +``` + +See [Building executables](../guides/building.md). +""", + "info": """ +Prints the name, version, requirements, 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 `1` when every test fails or when the manifest has no tests, and `0` otherwise, so check the +last line of the output (`All tests passed!`) in scripts. + +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. + +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, for example `openai:gpt-4o` or +`google:gemini-2.5-flash`, and the API key of that provider must be set in the environment. +""", + "ai generate": """ +`--preview` prints the prompt and exits without calling a model. +""", + "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}) |" 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 besides `commands`. `version` must be a string, so + quote values such as `"1.0"`. +- 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`). With the Typer backend a `str` +default is quoted for you; other defaults are emitted as written. +""", + "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. Paths are resolved relative to +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, + 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(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", + ), + "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.", + "", + "| 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()) From bb5ece177e1a3ffed19c0772fc6153fa28ed4679 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 06:16:32 -0400 Subject: [PATCH 3/7] Restructure the documentation site Replace the four-page site with Home, Installation, a Quickstart tutorial, thirteen task guides, the generated reference and project pages (changelog, contributing, migration notes, known issues). mkdocs.yaml gets an explicit nav, edit links, site_url, navigation tabs and strict mode with link and anchor validation. The git-committers plugin only runs in CI, so local builds no longer call the GitHub API. Co-Authored-By: Claude Opus 5.5 --- docs/guides/backends.md | 74 +++++++ docs/guides/building.md | 66 ++++++ docs/guides/commands.md | 152 ++++++++++++++ docs/guides/dev-loop.md | 71 +++++++ docs/guides/editor-setup.md | 56 +++++ docs/guides/generating-docs.md | 78 +++++++ docs/guides/includes.md | 64 ++++++ docs/guides/manifest-basics.md | 159 ++++++++++++++ docs/guides/params.md | 129 ++++++++++++ docs/guides/shell-commands.md | 98 +++++++++ docs/guides/templates-and-global-params.md | 94 +++++++++ docs/guides/testing.md | 110 ++++++++++ docs/guides/vars-and-jinja.md | 117 +++++++++++ docs/index.md | 227 ++++++++++---------- docs/installation.md | 106 ++++++++++ docs/project/changelog.md | 147 +++++++++++++ docs/project/contributing.md | 129 ++++++++++++ docs/project/known-issues.md | 89 ++++++++ docs/project/migration.md | 74 +++++++ docs/quickstart.md | 229 +++++++++++++++++++++ mkdocs.yaml | 90 ++++++-- 21 files changed, 2230 insertions(+), 129 deletions(-) create mode 100644 docs/guides/backends.md create mode 100644 docs/guides/building.md create mode 100644 docs/guides/commands.md create mode 100644 docs/guides/dev-loop.md create mode 100644 docs/guides/editor-setup.md create mode 100644 docs/guides/generating-docs.md create mode 100644 docs/guides/includes.md create mode 100644 docs/guides/manifest-basics.md create mode 100644 docs/guides/params.md create mode 100644 docs/guides/shell-commands.md create mode 100644 docs/guides/templates-and-global-params.md create mode 100644 docs/guides/testing.md create mode 100644 docs/guides/vars-and-jinja.md create mode 100644 docs/installation.md create mode 100644 docs/project/changelog.md create mode 100644 docs/project/contributing.md create mode 100644 docs/project/known-issues.md create mode 100644 docs/project/migration.md create mode 100644 docs/quickstart.md diff --git a/docs/guides/backends.md b/docs/guides/backends.md new file mode 100644 index 0000000..b73c4bd --- /dev/null +++ b/docs/guides/backends.md @@ -0,0 +1,74 @@ +# 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, and the only one `cli test` supports. + +## 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` | +|---|---| +| Short option names (`--times\|-t`) | Not supported in shorthand params. Use a generic param: `"@click.option('--times', '-t', type=int, default=1)"`. | +| Boolean options | `--loud: bool = False` expects a value (`--loud true`). For a flag, use a generic param with `is_flag=True`. | +| `types` | Not supported. Type names are passed to Click as written, so only real Python types work. | +| `config` | Ignored. | +| `global_params` | Added to commands that have params, as with Typer. | +| `cli test` | Not supported. | +| `--version` output | `, version ` instead of `, `. | + +### Building + +The generated module imports `rich_click`. `cli build` bundles Typer automatically but not +rich-click, so list it in the manifest: + +```yaml +requires: + - rich-click +``` + +## 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..98d1e0b --- /dev/null +++ b/docs/guides/building.md @@ -0,0 +1,66 @@ +# 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, Typer 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 --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. + +Manifests that set `use_click: true` must list `rich-click` under `requires`. See +[Click and Typer backends](backends.md#building). + +## 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. +- 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..c0932ea --- /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. Generating docs for a loaded CLI by name does not work in this release; see + [Known issues](../project/known-issues.md). diff --git a/docs/guides/includes.md b/docs/guides/includes.md new file mode 100644 index 0000000..b6a02cf --- /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. + +!!! warning "Paths are relative to where you run `cli`" + `shared/common.yaml` is looked up from the current working directory, not from the directory of + the manifest. Run `cli` from the directory that makes the paths in `includes` correct, or use + absolute paths. + +## 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..f2455f8 --- /dev/null +++ b/docs/guides/params.md @@ -0,0 +1,129 @@ +# 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" +``` + +!!! warning "Put Typer definitions in `types` or in a quoted string" + A shorthand entry such as `- name: str = typer.Option("World", "--name")` keeps only the type + and drops the `typer.Option(...)` part. Move the definition to `types`, or quote the whole + entry so that it is a generic param. See [Known issues](../project/known-issues.md). + +## 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..8f31024 --- /dev/null +++ b/docs/guides/templates-and-global-params.md @@ -0,0 +1,94 @@ +# 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 that has params, either its own or from a template. Give a +command at least one param if it needs the global ones; see +[Known issues](../project/known-issues.md). + +## 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..f36aaf7 --- /dev/null +++ b/docs/guides/testing.md @@ -0,0 +1,110 @@ +# 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. | 0 | +| `All tests failed ๐Ÿ’”` | No test case passed. | 1 | + +Because a partial failure still exits with 0, a CI job should check the output: + +```console +$ cli test mycli.yaml | tee /dev/stderr | grep -q "All tests passed" +``` + +## 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. +- **Typer only.** `cli test` does not support manifests that set `use_click: true`. + +## 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 - -![Cliffy logo](images/logo.svg) - -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 - - ![hello -h](images/hello.png) - - 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") - ``` - - ![todo-demo](images/demo.gif) - -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 + +![cliffy logo](images/logo.svg){ 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..e1aa007 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,106 @@ +# 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. 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..45fff2a --- /dev/null +++ b/docs/project/changelog.md @@ -0,0 +1,147 @@ +# 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; the default model is + `google:gemini-2.0-flash`. +- `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`. + +### 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..99fa121 --- /dev/null +++ b/docs/project/known-issues.md @@ -0,0 +1,89 @@ +# 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. + +## Params + +### Typer definitions written inline in a shorthand param are dropped + +```yaml +params: + - name: str = typer.Option("World", "--name", "-n", help="Name to greet") +``` + +Only `name: str` reaches the generated code, so `name` becomes a required positional argument and +the option, its default and its help text are lost. + +**Do this instead:** define the param under `types` and refer to it, or quote the whole entry so +that it is a generic param. Both are shown in [Params and types](../guides/params.md#types). + +### The manifest written by `cli init` without `--raw` does not run + +The full template uses the inline form above together with a global param. The code generated from +it is not valid Python, so `cli run` and the loaded CLI fail with +`parameter without a default follows parameter with a default`. `cli validate` does not catch this. + +**Do this instead:** start from `cli init mycli --raw`, or from a manifest in the +[Quickstart](../quickstart.md) or the [examples gallery](../reference/examples.md). + +### Global params skip commands that have no params + +`global_params` are added only to commands that declare at least one param, either their own or +through a template. A command without params that uses a global param in its script fails with +`NameError`. + +**Do this instead:** give such a command a param or a template that has one. + +## 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`. + +### `cli test` exits with 0 when only some tests fail + +The exit code is 1 only when every test fails. The progress counter also numbers shell steps, so it +can show `6 of 5`. + +**Do this instead:** check for the final line `All tests passed!`, as shown in +[Testing manifests](../guides/testing.md#running-tests). + +### `cli docs ` fails for a loaded CLI + +Passing the name of a loaded CLI instead of a manifest path ends in a validation error. + +**Do this instead:** pass the manifest path: `cli docs mycli.yaml`. + +### `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. + +### Include paths depend on the working directory + +Paths under `includes` are resolved from the directory you run `cli` in, not from the manifest's +directory. See [Includes and merging](../guides/includes.md). + +### 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 short option names in +shorthand params, no `types`, no `config`, and no `cli test`. 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/mkdocs.yaml b/mkdocs.yaml index 6a5981e..fdbcd99 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -1,21 +1,65 @@ -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 - - 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 + # Slots for pages owned by other merge requests. Uncomment when the page exists. + # - Blueprints: 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: @@ -25,17 +69,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: @@ -43,8 +99,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 From b71cc7f2425ae6bba90daf6c7ac0a0b690bc03c7 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 06:16:32 -0400 Subject: [PATCH 4/7] Trim the README to an overview that links into the docs Co-Authored-By: Claude Opus 5.5 --- README.md | 333 ++++++++++++++---------------------------------------- 1 file changed, 87 insertions(+), 246 deletions(-) diff --git a/README.md b/README.md index e4f3120..f64ddf1 100644 --- a/README.md +++ b/README.md @@ -1,280 +1,121 @@ -![Cliffy logo](docs/images/logo.svg) +![cliffy logo](https://raw.githubusercontent.com/jaykv/cliffy/main/docs/images/logo.svg) -[![GitHub Workflow Status (with branch)](https://img.shields.io/github/actions/workflow/status/jaykv/cliffy/python-app.yaml?branch=main)](https://github.com/jaykv/cliffy/actions) +[![CI](https://img.shields.io/github/actions/workflow/status/jaykv/cliffy/python-app.yaml?branch=main)](https://github.com/jaykv/cliffy/actions) [![PyPI](https://img.shields.io/pypi/v/cliffy)](https://pypi.org/project/cliffy/) [![Python versions](https://img.shields.io/pypi/pyversions/cliffy)](https://pypi.org/project/cliffy/) -![GitHub](https://img.shields.io/github/license/jaykv/cliffy) +![License](https://img.shields.io/github/license/jaykv/cliffy) [![codecov](https://codecov.io/github/jaykv/cliffy/graph/badge.svg?token=84SM8XD5IW)](https://codecov.io/github/jaykv/cliffy) -[![cliffy docs](https://img.shields.io/badge/๐Ÿ“%20docs-8A2BE2)](https://jaykv.github.io/cliffy) +[![Documentation](https://img.shields.io/badge/docs-jaykv.github.io%2Fcliffy-8A2BE2)](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` - -![hello-demo](docs/images/hello.png) - -### 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! ``` -![todo-demo](docs/images/demo.gif) +## 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) From 07aea1b9e15e3bd6f9a68d38e87766843f710fe5 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 06:16:32 -0400 Subject: [PATCH 5/7] Rebuild the docs when an example manifest changes The examples gallery embeds examples/*.yaml at build time. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yaml | 2 ++ 1 file changed, 2 insertions(+) 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: From 60f75f2df5d0a108ade94e58095a8ac28d43c2ee Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 06:16:32 -0400 Subject: [PATCH 6/7] Fix the cli dev help example that names a missing command examples/hello.yaml has no hello command; use python, which exists. Co-Authored-By: Claude Opus 5.5 --- cliffy/cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cliffy/cli.py b/cliffy/cli.py index 13affbd..038522d 100644 --- a/cliffy/cli.py +++ b/cliffy/cli.py @@ -233,7 +233,7 @@ def dev(manifest: str, run_cli: bool, run_cli_args: tuple[str]) -> None: - 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") Reloader.watch(manifest, run_cli, run_cli_args) From 8d178653e3c92254141f2cfc497cdf05461530dc Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 06:17:59 -0400 Subject: [PATCH 7/7] Show option aliases without a stray backslash in tables A pipe inside a code span does not need escaping in a Markdown table; the backslash was rendered literally. Also say which environment cli load writes the launcher into. Co-Authored-By: Claude Opus 5.5 --- docs/guides/backends.md | 2 +- docs/guides/params.md | 4 ++-- docs/reference/cli.md | 3 ++- docs/reference/manifest.md | 2 +- scripts/docs.py | 5 +++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/guides/backends.md b/docs/guides/backends.md index b73c4bd..a42c95d 100644 --- a/docs/guides/backends.md +++ b/docs/guides/backends.md @@ -42,7 +42,7 @@ migrating | Feature | With `use_click: true` | |---|---| -| Short option names (`--times\|-t`) | Not supported in shorthand params. Use a generic param: `"@click.option('--times', '-t', type=int, default=1)"`. | +| Short option names (`--times|-t`) | Not supported in shorthand params. Use a generic param: `"@click.option('--times', '-t', type=int, default=1)"`. | | Boolean options | `--loud: bool = False` expects a value (`--loud true`). For a flag, use a generic param with `is_flag=True`. | | `types` | Not supported. Type names are passed to Click as written, so only real Python types work. | | `config` | Ignored. | diff --git a/docs/guides/params.md b/docs/guides/params.md index f2455f8..450fcaf 100644 --- a/docs/guides/params.md +++ b/docs/guides/params.md @@ -24,8 +24,8 @@ Each entry is `name: type`. | `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. | +| `--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`. | diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f5f6c6a..654fb8c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -258,7 +258,8 @@ Alias: `cli add` 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 active Python environment, +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` diff --git a/docs/reference/manifest.md b/docs/reference/manifest.md index d78b9bc..5ea4192 100644 --- a/docs/reference/manifest.md +++ b/docs/reference/manifest.md @@ -85,7 +85,7 @@ A one-key mapping: the key is the name, the value is the type. |---|---|---| | `name` | Positional argument | `source: str` | | `--name` | Option | `--count: int` | -| `--name\|-n` | Option with a short alias | `--count\|-c: 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` | diff --git a/scripts/docs.py b/scripts/docs.py index 517b8c8..4801dd9 100644 --- a/scripts/docs.py +++ b/scripts/docs.py @@ -100,7 +100,8 @@ 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 active Python environment, +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` @@ -489,7 +490,7 @@ def render_cli_reference() -> str: |---|---|---| | `name` | Positional argument | `source: str` | | `--name` | Option | `--count: int` | -| `--name\\|-n` | Option with a short alias | `--count\\|-c: 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` |