<!--
  canonical-url: https://www.avonture.be/fr/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 : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid

> Une fonction zsh qui transforme une description en langage naturel, ou un fichier de config existant comme un docker-compose.yaml, en diagramme Mermaid — affiché sous forme de bloc de code prêt à coller. Là où docker-python-mermaid exige un script écrit pour chaque format d'entrée, ai-diagram prend ce que vous avez sous la main.

---
![ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid](/img/v2/ai_diagram.webp)

**TL;DR**

Cet article ajoute `ai-diagram` à la série « Ollama daily use » : donnez-lui la description d'un processus en langage naturel, ou pointez-le vers un fichier comme un `docker-compose.yaml`, et il renvoie un diagramme Mermaid — un bloc de code, prêt à coller directement dans un fichier Markdown. C'est un pont volontaire entre cette série et mes articles doc-as-code : `docker-compose-viz` et `docker-python-mermaid` transforment tous deux des données *structurées* en diagrammes avec de vrais parsers ; `ai-diagram` couvre le cas qu'ils ne savent pas traiter — une description sans le moindre schéma, ou un fichier pour lequel vous n'avez pas envie d'écrire un parser juste pour esquisser un diagramme.

J'ai écrit plus d'articles « transformer X en diagramme » que je ne le croyais : [Diagrams as code](/blog/docker-diagram-as-code), [un graphe à partir de compose.yaml](/blog/docker-compose-viz), [la documentation as code avec Python et Mermaid](/blog/docker-python-mermaid). Tous partagent la même forme : un vrai parser lit une entrée structurée et une librairie dessine l'image. Ça fonctionne très bien — jusqu'au moment où ce que je veux représenter est un paragraphe que je viens de taper, ou un fichier de config ponctuel qui ne mérite pas qu'on lui écrive un parser.

## Démo — une description en langage naturel {#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
````

Pas de YAML, pas de schéma, juste une phrase — et un flowchart qui se ramifie sur la validation, la seule étape de cette phrase dont l'issue décide de la suite, tandis que l'enregistrement et l'envoi restent de simples étapes. Collé dans cette page, le bloc s'affiche ainsi :

```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]
```

## Démo — un fichier de config existant {#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
````

Trois services, leurs liens de dépendance et le volume nommé — lus directement dans la structure du fichier compose, avec chaque image et chaque port publié intégrés au label de son service :

```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
```

> **Relisez le Mermaid avant de lui faire confiance:**
>
> Pour tout ce qui part dans de la vraie documentation — un document d'onboarding, un architecture decision record — traitez ça comme la sortie de `docker-compose-viz` : un bon premier jet. Le modèle peut rater un cas limite (un `depends_on: condition: service_healthy`, un alias réseau moins évident) qu'un vrai parser comme celui de `docker-python-mermaid` n'aurait pas manqué.

## D'autres phrases à essayer {#more-sentences-to-try}

Trois descriptions de plus, chacune prête à copier dans votre propre terminal. Chaque lien ouvre le diagramme produit par `ai-diagram`, dans le Mermaid Live Editor.

### Tri des tickets de support {#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."
```

[Voir le diagramme obtenu sur mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtDdXN0b21lciBzdWJtaXRzIHN1cHBvcnQgdGlja2V0XSAtLT4gQntJcyB0aWNrZXQgdXJnZW50P31cbiAgICBCIC0tIFlFUyAtLT4gQ1tBc3NpZ24gdG8gc2VuaW9yIGFnZW50XVxuICAgIEIgLS0gTk8gLS0-IERbUGxhY2UgaW4gc3RhbmRhcmQgcXVldWVdXG4gICAgRCAtLT4gRVtTZW5kIGF1dG9tYXRlZCBjb25maXJtYXRpb24gZW1haWxdXG4gICAgQyAtLT4gRltFbmRdXG4gICAgRSAtLT4gRltFbmRdIn0)

### Connexion avec verrouillage de compte {#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."
```

[Voir le diagramme obtenu sur mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtVc2VyIGVudGVycyB1c2VybmFtZSBhbmQgcGFzc3dvcmRdIC0tPiBCe0NyZWRlbnRpYWxzIGNvcnJlY3Q_fVxuICAgIEIgLS0gWWVzIC0tPiBDW0dyYW50IGFjY2Vzc11cbiAgICBDIC0tPiBEW0Rpc3BsYXkgbWFpbiBkYXNoYm9hcmRdXG4gICAgQiAtLSBObyAtLT4gRXtBdHRlbXB0IGNvdW50IDwgMz99XG4gICAgRSAtLSBZZXMgLS0-IEZbSW5jcmVtZW50IGNvdW50ZXJdXG4gICAgRiAtLT4gR1tBc2sgdXNlciB0byB0cnkgYWdhaW5dXG4gICAgRSAtLSBObyAtLT4gSFtMb2NrIGFjY291bnRdXG4gICAgSCAtLT4gSVtTZW5kIHBhc3N3b3JkIHJlc2V0IGxpbmsgdG8gZW1haWxdIn0)

