Skip to content

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.

The finished manifest is at the end of this page.

1. A manifest with one command

Create a file called notes.yaml:

notes.yaml
name: notes
version: 0.1.0
help: Keep short notes in a JSON file

commands:
  add:
    help: Add a note
    params:
      - text: str!
    run: |
      print(f"Added {text}")

tests:
  - add "buy milk": assert result.output == "Added buy milk\n"
  • name is what you will type to run the CLI. version is shown by notes --version.
  • Each key under commands is a command. run holds its Python code.
  • text: str! declares a parameter called text of type str. The ! makes it required. Parameters are ordinary variables inside run.
  • tests is optional. You will come back to it in step 7.

Run the command without installing anything:

$ 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:

$ cli render notes.yaml
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:

notes.yaml
imports: |
  import json
  import os
  from collections import Counter
  from pathlib import Path

vars:
  default_file: notes.json

functions:
  - |
    def notes_path() -> Path:
        return Path(os.environ.get("NOTES_FILE", "{{ default_file }}"))
  - |
    def load_notes() -> list[dict]:
        path = notes_path()
        return json.loads(path.read_text()) if path.exists() else []
  - |
    def save_notes(notes: list[dict]) -> None:
        notes_path().write_text(json.dumps(notes, indent=2))
  • imports is copied to the top of the generated module.
  • functions is a list of helper functions that every command can call.
  • vars defines manifest variables. {{ default_file }} is replaced with notes.json when the manifest is loaded. More in Vars and Jinja.

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 and reuse it. Add this after functions:

notes.yaml
types:
  Text: str = typer.Argument(..., help="Text of the note")
  Tag: str = typer.Option("general", "--tag", "-t", help="Tag to file the note under")

Now replace the add command so that it uses those types and saves the note:

notes.yaml
commands:
  add:
    help: Add a note
    params:
      - text: Text
      - tag: Tag
    run: |
      notes = load_notes()
      notes.append({"text": text, "tag": tag})
      save_notes(notes)
      print(f"Added note {len(notes)} [{tag}]")

Try it:

$ cli run notes.yaml add "buy milk"
Added note 1 [general]
$ cli run notes.yaml -- add "book flights" --tag travel
Added note 2 [travel]

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:

notes.yaml
  list|ls:
    help: List notes, optionally filtered by tag
    params:
      - --tag|-t: str = ""
    run: |
      for number, note in enumerate(load_notes(), start=1):
          if not tag or note["tag"] == tag:
              print(f"{number}. [{note['tag']}] {note['text']}")
  • The key list|ls creates the command list and the alias ls.
  • --tag|-t: str = "" is an option (the name starts with --) with the short form -t and an empty string as its default.
$ 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:

notes.yaml
  remove|rm:
    help: Remove a note by its number
    params:
      - number: int!
    run: |
      notes = load_notes()
      if not 1 <= number <= len(notes):
          print(f"No note {number}")
          raise typer.Exit(code=1)
      notes.pop(number - 1)
      save_notes(notes)
      print(f"Removed note {number}")

A dot in a command key creates a group. These two commands are run as notes tags list and notes tags rename. The entry tags, which has help and no run, sets the help text of the group:

notes.yaml
  tags:
    help: Work with tags

  tags.list:
    help: Count notes per tag
    run: |
      for tag, count in sorted(Counter(note["tag"] for note in load_notes()).items()):
          print(f"{tag} ({count})")

  tags.rename:
    help: Rename a tag on every note
    params:
      - old: str!
      - new: str!
    run: |
      notes = load_notes()
      for note in notes:
          if note["tag"] == old:
              note["tag"] = new
      save_notes(notes)
      print(f"Renamed {old} to {new}")
$ 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:

notes.yaml
  backup:
    help: Copy the notes file next to itself
    run: |
      $ cp f{notes_path()} f{notes_path()}.bak
      print(f"Backed up to {notes_path()}.bak")
$ cli run notes.yaml backup
Backed up to notes.json.bak

More in Shell commands.

7. Test it

Replace the tests section at the end of the file:

notes.yaml
tests:
  - $ rm -f notes.json notes.json.bak
  - add "buy milk" --tag home: assert result.output == "Added note 1 [home]\n"
  - add "ship release": assert "Added note 2 [general]" in result.output
  - list: |
      assert "1. [home] buy milk" in result.output
      assert "2. [general] ship release" in result.output
  - ls -t home: assert result_text == "1. [home] buy milk"
  - tags rename general work: assert result.exit_code == 0
  - tags list: assert "work (1)" in result.output
  - backup: assert "Backed up" in result.output
  - remove 9: assert result.exit_code == 1
  - rm 1: assert "Removed note 1" in result.output
  - $ rm -f notes.json notes.json.bak
  • An entry that starts with $ is a shell step. Here it removes leftover files before and after the run.
  • Every other entry maps a command line to Python assertions. result is the outcome of the command, with result.output and result.exit_code. result_text is the output with surrounding whitespace removed.
