Overview
While firestone-generated CLIs work out of the box, you can customize them in several ways:
- Custom Jinja2 templates - Modify generation templates
- Post-generation edits - Manually edit generated code
- Wrapper scripts - Add functionality without modifying generated code
- 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
- Integration - Connect with OpenAPI clients
- Usage Examples - Real-world scenarios
- Troubleshooting - Solve common issues