Build a Custom AI Terminal Assistant with Python: A Complete Step-by-Step Developer Tutorial

1 comment
(Developer Tutorials) - Stop context-switching between your terminal and a browser. Here's how to build a custom AI assistant that lives in your CLI, understands your project, and executes shell commands for you.

Build a Custom AI Terminal Assistant with Python: A Complete Step-by-Step Developer Tutorial

I spend way too much time in my terminal. And I hate leaving it.

Every time I need to look up a command, parse a log file, or figure out why a Docker container won’t start, I’m yanked out of my flow. Open a browser. Type a query. Scroll through Stack Overflow. Copy-paste. Hope it works.

How We Rebuilt a Legacy Logistics Platform in 6 Weeks: A Real Vietnam Offshore Case Study

How We Rebuilt a Legacy Logistics Platform in 6 Weeks: A Real Vietnam Offshore Case Study

How We Rebuilt a Legacy Logistics Platform in 6 Weeks: A Real Vietnam Offshore Case Study Let me… ...

That’s stupid. So I built something better.

A custom AI terminal assistant that lives right in my CLI. It understands my project context, executes shell commands, and even writes code snippets. No browser needed. No context switching.

Orchestration vs Choreography: Why Your Multi-Agent System Needs Both (and How to Get It Right)

Orchestration vs Choreography: Why Your Multi-Agent System Needs Both (and How to Get It Right)

Orchestration vs Choreography: Why Your Multi-Agent System Needs Both (and How to Get It Right) Let me be… ...

Here’s exactly how you can build one too. We’ll use Python, OpenAI’s API, and a few clever tricks to make it feel like a native part of your development environment.

Why Build Your Own?

You could just use GitHub Copilot in the terminal. Or Claude Code. But there’s a catch: those tools are generic. They don’t know your specific project structure, your custom scripts, or your team’s conventions.

Building your own gives you:

  • Full control over the model and prompts
  • Custom context injection (your project’s README, your API docs, your config files)
  • No data leaving your machine (if you use a local LLM)
  • The ability to execute commands with your explicit approval

Let’s be real: most AI coding tools are black boxes. You don’t know what they’re sending to the cloud. When you build your own, you own the pipeline.

What We’re Building

A CLI tool called `termai` that:

  1. Takes a natural language query from the terminal
  2. Injects relevant project context (file tree, recent git history, key config files)
  3. Sends it to an LLM (we’ll use OpenAI, but you can swap in Ollama or Claude)
  4. Returns a response that can include shell commands, code blocks, or explanations
  5. Optionally executes approved shell commands directly

Here’s what it looks like in action:

bash
$ termai "find all Python files that import requests and are larger than 100 lines"

Response:

bash
Found 3 files matching your criteria:
- src/api/client.py (245 lines)
- tests/test_api.py (180 lines)
- scripts/fetch_data.py (120 lines)

Want me to show you the import lines? (y/n)

That’s the kind of interaction that saves you 10 minutes of `grep`, `find`, and `awk` gymnastics.

Step 1: Project Setup

Create a new directory and set up a virtual environment:

bash
mkdir termai
cd termai
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

Install the dependencies:

bash
pip install openai typer rich pyyaml
  • openai: For the LLM API calls
  • typer: For building the CLI interface (it’s like Click but cleaner)
  • rich: For pretty terminal output (syntax highlighting, tables, markdown rendering)
  • pyyaml: For reading project config files

Create the main file:

bash
touch termai.py

Step 2: The Core CLI Structure

Let’s build the basic CLI scaffold with Typer. Open `termai.py`:

python
#!/usr/bin/env python3
import typer
from rich.console import Console
from rich.markdown import Markdown
from rich.syntax import Syntax
import subprocess
import os
import json
from pathlib import Path

app = typer.Typer()
console = Console()

@app.command()
def ask(
    query: str = typer.Argument(..., help="Your natural language query"),
    context: bool = typer.Option(True, "--no-context", help="Skip project context injection"),
    execute: bool = typer.Option(False, "--execute", "-x", help="Auto-execute shell commands"),
):
    """Ask termai a question about your project."""
    
    # 1. Gather project context
    project_context = ""
    if context:
        project_context = gather_project_context()
    
    # 2. Build the prompt
    prompt = build_prompt(query, project_context)
    
    # 3. Call the LLM
    response = call_llm(prompt)
    
    # 4. Display the response
    display_response(response)
    
    # 5. Handle command execution
    if execute:
        handle_execution(response)

