Building AI-Powered CLI Tools: From Script to Distributable Package
Create professional command-line tools with argument parsing, config, testing, and PyPI publishing.
Introduction
CLI tools are the backbone of developer productivity. This guide takes you from a simple script to a polished, installable package with proper structure, testing, and distribution.
Project Structure
my-cli-tool/
├── pyproject.toml # Modern packaging config
├── README.md
├── LICENSE
├── src/
│ └── my_cli_tool/
│ ├── __init__.py
│ ├── __main__.py # Entry point
│ ├── cli.py # Argument parsing
│ ├── commands/ # Subcommands
│ │ ├── __init__.py
│ │ ├── init.py
│ │ ├── run.py
│ │ └── config.py
│ ├── core/ # Business logic
│ │ ├── __init__.py
│ │ ├── engine.py
│ │ └── models.py
│ └── utils/
│ ├── __init__.py
│ ├── config.py
│ └── logging.py
├── tests/
│ ├── __init__.py
│ ├── test_cli.py
│ ├── test_core.py
│ └── fixtures/
└── scripts/
└── install.sh
Step 1: Modern Packaging (pyproject.toml)
# pyproject.toml
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "my-cli-tool"
version = "0.1.0"
description = "AI-powered CLI tool for [purpose]"
readme = "README.md"
license = {text = "MIT"}
authors = [{name = "Your Name", email = "you@example.com"}]
requires-python = ">=3.9"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Topic :: Utilities",
]
dependencies = [
"click>=8.1",
"rich>=13.0",
"pydantic>=2.0",
"pyyaml>=6.0",
"httpx>=0.25",
"openai>=1.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"ruff>=0.1",
"mypy>=1.0",
"pre-commit>=3.0",
]
[project.scripts]
my-cli = "my_cli_tool.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --cov=my_cli_tool --cov-report=term-missing"
[tool.ruff]
line-length = 100
target-version = "py39"
select = ["E", "F", "I", "UP", "B", "C4", "SIM", "T20"]
ignore = ["E501"]
[tool.mypy]
python_version = "3.9"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
Step 2: Configuration Management
# src/my_cli_tool/utils/config.py
import os
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
import yaml
class Config(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="MYCLI_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore"
)
# API Keys
openrouter_api_key: Optional[str] = None
anthropic_api_key: Optional[str] = None
# Defaults
default_model: str = "google/gemini-2.0-flash-exp:free"
max_tokens: int = 2000
temperature: float = 0.7
# Paths
config_dir: Path = Field(default_factory=lambda: Path.home() / ".config" / "my-cli-tool")
cache_dir: Path = Field(default_factory=lambda: Path.home() / ".cache" / "my-cli-tool")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.config_dir.mkdir(parents=True, exist_ok=True)
self.cache_dir.mkdir(parents=True, exist_ok=True)
@property
def config_file(self) -> Path:
return self.config_dir / "config.yaml"
def load_from_file(self) -> "Config":
if self.config_file.exists():
with open(self.config_file) as f:
data = yaml.safe_load(f) or {}
return Config(**{**self.model_dump(), **data})
return self
def save_to_file(self):
with open(self.config_file, "w") as f:
yaml.dump(self.model_dump(exclude={"config_dir", "cache_dir"}), f)
# Global config instance
config = Config().load_from_file()
Step 3: CLI Framework with Click + Rich
# src/my_cli_tool/cli.py
import click
from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.prompt import Prompt, Confirm
from rich.syntax import Syntax
from rich.markdown import Markdown
from pathlib import Path
from typing import Optional
from my_cli_tool.utils.config import config
from my_cli_tool.commands import init, run, config as config_cmd
console = Console()
@click.group(invoke_without_command=True)
@click.version_option(version="0.1.0", prog_name="my-cli")
@click.option("--verbose", "-v", count=True, help="Increase verbosity")
@click.option("--quiet", "-q", is_flag=True, help="Suppress output")
@click.pass_context
def cli(ctx, verbose: int, quiet: bool):
"""🤖 My AI-Powered CLI Tool - Description here"""
ctx.ensure_object(dict)
ctx.obj["verbose"] = verbose
ctx.obj["quiet"] = quiet
if ctx.invoked_subcommand is None:
console.print(Panel.fit(
"[bold cyan]My CLI Tool[/bold cyan] v0.1.0\n"
"AI-powered command-line utility\n\n"
"Run [bold]my-cli --help[/bold] for commands",
title="Welcome",
border_style="blue"
))
@cli.command()
@click.option("--force", "-f", is_flag=True, help="Overwrite existing config")
@click.pass_context
def init(ctx, force: bool):
"""Initialize configuration and project structure"""
init.run(force=force, console=console)
@cli.command()
@click.argument("prompt", required=False)
@click.option("--model", "-m", help="Model to use")
@click.option("--temperature", "-t", type=float, help="Temperature (0-2)")
@click.option("--max-tokens", type=int, help="Max tokens")
@click.option("--stream/--no-stream", default=True, help="Stream output")
@click.option("--save", "-s", type=click.Path(), help="Save output to file")
@click.pass_context
def run(ctx, prompt: Optional[str], model: Optional[str], temperature: Optional[float],
max_tokens: Optional[int], stream: bool, save: Optional[str]):
"""Run AI generation with prompt"""
run.run(
prompt=prompt,
model=model or config.default_model,
temperature=temperature or config.temperature,
max_tokens=max_tokens or config.max_tokens,
stream=stream,
save=save,
console=console
)
@cli.group()
def config():
"""Manage configuration"""
pass
@config.command("show")
def config_show():
"""Show current configuration"""
config_cmd.show(console)
@config.command("set")
@click.argument("key")
@click.argument("value")
def config_set(key: str, value: str):
"""Set configuration value"""
config_cmd.set(key, value, console)
@config.command("path")
def config_path():
"""Show config file path"""
console.print(f"Config file: [cyan]{config.config_file}[/cyan]")
# Error handling
@cli.result_callback()
def handle_result(result, **kwargs):
pass
if __name__ == "__main__":
cli()
Step 4: Command Implementation
# src/my_cli_tool/commands/run.py
import os
import json
from pathlib import Path
from typing import Optional
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.markdown import Markdown
from rich.syntax import Syntax
import httpx
from my_cli_tool.utils.config import config
async def call_openrouter(prompt: str, model: str, temperature: float, max_tokens: int, stream: bool):
"""Call OpenRouter API"""
if not config.openrouter_api_key:
raise ValueError("OpenRouter API key not configured. Run: my-cli config set openrouter_api_key YOUR_KEY")
headers = {
"Authorization": f"Bearer {config.openrouter_api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://github.com/yourusername/my-cli-tool",
"X-Title": "My CLI Tool"
}
data = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": stream
}
async with httpx.AsyncClient(timeout=60.0) as client:
if stream:
async with client.stream("POST", "https://openrouter.ai/api/v1/chat/completions",
headers=headers, json=data) as response:
response.raise_for_status()
async for line in response.aiter_lines():
if line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk["choices"][0]["delta"]
if "content" in delta:
yield delta["content"]
except json.JSONDecodeError:
continue
else:
response = await client.post("https://openrouter.ai/api/v1/chat/completions",
headers=headers, json=data)
response.raise_for_status()
result = response.json()
yield result["choices"][0]["message"]["content"]
def run(prompt: Optional[str], model: str, temperature: float, max_tokens: int,
stream: bool, save: Optional[str], console: Console):
"""Execute the run command"""
# Get prompt from stdin if not provided
if prompt is None:
import sys
if not sys.stdin.isatty():
prompt = sys.stdin.read().strip()
else:
prompt = Prompt.ask("[bold cyan]Enter prompt[/bold cyan]")
if not prompt:
console.print("[red]Error:[/red] No prompt provided")
return
console.print(f"\n[dim]Model: {model} | Temp: {temperature} | Max tokens: {max_tokens}[/dim]\n")
# Run async
import asyncio
output_parts = []
async def execute():
nonlocal output_parts
try:
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
console=console,
transient=True
) as progress:
task = progress.add_task("Generating...", total=None)
async for chunk in call_openrouter(prompt, model, temperature, max_tokens, stream):
output_parts.append(chunk)
if stream:
console.print(chunk, end="", markup=False)
progress.update(task, completed=True)
if not stream:
full_output = "".join(output_parts)
console.print(Markdown(full_output))
# Save if requested
if save:
save_path = Path(save)
save_path.write_text("".join(output_parts))
console.print(f"\n[green]Saved to:[/green] {save_path}")
except httpx.HTTPStatusError as e:
console.print(f"[red]API Error {e.response.status_code}:[/red] {e.response.text}")
except Exception as e:
console.print(f"[red]Error:[/red] {e}")
asyncio.run(execute())
# src/my_cli_tool/commands/init.py
from pathlib import Path
from rich.console import Console
from rich.prompt import Prompt, Confirm
from my_cli_tool.utils.config import config
def run(force: bool, console: Console):
"""Initialize configuration"""
if config.config_file.exists() and not force:
if not Confirm.ask(f"Config exists at [cyan]{config.config_file}[/cyan]. Overwrite?"):
console.print("[yellow]Cancelled[/yellow]")
return
console.print(Panel.fit(
"[bold]Configuration Setup[/bold]\n"
"Enter your API keys (press Enter to skip)",
border_style="blue"
))
# OpenRouter API Key
openrouter_key = Prompt.ask(
"OpenRouter API Key",
default="",
show_default=False,
password=True
)
# Model preference
model = Prompt.ask(
"Default model",
default=config.default_model,
choices=[
"google/gemini-2.0-flash-exp:free",
"mistralai/mistral-7b-instruct:free",
"meta-llama/llama-3.1-8b-instruct:free",
"anthropic/claude-3-haiku",
"openai/gpt-4o-mini"
]
)
# Temperature
temp = Prompt.ask(
"Default temperature (0-2)",
default=str(config.temperature)
)
# Create config
new_config = Config(
openrouter_api_key=openrouter_key if openrouter_key else None,
default_model=model,
temperature=float(temp)
)
new_config.save_to_file()
console.print(f"\n[green]✓[/green] Config saved to [cyan]{config.config_file}[/cyan]")
console.print("\nNext steps:")
console.print(" [bold]my-cli run[/bold] \"Your prompt here\"")
console.print(" [bold]my-cli config show[/bold] to verify")
# src/my_cli_tool/commands/config.py
from rich.console import Console
from rich.table import Table
from rich.syntax import Syntax
import yaml
from my_cli_tool.utils.config import config
def show(console: Console):
"""Display current configuration"""
table = Table(title="Current Configuration", show_header=True)
table.add_column("Key", style="cyan")
table.add_column("Value", style="white")
table.add_column("Source", style="dim")
data = config.model_dump(exclude={"config_dir", "cache_dir"})
for key, value in data.items():
if "key" in key.lower() and value:
# Mask API keys
display = value[:8] + "..." + value[-4:] if len(value) > 12 else "***"
source = "env" if key.upper() in os.environ else "file"
else:
display = str(value)
source = "default" if key in Config.model_fields else "file"
table.add_row(key, display, source)
console.print(table)
console.print(f"\nConfig file: [cyan]{config.config_file}[/cyan]")
def set(key: str, value: str, console: Console):
"""Set a configuration value"""
if key not in config.model_fields:
console.print(f"[red]Unknown config key:[/red] {key}")
console.print("Available keys:", ", ".join(config.model_fields.keys()))
return
# Type conversion
field = config.model_fields[key]
try:
if field.annotation == int:
value = int(value)
elif field.annotation == float:
value = float(value)
elif field.annotation == bool:
value = value.lower() in ("true", "1", "yes", "on")
except ValueError:
console.print(f"[red]Invalid value for {key}[/red]")
return
setattr(config, key, value)
config.save_to_file()
console.print(f"[green]✓[/green] Set [cyan]{key}[/cyan] = {value}")
Step 5: Core Business Logic
# src/my_cli_tool/core/engine.py
from dataclasses import dataclass
from typing import List, Optional
from pydantic import BaseModel
from enum import Enum
class TaskType(str, Enum):
GENERATE = "generate"
ANALYZE = "analyze"
REFACTOR = "refactor"
TEST = "test"
DOCUMENT = "document"
@dataclass
class GenerationRequest:
prompt: str
task_type: TaskType = TaskType.GENERATE
context: Optional[str] = None
files: List[str] = None
def __post_init__(self):
if self.files is None:
self.files = []
class GenerationResult(BaseModel):
success: bool
output: str
metadata: dict = {}
error: Optional[str] = None
class AIEngine:
"""Core engine for AI operations"""
def __init__(self, config):
self.config = config
async def generate(self, request: GenerationRequest) -> GenerationResult:
"""Main generation entry point"""
# Build enhanced prompt with context
full_prompt = self._build_prompt(request)
# Call API (simplified - use your actual implementation)
from my_cli_tool.commands.run import call_openrouter
try:
output_parts = []
async for chunk in call_openrouter(
full_prompt,
self.config.default_model,
self.config.temperature,
self.config.max_tokens,
stream=False
):
output_parts.append(chunk)
return GenerationResult(
success=True,
output="".join(output_parts),
metadata={"model": self.config.default_model}
)
except Exception as e:
return GenerationResult(
success=False,
output="",
error=str(e)
)
def _build_prompt(self, request: GenerationRequest) -> str:
"""Build enhanced prompt with context and task-specific instructions"""
parts = []
# Task-specific system prompt
task_prompts = {
TaskType.GENERATE: "Generate clean, production-ready code.",
TaskType.ANALYZE: "Analyze the code for bugs, security issues, and improvements.",
TaskType.REFACTOR: "Refactor the code for better structure, performance, and readability.",
TaskType.TEST: "Write comprehensive tests including edge cases.",
TaskType.DOCUMENT: "Write clear documentation with examples."
}
parts.append(f"SYSTEM: {task_prompts.get(request.task_type, task_prompts[TaskType.GENERATE])}")
if request.context:
parts.append(f"CONTEXT:\n{request.context}")
if request.files:
parts.append("FILES:")
for f in request.files:
parts.append(f"--- {f} ---")
parts.append(f"TASK:\n{request.prompt}")
return "\n\n".join(parts)
Step 6: Testing
# tests/test_cli.py
import pytest
from click.testing import CliRunner
from my_cli_tool.__main__ import cli
from my_cli_tool.utils.config import Config
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def isolated_config(tmp_path, monkeypatch):
"""Isolated config for testing"""
config_dir = tmp_path / "config"
config_dir.mkdir()
monkeypatch.setenv("MYCLI_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("MYCLI_OPENROUTER_API_KEY", "test-key")
return Config()
def test_cli_help(runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "My AI-Powered CLI Tool" in result.output
def test_config_show(runner, isolated_config):
result = runner.invoke(cli, ["config", "show"])
assert result.exit_code == 0
assert "Configuration" in result.output
def test_config_set(runner, isolated_config):
result = runner.invoke(cli, ["config", "set", "temperature", "0.5"])
assert result.exit_code == 0
assert "temperature" in result.output.lower()
def test_init_command(runner, isolated_config, tmp_path):
result = runner.invoke(cli, ["init", "--force"], input="\n\n0.5\n")
assert result.exit_code == 0
assert "saved" in result.output.lower()
# tests/test_core.py
import pytest
from my_cli_tool.core.engine import AIEngine, GenerationRequest, TaskType
from my_cli_tool.utils.config import Config
@pytest.fixture
def engine():
config = Config(openrouter_api_key="test-key")
return AIEngine(config)
@pytest.mark.asyncio
async def test_generate_request(engine):
request = GenerationRequest(
prompt="Write a hello world function",
task_type=TaskType.GENERATE
)
result = await engine.generate(request)
# This would need mocking in real tests
assert isinstance(result.success, bool)
Step 7: GitHub Actions CI/CD
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: |
pip install -e ".[dev]"
- name: Lint with Ruff
run: ruff check src tests
- name: Type check with MyPy
run: mypy src
- name: Test with pytest
run: pytest --cov=my_cli_tool --cov-report=xml
- name: Upload coverage
uses: codecov/codecov-action@v3
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Build package
run: |
pip install build
python -m build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
Step 8: Publishing to PyPI
# Build
python -m build
# Check
twine check dist/*
# Test install
pip install dist/my_cli_tool-0.1.0.tar.gz
# Publish (requires PyPI token)
twine upload dist/*
# Or use GitHub Actions (tag a release)
git tag v0.1.0
git push origin v0.1.0
Step 9: Homebrew Formula (Optional)
# Formula/my-cli-tool.rb
class MyCliTool < Formula
desc "AI-powered CLI tool for developers"
homepage "https://github.com/yourusername/my-cli-tool"
url "https://github.com/yourusername/my-cli-tool/archive/refs/tags/v0.1.0.tar.gz"
sha256 "YOUR_SHA256_HERE"
license "MIT"
depends_on "python@3.11"
def install
virtualenv_install_with_resources
end
test do
assert_match "My CLI Tool", shell_output("#{bin}/my-cli --help")
end
end
Step 10: Installation Methods
# From PyPI (recommended)
pipx install my-cli-tool
# From GitHub
pipx install git+https://github.com/yourusername/my-cli-tool.git
# Development install
git clone https://github.com/yourusername/my-cli-tool
cd my-cli-tool
pip install -e ".[dev]"
# Homebrew (macOS/Linux)
brew tap yourusername/tap
brew install my-cli-tool
# Standalone binary (PyInstaller)
pip install pyinstaller
pyinstaller --onefile src/my_cli_tool/__main__.py
# dist/my-cli -> copy anywhere
Best Practices Summary
| Area | Recommendation |
|---|---|
| Packaging | Use pyproject.toml, src/ layout, setuptools
|
| CLI Framework | Click + Rich for beautiful output |
| Config | Pydantic Settings with env vars + YAML file |
| Async | Use httpx for API calls, asyncio for concurrency |
| Testing |
pytest + click.testing.CliRunner
|
| Quality |
ruff (lint), mypy (types), pre-commit
|
| CI/CD | GitHub Actions → test → build → PyPI |
| Distribution |
pipx for users, Homebrew for macOS |
| Versioning | Semantic versioning, tag releases |
Complete Example: AI Code Reviewer
# Usage examples
my-cli run "Review this PR for security issues" --model anthropic/claude-3-haiku
my-cli run "Write tests for auth.py" -f auth.py --save tests/test_auth.py
my-cli config set openrouter_api_key sk-or-xxx
my-cli config show
Conclusion
You now have a production-ready CLI tool template. Key takeaways:
-
Structure matters —
src/layout, clear separation - Config first — Pydantic Settings handles complexity
- Rich output — Colors, tables, progress bars delight users
-
Test everything —
CliRunnermakes CLI testing easy - Automate releases — GitHub Actions → PyPI → users
Extend with: plugin system, shell completions, TUI mode, web UI.
Part of the CLI Development series on Dev.to
Top comments (0)