<!--
  canonical-url: https://www.avonture.be/blog/ollama-ai-explain
  published:     2026-11-12
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# ai-explain: ELI5 for Scripts and Errors, Right in Your Terminal

> Another function in the Ollama daily-use series: pipe any script, error message, or stack trace to ai-explain and get a plain-English explanation of what it does or what went wrong — entirely local, no cloud required.

---
![ai-explain: ELI5 for Scripts and Errors, Right in Your Terminal](/img/v2/playing_with_ollama.webp)

**TL;DR**

`ai-explain` is another function in the Ollama daily-use series. Pipe any script, log output, or stack trace to it — or give it a file path, or paste an error message as an argument — and get a plain-English explanation: what the script does, what the error means, and what to fix. One function, three input modes, zero cloud.

You cloned a repository and found a Bash script that runs as part of the CI pipeline. Three hundred lines, no comments. You need to understand what it does before you modify it. You could read it carefully, look up the flags, trace the logic. Or you could pipe it through `ai-explain`.

Same story for stack traces. A React error in production with six frames of minified output. A PHP `Fatal error` at line 847 of a file you've never opened. A Docker build failure on a RUN step you didn't write.

`ai-explain` is the terminal counterpart to the [ELI5 tooltip component](/blog/docusaurus-eli5-snippet-tooltips) on this blog — the same "explain like I'm five" idea, but for your shell instead of the browser.

## Explain a Script You Didn't Write

**deploy.sh**

```bash
#!/usr/bin/env bash
set -euo pipefail

for tool in docker git curl; do
  if ! command -v "$tool" >/dev/null 2>&1; then
    echo "Missing prerequisite: $tool" >&2
    exit 1
  fi
done

git pull --rebase origin main

tag="myapp:$(git rev-parse --short HEAD)"
docker build -t "$tag" .

APP_IMAGE="$tag" docker compose up -d --no-deps app

for _ in $(seq 1 12); do
  if [[ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/health)" == "200" ]]; then
    echo "Deploy successful: $tag"
    exit 0
  fi
  sleep 5
done

echo "Health check failed for $tag" >&2
exit 1
```

```bash
$ ai-explain deploy.sh

This script automates deploying an application using Docker and Docker Compose. It first checks that `docker`, `git`, and `curl` are installed, exiting if any are missing. It pulls the latest code from the `main` branch and builds a new Docker image tagged with the current git commit hash. Then it starts the app service in detached mode without restarting dependent services. After starting the service, it waits up to 60 seconds (12 attempts of 5 seconds each) checking if the app responds with HTTP 200 at `/health`. If successful, it prints a success message and exits. If the health check fails after all attempts, it reports the failure and exits with error code 1. The script modifies the local Docker environment by building a new image and starting containers, but doesn't change any files on disk except through Git operations. Risks include failing to start the app or timing out if the service takes longer than 60 seconds to become healthy. To fix potential issues, ensure all prerequisites are installed and the app's health endpoint is correctly configured.
```

That's the entire interaction: a file path in, a plain-English breakdown out — prerequisites, the commit-hash image tag, the `--no-deps` restart, the 60-second health check and what happens when it times out.

## Why It Works

`ai-explain` handles three input modes, checked in this order: piped input (so `cat file | ai-explain` always wins even if a filename is also passed), an existing file, or inline text for a quick one-liner — one function covers "explain this file" and "explain this error" without separate commands to remember. The prompt asks for at most fifteen lines of plain text, never a copy of the input, and passes `uname -s` along so suggested commands match the platform you're actually on.

## Installing It

Everything runs on your machine: zsh, `curl` and `jq` (which builds the request sent to Ollama), and an Ollama server you can reach — see [installing Ollama](/blog/ollama-installation) if you don't have one yet.

**1. Create the files.** Every function of this series lives in its own file in `~/.zsh/fns/`. The one-liner below creates that folder and the two files in one go — or download the ZIP, or create each file by hand and paste its content. `_ollama.zsh` is the foundation the whole series shares, introduced in [ai-test](/blog/ollama-test-generator): if an earlier article already gave it to you, the copy here is the same file.

**Project: \~/.zsh/fns**

**\_ollama.zsh**

