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}<...
Source:https://dev.to/ipsphil_digital/building-ai-powered-cli-tools-from-script-to-distributable-package-35ld