Merge branch 'dev/todo'

[IMP] todo: safe startup, Claude command sync, English-first commits

8 commits. make runs todo.sh, whose name says a menu opens; install.sh keeps
choosing the interpreter, now probed by running code, since python -V
vouched for a venv built on another machine that could not start. The
install, which deletes an existing .venv.erplibre, runs only on a yes. The
Claude command list shows missing and outdated copies, their diff, and
redeploys them; commit messages open in English with a French title under
--- FR ---, enforced by the hook; language and Fork move to Configuration.
Checked: unit suites of each piece pass, 108 in test_todo and i18n.

--- FR ---

[IMP] todo : démarrage sûr, commandes Claude à jour, commits en anglais

8 commits. make lance todo.sh, dont le nom annonce un menu ; install.sh garde
le choix de l'interpréteur, sondé désormais en exécutant du code, puisque
python -V cautionnait un venv bâti sur une autre machine incapable de
démarrer. L'installation, qui supprime un .venv.erplibre existant, ne part
que sur un oui. La liste des commandes Claude montre les copies absentes et
périmées, leur diff, et les redéploie ; les messages de commit s'ouvrent en
anglais avec un titre français sous --- FR ---, que le hook exige ; la
langue et Fork passent dans Configuration.
Vérifié : suites unitaires de chaque partie au vert, 108 pour test_todo et i18n.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-09-25 01:46:07 -04:00
commit 9967a3b758
19 changed files with 780 additions and 127 deletions

View file