````bash
# Foundation file for every "ai-*" function in this series. Named with a
# leading underscore: the ~/.zsh/fns/*.zsh loader sources files in
# alphabetical order, and "_" sorts before any letter — so this file is
# always loaded first, no matter how many ai-* functions get added later.
#
# Provides:
#   - _ollama_check       reachability guard for Ollama
#   - _ollama_query       shared HTTP client (curl + jq)
#   - _ai_strip_fences    removes the Markdown fences models add anyway
#   - _ai_bat             resolves "bat" vs Debian's "batcat"
#   - _ai_confirm         yes/no prompt
#   - _git_staged_diff    validated staged-diff fetcher (shared by pre-commit functions)
#   - AI_COMMANDS         registry each ai-* function adds itself to
#   - AI_PARAMS           registry of interactive parameter types (file/language/number/text/none)
#   - _ai_prompt_*        FZF/read helpers for interactive argument collection
#   - ai                  entry-point dispatcher / interactive menu

typeset -gA AI_COMMANDS
typeset -gA AI_PARAMS

# ---------------------------------------------------------------------------
# Ollama helpers
# ---------------------------------------------------------------------------

# _ollama_check — verify that the Ollama daemon is reachable.
# Fails loudly so callers can bail early rather than watching curl hang.
_ollama_check() {
  local host="${OLLAMA_HOST:-http://localhost:11434}"
  if ! curl --silent --max-time 2 "${host}/api/tags" >/dev/null 2>&1; then
    echo "ollama: cannot reach ${host} — is the Ollama container running?" >&2
    return 1
  fi
}

# _ollama_query <prompt> — send a prompt to the local model and print the response.
# Assumes Ollama is reachable; call _ollama_check first if you need an early guard.
_ollama_query() {
  local prompt="$1"
  local model="${OLLAMA_MODEL:-qwen3-coder:30b}"
  local host="${OLLAMA_HOST:-http://localhost:11434}"

  _ollama_check || return 1

  # The prompt is piped into jq rather than passed with "--arg": Linux caps a
  # single argv entry at 128 KB (MAX_ARG_STRLEN), and a large staged diff or a
  # long source file sails straight through that ceiling — jq then dies with
  # "argument list too long". "-R -s" slurps stdin as one raw string, which jq
  # escapes exactly like --arg would.
  print -r -- "$prompt" \
    | jq -Rs --arg model "$model" '{model: $model, prompt: ., stream: false}' \
    | curl --silent "${host}/api/generate" --data-binary @- \
    | jq -r '.response // empty'
}

# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------

