<!--
  canonical-url: https://www.avonture.be/fr/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 : laissez un LLM local écrire vos one-liners jq et awk

> Une fonction zsh qui lit un fichier JSON ou CSV, comprend ses vrais champs et propose 5 commandes jq/awk pratiques adaptées à ce fichier précis — choisies via fzf, puis déposées sur votre ligne de commande (pas exécutées à votre place) pour que vous puissiez voir, modifier et comprendre la commande exacte avant d'appuyer sur Entrée.

---
![ai-data : laissez un LLM local écrire vos one-liners jq et awk](/img/v2/json.webp)

**TL;DR**

Cet article ajoute `ai-data` à la série « Ollama daily use » : pointez-la vers un fichier `.json` ou `.csv` et elle demande au modèle local 5 commandes `jq`/`awk` pratiques, adaptées aux noms de champs réels de ce fichier — pas des exemples génériques. Choisissez-en une avec `fzf` et, au lieu de l'exécuter pour vous, `ai-data` la dépose sur votre ligne de commande zsh avec `print -z`, prête à être lue, modifiée, puis seulement exécutée. L'objectif n'est pas juste d'avoir une réponse : c'est de repartir avec une commande que vous comprenez.

Mon usage de [`jq`](/blog/linux-jq) reste très basique ; je suis loin de savoir écrire une ligne avec `group_by` du premier coup. À chaque fois, c'est la même histoire : je dois bricoler (et perdre plusieurs minutes) juste pour obtenir ce que je veux — ou même… je ne sais même pas qu'une certaine fonctionnalité existe. Avec `awk`, c'est encore pire : je sais que ça existe et que c'est incroyablement puissant, mais la syntaxe est tout simplement impossible.

## Démo — JSON {#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
  }
]
```

Cinq suggestions, listées par ce à quoi elles répondent — chacune construite sur les vrais noms de champs (`active`, `birth_date`, `name`) que le modèle a vus dans l'échantillon, pas sur des placeholders d'un tutoriel `jq`. En choisissant la quatrième dans `fzf`, ce filtre `group_by` exact s'est retrouvé sur mon prompt ; je l'ai lu, il avait l'air correct, j'ai appuyé sur Entrée.

## Démo — CSV {#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
```

Le modèle a remarqué tout seul que `region` est la colonne intéressante pour un regroupement, et a répondu avec le classique pipeline `awk | sort | uniq -c` — le genre de ligne que je reconnais quand je la vois mais que je n'écris jamais de mémoire. L'avoir lue une fois sur mon propre prompt, je la comprends mieux que si j'avais juste vu la sortie.

## L'idée — des suggestions, pas des réponses {#the-idea--suggestions-not-answers}

La plupart des fonctions de cette série posent une question au modèle et affichent la réponse — [ai-diagram](/blog/ollama-ai-diagram) affiche un bloc Mermaid, prêt à coller. `ai-data` est différente volontairement : elle demande au modèle des **commandes candidates**, les présente via [`fzf`](/blog/linux-fzf-introduction), et quand vous en choisissez une, elle ne l'exécute pas — elle la dépose sur votre prompt, modifiable, grâce au `print -z` de zsh. Vous lisez le filtre `jq` ou le script `awk` exact avant qu'il ne touche vos données, et comme c'est vous qui appuyez sur Entrée, vous assimilez vraiment la syntaxe au lieu de simplement consommer la sortie.

> **print -z, en une phrase:**
>
> `print -z "some command"` pousse une chaîne dans le buffer d'édition de zsh — le prompt suivant l'affiche pré-tapée, exactement comme si vous l'aviez saisie vous-même, curseur prêt, rien d'exécuté.

## La fonction `ai-data` {#the-ai-data-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-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"
}
```

Étape par étape :

1. Détecter JSON ou CSV à partir de l'extension.
2. Construire un petit échantillon réel — pour le JSON, les 3 premiers éléments s'il s'agit d'un tableau (`jq -c '.[0:3]'`) ; pour le CSV, l'en-tête plus 5 lignes. Jamais le fichier entier : un CSV de 50 000 lignes n'a pas besoin d'en laisser plus de quelques-unes dans le prompt.
3. Pour le CSV, vérifier si [`mlr`](https://miller.readthedocs.io/) (Miller) est installé — si oui, on indique au modèle qu'il peut proposer `mlr` pour les regroupements/agrégations, ce qui se lit nettement mieux que l'équivalent `awk`. Sinon, il s'en tient à `awk`/`sort`/`uniq`.
4. Demander exactement 5 commandes, une par ligne, au format `COMMAND ||| description` — délibérément pas une sortie JSON, car parser cinq lignes de « texte avant un triple pipe » est trivial et évite le risque que le modèle emballe sa réponse dans des blocs markdown ou ajoute des commentaires.
5. Envoyer les cinq lignes dans `fzf`, en n'affichant que la description (`--with-nth=2`) pour que vous choisissiez par *intention*, et non en plissant les yeux sur de la syntaxe `jq`.
6. À la sélection, `print -rz` du premier champ de la commande choisie.

> **Pas de fzf ? Ça marche quand même:**
>
> Sans `fzf` installé, `ai-data` affiche simplement les cinq suggestions en texte brut — vous copiez celle que vous voulez manuellement. Même information, un confort en moins.

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

Une ligne — `AI_COMMANDS[data]=...` — accessible via `ai data <file>` ou directement avec `ai-data`, aux côtés de toutes les autres fonctions de la série.

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

> **L'échantillonnage veut dire que le modèle peut se tromper sur les cas limites:**
>
> Cinq lignes ne révéleront pas un champ `null` rare, un format de date incohérent trois lignes plus loin ou une colonne supplémentaire égarée. Les commandes proposées sont un bon point de départ, pas une garantie — exécutez-les, vérifiez que le nombre de lignes semble cohérent, et ajustez si les données réservent des surprises que l'échantillon ne montrait pas.

## Points clés {#key-takeaways}

**ai-data en bref**

* **Un échantillon, pas le fichier entier** — 3 éléments JSON ou 5 lignes CSV, assez pour voir les vrais noms de champs

* **Des suggestions, pas une seule réponse** — 5 commandes candidates, choisies par intention via `fzf`

* **`print -z`, pas d'exécution automatique** — la commande choisie arrive sur votre prompt, modifiable, rien ne s'exécute avant que vous appuyiez sur Entrée

* **`mlr` quand il est disponible** — syntaxe de regroupement/agrégation plus agréable que `awk` si Miller est installé

* **S'enregistre dans `ai`** — accessible via `ai data <file>`

## 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 joignable — voyez [installer 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 commande ci-dessous crée ce dossier et les deux fichiers d'un coup — ou téléchargez le ZIP, ou créez chaque fichier à la main et collez-y 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-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. 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 demandera 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-data
```

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

## Conclusion {#conclusion}

`ai-data` est la fonction de cette série dont j'attends le plus en termes d'apprentissage, justement parce qu'elle refuse de me donner une réponse toute faite — elle me donne la commande et me laisse être celui qui la lance. Quelques semaines à piocher des filtres `jq` dans une liste `fzf` et à les lire avant d'appuyer sur Entrée, et je soupçonne que j'en aurai moins besoin qu'aujourd'hui. Ce serait une forme étrange de succès pour un outil d'IA : l'objectif n'est pas de l'utiliser pour toujours, c'est de se rendre discrètement moins nécessaire, un `group_by` à la fois.