@ -90,9 +90,9 @@ liste, et elle est vraie demain.
- Branches : `develop` (développement), `master` (production)
- Pas de submodules Git — utilise **Google Repo** pour les addons
- Manifests XML dans `manifest/` pour chaque version Odoo
- Format de commit : `[TYPE] portée : sujet`, sujet à l'impératif, 72
caractères au plus. Tags réellement utilisés : `[UPD]`, `[FIX]`, `[ADD]`,
`[IMP]`, `[REF]`.
- Format de commit : `[TYPE] scope: subject`, sujet **en anglais**, à
l'impératif, 72 caractères au plus. Tags réellement utilisés : `[UPD]`,
`[FIX]`, `[ADD]`, `[IMP]`, `[REF]`.
- **Nommer les fichiers à l'indexation, jamais `git add -A`** : `private/` et
`tasks/` ne sont pas suivis EXPRÈS, et un ratissage les commit. Il emporte
aussi ce qui est en cours ailleurs dans le checkout, sous un sujet qui ne le
@ -117,14 +117,17 @@ Le sujet résume le commit ENTIER, pas sa plus grosse pièce. S'il lui faut un
Si le travail n'entre décidément pas dans une phrase de 72 caractères, ne pas
en écrire une amputée : des **mots-clés qui résument**, séparés par des
virgules, en disent plus dans la même place — `[FIX] proxmox : pmxcfs à terre,
pvesm muet, diagnostic à la source`. C'est un repli, pas un défaut : la phrase
reste préférable quand elle tient.
virgules, en disent plus dans la même place — `[FIX] proxmox: pmxcfs down,
pvesm silent, diagnosis at the source`. C'est un repli, pas un défaut : la
phrase reste préférable quand elle tient.
Un garde-fou refuse le mécanique. Sur le sujet : tag absent, plus de 72
caractères, ouverture sur une citation. Sur le corps : plus de 10 lignes pour
une langue, une adresse IP, un courriel, un chemin de compte. Ce qui reste un
jugement — « ce corps raconte-t-il l'enquête » — n'est vérifié par personne.
une langue, une adresse IP, un courriel, un chemin de compte. Sur l'ordre des
langues : un marqueur `--- EN ---`, une section française qui ne s'ouvre pas
sur le sujet traduit sous le même tag. Que le sujet soit bien en anglais
reste à l'auteur. Ce qui reste un jugement — « ce corps raconte-t-il
l'enquête » — n'est vérifié par personne.
```bash
git config core.hooksPath script/git/hooks # une fois par clone
@ -141,8 +144,9 @@ Trois exigences, sans exception — `AI_POLICY.md` en donne la raison :
- Trailer `Assisted-by: <modèle>`, une ligne par modèle. C'est **binaire** :
il y a eu IA ou non, aucun seuil à apprécier.
- **Jamais** d'IA dans `Co-authored-by:` — ce champ est réservé aux humains.
- Corps **bilingue** : le corps, puis `--- FR ---` (ou `--- EN ---`, le
marqueur nomme la langue de ce qui SUIT), puis la traduction.
- Message **bilingue, l'anglais d'abord** : le sujet et le corps en anglais,
puis `--- FR ---`, puis le sujet traduit en français sous le même tag, puis
le corps traduit. `--- EN ---` n'existe plus : l'ordre ne varie jamais.
Court et direct : **8 lignes par langue**, 10 est un plafond. Le corps dit
pourquoi c'était nécessaire, puis s'arrête. Rien de ce que le diff montre

View file

@ -162,13 +162,16 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere
- The tooling virtual environment `.venv.erplibre` runs Python 3.14.7, independently of the Odoo one (3.12.10 for Odoo 18.0). `install_erplibre.sh` builds it through `install_venv.sh` and `EL_PYTHON_PROVIDER` instead of the system `python3`
- `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted
- `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal
- `make` and `make todo` run `./todo.sh`, a launcher whose name says a menu opens and not an installation; it hands every argument to `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install, which runs only on a yes typed in a terminal (`o`, `oui`, `y`, `yes`) since it can delete `.venv.erplibre`. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal
- The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/`
- Debian 11 is dropped from the deployment catalogue: its LTS ended, and its security suite is neither served nor archived — the index it still publishes names packages whose pool no longer holds the file, so apt stops before installing git. Debian 13 takes its place, its cloud image always being the latest point release
- Every container image is built on bookworm, whatever the Odoo version. The base is `python:<version>-slim-<suite>`: the interpreter comes from the official image, never from Debian, and the bookworm variants exist down to 3.7.17. The wkhtmltopdf build follows the suite — bullseye's requires libssl1.1, absent from bookworm
- The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs
- `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black
- The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses
- The list of Claude Code commands in `TODO › Execute › GPT code › Claude configs` compares each ERPLibre template in `conf/` with its copy in `~/.claude/commands/`: a command not installed is shown, an outdated copy is marked with its count of added and removed lines, and a command that comes from elsewhere is labelled as such. On a yes it prints the diff, then redeploys the outdated copies, keeping the git name and e-mail `/commit` carried
- A commit message opens on an English subject and an English body; `--- FR ---` then opens the French section, which starts with the subject translated under the same tag. The `commit-msg` hook refuses a `--- EN ---` marker and a French section without that title, and checks the title like a subject. `/commit` and `/git_prepare_merge` follow the same order
- TODO menus: the language is set only from Configuration, the duplicate entry in Execute is gone, and Fork moves from the main menu to Configuration. The main menu now numbers Telemetry 4 and Configuration 5. The language chooser shows a flag per language
<!-- [fr] -->
@ -187,13 +190,16 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs
- L'environnement virtuel d'outillage `.venv.erplibre` tourne en Python 3.14.7, indépendamment de celui d'Odoo (3.12.10 pour Odoo 18.0). `install_erplibre.sh` le bâtit par `install_venv.sh` et `EL_PYTHON_PROVIDER` plutôt qu'avec le `python3` du système
- `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé
- `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal
- `make` et `make todo` lancent `./todo.sh`, dont le nom annonce un menu et non une installation ; il passe chaque argument à `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation, qui ne part que sur un oui tapé au terminal (`o`, `oui`, `y`, `yes`) puisqu'elle peut supprimer `.venv.erplibre`. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal
- L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/`
- Debian 11 quitte le catalogue de déploiement : son LTS est terminé, et sa suite de sécurité n'est ni servie ni archivée — l'index qu'elle publie encore nomme des paquets dont le pool ne porte plus le fichier, et apt s'arrête avant d'installer git. Debian 13 la remplace, son image cloud étant toujours le dernier point de version
- Toutes les images de conteneur reposent sur bookworm, quelle que soit la version d'Odoo. La base est `python:<version>-slim-<version Debian>` : l'interpréteur vient de l'image officielle, jamais de Debian, et les variantes bookworm existent jusqu'à 3.7.17. Le build de wkhtmltopdf suit la version — celui de bullseye réclame libssl1.1, absente de bookworm
- Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame
- `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black
- Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses
- La liste des commandes Claude Code de `TODO › Execute › GPT code › Claude configs` compare chaque gabarit d'ERPLibre de `conf/` à sa copie de `~/.claude/commands/` : une commande non installée est affichée, une copie périmée est marquée de son compte de lignes ajoutées et retirées, et une commande venue d'ailleurs est signalée comme telle. Sur un oui, elle affiche le diff, puis redéploie les copies périmées en gardant le nom et le courriel git que portait `/commit`
- Un message de commit s'ouvre sur un sujet et un corps en anglais ; `--- FR ---` ouvre ensuite la section française, qui commence par le sujet traduit sous le même tag. Le hook `commit-msg` refuse un marqueur `--- EN ---` et une section française sans ce titre, et juge ce titre comme un sujet. `/commit` et `/git_prepare_merge` suivent le même ordre
- Menus de TODO : la langue se règle seulement depuis Configuration, l'entrée en double dans Execute disparaît, et Fork quitte le menu principal pour Configuration. Le menu principal numérote désormais Télémétrie 4 et Configuration 5. Le choix de la langue montre un drapeau par langue
<!-- [en] -->
## Fixed
@ -262,6 +268,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- An interrupted image download resumes on the same mirror, three attempts, by a `Range` asking for the rest, instead of throwing away what it received: a VM image weighs half a gigabyte, and a truncated `.part` used to send the reader back to a hand-typed `curl -C -`. A server ignoring the Range returns the whole file, and the transfer restarts from zero rather than doubling the bytes already there
- The cache's certificate variables name a bundle only when the file exists. A recent Fedora lacks `/etc/pki/tls/certs/ca-bundle.crt`, and pointing pip at a missing path made it refuse EVERY download, including what has nothing to do with the cache. None found writes no variable, and pip keeps its own certificate set
- `make version` names both Pythons, Odoo's and the tooling's. A bare label suggested the repository had one, and the figure shown was not that of the venv the reader works in
- `make` no longer launches a `.venv.erplibre` built on another machine, such as a checkout mounted over the network, which died on « No module named 'encodings' ». `install.sh` reads the interpreter's version by running code: `python -V` answers before the standard library loads, so it vouched for an interpreter that could not start. Such a venv is now reported as built elsewhere, with the command that rebuilds it
<!-- [fr] -->
@ -326,6 +333,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un téléchargement d'image coupé se reprend sur le même miroir, trois essais, par un `Range` qui demande la suite, au lieu de jeter ce qu'il a reçu : une image de VM pèse un demi-gigaoctet, et un `.part` tronqué renvoyait le lecteur à un `curl -C -` tapé à la main. Un serveur qui ignore le Range rend le fichier entier, et le transfert repart alors de zéro plutôt que de doubler les octets déjà là
- Les variables de certificat du cache ne désignent un faisceau que s'il existe. Une Fedora récente n'a pas `/etc/pki/tls/certs/ca-bundle.crt`, et viser un chemin absent faisait refuser à pip TOUT téléchargement, y compris ce qui n'a rien à voir avec le cache. Aucun trouvé n'écrit aucune variable, et pip garde son propre jeu de certificats
- `make version` nomme les deux Python, celui d'Odoo et celui de l'outillage. Un libellé nu laissait croire que le dépôt n'en a qu'un, et le chiffre affiché n'était pas celui du venv dans lequel on travaille
- `make` ne lance plus un `.venv.erplibre` bâti sur une autre machine, comme un checkout monté par le réseau, qui mourait sur « No module named 'encodings' ». `install.sh` lit la version de l'interpréteur en exécutant du code : `python -V` répond avant le chargement de la bibliothèque standard, et se portait donc garant d'un interpréteur incapable de démarrer. Un tel venv est désormais signalé comme bâti ailleurs, avec la commande qui le rebâtit
<!-- [en] -->
## Removed

View file

@ -82,13 +82,16 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs
- L'environnement virtuel d'outillage `.venv.erplibre` tourne en Python 3.14.7, indépendamment de celui d'Odoo (3.12.10 pour Odoo 18.0). `install_erplibre.sh` le bâtit par `install_venv.sh` et `EL_PYTHON_PROVIDER` plutôt qu'avec le `python3` du système
- `.venv.erplibre` sur un Python incompatible est SUPPRIMÉ puis rebâti ; ce qu'on y avait posé à la main part avec lui. Le venv d'Odoo est conservé quand seule sa version diffère, et rebâti seulement s'il est hors service : le rebâtir refait une installation Poetry entière. Un répertoire sans `pyvenv.cfg` n'est jamais effacé
- `make` et `make todo` passent par `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal
- `make` et `make todo` lancent `./todo.sh`, dont le nom annonce un menu et non une installation ; il passe chaque argument à `install.sh`, qui choisit un interpréteur capable de LIRE le code avant de le lancer : `.venv.erplibre` s'il porte la bonne version, sinon le `python3` du système s'il est assez récent, sinon l'installation, qui ne part que sur un oui tapé au terminal (`o`, `oui`, `y`, `yes`) puisqu'elle peut supprimer `.venv.erplibre`. Un système plus ancien que `conf/python-erplibre-version` s'arrêterait autrement sur une erreur de syntaxe levée avant qu'aucun garde puisse nommer la commande à taper. TODO se relance ensuite dans `.venv.erplibre`, ou propose de lancer `install_erplibre.sh` en terminal
- L'image Docker de production bâtit `.venv.erplibre` sur le Python d'Odoo et s'arrête quand ce Python ne sait pas lire `script/`
- Debian 11 quitte le catalogue de déploiement : son LTS est terminé, et sa suite de sécurité n'est ni servie ni archivée — l'index qu'elle publie encore nomme des paquets dont le pool ne porte plus le fichier, et apt s'arrête avant d'installer git. Debian 13 la remplace, son image cloud étant toujours le dernier point de version
- Toutes les images de conteneur reposent sur bookworm, quelle que soit la version d'Odoo. La base est `python:<version>-slim-<version Debian>` : l'interpréteur vient de l'image officielle, jamais de Debian, et les variantes bookworm existent jusqu'à 3.7.17. Le build de wkhtmltopdf suit la version — celui de bullseye réclame libssl1.1, absente de bookworm
- Le PATCH ne borne que le venv d'Odoo, dont le pyproject exige `>=3.12.10,<3.13`. Pour celui de l'outillage, la majeure.mineure suffit : exiger le patch écartait le Python d'une distribution d'un cran en retard — NixOS 25.11 livre 3.14.2 quand conf demande 3.14.7 — et faisait COMPILER CPython à pyenv pour une différence que rien ne réclame
- `make format` choisit le formateur d'après le contexte de chaque fichier : un module Odoo garde isort et black en `py37`, la série la plus ancienne encore supportée, quand l'outillage de ce dépôt passe par ruff, réglé une fois dans `.ruff.toml`. ruff suit les versions de CPython, là où black 24.8.0 s'arrête à `py313`, et son tri d'imports remplace isort ; c'est aussi ce qu'emploie la norme OCA depuis qu'elle a quitté black
- Les dépôts que Google Repo rapatrie sous `script/` sont écartés de ce formatage, chemin nommé compris : les reformater écrirait dans l'historique d'autrui. `target-version` y reste à `py310`, parce que les hooks git portent `#!/usr/bin/env python3` et qu'une distribution livre encore 3.10 — à partir de 3.14, ruff écrirait `except A, B:` sans parenthèses
- La liste des commandes Claude Code de `TODO › Execute › GPT code › Claude configs` compare chaque gabarit d'ERPLibre de `conf/` à sa copie de `~/.claude/commands/` : une commande non installée est affichée, une copie périmée est marquée de son compte de lignes ajoutées et retirées, et une commande venue d'ailleurs est signalée comme telle. Sur un oui, elle affiche le diff, puis redéploie les copies périmées en gardant le nom et le courriel git que portait `/commit`
- Un message de commit s'ouvre sur un sujet et un corps en anglais ; `--- FR ---` ouvre ensuite la section française, qui commence par le sujet traduit sous le même tag. Le hook `commit-msg` refuse un marqueur `--- EN ---` et une section française sans ce titre, et juge ce titre comme un sujet. `/commit` et `/git_prepare_merge` suivent le même ordre
- Menus de TODO : la langue se règle seulement depuis Configuration, l'entrée en double dans Execute disparaît, et Fork quitte le menu principal pour Configuration. Le menu principal numérote désormais Télémétrie 4 et Configuration 5. Le choix de la langue montre un drapeau par langue
## Corrigé
@ -153,6 +156,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un téléchargement d'image coupé se reprend sur le même miroir, trois essais, par un `Range` qui demande la suite, au lieu de jeter ce qu'il a reçu : une image de VM pèse un demi-gigaoctet, et un `.part` tronqué renvoyait le lecteur à un `curl -C -` tapé à la main. Un serveur qui ignore le Range rend le fichier entier, et le transfert repart alors de zéro plutôt que de doubler les octets déjà là
- Les variables de certificat du cache ne désignent un faisceau que s'il existe. Une Fedora récente n'a pas `/etc/pki/tls/certs/ca-bundle.crt`, et viser un chemin absent faisait refuser à pip TOUT téléchargement, y compris ce qui n'a rien à voir avec le cache. Aucun trouvé n'écrit aucune variable, et pip garde son propre jeu de certificats
- `make version` nomme les deux Python, celui d'Odoo et celui de l'outillage. Un libellé nu laissait croire que le dépôt n'en a qu'un, et le chiffre affiché n'était pas celui du venv dans lequel on travaille
- `make` ne lance plus un `.venv.erplibre` bâti sur une autre machine, comme un checkout monté par le réseau, qui mourait sur « No module named 'encodings' ». `install.sh` lit la version de l'interpréteur en exécutant du code : `python -V` répond avant le chargement de la bibliothèque standard, et se portait donc garant d'un interpréteur incapable de démarrer. Un tel venv est désormais signalé comme bâti ailleurs, avec la commande qui le rebâtit
## Retiré

