Local AI File Explainer

Build a Python CLI that explains files with a private local AI model.

Introduction

30 Second Summary

A useful answer often sits behind an upload screen or an account. That feels excessive when the question is about a file already on your Mac.

In this project, you will build a Python CLI powered by Ollama for private local answers. You can type a question or point it at a file to see an explanation in your terminal.

What You'll Build

You will demo a terminal assistant that turns a question or local file into an answer that appears piece by piece on your screen.

By the end of this project, you'll have:

  • A private question command that returns locally generated answers in your terminal.
  • A file explanation command that turns a UTF-8 text or code file into a clear description.
  • Friendly error messages that replace raw tracebacks when a file or local model is unavailable.
  • Secret Mission: Add a --style option for beginner, concise, or reviewer responses.

What do I need before starting?

You need a Mac running macOS Sonoma 14 or newer. The setup covers Python 3.15.0, Visual Studio Code, Ollama 0.40.5, plus the 523 MB local model.

Before We Start

Before the hands-on work begins, this is your moment to commit to building a private terminal tool that answers questions and explains local UTF-8 files. Local processing keeps those file contents on your Mac instead of uploading them to a hosted API.

Set Up a Local AI Workspace

Your private CLI depends on four local layers. You need a supported Python runtime, an editor, a running model service, plus isolated project dependencies.

This step prepares Visual Studio Code for development. It also sets up Ollama to run the model on your Mac without an API key.

In this step, get ready to:
  • Confirm your Mac supports the required Python runtime.
  • Install the local model service with the selected model.
  • Create an isolated project with its pinned dependencies.
Prepare Python and Visual Studio Code

Ollama requires macOS Sonoma 14 or newer. Checking this first prevents you from installing tools that your Mac cannot run.

  • Select the Apple menu in the top-left corner of your screen.
  • Open the page that displays information about your Mac.
  • Confirm the displayed macOS version starts with 14 or a higher number.
  • Pause this project if the displayed version is lower than 14.

Python 3.15.0 provides the runtime for your application. A version check tells you whether the correct interpreter is ready.

  • Press Cmd+Space to open macOS search.
  • Type Terminal into the search field.
  • Press Enter to open Terminal.
  • Check your Python version by running this command:
python3 --version

What does this check do?

The --version option prints the interpreter version without starting Python. Use the result to select the matching path below.

✔️ I see the required Python version

Your terminal shows Python 3.15.0. Your runtime is ready for the project.

ⓧ I see an older version

The existing interpreter does not meet this project's version requirement. Install Python 3.15.0 before continuing.

  • Visit the official Python downloads page.
  • Download the macOS installer for Python 3.15.0.
  • Run the downloaded installer.
  • Keep the default installation choices when prompted.
  • Close Terminal after the installation completes.
  • Reopen Terminal through macOS search.
  • Repeat the version check shown above.

What should you see?

A fresh Terminal session picks up the interpreter installed by Python.org. Continue when the version check displays Python 3.15.0.

ⓧ Command not found

Your shell cannot find a Python 3 interpreter. The official macOS installer adds the required runtime.

  • Visit the official Python downloads page.
  • Download the macOS installer for Python 3.15.0.
  • Run the downloaded installer.
  • Keep the default installation choices when prompted.
  • Close Terminal after the installation completes.
  • Reopen Terminal through macOS search.
  • Repeat the version check shown above.

What should you see?

The command should now print Python 3.15.0. That output proves the interpreter is available from your shell.

Visual Studio Code gives you one place to edit files and run commands. Its terminal launcher also lets you open a folder directly from Terminal.

✔️ Visual Studio Code is installed

Visual Studio Code is already in your Applications folder. Continue with the launcher setup below.

ⓧ Visual Studio Code is not installed

  • Press Cmd+Space to open macOS search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Press Cmd+Shift+P to open the Command Palette.
  • Enter Shell Command: Install 'code' command in PATH into the Command Palette.
  • Select the matching command.

Why install the terminal launcher?

The launcher adds the code command to your shell path. You can then open the current folder in Visual Studio Code from Terminal.

  • Return to the Terminal window from earlier.
  • Quit Terminal to refresh its shell path.
  • Press Cmd+Space to open macOS search.
  • Type Terminal into the search field.
  • Press Enter to reopen Terminal.

Having trouble with Python or the code launcher?

Close every Terminal window after installing Python or the Visual Studio Code launcher. A new Terminal session reloads the shell path.

If the problem continues, help me check why Python or the code launcher is unavailable.

Install Ollama and download the model

Ollama runs the model as a local service on your Mac. The project uses Ollama 0.40.5 with the qwen3:0.6b model.

  • Check the installed Ollama version by running this command in Terminal:
ollama -v

What does this check do?

The version check confirms whether the Ollama command is installed. Its output also tells you whether an existing installation needs updating.

✔️ I see the required Ollama version

Your terminal shows Ollama 0.40.5. Continue below to start the local service.

ⓧ I see an older version