def gather_project_context() -> str:
    """Collect relevant context from the current project."""
    # We'll implement this in Step 3
    return ""

def build_prompt(query: str, context: str) -> str:
    """Build the system prompt with context."""
    # We'll implement this in Step 4
    return ""

def call_llm(prompt: str) -> str:
    """Call the OpenAI API."""
    # We'll implement this in Step 5
    return ""

def display_response(response: str):
    """Render the response with syntax highlighting."""
    # We'll implement this in Step 6
    console.print(response)

def handle_execution(response: str):
    """Extract and execute shell commands from the response."""
    # We'll implement this in Step 7
    pass

if __name__ == "__main__":
    app()

Run it to test:

bash
python termai.py "hello world"

You should see the help text. Good. Now let’s make it actually do something.

Step 3: Gathering Project Context

This is where the magic happens. A generic AI doesn’t know your project. We need to feed it relevant information.

Let’s implement `gather_project_context()`:

python
def gather_project_context() -> str:
    """Collect relevant context from the current project."""
    context_parts = []
    project_root = Path.cwd()
    
    # 1. File tree (top 3 levels, exclude common noise)
    tree = get_file_tree(project_root, max_depth=3)
    context_parts.append(f"## Project File Tree\n```\n{tree}\n```")
    
    # 2. Git status (recent changes)
    try:
        git_log = subprocess.run(
            ["git", "log", "--oneline", "-10"],
            capture_output=True, text=True, cwd=project_root
        )
        if git_log.returncode == 0:
            context_parts.append(f"## Recent Git History\n```\n{git_log.stdout}\n```")
    except FileNotFoundError:
        pass  # Not a git repo
    
    # 3. Key config files (pyproject.toml, package.json, etc.)
    config_files = ["pyproject.toml", "package.json", "Cargo.toml", "go.mod", "Makefile"]
    for config_file in config_files:
        config_path = project_root / config_file
        if config_path.exists():
            content = config_path.read_text()[:2000]  # Limit to 2000 chars
            context_parts.append(f"## {config_file}\n```\n{content}\n```")
    
    # 4. README (first 1000 chars)
    readme_path = project_root / "README.md"
    if readme_path.exists():
        content = readme_path.read_text()[:1000]
        context_parts.append(f"## README (first 1000 chars)\n{content}")
    
    return "\n\n".join(context_parts)

def get_file_tree(path: Path, max_depth: int = 3, prefix: str = "") -> str:
    """Generate a file tree string, excluding common noise directories."""
    exclude_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", ".tox", "dist", "build", ".egg-info"}
    exclude_files = {".DS_Store", "*.pyc", "*.pyo"}
    
    tree_lines = []
    items = sorted([p for p in path.iterdir() if p.name not in exclude_dirs and not any(p.name.endswith(ext.replace("*", "")) for ext in exclude_files)])
    
    for i, item in enumerate(items):
        is_last = i == len(items) - 1
        connector = "└── " if is_last else "├── "
        tree_lines.append(f"{prefix}{connector}{item.name}")
        
        if item.is_dir() and max_depth > 1:
            extension = "    " if is_last else "│   "
            tree_lines.append(get_file_tree(item, max_depth - 1, prefix + extension))
    
    return "\n".join(tree_lines)

This gives the LLM a snapshot of your project. It’s not the full codebase—that would blow the token budget—but it’s enough to answer most questions intelligently.

Step 4: Building the Prompt

Now we need a system prompt that tells the LLM how to behave:

python
def build_prompt(query: str, context: str) -> str:
    """Build the system prompt with context."""
    system_prompt = """You are termai, an AI terminal assistant for software developers.
Your job is to help the user with their project using natural language.

Rules:
1. Be concise. No fluff. Developers hate reading paragraphs.
2. If you suggest a shell command, wrap it in ```bash blocks.
3. If you suggest code, wrap it in the appropriate language block.
4. If you're unsure, say so. Don't hallucinate.
5. Prefer showing the actual command over describing what to do.
6. When appropriate, ask if the user wants you to execute the command.

Current project context:
"""
    
    if context:
        full_prompt = system_prompt + context + f"\n\n## User Query\n{query}"
    else:
        full_prompt = f"You are termai. Answer this query concisely:\n\n{query}"
    
    return full_prompt