View file

@ -82,13 +82,16 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere
- The tooling virtual environment `.venv.erplibre` runs Python 3.14.7, independently of the Odoo one (3.12.10 for Odoo 18.0). `install_erplibre.sh` builds it through `install_venv.sh` and `EL_PYTHON_PROVIDER` instead of the system `python3`
- `.venv.erplibre` on an incompatible Python is DELETED and rebuilt; whatever was installed in it by hand goes with it. Odoo's venv is kept when it merely differs in version, and rebuilt only when it is unusable: rebuilding it redoes a whole Poetry install. A directory without `pyvenv.cfg` is never deleted
- `make` and `make todo` go through `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal
- `make` and `make todo` run `./todo.sh`, a launcher whose name says a menu opens and not an installation; it hands every argument to `install.sh`, which picks an interpreter able to READ the code before running it: `.venv.erplibre` on the right version, else a recent enough system `python3`, else the install, which runs only on a yes typed in a terminal (`o`, `oui`, `y`, `yes`) since it can delete `.venv.erplibre`. A system older than `conf/python-erplibre-version` would otherwise stop on a syntax error raised before any guard could name the command to type. TODO then relaunches itself in `.venv.erplibre`, or offers to run `install_erplibre.sh` in a terminal
- The production Docker image builds `.venv.erplibre` on Odoo's Python and stops when that Python cannot parse `script/`
- Debian 11 is dropped from the deployment catalogue: its LTS ended, and its security suite is neither served nor archived — the index it still publishes names packages whose pool no longer holds the file, so apt stops before installing git. Debian 13 takes its place, its cloud image always being the latest point release
- Every container image is built on bookworm, whatever the Odoo version. The base is `python:<version>-slim-<suite>`: the interpreter comes from the official image, never from Debian, and the bookworm variants exist down to 3.7.17. The wkhtmltopdf build follows the suite — bullseye's requires libssl1.1, absent from bookworm
- The PATCH bounds only Odoo's venv, whose pyproject requires `>=3.12.10,<3.13`. For the tooling one, the major.minor is enough: requiring the patch turned away a distribution's Python one step behind — NixOS 25.11 ships 3.14.2 where conf asks 3.14.7 — and made pyenv COMPILE CPython for a difference nothing needs
- `make format` picks the formatter from each file's context: an Odoo module keeps isort and black on `py37`, the series still supported going down that far, while this repository's own tooling goes through ruff, configured once in `.ruff.toml`. ruff follows CPython's versions, where black 24.8.0 stops at `py313`, and its import sorting replaces isort; it is also what the OCA standard uses since it left black
- The repositories that Google Repo checks out under `script/` are excluded from that formatting, a named path included: reformatting them would write in someone else's history. `target-version` stays at `py310` there, because the git hooks carry `#!/usr/bin/env python3` and a distribution still ships 3.10 — from 3.14 on, ruff would write `except A, B:` without parentheses
- The list of Claude Code commands in `TODO › Execute › GPT code › Claude configs` compares each ERPLibre template in `conf/` with its copy in `~/.claude/commands/`: a command not installed is shown, an outdated copy is marked with its count of added and removed lines, and a command that comes from elsewhere is labelled as such. On a yes it prints the diff, then redeploys the outdated copies, keeping the git name and e-mail `/commit` carried
- A commit message opens on an English subject and an English body; `--- FR ---` then opens the French section, which starts with the subject translated under the same tag. The `commit-msg` hook refuses a `--- EN ---` marker and a French section without that title, and checks the title like a subject. `/commit` and `/git_prepare_merge` follow the same order
- TODO menus: the language is set only from Configuration, the duplicate entry in Execute is gone, and Fork moves from the main menu to Configuration. The main menu now numbers Telemetry 4 and Configuration 5. The language chooser shows a flag per language
## Fixed
@ -153,6 +156,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- An interrupted image download resumes on the same mirror, three attempts, by a `Range` asking for the rest, instead of throwing away what it received: a VM image weighs half a gigabyte, and a truncated `.part` used to send the reader back to a hand-typed `curl -C -`. A server ignoring the Range returns the whole file, and the transfer restarts from zero rather than doubling the bytes already there
- The cache's certificate variables name a bundle only when the file exists. A recent Fedora lacks `/etc/pki/tls/certs/ca-bundle.crt`, and pointing pip at a missing path made it refuse EVERY download, including what has nothing to do with the cache. None found writes no variable, and pip keeps its own certificate set
- `make version` names both Pythons, Odoo's and the tooling's. A bare label suggested the repository had one, and the figure shown was not that of the venv the reader works in
- `make` no longer launches a `.venv.erplibre` built on another machine, such as a checkout mounted over the network, which died on « No module named 'encodings' ». `install.sh` reads the interpreter's version by running code: `python -V` answers before the standard library loads, so it vouched for an interpreter that could not start. Such a venv is now reported as built elsewhere, with the command that rebuilds it
## Removed

View file

@ -257,8 +257,10 @@ make
`make` and `./install.sh` start TODO through the interpreter that can read it:
`.venv.erplibre` when it carries the right version, otherwise the system
`python3` when it is recent enough, otherwise the install itself — a system
older than `conf/python-erplibre-version` cannot parse the code, so it goes
straight to the install rather than stopping on a syntax error. When the
older than `conf/python-erplibre-version` cannot parse the code, so it offers
the install rather than stopping on a syntax error. The install runs only on a
yes typed in a terminal (`o`, `oui`, `y` or `yes`); Enter alone, or no
terminal, means no. When the
environment is missing but the system Python suffices, TODO offers to run
`./script/install/install_erplibre.sh` (in a terminal) or prints that command. The install builds the environment
through `EL_PYTHON_PROVIDER` (mise or pyenv); an existing `.venv.erplibre` on
@ -270,8 +272,10 @@ by hand goes with it.
`make` et `./install.sh` lancent TODO par l'interpréteur capable de le lire :
`.venv.erplibre` quand il porte la bonne version, sinon le `python3` du système
s'il est assez récent, sinon l'installation elle-même — un système plus ancien
que `conf/python-erplibre-version` ne sait pas analyser le code, donc on va
droit à l'installation plutôt que de s'arrêter sur une erreur de syntaxe. Quand
que `conf/python-erplibre-version` ne sait pas analyser le code, donc
l'installation est proposée plutôt que de s'arrêter sur une erreur de syntaxe.
Elle ne part que sur un oui tapé au terminal (`o`, `oui`, `y` ou `yes`) ;
Entrée seule, ou l'absence de terminal, vaut non. Quand
l'environnement manque mais que le Python du système suffit, TODO propose de
lancer `./script/install/install_erplibre.sh` (dans un terminal) ou affiche
cette commande. L'installation bâtit l'environnement par

View file

@ -129,8 +129,10 @@ make
`make` et `./install.sh` lancent TODO par l'interpréteur capable de le lire :
`.venv.erplibre` quand il porte la bonne version, sinon le `python3` du système
s'il est assez récent, sinon l'installation elle-même — un système plus ancien
que `conf/python-erplibre-version` ne sait pas analyser le code, donc on va
droit à l'installation plutôt que de s'arrêter sur une erreur de syntaxe. Quand
que `conf/python-erplibre-version` ne sait pas analyser le code, donc
l'installation est proposée plutôt que de s'arrêter sur une erreur de syntaxe.
Elle ne part que sur un oui tapé au terminal (`o`, `oui`, `y` ou `yes`) ;
Entrée seule, ou l'absence de terminal, vaut non. Quand
l'environnement manque mais que le Python du système suffit, TODO propose de
lancer `./script/install/install_erplibre.sh` (dans un terminal) ou affiche
cette commande. L'installation bâtit l'environnement par