The installed application needs an update. The current disk image contains Ollama 0.40.5.

  • Visit the official Ollama release page.
  • Download Ollama.dmg.
  • Open the downloaded disk image.
  • Move Ollama into your Applications folder.
  • Allow Ollama to create its command-line link if prompted.
  • Repeat the version check shown above.

What should you see?

The updated command should report Ollama 0.40.5.

ⓧ Command not found

Ollama is not installed in your shell path. Install the macOS application before downloading the model.

  • Visit the official Ollama release page.
  • Download Ollama.dmg.
  • Open the downloaded disk image.
  • Move Ollama into your Applications folder.
  • Allow Ollama to create its command-line link if prompted.
  • Repeat the version check shown above.

What should you see?

The command should now report Ollama 0.40.5.

  • Press Cmd+Space to open macOS search.
  • Type Ollama into the search field.
  • Press Enter to open Ollama.

The first model run downloads about 523 MB before opening a local chat. Expect the download to take a few minutes on a typical connection.

  • Download the selected model and open its local chat by running this command:
ollama run qwen3:0.6b

What does this command do?

Ollama downloads qwen3:0.6b when it is missing. It then opens an interactive prompt backed by the model running on your Mac.

  • Enter a short question about local AI privacy.
  • Wait for the model to print its answer.
  • Press Ctrl+C to return to your shell.

Your test answer proves the local service can generate text. The model is now available for the Python CLI you build next.

Before you check the download, do you expect the model name to appear in Ollama's local list?

  • Confirm the model is stored locally by running this command:
ollama ls

What does this check prove?

The model list shows every model available to your local Ollama service. Finding qwen3:0.6b confirms the download finished.

You should see qwen3:0.6b in the model list. That result confirms the service has the exact model required by the project.

Model missing or service unavailable?

  • Confirm the Ollama application remains open while you run its commands.
  • Run the model command again if the earlier download stopped before completion.
  • Use help me diagnose my Ollama installation or model download.
Create the isolated Python project

A virtual environment keeps this project's packages separate from the rest of your Mac. The local-ai-file-explainer folder holds the environment plus every file you build later.

  • Return to the Terminal window from earlier.
  • Move to your Desktop by running these commands:
cd ~/Desktop
mkdir local-ai-file-explainer
cd local-ai-file-explainer

What do these commands do?

  • The first command moves your shell to the Desktop.
  • The second command creates the local-ai-file-explainer folder.
  • The final command moves your shell into that new folder.
  • Open the current folder in Visual Studio Code by running this command:
code .

What does this command do?

The dot represents the folder your terminal is currently using. The code launcher opens that folder as your Visual Studio Code workspace.

You should see local-ai-file-explainer in the Explorer sidebar. That visible folder name also proves the terminal launcher works.

Did the code command fail?

Return to Visual Studio Code's Command Palette and run Shell Command: Install 'code' command in PATH again. Restart Terminal before retrying the launcher.

Use help me fix the Visual Studio Code terminal launcher.

  • Select Terminal in the Visual Studio Code menu bar.
  • Select New Terminal.

The integrated terminal opens inside local-ai-file-explainer. Commands you run here now apply to the correct project folder.

  • Create the virtual environment by running this command:
python3 -m venv .venv

What does this command do?

Python creates a .venv directory containing an isolated interpreter plus its package tools. Project dependencies installed there do not change your system-wide Python setup.

  • Confirm .venv appears in the Visual Studio Code Explorer sidebar.

The new .venv directory is the visible proof that Python created the environment.

  • Activate the virtual environment by running this command:
source .venv/bin/activate

What does activation change?

Activation points Python package commands at the environment inside .venv. Your terminal prompt also displays the environment name while it remains active.

You should see the virtual environment name at the beginning of your terminal prompt. Keep this terminal active for the remaining commands.

Why use an isolated environment?

A global package install can create version conflicts between unrelated projects. The environment gives this CLI its own dependency set.

  • Select the New File... control in the Explorer sidebar.
  • Enter requirements.txt as the file name.
  • Paste these pinned dependencies into requirements.txt:
typer==0.27.3
ollama==0.6.3

What do these dependencies provide?

  • Typer turns typed Python functions into terminal commands with generated help.
  • The Ollama Python package sends requests from your code to the local Ollama service.
  • The exact version pins keep every learner on the same package behavior.
  • Save requirements.txt.
  • Confirm the Explorer sidebar lists requirements.txt inside local-ai-file-explainer.

Is the requirements file missing?

Confirm the Explorer sidebar is showing local-ai-file-explainer before creating the file. The file must sit beside .venv.

Use help me find where requirements.txt was created.

✔️ Awesome, I've got everything!

Great. Double-check that requirements.txt is saved with both pinned packages.

ⓧ I'd like to double check the full code

Compare your complete requirements.txt file with this reference:

typer==0.27.3
ollama==0.6.3
  • Install the pinned project dependencies by running this command:
python3 -m pip install -r requirements.txt

What does this command do?

Python reads each exact package version from requirements.txt. The active environment receives Typer 0.27.3 plus Ollama Python 0.6.3.

