Posted on
Artificial Intelligence

Building Artificial Intelligence CLI Tools with Python

Author
  • User
    linuxbash
    Posts by this author
    Posts by this author

Building Artificial Intelligence CLI Tools with Python (for Linux Bash Users)

What if “using AI” felt like running grep or awk—quick, scriptable, and right in your terminal? In this guide, you’ll build practical AI-powered command‑line tools with Python that integrate cleanly into your existing Bash workflows. We’ll cover both hosted APIs (OpenAI) and local models (Ollama), show you how to package a real CLI, and share patterns that make your tools fast, composable, and safe to automate.

Why AI on the CLI?

  • It meets you where you work. Bash users already chain small tools into big wins; an AI CLI is just another sharp tool.

  • It’s script-friendly. Pipe data in, stream answers out, return sensible exit codes.

  • It’s portable. A small Python CLI can run on any distro you SSH into.

  • It’s private (if you choose). Local models via Ollama keep data on your machine.

You’ll leave with working examples and a structure you can copy for your own tools (summarizers, commit message generators, code reviewers, changelog writers, and more).


Prerequisites (apt, dnf, zypper)

We’ll need Python, pip/venv, and some helpers (git, curl). Choose your package manager:

  • Debian/Ubuntu (apt):

    sudo apt update
    sudo apt install -y python3 python3-venv python3-pip git curl build-essential
    # Optional: pipx for isolated CLI installs
    sudo apt install -y pipx || python3 -m pip install --user pipx
    python3 -m pipx ensurepath
    
  • Fedora/RHEL/CentOS (dnf):

    sudo dnf install -y python3 python3-pip python3-virtualenv git curl gcc gcc-c++ make
    # Optional: pipx
    sudo dnf install -y pipx || python3 -m pip install --user pipx
    python3 -m pipx ensurepath
    
  • openSUSE (zypper):

    sudo zypper refresh
    sudo zypper install -y python3 python3-pip python3-virtualenv git curl gcc gcc-c++ make
    # Optional: pipx
    sudo zypper install -y python3-pipx || python3 -m pip install --user pipx
    python3 -m pipx ensurepath
    

We’ll also show how to use:

  • OpenAI’s Python SDK (hosted API)

  • Ollama (local LLMs)

Install Ollama (Linux):

curl -fsSL https://ollama.com/install.sh | sh
# Then pull a model, e.g.:
ollama pull llama3

Step 1 — Scaffold a Clean Python CLI

We’ll create a small package called ai-cli with a console script entry point ai.

Directory layout:

ai-cli/
  pyproject.toml
  ai_cli/
    __init__.py
    cli.py

Create pyproject.toml:

[build-system]
requires = ["hatchling>=1.21.1"]
build-backend = "hatchling.build"

[project]
name = "ai-cli"
version = "0.1.0"
description = "AI-powered command line tools for Linux users"
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
  "requests>=2.31.0",
  "openai>=1.0.0",
  "python-dotenv>=1.0.0"
]

[project.scripts]
ai = "ai_cli.cli:main"

Create ai_cli/cli.py:

#!/usr/bin/env python3
import os, sys, argparse, textwrap, hashlib, json, time, subprocess, pathlib
from typing import Optional, Iterable

CACHE_DIR = pathlib.Path(os.getenv("XDG_CACHE_HOME", pathlib.Path.home() / ".cache")) / "ai-cli"
CACHE_DIR.mkdir(parents=True, exist_ok=True)

def _hash_key(s: str) -> str:
    import hashlib
    return hashlib.sha256(s.encode("utf-8")).hexdigest()

def _cache_get(key: str) -> Optional[str]:
    p = CACHE_DIR / f"{key}.txt"
    if p.exists():
        return p.read_text(encoding="utf-8")
    return None

def _cache_set(key: str, value: str) -> None:
    p = CACHE_DIR / f"{key}.txt"
    p.write_text(value, encoding="utf-8")

def _read_input(file: Optional[str]) -> str:
    if file:
        return pathlib.Path(file).read_text(encoding="utf-8")
    if not sys.stdin.isatty():
        return sys.stdin.read()
    raise SystemExit("No input provided. Pass a file or pipe content via stdin.")

def _print_stream(chunks: Iterable[str]) -> str:
    buf = []
    for chunk in chunks:
        buf.append(chunk)
        print(chunk, end="", flush=True)
    print()
    return "".join(buf)

# Backends
def _openai_complete(prompt: str, model: Optional[str], stream: bool) -> Iterable[str] | str:
    from openai import OpenAI
    api_key = os.getenv("OPENAI_API_KEY")
    if not api_key:
        raise SystemExit("Missing OPENAI_API_KEY in environment.")
    client = OpenAI(api_key=api_key)
    model = model or os.getenv("OPENAI_MODEL", "gpt-4o-mini")
    messages = [{"role": "user", "content": prompt}]
    if stream:
        resp = client.chat.completions.create(model=model, messages=messages, stream=True)
        def gen():
            for event in resp:
                delta = event.choices[0].delta.content or ""
                if delta:
                    yield delta
        return gen()
    else:
        resp = client.chat.completions.create(model=model, messages=messages)
        return resp.choices[0].message.content