Notice the tone. It’s direct. No “I understand your question” nonsense. Developers want answers, not pleasantries.

Step 5: Calling the LLM

Let’s implement the API call. We’ll use OpenAI’s API, but you can swap this for any provider:

python
import os
from openai import OpenAI

client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def call_llm(prompt: str) -> str:
    """Call the OpenAI API with the prompt."""
    try:
        response = client.chat.completions.create(
            model="gpt-4o-mini",  # Fast and cheap. Swap to gpt-4o for complex queries.
            messages=[
                {"role": "system", "content": "You are a helpful terminal assistant for developers."},
                {"role": "user", "content": prompt}
            ],
            temperature=0.3,  # Low temperature for more deterministic responses
            max_tokens=2000,
        )
        return response.choices[0].message.content
    except Exception as e:
        return f"Error calling LLM: {str(e)}"

Set your API key:

bash
export OPENAI_API_KEY="sk-your-key-here"

Pro tip: If you want to keep data local, swap the client for Ollama:

python
# Alternative: Use Ollama for local inference
import requests

def call_llm_local(prompt: str) -> str:
    response = requests.post(
        "http://localhost:11434/api/generate",
        json={"model": "codellama:7b", "prompt": prompt, "stream": False}
    )
    return response.json()["response"]

Step 6: Pretty Display with Rich

Let’s make the output readable:

python
def display_response(response: str):
    """Render the response with syntax highlighting."""
    # Check if response contains code blocks
    if "```" in response:
        # Split by code blocks and render each part
        parts = response.split("```")
        for i, part in enumerate(parts):
            if i % 2 == 0:
                # Regular text
                if part.strip():
                    console.print(Markdown(part))
            else:
                # Code block
                lines = part.split("\n")
                language = lines[0].strip() if lines else ""
                code = "\n".join(lines[1:]) if len(lines) > 1 else ""
                if code.strip():
                    syntax = Syntax(code, language or "python", theme="monokai", line_numbers=True)
                    console.print(syntax)
    else:
        console.print(Markdown(response))

This renders markdown and code blocks with proper syntax highlighting. It’s a small touch that makes a huge difference in readability.

Step 7: Executing Shell Commands

Here’s the risky but powerful part. We’ll extract shell commands from the response and offer to execute them:

python
import re

def handle_execution(response: str):
    """Extract and execute shell commands from the response."""
    # Find all bash code blocks
    pattern = r"```bash\n(.*?)```"
    matches = re.findall(pattern, response, re.DOTALL)
    
    if not matches:
        return
    
    for cmd in matches:
        cmd = cmd.strip()
        if not cmd:
            continue
        
        console.print(f"\n[bold yellow]Proposed command:[/bold yellow]")
        console.print(Syntax(cmd, "bash", theme="monokai"))
        
        confirm = typer.confirm("Execute this command?")
        if confirm:
            try:
                result = subprocess.run(
                    cmd, shell=True, capture_output=True, text=True, cwd=Path.cwd()
                )
                if result.stdout:
                    console.print(f"[green]Output:[/green]\n{result.stdout}")
                if result.stderr:
                    console.print(f"[red]Error:[/red]\n{result.stderr}")
                if result.returncode != 0:
                    console.print(f"[red]Exit code: {result.returncode}[/red]")
            except Exception as e:
                console.print(f"[red]Execution failed: {str(e)}[/red]")

Important safety note: Never auto-execute commands without confirmation. I’ve seen people pipe `rm -rf /` into their terminal. Don’t be that person.

Step 8: Putting It All Together

Here’s the complete `termai.py` file:

Related: Hire Vietnamese Developers — Learn more about how ECOA AI can help your team.

Related: Vietnam development team — Learn more about how ECOA AI can help your team.

Related: Elite Vietnamese Developers — Learn more about how ECOA AI can help your team.

Related: Hire Elite Vietnamese Developers — Learn more about how ECOA AI can help your team.

Related reading: Outsourcing Software in 2025: The Hard Truths, Hidden Costs, and How to Get It Right

Leave a Comment

Your email address will not be published. Required fields are marked *

Ready to Build with AI-Powered Developers?

Hire Vietnamese engineers augmented by ECOA AI Platform + Claude Code. 5x faster, 40% cheaper.