The installation can take a minute while Python downloads each package. You should see successful installation output before the terminal prompt returns.

Did the dependency installation fail?

  • Confirm the virtual environment name is visible at the start of your terminal prompt.
  • Confirm requirements.txt contains both lines from the reference tab.
  • Use help me diagnose this package installation error.

Before you run the final checks, do you expect both the local model and the Python imports to be ready?

  • Verify the model list plus both Python imports by running these commands:
ollama ls
python -c "import typer, ollama; print('Dependencies ready')"

What do these checks prove?

  • The first command proves the selected model remains available to the local Ollama service.
  • The second command imports both installed Python packages inside the active environment.
  • The printed confirmation proves both imports completed without an exception.

You should see qwen3:0.6b in the model list. The final line should read Dependencies ready.

Did a final check fail?

  • Open the Ollama application if the model service cannot be reached.
  • Activate .venv again if a Python import is missing.
  • Repeat the dependency installation if requirements.txt was changed after the first install.
  • Use help me debug the final workspace verification.

That's the foundation in place: your editor, local model, project environment, plus pinned dependencies are ready. Next up, you'll connect a typed command to the local model for your first generated answer.

Ask Your First Question

Your local AI workspace is ready. Ollama can already run qwen3:0.6b on your Mac.

Now you need the smallest visible proof that a typed CLI command can reach that local model. You will expose one question command through Typer.

This first version waits for the complete answer before printing it. That tight test proves the model connection before file handling enters the project.

In this step, get ready to:
  • Create a Typer application with generated command help.
  • Send one terminal question to qwen3:0.6b.
  • Confirm the terminal prints a complete local answer.
Create the Typer application

A Typer application is the container for your terminal commands. Its configuration also controls the help text learners see before running a command.

Why Typer for this CLI?

A hand-written argument parser would add setup code before you reach the local AI workflow. Typer turns typed function parameters into commands while generating help for you.

  • In the Visual Studio Code Explorer sidebar, select the local-ai-file-explainer folder.
  • Use the new-file control to create main.py inside the selected folder.

You should see main.py listed inside local-ai-file-explainer in the Explorer sidebar.

  • Build the application setup by pasting this code into main.py:
import ollama
import typer

MODEL = "qwen3:0.6b"

app = typer.Typer(
    no_args_is_help=True,
    help="Ask a local AI questions or explain a text file.",
)

What Does This Setup Create?

  • The two imports make the Ollama Python client available to your script. They also make Typer available.
  • MODEL keeps the local model identifier in one reusable place.
  • typer.Typer(...) creates the application that owns your commands.
  • no_args_is_help=True makes an empty invocation display generated help.
  • help="Ask a local AI questions or explain a text file." supplies the application description.
  • Save main.py.
  • Confirm the file name is no longer marked as unsaved in the editor.

Can't Save main.py?

  • Confirm main.py is inside the local-ai-file-explainer folder shown in the Explorer sidebar.
  • Check that the editor is showing a named file instead of an untitled document.
  • Help me save main.py in the correct project folder.
Add the ask command

Typer reads a function signature to create a terminal interface. A parameter without a default becomes a required positional argument.

  • In main.py, place your cursor beneath the closing parenthesis of the app = typer.Typer(...) block.
  • Complete the CLI by pasting this code at the cursor:
@app.command()
def ask(question: str) -> None:
    """Ask the local model a question."""
    response = ollama.chat(
        model=MODEL,
        messages=[{"role": "user", "content": question}],
        think=False,
    )
    print(response.message.content)


if __name__ == "__main__":
    app()

What Does This Command Do?

  • @app.command() registers ask as a CLI command.
  • question: str requires the learner to supply text after the command name.
  • ollama.chat() sends that question to the model stored in MODEL.
  • think=False disables thinking output for this request.
  • response.message.content holds the complete generated answer.
  • print() writes that answer to the terminal.
  • app() starts the Typer application when Python runs main.py directly.
  • Save main.py.

Before you run it, what do you expect Typer to show when no command is supplied?

  • Reveal the generated command help by running this command in the activated terminal:
python main.py

What Does This Command Prove?

Python executes main.py. The final app() starts Typer.

Because no subcommand is supplied, Typer displays the application help. You should see ask listed as an available command.

Great, your Python function now has a generated terminal interface. The CLI is ready to receive its first question.

No Help Output?

  • Confirm you saved main.py before running it.
  • Confirm @app.command() sits directly above the ask function.
  • Confirm the final block calls app().
  • Help me debug why main.py does not show Typer help.
Run the baby version

The ask command now has everything it needs to send a real question. This run tests the full path from terminal input to local model output.

Expect a brief pause while Ollama prepares the model. The exact timing depends on your Mac.

Before you run this, what kind of explanation do you expect the model to give about virtual environments?

  • Send the verification question to the local model by running this command:
python main.py ask "Why are Python virtual environments useful?"

What Should You See?

You should see a complete generated answer after the brief pause. The exact wording varies because the model creates a fresh response.

