ai newsDo., 15. Okt.
EilmeldungMeta and Apple team up to make Muse AI models run better on Macs

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, install

dev.toMontag 18:47 · 12.10.18 Min. Lesezeit
Bild: dev.to

Create professional command-line tools with argument parsing, config, testing, and PyPI publishing.

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.

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 ( " \n Next 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 " \n Config 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\n 0.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:

Extend with: plugin system, shell completions, TUI mode, web UI.

Part of the CLI Development series on Dev.to

Quelle: dev.to (dev.to). Original öffnen