$ cli test notes.yaml
✨ Invoking tests ✨
...
💚 All tests passed!

More in Testing manifests.

8. Load it as a real command

So far every call went through cli run. Load the manifest to get a notes command:

$ 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      <str>  Text of the note [required]                          │
╰────────────────────────────────────────────────────────────────────────────╯
╭─ Options ──────────────────────────────────────────────────────────────────╮
│ --tag   -t      <str>  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 do that on every save.

9. Ship it as one file

$ cli build notes.yaml -o dist
+ notes built 📦
  dist/notes (single file, needs Python >=3.10, 1.8 MB)
  run it with: ./dist/notes
$ ./dist/notes --version
notes, 0.1.0

dist/notes is a self-contained zipapp. It runs on any machine with a compatible Python 3 and does not need cliffy. With --target script you get a single Python script instead, and with --target native a compiled executable that does not even need Python. More in Building executables.

The finished manifest

notes.yaml
name: notes
version: 0.1.0
help: Keep short notes in a JSON file

imports: |
  import json
  import os
  from collections import Counter
  from pathlib import Path

vars:
  default_file: notes.json

functions:
  - |
    def notes_path() -> Path:
        return Path(os.environ.get("NOTES_FILE", "{{ default_file }}"))
  - |
    def load_notes() -> list[dict]:
        path = notes_path()
        return json.loads(path.read_text()) if path.exists() else []
  - |
    def save_notes(notes: list[dict]) -> None:
        notes_path().write_text(json.dumps(notes, indent=2))

types:
  Text: str = typer.Argument(..., help="Text of the note")
  Tag: str = typer.Option("general", "--tag", "-t", help="Tag to file the note under")

commands:
  add:
    help: Add a note
    params:
      - text: Text
      - tag: Tag
    run: |
      notes = load_notes()
      notes.append({"text": text, "tag": tag})
      save_notes(notes)
      print(f"Added note {len(notes)} [{tag}]")

  list|ls:
    help: List notes, optionally filtered by tag
    params:
      - --tag|-t: str = ""
    run: |
      for number, note in enumerate(load_notes(), start=1):
          if not tag or note["tag"] == tag:
              print(f"{number}. [{note['tag']}] {note['text']}")

  remove|rm:
    help: Remove a note by its number
    params:
      - number: int!
    run: |
      notes = load_notes()
      if not 1 <= number <= len(notes):
          print(f"No note {number}")
          raise typer.Exit(code=1)
      notes.pop(number - 1)
      save_notes(notes)
      print(f"Removed note {number}")

  tags:
    help: Work with tags

  tags.list:
    help: Count notes per tag
    run: |
      for tag, count in sorted(Counter(note["tag"] for note in load_notes()).items()):
          print(f"{tag} ({count})")

  tags.rename:
    help: Rename a tag on every note
    params:
      - old: str!
      - new: str!
    run: |
      notes = load_notes()
      for note in notes:
          if note["tag"] == old:
              note["tag"] = new
      save_notes(notes)
      print(f"Renamed {old} to {new}")

  backup:
    help: Copy the notes file next to itself
    run: |
      $ cp f{notes_path()} f{notes_path()}.bak
      print(f"Backed up to {notes_path()}.bak")

tests:
  - $ rm -f notes.json notes.json.bak
  - add "buy milk" --tag home: assert result.output == "Added note 1 [home]\n"
  - add "ship release": assert "Added note 2 [general]" in result.output
  - list: |
      assert "1. [home] buy milk" in result.output
      assert "2. [general] ship release" in result.output
  - ls -t home: assert result_text == "1. [home] buy milk"
  - tags rename general work: assert result.exit_code == 0
  - tags list: assert "work (1)" in result.output
  - backup: assert "Backed up" in result.output
  - remove 9: assert result.exit_code == 1
  - rm 1: assert "Removed note 1" in result.output
  - $ rm -f notes.json notes.json.bak

examples:
  - command: notes add "buy milk" --tag home
    description: Add a note tagged home.
  - command: notes ls --tag home
    description: Show only the notes tagged home.

The examples section at the end is shown at the end of notes --help and is used by cli docs.

Next steps