The answer should explain how a virtual environment keeps project dependencies isolated. This output proves that the typed question reached qwen3:0.6b through the local Ollama service.

You have your first local AI answer. Your typed CLI now reaches qwen3:0.6b from the terminal.

No Answer From the Model?

  • Return to Ollama from earlier to confirm the local service is still running.
  • Confirm qwen3:0.6b remains available in the model list you set up earlier.
  • Confirm the VS Code terminal still has .venv activated.
  • Help me debug why my local Ollama question does not return an answer.

✔️ Awesome, I've got everything!

Your main.py is saved. Your terminal shows generated help plus a complete local answer.

ⓧ I'd like to double check the full code

The complete main.py file is shown below for comparison.

import ollama
import typer

MODEL = "qwen3:0.6b"

app = typer.Typer(
    no_args_is_help=True,
    help="Ask a local AI questions or explain a text file.",
)


@app.command()
def ask(question: str) -> None:
    """Ask the local model a question."""
    response = ollama.chat(
        model=MODEL,
        messages=[{"role": "user", "content": question}],
        think=False,
    )
    print(response.message.content)


if __name__ == "__main__":
    app()

Your CLI can now answer a direct question with a model running on your Mac. Next, you will give it a file to explain.

Give the AI a File

Your Typer CLI can already carry a typed question to the local Ollama model. That proves the local request path works.

A question-only assistant cannot explain code that already lives on disk. This step gives the command a file path to read before you test its weakest assumption.

In this step, get ready to:
  • Create a Python file with a calculation you can run.
  • Add an explain command that builds a prompt from UTF-8 file content.
  • Compare the command's behavior across valid and missing file paths.
Create a file worth explaining

A short Python script gives you a known input for the new command. Its predictable output also gives the model a concrete result to describe.

  • Use the new-file control in the Visual Studio Code file sidebar from earlier to create sample.py inside local-ai-file-explainer.
  • Paste this calculation into sample.py:
def calculate_total(prices: list[float]) -> float:
    return sum(prices)


print(calculate_total([12.5, 8.0, 4.5]))

What does this code do?

  • The calculate_total() function accepts a list of prices.
  • The sum() call combines those prices into one total.
  • The final line prints the result produced from the three example values.
  • Save sample.py.
  • Check the calculation by running this command in the activated VS Code terminal:
python sample.py

What should you see?

The terminal should print 25.0 on its own line. This confirms that the sample has a predictable result.

Not seeing 25.0?

  • Confirm that sample.py is saved inside local-ai-file-explainer.
  • Compare each number in the final line with the code above.
  • Help me debug my sample calculation.

Good start. You now have a known file whose output gives the model something concrete to explain.

Add the explain command

The Path class turns the supplied file name into an object that Python can read. The new command then places that content inside a prompt for the local model.

  • In main.py, add this import above import ollama:
from pathlib import Path

Why add this import?

Python's pathlib module provides Path for working with filesystem paths. The command uses it to locate the file named by the learner.

  • In main.py, find the if __name__ == "__main__": block near the bottom.
  • Add this function immediately above that block:
@app.command()
def explain(
    file_path: str,
    question: str = "Explain what this file does in clear language.",
) -> None:
    """Explain a UTF-8 text or code file."""
    path = Path(file_path)
    content = path.read_text(encoding="utf-8")
    prompt = f"""{question}

File name: {path.name}

File contents:
{content}
"""
    response = ollama.chat(
        model=MODEL,
        messages=[{"role": "user", "content": prompt}],
        think=False,
    )
    print(response.message.content)

How does the file become a prompt?

  • The required file_path parameter becomes the file argument for the command.
  • The default question parameter becomes the optional --question value.
  • The Path(file_path) call represents the supplied location as a filesystem path.
  • The read_text() call reads the file as UTF-8 text.
  • The formatted string places the question at the start of the model prompt.
  • The formatted string also includes the file name plus its contents.
  • The ollama.chat() call sends the complete prompt to the existing local model.
  • The final print() call displays the complete response.
  • Save main.py.
Prove success, then hit the obstacle

A valid path gives the new command everything it needs to build a prompt. The local model may pause briefly while it generates the explanation.

  • Confirm that the command can read the sample by running:
python main.py explain sample.py

What should you see?

You'll see a locally generated explanation of sample.py. The answer should identify calculate_total().

The explanation should connect the three prices to the printed result of 25.0.

No explanation returned?

  • Confirm that Ollama from earlier is still running.
  • Check that sample.py sits beside main.py inside local-ai-file-explainer.
  • Help me diagnose why the explain command does not return an answer.

That is the key leap. Your CLI can now turn code from disk into a useful local explanation.

Before you run the missing-path check, decide whether the command reaches Ollama before it reads the file.

  • Test the unchecked path by running this command:
python main.py explain missing.py

The raw traceback is intentional

You'll see an uncaught FileNotFoundError traceback. The command stops at read_text() before Ollama receives a prompt.

This is the intended shortfall. The command trusts the path without checking whether the file exists.