# _ai_strip_fences <text> — return only the code, without the Markdown fences.
#
# Every prompt in this series ends with "no markdown fences" and models still
# wrap their answer in ```language … ``` often enough that the output can't be
# piped or pasted as-is. When at least one fence is present, only the fenced
# regions are kept (which also drops the "Here is your test suite:" preamble);
# when there is none, the text is returned untouched.
_ai_strip_fences() {
  local text="$1"

  if [[ "$text" != *'```'* ]]; then
    print -r -- "$text"
    return 0
  fi

  print -r -- "$text" | awk '
    /^[[:space:]]*```/ { inside = !inside; next }
    inside             { print }
  '
}

# _ai_bat — print the name of the "bat" binary installed on this machine, if any.
# Debian and Ubuntu ship the package as "batcat" because the "bat" name was
# already taken by bacula-console-qt, so both spellings have to be probed.
_ai_bat() {
  local candidate
  for candidate in bat batcat; do
    if command -v "$candidate" >/dev/null 2>&1; then
      print -- "$candidate"
      return 0
    fi
  done
  return 1
}

# _ai_relpath <path> — print <path> relative to the current folder when it sits
# below it. Purely cosmetic: it keeps prompts readable instead of asking
# "Save as /home/christophe/projects/…/tests/backup.bats?".
_ai_relpath() {
  local path="${1:A}"
  print -- "${path#${PWD}/}"
}

# _ai_confirm <question> — ask a yes/no question; return 0 only on an explicit yes.
_ai_confirm() {
  local answer
  print -n "${1} [y/N] "
  read -r answer
  [[ "$answer" == [yY]* ]]
}

# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------

# _git_staged_diff <caller> — validate the git context and return the staged diff.
# Handles three early-exit cases (not a repo, nothing staged, oversized diff)
# so every pre-commit function gets them for free without repeating the guards.
#
# Past AI_DIFF_MAX_CHARS the full diff is *not* returned: a diff of several
# hundred kilobytes overflows the model's context window, and the answer that
# comes back is worse than the one you get from a summary. The fallback keeps
# the shape of the change — per-file stat, plus the file and hunk headers,
# which carry the enclosing function names git puts after each "@@" — and
# drops the line-by-line content.
_git_staged_diff() {
  local caller="${1:-ai}"
  local max="${AI_DIFF_MAX_CHARS:-12000}"

  if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
    echo "${caller}: not inside a git repository" >&2
    return 1
  fi

  local diff
  diff=$(git diff --staged)

  if [[ -z "$diff" ]]; then
    echo "${caller}: nothing staged — run 'git add' first" >&2
    return 1
  fi

  # "print -r" everywhere below: without it, print expands escape sequences and
  # a literal \n or \t inside the diff would come back mangled.
  if (( ${#diff} <= max )); then
    print -r -- "$diff"
    return 0
  fi

  echo "${caller}: staged diff is large (${#diff} chars) — sending a structural summary instead of the full diff. Commit in smaller chunks for a more precise answer." >&2

  local summary
  summary="$(git diff --staged --stat)

--- FILE AND HUNK HEADERS ONLY (full diff omitted, ${#diff} chars) ---
$(print -r -- "$diff" | grep -E '^(diff --git|new file|deleted file|rename (from|to)|@@)')"

  # Even the summary can exceed the ceiling on a very wide change.
  if (( ${#summary} > max )); then
    summary="${summary[1,$max]}
[…summary truncated…]"
  fi

  print -r -- "$summary"
}

# ---------------------------------------------------------------------------
# Interactive argument helpers (used by the ai() dispatcher)
# ---------------------------------------------------------------------------

# _ai_prompt_file [label] — open an fzf file picker; print the chosen path.
_ai_prompt_file() {
  local label="${1:-Select a file:}"
  local bat_bin preview='cat {}'
  bat_bin=$(_ai_bat) && preview="${bat_bin} --color=always {} 2>/dev/null || cat {}"

  if command -v fd >/dev/null 2>&1; then
    fd --type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview"
  else
    find . -type f | fzf --prompt="${label} " --height=50% --reverse --preview="$preview"
  fi
}

# _ai_prompt_language — open an fzf language picker; print the chosen language.
_ai_prompt_language() {
  printf '%s\n' English French Dutch German Spanish Italian Portuguese Japanese Chinese Korean \
    | fzf --prompt='Target language: ' --height=40% --reverse
}

# _ai_prompt_number [label] — read a positive integer; print it (or nothing on Enter, which
# lets the caller fall back to its own default). Returns 1 only on invalid input.
_ai_prompt_number() {
  local label="${1:-Number (Enter for default):}"
  print -n "${label} "
  local n
  read n
  if [[ -z "$n" ]]; then
    return 0
  fi
  if [[ ! "$n" =~ ^[1-9][0-9]*$ ]]; then
    echo "Expected a positive integer." >&2
    return 1
  fi
  print -- "$n"
}

# _ai_prompt_text [label] — read a free-form string; print it. Returns 1 if empty.
_ai_prompt_text() {
  local label="${1:-Input:}"
  print -n "${label} "
  local t
  read t
  if [[ -z "$t" ]]; then
    echo "No input given." >&2
    return 1
  fi
  print -- "$t"
}

# ---------------------------------------------------------------------------
# Entry-point dispatcher
# ---------------------------------------------------------------------------

# ai              → interactive fzf menu of every registered ai-* command
# ai <cmd> [args] → dispatch straight to ai-<cmd> [args]
#
# When opened via the fzf menu, each command's declared AI_PARAMS type triggers
# a secondary picker before the command runs:
#   file     → fzf file picker (fd + preview if available)
#   language → fzf language list
#   number   → read prompt, empty input uses the command's own default
#   text     → read prompt, required
#   none     → run the command directly (it collects its own input)
# Space-separated values declare multiple parameters in order, e.g. "file language".
ai() {
  local cmd="$1"

  # Direct dispatch: ai <cmd> [args]
  if [[ -n "$cmd" && "$cmd" != "help" && "$cmd" != "list" ]]; then
    if (( ${+AI_COMMANDS[$cmd]} )); then
      shift
      "ai-${cmd}" "$@"
      return
    fi
    echo "ai: unknown command '${cmd}'" >&2
    echo >&2
  fi

  # Interactive menu
  if ! command -v fzf >/dev/null 2>&1; then
    echo "Available ai commands:"
    for key in ${(ok)AI_COMMANDS}; do
      printf "  ai %-10s %s\n" "$key" "$AI_COMMANDS[$key]"
    done
    return
  fi

  local picked
  picked=$(
    for key in ${(ok)AI_COMMANDS}; do
      printf '%s\t%s\n' "$key" "$AI_COMMANDS[$key]"
    done \
      | fzf --delimiter='\t' --with-nth=2 --prompt='ai > ' --height=40% --reverse \
      | cut -f1
  )
  [[ -z "$picked" ]] && return

  # Collect arguments interactively based on AI_PARAMS declaration
  local -a args=()
  local param_type
  for param_type in ${(s: :)${AI_PARAMS[$picked]:-none}}; do
    case "$param_type" in
      file)
        local f
        f=$(_ai_prompt_file "File for ai-${picked}:") || return 1
        args+=("$f")
        ;;
      language)
        local l
        l=$(_ai_prompt_language) || return 1
        args+=("$l")
        ;;
      number)
        local n
        n=$(_ai_prompt_number) || return 1
        [[ -n "$n" ]] && args+=("$n")
        ;;
      text)
        local t
        t=$(_ai_prompt_text) || return 1
        args+=("$t")
        ;;
      none) ;;
    esac
  done

  "ai-${picked}" "${args[@]}"
}
````

**ai-explain.zsh**

```bash
# ai-explain [file|text] — explain a script, an error message or any piped
# output in plain English. Three input modes, checked in this order:
#   cat error.log | ai-explain            piped input always wins
#   ai-explain deploy.sh                  an existing file is read
#   ai-explain 'error: undefined symbol'  anything else is the text itself

AI_COMMANDS[explain]="ai-explain [file|text]  — explain a script, an error or piped output in plain English"
AI_PARAMS[explain]="file"

ai-explain() {
  local input

  if [[ ! -t 0 ]]; then
    input=$(cat)
  elif [[ -f "$1" ]]; then
    input=$(cat "$1")
  elif (( $# > 0 )); then
    input="$*"
  else
    echo 'Usage: ai-explain <file>   or   command 2>&1 | ai-explain   or   ai-explain "error message"' >&2
    return 1
  fi

  if [[ -z "$input" ]]; then
    echo "ai-explain: empty input — nothing to explain" >&2
    return 1
  fi

  local prompt="You are a helpful assistant that explains technical content in plain English.

Look at the content below and:
- If it is a script (bash, zsh, python, php, etc.): explain what it does step by step. Mention what it produces, what it modifies, and any side effects or risks.
- If it is an error message, stack trace, or log output: explain what went wrong, why it happened, and what to do to fix it.
- If you are not sure: describe what you see and give your best interpretation.

Keep the explanation concrete and practical. No hype, no filler. If there is a fix, give the exact command or change.
Never copy the input back: name the command or the line you are talking about instead. Plain text, no headings, no emoji, at most 15 lines. The user is on $(uname -s), so only suggest commands for that platform.

---
${input}"

  _ollama_query "$prompt"
}
```

**2. Load the folder from `~/.zshrc`.** Already done if you followed an earlier article of this series. Otherwise, add these lines at the end of `~/.zshrc` — they source every `.zsh` file in the folder, so the next function you add needs no change here:

```zsh title="~/.zshrc"
for fn_file in ~/.zsh/fns/*.zsh; do
  source "$fn_file"
done
```

**3. Open a new terminal** (or run `exec zsh` in the current one), and check that the shell knows the new function:

```zsh
type ai-explain
```

`ai-explain is a shell function from …/ai-explain.zsh` means you're ready. `ai-explain not found` means step 2 is missing.

## More Demos

### Explain a stack trace

**example-error.txt**

```txt
Fatal error: Uncaught TypeError: Cannot read properties of undefined (reading 'map')
    at ProductList (/app/src/components/ProductList.jsx:42:32)
    at renderWithHooks (/app/node_modules/react-dom/cjs/react-dom.development.js:14985:18)
    at mountIndeterminateComponent (/app/node_modules/react-dom/cjs/react-dom.development.js:17811:13)
    at beginWork (/app/node_modules/react-dom/cjs/react-dom.development.js:19049:16)
    at HTMLUnknownElement.callCallback (/app/node_modules/react-dom/cjs/react-dom.development.js:3945:14)
    at Object.invokeGuardedCallbackDev (/app/node_modules/react-dom/cjs/react-dom.development.js:3994:16)
The above error occurred in the <ProductList> component.
Consider adding an error boundary to your tree to customize error handling behavior.
```

````bash
$ ai-explain example-error.txt

The error happens because the code tries to call `.map()` on a variable that is `undefined` in the ProductList component. This occurs at line 42 of ProductList.jsx where it expects an array but gets nothing.

To fix this, add a check to ensure the data exists before mapping:
```jsx
{products && products.map(product => (
  <div key={product.id}>{product.name}</div>
))}
```

If `products` is undefined, the component won't crash and will render nothing instead of throwing an error.
````

### Pipe any command output

The most useful pattern: run a command that fails and pipe its output directly:

```bash
docker build . 2>&1 | ai-explain
```

```bash
npm install 2>&1 | ai-explain
```

```bash
./run-tests.sh 2>&1 | tail -50 | ai-explain
```

The `2>&1` redirects stderr to stdout so both streams reach the pipe. Without it, error messages from most CLI tools never make it to `ai-explain`.

For commands that produce a lot of output, pipe through `tail` first to keep only the relevant end of the output (where errors usually land).

### Use from the `ai` dispatcher

Like every function in this series, `ai-explain` registers itself with the `AI_COMMANDS` registry. The `ai` dispatcher (from `_ollama.zsh`) lists every registered function — here with the published ones from this series installed:

```bash
$ ai

ai >
  ai-blog-search — ask your blog a question (RAG over every published article)
  ai-commit  — draft a Conventional Commits message from staged changes
  ai-data <file.json|file.csv>  — suggest jq/awk commands tailored to this file
  ai-diagram <description>|<file>  — turn a description or a config file into a Mermaid diagram
  ai-explain [file|text]  — explain a script, an error or piped output in plain English
  ai-review  — code review of staged changes (SOLID, magic constants, naming)
  ai-secrets  — scan staged changes for hardcoded credentials
  ai-summarize <file> [points]  — summarize a document locally (default: 5 points)
  ai-test <file.sh|.php|.py>  — generate or complete a unit test suite
  ai-translate <file> [language]  — translate a document locally (default: French)
```

Select `ai-explain` and the dispatcher opens its fzf file picker first: `AI_PARAMS[explain]="file"` tells it to ask for a file rather than a line of text.

## Under the Hood (skip this if you just want to use it)

### When ai-explain is most useful

* **Inherited scripts**: understanding what a CI/CD script does before modifying it
* **Unfamiliar errors**: a stack trace from a language or framework you don't know well
* **Cryptic log output**: Docker daemon warnings, systemd journal entries, kernel messages
* **One-liner archaeology**: that `awk '{ split($0,a,":"); ... }'` command you found in a Makefile
* **Quick sanity check**: does this script do what the filename suggests?

It's also useful as a learning tool: pipe a script you wrote yourself and see if the explanation matches your intent. If it doesn't, either the explanation is wrong (it happens) or the script has a subtle bug.

## Conclusion

`ai-explain` joins the "Ollama daily use" series.

The series started with [`ai-test`](/blog/ollama-test-generator) (the foundation: `_ollama.zsh`, the dispatcher, the `AI_COMMANDS` registry) and added functions one by one — each solving a specific terminal workflow problem with a local LLM:

* **Developer workflow**: [`ai-review`, `ai-secrets`, `ai-commit`](/blog/ollama-git-precommit), `ai-standup`
* **Operations**: `ai-ci`, `ai-fix`
* **Shell assistance**: `ai-ask`, [`ai-data`](/blog/ollama-ai-data), [`ai-diagram`](/blog/ollama-ai-diagram)
* **Document work**: [`ai-translate`, `ai-summarize`](/blog/ollama-ai-docs), `ai-diff`
* **Your own documentation**: [`ai-blog-search`](/blog/anythingllm-chat-with-your-docs)
* **Explanation**: `ai-explain`

All of them share the same `_ollama_query` foundation. Together they cover the moments in a development day where you'd normally reach for a browser search or a cloud AI — but where a local model is faster to reach and keeps everything on your machine.

The functions without a link are still to come. Run `ai` to see what your terminal already knows how to do.