### Une commande e-commerce {#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."
```

[Voir le diagramme obtenu sur mermaid.live](https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtSZWNlaXZlIGUtY29tbWVyY2Ugb3JkZXJdIC0tPiBCe0lzIGl0ZW0gaW4gc3RvY2s_fVxuICAgIEIgLS0gTm8gLS0-IENbTm90aWZ5IGN1c3RvbWVyXVxuICAgIEMgLS0-IER7V2FudCB0byBiYWNrb3JkZXIgb3IgY2FuY2VsP31cbiAgICBEIC0tIENhbmNlbCAtLT4gRVtQcm9jZXNzIGZ1bGwgcmVmdW5kXVxuICAgIEQgLS0gQmFja29yZGVyIC0tPiBGW1Byb2NlZWQgd2l0aCBvcmRlcl1cbiAgICBCIC0tIFllcyAtLT4gR3tQYXltZW50IHByb2Nlc3NlZCBzdWNjZXNzZnVsbHk_fVxuICAgIEcgLS0gTm8gLS0-IEhbUHJvbXB0IHVzZXIgdG8gdXBkYXRlIHBheW1lbnQgbWV0aG9kXVxuICAgIEggLS0-IEdcbiAgICBHIC0tIFllcyAtLT4gSVtQYWNrIG9yZGVyXVxuICAgIEkgLS0-IEpbR2VuZXJhdGUgc2hpcHBpbmcgbGFiZWxdXG4gICAgSiAtLT4gS1tTaGlwIGl0ZW1dXG4gICAgSyAtLT4gTFtTZW5kIHRyYWNraW5nIG51bWJlciB0byBjdXN0b21lcl0ifQ)

## Sa place réelle à côté de `docker-python-mermaid` {#where-this-actually-sits-next-to-docker-python-mermaid}

> **Pas un remplaçant — un autre métier:**
>
> [`docker-python-mermaid`](/blog/docker-python-mermaid) parse lui aussi un `compose.yaml`, et c'est le bon outil pour un diagramme qui vit dans la documentation : un script écrit une fois pour ce format d'entrée, déterministe, régénéré à chaque changement. `ai-diagram` sert à l'autre moitié du travail : une description de la taille d'un message Slack, ou un format pour lequel vous n'avez aucun script — un `nginx.conf`, un workflow CI, un schéma SQL. Moins de précision, aucune installation — j'utilise les deux, selon que le diagramme doit rester synchronisé ou simplement exister cet après-midi.

## La fonction `ai-diagram` {#the-ai-diagram-function}

Pas besoin de copier quoi que ce soit pour l'instant : les fichiers `.zsh` montrés dans cet article sont là pour être lus, et [L'installer](#installing-it) à la fin les crée tous dans `~/.zsh/fns/` pour vous.

**\~/.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
}
````

Deux modes d'entrée, distingués par un seul test — si le premier argument est un fichier existant, c'est un fichier :

1. **Un fichier** — lu tel quel, et le prompt demande au modèle de déduire le type de diagramme du contexte (un `compose.yaml` implique un graphe de services, un listing de dossier implique un arbre, une spec OpenAPI implique un diagramme de séquence). Une courte liste de règles garde le graphe honnête : un nœud par entité, une arête par relation, les attributs comme une image ou un port dans les labels, le stockage dessiné comme un cylindre.
2. **Du texte libre** — tout ce qui suit la commande est traité comme la description, et le modèle choisit lui-même le type de diagramme Mermaid le plus adapté, en ajoutant une branche d'échec uniquement là où une étape est elle-même une vérification (une validation, une connexion, un paiement) — jamais un « email envoyé ? » inventé sur une action simple.

Dans les deux cas, la sortie est un bloc ` ```mermaid ` et rien d'autre. Le prompt demande exactement ça, et `_ai_strip_fences` ne conserve ensuite que les lignes du bloc avant que la fonction ne les réencadre elle-même — de sorte que même une réponse qui commence par « Here is your diagram: » ressort prête à coller dans un article, où ce blog la rend nativement.