View file

@ -127,8 +127,10 @@ make
`make` and `./install.sh` start TODO through the interpreter that can read it:
`.venv.erplibre` when it carries the right version, otherwise the system
`python3` when it is recent enough, otherwise the install itself — a system
older than `conf/python-erplibre-version` cannot parse the code, so it goes
straight to the install rather than stopping on a syntax error. When the
older than `conf/python-erplibre-version` cannot parse the code, so it offers
the install rather than stopping on a syntax error. The install runs only on a
yes typed in a terminal (`o`, `oui`, `y` or `yes`); Enter alone, or no
terminal, means no. When the
environment is missing but the system Python suffices, TODO offers to run
`./script/install/install_erplibre.sh` (in a terminal) or prints that command. The install builds the environment
through `EL_PYTHON_PROVIDER` (mise or pyenv); an existing `.venv.erplibre` on

View file

@ -2,8 +2,11 @@
# TODO #
########
# Par install.sh, et non todo.py directement : lui seul choisit un interpréteur
# capable de LIRE le code avant de le lancer, et pose le venv s'il manque.
# Par todo.sh, et non todo.py directement : la chaîne passe par install.sh,
# qui choisit un interpréteur capable de LIRE le code avant de le lancer et
# pose le venv s'il manque. Le nom dit ce qui se passe : make affiche la
# recette, et « ./install.sh » y donnait à lire une installation là où l'on
# ouvre un menu.
.PHONY: todo
todo:
./install.sh
./todo.sh

View file

@ -77,7 +77,7 @@ The first five cover every one of the last 400 commits. Reach for `[REM]`,
### Format
```
[TAG] scope: short description in imperative mood
[TAG] scope: short description in imperative mood, in English
Explain WHY the change was made — the diff already shows what. Name the
failure mode, and what was measured rather than assumed, in words a
@ -87,11 +87,18 @@ Wrap at 80 characters.
--- FR ---
[TAG] portée : le sujet, traduit en français
The same body, translated.
Assisted-by: {MODEL}
```
The subject line is in ENGLISH, and the English body follows it: together
they are the English section. `--- FR ---` then opens the French section,
which starts with the subject translated, under the same tag, and goes on
with the translated body.
### The subject line
The subject is read a hundred times for every time the body is read: in
@ -108,9 +115,9 @@ built on them reads well and tells the next reader nothing:
| Instead of | Write |
|-----------|-------|
| `[FIX] nettoyage : les enfants s'en vont avec leur rebond` | `[FIX] nettoyage : les entrées ssh qui rebondissent par une VM effacée` |
| `[FIX] proxmox : « il manque le stockage » était le symptôme, pas la cause` | `[FIX] proxmox : signaler pmxcfs à terre, et non « aucun stockage »` |
| `[FIX] migration: un module fautif n'emporte plus tout le lot` | `[FIX] migration: isoler l'échec d'un module dans la désinstallation` |
| `[FIX] cleanup: the children leave with their bounce` | `[FIX] cleanup: ssh entries that bounce through a deleted VM` |
| `[FIX] proxmox: "storage is missing" was the symptom, not the cause` | `[FIX] proxmox: report pmxcfs down, not "no storage"` |
| `[FIX] migration: a faulty module no longer takes the whole batch` | `[FIX] migration: isolate a module's failure during uninstall` |
The scope is not the subject. `proxmox` says WHERE; the words after the colon
must say WHAT. A subject that works with its scope removed is usually the
@ -130,8 +137,8 @@ one — write **keywords that summarise**. A comma-separated list of the nouns
that matter says more in the space than half a sentence does:
```
[FIX] proxmox : pmxcfs à terre, pvesm muet, diagnostic à la source
[ADD] migration : copies de site, index doublés, réglages perdus
[FIX] proxmox: pmxcfs down, pvesm silent, diagnosis at the source
[ADD] migration: site copies, doubled indexes, lost settings
```
That form is a fallback, not a default. Prefer the sentence when it fits.
@ -139,7 +146,10 @@ That form is a fallback, not a default. Prefer the sentence when it fits.
**The guard rail.** `script/git/hooks/commit-msg` refuses a subject with no
tag, one over 72 characters, and one opening on a quotation. It reads the body
too: over ten lines for one language, an IP address, an e-mail, and a
`/home/<account>/` path. Install the hook with `git config core.hooksPath script/git/hooks`; `git commit
`/home/<account>/` path. And the order of the languages: a `--- EN ---`
marker, or a French section that does not open on the translated subject
under the same tag. Whether the subject is really in English stays yours to
check. Install the hook with `git config core.hooksPath script/git/hooks`; `git commit
--no-verify` passes a legitimate exception. It checks only what is mechanical
— whether the subject says what the code is about, and whether the body tells
the story instead of the mechanism, stay judgements, and the tests above are
@ -187,12 +197,15 @@ goes.
### Bilingual body
Every AI-assisted commit carries its body twice. Write it first in whichever
language you were thinking in, then the marker, then the translation.
Every AI-assisted commit carries its subject and its body twice, English
first: the subject and the body under it are in English, then `--- FR ---`,
then the subject translated into French under the same tag, then the body
translated. The order never varies — `--- EN ---` is refused — so a reader of
`git log --oneline` always gets English, and a French reader finds the French
title where the French section starts.
The marker names the language of what FOLLOWS it: `--- FR ---` after an
English body, `--- EN ---` after a French one. One marker per commit, never
both.
The translated subject obeys the same rules as the subject: 72 characters,
no opening quotation. It does not count against the body's line budget.
Translate, do not re-summarise: a reader of either language must get the same
reasoning, the same measured figures and the same caveats.
@ -259,12 +272,14 @@ the subject you just wrote, and no others. When one file carries two subjects,
git status --porcelain
git add script/module/thing.py test/test_thing.py
git -c user.name="Your Name" -c user.email="your@email.com" commit -F - <<'MSG'
[TAG] scope: description
[TAG] scope: description in English
Explain WHY here.
--- FR ---
[TAG] portée : la description en français
The same body, translated.
Assisted-by: {MODEL}

View file

@ -117,12 +117,14 @@ The shape, as this repository writes it:
```
Merge branch '<branch>'
[TAG] scope: what the branch delivers, imperative, 72 characters maximum
[TAG] scope: what the branch delivers, in English, 72 characters maximum
<N> commits. Why the branch existed: the failure mode it removes, the
figure that bounds it, what was verified and how. Wrap at 80 characters.
--- EN ---
--- FR ---
[TAG] portée : ce que livre la branche, traduit en français
The same body, translated.
@ -143,14 +145,15 @@ commits the branch carries, then say what they add up to. No bullet list, no
per-commit rundown: `git log <base>..HEAD` already gives that, and a body
repeating it teaches nothing.
The bilingual rule holds — body, then the marker naming the language of what
FOLLOWS, then the translation — as does the ban on naming an AI in
The bilingual rule holds as for a commit — the tagged line and the body in
English, then `--- FR ---`, then the tagged line translated under the same
tag, then the translated body — as does the ban on naming an AI in
`Co-authored-by:`.
**The hook does not check this one.** `script/git/hooks/commit-msg` skips any
message beginning with `Merge `, along with `Revert `, `fixup!` and `squash!`.
Length, addresses and account paths pass unchallenged here, so the discipline
is entirely yours.
Length, addresses, account paths and the order of the languages pass
unchallenged here, so the discipline is entirely yours.
### 3. Hand it over

View file

