Skip to content
Blog

Building an AI Pair Programmer: From IDE Plugin to CLI

An AI pair programmer is an agent with file tools and a loop. Learn the architecture, then build a minimal CLI pair programmer in Python with Gemini function calling.

Published on August 6, 2026

AI Assistant

An AI pair programmer is not magic — it’s the agent loop from the previous post pointed at a codebase. The model gets tools to read, search, and edit files, decides which to call, observes results, and iterates until the change is made. The only difference between an IDE plugin and a CLI is the surface: the plugin drives an editor’s UI and permission dialogs; the CLI drives the same loop from a terminal. If you can build the loop, you can build either.

The architecture (one loop, two shells)

user request
    → model (gemini-2.5-pro)
        → tool call: read_file / grep / write_file
    → your code executes it
    → result fed back to model
    → repeat until: no tool calls OR iteration cap
  • IDE plugin = the same loop plus editor bindings: highlights, inline diffs, a permission dialog per file write, MCP servers for extra tools.
  • CLI = the same loop in a terminal: read/write files directly, print diffs, prompt for approval.

The model behind both is identical. What changes is the tool layer and the UX.

Prerequisites

  • Python 3.10+
  • A Gemini API key (GEMINI_API_KEY)
  • google-genai installed

Step 1: Declare the file tools

from google import genai
from google.genai import types

client = genai.Client()

def read_file(path: str) -> str:
    with open(path, encoding="utf-8") as f:
        return f.read()

def write_file(path: str, content: str) -> str:
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    return f"wrote {path}"

tools = [
    types.Tool(function_declarations=[
        types.FunctionDeclaration(
            name="read_file",
            description="Read a file from the repository",
            parameters=types.Schema(type=types.Type.OBJECT, properties={
                "path": types.Schema(type=types.Type.STRING, description="Absolute or repo-relative path"),
            }),
        ),
        types.FunctionDeclaration(
            name="write_file",
            description="Overwrite a file with new content",
            parameters=types.Schema(type=types.Type.OBJECT, properties={
                "path": types.Schema(type=types.Type.STRING),
                "content": types.Schema(type=types.Type.STRING),
            }),
        ),
    ])
]

Step 2: The pair-programmer loop

def pair_program(task: str, max_steps: int = 8) -> str:
    messages = [{
        "role": "user",
        "parts": [{"text": f"Work in this repo. {task}\n"
                           "Read before you write. Report what you changed."}],
    }]
    tool_functions = {"read_file": read_file, "write_file": write_file}

    for step in range(max_steps):
        resp = client.models.generate_content(
            model="gemini-2.5-pro",
            contents=messages,
            config=types.GenerateContentConfig(tools=tools, tool_choice="auto"),
        )
        calls = resp.function_calls or []
        if not calls:
            return resp.text                     # done

        messages.append({"role": "model", "parts": [call.to_dict() for call in calls]})
        for call in calls:
            result = tool_functions[call.name](**call.args)
            messages.append({"role": "user", "parts": [
                types.Part(function_response=types.FunctionResponse(
                    name=call.name, response={"result": result}))
            ]})
    return "Reached step cap."

Notice tool_choice="auto": the model decides when it needs to read, and stops calling tools when the change is complete. The max_steps cap is your loop guardrail — a stuck agent can’t burn tokens forever.

Step 3: Add a review gate

A pair programmer that never checks its own work produces confident garbage. Add a deterministic review step: after the model reports done, ask a second pass to verify the diff compiles and the change matches the request.

def review(task: str, final_report: str) -> str:
    return client.models.generate_content(
        model="gemini-2.5-pro",
        contents=(
            f"Task: {task}\n\nChange summary: {final_report}\n\n"
            "Check the diff for correctness. Return APPROVE or CHANGES NEEDED with specifics."
        ),
    ).text

Making it a real CLI

import argparse, difflib, sys

def main():
    p = argparse.ArgumentParser()
    p.add_argument("task", nargs="+")
    args = p.parse_args()
    task = " ".join(args.task)

    report = pair_program(task)
    print("\n=== Agent report ===")
    print(report)
    verdict = review(task, report)
    print("\n=== Review ===")
    print(verdict)
    if "CHANGES NEEDED" in verdict:
        print("Agent will iterate...", file=sys.stderr)

if __name__ == "__main__":
    main()

Production considerations

  • Permissions / sandboxing. IDE plugins route every write through a permission dialog; a CLI should --yes / --restricted split (restricted = no destructive commands). Never let an agent run arbitrary shell on an untrusted workspace.
  • Context files. Both Claude Code (CLAUDE.md) and Gemini Code Assist (GEMINI.md) load a stable project-context file at session start — that’s the pinned region that hits the prompt cache on every turn, keeping long coding sessions economical.
  • MCP for more tools. Register MCP servers (linters, test runners, databases) so the agent can call them as tools instead of you hand-rolling each one.
  • Pin the model. Tool-selection behavior changes across model generations; pin an explicit model id rather than letting a default silently change.

Putting It All Together

Run it:

python pair.py "refactor util.py to use dataclasses and add type hints"
# Agent reads util.py, writes the refactor, reports changes.
# Review pass verifies the diff. Done.

Conclusion & Next Steps

You’ve built the core of every AI coding assistant: a model in a loop with file tools, a review gate, and a CLI shell — the exact same loop that powers an IDE agent mode. Next: add a grep/glob tool, wire in an MCP linter so it self-checks, and add a diff preview with y/n approval before writes to make it trustworthy in real repos.

References / Sources