Avant de la coller quelque part, vous voudrez la voir. La dernière ligne, `Preview: https://mermaid.live/edit#base64:…`, ouvre le diagramme dans le [Mermaid Live Editor](https://mermaid.live) avec un Ctrl+clic depuis le terminal — rendu, et modifiable si le modèle s'est trompé sur un label. L'éditeur lit tout son état dans la partie de l'URL après le `#` : le JSON `{"code": "…"}`, encodé en base64url, donc `jq` et `base64` suffisent à le construire. Ce fragment n'est jamais envoyé au serveur dans la requête ; le diagramme est décodé et dessiné par la page, dans votre navigateur. Le lien part sur stderr, donc `ai-diagram compose.yaml > docs/services.md` n'écrit toujours rien d'autre que le bloc Mermaid.

## Enregistrée dans le menu `ai` {#registered-in-the-ai-menu}

Deux lignes — `AI_COMMANDS[diagram]=...` et `AI_PARAMS[diagram]="text"` — la rendent accessible via `ai diagram <input>` ou directement via `ai-diagram`, aux côtés de toutes les autres fonctions de la série. Choisie depuis le menu `ai` seul, elle demande son entrée : tapez une description, ou un nom de fichier.

`_ollama_query`, `_ai_strip_fences` et le registre `AI_COMMANDS` vivent tous dans la fondation partagée `_ollama.zsh` présentée dans [ai-test](/blog/ollama-test-generator). Si vous avez déjà suivi un article précédent de cette série, elle se trouve déjà dans `~/.zsh/fns/` et est sourcée par votre autoloader — rien de plus à faire. Vous commencez ici ? C'est l'un des fichiers créés dans [L'installer](#installing-it) plus bas.

## À retenir {#key-takeaways}

**ai-diagram en bref**

* **Deux modes d'entrée** — un fichier existant, ou une description en texte libre, détectés automatiquement

* **Le type de diagramme est déduit** — flowchart, séquence, ER, classe, état — le modèle choisit selon le contexte

* **La sortie n'est qu'un bloc** — un bloc \`\`\`mermaid brut, réencadré par la fonction elle-même, destiné à être collé directement

* **Ctrl+clic pour prévisualiser** — un lien mermaid.live sur stderr ouvre le diagramme rendu, stdout reste prêt à coller

* **Complète, ne remplace pas, `docker-python-mermaid`** — un script pour les diagrammes qui doivent rester synchronisés ; ceci pour tout le reste

* **S'enregistre dans `ai`** — accessible via `ai diagram`

## L'installer {#installing-it}

Tout tourne sur votre machine : zsh, `curl` et `jq` (qui construit la requête envoyée à Ollama), et un serveur Ollama accessible — voyez [installing Ollama](/blog/ollama-installation) si vous n'en avez pas encore.

**1. Créez les fichiers.** Chaque fonction de cette série vit dans son propre fichier dans `~/.zsh/fns/`. La ligne de commande ci-dessous crée ce dossier et les deux fichiers d'un coup — sinon téléchargez le ZIP, ou créez chaque fichier à la main et collez son contenu. `_ollama.zsh` est la fondation partagée par toute la série : si un article précédent vous l'a déjà donnée, la copie ici est le même fichier.

**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. Chargez le dossier depuis `~/.zshrc`.** Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de `~/.zshrc` — elles sourcent chaque fichier `.zsh` du dossier, donc la prochaine fonction que vous ajouterez ne nécessitera aucun changement ici :

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

**3. Ouvrez un nouveau terminal** (ou lancez `exec zsh` dans celui en cours), et vérifiez que le shell connaît la nouvelle fonction :

```zsh
type ai-diagram
```

`ai-diagram is a shell function from …/ai-diagram.zsh` signifie que vous êtes prêt. `ai-diagram not found` signifie que l'étape 2 manque.

## Conclusion {#conclusion}

Mes articles doc-as-code ont toujours supposé que j'avais déjà quelque chose de structuré à donner à un parser. `ai-diagram` comble le vide en amont — la phrase que je viens de taper, le fichier que j'ai mais pour lequel je n'ai pas écrit de parser. Il ne remplacera pas [Diagrams as code](/blog/docker-diagram-as-code) ni [docker-python-mermaid](/blog/docker-python-mermaid) pour ce qui doit être exact et reproductible, mais pour le cas « laisse-moi juste esquisser ça vite fait » qui signifiait avant d'ouvrir un outil de whiteboard, c'est devenu une commande et un collage.