def _ollama_complete(prompt: str, model: Optional[str], stream: bool) -> Iterable[str] | str:
    import requests
    url = os.getenv("OLLAMA_URL", "http://localhost:11434/api/generate")
    model = model or os.getenv("OLLAMA_MODEL", "llama3")
    payload = {"model": model, "prompt": prompt, "stream": stream}
    if stream:
        r = requests.post(url, json=payload, stream=True, timeout=600)
        r.raise_for_status()
        def gen():
            for line in r.iter_lines():
                if not line:
                    continue
                try:
                    obj = json.loads(line.decode("utf-8"))
                    yield obj.get("response", "")
                except Exception:
                    continue
        return gen()
    else:
        r = requests.post(url, json=payload, timeout=600)
        r.raise_for_status()
        return r.json().get("response", "")

def complete(prompt: str, model: Optional[str] = None, stream: bool = False) -> str:
    backend = os.getenv("AI_BACKEND", "ollama").lower()
    key = _hash_key(f"{backend}|{model}|{prompt}")
    if not os.getenv("AI_NO_CACHE"):
        cached = _cache_get(key)
        if cached:
            return cached
    if backend == "openai":
        result = _print_stream(_openai_complete(prompt, model, True)) if stream else _openai_complete(prompt, model, False)  # type: ignore
    elif backend == "ollama":
        result = _print_stream(_ollama_complete(prompt, model, True)) if stream else _ollama_complete(prompt, model, False)  # type: ignore
    else:
        raise SystemExit(f"Unknown AI_BACKEND: {backend}")
    if isinstance(result, str):
        _cache_set(key, result)
        return result
    else:
        out = result  # type: ignore
        _cache_set(key, out)
        return out

def cmd_summarize(args):
    text = _read_input(args.file)
    sys_prompt = "You are a concise Linux/Bash technical summarizer. Output short bullet points."
    prompt = f"{sys_prompt}\n\n=== INPUT START ===\n{text}\n=== INPUT END ==="
    res = complete(prompt, model=args.model, stream=args.stream)
    if isinstance(res, str):
        print(res)

def cmd_ask(args):
    prompt = args.prompt or _read_input(None)
    res = complete(prompt, model=args.model, stream=args.stream)
    if isinstance(res, str):
        print(res)

def _git_diff(staged: bool) -> str:
    cmd = ["git", "diff", "--staged"] if staged else ["git", "diff"]
    try:
        out = subprocess.check_output(cmd, text=True, stderr=subprocess.STDOUT)
        return out.strip()
    except subprocess.CalledProcessError as e:
        raise SystemExit(e.output)

def cmd_commit(args):
    diff = _git_diff(staged=True)
    if not diff:
        raise SystemExit("No staged changes. Stage files first (git add).")
    prompt = textwrap.dedent(f"""
        You are a senior developer. Write a clear, conventional commit message
        (title: 72 chars max; body: wrapped at 72; include scope/types if obvious)
        based on this git diff:

        {diff}
    """).strip()
    res = complete(prompt, model=args.model, stream=args.stream)
    if isinstance(res, str):
        msg = res.strip()
        if args.preview:
            print(msg)
        else:
            # Open editor pre-filled or directly commit
            subprocess.run(["git", "commit", "-m", msg])
            print("Committed with AI-generated message.")

def cmd_env(_args):
    print("AI_BACKEND:", os.getenv("AI_BACKEND", "ollama"))
    print("OPENAI_MODEL:", os.getenv("OPENAI_MODEL", "gpt-4o-mini"))
    print("OLLAMA_MODEL:", os.getenv("OLLAMA_MODEL", "llama3"))
    print("Cache dir:", str(CACHE_DIR))
    print("AI_NO_CACHE:", os.getenv("AI_NO_CACHE", ""))

def main():
    p = argparse.ArgumentParser(prog="ai", description="AI CLI tools for Linux users")
    sub = p.add_subparsers(dest="cmd", required=True)

    sp = sub.add_parser("summarize", help="Summarize stdin or a file")
    sp.add_argument("file", nargs="?", help="File to summarize (optional, else stdin)")
    sp.add_argument("-m", "--model", help="Override model name")
    sp.add_argument("-s", "--stream", action="store_true", help="Stream output")
    sp.set_defaults(func=cmd_summarize)

    ap = sub.add_parser("ask", help="Ask a question (arg or stdin)")
    ap.add_argument("prompt", nargs="?", help="Prompt text (optional, else stdin)")
    ap.add_argument("-m", "--model", help="Override model name")
    ap.add_argument("-s", "--stream", action="store_true", help="Stream output")
    ap.set_defaults(func=cmd_ask)

    cp = sub.add_parser("commit", help="Generate a commit message from staged diff")
    cp.add_argument("-m", "--model", help="Override model name")
    cp.add_argument("-s", "--stream", action="store_true", help="Stream output")
    cp.add_argument("--preview", action="store_true", help="Print message instead of committing")
    cp.set_defaults(func=cmd_commit)

    ep = sub.add_parser("env", help="Show effective configuration")
    ep.set_defaults(func=cmd_env)

    args = p.parse_args()
    try:
        args.func(args)
    except KeyboardInterrupt:
        sys.exit(130)
    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()

