# Ollama daily use A series of 9 article(s), in reading order. # Installing Ollama and get local AI > Learn how to deploy Ollama locally via Docker to run private, free LLMs and enable AI-powered code autocompletion in VSCode. --- ![Installing Ollama and get local AI](/img/v2/playing_with_ollama.webp) **TL;DR**This guide explains how to install Ollama locally using Docker to run large language models for free while maintaining complete data privacy. It details how to select appropriate models based on your available system memory for tasks like rapid code autocompletion and complex refactoring. You will also learn to integrate these tools directly into your workflow using Open WebUI and the Continue extension for VSCode. In one of my last articles about my [TL;DR summary](/blog/gemini-tldr) component, I created a Python script to consume the Gemini API and boom, after just a few requests, I got a maximum quota exceeded error. What if we can solve this for free? How? Simply by installing a LLM locally, on our host. No more quota. ## Try It in Your Terminal Once Ollama is running and a model is pulled (covered under Installation below), the command is `ollama run `: ```bash $ docker exec -it ollama ollama run llama3.1:8b ``` You can now start to ask anything like a comparison between Quarto and Docusaurus: ![Asking to compare Quarto and Docusaurus](./images/test_quarto_docusaurus_comparaison.webp) Or getting a joke : ![Getting a joke](./images/test_joke.webp) Type `/bye` to exit. > **Faster the second time:** > > You've experienced some delay right before getting the prompt? Run the exact same command again; you'll see, it's now immediate since the model is already loaded in RAM. > > And, if you don't use the model for the next five minutes, Ollama will unload it, freeing up your RAM. ### Should I speak English with the model? In fact, no — just like a webpage, you can use your own language. In the example below, you can also see another way to ask a question: instead of jumping into a terminal, you can type your question directly on the command line. ![Asking in French](./images/test_asking_in_french.webp) ## Do you need a local LLM? Probably the most important reason is privacy: having it locally, on your host, means that your documents stay on your computer. You won't share your codebase, for example, with AI companies. *That same argument is why I liked [Tabnine](/blog/vscode-tabnine), which also works offline — Ollama simply takes the idea much further.* You can also think about automation: you'll be able to run automation scripts without the fear of reaching any quota. Also, you don't have to be afraid of billing; since it runs on your host, it's completely free. ## Installing and running Ollama You know, as a Docker lover, I won't install Ollama by hand. Let's create the `compose.yaml` file on your disk. I use a few Docker containers every day, and I save their configuration files in a `~/tools` folder; let's do the same here. Run `mkdir ~/tools/ollama && cd $_` then create a `compose.yaml` with this content: **compose.yaml** ```yaml name: tools services: ollama: image: ollama/ollama container_name: ollama ports: - "11434:11434" volumes: - ollama_data:/root/.ollama restart: always # Uncomment the following lines if you have an NVIDIA GPU and want to use it # deploy: # resources: # reservations: # devices: # - driver: nvidia # count: 1 # capabilities: [gpu] volumes: ollama_data: ``` Still in the `~/tools/ollama` folder, simply run `docker compose up --detach` to download ollama and run it as a Docker container. ## Download an LLM model First, you need to think about your use case. Most likely, you'll want speed and accuracy. For speed, you should use a "small" LLM like `llama3.1:8b`. Let's take an example: you plan to use a VSCode extension, and you need speed for autocompletion. For accuracy, you might be okay with waiting a bit longer to get better results. First, pay attention to how much free RAM you have. ```bash $ free -h total used free shared buff/cache available Mem: 15Gi 5.4Gi 2.4Gi 9.7Mi 7.8Gi 9.9Gi Swap: 4.0Gi 11Mi 4.0Gi ``` Look at the last column **available**. I actually have \~10GB free, so I can easily choose a model of \~4.7GB, like `llama3.1:8b`. Start a new terminal and run this command: ```bash $ docker exec -it ollama ollama pull llama3.1:8b ``` > **We're using Docker right?:** > > Since we've installed ollama as a Docker container, we've to use the `docker exec -it ollama` syntax then the ollama CLI. > > This is why, for instance, we're running `docker exec -it ollama ollama pull llama3.1:8b`. If you run `free -h` again, look at the `Available` column again. I had \~9.9G before, now I've \~5.5G. > **Where are stored LLMs?:** > > Since we're using Docker, look at the `compose.yaml` file. We're using a `ollama_data` Docker self-managed volume. > > Like any other volume, nothing will pollute our disk. If one day we wish to remove Ollama, we just need to remove the Docker container and the associated volume. Et voilà. ### More power needed? There are a lot of models available, look at the suffix at the end of `llama3.1:8b`; for instance, `8b` means 8 billion parameters. The more you can have, the more knowledge you'll get, but it has a price: the size. If you've more than 32GB of RAM, you can certainly also use `gemma2:27b` (27 billion parameters, around \~17G), `mixtral:8x7b` (around \~26G) or `llama3:70b` (70 billion parameters, \~45GB). The higher the parameter count, the better the results — but also the slower the response. You have to determine, for your own use case (your hardware, your GPU, your needs, ...), one or two LLMs you'll use. For instance, a very fast (so, small) model for quick interactions — you don't want to wait 30 seconds for an answer — and a more powerful one for heavy tasks like code refactoring or generating a codebase. #### What Does 8x7b Mean? If you look at mixtral, you'll notice the `8x7b` suffix. This indicates that the model uses a **Mixture of Experts** (MoE) architecture, meaning it is **composed of 8 distinct expert neural networks, each with 7 billion parameters**. When you use this type of model, a built-in router first analyzes your prompt to determine which experts are best equipped to answer. Let's look at an example: imagine you ask Mixtral to generate a Python function. First, the router processes the context of your question. Then, it seamlessly activates only the most relevant experts for that specific task (typically just two out of the eight) to generate the best possible answer. Because it selectively uses its experts, mixtral is an incredibly versatile and highly efficient model—provided your machine has enough RAM to load the entire framework into memory! ### Getting the list of models If you want to retrieve models you've already installed: ```bash $ docker exec -it ollama ollama list ``` The entire list of existing models is online: ## Using a web interface You can download a WebGUI to interact with Ollama and the most used one is **Open WebUI** Let's edit our `compose.yaml` file and add a new service: **compose.yaml** ```yaml name: tools services: ollama: image: ollama/ollama container_name: ollama ports: - "11434:11434" volumes: - ollama_data:/root/.ollama restart: always # Uncomment the following lines if you have an NVIDIA GPU and want to use it # deploy: # resources: # reservations: # devices: # - driver: nvidia # count: 1 # capabilities: [gpu] open-webui: image: ghcr.io/open-webui/open-webui:main container_name: open-webui ports: - "4000:8080" extra_hosts: - "host.docker.internal:host-gateway" environment: - OLLAMA_BASE_URL=http://ollama:11434 volumes: - open-webui_data:/app/backend/data restart: always depends_on: - ollama volumes: ollama_data: open-webui_data: ``` Run `docker compose up --detach` to download the `open-webui` image (\~1.7G) and create the container. Now, simply surf to `http://localhost:`4000 and you'll have your interface and, you can start to interact with your local LLM. > Screenshot — http\://localhost:4000 > > ![Asking for suggestions for a gift for my wife](./images/open_webui.webp) Pay attention top left: you can see there the list of installed models. The chat window is only the surface: model presets, RAG on your documents, Python Tools and API keys are covered in [Open WebUI — Beyond the Chat](/blog/open-webui-advanced). ![The list of models is available in the Open WebUI interface](./images/open_webui_models.webp) ## Using a VSCode extension If you want to add AI in VSCode, you can install [Continue - open-source AI agent](https://marketplace.visualstudio.com/items?itemName=Continue.continue). ![Installing Continue extension in VSCode](./images/install_continue_extension.webp) > **Continue's status as of July 2026:** > > Continue was acqui-hired by Cursor (Anysphere) in June 2026, and the standalone open-source project was wound down: v2.0.0 (June 19, 2026) is the final release, and the GitHub repository is now read-only — no further security patches, model adapters, or editor-compatibility fixes from the original team. The bring-your-own-LLM path with Ollama, which is all this section uses, still works fine in that final release. Worth knowing before investing time in it, but not a reason to skip local autocompletion entirely. Continue requires a config file **in your Windows home folder** — not on the WSL side, at least in my original testing; worth reconfirming on current versions if this doesn't match what you see. > **config.yaml, not config.json:** > > Older Continue versions (pre-1.0) used `config.json`. That format is now deprecated — current versions use `~/.continue/config.yaml` instead, with models listed under explicit `roles` (`chat`, `edit`, `autocomplete`). The snippets below use this current format. Start a Powershell terminal, run `cd ~/.continue ; notepad config.yaml` and paste the content of this file: **.continue/config.yaml** ```yaml name: Local Config version: 0.0.1 schema: v1 models: - name: Assistant (fast) provider: ollama model: llama3.1:8b apiBase: http://127.0.0.1:11434 roles: - chat - edit ``` ![Configure Continue extension](./images/configure_continue_extension.webp) Restart VSCode again or, in the Continue panel, find the `Local config` option and select `Refresh`. When you see your model name in the list of loaded models, Continue is ready to manage your first question. ![Selecting a model in Continue extension](./images/continue_select_model.webp) From now on, you can use Continue to ask questions about your codebase, generate unit tests, refactor some code, and so on. The speed will depend on the model you've selected and your hardware for sure. ### Using autocompletion When you're typing some text in VSCode, you wish immediate autocompletion and not to wait one second or more. For this, you'll need to use a faster model; let's use `qwen2.5-coder:1.5b` (1.5 billion parameters, around \~1G). ```bash $ docker exec -it ollama ollama pull qwen2.5-coder:1.5b ``` Update your `.continue/config.yaml` file (on Windows side) with this new content: **.continue/config.yaml** ```yaml name: Local Config version: 0.0.1 schema: v1 models: - name: Assistant (fast) provider: ollama model: llama3.1:8b apiBase: http://127.0.0.1:11434 roles: - chat - edit - name: Autocompletion provider: ollama model: qwen2.5-coder:1.5b apiBase: http://127.0.0.1:11434 roles: - autocomplete ``` Now that I've added the `qwen2.5-coder:1.5b` model in my config file, let's create a `calculate.sh` file with this content: **calculate.sh** ```bash #!/bin/bash function ``` ... and nothing more. After just one second, VSCode will suggest completing the function with the right code: ![Continue autocompletion in action](./images/continue_autocompletion.webp) I accepted the suggestion and typed `function main`; again, after one second, the rest of the code was autocompleted. Then I typed `function help`, accepted the proposal, and finally accepted the last lines of the script. So, in less than 5 seconds, I've got a complete script with the right code and the right comments. All this without writing a single line of code, just by accepting suggestions from the LLM. **calculate.sh** ```bash #!/bin/bash function calculate() { local num1=$1 local num2=$2 local operator=$3 case $operator in "+") echo "$num1 + $num2 = $(($num1 + $num2))" ;; "-") echo "$num1 - $num2 = $(($num1 - $num2))" ;; "*") echo "$num1 * $num2 = $(($num1 * $num2))" ;; "/") if [ $num2 -ne 0 ]; then echo "$num1 / $num2 = $(($num1 / $num2))" else echo "Error: Division by zero is not allowed." fi ;; *) echo "Error: Invalid operator. Please use +, -, *, or /." ;; esac } function main() { if [ $# -ne 3 ]; then echo "Usage: $0 " echo "Example: $0 5 3 +" exit 1 fi calculate "$1" "$2" "$3" } function help() { echo "This script performs basic arithmetic operations." echo "Usage: $0 " echo "Operators supported: +, -, *, /" echo "Example: $0 5 3 +" } if [[ "$1" == "help" ]]; then help else main "$@" fi ``` And it works out-of-the-box! ```bash $ chmod +x ./calculate.sh $ ./calculate.sh Usage: ./calculate.sh \ \ \ Example: ./calculate.sh 5 3 + ❯ ./calculate.sh 5 3 - 5 - 3 = 2 ``` ### Using more powerful LLM depending on your expectations > **Updated 2026-07-27:** > > This section originally recommended `gemma2:27b`, a dense 27-billion-parameter model. I've since moved to `qwen3-coder:30b` for this role — same Mixture-of-Experts trick explained earlier for Mixtral: 30 billion parameters total, but only \~3.3 billion active per token, so it's noticeably faster than a dense model of similar size while fitting comfortably in 24GB of VRAM (around 19GB on disk with the default quantization). The screenshot further down still shows the original `gemma2:27b` test — the config and pull command below reflect the current recommendation. If you've a powerful CPU and/or GPU with 24GB or more, you can add a stronger model like `qwen3-coder:30b`: **.continue/config.yaml** ```yaml name: Local Config version: 0.0.1 schema: v1 models: - name: Assistant (fast) provider: ollama model: llama3.1:8b apiBase: http://127.0.0.1:11434 roles: - chat - edit - name: Assistant Expert (Qwen3-Coder) provider: ollama model: qwen3-coder:30b roles: - chat - edit - name: Autocompletion provider: ollama model: qwen2.5-coder:1.5b apiBase: http://127.0.0.1:11434 roles: - autocomplete ``` ```bash $ docker exec -it ollama ollama pull qwen3-coder:30b ``` This model is noticeably more accurate than the fast assistant, and — thanks to the Mixture-of-Experts architecture — still reasonably quick despite its size. Save it for intensive code analysis tasks such as code refactoring, generating complex unit tests, and so on. Here's the original test, asking `gemma2:27b` for a refactoring — it was really slow even on my machine (i9 - 64GB): ![Refactory my current file using Continue](./images/continue_refactor_current_file.webp) And using autocompletion for the `readme.md` ![Using autocompletion for the readme.md file](./images/readme_autocompletion.webp) **Bonus** ### CanIRun.ai The website automatically detects your CPU, GPU, and memory configuration—strictly locally, without transmitting your system data to an external server—and instantly outputs a realistic assessment of which AI models your machine can execute smoothly. By visiting that web page, the system will calculate which AI models can run on your hardware and how fast. No data is sent to any server. Everything is computed client-side. ### Freeing Up RAM with ollama stop If you ever need to instantly reclaim your system's memory, you can force a model to unload by running `docker exec -it ` ollama ` ollama stop `. However, this manual step usually isn't necessary, as Ollama is designed to automatically unload models from your RAM after a few minutes of inactivity. It's simply a handy command to keep in your back pocket for those times you need immediate control over your resources! ### Remove a model Simply run `docker exec -it ` ollama ` ollama rm `. ### .wslconfig file If you're a WSL2 user, it's a good idea to create a `.wslconfig` file in your Windows partition in order to specify a few default settings for WSL, such as the maximum amount of RAM it can use. On my host, I use these settings: **.wslconfig** ``` [wsl2] # Limits RAM to leave 12GB for Windows memory=20GB # (Optional) Adds some dedicated swap space for WSL swap=8GB # Mirror Windows network interfaces to WSL for seamless localhost access networkingMode=mirrored # Automatically shrink the WSL virtual disk when files are deleted to free up space sparseVhd=true [experimental] # Enables automatic RAM reclamation so, Windows can reclaim it autoMemoryReclaim=gradual ``` To create this file, simply start a new Powershell terminal, run `cd ~` to go to your home directory (at Windows level) and run `notepad .wslconfig` to open that file (and create it if needed). > **Tip:** > > Remember to run `wsl --shutdown` in a Powershell console if you've created/updated the `.wslconfig` file. --- # Accessing Ollama across your local network > Learn how to set up a dedicated Ollama server on your local network and connect your code editor to it for a private, free AI coding assistant. --- ![Accessing Ollama across your local network](/img/v2/using_ollama_local_network.webp) > **The Continue extension is shut down:** > > The [Continue](https://marketplace.visualstudio.com/items?itemName=Continue.continue) VSCode extension featured in this article was acquired by Cursor on June 18, 2026 and the standalone product has been shut down — the user-data export deadline (July 15, 2026) has passed and the repository is now read-only. The VSCode integration section below has been rewritten accordingly. **TL;DR** This guide shows you how to decouple your heavy AI workloads by setting up a dedicated Ollama server on your local network. You'll learn how to find your server's IP, verify connectivity with `curl`, and connect a VSCode extension that supports Ollama as a backend. The result is a fast, free, and completely private AI coding assistant that replaces cloud alternatives like GitHub Copilot. In a previous article, we installed Ollama, one or more AI models (LLMs), and a web interface called **Open WebUI**. We learned how to play with Ollama locally on a single machine, but we haven't learned how to access it from another computer—for example, across your home network. This is what we're going to do in this article. The idea is to use one computer as a *server* (the heavy lifter), and another as the *client* (your everyday laptop) to access it. The server should have as much Video RAM (VRAM) and regular memory (RAM) as possible to run the AI smoothly. The client will just send web requests to it over your network, so a regular, less powerful computer is perfectly fine. *If containers on that server can't reach each other once everything is in place, [Troubleshooting for Docker containers - Accessing the other one](/blog/docker-networking-troubleshooting) goes through the diagnosis step by step — a proxy is a frequent culprit.* In this article, we'll implement this architecture. Please refer to my previous article ([Installing Ollama and get local AI](/blog/ollama-installation)) for the set-up of the **Local AI Server**. ![Our local AI](./images/diagram.webp) ## Using a Local Network Most likely, you already have a local area network (LAN) at home—this is usually managed by your Wi-Fi router. You can use it to securely access Ollama across your home without sending data to the internet. Personally, I use a simple network switch (like the **D-Link DGS-108**) to connect my computers with cables for the best speed, but a good Wi-Fi connection works too! This setup provides high speed (1000Mbps bandwidth) with almost no delay (latency), which is perfect for our use case. ## Finding your Server's IP address To connect to your server, you need its IP address—think of it as the computer's internal phone number on your home network. Since my server runs on Windows 11, I can find it by opening a Powershell window and running the command: `ipconfig | Select-String -Pattern "IPv4"`. On my server, the output looks like this: `IPv4 Address. . . . . . . . . . . : 192.168.0.218`. Write this number down! ## Setting up the client computer On your second computer (the client), let's first check if it can "talk" to your server. We do this using a networking command called `ping`. Open your terminal and type `ping 192.168.0.218` (replace with your server's IP): ```bash $ Pinging 192.168.0.218 with 32 bytes of data: Reply from 192.168.0.218: bytes=32 time<1ms TTL=128 Reply from 192.168.0.218: bytes=32 time<1ms TTL=128 Reply from 192.168.0.218: bytes=32 time=1ms TTL=128 Reply from 192.168.0.218: bytes=32 time=1ms TTL=128 Ping statistics for 192.168.0.218: Packets: Sent = 4, Received = 4, Lost = 0 (0% loss), Approximate round trip times in milliseconds: Minimum = 0ms, Maximum = 1ms, Average = 0ms ``` This output means that our second computer can access the server with almost zero latency (`time < 1ms`). ### Running Open WebUI First, make sure Ollama and **Open WebUI** are still running on the server computer. If they are, you should be able to open a web browser on your client computer and navigate to `http://192.168.0.218:4000`. Because we already verified the network connection, you should now see the Open WebUI login screen. Once connected, you'll be able to see the Ollama interface, view your available AI models, and start a chat! ### Double-checking the connection Just before we jump into our code editor (VSCode) to set up our AI coding assistant, let's make sure the AI engine is actively listening. To get the list of installed AI models on your server, simply run this command to ask the server for its list: `curl http://192.168.0.218:11434/api/tags` (add `| jq` to the end if you have it installed for prettier formatting). And if you want to test the AI itself—for instance, to ask what a `Dockerfile` is—just fire this command in your client computer's console: ```bash $ curl -X POST http://192.168.0.218:11434/api/generate \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5-coder:1.5b-base", "prompt": "What is a Dockerfile? Please explain it like I''m five.", "stream": false }' ``` > **Check your model name:** > > Make sure the LLM `qwen2.5-coder:1.5b-base` model is indeed present; use another one based on your own list. ## Configure your Code Editor (VSCode) The original article used **Continue**, which has since been shut down (see the notice at the top of this page). The good news: the principle is the same regardless of which extension you use. Any VSCode extension that supports Ollama as a backend gives you the same two capabilities: 1. **Inline Autocomplete**: Real-time code suggestions as you type, powered by **FIM** (Fill-In-the-Middle) inference. 2. **Chat Interface**: Talk directly with your AI assistant to find bugs, rewrite code, or answer questions. At the time of writing, the most active open-source alternatives that natively support an Ollama endpoint are **Cline** and **RooCode** (an actively maintained fork of Cline). Both are available in the VSCode Marketplace. Regardless of the extension you choose, the configuration principle is always the same: point the extension at your Ollama server's address (`http://192.168.0.218:11434`, replacing the IP with your own), select `Ollama` as the provider, and pick a model from your installed list (`ollama list` on the server to see what is available). > **WSL users: install the extension on the Linux side:** > > If you are using WSL2, make sure the extension is installed on the **Linux** side and not on Windows. Open VSCode connected to your WSL session and install the extension from the terminal with `code --install-extension --force`. ## Conclusion By following this guide, we've successfully decoupled our heavy AI workloads from our daily development environment. Setting up a dedicated "AI Server" on your local network allows you to leverage powerful LLMs without draining your primary computer's battery or monopolizing its RAM and CPU. By connecting a VSCode extension that supports Ollama to this server, you get a private, self-hosted, and completely free alternative to cloud-based AI assistants like GitHub Copilot. Because everything runs over your LAN, your code and prompts never leave your local network, ensuring complete privacy and zero subscription fees. Whether you are generating code, getting autocomplete suggestions, or asking questions about your codebase, your own local AI assistant is now just a quick network request away! --- # ai-test: Generate Missing Unit Tests From Your Terminal With a Local LLM > Turn your local Ollama model into an on-demand unit test generator. A zsh function that reads a Bash, PHP or Python file, detects whether you already have tests for it, prints only the missing Bats/Pest/Pytest cases needed for full coverage, offers to save them where the framework expects them, and runs the suite in a throw-away Docker container. --- ![ai-test: Generate Missing Unit Tests From Your Terminal With a Local LLM](/img/v2/ai_test.webp) **TL;DR** `ai-test` turns your local Ollama model into an on-demand unit test generator. Point it at a Bash, PHP or Python file, review the generated tests, save them if you like, then run them immediately in Docker — all without leaving your terminal. It supports Bats (Bash), Pest (PHP) and Pytest (Python). This article opens the **[Ollama daily use](/series/ollama-daily-use)** series: small tools that turn a local LLM into a practical terminal companion instead of another browser tab. Let's stop pretending: we all know unit tests are necessary, and almost nobody enjoys writing them. There's always an excuse — a tight deadline, a Bash script that was only supposed to be temporary, the sheer tedium of mocking dependencies. We promise ourselves we'll add them later, and *later* is the moment the technical debt explodes in production. `ai-test` does the grunt work instead. Point it at a file, get a Bats, Pest or Pytest suite back, read it, save it, run it — all from the terminal, all on your machine. **Quick Jump:** [What ai-test Does For You](#what-ai-test-does-for-you) | [Installing It](#installing-it) ## What `ai-test` Does For You Take `greet.sh`, a small script that asks for a name and a language. One command, and a full Bats suite comes back — trimmed to three tests below, the real run produced more than fifteen: ```bash $ ai-test greet.sh → No test file found for 'greet' — generating a full Bats suite. setup() { source "$BATS_TEST_DIRNAME/../greet.sh" } @test "happy path: greets in English by default" { run greet "Christophe" [ "$status" -eq 0 ] [ "$output" = "Hello Christophe!" ] } @test "happy path: greets in French" { run greet "Christophe" "fr" [ "$status" -eq 0 ] [ "$output" = "Bonjour Christophe !" ] } @test "happy path: greets in Dutch" { run greet "Christophe" "nl" [ "$status" -eq 0 ] [ "$output" = "Hallo Christophe!" ] } → Save this suite as tests/greet.bats? [y/N] y → Written to tests/greet.bats → Run the suite now, in Docker? [y/N] y 1..3 ok 1 happy path: greets in English by default ok 2 happy path: greets in French ok 3 happy path: greets in Dutch ``` That's the whole pitch: one command, a suite you can read in a minute, two confirmations, and a green run telling you it isn't decorative. Nothing was installed to run those tests, and nothing left the machine. ## Why It Works Four ideas, and none of them require you to read a line of zsh yet: * **The extension decides everything** — framework, naming convention, and where the file lands. * **The prompt carries your project layout.** The model never sees a path, only a blob of text; `ai-test` computes the destination first, then dictates how the suite must load the code. * **It reads the tests you already have** and asks for the gaps only, appended to the existing file. * **It validates in a throw-away container**, so a suite that *looks* right has to prove it *passes*. ## Installing It Two files to drop into `~/.zsh/fns/`, one new shell. First, the one hard dependency — `jq`, used to build the JSON payload sent to Ollama: Prerequisite: jq ```bash $ sudo apt update && sudo apt install jq -y Reading package lists... Done Building dependency tree... Done 0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded. ``` Verify: ```bash $ jq --version jq-1.7 ``` You also need Ollama running somewhere reachable — I've covered [installing it](/blog/ollama-installation) and [exposing it across my network](/blog/accessing-ollama-across-your-local-network) before; mine sits idle most of the day, so why not point it at the one task I keep postponing? **Two optional tools: bat and docker (click if you want the details)** **`bat`** — if [it](https://github.com/sharkdp/bat) is installed, the generated code is syntax-highlighted before it hits your terminal. If not, `ai-test` falls back to plain output. Watch out for one Debian/Ubuntu quirk: the `bat` name was already taken by `bacula-console-qt`, so `apt install bat` installs the binary as **`batcat`**. That's why the `_ai_bat` helper probes both spellings instead of hard-coding `bat`. Prerequisite: bat ```bash $ sudo apt install bat ``` Verify: ```bash $ batcat --version bat 0.24.0 ``` **`docker`** — only needed for the "run the suite now" step at the very end. `ai-test` skips that step with a message when `docker` isn't on your `PATH`. Prerequisite: docker ```bash $ sudo apt install docker ``` Verify: ```bash $ docker --version Docker version 29.6.2, build dfc4efb ``` Now the two files. `_ollama.zsh` holds everything the series shares — the query helper, the command registry, and the `ai` entry point — while `ai-test.zsh` is the function itself: **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 — 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 — 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 — print 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 — 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 — 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 [args] → dispatch straight to ai- [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 [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-test.zsh** ```bash # ai-test — generate (or complete) a unit test suite for a script using # a local LLM. Detects Bash/PHP/Python from the extension, looks for an # existing test file using each ecosystem's naming convention, and asks the # model to either write a full suite or fill the coverage gaps only. # # The result is printed first, then offered for saving at the path its # ecosystem expects, then optionally run inside a throw-away Docker container # so no test runner has to be installed on the host. AI_COMMANDS[test]="ai-test — generate or complete a unit test suite" AI_PARAMS[test]="file" # Images used to run the generated suites. Overridable from ~/.zshrc. : ${AI_TEST_BATS_IMAGE:=bats/bats:latest} : ${AI_TEST_PHP_IMAGE:=php:8.4-cli} : ${AI_TEST_PY_IMAGE:=python:3.14-slim} # _ai_project_root — walk up from until a folder # containing one of the markers is found; print it, or when none matches. # This is what keeps "src/Backup.php" from proposing "src/tests/…". _ai_project_root() { local dir="${1:A}" shift local candidate="$dir" marker while [[ "$candidate" != "/" ]]; do for marker in "$@"; do if [[ -e "${candidate}/${marker}" ]]; then print -- "$candidate" return 0 fi done candidate="${candidate:h}" done print -- "$dir" } # _ai_test_root — print the folder the "tests/" tree hangs from, # using the marker file each ecosystem puts at the root of a project. _ai_test_root() { local ext="$1" dir="$2" case "$ext" in sh|bash) _ai_project_root "$dir" .git ;; php) _ai_project_root "$dir" composer.json .git ;; py) _ai_project_root "$dir" pyproject.toml setup.py .git ;; esac } # _ai_test_target — print the conventional path where the # generated suite belongs, following each ecosystem's own layout: # Bats → tests/.bats # Pest → tests/Unit/Test.php (PSR-4 style, next to composer.json) # Pytest → tests/test_.py _ai_test_target() { local ext="$1" root="$2" base="$3" case "$ext" in sh|bash) print -- "${root}/tests/${base}.bats" ;; php) print -- "${root}/tests/Unit/${base}Test.php" ;; py) print -- "${root}/tests/test_${base}.py" ;; esac } # _ai_relative_to — print as seen from . # Used to tell the model how the test file will reach the code under test. _ai_relative_to() { local from="${1:A}" to="${2:A}" rel if rel=$(realpath --relative-to="$from" "$to" 2>/dev/null); then print -- "$rel" return 0 fi # Portable fallback: climb until "from" is a prefix of "to", counting the # hops as "../" segments. local common="$from" up="" while [[ "$to" != "${common}/"* && "$common" != "/" ]]; do common="${common:h}" up="../${up}" done print -- "${up}${to#${common}/}" } # _ai_test_bootstrap — print the paragraph that tells the # model how the suite is going to load the code it is testing. # # This is the single most important part of the prompt. Without it the model # writes a technically perfect suite that calls functions nobody ever sourced, # and every test dies with "command not found" (exit code 127). The model can't # guess it: it only ever sees the file *contents*, never the layout. Now that # ai-test knows the exact path the suite will be saved at, it can dictate the # bootstrap line instead of hoping. _ai_test_bootstrap() { local ext="$1" file="$2" target="$3" local rel rel=$(_ai_relative_to "${target:h}" "$file") case "$ext" in sh|bash) cat </dev/null \ | sed -E 's/^[[:space:]]*namespace[[:space:]]+//; s/[[:space:]]*;.*$//') if [[ -n "$namespace" ]]; then cat < — warn about a suite that is already # broken before we add anything to it. # # The classic one: a Bats file calling assert_success / assert_output without # ever loading bats-assert. Those helpers are an optional library, so every # such test dies with "command not found" — and since gap-fill mode asks the # model to match the existing style, the new cases inherit the breakage. _ai_test_lint_existing() { local ext="$1" file="$2" [[ "$ext" == sh || "$ext" == bash ]] || return 0 if grep -qE '(^|[^[:alnum:]_])(assert|refute)_[a-z]' "$file" 2>/dev/null \ && ! grep -qE 'load[[:space:]].*bats-(assert|support)' "$file" 2>/dev/null; then echo "→ Warning: $(_ai_relpath "$file") calls bats-assert helpers but never loads them." >&2 echo " Those tests already fail with 'command not found'; add to setup():" >&2 echo " load '/usr/lib/bats/bats-support/load'" >&2 echo " load '/usr/lib/bats/bats-assert/load'" >&2 fi } # _ai_test_run — offer to run the suite in a # disposable container. Nothing is installed on the host and the container is # removed on exit; only the project folder is mounted. _ai_test_run() { local ext="$1" root="$2" rel="$3" if ! command -v docker >/dev/null 2>&1; then echo "→ Docker not available — skipping the test run." >&2 return 0 fi _ai_confirm "→ Run the suite now, in Docker?" || return 0 case "$ext" in sh|bash) # The working directory is a scratch folder *inside* the container, not # the mounted project. Any test that calls the code under test with a # default "current directory" destination then writes its artifacts to a # path that disappears with --rm, instead of littering the repository # with half-cleaned-up files. Tests that need real files on disk use # $BATS_TEST_DIRNAME, which still points into /code. docker run --rm --user "$(id -u):$(id -g)" \ --volume "${root}:/code" --workdir /tmp \ "$AI_TEST_BATS_IMAGE" "/code/${rel}" ;; php) if [[ ! -x "${root}/vendor/bin/pest" ]]; then echo "ai-test: ${root}/vendor/bin/pest not found — run 'composer install' first." >&2 return 1 fi docker run --rm --user "$(id -u):$(id -g)" \ --volume "${root}:/code" --workdir /code \ "$AI_TEST_PHP_IMAGE" vendor/bin/pest "$rel" ;; py) # Runs as root so "pip install" can reach the system site-packages; the # two cache flags are what stop it from leaving root-owned folders behind. # PYTHONPATH=/code is what makes a plain "import backup" work from # tests/test_backup.py, so the suite needs no sys.path gymnastics. docker run --rm --env PYTHONDONTWRITEBYTECODE=1 --env PYTHONPATH=/code \ --volume "${root}:/code" --workdir /code \ "$AI_TEST_PY_IMAGE" \ sh -c "pip install --quiet --root-user-action=ignore pytest && pytest -p no:cacheprovider '${rel}'" ;; esac } ai-test() { local file="$1" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-test " >&2 return 1 fi local ext="${file:e}" local lang framework bat_lang case "$ext" in sh|bash) lang="Bash"; framework="Bats"; bat_lang="bash" ;; php) lang="PHP"; framework="Pest"; bat_lang="php" ;; py) lang="Python"; framework="Pytest"; bat_lang="python" ;; *) echo "ai-test: unsupported extension .$ext (expected .sh, .php or .py)" >&2 return 1 ;; esac local dir="${file:h}" local base="${file:t:r}" local root target root=$(_ai_test_root "$ext" "$dir") target=$(_ai_test_target "$ext" "$root" "$base") local existing="" case "$ext" in sh|bash) existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 2 \ -iname "${base}.bats" 2>/dev/null | head -1) ;; php) existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 3 \ -iregex ".*/${base}Test\.php" 2>/dev/null | head -1) ;; py) existing=$(find "$dir" "$dir/tests" "$root/tests" -maxdepth 2 \ \( -iname "test_${base}.py" -o -iname "${base}_test.py" \) 2>/dev/null | head -1) ;; esac local source_code source_code=$(<"$file") local prompt if [[ -n "$existing" ]]; then echo "→ Found existing tests: $(_ai_relpath "$existing") — asking only for the coverage gaps." >&2 _ai_test_lint_existing "$ext" "$existing" local existing_tests existing_tests=$(<"$existing") prompt="You are a senior $lang test engineer. Below is a source file and its CURRENT $framework test suite. Compare them, identify every function, branch and edge case in the source that is NOT exercised by the current tests, and output ONLY the additional $framework test cases needed to reach 100% coverage. Do not repeat any existing test. Output raw $framework code only — no explanations, no markdown fences. The new cases are appended to the existing file, so they inherit its setup/fixtures: do not repeat them. Use only constructs and helpers that already appear in the current suite, and do not hard-code any value derived from today's date. --- SOURCE: $file --- $source_code --- EXISTING TESTS: $existing --- $existing_tests" else echo "→ No test file found for '$base' — generating a full $framework suite." >&2 local bootstrap bootstrap=$(_ai_test_bootstrap "$ext" "$file" "$target") prompt="You are a senior $lang test engineer. Write a complete $framework test suite for the file below: happy path, edge cases, and error conditions. Output raw $framework code only — no explanations, no markdown fences. ${bootstrap} --- SOURCE: $file --- $source_code" fi local result result=$(_ollama_query "$prompt") || return 1 # The prompt says "no markdown fences"; models add them anyway often enough # that stripping is the only reliable way to get paste-ready code. result=$(_ai_strip_fences "$result") if [[ -z "$result" ]]; then echo "ai-test: the model returned an empty response." >&2 return 1 fi local bat_bin if bat_bin=$(_ai_bat); then print -r -- "$result" \ | "$bat_bin" --language="$bat_lang" --style=plain --paging=never --color=always else print -r -- "$result" fi # --- Save -------------------------------------------------------------- echo local shown if [[ -n "$existing" ]]; then shown=$(_ai_relpath "$existing") _ai_confirm "→ Append these cases to ${shown}?" || { echo "→ Not saved."; return 0; } print -r -- "" >> "$existing" print -r -- "$result" >> "$existing" echo "→ Appended to ${shown}" target="$existing" else shown=$(_ai_relpath "$target") if [[ -e "$target" ]]; then _ai_confirm "→ ${shown} already exists. Overwrite it?" || { echo "→ Not saved."; return 0; } else _ai_confirm "→ Save this suite as ${shown}?" || { echo "→ Not saved."; return 0; } fi mkdir -p "${target:h}" print -r -- "$result" > "$target" echo "→ Written to ${shown}" fi # --- Run --------------------------------------------------------------- local abs_target="${target:A}" _ai_test_run "$ext" "$root" "${abs_target#${root}/}" } ``` If you already set up the `~/.zsh/fns/` autoloader from [my ripgrep article](/blog/ripgrep), you're done: open a new shell. If not, here's what to add to your existing `~/.zshrc`: ```bash title="~/.zshrc" export OLLAMA_MODEL="qwen3-coder:30b" # optional, this is already the default export AI_TEST_PHP_IMAGE="php:8.4-cli" # optional, override to match your project's PHP export AI_TEST_PY_IMAGE="python:3.14-slim" # optional, same idea for Python for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` The exports go *above* the loop on purpose: the `AI_TEST_*` defaults are applied at source time, so anything you set afterwards would arrive too late. ### One Entry Point: `ai` Open a new shell and type `ai`, with no argument. Instead of having to remember what you called your functions six months ago, you get an `fzf` picker listing every command that registered itself — today just `test`, tomorrow whatever else the series adds: ![ai-test](./images/ai-test.gif) Pick one and it runs. If you already know what you want, `ai test greet.sh` dispatches straight to `ai-test` and skips the menu entirely — the two forms are equivalent, one is discoverable and, the other is fast. > **Read before you trust:** > > Generated tests are a draft, not a guarantee — read them like a pull request from a junior contributor. Running the suite proves it *executes*; only reading it proves it *checks* something. ## More Demos ### A Real Script, and the Gaps It Fills `greet.sh` was a warm-up. `backup.sh` is a different beast — an external command, a timestamp, and real filesystem side effects: **backup.sh** ```bash #!/usr/bin/env bash # backup.sh — tar+gzip a directory into a timestamped archive backup_dir() { local src="$1" local dest_dir="${2:-.}" if [[ -z "$src" ]]; then echo "Usage: backup_dir [dest_dir]" >&2 return 1 fi if [[ ! -d "$src" ]]; then echo "Error: '$src' is not a directory" >&2 return 1 fi local timestamp timestamp=$(date +%Y%m%d-%H%M%S) local archive_name archive_name="$(basename "$src")-${timestamp}.tar.gz" mkdir -p "$dest_dir" if tar -czf "${dest_dir}/${archive_name}" -C "$(dirname "$src")" "$(basename "$src")" 2>/dev/null; then echo "${dest_dir}/${archive_name}" return 0 else echo "Error: tar failed for '$src'" >&2 return 1 fi } ``` `ai-test backup.sh` came back with ten tests, all green. Here are the first three: ```bash ❯ ai-test backup.sh → No test file found for 'backup' — generating a full Bats suite. setup() { source "$BATS_TEST_DIRNAME/../backup.sh" } @test "happy path: backup a directory with default destination" { local test_dir="$BATS_TEST_DIRNAME/test_dir" local archive_name mkdir -p "$test_dir" echo "test content" > "$test_dir/file1.txt" mkdir -p "$test_dir/subdir" echo "sub content" > "$test_dir/subdir/file2.txt" archive_name=$(backup_dir "$test_dir") [ -f "$archive_name" ] [ -n "$archive_name" ] [[ "$archive_name" == *.tar.gz ]] } @test "happy path: backup a directory with custom destination" { local test_dir="$BATS_TEST_DIRNAME/test_dir" local dest_dir="$BATS_TEST_DIRNAME/dest" local archive_name mkdir -p "$test_dir" echo "test content" > "$test_dir/file1.txt" archive_name=$(backup_dir "$test_dir" "$dest_dir") [ -f "$archive_name" ] [ -n "$archive_name" ] [[ "$archive_name" == "$dest_dir"/* ]] [[ "$archive_name" == *.tar.gz ]] } @test "edge case: backup directory with special characters in name" { local test_dir="$BATS_TEST_DIRNAME/test dir with spaces" local archive_name mkdir -p "$test_dir" echo "test content" > "$test_dir/file.txt" archive_name=$(backup_dir "$test_dir") [ -f "$archive_name" ] [ -n "$archive_name" ] } → Save this suite as tests/backup.bats? [y/N] y → Written to tests/backup.bats → Run the suite now, in Docker? [y/N] y 1..3 ok 1 happy path: backup a directory with default destination ok 2 happy path: backup a directory with custom destination ok 3 edge case: backup directory with special characters in name ``` Now run the very same command a second time: ```bash $ ai-test backup.sh → Found existing tests: tests/backup.bats — asking only for the coverage gaps. @test "error condition: tar fails when source directory is empty" { local test_dir="$BATS_TEST_DIRNAME/empty_dir" local archive_name mkdir -p "$test_dir" archive_name=$(backup_dir "$test_dir" 2>&1) [ -n "$archive_name" ] [[ "$archive_name" == *"Error: tar failed"* ]] } ``` `ai-test` found the `tests/backup.bats` it wrote a minute ago, sent it to the model along with the source, and asked for the missing cases only. Instead of re-reading ten tests I already trust, I'm reviewing one new, focused addition — and it gets **appended** to the existing file, not written to a second one that would duplicate the `setup()`. That's the mode you'll use the most: every time you add a branch to a function, a second run asks the model for what changed and nothing else. ### The Same Command, in PHP and in Python Two Bash demos might give the impression this is a shell-scripting toy, so let's be explicit: the only thing that changes between languages is the extension you type. Here's a PHP class small enough to keep in your head, doing the one thing every project ends up rewriting: **src/Slug.php** ```php float: """Return `price` reduced by `percent`, rounded to the cent. A 0% discount returns the price unchanged; 100% returns 0.0. """ if price < 0: raise ValueError("price cannot be negative") if not 0 <= percent <= 100: raise ValueError("percent must be between 0 and 100") return round(price * (1 - percent / 100), 2) def cheapest(offers: dict[str, float]) -> str: """Return the name of the cheapest offer. Ties are resolved by name, so the result never depends on dict ordering. """ if not offers: raise ValueError("no offer given") return min(sorted(offers), key=offers.__getitem__) ``` Run `ai-test src/Slug.php` and `ai-test src/prices.py`, and everything behaves the same way: same prompt, same two confirmations, same throw-away container. Only the destination changes — `tests/Unit/SlugTest.php` and `tests/test_prices.py`, both resolved from the project root (`composer.json` for PHP, `pyproject.toml` or `setup.py` for Python) rather than from `src/`. ## Under the Hood (skip this if you just want to use it) ### The Logic, Step by Step 1. Register itself with `AI_COMMANDS[test]=...` — the one line that puts `ai-test` in the `ai` menu. 2. Walk up to the project root (`.git`, `composer.json`, `pyproject.toml`…), derive where the suite *should* live, and look for an existing test file using each ecosystem's naming convention: `.bats`, `Test.php`, `test_.py` / `_test.py`. 3. Build one of two prompts — "write a full suite", or "here's the source *and* the current tests, give me only what's missing" — and, in the first case, append the **bootstrap paragraph**. 4. Send it through `_ollama_query`, strip the Markdown fences the model adds despite being told not to, and pretty-print the result with `bat` if it's available. Three things worth calling out in `_ollama.zsh` itself: * The leading underscore is deliberate: the loader (`for fn_file in ~/.zsh/fns/*.zsh`) sources files alphabetically and `_` sorts before any letter, so `AI_COMMANDS` and `ai()` always exist before an `ai-*.zsh` file tries to register itself into them. * `_ollama_query` pings `${host}/api/tags` first, with a two-second timeout, and fails loudly if Ollama isn't reachable — better an immediate error than a `curl` hanging for thirty seconds against a stopped container. It also builds its JSON payload with `jq`, so quotes, backslashes and newlines in the source code I'm about to paste into the prompt are escaped for me. Note that the prompt is *piped* into `jq` rather than passed as a `--arg` value: Linux caps a single command-line argument at 128 KB, and a big file or a long diff would otherwise blow up with `argument list too long`. * `AI_COMMANDS` is a plain associative array, and that is why one line of registration is enough to make a new function show up in the `ai` menu — no central list to maintain, no file to edit when the series grows. ### The One Instruction That Makes the Difference That bootstrap paragraph in step 3 is what decides whether a generated suite works or is completely inert. The rule is simple: **a test can only call code it has loaded, and the model never sees where that code lives.** It receives a blob of text, never a path. Ask for a suite without saying anything else, and you get well-named tests with sensible assertions that all fail the same way — `command not found`, exit code 127 — because nothing ever loaded the file under test. `ai-test` computed the save path a step earlier, so it doesn't hope: `_ai_test_bootstrap` dictates the loading line, in the form each ecosystem expects. * **[Bats](/blog/bats-unit-tests)** — each test runs in a fresh shell that has loaded nothing but the `.bats` file, so the suite must open with a `setup()` that sources the script. `_ai_relative_to` computes the path from the test's folder to the source, so `src/lib/tool.sh` saved as `tests/tool.bats` yields `source "$BATS_TEST_DIRNAME/../src/lib/tool.sh"`. * **Pytest** — `tests/test_tool.py` can't `import tool` unless the project root is on the path. Rather than let the model write `sys.path` incantations, the Docker run sets `PYTHONPATH=/code` and the prompt says *"import the module directly, do not touch sys.path"*. * **Pest** — the class is autoloaded by Composer, so what the model needs is the **namespace**: `_ai_test_bootstrap` greps the `namespace` line out of the source and hands over a ready-made `use` statement. No namespace declared? It falls back to a `require_once` with the computed relative path. > **Give the model the layout, not just the code:** > > This generalizes well beyond tests. When an LLM writes code that will live in a *specific place* in a *specific project*, most of its mistakes come from the context you didn't give it — file paths, import roots, namespaces, conventions. Those are things your script already knows, and every fact you hand over is a fact the model no longer has to guess wrong. ### Where the File Gets Saved The suite is printed in your console first: you read it there, and nothing has touched your disk yet. Then `ai-test` asks if you want to keep it — and since it knows the framework, it proposes the path that framework expects, so you never have to remember whether Pest wants `tests/Unit/` or `tests/Feature/`: | Source | Framework | Proposed path | | ---------------- | --------- | --------------------------- | | `backup.sh` | Bats | `tests/backup.bats` | | `src/Backup.php` | Pest | `tests/Unit/BackupTest.php` | | `src/backup.py` | Pytest | `tests/test_backup.py` | Note that `tests/` hangs off the **project root**, not off the source file's folder: `src/Backup.php` proposes `tests/Unit/BackupTest.php` at the top of the repository, not the nonsensical `src/tests/Unit/BackupTest.php`. > **It still asks first:** > > Nothing is written without an explicit `y`, and if the target file already exists the question becomes an unambiguous *"… already exists. Overwrite it?"*. Anything else — including just pressing Enter — prints `→ Not saved.` and stops there. Generated code shouldn't land in your repository by accident. ### Running the Suite Without Installing a Test Runner Installing a test runner for code I might delete thirty seconds later is an absurd amount of setup, so `ai-test` runs the suite in a container instead. Bats [publishes an official image](https://hub.docker.com/r/bats/bats) whose entrypoint is `bats` itself, which makes it a one-liner: ```bash $ docker run --rm --user "$(id -u):$(id -g)" --volume "${root}:/code" --workdir /tmp bats/bats:latest /code/tests/backup.bats ``` The three images are overridable through `AI_TEST_BATS_IMAGE`, `AI_TEST_PHP_IMAGE` and `AI_TEST_PY_IMAGE` — see the `~/.zshrc` block above. > **Your code is mounted read-write:** > > The container gets the project folder, not a copy of it, because the tests have to create temporary files next to the code. It's a throw-away container (`--rm`), but it *is* running generated code against your working tree — one more reason to read the output before answering `y`. ### Write for the Test You Haven't Written Yet Here's the `greet.sh` from the top of this article: **greet.sh** ```bash #!/usr/bin/env bash # greet.sh — ask for a name and a language, then print the greeting. # normalize_name — trim the surrounding spaces and capitalize the first # letter, so " christophe " and "Christophe" end up as the same thing. normalize_name() { local name="$1" # Strip leading, then trailing whitespace — pure parameter expansion, no # external command involved. name="${name#"${name%%[![:space:]]*}"}" name="${name%"${name##*[![:space:]]}"}" if [[ -z "$name" ]]; then echo "Error: no name given" >&2 return 1 fi echo "${name^}" } # greet [lang] — print the greeting line. The language defaults to # English and is case-insensitive. greet() { local name="$1" local lang="${2:-en}" local clean clean=$(normalize_name "$name") || return 1 case "${lang,,}" in en) echo "Hello ${clean}!" ;; fr) echo "Bonjour ${clean} !" ;; nl) echo "Hallo ${clean}!" ;; *) echo "Error: unsupported language '${lang}' (expected en, fr or nl)" >&2 return 1 ;; esac } main() { local name lang read -rp "What is your first name? " name read -rp "Which language (en/fr/nl)? " lang greet "$name" "$lang" } # Only run main() when the script is executed, never when it is sourced — # this is what lets a test suite load the functions without triggering the # interactive prompts. if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi ``` It's tiny, but it already has everything a test suite cares about: two functions, a default value, and three ways to fail. Run on its own, it's just an interactive prompt: ```bash $ ./greet.sh What is your first name? christophe Which language (en/fr/nl)? fr Bonjour Christophe ! ``` The last three lines are what makes the whole exercise possible: ```bash if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" fi ``` `main` runs only when the script is *executed*, never when it is *sourced*. Without that guard, the `setup()` of the generated suite would `source greet.sh`, hit `read -rp "What is your first name? "`, and hang forever on input that never comes. Which gives the two habits that make any script testable: put the logic in functions that take arguments and print results, and wrap the interactive part in a `BASH_SOURCE` guard. Skip them, and the best model in the world can only generate tests that hang. ### Choosing Your Model This isn't a one-model-fits-all situation. On a 24GB card, `qwen3-coder:30b` gives noticeably better test coverage reasoning than the smaller `1.5b`/`7b` variants I compared in my [network access article](/blog/accessing-ollama-across-your-local-network) — it's slower, but for a task you run a handful of times a day, not thousands, the extra ten seconds don't matter. If you're VRAM-constrained, drop to `qwen2.5-coder:7b` and expect a good happy-path suite with weaker edge-case coverage; you'll do more of the thinking yourself. ## Conclusion Two files to copy into `~/.zsh/fns/`, one new shell, and the script you were never going to test becomes `ai-test greet.sh`: a full suite, green, in the time it takes to make coffee — nothing installed, nothing leaving your machine. Grab `greet.sh` above and try it on your own scripts. Then look at the one you've been avoiding for months, the `backup.sh` of your repository, and run it there too: worst case you throw the output away, best case you finally have a suite. --- # ai-review, ai-secrets, ai-commit: Three zsh Checks Before Every git Commit > Three zsh functions that together form a local, offline pre-commit quality gate: ai-review catches SOLID violations and naming issues, ai-secrets separates real hardcoded credentials from false positives, and ai-commit drafts the Conventional Commits message once the diff is actually clean. --- ![ai-review, ai-secrets, ai-commit: Three zsh Checks Before Every git Commit](/img/v2/ai_review.webp) **TL;DR** This article adds three functions to the "Ollama daily use" series — `ai-review`, `ai-secrets`, and `ai-commit` — that together cover the moment right before a `git commit`. All three read the same `git diff --staged`, so they share a single extracted helper (`_git_staged_diff`) instead of repeating the same guard clauses three times. The workflow is intentional: review first, catch any secrets second, write the commit message last — once the diff is actually what you want it to be. The moment right before a `git commit` is probably the most valuable one in the whole development cycle to pause and look at what you've actually written. Not because something is necessarily wrong — usually it isn't — but because that's when the diff is small and bounded. One more minute of review when you can still see every changed line clearly is worth more than an hour of archaeology later. I already have [pre-commit hooks](/blog/git-precommit) running phpcbf, PHPStan and friends. Those catch *rules*. What they don't catch is judgment: a method that quietly grew three responsibilities, `0.21` on line 20 that should have a name (like `discount_rate`), `$d` that was a lazy parameter name three months ago and is unreadable today — or, more critically, a real database password that slipped into a config diff. These three functions fill that gap, locally, without a cloud subscription. ## What `ai-review` Catches Before You Even Write a Commit Message `ai-review` reads your staged diff and flags what a linter can't: a method quietly doing three things at once, a magic number that should have a name, a parameter that used to make sense. Here's a staged diff with a few deliberate issues planted in it: **src/Invoice/InvoiceProcessor.php (staged diff)** ```php diff --git a/src/Invoice/InvoiceProcessor.php b/src/Invoice/InvoiceProcessor.php index 1234567..89abcde 100644 --- a/src/Invoice/InvoiceProcessor.php +++ b/src/Invoice/InvoiceProcessor.php @@ -10,6 +10,32 @@ class InvoiceProcessor { + public function process($d) + { + if (!isset($d['amount']) || !isset($d['email'])) { + return false; + } + + if ($d['amount'] <= 0) { + return false; + } + + $tax = $d['amount'] * 0.21; + $total = $d['amount'] + $tax; + + $conn = new PDO('mysql:host=localhost;dbname=billing', 'root', 'root'); + $stmt = $conn->prepare('INSERT INTO invoices (amount, tax, total) VALUES (?, ?, ?)'); + $stmt->execute([$d['amount'], $tax, $total]); + + $subject = 'Your invoice'; + $body = 'Total due: ' . $total; + mail($d['email'], $subject, $body); + + return true; + } } ``` ```bash $ git diff --staged --stat src/Invoice/InvoiceProcessor.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) $ ai-review ## SOLID violations - src/Invoice/InvoiceProcessor.php:11 — process() validates input, calculates tax, persists to the database, and sends an email: four responsibilities in one method, violating Single Responsibility. Split into validateInvoiceData(), calculateTax(), saveInvoice() and sendInvoiceEmail(). ## Magic constants - src/Invoice/InvoiceProcessor.php:20 — 0.21 is a hardcoded VAT rate with no named meaning. Extract it to a class constant such as VAT_RATE_STANDARD = 0.21, or load it from configuration. ## Naming - src/Invoice/InvoiceProcessor.php:11 — the parameter $d gives no indication of its shape or purpose. Rename to $invoiceData, and consider a typed value object instead of a raw array. ## Overall quality - src/Invoice/InvoiceProcessor.php:23 — database credentials ('root', 'root') are hardcoded directly in the method body. Move them to environment variables or a configuration service; this connection also should not be constructed inside a method that also handles business logic. ``` Four genuine issues surfaced, under four of the five fixed headings the prompt allows. "Long functions" stayed empty — the model decided 26 lines didn't cross that line, and the prompt explicitly told it not to invent a section just to fill it. > **Read-only, on purpose:** > > `ai-review` never touches the diff, never blocks a commit, never writes anything. It prints a review to your terminal. What you do with it is your call. ## Why It's Built This Way * All three read the same `git diff --staged` — they share one helper for the guard clauses (repo check, empty diff, size warning) instead of repeating them three times. * `ai-secrets` runs a cheap regex first; the model is only called when something actually looks suspicious, so a clean diff costs zero model calls. * `ai-commit` never commits by itself — the default answer is "no," you explicitly type `y` to accept or `e` to edit. * The order isn't arbitrary: review while you can still fix the diff, check for secrets once it's the diff you meant to write, then write the message for what you've actually cleaned up. ## Installing the Three Functions All three build on a shared `_ollama.zsh` foundation (introduced in [ai-test](/blog/ollama-test-generator)) that owns the guard clauses once, instead of each function repeating them: **\~/.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 — 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 — 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 — print 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 — 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 — 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 [args] → dispatch straight to ai- [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 [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[@]}" } ```` Add the three functions on top of it. Save each file in `~/.zsh/fns/` under the name shown above it (`mkdir -p ~/.zsh/fns` first if the folder doesn't exist yet), pasting its content: **\~/.zsh/fns/ai-review\.zsh** ```bash # ai-review — SOLID, magic-constants, naming and overall quality review of # staged changes, printed straight to the terminal. Read-only: it never # modifies the diff or blocks a commit — that decision stays with you. AI_COMMANDS[review]="ai-review — code review of staged changes (SOLID, magic constants, naming)" AI_PARAMS[review]="none" ai-review() { local diff diff=$(_git_staged_diff ai-review) || return 1 local prompt="You are a strict senior code reviewer. Review the staged diff below and report ONLY genuine issues, organized under these exact headings (omit a heading entirely if there is nothing to report under it): ## SOLID violations ## Magic constants ## Long functions ## Naming ## Overall quality For each issue: cite the file and approximate line, state the problem in one sentence, and suggest a concrete fix in one sentence. Do not invent issues to fill every heading — an empty section is a good outcome, not a failure. Do not comment on formatting or style already enforced by a linter. --- STAGED DIFF --- ${diff}" _ollama_query "$prompt" } ``` **\~/.zsh/fns/ai-secrets.zsh** ```bash # ai-secrets — scan staged changes for hardcoded credentials. # Phase 1: a cheap regex pass over the diff finds candidate lines # (no model call wasted if nothing matches). # Phase 2: the LLM judges each candidate — real leak vs. safe pattern # (getenv(), placeholder, variable name only, already-redacted value). AI_COMMANDS[secrets]="ai-secrets — scan staged changes for hardcoded credentials" AI_PARAMS[secrets]="none" ai-secrets() { local diff diff=$(_git_staged_diff ai-secrets) || return 1 local suspects suspects=$(print -- "$diff" | grep -nEi \ '(api[_-]?key|secret|password|passwd|token|access[_-]?key|private[_-]?key|AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----)') if [[ -z "$suspects" ]]; then echo "ai-secrets: no suspicious patterns in the staged diff." >&2 return 0 fi local prompt="You are a secrets-detection expert reviewing a git diff. Below are ONLY the lines that matched a suspicious pattern — context around them has been trimmed. For each line: quote it, then state whether it is a genuine hardcoded secret or a false positive. False positives include: a variable NAME containing 'password' with no real value, a placeholder like 'changeme', an already-redacted value, or a correct read from the environment via getenv()/os.environ/process.env. Output only genuine findings, one per line with a one-sentence reason, or the single line 'No secrets detected.' if every match is a false positive. --- SUSPICIOUS LINES --- ${suspects}" _ollama_query "$prompt" } ``` **\~/.zsh/fns/ai-commit.zsh** ```bash # ai-commit — draft a Conventional Commits message from staged changes using # a local LLM, then ask for explicit confirmation before committing anything. # The default answer (Enter) is always "no" — this function never commits # on your behalf without a deliberate y or e. AI_COMMANDS[commit]="ai-commit — draft a Conventional Commits message from staged changes" AI_PARAMS[commit]="none" ai-commit() { local diff diff=$(_git_staged_diff ai-commit) || return 1 local prompt="You are a git commit message expert. Write a single Conventional Commits message (type(scope): subject, optionally a short body) for the staged diff below. Keep the subject line under 72 characters, imperative mood. Output ONLY the commit message — no explanations, no markdown fences, no surrounding quotes. --- STAGED DIFF --- ${diff}" local message message=$(_ollama_query "$prompt") || return 1 echo "--------------------------------------------------" echo "$message" echo "--------------------------------------------------" print -n "Use this message? [y]es / [e]dit / [N]o: " local answer read -k1 answer echo case "$answer" in y|Y) git commit -m "$message" ;; e|E) git commit -e -m "$message" ;; *) echo "ai-commit: aborted, nothing committed." ;; esac } ``` If an earlier article of this series already made your `~/.zshrc` load that folder, there's nothing else to set up. Otherwise, add these lines at the end of `~/.zshrc` — they source every `.zsh` file in the folder: ```zsh title="~/.zshrc" for fn_file in ~/.zsh/fns/*.zsh; do source "$fn_file" done ``` Then open a new terminal (or run `exec zsh`), and check that the shell knows the three functions: ```zsh type ai-review ai-secrets ai-commit ``` Each one should answer `… is a shell function from …`; `not found` means the `~/.zshrc` loop is missing. ## `ai-secrets`: Catching Real Credentials A plain regex scanner can't tell `'password' => 'Sup3rSecret!'` (a real leak) from `'password' => getenv('DB_PASSWORD')` (the right pattern) — both lines contain "password" next to an `=`. That's exactly the gap the model closes: **config/database.php (staged diff)** ```php diff --git a/config/database.php b/config/database.php index 1234567..89abcde 100644 --- a/config/database.php +++ b/config/database.php @@ -5,6 +5,14 @@ return [ + 'mysql' => [ + 'host' => 'db.internal.example.com', + 'username' => 'app_user', + 'password' => 'Sup3rSecret!2027', + ], + + 'legacy' => [ + 'password' => getenv('LEGACY_DB_PASSWORD'), + ], ]; ``` ```bash $ git diff --staged --stat config/database.php | 8 ++++++++ 1 file changed, 8 insertions(+) $ ai-secrets - config/database.php:8 — 'password' => 'Sup3rSecret!2027' is a genuine hardcoded credential in plain text. Move it to an environment variable and load it with getenv('DB_PASSWORD'), matching the pattern already used two lines below for 'legacy'. - config/database.php:12 — 'password' => getenv('LEGACY_DB_PASSWORD') is the correct pattern (reads from an environment variable), not a leak. No action needed. ``` Two lines matched the regex, both containing "password." The model correctly separated them: one is a real leak with a concrete fix, the other is the exact `getenv()` pattern that fix should follow — correctly left alone. > **A safety net, not a guarantee:** > > This catches what shows up in a text diff. It won't catch a secret split across multiple commits, baked into a binary, or encoded in a way the regex doesn't recognize. Treat it as a second pair of eyes before a commit, not a replacement for a real secrets-scanning tool in CI. ## `ai-commit`: Writing the Message for the Diff You've Just Cleaned Up Once the diff is what you actually want to commit, hand it to `ai-commit` instead of typing "fix" for the fourth time this week — it already contains everything needed to describe what changed: **api/reactions.php (staged diff)** ```php diff --git a/api/reactions.php b/api/reactions.php index 1234567..89abcde 100644 --- a/api/reactions.php +++ b/api/reactions.php @@ -12,6 +12,10 @@ header('Content-Type: application/json'); $slug = $_POST['slug'] ?? ''; +if ($slug === '') { + http_response_code(400); + exit(json_encode(['error' => 'Missing slug parameter'])); +} + $reaction = $_POST['reaction'] ?? ''; ``` ```bash $ git diff --staged --stat api/reactions.php | 4 ++++ 1 file changed, 4 insertions(+) $ ai-commit -------------------------------------------------- fix(api): reject empty slug parameter in reactions endpoint Return a 400 response with a clear error message when the `slug` POST parameter is missing or empty, instead of silently processing an invalid reaction. -------------------------------------------------- Use this message? [y]es / [e]dit / [N]o: y [main a1b2c3d] fix(api): reject empty slug parameter in reactions endpoint 1 file changed, 4 insertions(+) ``` A properly scoped `fix(api):` message with a short explanatory body, generated in the two or three seconds it takes the model to read a four-line diff. > **Nothing commits itself:** > > `ai-commit` only ever calls `git commit` after an explicit `y` or `e`. Pressing Enter, Escape, or anything else aborts cleanly. ## Under the Hood: `_git_staged_diff` (skip this if you just want to use it) The guard clauses shared by all three functions — repo check, empty-diff check, size ceiling — live in one place: ```zsh title="~/.zsh/fns/_ollama.zsh (extract)" _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 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." >&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)|@@)')" if (( ${#summary} > max )); then summary="${summary[1,$max]} […summary truncated…]" fi print -r -- "$summary" } ``` Every function above calls `_git_staged_diff` and gets back the diff — or exits cleanly with a clear message. In the original versions of these functions, those guard clauses were copy-pasted into each one — a classic SRP violation hiding in plain sight. One responsibility, one place, now. The size ceiling deserves a word, because I learned it the hard way: staging a few hundred files and running `ai-commit` on a 490 KB diff gives you a *worse* commit message, not a longer one — the model's context window overflows and what comes back is vague or empty. So past `AI_DIFF_MAX_CHARS` (12 000 by default, override it in your shell if your model has room), the helper stops sending the content and sends the *shape* instead: the per-file `--stat`, plus every file and hunk header. Those `@@` lines carry the enclosing function name git puts after them, so the model still knows which files changed, how much, and where — enough for a decent `feat(scope):` line, without drowning. > **Why the prompt is piped into jq, not passed as an argument:** > > In `_ollama_query` you'll see `print -r -- "$prompt" | jq -Rs …` rather than the more obvious `jq -n --arg prompt "$prompt"`. That's deliberate: Linux caps a **single** command-line argument at 128 KB (`MAX_ARG_STRLEN`), regardless of the much larger total `ARG_MAX`. Pass a big diff or a long source file as `--arg` and `jq` dies before it starts, with `argument list too long`. A pipe has no such limit, and `jq -R -s` slurps stdin as one raw string — escaping quotes, backslashes and newlines exactly like `--arg` would. ## Running Them Together These three aren't a pipeline — you don't have to run all three, and you don't have to run them in order. But the order makes sense when you do: ```zsh # 1. Look at the diff for design issues ai-review # 2. Fix anything worth fixing, then re-stage git add -p # 3. Make sure you haven't accidentally left a real credential ai-secrets # 4. Write the commit message for the diff you've now cleaned up ai-commit ``` Each function is also reachable directly (`ai-review`, `ai-secrets`, `ai-commit`) or via the `ai` dispatcher — type bare `ai`, pick a function from the fzf menu, and if the function needs a parameter the menu collects it interactively before running. All three register themselves with one line each — `AI_COMMANDS[review]=...`, `AI_COMMANDS[secrets]=...`, `AI_COMMANDS[commit]=...` — and declare `AI_PARAMS[...]=none`, since they read the staged diff themselves and need no argument. That registry is what makes the family extensible. [`ai-blog-search`](/blog/anythingllm-chat-with-your-docs) joined it later with the same two lines — except it declares `AI_PARAMS[blog-search]="text"`, so the menu prompts for a question first, and it queries an AnythingLLM workspace rather than Ollama directly. ## Conclusion None of these three functions does anything you couldn't do manually. `ai-review` is a slower version of reading your own diff. `ai-secrets` is a faster version of `grep -i password`. `ai-commit` is a draft of the message you were going to write anyway. The value isn't in the individual capabilities — it's in the fact that all three run locally, take under ten seconds each, and slot into the moment you're already in: staged, ready to commit, just before you actually do. That's the only moment when the cost of "fix it now" is still lower than the cost of "fix it later." --- # AnythingLLM: Chat With All Your Scattered Documentation > AnythingLLM turns a pile of Markdown, Quarto, PDF, DOCX, Excel and PowerPoint files into something you can actually chat with, backed by a local Ollama model. Part 1 sets it up on a single machine; Part 2 keeps the documents on my work PC while borrowing the GPU sitting at home. --- ![AnythingLLM: Chat With All Your Scattered Documentation](/img/v2/anythingllm.webp) **TL;DR** AnythingLLM is a self-hosted, Docker-friendly app that turns your own documents — Markdown, Quarto, PDF, DOCX, Excel, PowerPoint, whatever — into a searchable, chattable knowledge base, with answers that cite the exact file they came from. It doesn't run its own AI; it talks to an LLM provider of your choice, which means it can reuse an Ollama server you already have running elsewhere. This article sets it up twice: once fully on one machine (my home PC), and once split across two — documents on my work PC, GPU inference on the home PC — because some documents are simply not allowed to leave the machine they live on. Under `~/repositories` on my work laptop, I have dozens of project folders, and every one of them has quietly grown its own pile of documentation: a `README.md` here, a Quarto report there, a PDF spec somebody sent me, an Excel sheet nobody remembers the purpose of anymore, a PowerPoint from a meeting six months ago. Individually, each file is fine. Together, they're a graveyard — I *know* I wrote down the answer to "how did I configure that VPN last time" somewhere, in one of these folders, in one of these formats, but finding it means either remembering the exact filename or grepping and hoping the words match. If you've ever opened a terminal, typed `grep -r "some term" ~/repositories`, gotten forty irrelevant hits, and given up — you already have the problem this article is about. ## Seeing It Work Here is a real question against a real workspace — the 248 articles of this blog, indexed with the script further down. Asking is one API call, the same request the chat panel sends when you type into it: ```bash $ curl -s -X POST http://localhost:3001/api/v1/workspace/blog/chat \ -H "Authorization: Bearer $ANYTHINGLLM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message":"There was a trick I documented to see, the moment I enter a repository, which branches I had touched most recently. How did I do it?","mode":"query"}' You documented a trick to automatically display the three most recently updated local Git branches whenever you enter a Git repository. This was achieved by adding a custom function to your Zsh configuration that hooks into the `chpwd` hook (which triggers when changing directories) and uses the `git for-each-ref` command to list local branches sorted by the date of their last commit. Here's how you did it: 1. **Zsh Integration**: You leveraged the `chpwd` hook in Zsh, which is called automatically when you use commands like `cd` or `pushd`. 2. **Function Logic**: The function `_display_recent_git_context` checks if you're inside a Git repository and then retrieves the active branch. It uses `git for-each-ref` to list local branches, sorting them by the date of their last commit in descending order (`--sort=-committerdate`). 3. **Display**: The output shows the last three updated branches, with an asterisk (*) indicating the active branch. 4. **Alias Integration**: You also defined a `gst` alias in Zsh that calls this function before running `git status`. This setup ensures that every time you `cd` into a Git project, you immediately see the most recently active branches, helping avoid accidentally working on the wrong branch. The full implementation details can be found in the article [Showing the last 3 updated branches when you jump in a git repo](https://www.avonture.be/blog/git-branches-gst). ``` Read the question again and notice what it doesn't contain. Not `chpwd`. Not `for-each-ref`. Not `committerdate`. Not even the words in the title of the article it found — *Showing the last 3 updated branches when you jump in a git repo*. I described a vague memory in the words I'd actually use two years later, and got back the mechanism, the hook it's wired to, the flag that sorts the branches, and a link. That is precisely the query `grep` cannot serve. `grep -r "chpwd"` would have found it instantly — if I had remembered the word `chpwd`. The whole point is that I hadn't. ## Why It Works * [AnythingLLM](https://anythingllm.com/) is an open-source, self-hosted app built around Retrieval-Augmented Generation (RAG) over your own files, organized into **workspaces** — one per project, per client, or per topic, with nothing leaking between them. * **It's a client, not an LLM.** AnythingLLM doesn't ship a model of its own — it calls out to an **LLM Provider** you configure: OpenAI, Anthropic, or, the interesting part for us, **Ollama**. If you already run Ollama somewhere, AnythingLLM is just another thing that talks to it. * Every answer comes back with a reference to the source file and the chunk it was pulled from — a citation, not a guess you have to trust on faith. * It reads what `grep` cannot open at all: the PDF spec, the Excel sheet, the PowerPoint deck. That is the other half of why it complements `rg` rather than replacing it. It also coexists with [Open WebUI](/blog/open-webui-advanced) — Open WebUI is a general-purpose chat client for Ollama, AnythingLLM is specifically the document-RAG layer, and both are happy to talk to the same Ollama instance. > **What you get for the setup effort:** > > A natural-language question over everything in a workspace, with the source file named in the answer. That single feature is worth the twenty minutes of Docker setup below — it turns "I know I wrote this down somewhere" into an actual answer. Four things follow, and they are worth reading in any order you like: * [**Installing it**](#install) — one `compose.yaml`, plus the thirty-second check that saves you from an embedder that silently returns noise. * [**Getting documents in**](#ingest) — why there is no "index this folder" button, and the script that indexed 248 articles in 94 seconds. * [**Searching from the terminal**](#search) — one `curl`, then the same thing as a shell command with clickable results. * [**Documents here, GPU there**](#two-machines) — the two-machine split, for documents that are not allowed to travel. ## Part 1 — Installing AnythingLLM on One Machine {#install} This is the simple case: AnythingLLM and Ollama live on the same machine, my home PC with 24GB of VRAM to spare. > **Prerequisites:** > > This assumes Ollama is already running as a Docker container, the way I set it up in [Installing Ollama and get local AI](/blog/ollama-installation). Nothing below re-explains that part. ### The compose.yaml file Following my usual habit of one folder per tool under `~/tools`, let's create `~/tools/anythingllm/compose.yaml`: **compose.yaml** ```yaml name: tools services: anythingllm: image: mintplexlabs/anythingllm container_name: anythingllm cap_add: - SYS_ADMIN ports: - "3001:3001" env_file: - .env environment: - STORAGE_DIR=/app/server/storage - LLM_PROVIDER=ollama - OLLAMA_BASE_PATH=http://192.168.0.218:11434 - OLLAMA_MODEL_PREF=qwen2.5:14b-instruct - OLLAMA_MODEL_TOKEN_LIMIT=8192 - EMBEDDING_ENGINE=ollama - EMBEDDING_BASE_PATH=http://192.168.0.218:11434 - EMBEDDING_MODEL_PREF=mxbai-embed-large # mxbai-embed-large has a 512-token context — a larger chunk size makes # every single embedding call fail. Keep this comfortably under it. - EMBEDDING_MODEL_MAX_CHUNK_LENGTH=400 - OLLAMA_EMBEDDING_BATCH_SIZE=16 - VECTOR_DB=lancedb volumes: - anythingllm_storage:/app/server/storage restart: always volumes: anythingllm_storage: ``` Before starting it, create the `.env` file it references, holding a random secret AnythingLLM uses to sign session tokens: ```bash $ echo "JWT_SECRET=$(openssl rand -hex 32)" > .env ``` > **About cap\_add: SYS\_ADMIN:** > > AnythingLLM uses a headless Chromium under the hood for some document and web-scraping features, and Chromium wants this capability to run its sandbox inside a container. It's a broader grant of privilege than I'd like to hand out by default — the project has an open discussion about replacing it with a narrower seccomp profile instead, but as of now, `SYS_ADMIN` is what the official image expects. Worth knowing, not worth losing sleep over on a home LAN. `OLLAMA_BASE_PATH` and `EMBEDDING_BASE_PATH` both point at `192.168.0.218` — the same home-server IP address I used in [Accessing Ollama across your local network](/blog/accessing-ollama-across-your-local-network). Replace it with your own server's IP. Notice that even though Ollama and AnythingLLM run on the very same machine here, I'm still using the LAN IP rather than `localhost` — that detail matters later, where it stops being a detail and becomes the whole point. Pull the embedding model on the Ollama side before starting AnythingLLM — it's small (a few hundred MB) and required for anything to get embedded at all: ```bash $ docker exec -it ollama ollama pull mxbai-embed-large ``` > **Match the chunk size to the embedding model, or nothing will embed at all:** > > `EMBEDDING_MODEL_MAX_CHUNK_LENGTH` above is set to `400` for a reason. Run `docker exec ollama ollama show mxbai-embed-large` and you'll see **context length 512** — set the chunk size higher than that and *every* document fails to embed, with `Ollama Failed to embed: the input length exceeds the context length` buried in `docker logs anythingllm`. The UI gives you no hint: the upload succeeds, the document shows up in the library, and the workspace simply stays empty. `OLLAMA_EMBEDDING_BATCH_SIZE` is pure speed — the default of 1 sends one chunk per HTTP call, which turns a few hundred documents into a very long afternoon. ### Check that your embedder actually discriminates I originally used `nomic-embed-text` here — it's the model everyone recommends for this job. It embedded all 248 articles without a single error, and then answered "there are no articles specifically covering WordPress" about a blog containing an article titled *Quickly install WordPress in just three commands*. Nothing had failed. The embeddings were simply meaningless, and a RAG stack has no way to tell you that: it retrieves the closest vectors it can find and hands them to the model, whatever they are. So before trusting an embedder, spend thirty seconds proving it separates related text from unrelated text: **embedder-sanity-check.py** ```python #!/usr/bin/env python3 """Prove an Ollama embedding model separates related text from unrelated text. python3 embedder-sanity-check.py mxbai-embed-large nomic-embed-text """ import json import math import sys import urllib.request OLLAMA = "http://localhost:11434/api/embed" # The first two sentences say the same thing; the third is unrelated. SENTENCES = [ "Installing WordPress using Docker containers", "WordPress installation with Docker", "Powerlevel10k is a theme for the zsh shell", ] def embed(texts, model): payload = json.dumps({"model": model, "input": texts}).encode() request = urllib.request.Request( OLLAMA, data=payload, headers={"Content-Type": "application/json"} ) with urllib.request.urlopen(request, timeout=300) as response: return json.loads(response.read())["embeddings"] def cosine(a, b): dot = sum(x * y for x, y in zip(a, b)) return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))) for model in sys.argv[1:] or ["mxbai-embed-large"]: vectors = embed(SENTENCES, model) related = cosine(vectors[0], vectors[1]) unrelated = cosine(vectors[0], vectors[2]) print( f"{model:<20} dims={len(vectors[0]):<5}" f" related={related:.4f} unrelated={unrelated:.4f}" f" separation={related - unrelated:+.4f}" ) ``` ```bash $ python3 embedder-sanity-check.py mxbai-embed-large nomic-embed-text mxbai-embed-large dims=1024 related=0.9536 unrelated=0.4591 separation=+0.4945 nomic-embed-text dims=768 related=0.5910 unrelated=0.5377 separation=+0.0532 ``` Two sentences that mean the same thing should score far above an unrelated one. `mxbai-embed-large` separates them by **+0.49**; on my machine `nomic-embed-text` managed **+0.05**, which is noise. I never traced the root cause — both the `:latest` and `:v1.5` tags behaved identically, and Ollama's `OLLAMA_FLASH_ATTENTION` / `OLLAMA_KV_CACHE_TYPE` tuning made no difference — but the fix doesn't depend on the cause. Run the check, and if the separation is small, change the model rather than your questions. Then bring AnythingLLM up: ```bash $ docker compose up --detach [+] Running 1/1 ✔ Container anythingllm Started ``` ### First-run setup Browse to `http://localhost:`3001 (or `http://192.168.0.218 : 3001` from another machine on the network). The onboarding wizard asks for: **AnythingLLM onboarding** 1. **LLM Provider** — select Ollama, paste your server's URL (`http://192.168.0.218:11434`), pick the model you already pulled (`qwen2.5:14b-instruct` fits comfortably in 24GB of VRAM alongside the embedding model) 2. **Embedding Provider** — select Ollama again, same URL, pick `mxbai-embed-large` 3. **Vector Database** — leave the default, LanceDB; it's embedded, no extra container to run 4. **Create your first workspace** — name it after a real project, not "test" > Screenshot — http\://localhost:3001/workspace/blog > > ![Where did I mention Mermaid](./images/mermaid.webp) ## Getting Your Documents In {#ingest} Inside the workspace, the upload dialog accepts exactly the mix I described earlier: drop in a `README.md`, a Quarto `.qmd` report, a PDF, a DOCX, an Excel sheet, a PowerPoint deck — multi-select as many as the OS file picker lets you grab at once. AnythingLLM parses each format on its own; you don't need to convert anything first. Once the documents show a green "embedded" status, ask a real question in the chat panel — something you'd normally have gone digging for, the same way the terminal proof above got its answer. The answer comes back with the source document named next to it, so you can go double-check it instead of taking the model's word for it. ### You don't give it a path — and that surprises everyone This is the part I got wrong the first time, so let's make it explicit: **there is no field anywhere in AnythingLLM where you type `/home/me/repositories` and let it index that folder.** Two things stack up to make that impossible: * The server runs in a container, and the `compose.yaml` above mounts exactly one volume — `anythingllm_storage`. Your documents folder simply doesn't exist inside that container. * Adding a bind mount wouldn't help either, because that *Upload* dialog is your **browser's** file picker, running on your machine. It can't see inside the container, and the container never learns where the file came from — the bytes arrive over HTTP like any other form upload. So AnythingLLM never reads your files in place. It **copies** each one into its storage volume, parses it to JSON, splits it into chunks and embeds them. That's the same fact Part 2 below hangs its entire argument on, and it's worth internalizing early: *documents live where the container runs*. Which leaves a real question — how do you get a few hundred files in there without clicking through a file picker a few hundred times? ### Indexing a whole folder tree with one command The GUI is one of three doors, and the other two are better suited to bulk: * **The Developer API** — a REST endpoint that takes one file per call. Wrap it in a loop and you can feed it anything you can `find`. This is the one we'll use. * **Data connectors** (*Settings → Data Connectors*) — ready-made importers for GitHub, GitLab, Confluence, Obsidian and a website depth-crawler. If your documents already live in a repository, pointing the GitHub connector at it is zero scripting. Do give it an access token though: unauthenticated, it's throttled by GitHub's public rate limit and often only picks up top-level files. * **The website crawler**, if the content is published somewhere. Simplest of all, but you index rendered HTML — navigation, footer and sidebar included — instead of the source. I went with the API, because what I actually want indexed is this blog: 248 articles, each one an `index.md` in its own dated folder. Generate a key under *Settings → Tools → Developer API*, and a single call looks like this: ```bash $ curl -X POST http://localhost:3001/api/v1/document/upload \ -H "Authorization: Bearer $ANYTHINGLLM_API_KEY" \ -F "file=@blog/2026/08/17/anythingllm-chat-with-your-docs/index.md" \ -F "addToWorkspaces=blog" ``` The `addToWorkspaces` field is what makes this a one-step operation: without it the document lands in AnythingLLM's document library but isn't embedded into anything, and you'd still have to go tick boxes in the UI. > **The trap: 241 files named index.md:** > > Docusaurus puts every article in its own folder as `index.md`, and AnythingLLM cites its sources **by filename**. Upload them as-is and every single answer ends with `Source: index.md` — 241 of them, indistinguishable. Citation is the whole reason to use this tool, so it has to be fixed at upload time: `curl` lets you override the transmitted name with `-F "file=@path/index.md;filename=the-slug.md"`, and the slug is sitting right there in the frontmatter. That, plus skipping files that haven't changed since last time, is all the script does: **anythingllm-index.sh** ```bash #!/usr/bin/env bash # # Index every blog post into an AnythingLLM workspace. # # ANYTHINGLLM_API_KEY=xxxxxx ./anythingllm-index.sh # # Re-run it after publishing a new article: only new and modified posts are # sent, everything else is skipped. set -o errexit set -o nounset set -o pipefail WORKSPACE="${ANYTHINGLLM_WORKSPACE:-blog}" BLOG_DIR="${BLOG_DIR:-blog}" STATE_FILE="${STATE_FILE:-.anythingllm-indexed}" SITE_URL="${SITE_URL:-https://www.avonture.be}" # The key can come from the environment (a line in ~/.zshrc) or from the # gitignored .env at the root of the blog — handy inside a devcontainer, which # doesn't inherit the host's shell configuration. if [[ -z "${ANYTHINGLLM_API_KEY:-}" && -f ".env" ]]; then ANYTHINGLLM_API_KEY="$(grep -E '^ANYTHINGLLM_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d "\"'")" || true fi : "${ANYTHINGLLM_API_KEY:?Generate one in Settings > Tools > Developer API}" # "localhost" is right when the script runs on the Docker host; from inside # another container it isn't, and the bridge gateway is what reaches it. if [[ -z "${ANYTHINGLLM_URL:-}" ]]; then for candidate in "http://localhost:3001" "http://172.17.0.1:3001"; do if curl --silent --fail --max-time 2 "${candidate}/api/ping" >/dev/null 2>&1; then ANYTHINGLLM_URL="${candidate}" break fi done fi : "${ANYTHINGLLM_URL:?No AnythingLLM instance answered on port 3001}" api() { curl --silent --show-error \ --header "Authorization: Bearer ${ANYTHINGLLM_API_KEY}" "$@" } # Read one key out of a Markdown file's YAML frontmatter, unquoted. frontmatter() { local key="${1}" file="${2}" awk -v key="${key}" ' NR == 1 && $0 == "---" { in_fm = 1; next } in_fm && $0 == "---" { exit } in_fm && index($0, key ":") == 1 { sub("^" key ": *", "") gsub(/^"|"$/, "") print exit } ' "${file}" } # Fail early and loudly rather than uploading 248 files into the void. if ! api "${ANYTHINGLLM_URL}/api/v1/workspaces" | jq -e --arg slug "${WORKSPACE}" '.workspaces[]? | select(.slug == $slug)' >/dev/null; then printf 'Workspace "%s" not found at %s (check the slug and the API key)\n' \ "${WORKSPACE}" "${ANYTHINGLLM_URL}" >&2 exit 1 fi # Replay the state file: post path -> "\t". declare -A previous=() if [[ -f "${STATE_FILE}" ]]; then while IFS=$'\t' read -r hash path location; do [[ -n "${path}" ]] && previous["${path}"]="${hash}"$'\t'"${location}" done <"${STATE_FILE}" fi declare -A current=() added=0 updated=0 skipped=0 failed=0 while IFS= read -r -d '' file; do hash="$(sha256sum "${file}" | cut -d' ' -f1)" entry="${previous["${file}"]:-}" known_hash="${entry%%$'\t'*}" if [[ "${known_hash}" == "${hash}" ]]; then current["${file}"]="${entry}" skipped=$((skipped + 1)) continue fi if [[ "$(frontmatter draft "${file}")" == "true" ]]; then continue fi # Every post is an index.md, so the folder name is what makes it # identifiable — send it as the filename AnythingLLM will cite. slug="$(frontmatter slug "${file}")" [[ -n "${slug}" ]] || slug="$(basename "$(dirname "${file}")")" title="$(frontmatter title "${file}")" [[ -n "${title}" ]] || title="${slug}" # AnythingLLM stamps each document with a "published" date taken from the # temp file the collector received — i.e. the upload date, always today, and # not overridable through the API. Ask the workspace when something was # written and it answers confidently and wrongly. # Of the three fields copied into every chunk's header, "title" is the only # one we control, so the real date rides along inside it. published="$(frontmatter date "${file}")" [[ -z "${published}" ]] || title="${title} (published ${published})" # A modified post: drop the previously embedded copy first, otherwise the # workspace ends up answering from two versions of the same article. if [[ -n "${entry}" ]]; then old_location="${entry#*$'\t'}" api --request DELETE "${ANYTHINGLLM_URL}/api/v1/system/remove-documents" \ --header "Content-Type: application/json" \ --data "$(jq -nc --arg name "${old_location}" '{names: [$name]}')" >/dev/null || true fi # A "link://" chunkSource is the one metadata field AnythingLLM copies into # every chunk's header, so this is what lets the model answer with the live # URL of the article instead of just a filename. response="$(api --request POST "${ANYTHINGLLM_URL}/api/v1/document/upload" \ --form "file=@${file};filename=${slug}.md;type=text/markdown" \ --form "addToWorkspaces=${WORKSPACE}" \ --form "metadata=$(jq -nc \ --arg title "${title}" \ --arg source "${file}" \ --arg link "link://${SITE_URL}/blog/${slug}" \ '{title: $title, docSource: $source, chunkSource: $link}')")" location="$(jq -r '.documents[0].location // empty' <<<"${response}")" if [[ -z "${location}" ]]; then printf 'FAILED %s: %s\n' "${slug}" "$(jq -r '.error // "unknown error"' <<<"${response}")" >&2 failed=$((failed + 1)) continue fi current["${file}"]="${hash}"$'\t'"${location}" if [[ -n "${entry}" ]]; then printf 'updated %s\n' "${slug}" updated=$((updated + 1)) else printf 'added %s\n' "${slug}" added=$((added + 1)) fi done < <(find "${BLOG_DIR}" -type f \( -name 'index.md' -o -name 'index.mdx' \) -print0 | sort -z) # Rewrite the state file from scratch so deleted posts drop out of it. for path in "${!current[@]}"; do printf '%s\t%s\t%s\n' \ "${current["${path}"]%%$'\t'*}" "${path}" "${current["${path}"]#*$'\t'}" done | sort -k2 >"${STATE_FILE}" printf '\n%d added, %d updated, %d unchanged, %d failed\n' \ "${added}" "${updated}" "${skipped}" "${failed}" [[ "${failed}" -eq 0 ]] ``` Create a workspace named `blog` in the UI first, then run it from the root of the blog repository: ```bash $ export ANYTHINGLLM_API_KEY=your-key-here $ ./anythingllm-index.sh added docker-php-run-script-or-website added keepass-overriding-url added welcome added wslg-rpd-connection added docker-volume added apache-htaccess ... added ollama-test-generator added ollama-git-precommit added anythingllm-chat-with-your-docs 248 added, 0 updated, 0 unchanged, 0 failed ``` ### One manual step: the workspace system prompt The script handles everything the upload API allows, but one thing it cannot reach is how the model reads what it receives. Paste this into *Workspace Settings → Chat Settings → Prompt* — it is what makes dates come out right, and what stops the model reconstructing URLs it half-remembers: **Workspace system prompt** ``` You answer questions about a personal technical blog, using only the retrieved excerpts. Each excerpt starts with a block. Read it as follows: - "sourceDocument" is the article title. When it ends with "(published YYYY-MM-DD)", that is the article's real publication date. - "published:" is the date the article was fed to the search index. It is ALWAYS today's date and ALWAYS wrong. Never quote it, never mention it, never use it to compare or sort articles. - "source:" is the article's public URL. Use it verbatim; never invent or reconstruct a URL. When you state a date, write it as DD/MM/YYYY. Always answer in the language the question was asked in. When listing articles, give the title and the URL of each. If the excerpts do not answer the question, say so plainly rather than guessing. ``` > **Ask in French, get 02/03/2024:** > > With that prompt in place, *"Quand ai-je publié l'article sur Tabnine ?"* answers **02/03/2024** — right value, right format, right language. Without it, the same question returns `8/10/2026, 9:56:01 AM`: the day I ran the indexer, in a US format nobody asked for. Skipping this step is the one way to end up with a workspace that looks fine and lies about every date; [the last section](#where-that-wrong-date-comes-from) explains where that second date comes from. ### Keeping the index up to date One habit to unlearn before you build anything on top of this: **AnythingLLM never re-scans anything on its own.** An embedded document is a frozen copy; editing the original Markdown on disk changes nothing in the workspace, and a brand-new file is simply invisible to it. There *is* an automatic sync feature, but it doesn't cover this case: in the Docker deployment it only watches website links and documents pulled in by a data connector. Manually uploaded files — everything the API route produces — are explicitly out of scope. (The desktop app does watch local files, every 10 minutes, but only while the app is open.) Hence the state file the script keeps. Re-running it is the sync: **Keeping the workspace current** 1. **A new article** — its path isn't in `.anythingllm-indexed`, so it gets uploaded and embedded 2. **A modified article** — its checksum no longer matches, so the old copy is removed first, then the new one uploaded; without that removal the workspace would answer from two versions of the same text 3. **Everything else** — checksum unchanged, skipped without an HTTP call of its own The numbers make the case better than I can: indexing all 248 articles from scratch took **1 minute 34**, and the second run — where nothing had changed — took **0.26 seconds** and made exactly one HTTP call, the workspace lookup. That's cheap enough to hang off every publish: a line in your deploy script, a `post-commit` hook, or a nightly cron entry. > **One workspace per project:** > > For my `~/repositories` documentation, I still keep one workspace per project rather than dumping everything into one — it keeps the vector space focused, so a question about project A doesn't get diluted by irrelevant chunks from project B. The script takes the target workspace as `ANYTHINGLLM_WORKSPACE`, so the same loop covers both layouts. ## Searching From the Terminal {#search} With 248 articles embedded, the payoff is a single `curl` away. Export the key once — I keep mine in `~/.zshrc`, but a plain `export` in the shell works just as well — and query the workspace: ```bash $ export ANYTHINGLLM_API_KEY=your-key-here $ curl -s -X POST http://localhost:3001/api/v1/workspace/blog/chat \ -H "Authorization: Bearer $ANYTHINGLLM_API_KEY" \ -H "Content-Type: application/json" \ -d '{"message":"Which articles cover devcontainers?","mode":"query"}' \ | jq -r '.textResponse' Here are the articles that cover devcontainers: 1. **[PHP development in a devcontainer with preinstalled code quality tools](https://www.avonture.be/blog/vscode-devcontainer)** (published 09/02/2024) - This article explains how to set up a PHP development environment using a devcontainer with preinstalled code quality tools. 2. **[Docker - Python devcontainer](https://www.avonture.be/blog/docker-python-devcontainer)** (published 30/10/2024) - This article guides you through setting up a VSCode devcontainer for Python development using Docker. 3. **[One Docker Image for Production and Devcontainers - The Clean Way](https://www.avonture.be/blog/docker-prod-devcontainer)** (published 13/10/2025) - This article discusses how to use a single Docker image for both production deployments and VS Code Devcontainers, emphasizing clean overrides and environment isolation. 4. **[Make your Quarto project Devcontainer-Ready — No More Setup Headaches](https://www.avonture.be/blog/quarto-devcontainer)** (published 03/11/2025) - This article explains how to make Quarto projects devcontainer-ready, using the `.devcontainer/devcontainer.json` file for configuration. 5. **[Level Up Your Presentations with Quarto, reveal.js, Decktape, Docker and DevContainers](https://www.avonture.be/blog/running-revealjs-with-docker)** (published 15/12/2025) - This article mentions using devcontainers for presentations with Quarto and reveal.js. 6. **[Running Docusaurus using Docker](https://www.avonture.be/blog/running-docusaurus-with-docker)** (published 11/11/2025) - This article discusses how devcontainers can be used to fine-tune environments, including VSCode extensions and editor settings. 7. **[Install a PHP Docker environment in a matter of seconds](https://www.avonture.be/blog/php-devcontainer)** (published 23/02/2024) - This article aims to create a development environment (devcontainer) for PHP. ``` Three things make that answer useful rather than merely impressive: * **`"mode": "query"`** restricts the model to what it actually retrieved. The other value, `"chat"`, lets it fall back on its own general knowledge when the workspace has nothing relevant — which is precisely how you get a confident answer about an article you never wrote. For searching your own corpus, `query` is the honest setting. * **The URLs are real**, not reconstructed. That's the `chunkSource` field in the script: AnythingLLM copies metadata prefixed with `link://` into every chunk's header, so the live URL travels with the text into the model's context. Without it you get filenames, and a model left to guess at URLs will happily invent them. * **`jq -r '.textResponse'`** is all you need for reading; drop it to see the full payload, where `sources[]` gives you each retrieved chunk with its `title`, `docSource` (the path in the repo) and a similarity `score`. ### `ai-blog-search`: the portable script Typing that `curl` every time gets old fast, so it lives in a script — same key/URL resolution as the indexer, plus source formatting: **anythingllm-search.sh** ```bash #!/usr/bin/env bash # # Ask the AnythingLLM 'blog' workspace a question and print the answer plus the # articles it came from, as clickable links. # # ai-blog-search which articles cover docker? # # Index first with .scripts/anythingllm-index.sh — this script only reads. set -o errexit set -o nounset set -o pipefail WORKSPACE="${ANYTHINGLLM_WORKSPACE:-blog}" if [[ "$#" -eq 0 ]]; then printf 'Usage: %s \n' "$(basename "${0}")" >&2 exit 1 fi question="$*" # Same resolution as the indexer: environment first, then the gitignored .env. if [[ -z "${ANYTHINGLLM_API_KEY:-}" && -f ".env" ]]; then ANYTHINGLLM_API_KEY="$(grep -E '^ANYTHINGLLM_API_KEY=' .env | head -1 | cut -d= -f2- | tr -d "\"'")" || true fi : "${ANYTHINGLLM_API_KEY:?Generate one in Settings > Tools > Developer API}" if [[ -z "${ANYTHINGLLM_URL:-}" ]]; then for candidate in "http://localhost:3001" "http://172.17.0.1:3001"; do if curl --silent --fail --max-time 2 "${candidate}/api/ping" >/dev/null 2>&1; then ANYTHINGLLM_URL="${candidate}" break fi done fi : "${ANYTHINGLLM_URL:?No AnythingLLM instance answered on port 3001}" # "query" keeps the model inside the retrieved documents; "chat" would let it # answer from its own general knowledge, which is how you get invented articles. # # The random sessionId matters more than it looks: without it every call lands # in the workspace's default thread, and the model reads its own previous # answers as context. One bad answer then repeats itself for every later # question, even after the underlying retrieval has been fixed. response="$(curl --silent --show-error \ --header "Authorization: Bearer ${ANYTHINGLLM_API_KEY}" \ --header "Content-Type: application/json" \ --request POST "${ANYTHINGLLM_URL}/api/v1/workspace/${WORKSPACE}/chat" \ --data "$(jq -nc --arg m "${question}" --arg s "cli-${RANDOM}${RANDOM}-$$" \ '{message: $m, mode: "query", sessionId: $s}')")" error="$(jq -r '.error // empty' <<<"${response}")" if [[ -n "${error}" && "${error}" != "null" ]]; then printf 'AnythingLLM returned an error: %s\n' "${error}" >&2 exit 1 fi printf '\n\033[1;36m❓ %s\033[0m\n\n' "${question}" jq -r '.textResponse' <<<"${response}" # OSC 8 hyperlinks make the title itself clickable in VS Code's terminal and # Windows Terminal; anywhere else (or when piped) fall back to the bare URL. if [[ -t 1 ]]; then print_source() { printf ' \033[1;32m•\033[0m \033]8;;%s\033\\\033[4m%s\033[0m\033]8;;\033\\\n' "${1}" "${2}" printf ' \033[2m%s\033[0m\n' "${3}" } else print_source() { printf ' • %s\n %s\n' "${2}" "${1}" } fi sources="$(jq -r ' [ .sources[]? | select(.chunkSource // "" | startswith("link://")) | { title, url: (.chunkSource | sub("^link://"; "")) } ] | unique_by(.url) | .[] | "\(.url)\t\(.title)"' <<<"${response}")" if [[ -n "${sources}" ]]; then printf '\n\033[1;33m📚 Sources the answer was built from\033[0m\n' while IFS=$'\t' read -r url title; do print_source "${url}" "${title}" "${url##*/}" done <<<"${sources}" else printf '\n\033[2mNo source document was cited.\033[0m\n' fi # Vector search ranks by similarity and stops at topN, so it will never # enumerate exhaustively: a couple of long articles can fill every slot. This # second pass is the opposite trade-off — a dumb, exact, complete scan of the # frontmatter. Together they answer "where did I write about X" properly. blog_dir="${BLOG_DIR:-blog}" # Function words, English and French — the vocabulary of asking a question # rather than of any topic. Rarity alone can't catch these: "content" and # "already" each legitimately appear in a handful of titles. stopwords=" about after algo already also always another anything article articles been before blog both content cover covers does each every find first from give have here how into just know like list made make mention mentions more most must need only other post posts really related same search some something such talk talked than that their them then there these they this those topic use used using very want what when where which while will with write written wrote your alors aussi autre avec avoir beaucoup cela cette comme contenu dans deja déjà donc dont elle encore est etre être fait faire ils leur mais mes mon nous ont parle parler parlé pour quel quelle quelles quels qui quoi sans sont sujet sur tous tout toute une vous " mapfile -t candidates < <(printf '%s' "${question}" | tr -c '[:alnum:]' '\n' | tr '[:upper:]' '[:lower:]' | awk 'length($0) >= 4' | sort -u | grep -vxF -f <(printf '%s' "${stopwords}" | tr -s '[:space:]' '\n' | grep .) || true) # Second filter, dictionary-free: drop any surviving word that is simply too # common to be a topic. "docker" matches half the blog and would drown the # result; "joomla" matches nine posts and is exactly what you asked about. total="$(find "${blog_dir}" -type f \( -name 'index.md' -o -name 'index.mdx' \) | wc -l)" ceiling=$(( total / 10 )) [[ "${ceiling}" -ge 3 ]] || ceiling=3 keywords=() matched="" for word in "${candidates[@]}"; do files="$(grep -rliE "^(title|slug|description|mainTag):.*${word}|^ - ${word}$" \ "${blog_dir}" --include='index.md' --include='index.mdx' || true)" count="$(grep -c . <<<"${files}" || true)" [[ -n "${files}" && "${count}" -le "${ceiling}" ]] || continue keywords+=("${word}") matched+="${files}"$'\n' done if [[ "${#keywords[@]}" -gt 0 ]]; then matches="$(sort -u <<<"${matched}" | grep . || true)" if [[ -n "${matches}" ]]; then printf '\n\033[1;33m🔎 Exact frontmatter matches (%s)\033[0m\n' "${keywords[*]}" while read -r file; do [[ -n "${file}" ]] || continue # sed, not awk -F': ' — plenty of titles contain a colon of their own. slug="$(sed -n 's/^slug: *//p' "${file}" | head -1 | sed 's/^"//; s/"$//')" title="$(sed -n 's/^title: *//p' "${file}" | head -1 | sed 's/^"//; s/"$//')" print_source "${SITE_URL:-https://www.avonture.be}/blog/${slug}" "${title}" "${slug}" done <<<"${matches}" fi fi printf '\n' ``` Point your shell at it, exporting the key once: ```zsh title="~/.zshrc" export ANYTHINGLLM_API_KEY="your-key-here" ai-blog-search() { (cd ~/repositories/blog && .scripts/anythingllm-search.sh "$@"); } ``` ```bash $ ai-blog-search Do I already have content about Joomla? ❓ Do I already have content about Joomla? Yes, you have content about Joomla. Here are the articles related to Joomla: 1. **Create your Joomla website using Docker - Part 1** (published 2023-12-22) - URL: [https://www.avonture.be/blog/docker-joomla](https://www.avonture.be/blog/docker-joomla) 2. **Create your Joomla website using Docker - Part 2** (published 2024-10-18) - URL: [https://www.avonture.be/blog/docker-joomla-part-2](https://www.avonture.be/blog/docker-joomla-part-2) 3. **Joomla - Run a SQL statement outside Joomla and display a nice HTML table** (published 2024-06-02) - URL: [https://www.avonture.be/blog/joomla-show-table](https://www.avonture.be/blog/joomla-show-table) These articles cover topics such as setting up a Joomla website using Docker and running SQL statements in Joomla. 📚 Sources the answer was built from • Create your Joomla website using Docker - Part 1 (published 2023-12-22) https://www.avonture.be/blog/docker-joomla • Create your Joomla website using Docker - Part 2 (published 2024-10-18) https://www.avonture.be/blog/docker-joomla-part-2 • Organize Your Docusaurus Content with a Custom Series Component (published 2025-09-09) https://www.avonture.be/blog/docusaurus-series • Joomla - Run a SQL statement outside Joomla and display a nice HTML table (published 2024-06-02) https://www.avonture.be/blog/joomla-show-table 🔎 Exact frontmatter matches (joomla) • FrankenPHP, a modern application server for PHP https://www.avonture.be/blog/frankenphp-docker-joomla • Create your Joomla website using Docker - Part 1 https://www.avonture.be/blog/docker-joomla • Update php.ini when using a Docker image https://www.avonture.be/blog/docker-php-ini • Joomla - delete tables from your database according to a certain prefix https://www.avonture.be/blog/joomla-db-kill-tables-prefix • Joomla - Run a SQL statement outside Joomla and display a nice HTML table https://www.avonture.be/blog/joomla-show-table • aeSecure - QuickScan - Free viruses scanner https://www.avonture.be/blog/aesecure-quickscan • Start Joomla with Docker in just a few clicks https://www.avonture.be/blog/docker-joomla-right-to-the-point • Create your Joomla website using Docker - Part 2 https://www.avonture.be/blog/docker-joomla-part-2 • Restore a Joomla backup using Docker https://www.avonture.be/blog/docker-joomla-restore-jpa ``` Two design decisions in there earned their place the hard way. **A random `sessionId` on every call.** Without one, each query lands in the workspace's default thread and the model reads its own previous answers as context. I spent a genuinely embarrassing amount of time on a workspace that kept insisting it had no WordPress article — long after retrieval had been fixed and was returning the right chunks. It was quoting *itself*, from the failed attempt before. A search command has no business carrying conversation history. **Two result lists instead of one.** Look at the run above: the semantic half names three Joomla articles, the `grep` half finds nine. That gap isn't a bug to tune away — vector search stops at the workspace's `topN` and ranks by similarity, so a couple of long articles fill the slots with their own chunks. Raising `topN` from 20 to 60 added exactly one result. So the script also runs a plain `grep` over `title`/`slug`/`description`/tags. Semantic search finds what you couldn't phrase; `grep` guarantees you didn't miss anything. Printing both is the honest answer. Turning a sentence into `grep` terms needs one trick worth stealing: after dropping the obvious function words, the script also drops any surviving word that matches **more than 10% of the blog**. "Docker" would bury everything under half the corpus; "joomla" matches nine posts and is precisely what you asked about. Rarity does the work no stopword list can, and it doesn't care which language you asked in. > **RAG is not a search index:** > > This is the mental adjustment that matters most. "Which articles mention X" is a question for `grep` or the site search — exact, complete, instant. RAG earns its keep on the other kind of question: the `chpwd` one at the top of this article, where you remember the *idea* but not a single word you actually used, or where the answer is buried in a PDF that `grep` can't read at all. Expecting exhaustive enumeration from a similarity ranking is expecting the wrong thing from the right tool. ### The same thing as a zsh function The script above is the portable version — it runs in a devcontainer, in CI, in a `post-commit` hook, anywhere with `bash` and `jq`. For daily use I'd rather type a word, so it also exists as a member of the [`ai-*` family](/blog/ollama-git-precommit): drop it in `~/.zsh/fns/`, and it registers itself with the `ai` dispatcher exactly like `ai-review` and `ai-commit` do. **\~/.zsh/fns/ai-blog-search.zsh** ```bash # ai-blog-search — ask your own blog a question and get the matching articles # back, as clickable links. # # The odd one out in this series: it doesn't call Ollama directly. It queries an # AnythingLLM workspace, which owns the vector index and calls Ollama itself for # both the embeddings and the answer. So the guard clause below is the # AnythingLLM counterpart of _ollama_check rather than a reuse of it. AI_COMMANDS[blog-search]="ai-blog-search — ask your blog a question (RAG over every published article)" AI_PARAMS[blog-search]="text" # Where the blog repository lives, used only by the exact-match pass. Without # it you still get the semantic answer — just not the grep safety net. : "${AI_BLOG_DIR:=$HOME/repositories/blog}" : "${AI_BLOG_SITE_URL:=https://www.avonture.be}" : "${AI_BLOG_WORKSPACE:=blog}" # _anythingllm_check — credentials and reachability guard. _anythingllm_check() { if [[ -z "${ANYTHINGLLM_API_KEY:-}" ]]; then echo "ai-blog-search: ANYTHINGLLM_API_KEY is not set — generate one under Settings > Tools > Developer API" >&2 return 1 fi local host="${ANYTHINGLLM_URL:-http://localhost:3001}" if ! curl --silent --max-time 2 "${host}/api/ping" >/dev/null 2>&1; then echo "ai-blog-search: cannot reach ${host} — is the AnythingLLM container running?" >&2 return 1 fi } # _ai_blog_link — one source line, hyperlinked when the terminal # is one (OSC 8) and a plain URL when the output is being piped. _ai_blog_link() { if [[ -t 1 ]]; then printf ' \e[1;32m•\e[0m \e]8;;%s\e\\\e[4m%s\e[0m\e]8;;\e\\\n' "$1" "$2" printf ' \e[2m%s\e[0m\n' "${1:t}" else printf ' • %s\n %s\n' "$2" "$1" fi } # The alias at the bottom of this file survives in the running shell, so on a # re-source ("source ~/.zshrc") it would shadow the name below and the function # definition would die with "parse error near '()'". Drop it first. (( $+aliases[ai-blog-search] )) && unalias ai-blog-search ai-blog-search() { local question="$*" if [[ -z "$question" ]]; then echo "usage: ai-blog-search <your question>" >&2 return 1 fi _anythingllm_check || return 1 local host="${ANYTHINGLLM_URL:-http://localhost:3001}" # "query" confines the model to the retrieved excerpts; "chat" would let it # answer from general knowledge and describe articles that don't exist. # # The random sessionId is not cosmetic: without one every call lands in the # workspace's default thread, the model reads its own previous answers as # context, and a single wrong answer repeats itself for every later question. local response response=$( jq -nc --arg m "$question" --arg s "zsh-${RANDOM}${RANDOM}-$$" \ '{message: $m, mode: "query", sessionId: $s}' \ | curl --silent --show-error --data-binary @- \ --header "Authorization: Bearer ${ANYTHINGLLM_API_KEY}" \ --header "Content-Type: application/json" \ "${host}/api/v1/workspace/${AI_BLOG_WORKSPACE}/chat" ) local error error=$(print -r -- "$response" | jq -r '.error // empty') if [[ -n "$error" ]]; then echo "ai-blog-search: ${error}" >&2 return 1 fi printf '\n\e[1;36m❓ %s\e[0m\n\n' "$question" print -r -- "$response" | jq -r '.textResponse' local sources sources=$(print -r -- "$response" | jq -r ' [ .sources[]? | select(.chunkSource // "" | startswith("link://")) | {title, url: (.chunkSource | sub("^link://"; ""))} ] | unique_by(.url) | .[] | "\(.url)\t\(.title)"') if [[ -n "$sources" ]]; then printf '\n\e[1;33m📚 Sources the answer was built from\e[0m\n' local line for line in ${(f)sources}; do _ai_blog_link "${line%%$'\t'*}" "${line#*$'\t'}" done fi _ai_blog_grep "$question" } # "?" and "*" are glob characters in zsh, and a natural question ends in one. # Without this, "ai-blog-search which posts cover Joomla?" dies with "no matches # found" before the function is even called. Declared after the function, since # zsh refuses to define a function whose name is an existing alias. alias ai-blog-search='noglob ai-blog-search' # _ai_blog_grep <question> — the exact half of the answer. # # Vector search ranks by similarity and stops at the workspace's topN, so it # never enumerates: two long articles can fill every slot. This pass is the # opposite trade-off — dumb, exact and complete. _ai_blog_grep() { [[ -d "${AI_BLOG_DIR}" ]] || return 0 local question="$1" # Function words, English and French: the vocabulary of asking a question # rather than of any topic. Rarity alone can't catch these — "content" and # "already" each legitimately appear in a handful of titles. local stopwords="about after already also always another anything article articles been before blog both content cover covers does each every find first from give have here into just know like list made make mention mentions more most must need only other post posts really related same search some something such talk talked than that their them then there these they this those topic used using very want what when where which while will with write written wrote your alors aussi autre avec avoir beaucoup cela cette comme contenu dans deja donc dont elle encore est etre fait faire ils leur mais mes mon nous ont parle parler pour quel quelle quelles quels qui quoi sans sont sujet sur tous tout toute une vous" local -a candidates candidates=(${(u)${(s: :)${(L)question//[^[:alnum:]]/ }}}) # Second filter, dictionary-free: drop any surviving word too common to be a # topic. "docker" matches half the blog; "joomla" matches nine posts. local total ceiling total=$(find "${AI_BLOG_DIR}/blog" -type f \( -name 'index.md' -o -name 'index.mdx' \) | wc -l) ceiling=$(( total / 10 )) (( ceiling >= 3 )) || ceiling=3 local -a keywords matched local word files count for word in $candidates; do (( ${#word} >= 4 )) || continue [[ " ${stopwords} " == *" ${word} "* ]] && continue files=$(grep -rliE "^(title|slug|description|mainTag):.*${word}|^ - ${word}\$" \ "${AI_BLOG_DIR}/blog" --include='index.md' --include='index.mdx' 2>/dev/null) [[ -n "$files" ]] || continue count=$(print -r -- "$files" | grep -c .) (( count <= ceiling )) || continue keywords+=("$word") matched+=(${(f)files}) done (( ${#keywords} > 0 )) || return 0 printf '\n\e[1;33m🔎 Exact frontmatter matches (%s)\e[0m\n' "${keywords[*]}" local file slug title for file in ${(u)matched}; do # sed, not awk -F': ' — plenty of titles contain a colon of their own. slug=$(sed -n 's/^slug: *//p' "$file" | head -1 | sed 's/^"//; s/"$//') title=$(sed -n 's/^title: *//p' "$file" | head -1 | sed 's/^"//; s/"$//') _ai_blog_link "${AI_BLOG_SITE_URL}/blog/${slug}" "$title" done printf '\n' } ``` Save it as `~/.zsh/fns/ai-blog-search.zsh`, next to the series' shared `_ollama.zsh`, which declares the `AI_COMMANDS` registry this file adds itself to. Both, and the three `~/.zshrc` lines that load the folder, are in [ai-test's installation steps](/blog/ollama-test-generator#installing-it). Open a new terminal afterwards, and `ai-blog-search` is ready. Here is an example of use: ![Searching for articles about Code Quality](./images/ai-blog-search.webp) It's the odd one out in that series, and worth saying why: every other `ai-*` function calls `_ollama_query`, while this one talks to AnythingLLM — which owns the vector index and calls Ollama itself, for the embeddings and for the answer. So it brings its own `_anythingllm_check` guard rather than reusing `_ollama_check`, and declares `AI_PARAMS[blog-search]="text"` so the `ai` menu prompts for the question before running it. > **The zsh detail that costs you an afternoon:** > > `?` and `*` are glob characters, and a natural question ends in one. Without the `alias ai-blog-search='noglob ai-blog-search'` at the bottom of that file, `ai-blog-search which posts cover Joomla?` dies with `zsh: no matches found` before the function is ever entered. The alias has to come *after* the function definition, too — zsh expands aliases at parse time, so it refuses to define a function whose name is already an alias. And since that alias then lives on in your shell, the file drops it again on the way in (`(( $+aliases[ai-blog-search] )) && unalias ai-blog-search`); without that guard the file works once and then breaks every later `source ~/.zshrc` with `parse error near '()'`. Two implementations of one idea is a real cost, so be deliberate about it: the bash script is the repo's tool and the single source of truth for the logic; the zsh function is the daily driver, and stays self-contained because the whole promise of that series is "drop the file in and it works". If you only ever query from inside the blog repository, take the bash one and skip this section entirely. At this point, everything — the documents themselves, their embeddings, the LanceDB files — lives inside the `anythingllm_storage` volume, on the home PC. Fine for this machine. Not fine for what comes next. ## Part 2 — Documents on One Machine, GPU on Another {#two-machines} Here's the constraint that changes everything: my actual documentation — the stuff under `~/repositories` — lives on my **work** PC, and it's staying there. Not synced, not copied, not uploaded anywhere for convenience. That rules out the simplest option, which would be "just open `http://192.168.0.218 : 3001` from my work PC's browser and upload files there." It would technically work — but uploading a file to that page sends its content over the network to the home PC's container, where AnythingLLM stores the parsed text, the vector embeddings, and a cached copy, inside `anythingllm_storage`, on a disk that isn't mine at the office. That's exactly the kind of copy I ruled out. The browser is just the window; the documents themselves land wherever the **server** behind that page is actually running. So the fix follows directly from that: run the AnythingLLM **server** itself on the work PC — same Docker container, same `compose.yaml` — and only reach out to the home PC for the one thing that doesn't touch document content: the model doing the actual thinking. ```text Work PC (Docker) Home PC ┌─────────────────────────┐ ┌──────────────────────┐ │ anythingllm container │ inference │ ollama container │ │ - documents │ ────────────► │ - qwen2.5:14b │ │ - vectors (LanceDB) │ ◄──────────── │ - mxbai-embed-large │ │ - STORAGE_DIR │ tokens back │ - 24GB VRAM │ └─────────────────────────┘ └──────────────────────┘ ``` Only prompts and the retrieved text chunks needed to answer them cross the network for that single request — nothing gets stored on the other end. ### What actually changes in the compose file Nothing structural — it's the exact same `compose.yaml` from Part 1, running through Docker Desktop on the work PC instead. The only thing that changes is which address `OLLAMA_BASE_PATH` and `EMBEDDING_BASE_PATH` point to, and that address depends on where the work PC physically is: ```yaml title="compose.yaml — the two lines that change" - OLLAMA_BASE_PATH=http://192.168.0.218:11434 # same LAN, e.g. working from home - EMBEDDING_BASE_PATH=http://192.168.0.218:11434 ``` When the work PC is sitting on the same home network, that's it — `192.168.0.218` is reachable exactly as it was in [the earlier article](/blog/accessing-ollama-across-your-local-network), and the rest of this setup is a copy-paste of Part 1. ### And when it isn't home Five days a week, that work PC isn't on the home LAN at all. A plain IP address on a private `192.168.x.x` range simply isn't reachable from the office — that's the whole point of a private network. > **The practical fix: a mesh VPN:** > > Tools like Tailscale or WireGuard give every device — home PC included — a stable address that stays reachable no matter which network it's actually sitting on, without opening ports on your home router to the internet. Install it on the home PC, install it on the work PC, and the same `OLLAMA_BASE_PATH` pattern above keeps working — just with the VPN-assigned address instead of `192.168.0.218`. That setup deserves its own article rather than a rushed paragraph here, so consider this a preview of one. > **Ollama has no authentication of its own:** > > Exposing `192.168.0.218:11434` inside a private LAN is one thing; making it reachable from anywhere via a VPN is another step up in exposure. Ollama's API doesn't ask for a password — anyone who can reach that port can query your models. A mesh VPN like Tailscale keeps that surface limited to devices you've explicitly authorized, which is precisely why it's the recommended answer here rather than a router port-forward. ### Recreate the workspaces, this time for real With the container now running on the work PC, redo the onboarding from Part 1 — same LLM provider, same embedding model, pointing at whichever address currently reaches the home PC. Then start creating one workspace per repository under `~/repositories`, feeding each one its own documentation. This time, the documents never leave the machine you're sitting at. The only thing making the round trip to the home PC is a question and the handful of text chunks needed to answer it — exactly the split I wanted going in. ## Under the Hood (skip this if you just want to use it) Everything above is enough to run this. What follows is the one piece of AnythingLLM's internals worth knowing, because it explains why the indexing script does something that otherwise looks arbitrary. ### Where that wrong date comes from {#where-that-wrong-date-comes-from} Three fields, and only three, ever reach the model. Whatever the API accepted at upload time, AnythingLLM builds this header and prepends it to **every** chunk: ```bash <document_metadata> sourceDocument: Tabnine - AI Autocomplete & Chat for Javascript, Python, ... (published 2024-03-02) published: 8/10/2026, 9:56:01 AM source: https://www.avonture.be/blog/vscode-tabnine </document_metadata> ``` `published` comes from the collector: `published: createdDate(fullFilePath)`, stat'ed off the temporary file it has just received. Not your frontmatter `date` — the day you ran the indexer. And `metadata.published` is silently ignored on upload, so there is no way to correct it. That leaves exactly two levers, which is why the script pulls both: the title carries the real date appended as `(published YYYY-MM-DD)`, and `chunkSource` carries the live URL because a `link://` prefix is the one thing that turns into the `source:` line. Everything else you pass at upload — `docSource`, `description`, `docAuthor` — is stored, retrievable through the API, and never shown to the model. Which is also why the system prompt isn't optional decoration. Two contradictory dates sit in that header on every single chunk, and nothing in the data says which one to believe. ## Key Takeaways The one thing to carry away in prose: **documents live where the container runs, not where the browser is.** Everything else on this page follows from that — where to deploy it, why there is no "index this folder" box, and why the bulk route is the API rather than the GUI. The rest is lookup material, and these are the values that cost me an afternoon each: **Settings that silently break things when wrong** * `EMBEDDING_MODEL_MAX_CHUNK_LENGTH=400` — must stay under the embedder's context (`ollama show <model>`; 512 for `mxbai-embed-large`). Above it, **every** embedding fails and the workspace stays empty with no error in the UI * `OLLAMA_EMBEDDING_BATCH_SIZE=16` — pure speed; the default of 1 sends one chunk per HTTP call * `topN` = 20, in *Workspace Settings → Vector Database* — the default of 4 answers corpus-wide questions from a single article. Past \~20 it buys almost nothing * **Workspace system prompt** — not optional: it is what settles the two contradictory dates sitting in every chunk header, and what fixes date formatting once * `"mode": "query"` in every API call — `"chat"` lets the model answer from general knowledge and invent articles you never wrote * **A fresh `sessionId` per call** — without one the model reads its own previous answers, and one wrong answer repeats forever * `ANYTHINGLLM_API_KEY` (*Settings → Tools → Developer API*), plus `AI_BLOG_DIR` and `AI_BLOG_SITE_URL` for the zsh function ## Conclusion What started as "I can never find that one config I wrote down somewhere" turned into a genuinely small amount of Docker work: one `compose.yaml`, pointed at a model I was already running for something else. The part worth remembering isn't the YAML — it's the realization that a web UI's "upload" button is a request to wherever its server happens to live, which is exactly the detail that decides whether your documents stay put or quietly migrate to a machine you didn't intend. Once that clicked, the two-machine setup wasn't a compromise; it was just the obvious shape once GPU and documents don't live in the same place. Now, every time I catch myself about to `grep -r` and hope, I ask the workspace instead. The uncomfortable lesson is the other one, though: a RAG stack cannot tell you it is broken. Mine indexed 248 articles without a single error and then denied having anything on WordPress. If you take one habit from this article, take the thirty-second cosine check before you trust an embedder — and if you want more of your terminal answering questions locally, the [`ai-review`, `ai-secrets` and `ai-commit` family](/blog/ollama-git-precommit) is where this one came from. The mesh VPN that makes the two-machine setup work from the office is the article I owe you next. --- <!-- canonical-url: https://www.avonture.be/blog/ollama-ai-docs published: 2026-09-21 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-translate & ai-summarize: Confidential Documents, Handled 100% Locally > Two zsh functions that translate or summarize a Word, PDF, PowerPoint or Excel document without a single byte leaving your machine — Docling extracts the text locally, a local Ollama model does the translation or summary. Built for the document you'd never paste into a cloud translation tool. --- ![ai-translate & ai-summarize: Confidential Documents, Handled 100% Locally](/img/v2/ai_doc.webp) **TL;DR** This article adds `ai-translate` and `ai-summarize` to the "Ollama daily use" series. Both accept a `.pdf`, `.docx`, `.pptx`, `.xlsx` or `.html` file — extracted to Markdown by [Docling](/blog/docling) — or a plain `.md`/`.txt` file directly, then hand the text to the local Ollama model to translate or condense into bullet points. The entire round trip stays on your machine: no cloud translation API, no document upload anywhere, which is precisely the point for a contract, an HR document, or anything else you'd think twice about pasting into Google Translate. I work in an office where a `.docx` lands in my inbox in Dutch, English or French. For a lot of good reasons, I sometimes need to translate from one language to another. When it's not confidential at all, a reflex everyone has is to open the browser, paste into a translation tool, hope nobody minds that a client contract just passed through a third party's servers. Nobody ever explicitly told me not to. That's exactly the kind of thing you should not need permission to avoid. ## Demo — ai-translate Translation of a contract from any language to French: ```bash $ ai-translate contract.docx French → Converting 'contract.docx' with docling-convert... ## Note de livraison trimestrielle *Périmètre, jalons et résiliation — diffusion interne uniquement* ### Article 1 À la suite de la revue trimestrielle tenue le 3 septembre, la présente note consacre le périmètre convenu, les jalons de livraison ainsi que les conditions de résiliation applicables à l'une ou l'autre des parties. Elle se substitue au projet diffusé en juillet. En cas de conflit entre une disposition des présentes et l'accord-cadre, c'est l'accord-cadre qui prévaut, sauf pour l'échéancier de paiement défini à l'article trois, qui est conçu pour le remplacer intégralement. ### Article 2 Le prestataire livrera la première tranche dans un délai de six semaines à compter de la signature, et chaque tranche subséquente à des intervalles n'excédant pas quatre semaines. Une tranche est réputée livrée dès qu'elle a été déployée dans l'environnement de staging et que les tests de réception consignés dans l'annexe B ont été validés. La livraison hors heures ouvrables ne réduit pas le délai de vérification, qui court pendant cinq jours ouvrables à compter de l'avis de livraison. ### Article 3 Les factures sont payables trente jours après réception. Le client peut retenir le paiement d'une tranche ayant échoué aux tests de réception, mais uniquement pour cette tranche et seulement jusqu'à la régularisation du défaut. Les intérêts de retard courent au taux légal. Aucune des parties ne peut compenser les sommes dues au titre des présentes avec celles dues au titre de tout autre accord sans le consentement écrit de l'autre partie. ### Article 4 Toute partie peut résilier le contrat sur préavis écrit de soixante jours. En cas de résiliation, le prestataire devra remettre tout avancement, y compris le code source, la configuration de build et la documentation, sous une forme permettant à un tiers compétent de poursuivre le travail. Le client devra payer les travaux achevés et acceptés jusqu'à la date de résiliation. | Tranche | Échéance | Statut | | ------ | --- | --- | | Première | 6 semaines | Livrée | | Deuxième | 10 semaines| En cours | ``` And the same to Dutch: ```bash $ ai-translate contract.docx Dutch → Converting 'contract.docx' with docling-convert... ## Kwartaal Leveringsnota *Reikwijdte, mijlpalen en beëindiging — uitsluitend voor intern gebruik* ### Artikel 1 Naar aanleiding van het kwartaaloverleg van 3 september houdt deze nota de overeengekomen reikwijdte, de leveringsmijlpalen en de voorwaarden vast onder welke elke partij kan opzeggen. Deze nota vervangt de in juli verspreide conceptversie. Indien een bepaling in deze nota strijdig is met de raamovereenkomst, heeft de raamovereenkomst voorrang, met uitzondering van de in afdeling drie genoemde betalingsregeling, die geheel ter vervanging daarvan is bedoeld. ### Artikel 2 De leverancier levert de eerste levering binnen zes weken na ondertekening, en elke daaropvolgende levering in intervallen van maximaal vier weken. Een levering wordt geacht geleverd te zijn wanneer deze is geïmplementeerd in de staging-omgeving en de acceptatietests in appendix B zijn geslaagd. Levering buiten werkuren verkort het acceptatievenster niet, dat vijf werkdagen duurt te rekenen vanaf de kennisgeving van levering. ### Artikel 3 Facturen zijn betaalbaar binnen dertig dagen na ontvangst. De opdrachtgever mag de betaling voor een levering die niet is geaccepteerd, inhouden, maar uitsluitend voor die specifieke levering en alleen totdat het gebrek is verholpen. Rentevergoeding bij late betaling loopt tegen het wettelijk rentepercentage. Geen van beide partijen mag vorderingen uit deze nota verrekenen met vorderingen uit enige andere overeenkomst zonder schriftelijke toestemming van de andere partij. ### Artikel 4 Elke partij kan opzeggen met een opzegtermijn van zestig dagen schriftelijk. Bij beëindiging draagt de leverancier alle werk in uitvoering, inclusief broncode, buildconfiguratie en documentatie, over in een vorm die door een kundige derde kan worden voortgezet. De opdrachtgever betaalt voor het tot de datum van beëindiging voltooide en geaccepteerde werk. | Levering | Te leveren | Status | | ------ | --- | --- | | Eerste | 6 weken | Geleverd | | Tweede | 10 weken | In uitvoering | ``` That's a fictional service agreement, converted from `.docx` and translated into French/Dutch, with every heading and every bold term intact — the kind of document I would genuinely not paste into a browser-based translator. Same document, so you can try it yourself before adapting either function to your own files: contract.docx — the source document: `./files/contract.docx` ## Demo — ai-summarize The same contract, condensed to four bullet points instead of translated: ```bash $ ai-summarize contract.docx 4 → Converting 'contract.docx' with docling-convert... - First increment delivery due within 6 weeks of signature; subsequent increments delivered every ≤4 weeks, requiring staging deployment and Appendix B test completion within a 5-working-day acceptance window. - Invoices payable 30 days from receipt; payment withholding permitted only for failed increments until remedied, with late interest accruing at the statutory rate. - Either party may terminate with 60 days written notice, mandating handover of all work-in-progress, source code, and documentation, plus immediate payment for completed and accepted work. - Framework agreement prevails except for the payment schedule, which is entirely replaced; first increment is delivered, second increment is due at 10 weeks. ``` Delivery deadlines, payment terms, the termination clause and which parts of the framework agreement get overridden — the four points that matter, pulled out without reading the full document. ## Reusing Docling for Extraction Both functions below share one job before they ever talk to Ollama: turning a document into plain text. I already built that piece in [my Docling article](/blog/docling) — `docling-convert file.docx` produces `file.md` locally, GPU-accelerated if you set it up that way. Rather than duplicate that logic twice, it lives in one small helper: No need to copy anything yet: the `.zsh` files shown in this article are here to be read, and [Installing It](#installing-it) at the end creates every one of them in `~/.zsh/fns/` for you. **\~/.zsh/fns/\_ai-docs.zsh** ```bash # Shared document-to-text extraction for ai-translate and ai-summarize. # .pdf/.docx/.pptx/.xlsx/.html go through docling-convert (see /blog/docling) # first; .md/.markdown/.txt files are read directly, no conversion needed. # # Leading underscore: loads after _ollama.zsh (still alphabetically before # ai-summarize.zsh and ai-translate.zsh), so _ollama_query is always ready. _ai_extract_text() { local file="$1" local ext="${file:e:l}" case "$ext" in md|markdown|txt) cat "$file" ;; pdf|docx|pptx|xlsx|html|htm) if ! command -v docling-convert >/dev/null 2>&1; then echo "_ai_extract_text: docling-convert not found — see /blog/docling to set it up" >&2 return 1 fi local dir filename base mdfile dir="${file:h}" filename="${file:t}" base="${filename%.*}" mdfile="${dir}/${base}.md" echo "→ Converting '$file' with docling-convert..." >&2 ( cd "$dir" && docling-convert "$filename" >/dev/null 2>&1 ) || { echo "_ai_extract_text: docling-convert failed on '$file'" >&2 return 1 } cat "$mdfile" ;; *) echo "_ai_extract_text: unsupported extension .$ext (expected pdf/docx/pptx/xlsx/html/md/txt)" >&2 return 1 ;; esac } ``` > **Markdown and text files skip conversion entirely:** > > If you already have a `.md` or `.txt` file, `_ai_extract_text` just `cat`s it — no Docling call, no dependency. The conversion step only kicks in for actual office formats. > **Why the subshell around docling-convert:** > > `docling-convert` (from the previous article) mounts the *current directory* into its container and expects a filename relative to it — it wasn't built to resolve an arbitrary absolute path. `( cd "$dir" && docling-convert "$filename" )` runs it from the document's own folder, in a subshell, so your actual working directory is untouched when the function returns. ## ai-translate **\~/.zsh/fns/ai-translate.zsh** ```bash # ai-translate <file> [language] — translate a document into the given # language (default: French), entirely locally. Accepts .pdf/.docx/.pptx/ # .xlsx/.html (via docling-convert) or plain .md/.txt files directly. AI_COMMANDS[translate]="ai-translate <file> [language] — translate a document locally (default: French)" ai-translate() { local file="$1" local lang="${2:-French}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-translate <file> [target-language]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Translate the following document into $lang. Preserve the Markdown structure exactly — headings, lists, tables, emphasis. Output only the translated document, no commentary and no notes about the translation. $text" _ollama_query "$prompt" } ``` Extract, then ask the model to translate into whatever language you pass as the second argument (French by default — the language I need most often), with an explicit instruction to preserve the Markdown structure so headings, lists, and tables survive the round trip instead of collapsing into a wall of text. ## ai-summarize **\~/.zsh/fns/ai-summarize.zsh** ```bash # ai-summarize <file> [points] — summarize a document in N bullet points # (default: 5), entirely locally. Accepts .pdf/.docx/.pptx/.xlsx/.html (via # docling-convert) or plain .md/.txt files directly. AI_COMMANDS[summarize]="ai-summarize <file> [points] — summarize a document locally (default: 5 points)" ai-summarize() { local file="$1" local points="${2:-5}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-summarize <file> [number-of-points]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Summarize the following document in exactly $points bullet points, written in the same language as the document. Prioritize decisions, numbers, deadlines and action items over generic description. Output only the bullet points, nothing else. $text" _ollama_query "$prompt" } ``` Same extraction step, different prompt: a fixed number of bullet points (5 by default), explicitly told to prioritize decisions, numbers, deadlines and action items over generic description — and to answer in whatever language the source document is already in, rather than silently switching to English. ## Registered in the ai Menu Two lines — `AI_COMMANDS[translate]=...` and `AI_COMMANDS[summarize]=...` — and both are reachable as `ai translate` / `ai summarize`, or directly as `ai-translate` / `ai-summarize`, alongside every other function in this series. Both functions call `_ollama_query` and register into `AI_COMMANDS`, which live in the shared `_ollama.zsh` foundation introduced in [ai-test](/blog/ollama-test-generator). If you already followed an earlier article in this series, it's already sitting in `~/.zsh/fns/` and sourced by your autoloader — nothing more to do. Starting here instead? It is one of the files created in [Installing It](#installing-it) below. > **Docling and Ollama don't have to be on the same machine:** > > `docling-convert` always runs locally, no GPU required — converting a document to Markdown works on any laptop. Translating or summarizing still needs an Ollama server reachable somewhere, though: both functions connect to `OLLAMA_HOST`, same setup as [accessing Ollama across your local network](/blog/accessing-ollama-across-your-local-network) — a shared LAN, or a VPN/tunnel across two separate networks. > > ```mermaid > flowchart TD > subgraph C["💻 Your computer"] > F["📄 .docx / .pdf / …"] --> D["🐳 docling-convert"] > D --> T["📝 plain text (.md)"] > end > > subgraph S["🧠 Ollama server"] > O["Ollama"] > end > > T -->|OLLAMA_HOST| O > O -->|"translated / summarized,\nback on your computer"| R["✅ result"] > > style D fill:#4a90d9,color:#fff > style O fill:#27ae60,color:#fff > ``` ## Key Takeaways **ai-translate / ai-summarize quick reference** * **Shared extraction** — `_ai_extract_text` in `_ai-docs.zsh`, reused by both functions * **Docling for office formats** — `.pdf`/`.docx`/`.pptx`/`.xlsx`/`.html` go through `docling-convert` first * **Markdown/text skip conversion** — `.md`/`.txt` files are read directly * **Nothing leaves the machine** — extraction and translation/summary both run locally, end to end * **Registers into `ai`** — reachable as `ai translate` and `ai summarize` ## 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. For `.pdf`, `.docx`, `.pptx`, `.xlsx` and `.html` files, you also need `docling-convert`, from [my Docling article](/blog/docling). **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 four 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: 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-docs.zsh** ```bash # Shared document-to-text extraction for ai-translate and ai-summarize. # .pdf/.docx/.pptx/.xlsx/.html go through docling-convert (see /blog/docling) # first; .md/.markdown/.txt files are read directly, no conversion needed. # # Leading underscore: loads after _ollama.zsh (still alphabetically before # ai-summarize.zsh and ai-translate.zsh), so _ollama_query is always ready. _ai_extract_text() { local file="$1" local ext="${file:e:l}" case "$ext" in md|markdown|txt) cat "$file" ;; pdf|docx|pptx|xlsx|html|htm) if ! command -v docling-convert >/dev/null 2>&1; then echo "_ai_extract_text: docling-convert not found — see /blog/docling to set it up" >&2 return 1 fi local dir filename base mdfile dir="${file:h}" filename="${file:t}" base="${filename%.*}" mdfile="${dir}/${base}.md" echo "→ Converting '$file' with docling-convert..." >&2 ( cd "$dir" && docling-convert "$filename" >/dev/null 2>&1 ) || { echo "_ai_extract_text: docling-convert failed on '$file'" >&2 return 1 } cat "$mdfile" ;; *) echo "_ai_extract_text: unsupported extension .$ext (expected pdf/docx/pptx/xlsx/html/md/txt)" >&2 return 1 ;; esac } ``` **ai-translate.zsh** ```bash # ai-translate <file> [language] — translate a document into the given # language (default: French), entirely locally. Accepts .pdf/.docx/.pptx/ # .xlsx/.html (via docling-convert) or plain .md/.txt files directly. AI_COMMANDS[translate]="ai-translate <file> [language] — translate a document locally (default: French)" ai-translate() { local file="$1" local lang="${2:-French}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-translate <file> [target-language]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Translate the following document into $lang. Preserve the Markdown structure exactly — headings, lists, tables, emphasis. Output only the translated document, no commentary and no notes about the translation. $text" _ollama_query "$prompt" } ``` **ai-summarize.zsh** ```bash # ai-summarize <file> [points] — summarize a document in N bullet points # (default: 5), entirely locally. Accepts .pdf/.docx/.pptx/.xlsx/.html (via # docling-convert) or plain .md/.txt files directly. AI_COMMANDS[summarize]="ai-summarize <file> [points] — summarize a document locally (default: 5 points)" ai-summarize() { local file="$1" local points="${2:-5}" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-summarize <file> [number-of-points]" >&2 return 1 fi local text text=$(_ai_extract_text "$file") || return 1 local prompt="Summarize the following document in exactly $points bullet points, written in the same language as the document. Prioritize decisions, numbers, deadlines and action items over generic description. Output only the bullet points, nothing else. $text" _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 functions: ```zsh type ai-translate ai-summarize ``` `ai-translate is a shell function from …/ai-translate.zsh` means you're ready. `ai-translate not found` means step 2 is missing. ## Conclusion The actual privacy story here isn't a policy or a promise — it's an architecture. A document goes in, Docling turns it into text without a network call, Ollama processes that text without a network call, and the result lands in my terminal. There's no step in that chain where "trust the vendor" is required, which is exactly what makes it usable for the documents I'd never risk on a cloud tool in the first place. Between this, `ai-ci` for pipelines, `ai-standup` for the morning recap and the rest of this series, the local model I already had running for other reasons keeps finding one more thing worth doing quietly, in the background, without asking anything of me except a file path. --- <!-- canonical-url: https://www.avonture.be/blog/ollama-ai-diagram published: 2026-10-15 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-diagram: From a Sentence (or a compose.yaml) to a Mermaid Diagram > A zsh function that turns a plain-English description, or an existing config file like a docker-compose.yaml, into a Mermaid diagram — printed as a ready-to-paste fenced code block. Where docker-python-mermaid needs a script written for each input format, ai-diagram takes whatever you have in hand. --- ![ai-diagram: From a Sentence (or a compose.yaml) to a Mermaid Diagram](/img/v2/ai_diagram.webp) **TL;DR** This article adds `ai-diagram` to the "Ollama daily use" series: give it a plain-English description of a process, or point it at a file like a `docker-compose.yaml`, and it returns a Mermaid diagram — a fenced code block, ready to paste straight into a Markdown file. It's a deliberate bridge between this series and my doc-as-code articles: `docker-compose-viz` and `docker-python-mermaid` both turn *structured* data into diagrams with real parsers; `ai-diagram` handles the case those can't — a description with no schema at all, or a file you don't want to write a parser for just to sketch one diagram. I've written more "turn X into a diagram" articles than I first realized: [Diagrams as code](/blog/docker-diagram-as-code), [a graph from compose.yaml](/blog/docker-compose-viz), [documentation as code with Python and Mermaid](/blog/docker-python-mermaid). Every one of them shares the same shape: a real parser reads a structured input and a library draws the picture. That works great — right up until what I want to diagram is a paragraph I just typed, or a one-off config file that isn't worth writing a parser for. ## Demo — Plain-English Description ````bash $ ai-diagram "a user submits a contact form, the API validates the payload, saves it to Postgres, then sends a confirmation email via SMTP" ```mermaid flowchart TD A[User submits contact form] --> B[API validates payload] B -->|Validation fails| C[Return validation errors] B -->|Validation passes| D[Save to Postgres] D --> E[Send confirmation email via SMTP] E --> F[Email sent successfully] ``` Preview: https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtVc2VyIHN1Ym1pdHMgY29udGFjdCBmb3JtXSAtLT4gQltBUEkgdmFsaWRhdGVzIHBheWxvYWRdXG4gICAgQiAtLT58VmFsaWRhdGlvbiBmYWlsc3wgQ1tSZXR1cm4gdmFsaWRhdGlvbiBlcnJvcnNdXG4gICAgQiAtLT58VmFsaWRhdGlvbiBwYXNzZXN8IERbU2F2ZSB0byBQb3N0Z3Jlc11cbiAgICBEIC0tPiBFW1NlbmQgY29uZmlybWF0aW9uIGVtYWlsIHZpYSBTTVRQXVxuICAgIEUgLS0-IEZbRW1haWwgc2VudCBzdWNjZXNzZnVsbHldIn0 ```` No YAML, no schema, just a sentence — and a flowchart that branches on the validation, the one step in that sentence whose outcome decides what happens next, while saving and sending stay plain steps. Pasted into this page, the block renders as: ```mermaid flowchart TD A[User submits contact form] --> B[API validates payload] B -->|Validation fails| C[Return validation errors] B -->|Validation passes| D[Save to Postgres] D --> E[Send confirmation email via SMTP] E --> F[Email sent successfully] ``` ## Demo — An Existing Config File **compose.yaml** ```yaml services: app: build: . depends_on: - db - cache ports: - "8080:80" db: image: postgres:16 volumes: - db-data:/var/lib/postgresql/data cache: image: redis:7 volumes: db-data: ``` ````bash $ ai-diagram compose.yaml ```mermaid graph TD app["app · build · :8080"] db["db · postgres:16"] cache["cache · redis:7"] db_data[("db-data")] app -- "depends_on" --> db app -- "depends_on" --> cache db -- "volumes" --> db_data ``` Preview: https://mermaid.live/edit#base64:eyJjb2RlIjoiZ3JhcGggVERcbiAgYXBwW1wiYXBwIMK3IGJ1aWxkIMK3IDo4MDgwXCJdXG4gIGRiW1wiZGIgwrcgcG9zdGdyZXM6MTZcIl1cbiAgY2FjaGVbXCJjYWNoZSDCtyByZWRpczo3XCJdXG4gIGRiX2RhdGFbKFwiZGItZGF0YVwiKV1cblxuICBhcHAgLS0gXCJkZXBlbmRzX29uXCIgLS0-IGRiXG4gIGFwcCAtLSBcImRlcGVuZHNfb25cIiAtLT4gY2FjaGVcbiAgZGIgLS0gXCJ2b2x1bWVzXCIgLS0-IGRiX2RhdGEifQ ```` Three services, their dependency edges, and the named volume — read straight out of the compose file's own structure, with each image and published port folded into its service's label: ```mermaid graph TD app["app · build · :8080"] db["db · postgres:16"] cache["cache · redis:7"] db_data[("db-data")] app -- "depends_on" --> db app -- "depends_on" --> cache db -- "volumes" --> db_data ``` > **Read the Mermaid before you trust it:** > > For anything going into actual documentation — an onboarding doc, an architecture decision record — treat this the same way as `docker-compose-viz`'s output: a strong first draft. The model can miss an edge case (a `depends_on: condition: service_healthy`, a less obvious network alias) that a real parser like `docker-python-mermaid`'s wouldn't. ## More Sentences to Try Three more descriptions, each one ready to copy into your own terminal. Every link opens the diagram `ai-diagram` printed for it, in the Mermaid Live Editor. ### Support Ticket Triage ```bash ai-diagram "When a customer submits a support ticket, check if it is urgent. If it is urgent, assign it directly to a senior agent. If it is not urgent, place it in the standard queue and send an automated confirmation email to the customer." ``` [See the diagram it produces on mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtDdXN0b21lciBzdWJtaXRzIHN1cHBvcnQgdGlja2V0XSAtLT4gQntJcyB0aWNrZXQgdXJnZW50P31cbiAgICBCIC0tIFlFUyAtLT4gQ1tBc3NpZ24gdG8gc2VuaW9yIGFnZW50XVxuICAgIEIgLS0gTk8gLS0-IERbUGxhY2UgaW4gc3RhbmRhcmQgcXVldWVdXG4gICAgRCAtLT4gRVtTZW5kIGF1dG9tYXRlZCBjb25maXJtYXRpb24gZW1haWxdXG4gICAgQyAtLT4gRltFbmRdXG4gICAgRSAtLT4gRltFbmRdIn0) ### Login With a Lockout ```bash ai-diagram "To log into the system, the user enters their username and password. If the credentials are correct, grant access and display the main dashboard. If the credentials are incorrect, check if the attempt count is less than three. If it is less than three, increment the counter and ask the user to try again. Otherwise, lock the account and send a password reset link to the user's email." ``` [See the diagram it produces on mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtVc2VyIGVudGVycyB1c2VybmFtZSBhbmQgcGFzc3dvcmRdIC0tPiBCe0NyZWRlbnRpYWxzIGNvcnJlY3Q_fVxuICAgIEIgLS0gWWVzIC0tPiBDW0dyYW50IGFjY2Vzc11cbiAgICBDIC0tPiBEW0Rpc3BsYXkgbWFpbiBkYXNoYm9hcmRdXG4gICAgQiAtLSBObyAtLT4gRXtBdHRlbXB0IGNvdW50IDwgMz99XG4gICAgRSAtLSBZZXMgLS0-IEZbSW5jcmVtZW50IGNvdW50ZXJdXG4gICAgRiAtLT4gR1tBc2sgdXNlciB0byB0cnkgYWdhaW5dXG4gICAgRSAtLSBObyAtLT4gSFtMb2NrIGFjY291bnRdXG4gICAgSCAtLT4gSVtTZW5kIHBhc3N3b3JkIHJlc2V0IGxpbmsgdG8gZW1haWxdIn0) ### An E-Commerce Order ```bash ai-diagram "When receiving an e-commerce order, first verify if the requested item is in stock. If it is out of stock, notify the customer and ask if they want to backorder or cancel. If they cancel, process a full refund. If the item is in stock, check if the payment is processed successfully. If payment fails, prompt the user to update their payment method. If payment succeeds, pack the order, generate a shipping label, ship the item, and send a tracking number to the customer." ``` [See the diagram it produces on mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtSZWNlaXZlIGUtY29tbWVyY2Ugb3JkZXJdIC0tPiBCe0lzIGl0ZW0gaW4gc3RvY2s_fVxuICAgIEIgLS0gTm8gLS0-IENbTm90aWZ5IGN1c3RvbWVyXVxuICAgIEMgLS0-IER7V2FudCB0byBiYWNrb3JkZXIgb3IgY2FuY2VsP31cbiAgICBEIC0tIENhbmNlbCAtLT4gRVtQcm9jZXNzIGZ1bGwgcmVmdW5kXVxuICAgIEQgLS0gQmFja29yZGVyIC0tPiBGW1Byb2NlZWQgd2l0aCBvcmRlcl1cbiAgICBCIC0tIFllcyAtLT4gR3tQYXltZW50IHByb2Nlc3NlZCBzdWNjZXNzZnVsbHk_fVxuICAgIEcgLS0gTm8gLS0-IEhbUHJvbXB0IHVzZXIgdG8gdXBkYXRlIHBheW1lbnQgbWV0aG9kXVxuICAgIEggLS0-IEdcbiAgICBHIC0tIFllcyAtLT4gSVtQYWNrIG9yZGVyXVxuICAgIEkgLS0-IEpbR2VuZXJhdGUgc2hpcHBpbmcgbGFiZWxdXG4gICAgSiAtLT4gS1tTaGlwIGl0ZW1dXG4gICAgSyAtLT4gTFtTZW5kIHRyYWNraW5nIG51bWJlciB0byBjdXN0b21lcl0ifQ) ## Where This Actually Sits Next to `docker-python-mermaid` > **Not a replacement — a different job:** > > [`docker-python-mermaid`](/blog/docker-python-mermaid) parses a `compose.yaml` too, and it is the right tool for a diagram that lives in the documentation: a script written once for that input format, deterministic, regenerated on every change. `ai-diagram` is for the other half of the job: a Slack message's worth of description, or a format you have no script for — an `nginx.conf`, a CI workflow, a SQL schema. Less precision, no setup at all — I use both, depending on whether the diagram has to stay in sync or just has to exist by this afternoon. ## The `ai-diagram` Function No need to copy anything yet: the `.zsh` files shown in this article are here to be read, and [Installing It](#installing-it) at the end creates every one of them in `~/.zsh/fns/` for you. **\~/.zsh/fns/ai-diagram.zsh** ````bash # ai-diagram <description>|<file> — turn a plain-English description, or an # existing config/structure file, into a Mermaid diagram. Prints a fenced # ```mermaid code block, ready to paste into a Markdown file (this blog # already renders Mermaid natively). AI_COMMANDS[diagram]="ai-diagram <description>|<file> — turn a description or a config file into a Mermaid diagram" AI_PARAMS[diagram]="text" ai-diagram() { if [[ -z "$1" ]]; then echo 'Usage: ai-diagram "plain-English description" or ai-diagram <file>' >&2 return 1 fi local input hint if [[ -f "$1" ]]; then input=$(cat "$1") # Without these rules, a model turns every value it reads into a node of # its own: "postgres:16" or "8080:80" end up as boxes next to the services. hint="The input below is the file '$1'. Infer the most natural Mermaid diagram type from its content — a docker-compose.yaml suggests a graph of services and their links, a folder listing suggests a flowchart or tree, an OpenAPI spec suggests a sequence diagram. Follow these rules: - one node per entity the file declares (a service, a table, a class, a job), labelled with its name and its key attributes, e.g. app[\"app · build · :8080\"]; - one edge per relationship the file states (depends_on, a foreign key, needs), from the entity to what it relies on; - storage (a named volume, a database file) is a cylinder node, e.g. db_data[(\"db-data\")], linked from the entity that uses it; - attributes (an image, a port, a type) appear only inside labels, never as nodes of their own; - node ids use letters, digits and underscores only." else input="$*" hint="The input below is a plain-English description. Pick whichever Mermaid diagram type (flowchart, sequence, ER, class, state) best fits what is being described. Draw every branch the description states. Add a failure branch on your own only where a step is itself a check whose outcome decides what happens next (a validation, a login, a payment) — never for a plain action such as saving, sending or assigning something." fi local prompt="You are a Mermaid diagram expert. $hint Output ONLY a fenced Mermaid code block — no explanation before or after it, no extra commentary, nothing outside the \`\`\`mermaid ... \`\`\` fence. --- INPUT --- $input" local diagram diagram=$(_ollama_query "$prompt") || return 1 if [[ -z "$diagram" ]]; then echo "ai-diagram: the model returned an empty answer" >&2 return 1 fi # The prompt asks for the fence, but a model still adds a sentence before it # now and then. Keeping only the fenced lines and re-wrapping them makes the # output paste-ready every time, whatever the model wrapped around it. local code code=$(_ai_strip_fences "$diagram") print -r -- '```mermaid' print -r -- "$code" print -r -- '```' # A Ctrl+click preview on mermaid.live. "base64:" is one of the two formats # the editor reads from the URL hash — the JSON state, base64url-encoded — so # jq and coreutils are enough, no compression library. It goes to stderr: # stdout stays the bare Markdown block, even redirected to a file. local state state=$(jq -jcn --arg code "$code" '{code: $code}' | base64 | tr -d '\n=' | tr '+/' '-_') print -r -- "Preview: https://mermaid.live/edit#base64:${state}" >&2 } ```` Two input modes, told apart by a single test — if the first argument is an existing file, it's a file: 1. **A file** — read as-is, and the prompt asks the model to infer the diagram type from context (a `compose.yaml` implies a service graph, a folder listing implies a tree, an OpenAPI spec implies a sequence diagram). A short list of rules keeps the graph honest: one node per entity, one edge per relationship, attributes such as an image or a port inside the labels, storage drawn as a cylinder. 2. **Free text** — everything after the command is treated as the description, and the model picks whichever Mermaid diagram type fits best on its own, adding a failure branch only where a step is itself a check (a validation, a login, a payment) — never an invented "email sent?" on a plain action. Either way, the output is a ` ```mermaid ` block and nothing else. The prompt asks for exactly that, and `_ai_strip_fences` then keeps only the fenced lines before the function re-wraps them itself — so even an answer that opens with "Here is your diagram:" comes out ready to paste into a post, where this blog renders it natively. Before pasting it anywhere, you'll want to see it. The last line, `Preview: https://mermaid.live/edit#base64:…`, opens the diagram in the [Mermaid Live Editor](https://mermaid.live) with a Ctrl+click from the terminal — rendered, and editable if the model got a label wrong. The editor reads its whole state from the part of the URL after the `#`: the JSON `{"code": "…"}`, base64url-encoded, so `jq` and `base64` are all it takes to build. That fragment is never sent to the server in the request; the diagram is decoded and drawn by the page, in your browser. The link goes to stderr, so `ai-diagram compose.yaml > docs/services.md` still writes nothing but the Mermaid block. ## Registered in the `ai` Menu Two lines — `AI_COMMANDS[diagram]=...` and `AI_PARAMS[diagram]="text"` — make it reachable as `ai diagram <input>` or directly as `ai-diagram`, alongside every other function in the series. Picked from the bare `ai` menu, it prompts for its input: type a description, or a file name. `_ollama_query`, `_ai_strip_fences` and the `AI_COMMANDS` registry all live in the shared `_ollama.zsh` foundation introduced in [ai-test](/blog/ollama-test-generator). If you already followed an earlier article in this series, it's already sitting in `~/.zsh/fns/` and sourced by your autoloader — nothing more to do. Starting here instead? It is one of the files created in [Installing It](#installing-it) below. ## Key Takeaways **ai-diagram quick reference** * **Two input modes** — an existing file, or free-text description, auto-detected * **Diagram type is inferred** — flowchart, sequence, ER, class, state — the model picks based on context * **Output is fence-only** — a raw \`\`\`mermaid block, re-wrapped by the function itself, meant to be pasted directly * **Ctrl+click to preview** — a mermaid.live link on stderr opens the rendered diagram, stdout stays paste-ready * **Complements, doesn't replace, `docker-python-mermaid`** — a script for diagrams that must stay in sync; this for everything else * **Registers into `ai`** — reachable as `ai diagram` ## 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: 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-diagram.zsh** ````bash # ai-diagram <description>|<file> — turn a plain-English description, or an # existing config/structure file, into a Mermaid diagram. Prints a fenced # ```mermaid code block, ready to paste into a Markdown file (this blog # already renders Mermaid natively). AI_COMMANDS[diagram]="ai-diagram <description>|<file> — turn a description or a config file into a Mermaid diagram" AI_PARAMS[diagram]="text" ai-diagram() { if [[ -z "$1" ]]; then echo 'Usage: ai-diagram "plain-English description" or ai-diagram <file>' >&2 return 1 fi local input hint if [[ -f "$1" ]]; then input=$(cat "$1") # Without these rules, a model turns every value it reads into a node of # its own: "postgres:16" or "8080:80" end up as boxes next to the services. hint="The input below is the file '$1'. Infer the most natural Mermaid diagram type from its content — a docker-compose.yaml suggests a graph of services and their links, a folder listing suggests a flowchart or tree, an OpenAPI spec suggests a sequence diagram. Follow these rules: - one node per entity the file declares (a service, a table, a class, a job), labelled with its name and its key attributes, e.g. app[\"app · build · :8080\"]; - one edge per relationship the file states (depends_on, a foreign key, needs), from the entity to what it relies on; - storage (a named volume, a database file) is a cylinder node, e.g. db_data[(\"db-data\")], linked from the entity that uses it; - attributes (an image, a port, a type) appear only inside labels, never as nodes of their own; - node ids use letters, digits and underscores only." else input="$*" hint="The input below is a plain-English description. Pick whichever Mermaid diagram type (flowchart, sequence, ER, class, state) best fits what is being described. Draw every branch the description states. Add a failure branch on your own only where a step is itself a check whose outcome decides what happens next (a validation, a login, a payment) — never for a plain action such as saving, sending or assigning something." fi local prompt="You are a Mermaid diagram expert. $hint Output ONLY a fenced Mermaid code block — no explanation before or after it, no extra commentary, nothing outside the \`\`\`mermaid ... \`\`\` fence. --- INPUT --- $input" local diagram diagram=$(_ollama_query "$prompt") || return 1 if [[ -z "$diagram" ]]; then echo "ai-diagram: the model returned an empty answer" >&2 return 1 fi # The prompt asks for the fence, but a model still adds a sentence before it # now and then. Keeping only the fenced lines and re-wrapping them makes the # output paste-ready every time, whatever the model wrapped around it. local code code=$(_ai_strip_fences "$diagram") print -r -- '```mermaid' print -r -- "$code" print -r -- '```' # A Ctrl+click preview on mermaid.live. "base64:" is one of the two formats # the editor reads from the URL hash — the JSON state, base64url-encoded — so # jq and coreutils are enough, no compression library. It goes to stderr: # stdout stays the bare Markdown block, even redirected to a file. local state state=$(jq -jcn --arg code "$code" '{code: $code}' | base64 | tr -d '\n=' | tr '+/' '-_') print -r -- "Preview: https://mermaid.live/edit#base64:${state}" >&2 } ```` **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-diagram ``` `ai-diagram is a shell function from …/ai-diagram.zsh` means you're ready. `ai-diagram not found` means step 2 is missing. ## Conclusion My doc-as-code articles have always assumed I already had something structured to feed a parser. `ai-diagram` is what fills the gap before that point — the sentence I just typed, the file I have but haven't written a parser for. It won't replace [Diagrams as code](/blog/docker-diagram-as-code) or [docker-python-mermaid](/blog/docker-python-mermaid) for anything that needs to be exact and reproducible, but for the "let me just sketch this quickly" case that used to mean opening a whiteboard tool, it's turned into one command and a paste. --- <!-- canonical-url: https://www.avonture.be/blog/ollama-ai-data published: 2026-10-22 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-data: Let a Local LLM Write Your jq and awk One-Liners > A zsh function that reads a JSON or CSV file, understands its actual fields, and suggests 5 practical jq/awk commands tailored to that exact file — picked via fzf, then loaded onto your command line (not run for you) so you see, edit and learn the exact command before pressing Enter. --- ![ai-data: Let a Local LLM Write Your jq and awk One-Liners](/img/v2/json.webp) **TL;DR** This article adds `ai-data` to the "Ollama daily use" series: point it at a `.json` or `.csv` file, and it asks the local model for 5 practical `jq`/`awk` commands tailored to that file's actual field names — not generic examples. Pick one with `fzf`, and instead of running it for you, `ai-data` loads it onto your zsh command line with `print -z`, ready to read, edit, and only then execute. The goal isn't just an answer — it's leaving with a command you understand. My use of [`jq`](/blog/linux-jq) is still very basic; I'm nowhere near being able to write a `group_by` line of code on the first try.Every time, it’s the same routine: I have to fiddle around (and waste several minutes) just to get what I want—or even… I don’t even know that a certain feature exists. With `awk`, it's even worse: I know it exists and that it's incredibly powerful, but the syntax is just impossible. ## Demo — JSON ```bash $ ai-data users.json → Analyzing users.json (json)... ai-data > 5/5 ──────────────────────────────── Extract all user names from the JSON file Count how many active users exist in the file Display name and birth date for each user, separated by colon > Group users by active status and count them Find names of users born in the 1990s $ jq 'group_by(.active) | map({"active": .[0].active, "count": length})' users.json [ { "active": false, "count": 1 }, { "active": true, "count": 2 } ] ``` Five suggestions, listed by what they answer — every one of them built on the real field names (`active`, `birth_date`, `name`) the model saw in the sample, not on placeholders from a `jq` tutorial. Picking the fourth one in `fzf` left that exact `group_by` filter sitting at my prompt; I read it, it looked right, I pressed Enter. ## Demo — CSV ```bash $ ai-data orders.csv → Analyzing orders.csv (csv)... ai-data > 5/5 ──────────────────────────────── Show order IDs and amounts for EMEA region only Calculate total sales amount across all orders > Count orders by region and display in descending order Display all orders with amount greater than 100 Show first 10 orders with all fields $ awk -F, 'NR>1 {print $2}' orders.csv | sort | uniq -c | sort -nr 2 EMEA 2 APAC 1 AMER ``` The model noticed on its own that `region` is the column worth grouping by, and answered with the classic `awk | sort | uniq -c` pipeline — the kind of line I recognize when I see it but never write from memory. Having read it once at my own prompt, I understand it better than if I'd just seen the output. And when the question grows into a join or a window function, that's where [DuckDB](/blog/duckdb-json-csv) takes over — one line of SQL, run straight against the same files. ## The Idea — Suggestions, Not Answers Most functions in this series ask the model a question and print the answer — [ai-diagram](/blog/ollama-ai-diagram) prints a Mermaid block, ready to paste. `ai-data` is different on purpose: it asks the model for **candidate commands**, shows them through [`fzf`](/blog/linux-fzf-introduction), and when you pick one, it doesn't run it — it drops it onto your prompt, editable, via zsh's `print -z`. You read the exact `jq` filter or `awk` script before it touches your data, and because you're the one hitting Enter, you actually absorb the syntax instead of just consuming the output. > **print -z, in one sentence:** > > `print -z "some command"` pushes a string onto zsh's editor buffer — the next prompt shows it pre-typed, exactly as if you'd typed it yourself, cursor ready, nothing executed yet. ## The `ai-data` Function No need to copy anything yet: the `.zsh` files shown in this article are here to be read, and [Installing It](#installing-it) at the end creates every one of them in `~/.zsh/fns/` for you. **\~/.zsh/fns/ai-data.zsh** ```bash # ai-data <file.json|file.csv> — analyze a JSON or CSV file and suggest 5 # practical jq/awk commands tailored to its actual fields. Pick one via fzf; # instead of running it for you, it's loaded into your command line with # `print -z` so you can read it, edit it, and learn from it before hitting # Enter yourself. AI_COMMANDS[data]="ai-data <file.json|file.csv> — suggest jq/awk commands tailored to this file" AI_PARAMS[data]="file" ai-data() { local file="$1" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-data <file.json|file.csv>" >&2 return 1 fi local ext="${file:e:l}" local sample tool case "$ext" in json) tool="jq" if jq -e 'type == "array"' "$file" >/dev/null 2>&1; then sample=$(jq -c '.[0:3]' "$file") else sample=$(head -c 3000 "$file") fi ;; csv) if command -v mlr >/dev/null 2>&1; then tool="awk, or mlr (Miller — detected as installed, prefer it for group-by/aggregation)" else tool="awk (Miller not detected — stick to awk/sort/uniq idioms)" fi sample=$(head -n 6 "$file") ;; *) echo "ai-data: unsupported extension .$ext (expected .json or .csv)" >&2 return 1 ;; esac echo "→ Analyzing $file ($ext)..." >&2 local prompt="You are a data analyst who writes $tool one-liners. Below is a sample of a $ext file — the field names and shapes are real, only the row count is truncated. Suggest exactly 5 practical, ready-to-run commands a reader would actually want on THIS file: filtering, extracting specific fields, counting, grouping. Reference the real filename '$file' in every command. Output exactly one command per line, in this exact format and nothing else — no numbering, no markdown fences: COMMAND ||| one-line description of what it answers --- SAMPLE --- $sample" local suggestions suggestions=$(_ollama_query "$prompt") || return 1 if [[ -z "$suggestions" ]]; then echo "ai-data: no suggestions returned" >&2 return 1 fi # "print -r" and never echo: zsh's echo expands escape sequences, and the # "\n" of an awk printf would split the suggestion over two lines. if ! command -v fzf >/dev/null 2>&1; then print -r -- "$suggestions" return 0 fi local picked picked=$(print -r -- "$suggestions" | fzf --delimiter=' \|\|\| ' --with-nth=2 \ --prompt="ai-data > " --height=40% --reverse) [[ -z "$picked" ]] && return 0 local cmd="${picked%% \|\|\| *}" # Same reason here: without -r, "\n" would reach the prompt as a real newline. print -rz -- "$cmd" } ``` Step by step: 1. Detect JSON vs CSV from the extension. 2. Build a small, real sample — for JSON, the first 3 elements if it's an array (`jq -c '.[0:3]'`); for CSV, the header plus 5 rows. Never the whole file: a 50,000-row CSV doesn't need to leave more than a few lines in the prompt. 3. For CSV, check whether [`mlr`](https://miller.readthedocs.io/) (Miller) is installed — if so, the model is told it can suggest `mlr` for group-by/aggregation, which reads dramatically cleaner than the equivalent `awk`. If not, it sticks to `awk`/`sort`/`uniq`. 4. Ask for exactly 5 commands, one per line, in a `COMMAND ||| description` format — deliberately not JSON output, because parsing five lines of "text before a triple-pipe" is trivial and doesn't risk the model wrapping its answer in markdown fences or extra commentary. 5. Pipe the five lines into `fzf`, showing only the description (`--with-nth=2`) so you're choosing by *intent*, not by squinting at `jq` syntax first. 6. On selection, `print -rz` the chosen command's first field. > **No fzf? Still works:** > > Without `fzf` installed, `ai-data` just prints the five suggestions as plain text — you copy the one you want manually. Same information, one less convenience. ## Registered in the `ai` Menu One line — `AI_COMMANDS[data]=...` — reachable as `ai data <file>` or directly as `ai-data`, alongside every other function in the series. `_ollama_query` and the `AI_COMMANDS` registry live in the shared `_ollama.zsh` foundation introduced in [ai-test](/blog/ollama-test-generator). If you already followed an earlier article in this series, it's already sitting in `~/.zsh/fns/` and sourced by your autoloader — nothing more to do. Starting here instead? It is one of the files created in [Installing It](#installing-it) below. > **Sampling means the model can guess wrong on edge cases:** > > Five rows won't reveal a rare `null` field, an inconsistent date format three rows down, or a stray extra column. The suggested commands are a strong starting point, not a guarantee — run them, check the row count looks sane, and adjust if the data has surprises the sample didn't show. ## Key Takeaways **ai-data quick reference** * **Sample, not the whole file** — 3 JSON elements or 5 CSV rows, enough to see real field names * **Suggestions, not one answer** — 5 candidate commands, chosen by intent through `fzf` * **`print -z`, not auto-run** — the picked command lands on your prompt, editable, nothing executes until you press Enter * **`mlr` when available** — nicer group-by/aggregation syntax than `awk` if Miller is installed * **Registers into `ai`** — reachable as `ai data <file>` ## 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: 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-data.zsh** ```bash # ai-data <file.json|file.csv> — analyze a JSON or CSV file and suggest 5 # practical jq/awk commands tailored to its actual fields. Pick one via fzf; # instead of running it for you, it's loaded into your command line with # `print -z` so you can read it, edit it, and learn from it before hitting # Enter yourself. AI_COMMANDS[data]="ai-data <file.json|file.csv> — suggest jq/awk commands tailored to this file" AI_PARAMS[data]="file" ai-data() { local file="$1" if [[ -z "$file" || ! -f "$file" ]]; then echo "Usage: ai-data <file.json|file.csv>" >&2 return 1 fi local ext="${file:e:l}" local sample tool case "$ext" in json) tool="jq" if jq -e 'type == "array"' "$file" >/dev/null 2>&1; then sample=$(jq -c '.[0:3]' "$file") else sample=$(head -c 3000 "$file") fi ;; csv) if command -v mlr >/dev/null 2>&1; then tool="awk, or mlr (Miller — detected as installed, prefer it for group-by/aggregation)" else tool="awk (Miller not detected — stick to awk/sort/uniq idioms)" fi sample=$(head -n 6 "$file") ;; *) echo "ai-data: unsupported extension .$ext (expected .json or .csv)" >&2 return 1 ;; esac echo "→ Analyzing $file ($ext)..." >&2 local prompt="You are a data analyst who writes $tool one-liners. Below is a sample of a $ext file — the field names and shapes are real, only the row count is truncated. Suggest exactly 5 practical, ready-to-run commands a reader would actually want on THIS file: filtering, extracting specific fields, counting, grouping. Reference the real filename '$file' in every command. Output exactly one command per line, in this exact format and nothing else — no numbering, no markdown fences: COMMAND ||| one-line description of what it answers --- SAMPLE --- $sample" local suggestions suggestions=$(_ollama_query "$prompt") || return 1 if [[ -z "$suggestions" ]]; then echo "ai-data: no suggestions returned" >&2 return 1 fi # "print -r" and never echo: zsh's echo expands escape sequences, and the # "\n" of an awk printf would split the suggestion over two lines. if ! command -v fzf >/dev/null 2>&1; then print -r -- "$suggestions" return 0 fi local picked picked=$(print -r -- "$suggestions" | fzf --delimiter=' \|\|\| ' --with-nth=2 \ --prompt="ai-data > " --height=40% --reverse) [[ -z "$picked" ]] && return 0 local cmd="${picked%% \|\|\| *}" # Same reason here: without -r, "\n" would reach the prompt as a real newline. print -rz -- "$cmd" } ``` **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-data ``` `ai-data is a shell function from …/ai-data.zsh` means you're ready. `ai-data not found` means step 2 is missing. ## Conclusion `ai-data` is the function in this series I expect to teach me the most, precisely because it refuses to just hand me an answer — it hands me the command and lets me be the one who runs it. A few weeks of picking `jq` filters off an `fzf` list and reading them before pressing Enter, and I suspect I'll need this less than I do today. That would be a strange kind of success for an AI tool: the goal isn't to keep using it forever, it's to quietly make itself less necessary one `group_by` at a time. --- <!-- 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. ---