DEV Community

Phil IPS
Phil IPS

Posted on

Building AI-Powered CLI Tools: From Script to Distributable Package

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode
# 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")
Enter fullscreen mode Exit fullscreen mode
# 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}")
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode
# 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)
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Conclusion

You now have a production-ready CLI tool template. Key takeaways:

  1. Structure matters — src/ layout, clear separation
  2. Config first — Pydantic Settings handles complexity
  3. Rich output — Colors, tables, progress bars delight users
  4. Test everything — CliRunner makes CLI testing easy
  5. 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)