✔️ Awesome, I've got everything!

Your files are saved. The valid path returns an explanation while the missing path exposes the intended traceback.

ⓧ I'd like to double check the full code

Compare each project file with these cumulative versions.

from pathlib import Path

import ollama
import typer

MODEL = "qwen3:0.6b"

app = typer.Typer(
    no_args_is_help=True,
    help="Ask a local AI questions or explain a text file.",
)


@app.command()
def ask(question: str) -> None:
    """Ask the local model a question."""
    response = ollama.chat(
        model=MODEL,
        messages=[{"role": "user", "content": question}],
        think=False,
    )
    print(response.message.content)


@app.command()
def explain(
    file_path: str,
    question: str = "Explain what this file does in clear language.",
) -> None:
    """Explain a UTF-8 text or code file."""
    path = Path(file_path)
    content = path.read_text(encoding="utf-8")
    prompt = f"""{question}

File name: {path.name}

File contents:
{content}
"""
    response = ollama.chat(
        model=MODEL,
        messages=[{"role": "user", "content": prompt}],
        think=False,
    )
    print(response.message.content)


if __name__ == "__main__":
    app()
typer==0.27.3
ollama==0.6.3
def calculate_total(prices: list[float]) -> float:
    return sum(prices)


print(calculate_total([12.5, 8.0, 4.5]))

You have exposed the exact rough edge that makes file validation necessary. Next up, you'll replace the traceback with concise error messages for invalid paths and local model failures.

Replace Tracebacks with Useful Errors

Your Typer CLI can explain sample.py with a model running through Ollama. The missing-file test exposed a raw Python traceback.

A usable command catches predictable failures before they reach the learner. You'll validate file input first. You'll also convert local model failures into concise terminal messages.

In this step, get ready to:
  • Validate file paths before reading their content.
  • Handle local model failures with concise terminal messages.
  • Verify error handling without breaking valid file explanations.
Validate the path before reading

The file checks belong in one helper so every explanation follows the same rules. Error messages go to sys.stderr so the CLI keeps failures separate from normal answers.

  • In main.py, find the import that currently starts the file:
from pathlib import Path

Why does this import change?

The existing Path import handles filesystem paths. The new error messages also need access to Python's standard error stream.

  • Replace that import with these two lines:
import sys
from pathlib import Path

Why import sys?

