Overview

While firestone-generated CLIs work out of the box, you can customize them in several ways:

  1. Custom Jinja2 templates - Modify generation templates
  2. Post-generation edits - Manually edit generated code
  3. Wrapper scripts - Add functionality without modifying generated code
  4. Template inheritance - Extend base templates

Custom Templates

Template Basics

Firestone uses Jinja2 templates to generate CLI code. You can provide custom templates with --template.

Default templates location:

firestone/firestone/schema/
├── main.py.jinja2        # Single-file CLI
└── cli_module.py.jinja2  # Module CLI

Available Variables

In your custom template, you have access to:

{{ title }}           # API title
{{ description }}     # API description
{{ summary }}         # API summary
{{ version }}         # API version
{{ pkg }}             # Package name (--pkg)
{{ client_pkg }}      # Client package (--client-pkg)
{{ rsrcs }}           # List of resources (single-file mode)
{{ rsrc }}            # Current resource (module mode)

Resource Structure

rsrc = {
    "name": "tasks",  # Resource name
    "operations": {
        "resource": [  # Collection operations
            {
                "name": "list",  # or "create"
                "id": "tasks_get",
                "description": "List all tasks",
                "attrs": [  # Parameters
                    {
                        "name": "limit",
                        "type": "int",
                        "description": "Limit results",
                        "required": False,
                        "argument": False  # True for positional args
                    }
                ]
            }
        ],
        "instance": [  # Instance operations
            {
                "name": "get",  # or "update", "delete"
                "id": "tasks_task_id_get",
                "description": "Get a task",
                "attrs": [...]
            }
        ]
    }
}

Example: Custom Single-File Template

Create custom_cli.jinja2:

#!/usr/bin/env python
"""
{{ title }} - Custom CLI
Generated by firestone

{{ description }}
"""
import click
import json
from {{ client_pkg }} import api_client
from {{ client_pkg }} import configuration

# Custom: Add colorama for colored output
from colorama import init, Fore, Style
init()

@click.group()
@click.option("--api-url", envvar="API_URL", required=True)
@click.pass_context
def main(ctx, api_url):
    """{{ title }}"""
    # Custom: Print banner
    print(f"{Fore.GREEN}=== {{ title }} ==={Style.RESET_ALL}")
    print(f"API: {api_url}\n")

    config = configuration.Configuration(host=api_url)
    ctx.obj = {"api_client_config": config}

{% for rsrc in rsrcs %}
@main.group()
@click.pass_obj
def {{ rsrc["name"] }}(ctx_obj):
    """{{ rsrc["name"] }} operations"""
    config = ctx_obj["api_client_config"]
    aclient = api_client.ApiClient(configuration=config)
    from {{ client_pkg }}.api import {{ rsrc["name"] }}_api
    ctx_obj["api_obj"] = {{ rsrc["name"] }}_api.{{ rsrc["name"].capitalize() }}Api(api_client=aclient)

{% for op in rsrc["operations"]["resource"] %}
@{{ rsrc["name"] }}.command("{{ op["name"] }}")
{% for attr in op["attrs"] %}
@click.option("--{{ attr["name"] }}", help="{{ attr["description"] }}", type={{ attr["type"] }}, required={{ attr["required"] }})
{% endfor %}
@click.pass_obj
def {{ op["id"] }}(ctx_obj{% for attr in op["attrs"] %}, {{ attr["name"] }}{% endfor %}):
    """{{ op["description"] }}"""
    # Custom: Colored output
    print(f"{Fore.YELLOW}Executing {{ op['name'] }}...{Style.RESET_ALL}")

    api_obj = ctx_obj["api_obj"]
    resp = api_obj.{{ op["id"] }}(
        {% for attr in op["attrs"] %}
        {{ attr["name"] }}={{ attr["name"] }},
        {% endfor %}
    )

    # Custom: Pretty JSON with syntax highlighting
    import pygments
    from pygments.lexers import JsonLexer
    from pygments.formatters import TerminalFormatter

    json_str = json.dumps(resp.to_dict() if hasattr(resp, 'to_dict') else resp, indent=2)
    print(pygments.highlight(json_str, JsonLexer(), TerminalFormatter()))
{% endfor %}
{% endfor %}

if __name__ == "__main__":
    main()

Use it:

firestone generate \
  --title "My API" \
  --description "Custom CLI example" \
  --version 1.0 \
  --resources tasks.yaml \
  cli \
  --pkg myapi \
  --client-pkg myapi.client \
  --template custom_cli.jinja2 \
  --output custom_cli.py

Example: Custom Module Template

Create custom_module.jinja2:

#!/usr/bin/env python
"""
{{ rsrc["name"] }} CLI module - Enhanced
"""
import click
import json
from {{ client_pkg }}.api import {{ rsrc["name"] }}_api