Make it executable:

chmod +x ai_cli/cli.py

Install locally with pipx (recommended):

cd ai-cli
pipx install .

Or with pip in a venv:

python3 -m venv .venv
. .venv/bin/activate
pip install .

Step 2 — Choose a Model Backend (OpenAI or Ollama)

You can switch at runtime with environment variables.

  • Use OpenAI (hosted):

    export AI_BACKEND=openai
    export OPENAI_API_KEY=sk-...
    export OPENAI_MODEL=gpt-4o-mini   # or another available model
    
  • Use Ollama (local, private, free to run):

    export AI_BACKEND=ollama
    export OLLAMA_MODEL=llama3        # or mistral, codellama, etc.
    # optional custom URL if remote:
    # export OLLAMA_URL=http://remote-host:11434/api/generate
    

Tip: Save these in your shell profile or use a .env file:

AI_BACKEND=ollama
OLLAMA_MODEL=llama3

Load with:

python -m pip install --user python-dotenv
# or just `source .env` in Bash

Optional: Install direnv to auto-load per-directory env:

  • apt:

    sudo apt install -y direnv
    
  • dnf:

    sudo dnf install -y direnv
    
  • zypper:

    sudo zypper install -y direnv
    

    Then echo 'eval "$(direnv hook bash)"' >> ~/.bashrc and add .envrc.


Step 3 — Real-World Examples You Can Use Today

Once installed, try:

1) Summarize logs or docs:

journalctl -u ssh.service --no-pager | tail -n 200 | ai summarize -s

Or:

ai summarize README.md

2) Generate a commit message:

git add -A
ai commit --preview
# If you like it:
ai commit

3) Ask ad-hoc questions (great in scripts):

echo "Explain Linux cgroups v2 in 5 bullets for a sysadmin." | ai ask -s

These commands are pipe-friendly, support streaming, and cache results to avoid re-billing or re-computing identical prompts. Disable cache per call:

AI_NO_CACHE=1 ai summarize myfile.txt

Step 4 — Package, Distribute, and Keep Secrets Safe

  • Secrets:

    • Never hardcode keys. Use env vars (OPENAI_API_KEY) or .env files ignored by git.
    • Consider per-project env via direnv.
  • Packaging:

    • We used pyproject.toml with a console script, so ai is on your PATH after pipx install ..
    • To share, publish to a git repo and install directly:
    pipx install git+https://github.com/yourname/ai-cli
    
    • Or build a wheel:
    python -m build
    pipx install dist/ai_cli-0.1.0-py3-none-any.whl
    
  • Version pinning:

    • In pyproject.toml, pin major versions for stability (already done with >= minimums; you can lock exact versions if desired).

Step 5 — Polish for Production (UX, Errors, Performance)

  • Streaming output:

    • Already implemented with -s/--stream to get tokens incrementally—feels snappy in the terminal.
  • Exit codes:

    • Non-zero on error, 130 on Ctrl-C—so scripts can detect failures.
  • Caching:

    • Simple SHA-256 cache in ~/.cache/ai-cli. Toggle with AI_NO_CACHE=1 for one-offs.
  • Timeouts and retries:

    • Requests to Ollama include a timeout; for APIs, consider exponential backoff if you’re batching tasks.
  • Safety defaults:

    • Summarizer uses a clear system prompt for concise, bullet output—tune prompts for predictable formatting in pipelines.
  • Observability:

    • Wrap calls with timestamps or write simple logs if you’re running many automated invocations.

Optional: Installing Dev Dependencies via Your Package Manager

If you plan to hack further:

  • Ubuntu/Debian (apt):

    sudo apt install -y ripgrep jq
    
  • Fedora (dnf):

    sudo dnf install -y ripgrep jq
    
  • openSUSE (zypper):

    sudo zypper install -y ripgrep jq
    

These help when wiring CLIs into Bash pipelines.


Quick Reference Commands

  • Switch backends:

    export AI_BACKEND=openai   # or ollama
    
  • Set models:

    export OPENAI_MODEL=gpt-4o-mini
    export OLLAMA_MODEL=llama3
    
  • Run:

    echo "Summarize this long text" | ai summarize -s
    ai ask "Write a one-liner bash command to find the 10 largest files"
    git add -A && ai commit --preview
    

Conclusion / Call to Action

You now have a practical, extensible pattern for AI in the terminal:

  • A simple Python CLI that reads stdin, streams results, and plays nicely with Bash.

  • Pluggable backends (OpenAI for hosted convenience; Ollama for local, private inference).

  • Real commands you can drop into daily workflows.

Next steps:

  • Add new subcommands: ai review (PR feedback), ai explain (explain shell pipelines), ai changelog.

  • Share your CLI with your team via a git repo and pipx install.

  • Benchmark local vs hosted models for your use cases and tune prompts for consistent, machine-readable outputs.

If this unlocked ideas, turn one into a subcommand today—your future self at 2 a.m. debugging prod will thank you.