The sys module provides sys.stderr. It also supports the explicit exit behavior used by the validation helper.

  • Add the file-validation helper below the app = typer.Typer( block by pasting this code:
def load_file(file_path: str) -> tuple[Path, str]:
    path = Path(file_path).expanduser()

    if not path.exists():
        print(f"File not found: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    if not path.is_file():
        print(f"Not a file: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    try:
        content = path.read_text(encoding="utf-8")
    except UnicodeDecodeError as exc:
        print(f"File is not valid UTF-8 text: {file_path}", file=sys.stderr)
        raise SystemExit(1) from exc
    except OSError as exc:
        print(f"Could not read file: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    if not content.strip():
        print(f"File is empty: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    return path, content

What does this helper do?

  • The expanduser() call expands a home-directory shortcut in the supplied path.
  • The exists() check rejects a missing path.
  • The is_file() check rejects directories.
  • The exception handlers convert invalid UTF-8 content or other read failures into concise messages.
  • The strip() check rejects files with no meaningful content.
  • Each failure raises SystemExit(1) so the command stops before contacting the model.
  • Save main.py.
  • Confirm the file still loads by running this command:
python main.py

What should I see?

You'll see generated command help listing ask and explain. This confirms the new helper has valid Python syntax.

Seeing a syntax error?

  • Check that load_file() starts at the left edge of main.py.
  • Check that every line inside the function uses four spaces of indentation.
  • Confirm that import sys appears above from pathlib import Path.
  • Help me fix the syntax in my load_file helper.

The helper now defines the checks. The explain command must use it before constructing a model prompt.

  • In main.py, find these two lines inside explain():
    path = Path(file_path)
    content = path.read_text(encoding="utf-8")

Why is the direct read unsafe?

The direct read_text() call has no preflight checks. A missing path reaches Python's uncaught file exception.

  • Replace those two lines with the helper call below:
    path, content = load_file(file_path)

What changes here?

The helper returns the validated path plus its decoded content. Prompt construction only begins after those checks pass.

  • Save main.py.
Handle local model failures

A valid file can still fail at the model boundary. Both commands need clear responses when the local service is unavailable or the server rejects a request.

  • In main.py, locate the complete ask() command.
  • Replace the existing command with this version:
@app.command()
def ask(question: str) -> None:
    """Ask the local model a question."""
    try:
        response = ollama.chat(
            model=MODEL,
            messages=[{"role": "user", "content": question}],
            think=False,
        )
        print(response.message.content)
    except ConnectionError as exc:
        print(
            "Could not reach Ollama. Open the Ollama app and try again.",
            file=sys.stderr,
        )
        raise SystemExit(1) from exc
    except ollama.ResponseError as exc:
        print(f"Ollama error: {exc.error}", file=sys.stderr)
        raise SystemExit(1) from exc

How does the error boundary work?

  • The try block keeps the successful model call unchanged.
  • The ConnectionError handler reports that the local Ollama service cannot be reached.
  • The ollama.ResponseError handler prints the SDK's documented exc.error value.
  • Both handlers stop the command with status code 1.
  • Save main.py.
  • Confirm the successful ask path still works by running:
python main.py ask "Why are Python virtual environments useful?"

What should I see?

After a short wait, you'll see a complete locally generated answer. This proves the new error handlers leave successful requests intact.

Seeing a local model failure?

  • Return to the Ollama application from earlier if the message says the local service cannot be reached.
  • Check the model-related message if Ollama rejects the request.
  • Help me diagnose this Ollama failure.

The explain command makes its own model call. It needs the same error boundary after file validation succeeds.

  • In main.py, locate the complete explain() command.
  • Replace the existing command with this version:
@app.command()
def explain(
    file_path: str,
    question: str = "Explain what this file does in clear language.",
) -> None:
    """Explain a UTF-8 text or code file."""
    path, content = load_file(file_path)
    prompt = f"""{question}

File name: {path.name}

File contents:
{content}
"""
    try:
        response = ollama.chat(
            model=MODEL,
            messages=[{"role": "user", "content": prompt}],
            think=False,
        )
        print(response.message.content)
    except ConnectionError as exc:
        print(
            "Could not reach Ollama. Open the Ollama app and try again.",
            file=sys.stderr,
        )
        raise SystemExit(1) from exc
    except ollama.ResponseError as exc:
        print(f"Ollama error: {exc.error}", file=sys.stderr)
        raise SystemExit(1) from exc

What does this version protect?

The call to load_file() handles input failures before prompt construction. The try block then protects the separate model request.

This creates two clear boundaries. File errors stop before Ollama receives a request. Model errors stop with a concise message.

  • Save main.py.
Recheck both explain paths

The failing path should now stop inside load_file(). The successful path should still reach the local model.

Before you run the missing-file check, do you expect to see a full traceback or one concise message?

  • Test the missing-file path by running:
python main.py explain missing.py

The raw traceback is gone

You'll see File not found: missing.py. The command exits without a traceback.

Ollama receives no request because validation stops the command first.

Before you run the valid-file check, do you expect validation to change the answer for a real file? Expect a short pause while the local model generates its response.

  • Test the successful path by running:
python main.py explain sample.py

What should I see?

You'll see a complete explanation of sample.py. The response should describe calculate_total() plus the printed result.

Still seeing a traceback?

  • Confirm that explain() calls load_file(file_path) before building the prompt.
  • Check that each validation branch raises SystemExit(1) after printing its message.
  • Check that both model calls sit inside their own try blocks.
  • Help me find why my CLI still shows a traceback.

✔️ Awesome, I've got everything!

Great. Your CLI now reports a missing file cleanly while preserving successful local explanations.

ⓧ I'd like to double check the full code

Compare your saved main.py with this complete version:

import sys
from pathlib import Path

import ollama
import typer

MODEL = "qwen3:0.6b"

app = typer.Typer(
    no_args_is_help=True,
    help="Ask a local AI questions or explain a text file.",
)


def load_file(file_path: str) -> tuple[Path, str]:
    path = Path(file_path).expanduser()

    if not path.exists():
        print(f"File not found: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    if not path.is_file():
        print(f"Not a file: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    try:
        content = path.read_text(encoding="utf-8")
    except UnicodeDecodeError as exc:
        print(f"File is not valid UTF-8 text: {file_path}", file=sys.stderr)
        raise SystemExit(1) from exc
    except OSError as exc:
        print(f"Could not read file: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    if not content.strip():
        print(f"File is empty: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    return path, content


@app.command()
def ask(question: str) -> None:
    """Ask the local model a question."""
    try:
        response = ollama.chat(
            model=MODEL,
            messages=[{"role": "user", "content": question}],
            think=False,
        )
        print(response.message.content)
    except ConnectionError as exc:
        print(
            "Could not reach Ollama. Open the Ollama app and try again.",
            file=sys.stderr,
        )
        raise SystemExit(1) from exc
    except ollama.ResponseError as exc:
        print(f"Ollama error: {exc.error}", file=sys.stderr)
        raise SystemExit(1) from exc


@app.command()
def explain(
    file_path: str,
    question: str = "Explain what this file does in clear language.",
) -> None:
    """Explain a UTF-8 text or code file."""
    path, content = load_file(file_path)
    prompt = f"""{question}

File name: {path.name}

File contents:
{content}
"""
    try:
        response = ollama.chat(
            model=MODEL,
            messages=[{"role": "user", "content": prompt}],
            think=False,
        )
        print(response.message.content)
    except ConnectionError as exc:
        print(
            "Could not reach Ollama. Open the Ollama app and try again.",
            file=sys.stderr,
        )
        raise SystemExit(1) from exc
    except ollama.ResponseError as exc:
        print(f"Ollama error: {exc.error}", file=sys.stderr)
        raise SystemExit(1) from exc


if __name__ == "__main__":
    app()

That's the rough edge removed. Your CLI now protects file input plus local model calls with clear errors. Next, you'll stream each answer so the terminal shows progress while the model generates it.

Stream the Answer as It Arrives

Your CLI now validates files before contacting the model. It also turns local model failures into useful terminal messages.

A complete response can still take time to generate. Streaming lets Ollama return fragments as soon as they are ready, so your terminal shows progress throughout the answer.

In this step, get ready to:
  • Create one helper that requests a streamed model response.
  • Print each response fragment as soon as it arrives.
  • Route direct questions and file explanations through the shared helper.
Create the streaming model helper

A streamed response is an iterator of smaller response chunks. The new stream_answer() helper owns that model request so both commands can use the same behavior.

  • Return to main.py in the editor tab from earlier.
  • Find the blank line above def load_file(file_path: str) -> tuple[Path, str]:.
  • Insert this helper in that blank space:
def stream_answer(prompt: str) -> None:
    try:
        stream = ollama.chat(
            model=MODEL,
            messages=[{"role": "user", "content": prompt}],
            stream=True,
            think=False,
        )

        for chunk in stream:
            if chunk.message.content:
                print(chunk.message.content, end="", flush=True)
        print()
    except ConnectionError as exc:
        print(
            "Could not reach Ollama. Open the Ollama app and try again.",
            file=sys.stderr,
        )
        raise SystemExit(1) from exc
    except ollama.ResponseError as exc:
        print(f"Ollama error: {exc.error}", file=sys.stderr)
        raise SystemExit(1) from exc

How Does Streaming Work?

  • The prompt parameter holds the question or file explanation request sent to the model.
  • The stream=True setting makes ollama.chat return an iterator of response chunks.
  • The think=False setting keeps the response focused on visible answer content.
  • The loop reads each chunk in the order Ollama produces it.
  • The if check skips empty content fragments.
  • The existing exception handlers keep connection failures and model failures concise.
  • Save main.py.

Before you run the current command, do you expect the first words to appear immediately or after the complete answer is ready?

  • Test the current direct-question path by running:
python main.py ask "Explain how a Python virtual environment works in five bullet points"

You will see the complete answer arrive as one block after a short wait. The new helper exists, but the ask command still uses its previous complete-response call.

Seeing a Python Error?

  • Check that stream_answer() starts above load_file() with no leading indentation.
  • Check that the lines inside try use four spaces of indentation.
  • Compare every closing parenthesis with the helper shown above.
  • Help me debug my streaming helper.
Render every response chunk

The helper can now print fragments immediately. The ask command needs to pass its question into that helper before you can observe the difference.

  • Find the @app.command() decorator directly above def ask(question: str) -> None:.
  • Select that decorator through the final line of the current ask function.
  • Replace the selection with this version:
@app.command()
def ask(question: str) -> None:
    """Ask the local model a question."""
    stream_answer(question)

What Does This Change Do?

Typer still passes the required terminal argument into question. The command now delegates the model request and output rendering to stream_answer().

This removes duplicated model integration from the command. One helper now controls streaming and error handling.

  • Save main.py.

Before you run the same question again, how do you think its arrival will differ from the previous result?

  • Run the direct-question command again:
python main.py ask "Explain how a Python virtual environment works in five bullet points"

You will see words appear piece by piece before the answer is complete. That visible movement proves the ask command is consuming the response stream.

There it is. Your direct-question command now shows progress while the local model is generating its answer.

Still Waiting for the Complete Answer?

  • Confirm the ask function contains stream_answer(question).
  • Confirm stream_answer() passes stream=True to ollama.chat.
  • Confirm the chunk print call includes flush=True.
  • Help me find why my answer is not streaming.
Route both commands through streaming

Direct questions now stream through the shared helper. File explanations need the same route so every model response behaves consistently.

  • Find the @app.command() decorator directly above the current explain function.
  • Select that decorator through the final line of the current explain function.
  • Replace the selection with this version:
@app.command()
def explain(
    file_path: str,
    question: str = "Explain what this file does in clear language.",
) -> None:
    """Explain a UTF-8 text or code file."""
    path, content = load_file(file_path)
    prompt = f"""{question}

File name: {path.name}

File contents:
{content}
"""
    stream_answer(prompt)

What Does This Change Do?

The explain command keeps its validated file loading and prompt construction. It passes the finished prompt into the same streaming helper used by ask.

Both commands now share one model call path. Future output changes only need to happen inside stream_answer().

  • Save main.py.

Before the final check, do you expect the first bullet point to appear before the model finishes the fifth one?

  • Verify streamed direct answers by running:
python main.py ask "Explain how a Python virtual environment works in five bullet points"

You will see the bullet points build in the terminal before the full answer is complete.

  • Verify streamed file explanations by running:
python main.py explain sample.py

You will see the explanation arrive in fragments. The answer describes the function and the total printed by sample.py.

You have completed the local AI CLI. Both command paths now validate their input and reveal model output as it is generated.

Is One Command Still Non-Streaming?

  • Confirm ask ends with stream_answer(question).
  • Confirm explain ends with stream_answer(prompt).
  • Remove any remaining direct ollama.chat calls from the two command functions.
  • Help me route both CLI commands through streaming.

✔️ Awesome, I've got everything!

Your direct questions and file explanations now stream through stream_answer(). Double check that main.py is saved.

ⓧ I'd like to double check the full code

Compare your saved main.py with this complete version:

import sys
from pathlib import Path

import ollama
import typer

MODEL = "qwen3:0.6b"

app = typer.Typer(
    no_args_is_help=True,
    help="Ask a local AI questions or explain a text file.",
)


def stream_answer(prompt: str) -> None:
    try:
        stream = ollama.chat(
            model=MODEL,
            messages=[{"role": "user", "content": prompt}],
            stream=True,
            think=False,
        )

        for chunk in stream:
            if chunk.message.content:
                print(chunk.message.content, end="", flush=True)
        print()
    except ConnectionError as exc:
        print(
            "Could not reach Ollama. Open the Ollama app and try again.",
            file=sys.stderr,
        )
        raise SystemExit(1) from exc
    except ollama.ResponseError as exc:
        print(f"Ollama error: {exc.error}", file=sys.stderr)
        raise SystemExit(1) from exc


def load_file(file_path: str) -> tuple[Path, str]:
    path = Path(file_path).expanduser()

    if not path.exists():
        print(f"File not found: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    if not path.is_file():
        print(f"Not a file: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    try:
        content = path.read_text(encoding="utf-8")
    except UnicodeDecodeError as exc:
        print(f"File is not valid UTF-8 text: {file_path}", file=sys.stderr)
        raise SystemExit(1) from exc
    except OSError as exc:
        print(f"Could not read file: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    if not content.strip():
        print(f"File is empty: {file_path}", file=sys.stderr)
        raise SystemExit(1)

    return path, content


@app.command()
def ask(question: str) -> None:
    """Ask the local model a question."""
    stream_answer(question)


@app.command()
def explain(
    file_path: str,
    question: str = "Explain what this file does in clear language.",
) -> None:
    """Explain a UTF-8 text or code file."""
    path, content = load_file(file_path)
    prompt = f"""{question}

File name: {path.name}

File contents:
{content}
"""
    stream_answer(prompt)


if __name__ == "__main__":
    app()

Secret mission

Add Response Styles

One answer format cannot serve every reader equally well. Add a validated style option that streams beginner-friendly explanations, concise answers, or critical reviews from the same local model.

Clean Up Your Resources

Clean Up Your Resources

Everything you created lives on your Mac. There are no cloud resources, API keys, paid services, or ongoing charges. Choose whether to keep the CLI ready, pause its running processes, or remove its local resources.

Resources you used:

  • The local local-ai-file-explainer project folder.
  • The .venv virtual environment inside local-ai-file-explainer.
  • The 523 MB qwen3:0.6b model in Ollama's local storage.

Keep everything running

No action is needed. This option suits you if you want to keep using or extending the CLI.

  • Your local-ai-file-explainer folder keeps the working CLI.
  • The .venv remains ready inside the folder.
  • The qwen3:0.6b model remains downloaded for future local answers.

Pause - I'll come back to this later

This option shuts down the active environment and running model while keeping every downloaded file.

  • Pause the active tools by running these commands:
deactivate
ollama stop qwen3:0.6b

What do these commands pause?

  • The deactivate command leaves the active Python virtual environment.
  • Ollama stops the running qwen3:0.6b model.
  • The model stays downloaded for your next session.

Delete - I don't want to use this again

Deleting these resources is permanent, so use this option only when you no longer need the CLI. You can rebuild everything from this guide.

  • Remove the local model and project folder by running these commands:
deactivate
ollama stop qwen3:0.6b
cd ..
ollama rm qwen3:0.6b
rm -rf local-ai-file-explainer
ollama ls
ls

What should you see?

  • The virtual environment closes before its directory is removed.
  • Ollama stops the active model before deleting its local download.
  • The directory command moves the terminal one level above local-ai-file-explainer.
  • The folder removal deletes everything inside local-ai-file-explainer.
  • Your final model list should omit qwen3:0.6b.
  • Your final directory listing should omit local-ai-file-explainer.

Nice Work!

Nice Work!

You did it! Your Local AI File Explainer now answers terminal questions through Ollama on your Mac.

You've learned how to:

  • Build a typed Python CLI with multiple commands. Generate command help through Typer.
  • Connect direct questions to qwen3:0.6b through local model inference. Turn UTF-8 files into focused explanation prompts without uploading them to a hosted API.
  • Deliver clear error handling for invalid files or unavailable local models. Print streamed output as each response fragment arrives.
  • Secret Mission: Add a validated --style option to both commands. Use beginner, concise, or reviewer responses.

Ready to quiz yourself?