@ -42,6 +42,20 @@ el_assez_recent() {
= "${ATTENDUE}" ]
}
# Vrai sur une réponse affirmative lue au terminal, en français ou en anglais :
# o, oui, y, yes, sans égard à la casse. Le défaut est NON : une frappe sur
# Entrée ne lance pas une installation qui peut détruire un venv. Sans
# terminal sur l'entrée, rien ne peut consentir, et la réponse est non.
el_confirmer() {
[ -t 0 ] || return 1
printf '%s [o/N] ' "$1"
read -r reponse || return 1
case "$(printf '%s' "${reponse}" | tr '[:upper:]' '[:lower:]')" in
o | oui | y | yes) return 0 ;;
*) return 1 ;;
esac
}
PYTHON_VENV="./${VENV}/bin/python"
if [ -n "${VENV}" ] && [ -x "${PYTHON_VENV}" ] \
&& el_assez_recent "$(el_mineure "${PYTHON_VENV}")"; then
@ -64,7 +78,17 @@ if command -v python3 > /dev/null 2>&1 \
exec ./script/todo/todo.py "$@"
fi
SYSTEME="$(el_mineure python3)"
echo "Python ${VOULUE:-du depot} est requis pour lire le code de TODO ;"
echo " ce systeme livre $(el_mineure python3 2> /dev/null || echo 'aucun python3')."
echo " Installation du venv d'outillage, qui le pose :"
echo " ce systeme livre ${SYSTEME:-aucun python3 utilisable}."
echo " L'installation du venv d'outillage le pose :"
echo " ./script/install/install_erplibre.sh"
if [ -d "./${VENV}" ]; then
echo " ${VENV} existant sera SUPPRIME puis rebati, avec ce qui y a ete"
echo " pose a la main."
fi
if ! el_confirmer "Lancer l'installation ?"; then
echo "Installation non lancee : TODO ne demarre pas."
exit 1
fi
exec ./script/todo/source_todo.sh "$@"

View file

