Custom Command Development Guide¶
This guide explains how to create, register, and debug custom commands using the Command Factory framework. Custom commands work with both ppxai (Rich TUI) and ppxaide (Textual TUI).
Table of Contents¶
- Quick Start
- Command Structure
- Registration
- Arguments and Parsing
- Accessing Handler Context
- Output and Formatting
- Error Handling
- Logging and Debugging
- Testing Your Command
- Best Practices
- Examples
Quick Start¶
Step 1: Create the Commands Directory¶
Step 2: Create Your Command File¶
# ~/.ppxai/commands/hello.py
from ppxai.commands import CommandFactory, CommandSpec
from rich.console import Console
console = Console()
def handle_hello(handler, args: str):
"""Handle /hello command."""
name = args.strip() if args.strip() else "World"
console.print(f"[green]Hello, {name}![/green]")
# Register the command
CommandFactory.register(CommandSpec(
name="hello",
description="Say hello to someone",
handler=handle_hello,
category="custom",
usage="/hello [name]"
))
Step 3: Load and Use¶
> /reload
User commands reloaded (1 modules).
> /hello Claude
Hello, Claude!
> /help
...
Custom
/hello - Say hello to someone
Command Structure¶
The Handler Function¶
Every command is a Python function with this signature:
def handle_<name>(handler, args: str) -> Optional[bool]:
"""
Handle /<name> command.
Args:
handler: CommandHandler instance (provides access to engine, session, etc.)
args: Raw argument string (everything after the command name)
Returns:
None - Normal completion
True - Signal to exit the application (only for /quit-like commands)
False - Continue but indicate failure (rarely used)
"""
pass
The CommandSpec¶
@dataclass
class CommandSpec:
name: str # Command name (without /)
description: str # Short description for /help
handler: Callable # The handler function
category: str = "general" # Category for grouping in /help
aliases: List[str] = [] # Alternative names (e.g., ["h"] for /h -> /hello)
usage: str = "" # Usage hint (e.g., "/hello [name]")
hidden: bool = False # Hide from /help listing
Registration¶
Basic Registration¶
from ppxai.commands import CommandFactory, CommandSpec
CommandFactory.register(CommandSpec(
name="mycommand",
description="Does something useful",
handler=handle_mycommand,
category="custom"
))
With Aliases¶
CommandFactory.register(CommandSpec(
name="search",
aliases=["s", "find"], # /s and /find also work
description="Search for something",
handler=handle_search,
category="custom",
usage="/search <query>"
))
Hidden Commands¶
CommandFactory.register(CommandSpec(
name="debug_internal",
description="Internal debugging command",
handler=handle_debug_internal,
category="custom",
hidden=True # Won't appear in /help
))
Multiple Commands in One File¶
# ~/.ppxai/commands/git_commands.py
def handle_gstatus(handler, args: str):
"""Show git status."""
...
def handle_glog(handler, args: str):
"""Show git log."""
...
def handle_gdiff(handler, args: str):
"""Show git diff."""
...
# Register all commands
for name, func, desc in [
("gstatus", handle_gstatus, "Show git status"),
("glog", handle_glog, "Show git log"),
("gdiff", handle_gdiff, "Show git diff"),
]:
CommandFactory.register(CommandSpec(
name=name,
description=desc,
handler=func,
category="git"
))
Arguments and Parsing¶
Raw Arguments¶
The args parameter contains everything after the command name:
Simple Argument Parsing¶
def handle_greet(handler, args: str):
"""Handle /greet <name> [greeting]"""
parts = args.split(maxsplit=1)
if not parts:
console.print("[red]Usage: /greet <name> [greeting][/red]")
return
name = parts[0]
greeting = parts[1] if len(parts) > 1 else "Hello"
console.print(f"{greeting}, {name}!")
Flag-Style Arguments¶
def handle_list(handler, args: str):
"""Handle /list [-a|--all] [-n NUM] [path]"""
import shlex
try:
tokens = shlex.split(args)
except ValueError:
tokens = args.split()
show_all = False
limit = 10
path = "."
i = 0
while i < len(tokens):
if tokens[i] in ("-a", "--all"):
show_all = True
elif tokens[i] == "-n" and i + 1 < len(tokens):
limit = int(tokens[i + 1])
i += 1
elif not tokens[i].startswith("-"):
path = tokens[i]
i += 1
# Use parsed arguments...
Using argparse (Advanced)¶
import argparse
import shlex
def handle_fetch(handler, args: str):
"""Handle /fetch [options] <url>"""
parser = argparse.ArgumentParser(prog="/fetch", add_help=False)
parser.add_argument("url", help="URL to fetch")
parser.add_argument("-o", "--output", help="Output file")
parser.add_argument("-q", "--quiet", action="store_true")
try:
parsed = parser.parse_args(shlex.split(args))
except SystemExit:
console.print(f"[dim]Usage: /fetch [-o FILE] [-q] <url>[/dim]")
return
# Use parsed.url, parsed.output, parsed.quiet
...
Accessing Handler Context¶
The handler parameter provides access to the full application context:
Engine Client¶
def handle_ask(handler, args: str):
"""Send a message to the AI."""
import asyncio
async def run():
async for event in handler.engine_client.chat(args, stream=True):
if event.type.name == "STREAM_CHUNK":
console.print(event.data, end="")
console.print()
asyncio.run(run())
Session Data¶
def handle_history(handler, args: str):
"""Show conversation history."""
messages = handler.engine_client.session.messages
for i, msg in enumerate(messages[-10:], 1):
role = msg.get("role", "unknown")
content = msg.get("content", "")[:50]
console.print(f"{i}. [{role}] {content}...")
Current Model and Provider¶
def handle_info(handler, args: str):
"""Show current configuration."""
console.print(f"Provider: {handler.provider}")
console.print(f"Model: {handler.current_model}")
console.print(f"Tools enabled: {handler.engine_client.tools_enabled}")
console.print(f"Working dir: {handler.engine_client.working_dir}")
Theme¶
def handle_themed(handler, args: str):
"""Output using current theme."""
theme = handler.theme
console.print(f"[{theme.info}]Info message[/]")
console.print(f"[{theme.success}]Success message[/]")
console.print(f"[{theme.error}]Error message[/]")
Output and Formatting¶
Using Rich Console¶
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.markdown import Markdown
console = Console()
def handle_table(handler, args: str):
"""Show data in a table."""
table = Table(title="My Data")
table.add_column("Name", style="cyan")
table.add_column("Value", style="green")
table.add_row("Item 1", "100")
table.add_row("Item 2", "200")
console.print(table)
def handle_panel(handler, args: str):
"""Show content in a panel."""
console.print(Panel(
"This is important content",
title="Notice",
border_style="yellow"
))
def handle_markdown(handler, args: str):
"""Render markdown."""
md = Markdown("""
# Heading
- Item 1
- Item 2
**Bold** and *italic* text.
""")
console.print(md)
Status Messages¶
def handle_process(handler, args: str):
"""Process with status indicator."""
from rich.status import Status
with console.status("[bold green]Processing...") as status:
# Do work...
import time
time.sleep(2)
console.print("[green]Done![/green]")
Error Handling¶
User Errors¶
def handle_open(handler, args: str):
"""Open a file."""
if not args.strip():
console.print("[red]Error: Please specify a file path[/red]")
console.print("[dim]Usage: /open <filepath>[/dim]")
return
path = Path(args.strip()).expanduser()
if not path.exists():
console.print(f"[red]Error: File not found: {path}[/red]")
return
if not path.is_file():
console.print(f"[red]Error: Not a file: {path}[/red]")
return
# Process file...
Exception Handling¶
def handle_risky(handler, args: str):
"""Command that might fail."""
try:
result = do_something_risky(args)
console.print(f"[green]Success: {result}[/green]")
except ValueError as e:
console.print(f"[red]Invalid input: {e}[/red]")
except FileNotFoundError as e:
console.print(f"[red]File not found: {e}[/red]")
except Exception as e:
# Log unexpected errors
logger.exception(f"Unexpected error in /risky: {e}")
console.print(f"[red]Unexpected error: {e}[/red]")
console.print("[dim]Check logs for details.[/dim]")
Logging and Debugging¶
Setting Up Logging¶
# ~/.ppxai/commands/my_commands.py
from ppxai.common.logger import get_logger
logger = get_logger("custom.mycommands")
def handle_debug_example(handler, args: str):
"""Example with logging."""
logger.debug(f"Command called with args: {args!r}")
try:
result = process(args)
logger.info(f"Processed successfully: {result}")
console.print(f"[green]Result: {result}[/green]")
except Exception as e:
logger.exception(f"Failed to process: {e}")
console.print(f"[red]Error: {e}[/red]")
Log Levels¶
logger.debug("Detailed debugging info (only in debug mode)")
logger.info("General information")
logger.warning("Warning - something unexpected but not fatal")
logger.error("Error - operation failed")
logger.exception("Error with stack trace (use in except blocks)")
Viewing Logs¶
# Logs are stored in ~/.ppxai/logs/
tail -f ~/.ppxai/logs/ppxai.log
# Or use /debug_log command in ppxai
> /debug_log tail 50
Debug Mode¶
def handle_verbose(handler, args: str):
"""Command with verbose output."""
verbose = "-v" in args or "--verbose" in args
if verbose:
console.print("[dim]Step 1: Initializing...[/dim]")
# Do step 1...
if verbose:
console.print("[dim]Step 2: Processing...[/dim]")
# Do step 2...
Testing Your Command¶
Interactive Testing¶
# 1. Create your command file
vim ~/.ppxai/commands/test_command.py
# 2. Start ppxai
ppxai
# 3. Reload to pick up changes
> /reload
User commands reloaded (1 modules).
# 4. Test your command
> /test_command arg1 arg2
# 5. Make changes, reload, repeat
> /reload
Unit Testing¶
# tests/test_my_commands.py
import pytest
from unittest.mock import MagicMock, patch
# Import your command module
import sys
sys.path.insert(0, str(Path.home() / ".ppxai" / "commands"))
import my_commands
def test_handle_hello():
"""Test hello command."""
handler = MagicMock()
with patch.object(my_commands, 'console') as mock_console:
my_commands.handle_hello(handler, "Claude")
mock_console.print.assert_called_once()
call_args = mock_console.print.call_args[0][0]
assert "Claude" in call_args
def test_handle_hello_no_args():
"""Test hello with no arguments."""
handler = MagicMock()
with patch.object(my_commands, 'console') as mock_console:
my_commands.handle_hello(handler, "")
call_args = mock_console.print.call_args[0][0]
assert "World" in call_args
Best Practices¶
1. Validate Arguments Early¶
def handle_cmd(handler, args: str):
# Validate first
if not args.strip():
console.print("[red]Error: Argument required[/red]")
return
# Then process
...
2. Use Descriptive Names¶
# Good
CommandSpec(name="search_files", ...)
CommandSpec(name="git_status", ...)
# Avoid
CommandSpec(name="sf", ...) # Too cryptic
CommandSpec(name="do_thing", ...) # Too vague
3. Provide Usage Hints¶
CommandSpec(
name="convert",
description="Convert file format",
usage="/convert <input> <output> [-f FORMAT]",
...
)
4. Use Categories¶
# Group related commands
category="git" # Git operations
category="file" # File operations
category="session" # Session management
category="custom" # User-defined (default)
5. Handle Async Operations¶
import asyncio
def handle_async_cmd(handler, args: str):
"""Command that needs async."""
async def run():
# Async operations here
result = await some_async_function()
console.print(result)
asyncio.run(run())
6. Don't Block the Event Loop¶
# Bad - blocks for a long time
def handle_slow(handler, args: str):
import time
time.sleep(60) # Blocks everything!
# Better - show progress
def handle_slow(handler, args: str):
from rich.progress import Progress
with Progress() as progress:
task = progress.add_task("Processing...", total=100)
for i in range(100):
do_work_chunk()
progress.update(task, advance=1)
7. Clean Up Resources¶
def handle_file_op(handler, args: str):
"""Handle file with proper cleanup."""
path = Path(args.strip())
try:
with open(path) as f:
content = f.read()
# Process content
except IOError as e:
console.print(f"[red]Error: {e}[/red]")
Examples¶
Example 1: Git Status Command¶
# ~/.ppxai/commands/git.py
"""Git helper commands."""
import subprocess
from ppxai.commands import CommandFactory, CommandSpec
from rich.console import Console
from rich.syntax import Syntax
console = Console()
def run_git(args: list) -> tuple[str, int]:
"""Run git command and return output."""
result = subprocess.run(
["git"] + args,
capture_output=True,
text=True
)
return result.stdout + result.stderr, result.returncode
def handle_gs(handler, args: str):
"""Show git status."""
output, code = run_git(["status", "--short"])
if code == 0:
if output.strip():
console.print(output)
else:
console.print("[green]Working tree clean[/green]")
else:
console.print(f"[red]{output}[/red]")
def handle_gl(handler, args: str):
"""Show git log."""
count = args.strip() if args.strip() else "10"
output, code = run_git(["log", f"-{count}", "--oneline"])
if code == 0:
console.print(output)
else:
console.print(f"[red]{output}[/red]")
def handle_gd(handler, args: str):
"""Show git diff."""
output, code = run_git(["diff"] + args.split())
if code == 0:
syntax = Syntax(output, "diff", theme="monokai")
console.print(syntax)
else:
console.print(f"[red]{output}[/red]")
# Register commands
CommandFactory.register(CommandSpec(
name="gs", description="Git status (short)", handler=handle_gs, category="git"
))
CommandFactory.register(CommandSpec(
name="gl", description="Git log", handler=handle_gl, category="git", usage="/gl [count]"
))
CommandFactory.register(CommandSpec(
name="gd", description="Git diff", handler=handle_gd, category="git", usage="/gd [file]"
))
Example 2: Quick Note Command¶
# ~/.ppxai/commands/notes.py
"""Quick notes commands."""
from pathlib import Path
from datetime import datetime
from ppxai.commands import CommandFactory, CommandSpec
from rich.console import Console
console = Console()
NOTES_FILE = Path.home() / ".ppxai" / "notes.md"
def handle_note(handler, args: str):
"""Add a quick note."""
if not args.strip():
console.print("[red]Usage: /note <your note>[/red]")
return
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
entry = f"\n## {timestamp}\n\n{args.strip()}\n"
NOTES_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(NOTES_FILE, "a") as f:
f.write(entry)
console.print(f"[green]Note saved.[/green]")
def handle_notes(handler, args: str):
"""Show recent notes."""
if not NOTES_FILE.exists():
console.print("[dim]No notes yet. Use /note to add one.[/dim]")
return
content = NOTES_FILE.read_text()
entries = content.split("\n## ")[-5:] # Last 5 entries
for entry in entries:
if entry.strip():
console.print(f"[cyan]## {entry}[/cyan]")
CommandFactory.register(CommandSpec(
name="note", description="Add a quick note", handler=handle_note,
category="notes", usage="/note <text>"
))
CommandFactory.register(CommandSpec(
name="notes", description="Show recent notes", handler=handle_notes,
category="notes"
))
Example 3: API Helper Command¶
# ~/.ppxai/commands/api.py
"""API helper commands."""
import json
import asyncio
from ppxai.commands import CommandFactory, CommandSpec
from rich.console import Console
from rich.syntax import Syntax
console = Console()
def handle_api(handler, args: str):
"""Make API request via AI."""
if not args.strip():
console.print("[red]Usage: /api <describe what you want>[/red]")
return
prompt = f"""Generate a curl command for: {args}
Return ONLY the curl command, no explanation."""
async def run():
response = ""
async for event in handler.engine_client.chat(prompt, stream=True):
if event.type.name == "STREAM_CHUNK":
response += event.data
# Extract curl command
if "curl" in response:
console.print("\n[bold]Generated command:[/bold]")
syntax = Syntax(response.strip(), "bash", theme="monokai")
console.print(syntax)
asyncio.run(run())
CommandFactory.register(CommandSpec(
name="api",
description="Generate API curl command",
handler=handle_api,
category="tools",
usage="/api <describe the API call>"
))
Troubleshooting¶
Command Not Found After /reload¶
- Check file is in
~/.ppxai/commands/ - Check file has
.pyextension - Check for syntax errors:
python ~/.ppxai/commands/myfile.py - Check logs:
/debug_log tail 20
Import Errors¶
# Wrong - ppxai might not be in path
from commands import CommandFactory
# Correct
from ppxai.commands import CommandFactory, CommandSpec
Command Registered But Not Working¶
# Check registration
from ppxai.commands import CommandFactory
print(CommandFactory.list_commands())
print(CommandFactory.get("mycommand"))
Handler Not Called¶
- Ensure function signature is
def handle_xxx(handler, args: str) - Check the
handlerparameter inCommandSpecpoints to correct function
Reference¶
Available Imports¶
# Command framework
from ppxai.commands import CommandFactory, CommandSpec
# Logging
from ppxai.common.logger import get_logger
# Configuration
from ppxai.config import get_tui_config, get_provider_config
# UI utilities
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.markdown import Markdown
from rich.syntax import Syntax
Handler Context (handler object)¶
| Attribute | Type | Description |
|---|---|---|
handler.engine_client |
EngineClient | Main AI client |
handler.provider |
str | Current provider name |
handler.current_model |
str | Current model name |
handler.theme |
Theme | Current UI theme |
handler.api_key |
str | API key |
handler.base_url |
str | API base URL |
handler.tools_verbose |
bool | Verbose tool logging |
handler.auto_route |
bool | Auto-route to coding model |
Engine Client Methods¶
| Method | Description |
|---|---|
engine_client.chat(message, stream=True) |
Send message, get response |
engine_client.set_model(name) |
Change model |
engine_client.set_provider(name) |
Change provider |
engine_client.enable_tools() |
Enable tools |
engine_client.disable_tools() |
Disable tools |
engine_client.session.save() |
Save session |
engine_client.session.load(name) |
Load session |
engine_client.session.messages |
Conversation history |