Introduction: Command-line tools are the developer’s natural habitat. Adding LLM capabilities to CLI tools creates powerful utilities for code generation, documentation, data transformation, and automation. Unlike web apps, CLI tools are fast to build, easy to integrate into existing workflows, and perfect for power users who live in the terminal. This guide covers building production-quality LLM-powered CLI tools using Python: argument parsing with Click and Typer, streaming output with Rich, configuration management, and patterns for common use cases like code explanation, commit message generation, and interactive chat.

Basic CLI with Typer
# pip install typer rich openai
import typer
from rich.console import Console
from rich.markdown import Markdown
from openai import OpenAI
app = typer.Typer(help="AI-powered CLI tools")
console = Console()
client = OpenAI()
@app.command()
def ask(
question: str = typer.Argument(..., help="Question to ask the AI"),
model: str = typer.Option("gpt-4o-mini", "--model", "-m", help="Model to use"),
system: str = typer.Option("You are a helpful assistant.", "--system", "-s"),
):
"""Ask a question and get an AI response."""
with console.status("[bold green]Thinking..."):
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": question}
]
)
answer = response.choices[0].message.content
console.print(Markdown(answer))
@app.command()
def chat(
model: str = typer.Option("gpt-4o", "--model", "-m"),
):
"""Start an interactive chat session."""
console.print("[bold blue]Starting chat. Type 'exit' to quit.[/bold blue]\n")
messages = []
while True:
try:
user_input = console.input("[bold green]You:[/bold green] ")
except (KeyboardInterrupt, EOFError):
break
if user_input.lower() in ("exit", "quit", "q"):
break
messages.append({"role": "user", "content": user_input})
console.print("[bold blue]AI:[/bold blue] ", end="")
stream = client.chat.completions.create(
model=model,
messages=messages,
stream=True
)
full_response = ""
for chunk in stream:
if chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
console.print(content, end="")
full_response += content
console.print("\n")
messages.append({"role": "assistant", "content": full_response})
if __name__ == "__main__":
app()
Code Explanation Tool
import typer
from pathlib import Path
from rich.console import Console
from rich.syntax import Syntax
from rich.panel import Panel
from openai import OpenAI
app = typer.Typer()
console = Console()
client = OpenAI()
@app.command()
def explain(
file_path: Path = typer.Argument(..., help="Path to code file"),
detailed: bool = typer.Option(False, "--detailed", "-d", help="Detailed explanation"),
language: str = typer.Option(None, "--lang", "-l", help="Override language detection"),
):
"""Explain what a code file does."""
if not file_path.exists():
console.print(f"[red]Error: File not found: {file_path}[/red]")
raise typer.Exit(1)
code = file_path.read_text()
# Detect language from extension
lang = language or file_path.suffix.lstrip(".")
lang_map = {"py": "python", "js": "javascript", "ts": "typescript", "rb": "ruby"}
lang = lang_map.get(lang, lang)
# Show the code
console.print(Panel(
Syntax(code, lang, theme="monokai", line_numbers=True),
title=f"[bold]{file_path.name}[/bold]"
))
# Build prompt
detail_level = "detailed, line-by-line" if detailed else "concise"
prompt = f"""Explain this {lang} code in a {detail_level} manner:
```{lang}
{code}
```
Focus on:
1. What the code does overall
2. Key functions/classes and their purposes
3. Any notable patterns or techniques used
4. Potential issues or improvements"""
console.print("\n[bold blue]Explanation:[/bold blue]\n")
stream = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You are an expert code reviewer. Explain code clearly and concisely."},
{"role": "user", "content": prompt}
],
stream=True
)
for chunk in stream:
if chunk.choices[0].delta.content:
console.print(chunk.choices[0].delta.content, end="")
console.print("\n")
@app.command()
def review(
file_path: Path = typer.Argument(..., help="Path to code file"),
focus: str = typer.Option("all", "--focus", "-f", help="Focus area: security, performance, style, all"),
):
"""Review code for issues and improvements."""
code = file_path.read_text()
lang = file_path.suffix.lstrip(".")
focus_prompts = {
"security": "Focus on security vulnerabilities, injection risks, and unsafe patterns.",
"performance": "Focus on performance issues, inefficiencies, and optimization opportunities.",
"style": "Focus on code style, readability, and best practices.",
"all": "Review for security, performance, style, and general code quality."
}
prompt = f"""Review this code:
```{lang}
{code}
```
{focus_prompts.get(focus, focus_prompts["all"])}
Format your response as:
## Issues Found
- List each issue with severity (High/Medium/Low)
## Recommendations
- Specific improvements with code examples"""
with console.status("[bold green]Reviewing code..."):
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "You are a senior code reviewer. Be thorough but constructive."},
{"role": "user", "content": prompt}
]
)
from rich.markdown import Markdown
console.print(Markdown(response.choices[0].message.content))
Git Commit Message Generator
import subprocess
import typer
from rich.console import Console
from openai import OpenAI
app = typer.Typer()
console = Console()
client = OpenAI()
def get_git_diff(staged_only: bool = True) -> str:
"""Get git diff."""
cmd = ["git", "diff", "--cached"] if staged_only else ["git", "diff"]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout
def get_recent_commits(n: int = 5) -> str:
"""Get recent commit messages for style reference."""
result = subprocess.run(
["git", "log", f"-{n}", "--oneline"],
capture_output=True, text=True
)
return result.stdout
@app.command()
def commit(
staged: bool = typer.Option(True, "--staged/--all", help="Use staged changes only"),
conventional: bool = typer.Option(True, "--conventional/--simple", help="Use conventional commits"),
auto: bool = typer.Option(False, "--auto", "-a", help="Auto-commit without confirmation"),
):
"""Generate a commit message from git diff."""
diff = get_git_diff(staged)
if not diff:
console.print("[yellow]No changes to commit.[/yellow]")
raise typer.Exit(0)
# Truncate very long diffs
if len(diff) > 10000:
diff = diff[:10000] + "\n... (truncated)"
recent = get_recent_commits(5)
style_guide = """Use conventional commit format:
():
[optional body]
Types: feat, fix, docs, style, refactor, test, chore
Keep the first line under 72 characters.""" if conventional else "Write a clear, concise commit message."
prompt = f"""Generate a commit message for these changes:
```diff
{diff}
```
Recent commits for style reference:
{recent}
{style_guide}
Return ONLY the commit message, no explanation."""
with console.status("[bold green]Generating commit message..."):
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a git commit message generator. Write clear, professional commit messages."},
{"role": "user", "content": prompt}
]
)
message = response.choices[0].message.content.strip()
console.print("\n[bold blue]Generated commit message:[/bold blue]")
console.print(f"\n{message}\n")
if auto:
do_commit = True
else:
do_commit = typer.confirm("Use this commit message?")
if do_commit:
result = subprocess.run(
["git", "commit", "-m", message],
capture_output=True, text=True
)
if result.returncode == 0:
console.print("[green]Committed successfully![/green]")
else:
console.print(f"[red]Commit failed: {result.stderr}[/red]")
else:
console.print("[yellow]Commit cancelled.[/yellow]")
@app.command()
def pr(
base: str = typer.Option("main", "--base", "-b", help="Base branch"),
):
"""Generate a PR description from commits."""
# Get commits not in base
result = subprocess.run(
["git", "log", f"{base}..HEAD", "--oneline"],
capture_output=True, text=True
)
commits = result.stdout
# Get full diff
result = subprocess.run(
["git", "diff", f"{base}...HEAD"],
capture_output=True, text=True
)
diff = result.stdout[:15000] # Truncate
prompt = f"""Generate a PR description for these changes:
Commits:
{commits}
Diff summary:
```diff
{diff}
```
Format:
## Summary
Brief overview of changes
## Changes
- List of specific changes
## Testing
How to test these changes"""
with console.status("[bold green]Generating PR description..."):
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}]
)
from rich.markdown import Markdown
console.print(Markdown(response.choices[0].message.content))
Configuration Management
import os
from pathlib import Path
from dataclasses import dataclass
import json
import typer
from rich.console import Console
@dataclass
class Config:
api_key: str = ""
default_model: str = "gpt-4o-mini"
temperature: float = 0.7
max_tokens: int = 2000
@classmethod
def load(cls) -> "Config":
"""Load config from file and environment."""
config_path = Path.home() / ".config" / "llm-cli" / "config.json"
config = cls()
# Load from file
if config_path.exists():
data = json.loads(config_path.read_text())
config.default_model = data.get("default_model", config.default_model)
config.temperature = data.get("temperature", config.temperature)
config.max_tokens = data.get("max_tokens", config.max_tokens)
# Environment overrides
config.api_key = os.environ.get("OPENAI_API_KEY", config.api_key)
if env_model := os.environ.get("LLM_MODEL"):
config.default_model = env_model
return config
def save(self):
"""Save config to file."""
config_path = Path.home() / ".config" / "llm-cli" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
data = {
"default_model": self.default_model,
"temperature": self.temperature,
"max_tokens": self.max_tokens
}
config_path.write_text(json.dumps(data, indent=2))
# CLI with config
app = typer.Typer()
console = Console()
@app.command()
def config_show():
"""Show current configuration."""
cfg = Config.load()
console.print("[bold]Current Configuration:[/bold]")
console.print(f" Model: {cfg.default_model}")
console.print(f" Temperature: {cfg.temperature}")
console.print(f" Max Tokens: {cfg.max_tokens}")
console.print(f" API Key: {'[set]' if cfg.api_key else '[not set]'}")
@app.command()
def config_set(
model: str = typer.Option(None, "--model", "-m"),
temperature: float = typer.Option(None, "--temperature", "-t"),
max_tokens: int = typer.Option(None, "--max-tokens"),
):
"""Update configuration."""
cfg = Config.load()
if model:
cfg.default_model = model
if temperature is not None:
cfg.temperature = temperature
if max_tokens:
cfg.max_tokens = max_tokens
cfg.save()
console.print("[green]Configuration saved.[/green]")
# Use config in commands
@app.command()
def ask(question: str):
"""Ask with configured defaults."""
cfg = Config.load()
from openai import OpenAI
client = OpenAI(api_key=cfg.api_key) if cfg.api_key else OpenAI()
response = client.chat.completions.create(
model=cfg.default_model,
messages=[{"role": "user", "content": question}],
temperature=cfg.temperature,
max_tokens=cfg.max_tokens
)
console.print(response.choices[0].message.content)
Pipe-Friendly CLI
import sys
import typer
from rich.console import Console
app = typer.Typer()
console = Console()
def read_stdin() -> str:
"""Read from stdin if available."""
if not sys.stdin.isatty():
return sys.stdin.read()
return ""
@app.command()
def transform(
instruction: str = typer.Argument(..., help="Transformation instruction"),
input_text: str = typer.Option(None, "--input", "-i", help="Input text (or use stdin)"),
):
"""Transform text using AI. Supports piping."""
# Get input from argument, option, or stdin
text = input_text or read_stdin()
if not text:
console.print("[red]No input provided. Use --input or pipe text.[/red]")
raise typer.Exit(1)
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": f"Transform the input text according to: {instruction}. Output only the transformed text."},
{"role": "user", "content": text}
]
)
# Output to stdout (no formatting for pipe compatibility)
print(response.choices[0].message.content)
@app.command()
def summarize(
length: str = typer.Option("medium", "--length", "-l", help="short, medium, or long"),
):
"""Summarize text from stdin."""
text = read_stdin()
if not text:
console.print("[red]Pipe text to summarize.[/red]")
raise typer.Exit(1)
length_tokens = {"short": 50, "medium": 150, "long": 300}
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": f"Summarize the text in about {length_tokens[length]} words."},
{"role": "user", "content": text}
]
)
print(response.choices[0].message.content)
# Usage examples:
# cat file.txt | llm summarize
# echo "Hello world" | llm transform "translate to French"
# curl https://example.com | llm summarize --length short
References
- Typer: https://typer.tiangolo.com/
- Rich: https://rich.readthedocs.io/
- Click: https://click.palletsprojects.com/
- llm CLI by Simon Willison: https://llm.datasette.io/
Conclusion
LLM-powered CLI tools bring AI capabilities directly into developer workflows. The terminal is where developers spend their time, and well-designed CLI tools integrate seamlessly with existing processes. Use Typer for clean argument parsing and Rich for beautiful output. Support both interactive and pipe-friendly modes. Implement streaming for responsive feedback on long generations. Add configuration management so users can customize defaults. The patterns here—code explanation, commit message generation, text transformation—are just starting points. Any repetitive text task is a candidate for an LLM CLI tool. Build tools that solve your own pain points first, then share them with your team. The best CLI tools are the ones that become muscle memory—so simple and useful that you reach for them without thinking.
Discover more from Code, Cloud & Context
Subscribe to get the latest posts sent to your email.

Leave a Reply