# Custom: Add caching
from functools import lru_cache

@lru_cache(maxsize=100)
def get_api_client(config):
    """Cached API client initialization"""
    from {{ client_pkg }} import api_client
    aclient = api_client.ApiClient(configuration=config)
    return {{ rsrc["name"] }}_api.{{ rsrc["name"].capitalize() }}Api(api_client=aclient)

def init():
    """Initialize {{ rsrc["name"] }} resource CLI."""

    @click.group()
    @click.pass_obj
    def {{ rsrc["name"] }}(ctx_obj):
        """{{ rsrc["name"] }} operations"""
        config = ctx_obj["api_client_config"]
        # Custom: Use cached client
        ctx_obj["api_obj"] = get_api_client(config)

    # Custom: Add a summary command
    @{{ rsrc["name"] }}.command("summary")
    @click.pass_obj
    def summary(ctx_obj):
        """Show {{ rsrc["name"] }} summary"""
        api_obj = ctx_obj["api_obj"]
        items = api_obj.{{ rsrc["name"] }}_get()
        print(f"Total {{ rsrc['name'] }}: {len(items)}")

    {% for op in rsrc["operations"]["resource"] %}
    @{{ rsrc["name"] }}.command("{{ op["name"] }}")
    {% for attr in op["attrs"] %}
    @click.option("--{{ attr["name"] }}", type={{ attr["type"] }}, required={{ attr["required"] }})
    {% endfor %}
    @click.pass_obj
    def {{ op["id"] }}(ctx_obj{% for attr in op["attrs"] %}, {{ attr["name"] }}{% endfor %}):
        """{{ op["description"] }}"""
        api_obj = ctx_obj["api_obj"]
        resp = api_obj.{{ op["id"] }}(
            {% for attr in op["attrs"] %}
            {{ attr["name"] }}={{ attr["name"] }},
            {% endfor %}
        )
        print(json.dumps(resp.to_dict(), indent=2))
    {% endfor %}

    return {{ rsrc["name"] }}

Use it:

firestone generate \
  --title "My API" \
  --description "Custom modules" \
  --version 1.0 \
  --resources tasks.yaml projects.yaml \
  cli \
  --pkg myapi \
  --client-pkg myapi.client \
  --template custom_module.jinja2 \
  --as-modules \
  --output-dir myapi/cli/

Post-Generation Edits

When to Edit

Edit generated code when:

  • Templates are too complex
  • One-off customizations needed
  • Quick prototyping

Important: Regenerating will overwrite your changes!

Safe Edit Patterns

1. Separate Custom Code

# Generated: cli.py
# Custom: cli_extensions.py

# cli_extensions.py
import click
from cli import tasks

@tasks.command("export")
@click.option("--format", type=click.Choice(["json", "csv"]))
def export_tasks(format):
    """Export tasks"""
    # Custom implementation
    pass

Then import in your main:

# main.py
from cli import main
import cli_extensions  # Registers custom commands

if __name__ == "__main__":
    main()

2. Wrapper Functions

# Generated code
@tasks.command("list")
def tasks_list(ctx_obj, limit, offset):
    # ... generated code ...

# Add wrapper (at end of file)
def enhanced_list():
    """Enhanced list with caching"""
    # Custom pre-processing
    result = tasks_list()
    # Custom post-processing
    return result

3. Inheritance

# custom_cli.py
from generated_cli import main as base_main

class CustomCLI(base_main):
    def invoke(self, ctx):
        # Custom logic before
        result = super().invoke(ctx)
        # Custom logic after
        return result

Regeneration Strategy

Create a patch file:

# After customizing cli.py
diff -u cli.py.orig cli.py > cli.patch

# After regenerating
firestone generate ... > cli.py
patch cli.py < cli.patch

Or use version control:

# Commit generated version
git add cli.py
git commit -m "Generated CLI v1"

# Make customizations
# edit cli.py
git commit -m "Custom: Add export command"

# Regenerate
firestone generate ... > cli.py

# Merge customizations
git merge

Wrapper Scripts

Enhanced CLI Wrapper

Create mycli:

#!/bin/bash
# mycli - Enhanced wrapper for generated CLI

# Set environment
export API_URL=${API_URL:-https://api.example.com}
export API_KEY=${API_KEY:-$(cat ~/.api_key 2>/dev/null)}

# Colored output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'

# Custom commands
case "$1" in
  version)
    echo "mycli version 1.0"
    python cli.py --help | head -n 3
    exit 0
    ;;
  config)
    echo "API URL: $API_URL"
    echo "API Key: ${API_KEY:0:8}..."
    exit 0
    ;;
  help)
    echo "mycli - Enhanced CLI"
    echo ""
    echo "Custom commands:"
    echo "  version  - Show version"
    echo "  config   - Show configuration"
    echo ""
    echo "Standard commands:"
    python cli.py --help
    exit 0
    ;;
