Skip to content

jaykv/cliffy

Repository files navigation

GitHub Workflow Status (with branch) PyPI GitHub

cliffy ⛰️

cliffy simplifies the creation, management, and deployment of CLIs. Define your CLI's structure and behavior in a YAML manifest, and let cliffy handle the rest.

Features

  • Write CLIs with YAML manifests
  • Manage CLIs- load, test, update, list, and remove
  • Built-in shell and Python scripting support
  • Supports Jinja2 templating
  • Hot-reload CLIs on manifest changes for easier development
  • Build CLIs into self-contained, single-file portable zipapps for sharing

Load

  1. Define a manifest
# hello.yaml
name: hello
version: 0.1.0

commands:
  shell: $echo "hello from shell"
  python: print("hello from python")
  1. Load CLI
$ cli load hello.yaml

Parses hello.yaml to generate a Typer CLI and load it into the running Python environment.

  1. Run CLI directly

hello -h

hello-demo

For more examples, check examples directory.

Build

  1. Define a manifest
# requires.yaml
name: requires
version: 0.1.0

requires:
  - requests >= 2.30.0
  - six

imports:
  - import six

commands:
  shell: $echo "hello from shell"
  python: print("hello from python")
  py: |
    if six.PY2:
        print("python 2")
    if six.PY3:
        print("python 3")
  1. Build CLI
$ cli build requires.yaml -o dist

Builds a portable zipapp containing the CLI and its package requirements.

  1. Run CLI
./dist/requires -h

Usage

cli <command>

  • init <cli name> --raw: Generate a template CLI manifest for a new CLI
  • load <manifest>: Add a new CLI based on the manifest
  • render <manifest>: View generated CLI script for a manifest
  • list or ls: Output a list of loaded CLIs
  • update <cli name>: Reload a loaded CLI
  • remove <cli name> or rm <cli name>: Remove a loaded CLI
  • run <manifest> -- <args>: Runs a CLI manifest as a one-time operation
  • build <cli name or manifest>: Build a CLI manifest or a loaded CLI into a self-contained zipapp
  • info <cli name>: Display CLI metadata
  • dev <manifest>: Start hot-reloader for a manifest for active development

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 CLI that is deployed directly as a script
  4. Any code starting with $ will translate to subprocess calls via PyBash
  5. Run loaded CLIs straight from the terminal
  6. When ready to share, run build to generate portable zipapps built with Shiv

Get started

Cliffy can be installed using either pip or uv package managers.

With pip

  • pip install "cliffy[rich]" to include rich-click for colorful CLI help output formatted with rich.

or

  • pip install cliffy to use the default help output.

With uv

  • uvx --from cliffy cli --help
  • Load: uvx --from cliffy cli load examples/hello.yaml
  • Run: uvx --from cliffy hello

Manifest template

Generated by cli init. For a barebones template, run cli init --raw

manifestVersion: v2

# 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

# 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

# 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' }}"

# 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

# 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")

# Arguments applied to all commands
global_args:
  - verbose: Verbose

# Reusable command templates
command_templates:
  with_confirmation:
    args:
      - "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)

# 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
    args:
      - 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
    args:
      - 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
    args: [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