@ -8,7 +8,13 @@ d'emploi dans `conf/template_claude_commands_commit.md`. Ce module en vérifie
la part MÉCANIQUE, sur deux plans :
- le sujet : le tag, la longueur, l'ouverture sur une citation ;
- le corps : sa longueur par langue, et la donnée identifiante.
- le corps : sa longueur par langue, et la donnée identifiante ;
- l'ordre des langues : le sujet et le corps qui le suit sont en anglais,
« --- FR --- » ouvre la traduction, et celle-ci commence par le sujet
traduit, sous le même tag.
Que le sujet soit VRAIMENT en anglais ne se vérifie pas : seul l'ordre des
moitiés et la présence du titre traduit le sont.
Le reste — « ce sujet dit-il sur quoi porte le code », « ce corps raconte-t-il
l'enquête plutôt que le fonctionnement » — est un jugement, et aucun hook ne
@ -64,6 +70,8 @@ GENERATED = ("Merge ", "Revert ", "fixup!", "squash!", "amend!")
QUOTES = ("«", '"', "'", "`", "“", "‘")
# Le marqueur nomme la langue de ce qui SUIT : il sépare les deux moitiés.
# Seul « --- FR --- » est admis, l'anglais venant toujours en premier ; « EN »
# est reconnu pour être refusé avec un message, et non ignoré.
MARKER = re.compile(r"^---\s*(FR|EN)\s*---\s*$", re.MULTILINE)
# `git commit --cleanup=scissors` laisse le diff en clair sous cette ligne :
@ -133,14 +141,26 @@ def _moities(body: str) -> list:
return [part for part in MARKER.split(body) if part not in ("FR", "EN")]
def _tag_of(subject: str):
"""Le tag qui ouvre le sujet, sans crochets ; None s'il n'y en a pas."""
for candidate in TAGS:
if subject.startswith(f"[{candidate}]"):
return candidate
return None
def _titre_traduit(moitie: str) -> str:
"""La première ligne non vide d'une moitié traduite : son titre."""
for ligne in moitie.split("\n"):
if ligne.strip():
return ligne.strip()
return ""
def _check_subject(subject: str) -> list:
problems = []
tag = None
for candidate in TAGS:
if subject.startswith(f"[{candidate}]"):
tag = candidate
break
tag = _tag_of(subject)
if tag is None:
problems.append(
t("the subject must start with a tag: %s")
@ -179,8 +199,12 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list:
"""
problems = []
for moitie in _moities(sans_trailers):
for rang, moitie in enumerate(_moities(sans_trailers)):
pleines = [ligne for ligne in moitie.split("\n") if ligne.strip()]
# Le titre traduit est le sujet de sa langue : il ne pèse pas sur le
# budget du corps, pas plus que le sujet anglais.
if rang and pleines and _tag_of(pleines[0].strip()):
pleines = pleines[1:]
if len(pleines) > MAX_BODY:
problems.append(
t(
@ -243,11 +267,45 @@ def _check_body(sans_trailers: str, avec_trailers: str) -> list:
return problems
def _check_langues(subject: str, sans_trailers: str) -> list:
"""L'anglais d'abord, puis « --- FR --- » et le sujet traduit.
Un message sans marqueur n'est pas bilingue et n'est pas jugé ici.
"""
marqueurs = MARKER.findall(sans_trailers)
if not marqueurs:
return []
if "EN" in marqueurs:
return [
t(
"the marker is « --- EN --- ». The subject and the body under it\n"
" are in English; « --- FR --- » opens the French translation."
)
]
tag = _tag_of(subject)
titre = _titre_traduit(_moities(sans_trailers)[-1])
if tag is None or not titre.startswith(f"[{tag}]"):
return [
t(
"the French section must open on the translated subject,\n"
" under the same tag: %s"
)
% ("[%s] …" % (tag or "TAG"))
]
return [
t("French subject: %s") % probleme
for probleme in _check_subject(titre)
]
def check(message: str) -> list:
"""Rend la liste des problèmes. Vide si le message passe."""
subject = subject_of(message)
if not subject or subject.startswith(GENERATED):
return []
return _check_subject(subject) + _check_body(
body_of(message), body_of(message, trailers=True)
sans_trailers = body_of(message)
return (
_check_subject(subject)
+ _check_langues(subject, sans_trailers)
+ _check_body(sans_trailers, body_of(message, trailers=True))
)

View file

@ -5,6 +5,7 @@
import ast
import configparser
import datetime
import difflib
import importlib.util
import inspect
import json
@ -214,9 +215,9 @@ class TODO(
def _ask_language(self):
if not lang_is_configured():
print()
print("Choisir la langue / Choose language:")
print("[1] Francais")
print("[2] English")
print("🌍 Choisir la langue / Choose language:")
print("[1] 🇫🇷 Français")
print("[2] 🇬🇧 English")
choice = ""
while choice not in ("1", "2"):
choice = input("Select / Choisir : ").strip()
@ -227,9 +228,9 @@ class TODO(
def _change_language(self):
print()
print(t("Choose language / Choisir la langue") + ":")
print(f"[1] {t('French')}")
print(f"[2] {t('English')}")
print("🌍 " + t("Choose language / Choisir la langue") + ":")
print(f"[1] 🇫🇷 {t('French')}")
print(f"[2] 🇬🇧 {t('English')}")
print(f"[0] {t('Back')}")
choice = ""
while choice not in ("0", "1", "2"):
@ -252,9 +253,8 @@ class TODO(
[1] {t("Execute")}
[2] {t("Install")}
[3] {t("Assistant")}
[4] {t("Fork - Open TODO in a new tab")}
[5] {t("Navigation telemetry (TUI)")}
[6] {t("Configuration")}
[4] {t("Navigation telemetry (TUI)")}
[5] {t("Configuration")}
[0] 🚪 {t("Quit")}
"""
while True:
@ -280,15 +280,8 @@ class TODO(
elif status == "3":
self.prompt_assistant()
elif status == "4":
# cmd = (
# f"gnome-terminal --tab -- bash -c 'source"
# f" ./{VENV_ERPLIBRE}/bin/activate;make todo'"
# )
cmd = "make todo"
self.execute.exec_command_live(cmd, source_erplibre=True)
elif status == "5":
self._todo_telemetry_tui()
elif status == "6":
elif status == "5":
self.prompt_configuration()
# elif status == "3" or status == "install":
# print("install")
@ -345,9 +338,6 @@ class TODO(
[13] {t("Deploy - Deploy ERPLibre locally")}
[14] {t("Network - Network tools")}
[15] {t("Security - Dependency security audit")}
── {t("Preferences")} ──
[16] {t("Language - Change language / Changer la langue")}
[0] {t("Back")}
"""
while True:
@ -415,10 +405,6 @@ class TODO(
status = self.prompt_execute_security()
if status is not False:
return
elif status == "16":
status = self._change_language()
if status is not False:
return
else:
print(t("Command not found !"))
@ -868,6 +854,7 @@ class TODO(
f"({self._pref_label('migration_ui')})"
)
},
{"prompt_description": t("Fork - Open TODO in a new tab")},
{"section": t("Maintenance")},
{"prompt_description": t("Reset all preferences")},
]
@ -884,6 +871,10 @@ class TODO(
elif status == "4":
self._pref_edit("migration_ui")
elif status == "5":
self.execute.exec_command_live(
"make todo", source_erplibre=True
)
elif status == "6":
n = todo_prefs.reset()
print(f"✅ {t('Preferences reset')} ({n})")
else:
@ -3380,29 +3371,172 @@ class TODO(
else:
print(t("Command not found !"))
def _list_claude_commands(self):
commands_dir = os.path.expanduser("~/.claude/commands")
if not os.path.isdir(commands_dir):
print(t("No custom commands found in ~/.claude/commands/"))
return
files = sorted(
f for f in os.listdir(commands_dir) if f.endswith(".md")
# Les commandes `/…` qu'ERPLibre déploie, et le gabarit de `conf/` dont
# chacune est la copie. La liste et l'écran de contexte en dérivent l'état
# de chaque copie ; le menu nomme les siens en littéraux, que les tests
# relient à `conf/`.
_CLAUDE_COMMAND_TEMPLATES = {
"commit": "template_claude_commands_commit.md",
"git_prepare_merge": "template_claude_commands_git_prepare_merge.md",
"todo_add_command": "template_claude_commands_todo_add_command.md",
"todo_generate_code": "template_claude_commands_todo_generate_code.md",
"todo_plan_max": "template_claude_commands_todo_plan_max.md",
}
_CLAUDE_COMMANDS_DIR = "~/.claude/commands"
# La ligne où le déploiement de /commit substitue le nom et le courriel.
_IDENTITE_GIT = re.compile(
r'user\.name="(?P<nom>[^"]*)" -c user\.email="(?P<courriel>[^"]*)"'
)
def _claude_template_path(self, gabarit):
"""Le chemin d'un gabarit de commande dans `conf/`."""
return os.path.join(self._claude_context_root(), "conf", gabarit)
@staticmethod
def _lignes_stables(chemin):
"""Les lignes d'un fichier, hors celles qui portent l'identité git.
Le déploiement de /commit y substitue le nom et le courriel : les
comparer déclarerait périmée toute copie personnalisée.
"""
with open(chemin, encoding="utf-8", errors="replace") as fh:
return [x for x in fh if "user.name=" not in x]
def _claude_command_diff(self, deployed, template):
"""Le diff unifié de la copie déployée vers le gabarit.
Rend une liste de lignes, vide quand les deux concordent hors
identité git. Les lignes « + » sont ce qu'un redéploiement apporte,
les lignes « - » ce qu'il retire.
"""
return list(
difflib.unified_diff(
self._lignes_stables(deployed),
self._lignes_stables(template),
fromfile=t("installed copy"),
tofile=t("template"),
)
)
if not files:
print(t("No custom commands found in ~/.claude/commands/"))
return
print(t("Claude Code custom commands:"))
print("-" * 50)
for f in files:
filepath = os.path.join(commands_dir, f)
mtime = os.path.getmtime(filepath)
date_str = datetime.datetime.fromtimestamp(mtime).strftime(
@staticmethod
def _compte_diff(diff):
"""(ajoutées, retirées) d'un diff unifié, en-têtes exclus."""
plus = sum(
1 for x in diff if x.startswith("+") and not x.startswith("+++")
)
moins = sum(
1 for x in diff if x.startswith("-") and not x.startswith("---")
)
return plus, moins
def _redeploy_claude_command(self, command_name, template_filename):
"""Réécrit une copie déployée depuis son gabarit, SANS question.
L'identité git que la copie portait est reportée dans la nouvelle :
une mise à jour ne redemande ni le nom ni le courriel. Rend True si
le fichier est écrit.
"""
dest_file = os.path.join(
os.path.expanduser(self._CLAUDE_COMMANDS_DIR),
f"{command_name}.md",
)
try:
with open(
self._claude_template_path(template_filename),
encoding="utf-8",
) as fh:
content = fh.read()
with open(dest_file, encoding="utf-8", errors="replace") as fh:
identite = self._IDENTITE_GIT.search(fh.read())
if identite:
content = content.replace(
"Your Name", identite.group("nom")
).replace("your@email.com", identite.group("courriel"))
with open(dest_file, "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as e:
print(f"{t('Error creating file: ')}{e}")
return False
print(f"{t('File created successfully: ')}{dest_file}")
return True
def _list_claude_commands(self):
"""Les commandes `/…` : installées ou non, à jour ou à redéployer.
Chaque gabarit d'ERPLibre est comparé à sa copie de
~/.claude/commands ; une commande installée qui n'en vient pas est
listée à part. Propose ensuite le diff des copies périmées, puis leur
redéploiement, chaque fois sur un oui explicite.
"""
commands_dir = os.path.expanduser(self._CLAUDE_COMMANDS_DIR)
installees = set()
if os.path.isdir(commands_dir):
installees = {
f[:-3] for f in os.listdir(commands_dir) if f.endswith(".md")
}
def date_de(nom):
mtime = os.path.getmtime(os.path.join(commands_dir, f"{nom}.md"))
return datetime.datetime.fromtimestamp(mtime).strftime(
"%Y-%m-%d %H:%M"
)
name = f[:-3] # remove .md
print(f" /{name:<30} {date_str}")
print("-" * 50)
print(f"{t('Total:')} {len(files)}")
largeur = 70
print(t("Claude Code custom commands:"))
print("-" * largeur)
perimees, absentes = [], []
for nom, gabarit in sorted(self._CLAUDE_COMMAND_TEMPLATES.items()):
modele = self._claude_template_path(gabarit)
if nom not in installees:
absentes.append(nom)
etat = t("command not installed")
print(f" /{nom:<26} {'-':<16} {etat}")
continue
copie = os.path.join(commands_dir, f"{nom}.md")
if not os.path.isfile(modele):
etat = t("not in the repository")
else:
diff = self._claude_command_diff(copie, modele)
if diff:
perimees.append((nom, diff))
plus, moins = self._compte_diff(diff)
etat = f"{t('redeploy needed')} (+{plus} -{moins})"
else:
etat = t("up to date")
print(f" /{nom:<26} {date_de(nom):<16} {etat}")
autres = sorted(installees - set(self._CLAUDE_COMMAND_TEMPLATES))
for nom in autres:
etat = t("not from ERPLibre")
print(f" /{nom:<26} {date_de(nom):<16} {etat}")
print("-" * largeur)
print(
t("Total: %s installed, %s to redeploy, %s not installed")
% (len(installees), len(perimees), len(absentes))
)
if absentes:
print(t("A command not installed is deployed from this menu."))
if not perimees:
return
print()
if self._is_yes(input(t("Show the differences? [o/N] "))):
for nom, diff in perimees:
print(f"\n/{nom}")
for ligne in diff:
print(ligne.rstrip("\n"))
print()
question = t("Redeploy the %s outdated command(s)? [o/N] ") % len(
perimees
)
if not self._is_yes(input(question)):
print(t("Nothing to do."))
return
for nom, _ in perimees:
self._redeploy_claude_command(
nom, self._CLAUDE_COMMAND_TEMPLATES[nom]
)
def _claude_context_root(self):
"""La racine du dépôt, deux niveaux au-dessus de ce fichier."""
@ -3429,13 +3563,8 @@ class TODO(
return t("not in the repository")
if not os.path.isfile(deployed):
return t("missing")
def stables(chemin):
with open(chemin, encoding="utf-8", errors="replace") as fh:
return [x for x in fh if "user.name=" not in x]
try:
if stables(deployed) == stables(template):
if not self._claude_command_diff(deployed, template):
return t("up to date")
except OSError:
return t("missing")
@ -3501,17 +3630,7 @@ class TODO(
print(f"{t('Skills'):<22} .claude/skills/ {t('missing')}")
print(f"{t('Deployed commands'):<22} ~/.claude/commands/")
gabarits = {
"commit": "template_claude_commands_commit.md",
"git_prepare_merge": (
"template_claude_commands_git_prepare_merge.md"
),
"todo_add_command": "template_claude_commands_todo_add_command.md",
"todo_generate_code": (
"template_claude_commands_todo_generate_code.md"
),
"todo_plan_max": "template_claude_commands_todo_plan_max.md",
}
gabarits = self._CLAUDE_COMMAND_TEMPLATES
for nom, gabarit in sorted(gabarits.items()):
etat = self._claude_command_state(
os.path.expanduser(f"~/.claude/commands/{nom}.md"),

View file

@ -100,10 +100,6 @@ TRANSLATIONS = {
"fr": "🪙 RTK - Proxy CLI pour réduire la consommation de tokens LLM",
"en": "🪙 RTK - CLI proxy to reduce LLM token consumption",
},
"Language - Change language / Changer la langue": {
"fr": "🌍 Langue - Changer la langue / Change language",
"en": "🌍 Language - Change language / Changer la langue",
},
# Deploy section
"Deploy - Deploy ERPLibre locally": {
"fr": "🚀 Déploiement - Déployer ERPLibre localement",
@ -185,10 +181,6 @@ TRANSLATIONS = {
"fr": "🌐 Déploiement, réseau & sécurité",
"en": "🌐 Deployment, network & security",
},
"Preferences": {
"fr": "🎨 Préférences",
"en": "🎨 Preferences",
},
"Deployment": {
"fr": "🚀 Déploiement",
"en": "🚀 Deployment",
@ -1192,7 +1184,7 @@ TRANSLATIONS = {
"en": "Choose language / Choisir la langue",
},
"French": {
"fr": "Francais",
"fr": "Français",
"en": "French",
},
"English": {
@ -1387,6 +1379,38 @@ TRANSLATIONS = {
"fr": "Aucune commande personnalisée trouvée dans ~/.claude/commands/",
"en": "No custom commands found in ~/.claude/commands/",
},
"command not installed": {
"fr": "non installée",
"en": "not installed",
},
"not from ERPLibre": {
"fr": "hors ERPLibre",
"en": "not from ERPLibre",
},
"installed copy": {
"fr": "copie installée",
"en": "installed copy",
},
"template": {
"fr": "gabarit",
"en": "template",
},
"Total: %s installed, %s to redeploy, %s not installed": {
"fr": "Total : %s installées, %s à redéployer, %s non installées",
"en": "Total: %s installed, %s to redeploy, %s not installed",
},
"A command not installed is deployed from this menu.": {
"fr": "Une commande non installée se déploie depuis ce menu.",
"en": "A command not installed is deployed from this menu.",
},
"Show the differences? [o/N] ": {
"fr": "Afficher les différences ? [o/N] ",
"en": "Show the differences? [y/N] ",
},
"Redeploy the %s outdated command(s)? [o/N] ": {
"fr": "Redéployer les %s commande(s) périmée(s) ? [o/N] ",
"en": "Redeploy the %s outdated command(s)? [y/N] ",
},
"Claude Code custom commands:": {
"fr": "Commandes personnalisées Claude Code :",
"en": "Claude Code custom commands:",
@ -9446,6 +9470,24 @@ TRANSLATIONS = {
"en": "the subject opens on a quotation. A screen message is\n"
" evidence: it belongs in the body. The subject names the cause.",
},
"the marker is « --- EN --- ». The subject and the body under it\n"
" are in English; « --- FR --- » opens the French translation.": {
"fr": "le marqueur est « --- EN --- ». Le sujet et le corps qui le suit\n"
" sont en anglais ; « --- FR --- » ouvre la traduction française.",
"en": "the marker is « --- EN --- ». The subject and the body under it\n"
" are in English; « --- FR --- » opens the French translation.",
},
"the French section must open on the translated subject,\n"
" under the same tag: %s": {
"fr": "la section française doit s'ouvrir sur le sujet traduit,\n"
" sous le même tag : %s",
"en": "the French section must open on the translated subject,\n"
" under the same tag: %s",
},
"French subject: %s": {
"fr": "sujet français : %s",
"en": "French subject: %s",
},
"the body is %s lines for one language, %s at most.\n"
" The body says why it was necessary, then stops.\n"
" The investigation, the dated measurements and the dead ends go\n"

View file

@ -151,7 +151,55 @@ class TestCeQuiEstRefuse(unittest.TestCase):
def _message(corps):
return "[FIX] portée : quelque chose\n\n" + corps + "\n"
return "[FIX] scope: something\n\n" + corps + "\n"
# Le sujet de _message, traduit : ce qui ouvre la section française.
TITRE_FR = "[FIX] portée : quelque chose"
class TestLesLangues(unittest.TestCase):
"""L'anglais d'abord, puis « --- FR --- » et le sujet traduit."""
def test_anglais_puis_francais_avec_titre_traduit(self):
corps = f"Why, in English.\n\n--- FR ---\n\n{TITRE_FR}\n\nPourquoi."
self.assertEqual([], check(_message(corps)))
def test_un_message_sans_marqueur_nest_pas_juge(self):
self.assertEqual([], check(_message("Why, in one language.")))
@EN_FRANCAIS
def test_le_marqueur_en_est_refuse(self):
corps = "Pourquoi, en français.\n\n--- EN ---\n\nWhy, in English."
problemes = check(_message(corps))
self.assertEqual(1, len(problemes))
self.assertIn("--- EN ---", problemes[0])
@EN_FRANCAIS
def test_la_section_francaise_sans_titre(self):
problemes = check(_message("Why.\n\n--- FR ---\n\nPourquoi."))
self.assertEqual(1, len(problemes))
self.assertIn("sujet traduit", problemes[0])
self.assertIn("[FIX] …", problemes[0])
def test_le_titre_traduit_porte_le_meme_tag(self):
corps = (
"Why.\n\n--- FR ---\n\n[ADD] portée : autre chose\n\nPourquoi."
)
self.assertEqual(1, len(check(_message(corps))))
@EN_FRANCAIS
def test_le_titre_traduit_suit_les_regles_du_sujet(self):
titre = "[FIX] portée : " + "é" * MAX
corps = f"Why.\n\n--- FR ---\n\n{titre}\n\nPourquoi."
problemes = check(_message(corps))
self.assertEqual(1, len(problemes))
self.assertTrue(problemes[0].startswith("sujet français :"), problemes[0])
def test_le_titre_traduit_ne_compte_pas_dans_le_corps(self):
moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY))
corps = f"{moitie}\n\n--- FR ---\n\n{TITRE_FR}\n{moitie}"
self.assertEqual([], check(_message(corps)))
class TestLeCorps(unittest.TestCase):
@ -166,7 +214,7 @@ class TestLeCorps(unittest.TestCase):
def test_dix_lignes_par_langue_passent(self):
moitie = "\n".join(f"ligne {n}" for n in range(MAX_BODY))
corps = f"{moitie}\n\n--- FR ---\n\n{moitie}"
corps = f"{moitie}\n\n--- FR ---\n\n{TITRE_FR}\n\n{moitie}"
self.assertEqual([], check(_message(corps)))
def test_onze_lignes_pour_une_langue_sont_refusees(self):

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La question qu'install.sh pose avant l'installation du venv d'outillage.
L'installation peut SUPPRIMER un .venv.erplibre existant : elle ne part que
sur un oui explicite. La réponse se donne en français ou en anglais (o, oui,
y, yes, toute casse) ; Entrée seule vaut non. Sans terminal sur l'entrée,
personne ne peut consentir, et la réponse est non sans question posée.
"""
import os
import select
import subprocess
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
SCRIPT = (
"source <(sed -n '/^el_confirmer()/,/^}/p' install.sh); "
'el_confirmer "Lancer ?"'
)
def repondre_au_terminal(reponse):
"""Lance el_confirmer sur un pseudo-terminal, y tape la réponse.
Rend le code de sortie et ce que le terminal a affiché.
"""
maitre, esclave = os.openpty()
proc = subprocess.Popen(
["bash", "-c", SCRIPT],
cwd=RACINE,
stdin=esclave,
stdout=esclave,
stderr=esclave,
)
os.close(esclave)
os.write(maitre, (reponse + "\n").encode())
code = proc.wait(timeout=10)
lu = b""
while select.select([maitre], [], [], 0.2)[0]:
try:
morceau = os.read(maitre, 4096)
except OSError:
break
if not morceau:
break
lu += morceau
os.close(maitre)
return code, lu.decode(errors="replace")
class TestConfirmer(unittest.TestCase):
def test_oui_en_francais_et_en_anglais(self):
for reponse in ("o", "O", "oui", "Oui", "y", "Y", "yes", "YES"):
with self.subTest(reponse=reponse):
self.assertEqual(repondre_au_terminal(reponse)[0], 0)
def test_entree_seule_et_non_refusent(self):
for reponse in ("", "n", "non", "no", "ouais", "x"):
with self.subTest(reponse=reponse):
self.assertEqual(repondre_au_terminal(reponse)[0], 1)
def test_la_question_annonce_le_defaut(self):
self.assertIn("Lancer ? [o/N]", repondre_au_terminal("n")[1])
def test_sans_terminal_la_reponse_est_non(self):
proc = subprocess.run(
["bash", "-c", SCRIPT],
cwd=RACINE,
input="o\n",
capture_output=True,
text=True,
check=False,
)
self.assertEqual(proc.returncode, 1)
self.assertNotIn("[o/N]", proc.stdout)
if __name__ == "__main__":
unittest.main()

View file

@ -568,6 +568,119 @@ class TestClaudeCommandTemplates(unittest.TestCase):
self.assertIn(f"name: {command}\n", text)
class TestListeCommandesClaude(unittest.TestCase):
"""La liste des commandes `/…` compare chaque gabarit à sa copie.
Elle montre les commandes non installées, les copies périmées avec leur
compte de lignes, et celles qui ne viennent pas d'ERPLibre ; elle propose
le diff puis le redéploiement, qui garde l'identité git de la copie.
"""
IDENTITE = 'user.name="Nom Inventé" -c user.email="nom@exemple.invalid"'
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.dossier = self._tmp.name
self.todo = TODO()
self.todo._CLAUDE_COMMANDS_DIR = self.dossier
gabarit = Path(
"conf", TODO._CLAUDE_COMMAND_TEMPLATES["commit"]
).read_text(encoding="utf-8")
self.gabarit_commit = gabarit
personnalise = gabarit.replace(
'user.name="Your Name" -c user.email="your@email.com"',
self.IDENTITE,
)
# /commit à jour hors identité ; /todo_plan_max périmée d'une ligne ;
# /perso hors ERPLibre ; les autres absentes.
self._ecrire("commit", personnalise)
plan = Path(
"conf", TODO._CLAUDE_COMMAND_TEMPLATES["todo_plan_max"]
).read_text(encoding="utf-8")
self._ecrire("todo_plan_max", plan + "ligne ajoutée à la main\n")
self._ecrire("perso", "une commande à soi\n")
def tearDown(self):
self._tmp.cleanup()
def _ecrire(self, nom, texte):
Path(self.dossier, f"{nom}.md").write_text(texte, encoding="utf-8")
def _lister(self, *reponses):
sortie = io.StringIO()
with (
patch("builtins.input", side_effect=list(reponses)),
patch("sys.stdout", sortie),
):
self.todo._list_claude_commands()
return sortie.getvalue()
def _ligne(self, sortie, nom):
return next(x for x in sortie.splitlines() if f"/{nom} " in x)
def test_etat_de_chaque_commande(self):
sortie = self._lister("n", "n")
self.assertIn(
todo_i18n.t("up to date"), self._ligne(sortie, "commit")
)
self.assertIn("(+0 -1)", self._ligne(sortie, "todo_plan_max"))
self.assertIn(
todo_i18n.t("command not installed"),
self._ligne(sortie, "git_prepare_merge"),
)
self.assertIn(
todo_i18n.t("not from ERPLibre"), self._ligne(sortie, "perso")
)
def test_le_diff_montre_ce_que_le_redeploiement_retire(self):
sortie = self._lister("o", "n")
self.assertIn("-ligne ajoutée à la main", sortie)
def test_un_refus_n_ecrit_rien(self):
avant = Path(self.dossier, "todo_plan_max.md").read_text()
self._lister("n", "n")
apres = Path(self.dossier, "todo_plan_max.md").read_text()
self.assertEqual(avant, apres)
def test_le_redeploiement_remet_le_gabarit(self):
self._lister("n", "o")
attendu = Path(
"conf", TODO._CLAUDE_COMMAND_TEMPLATES["todo_plan_max"]
).read_text(encoding="utf-8")
self.assertEqual(
attendu, Path(self.dossier, "todo_plan_max.md").read_text()
)
def test_le_redeploiement_garde_l_identite_git(self):
self._ecrire(
"commit",
self.gabarit_commit.replace(
'user.name="Your Name" -c user.email="your@email.com"',
self.IDENTITE,
)
+ "vieille ligne\n",
)
self._lister("n", "o")
texte = Path(self.dossier, "commit.md").read_text(encoding="utf-8")
self.assertIn(self.IDENTITE, texte)
self.assertNotIn("vieille ligne", texte)
self.assertNotIn("Your Name", texte)
def test_tout_a_jour_ne_pose_aucune_question(self):
for nom in ("todo_plan_max", "perso"):
os.remove(Path(self.dossier, f"{nom}.md"))
with patch("builtins.input") as question:
with patch("sys.stdout", io.StringIO()):
self.todo._list_claude_commands()
question.assert_not_called()
def test_la_table_couvre_les_gabarits_du_menu(self):
"""Une commande ajoutée au menu sans entrée dans la table resterait
invisible à la liste et à l'écran de contexte."""
nommes = set(TestClaudeCommandTemplates._deployed_templates())
self.assertEqual(nommes, set(TODO._CLAUDE_COMMAND_TEMPLATES.values()))
class TestClaudePlugins(unittest.TestCase):
"""Le menu des plugins Claude Code.

View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La chaîne de lancement de TODO tient-elle en un seul endroit ?
« make todo » passe par todo.sh, qui passe par install.sh, qui choisit un
interpréteur capable de LIRE le code avant de le lui donner. Trois fichiers,
mais UNE seule décision : elle vit dans install.sh, qui est aussi le point
d'entrée d'une machine neuve.
Ce que ces tests gardent :
- todo.sh reste un NOM, et non une seconde copie du choix d'interpréteur.
Deux copies dérivent, et celle qui dérive est toujours celle qu'on ne lit
pas — ici, le chemin qu'emprunte « make » ;
- les arguments traversent. Un lanceur qui les avale rend « ./todo.sh » et
« ./install.sh » différents sans le dire ;
- todo.sh se place dans son propre répertoire : « make -C » et un appel par
chemin absolu partent d'ailleurs, et les chemins relatifs d'install.sh
n'y survivraient pas.
"""
import re
import subprocess
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
TODO_SH = RACINE / "todo.sh"
INSTALL_SH = RACINE / "install.sh"
MAKEFILE = (RACINE / "conf/make.todo.Makefile").read_text(encoding="utf-8")
SOURCE = TODO_SH.read_text(encoding="utf-8")
class TestCible(unittest.TestCase):
def test_make_todo_lance_todo_sh(self):
"""La recette s'affiche : elle doit nommer ce qu'elle fait."""
recette = re.search(r"^todo:\n\t(.+)$", MAKEFILE, re.MULTILINE)
self.assertIsNotNone(recette, MAKEFILE)
self.assertEqual("./todo.sh", recette.group(1).strip())
def test_make_n_rend_la_meme_chose(self):
sortie = subprocess.run(
["make", "-n", "todo"],
cwd=RACINE,
capture_output=True,
text=True,
)
self.assertEqual(0, sortie.returncode, sortie.stderr)
self.assertIn("./todo.sh", sortie.stdout)
class TestLanceur(unittest.TestCase):
def test_il_est_executable(self):
self.assertTrue(TODO_SH.exists())
self.assertTrue(TODO_SH.stat().st_mode & 0o111)
def test_il_delegue_a_install_sh(self):
self.assertTrue(INSTALL_SH.exists())
self.assertIn("./install.sh", SOURCE)
def test_les_arguments_traversent(self):
self.assertIn('"$@"', SOURCE)
def test_il_se_place_dans_son_repertoire(self):
self.assertIn('cd "$(dirname "$0")"', SOURCE)
def test_il_ne_choisit_pas_l_interprete_lui_meme(self):
"""Le choix vit dans install.sh. Le recopier ici en ferait deux, et
celle qui dérive serait celle que « make » emprunte."""
for marqueur in ("python3", "pyvenv.cfg", "sort -V", "bin/python"):
with self.subTest(marqueur=marqueur):
self.assertNotIn(marqueur, SOURCE)
def test_il_reste_court(self):
"""Au-delà d'une poignée de lignes, ce n'est plus un nom mais une
seconde implémentation."""
code = [
ligne
for ligne in SOURCE.splitlines()
if ligne.strip() and not ligne.lstrip().startswith("#")
]
self.assertLessEqual(len(code), 6, code)
class TestSyntaxe(unittest.TestCase):
def test_les_deux_scripts_se_lisent(self):
for script in (TODO_SH, INSTALL_SH):
with self.subTest(script=script.name):
sortie = subprocess.run(
["bash", "-n", str(script)],
capture_output=True,
text=True,
)
self.assertEqual(0, sortie.returncode, sortie.stderr)
if __name__ == "__main__":
unittest.main()

18
todo.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Lancer TODO.
#
# Le travail est dans install.sh, qui choisit un interpréteur capable de LIRE
# le code avant de le lui donner. Ce fichier n'ajoute qu'un NOM, et c'est sa
# seule raison d'être : make affiche la recette qu'il exécute, et « ./install.sh »
# y donnait à lire une installation là où l'on ouvre un menu. Deux gestes
# distincts méritent deux noms, même quand ils mènent au même endroit.
#
# Les arguments passent tels quels : « ./todo.sh --help » vaut « ./install.sh
# --help ».
cd "$(dirname "$0")" || exit 1
exec ./install.sh "$@"