esac

# Run generated CLI with error handling
python cli.py "$@"
EXIT_CODE=$?

if [ $EXIT_CODE -ne 0 ]; then
  echo -e "${RED}Command failed with exit code $EXIT_CODE${NC}" >&2
else
  echo -e "${GREEN}Success${NC}"
fi

exit $EXIT_CODE

Make executable:

chmod +x mycli

Use:

./mycli version
./mycli config
./mycli tasks list

Python Wrapper

Create enhanced_cli.py:

#!/usr/bin/env python
"""Enhanced CLI wrapper with extra features"""

import sys
import click
from cli import main as base_main

@click.group()
@click.pass_context
def main(ctx):
    """Enhanced CLI with custom features"""
    pass

# Import all base commands
for name, cmd in base_main.commands.items():
    main.add_command(cmd, name)

# Add custom commands
@main.command()
def stats():
    """Show API statistics"""
    # Custom implementation
    click.echo("API Statistics:")
    # Call base commands programmatically
    ctx = click.Context(base_main)
    # ...

@main.command()
@click.option("--to", type=click.Choice(["csv", "json", "yaml"]))
def export(to):
    """Export all resources"""
    # Custom implementation
    pass

if __name__ == "__main__":
    main()

Advanced Customizations

Add Middleware

def timing_middleware(func):
    """Measure command execution time"""
    import time
    import functools

    @functools.wraps(func)
    async def wrapper(*args, **kwargs):
        start = time.time()
        result = await func(*args, **kwargs)
        elapsed = time.time() - start
        click.echo(f"\nCompleted in {elapsed:.2f}s", err=True)
        return result
    return wrapper

# Apply to all commands
for cmd in tasks.commands.values():
    cmd.callback = timing_middleware(cmd.callback)

Add Progress Bars

import click

@tasks.command("import")
@click.argument("file", type=click.File())
@click.pass_obj
def import_tasks(ctx_obj, file):
    """Import tasks from file"""
    data = json.load(file)
    api_obj = ctx_obj["api_obj"]

    with click.progressbar(data, label="Importing") as items:
        for item in items:
            api_obj.tasks_post(item)

Add Configuration Files

import configparser

@click.group()
@click.option("--config", type=click.Path(), default="~/.mycli.conf")
@click.pass_context
def main(ctx, config):
    """CLI with config file support"""
    cfg = configparser.ConfigParser()
    cfg.read(os.path.expanduser(config))

    ctx.obj = {
        "api_url": cfg.get("api", "url"),
        "api_key": cfg.get("api", "key"),
    }

Testing Customizations

Unit Tests

# test_cli.py
from click.testing import CliRunner
from cli import main

def test_list_tasks():
    runner = CliRunner()
    result = runner.invoke(main, ['tasks', 'list'])
    assert result.exit_code == 0
    assert 'task_id' in result.output

def test_create_task():
    runner = CliRunner()
    result = runner.invoke(main, [
        'tasks', 'create',
        '--title', 'Test task'
    ])
    assert result.exit_code == 0

Integration Tests

# test_integration.py
import subprocess
import json

def test_full_workflow():
    # Create
    result = subprocess.run(
        ['python', 'cli.py', 'tasks', 'create', '--title', 'Test'],
        capture_output=True,
        text=True
    )
    task = json.loads(result.stdout)
    task_id = task['task_id']

    # Get
    result = subprocess.run(
        ['python', 'cli.py', 'tasks', 'get', task_id],
        capture_output=True,
        text=True
    )
    assert json.loads(result.stdout)['title'] == 'Test'

    # Delete
    subprocess.run(['python', 'cli.py', 'tasks', 'delete', task_id])

Best Practices

1. Keep Templates Simple

Start with default templates and make minimal changes.

2. Version Your Templates

templates/
├── v1/
│   └── cli.jinja2
├── v2/
│   └── cli.jinja2
└── current -> v2

3. Document Customizations

# Custom modifications:
# 1. Added colorama for colored output
# 2. Added progress bars for import command
# 3. Changed JSON formatting to compact

# Last generated: 2025-01-15
# Template version: v2

4. Use Environment Variables

Don't hardcode:

# Bad
API_URL = "https://api.example.com"

# Good
API_URL = os.environ.get("API_URL", "https://api.example.com")

5. Separate Generated and Custom

myapi/
├── cli/
│   ├── generated/      # Regenerate freely
│   │   ├── tasks.py
│   │   └── projects.py
│   ├── custom/         # Manual edits
│   │   └── export.py
│   └── main.py         # Imports both

Next Steps