Merge branch 'develop_claude_skills'

Improve claude skills with TODO
This commit is contained in:
Mathieu Benoit 2026-09-03 05:00:30 -04:00
commit 97ccce1def
29 changed files with 3732 additions and 258 deletions

View file

@ -1,4 +1,11 @@
# Code Generator
---
name: erplibre-code-generator
description: >-
Génération de modules Odoo dans ERPLibre : créer un module, cloner un
module existant, où vivent le moteur et les gabarits, et l'hygiène des
commentaires du code produit. À charger avant de générer ou de cloner
un module.
---
ERPLibre inclut un système de génération de modules Odoo :
- `script/code_generator/new_project.py` — Créer un nouveau module
@ -20,26 +27,9 @@ ou de base y arrive tout seul.
## Le nettoyage au fur et à mesure
Rien ne se nettoie en une passe : on corrige les commentaires du fichier qu'on
touche, au moment où on le touche. Deux outils le rappellent.
Le hook `pre-commit` liste ce qui est à relire dans les fichiers indexés,
SANS bloquer le commit :
La règle et ses deux garde-fous sont dans `.claude/rules/04-code-conventions.md`. Une invocation lui manque, utile
sur du code généré en masse : elle ne relève que les trouvailles.
```bash
git config core.hooksPath script/git/hooks # une fois par clone
```
L'outil se lance aussi à la main, sur un fichier, un répertoire ou l'index :
```bash
python3 script/analyse/check_comment_hygiene.py script/todo/todo.py
python3 script/analyse/check_comment_hygiene.py --staged
python3 script/analyse/check_comment_hygiene.py script --identifying-only
```
🔴 `identifiant` — adresse, courriel, chemin de compte : à retirer.
🟡 `récit` — témoignage, date, première personne : à RELIRE, l'outil ne
tranche pas. Un fait durable reste ; l'incident
où on l'a observé part. Codes de sortie : 0 rien, 1 des trouvailles, 2 l'outil
a échoué.

View file

@ -1,4 +1,11 @@
# Déploiement
---
name: erplibre-deployment
description: >-
Déploiement ERPLibre : Docker, systemd, nginx, SSL, DNS, plateformes
supportées, et le choix de l'interpréteur Python (EL_PYTHON_PROVIDER)
comme du gestionnaire de paquets (EL_PIP_PROVIDER). À charger pour
déployer, installer ou changer de fournisseur Python.
---
- **Docker** : `docker-compose.yml` (PostgreSQL 18 + PostGIS 3.6)
- **Systemd** : `script/systemd/` pour les services

View file

@ -19,6 +19,9 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0)
`ls -d .venv.odoo*` plutôt que de le composer de tête
- Les scripts ERPLibre utilisent `.venv.erplibre/bin/python`
- Le Makefile principal inclut des fragments depuis `conf/make.*.Makefile`
- Un module cloné depuis un module existant hérite de ses commentaires
et de ses docstrings : les relire avant de committer, un nom de client
ou de base y arrive tout seul
- Les fichiers privés vont dans `private/`. C'est le SEUL endroit qui a le
droit de porter une donnée de client — nom, base, machine, adresse,
chiffres. Il peut être commité, mais seulement sur un dépôt privé : sur
@ -29,7 +32,6 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0)
`.claude/rules/04-code-conventions.md`
- La DB PostgreSQL par défaut est sur le port 5432, mot de passe admin : `admin`
- Port Odoo par défaut : 8069, longpolling : 8072
- Pour les commits : suivre le format `[TYPE] description` (ex: `[FIX]`, `[UPD]`, `[ADD]`, `[REM]`)
- Pour la documentation : modifier les `.base.md`, jamais les `.md` ou `.fr.md` directement
- Outil mmg disponible via `source .venv.erplibre/bin/activate && mmg`
- Les tests qui créent de VRAIES machines vivent dans `long_test/` et non dans
@ -37,31 +39,5 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0)
en quelques secondes, même sans virtualisation. Ils durent des heures et se
défont par `--detruire` — voir `long_test/README.md`
## Core Principles
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
## Règles détaillées
Les instructions détaillées sont dans `.claude/rules/` :
| Fichier | Contenu |
|---------|---------|
| `01-versions.md` | Versions Odoo supportées, où lit-on la correspondance |
| `04-code-conventions.md` | Où sont les configs de format, conventions Git |
| `06-code-generator.md` | Génération de modules Odoo |
| `07-documentation.md` | Interdit : ne pas éditer les `.md` générés |
| `08-deployment.md` | Docker, systemd, nginx, SSL, DNS |
| `09-workflow.md` | Workflow orchestration + task management |
Chargées à la demande (`.claude/skills/`) :
| Skill | Contenu |
|-------|---------|
| `erplibre-commands` | Commandes make et scripts : versions, run, tests, DB, Docker, repo |
| `erplibre-doc-i18n` | Mode d'emploi mmg (`.base.md`) et i18n du CLI TODO |
L'arborescence et la liste des venvs ne sont plus documentées : `ls` et
`ls -d .venv.*` en donnent l'état réel, la doc dérivait de la réalité.

View file

@ -0,0 +1,170 @@
---
name: git_prepare_merge
description: "ERPLibre merge preparation: changelog entry, then the merge message for the current branch."
disable-model-invocation: true
allowed-tools:
- Bash(git status:*)
- Bash(git branch:*)
- Bash(git log:*)
- Bash(git diff:*)
- Bash(git merge-base:*)
- Bash(sed:*)
- Bash(make doc_markdown:*)
- Bash(python3:*)
- Read
- Edit
- Write
---
## Context
- Current branch: !`git branch --show-current`
- Branch commits: !`git log --oneline $(git merge-base HEAD master)..HEAD`
- Files touched: !`git diff --stat $(git merge-base HEAD master)..HEAD`
- Working tree: !`git status --porcelain`
- Changelog head: !`sed -n '24,45p' CHANGELOG.base.md`
## Task
Prepare the merge of the CURRENT branch into its integration branch. Two
deliverables, in this order: the changelog entry, then the merge message.
Nothing is merged here — `/git_prepare_merge` prepares, the human merges.
### 0. Read the branch
`master` is production, `develop` is where the work lands. Take the target
from where the branch forked: `git merge-base HEAD develop` and
`git merge-base HEAD master`, the closer of the two names the target.
Read the WHOLE branch before writing a word — `git log -p <base>..HEAD` for
the commits, `git diff <base>..HEAD` for the net result. A merge message
summarises what the branch delivers, which is rarely the concatenation of its
subjects: commits that undo each other cancel, and a fix to a feature added on
the same branch is part of the feature, not a separate line.
Stop and say so, rather than inventing, when the branch is empty, when it is
already merged, or when the working tree carries changes not yet committed —
uncommitted work is not part of the merge and must not be described as if it
were.
### 1. The changelog entry
`CHANGELOG.base.md` at the repository root is the SOURCE. `CHANGELOG.md` and
`CHANGELOG.fr.md` are generated by mmg and every direct edit to them is lost
at the next `make doc_markdown` — never open them to write.
The entry goes under `## [Unreleased]`, in the section that fits: Added /
Ajouté, Changed / Modifié, Fixed / Corrigé, Removed / Retiré, Security /
Sécurité. Create the pair of headings if the section does not exist yet, in
the file's own order.
The file alternates language blocks with markers. Within one section the
English bullets sit under `<!-- [en] -->` and the French translation under
`<!-- [fr] -->`, in the SAME order: the two lists are read side by side, and a
bullet added to one language only leaves the other half wrong. Nothing goes
under `<!-- [common] -->` but the version headings.
What a bullet says: what the software now DOES or REFUSES, in the present, for
someone who was not on this branch. It is longer than a commit subject and
shorter than the commit body — the reader is choosing whether to upgrade, not
reviewing the diff. Keep the failure mode removed, the figure that bounds it,
the flag or the file a user has to know. Drop the internals nobody outside
calls.
The two rules of `.claude/rules/04-code-conventions.md` hold here as
everywhere: nothing identifying — no customer, no real database, no host, no
address, no account path — and the code as the subject, never the session
that produced it.
Regenerate afterwards, and stage the three files together, the generated pair
being what most readers actually open:
```bash
make doc_markdown
git status --porcelain CHANGELOG.base.md CHANGELOG.md CHANGELOG.fr.md
```
### 2. The merge message
Resolve `{MODEL}` exactly as `/commit` does — the trailer is required here
too, a merge message being as AI-assisted as any other. Run:
```bash
python3 -c "
import glob, json, os, sys
sid = os.environ.get('CLAUDE_CODE_SESSION_ID', '')
hits = glob.glob(os.path.expanduser('~/.claude/projects/*/%s.jsonl' % sid)) if sid else []
mid = ''
for path in hits[:1]:
with open(path) as fh:
for line in fh:
try:
m = json.loads(line).get('message', {}).get('model', '')
except Exception:
continue
if m and not m.startswith('<'):
mid = m
if not mid:
sys.exit('UNKNOWN')
mid = mid.removeprefix('claude-')
parts = [p for p in mid.split('-') if not (len(p) == 8 and p.isdigit())]
print('Claude %s %s' % (parts[0].capitalize(), '.'.join(parts[1:])))
"
```
The shape, as this repository writes it:
```
Merge branch '<branch>'
[TAG] scope: what the branch delivers, imperative, 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 ---
The same body, translated.
Assisted-by: {MODEL}
```
The first line stays `Merge branch '<branch>'` — git writes it, tools read it,
and a merge whose first line says something else no longer looks like a merge
in `git log --oneline`. The tagged line beneath it is what a reader gets from
`--oneline` on the second row and from a release note, so it carries the same
duty as a commit subject: name the part of the system, then what is now
different about it. The evidence — the symptom, the quoted error, the
metaphor — belongs in the body.
The body: the same budget as a commit, per language, and here a merge covers
several commits, so it is a SUMMARY and not a list. Open by stating how many
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
`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.
### 3. Hand it over
Write the message to `tasks/merge_message.txt` — `tasks/` is not versioned,
which is why the repository sends working material there — and print the two
commands the human runs, with `--no-ff` so the branch keeps a merge commit and
its history stays readable:
```bash
git switch <target>
git merge --no-ff <branch> -F tasks/merge_message.txt
```
Do not run them. Do not switch branch, do not merge, do not push: the merge is
the human's decision and the last chance to read the message before it is
permanent. Report, in a sentence each, the changelog section written to and
the number of commits summarised.

View file

@ -17,6 +17,14 @@ allowed-tools:
- Current todo.json sections: !`python3 -c "import json; d=json.load(open('script/todo/todo.json')); print('\n'.join(d.keys()))"`
- Current i18n keys count: !`grep -c fr.: script/todo/todo_i18n.py`
## Planning first
`/todo_plan_max` plans one entry at maximum effort: it asks what forks the
design, uses the superpowers plugin when it is installed, and writes the
specification to `tasks/todo.md`. Both commands are deployed together by
`TODO › Execute › GPT code › Claude configs`. When such a specification
exists, implement it rather than re-deciding the design here.
## Architecture Reference
### Files to modify

View file

@ -0,0 +1,228 @@
---
name: todo_generate_code
description: "Write code in ERPLibre by the rules the repository actually enforces: Odoo/OCA module conventions first, then the real format and verify toolchain."
disable-model-invocation: true
effort: high
allowed-tools:
- Read
- Edit
- Write
- Grep
- Glob
- Bash(make format:*)
- Bash(make test_unit:*)
- Bash(make test_unit_file:*)
- Bash(./script/maintenance/:*)
- Bash(python3:*)
- Bash(git status:*)
- Bash(git diff:*)
- Bash(ls:*)
- Bash(cat:*)
---
## Context
- Odoo series in this checkout: !`cat .odoo-version`
- Available venvs: !`ls -d .venv.* 2>/dev/null`
- Branch and pending work: !`git status --porcelain`
- Addons trees: !`ls -d odoo*/addons/*/ 2>/dev/null | head -12`
## Task
Write code in this repository by the rules it ENFORCES, which are not always
the rules it documents. Establish them first, then write. The sections below
were read off the configuration files and the in-house modules, not off the
prose; where a document disagrees with a tool, the tool wins and the
disagreement is named.
### The effort tier
The `effort: high` above applies to this invocation — "comprehensive
implementation with extensive testing", the tier for code that has to survive
review.
A session can be pinned above it. `/effort ultracode` sets xhigh AND turns on
dynamic workflow orchestration, and a system-reminder then asks for a workflow
on every substantive task. That standing opt-in does not apply here: writing
one module correctly is one agent's job, and fanning it out multiplies both the
token cost and the ways the pieces disagree.
So when a reminder says ultracode is on, say in one line that this command
works at high, and ask the user to type `/effort high` — that one command sets
the tier and clears the ultracode flag in the same move. A command's
frontmatter cannot release a session pin; only the user's own `/effort` can,
from an interactive terminal. Work solo either way.
### 1. Before writing a line
**Find the tree.** New modules go under `odoo<VERSION>/addons/<Org>_<repo>/<module>/`
— the version-prefixed tree is the real addons root, and the generator
rewrites any `addons/` it is handed into `odoo<VERSION>/addons/`
(`script/code_generator/new_project.py:144-146`). The version is in
`.odoo-version`; the venv carries BOTH versions in its name, so find it with
`ls -d .venv.odoo*` rather than composing it from memory.
**Read the neighbours.** Two or three in-house modules under
`odoo<VERSION>/addons/ERPLibre_erplibre_addons/` show the conventions in force
better than any list. Copy their shape.
**Bootstrap rather than hand-roll.** `script/code_generator/new_project.py -d
<git root dir> -m <module name>` creates a module; `create_from_existing_module.py`
clones one. A module cloned from an existing one INHERITS its comments and
docstrings — reread them before committing, a client or database name travels
that way on its own.
**Two rules bind every line you write**, and no tool checks either:
- Nothing identifying outside `private/` — no customer or third-party
organisation, no real database, host or VM name, no IP, e-mail or path
carrying an account name. Generalise to the class of situation instead.
- A comment says how the CODE works, in the present. A sentence whose subject
is an incident, a machine, a date or a person belongs in `tasks/`, which is
not versioned.
**Never edit a generated file.** `FICHIER.md` and `FICHIER.fr.md` come from
`FICHIER.base.md` through mmg; an edit is lost at the next `make doc_markdown`.
### 2. Odoo module conventions
`__manifest__.py` carries at minimum `name`, `version`, `author`, `license`,
`category`, `summary`, `depends`, `data`, `installable`. `version` is
`<series>.1.0.0` — `18.0.1.0.0` on an 18.0 checkout — and `license` is
`AGPL-3`, which every in-house module uses.
The `data` list is ordered `security/`, then `data/`, then `wizards/`, then
`views/`, with `views/menu.xml` last.
Non-Odoo Python requirements go in `external_dependencies: {"python": [...]}`,
and the import is guarded in the model with `try/except ImportError` logging at
debug level — a missing optional dependency must not break the registry.
Layout: `models/`, `views/`, `security/`, `wizards/`, `data/`, `controllers/`,
`i18n/`, `report/`, `tests/`, `static/description/`. Ship
`static/description/icon.png`.
Naming, one for one:
- `models/<model_name_with_dots_as_underscores>.py`, one model per file —
model `devops.workspace` lives in `models/devops_workspace.py`.
- `views/<same_name>.xml` for a Model; a TransientModel's Python AND its XML
both live in `wizards/`.
- `ir.ui.view` ids: `<model_underscored>_view_<type>` (`_view_form`,
`_view_tree`, `_view_search`, `_view_kanban`).
- `ir.actions.act_window` ids: `<model_underscored>_<action>_action_window`;
server actions: `<model_underscored>_<name>_server_action`.
- `res.groups` go in `security/<module_name>.xml` — named after the module, not
`security.xml` — and that file is listed BEFORE `ir.model.access.csv`.
`security/ir.model.access.csv` carries exactly the header
`id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink`.
Split the rows by privilege level — a read-only row for the broad group, a
full-CRUD row for the administrative one — rather than one blanket row.
Every Python file opens with the AGPL licence comment; hand-written models and
hooks also carry the shebang and copyright lines. A package `__init__.py`
starts with `# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)`,
a blank line, then imports. The root `__init__.py` imports subpackages on one
line (`from . import models, wizards`) and a hook by name
(`from .hooks import post_init_hook`); hooks live in a top-level `hooks.py` and
are declared under the matching manifest key.
Models declare `_name`, `_description` and, when mixins are used, `_inherit`;
the class name is the CamelCase of the model. Odoo symbols come in one grouped
import — `from odoo import _, api, exceptions, fields, models`.
Translations go in `i18n/` as `<module>.pot` plus one `.po` per locale.
### 3. Format — the toolchain that exists
`make format` before committing. It formats only what git reports as
modified, added, renamed or untracked, dispatching each file by extension —
so it is cheap and safe to run repeatedly. `make format_all` sweeps whole
areas instead.
What it runs underneath, and what to match when writing by hand:
| Kind | Tool and settings |
|------|-------------------|
| Python | `isort --profile black -l 79`, then `black -l 79 --preview -t py37` |
| XML | prettier + `@prettier/plugin-xml`, tab-width 4, print-width 120 |
| XML under `data/` | same, print-width 999999999 — long data strings stay on one line |
| js, css, scss, html | prettier, tab-width 4, print-width 120, no bracket spacing |
| Shell | `shfmt -i 2 -ci -w` |
black and isort run from `.venv.erplibre`, prettier from the repo-pinned
`./node_modules/.bin` — a globally installed prettier is a different version
and reformats differently. Nothing under `not_supported_files/` is formatted.
**What NOT to run**, each verified absent from this checkout rather than
assumed:
- `./script/maintenance/autopep8.sh` — `oca-autopep8` is not installed; the
script exits 1. `doc/DEVELOPMENT.base.md` still recommends it; the document
is stale.
- Any `oca-*` console script (`oca-gen-addon-readme`, `oca-towncrier`, …) —
`script/OCA_maintainer-tools` is checked out but never pip-installed, and its
install block in `install_locally_dev.sh` is commented out. Run one as a
module from that directory if you truly need it.
- `pre-commit run` at the repository root — there is no root
`.pre-commit-config.yaml`. ERPLibre's own hooks are hand-written Python,
installed once per clone with
`git config core.hooksPath script/git/hooks`.
**A vendored OCA addon** under `odoo<VERSION>/addons/OCA_*` carries its own
`.pre-commit-config.yaml`: run THAT from its directory instead of the ERPLibre
format scripts, so the patch matches what upstream will accept.
### 4. Lint
Neither linter runs on its own — no target, no hook, no CI invokes them, so
running them is a deliberate act:
- flake8 lives in the Odoo venv, not `.venv.erplibre`. The root `.flake8`
applies: max-line-length 80, max-complexity 16, `select = C,E,F,W,B,B9`,
ignoring E203, E501 and W503 for black compatibility.
- pylint-odoo also lives in the Odoo venv:
`.venv.odoo<...>/bin/pylint --load-plugins=pylint_odoo --rcfile=<file> <path>`.
The `--rcfile` is not optional — the repository root ships no `.pylintrc`,
and the ones found under vendored trees belong to those projects.
### 5. Verify
Verification is entirely local: `.github/` holds no workflow, so nothing
catches a mistake after the fact.
**Repository scripts** — `make test_unit` is the fast gate: no PostgreSQL, no
Odoo, no VM, a few seconds. While iterating on one file, `make test_unit_file
F=test/test_<name>.py`.
A new file in `test/` must declare at least one `test_*` function and end with
`if __name__ == "__main__": unittest.main()` as the LAST top-level statement —
the suite has its own test that enforces both, since the runner selects files
by the glob `test/test_*.py`. A test that creates a real machine, installs a
system or runs for hours goes in `long_test/` instead, and undoes itself with
`--detruire`.
**An Odoo module** — drop the database, then run the module's tests:
```bash
./odoo_bin.sh db --drop --database test_<module>
./test.sh -d test_<module> --db-filter test_<module> -i <module>
```
`./test.sh` is `./run.sh` with `ODOO_MODE_TEST=true --workers 0`, which adds
`--test-enable --no-http --stop-after-init`. For coverage, bracket that with
`./.venv.erplibre/bin/coverage erase` before and a combine/report after, and
set `ODOO_MODE_COVERAGE=true` — coverage is switched on through the
environment, never a CLI flag. `make open_test_coverage` opens the report.
To run ONE test file inside a module, install the module first, then pass
`--test-file=`.
### 6. Then, and only then, commit
`make format`, the tests above, then `/commit` — which resolves the model,
writes the `[TYPE] portée : sujet` subject under 72 characters, the bilingual
body under ten lines per language, and the `Assisted-by:` trailer. The
`commit-msg` hook REFUSES a message that breaks the mechanical part of that;
the `pre-commit` hook only reports comments worth rereading and never blocks.
Stage by naming files. `git add -A` sweeps in `private/` and `tasks/`, which
are untracked on purpose.

View file

@ -0,0 +1,115 @@
---
name: todo_plan_max
description: "Plan a todo.py command at maximum effort: questions first, superpowers when installed, then the specification /todo_add_command implements."
disable-model-invocation: true
effort: max
allowed-tools:
- Bash(claude plugin list:*)
- Bash(claude plugin marketplace list:*)
- Bash(grep:*)
- Bash(sed:*)
- Bash(ls:*)
- Bash(git log:*)
- Read
- Glob
- Grep
- Write
---
## Context
- Existing menus: !`grep -n "def prompt_execute" script/todo/todo.py`
- Menu sections in todo.json: !`python3 -c "import json;print(*json.load(open('script/todo/todo.json')),sep='\n')"`
- Installed plugins: !`claude plugin list`
- Last commits on the menu: !`git log --oneline -8 -- script/todo/`
## Task
Plan ONE command for the `script/todo/todo.py` menu, at the effort tier this
file pins. Planning is the whole job: this command produces a specification
and writes no menu code. `/todo_add_command` implements what comes out.
### The effort tier
The `effort: max` above applies to this invocation and to it alone.
Two other levers exist, and they are the user's to pull, not yours. `/effort
ultracode` PINS ultracode for the rest of the session — every substantive task
then goes through the Workflow tool, and an interactive terminal releases the
pin with `/effort high`. The bare keyword `ultracode` in a typed prompt opts in
that ONE turn. Say which of the two would serve, and let the user type it;
never claim a pin that is not shown as on.
Invoking this command is itself an explicit opt-in to multi-agent
orchestration, so the Workflow tool is available here without any further ask.
Reach for it when the plan genuinely has independent dimensions to explore in
parallel — several candidate designs, or a survey of how the existing menus
already solve the problem. A single obvious entry does not need one, and a
workflow spawned for it burns tokens the user is paying for.
### 1. Ask before planning
Ask with the question tool, never as prose the user has to answer in a
paragraph. Ask ONLY what changes the plan — a question whose every answer
leads to the same design is noise, and four is the ceiling per round.
What usually forks the design, in this repository:
- **Which menu.** Git, Code, Database, Config, Network, Process, Test, Update,
Run, Doc, Security — the parent decides who finds the entry.
- **Pattern A or B.** A hard-coded method when the entry prompts, branches or
reads state; a `todo.json` entry when it is one bash command or one make
target. Guessing wrong costs a rewrite, not an edit.
- **What it does on failure.** An entry that stops at the first error, one that
carries on and reports at the end, and one that asks before each step are
three different features wearing one name.
- **Whether it destroys anything.** A command that drops a database, deletes a
VM or overwrites a file needs a confirmation prompt and a name typed in full;
the repository already writes them that way.
- **Whether it touches customer data.** Only `private/` may hold it. If the
answer is yes, the plan says where the data lives and what never leaves it.
Take the answers as given. When one contradicts what the code does, say so in
a sentence and plan what was asked for.
### 2. Plan with superpowers when it is installed
Read the context block above. When `superpowers` appears among the installed
plugins, use it: its brainstorming skill for the design, its subagent-driven
development and code-review skills for the shape of the work, its systematic
debugging skill when the entry wraps something that already misbehaves.
When it is absent, say so in one line and plan without it — plan mode, the
repository's own conventions, and the menus already written. Do not install it
from here: `TODO › Execute › GPT code › Plugins Claude Code` carries the
ERPLibre list and the install is the user's decision, not a side effect of
asking for a plan.
### 3. Read what already exists
A menu of this size has almost always solved the problem next door. Before
designing anything, find the two or three closest entries and read them —
`grep -n "def prompt_execute" script/todo/todo.py` for the parents, then the
private methods under them. Copy the shape that is there: the same
confirmation prompt, the same `t()` keys, the same way of running a command.
An entry that behaves like its neighbours needs no explaining.
### 4. What the plan contains
Write it to `tasks/todo.md` — `tasks/` is not versioned, which is why the
convention sends working material there — as checkable items, and state:
- the parent menu and the position of the entry in it;
- pattern A or B, and why the other was rejected;
- the exact i18n keys, with their French and English text, both mandatory;
- for pattern A, the method name and its signature; for pattern B, the
`todo.json` section and the command line;
- what the entry prints on success and on failure, and every confirmation it
asks for;
- how to verify it: the syntax checks, the unit test to add under `test/`, and
what to run by hand. A real machine goes to `long_test/`, never to `test/`,
which stays runnable in seconds.
Then stop and hand it over. The plan is checked before code is written — that
is the whole point of planning at this tier. When it is approved,
`/todo_add_command` implements it.

View file

@ -563,9 +563,7 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
# de cache fait qu'un déploiement Debian 13 et un Proxmox se
# PARTAGENT le téléchargement (325 Mio) au lieu d'en faire deux. Sans
# cette branche, le repli de fin nommait l'image « fedora-cloud-9 ».
return (
f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2"
)
return f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2"
if distro == "arch":
return f"arch-linux-{a}-cloudimg.qcow2"
if distro == "opensuse":
@ -1072,6 +1070,7 @@ def setup_host(
assume_yes: bool,
no_install: bool,
reboot_if_needed: bool = False,
assume_yes_reboot: bool = False,
) -> None:
"""Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau.
@ -1114,14 +1113,25 @@ def setup_host(
# monte tout seul avec les modules du nouveau noyau. Un seul reboot
# suffit donc à rendre l'hôte utilisable, sans repasser par ici.
if reboot_if_needed:
print(
"\n↻ Redémarrage programmé (dans quelques secondes) : c'est la"
" SEULE façon de retrouver les modules du noyau.\n"
" Au retour, le réseau « default » démarrera seul"
" (autostart déjà actif)."
# Le consentement à installer des paquets ne vaut PAS consentement
# à redémarrer : assume_yes couvre pacman, jamais la machine de
# celui qui l'a tapé. Une provision sans personne devant l'écran
# passe --assume-yes-reboot, qui dit explicitement l'autre chose.
if assume_yes_reboot or prompt_yes_no(
"\n↻ Redémarrer MAINTENANT ? C'est la seule façon de"
" retrouver les modules du noyau. Au retour, le réseau"
" « default » démarrera seul (autostart déjà actif).",
default=False,
):
print("\n↻ Redémarrage programmé (dans quelques secondes).")
schedule_reboot(runner)
return
sys.exit(
"Erreur : l'hôte n'est pas prêt, redémarrage refusé.\n"
f" {stale}\n"
" Redémarrez quand vous le voudrez, puis relancez"
" --setup-host."
)
schedule_reboot(runner)
return
sys.exit(f"Erreur : l'hôte n'est pas prêt.\n {stale}")
if not (ok and active):
@ -1749,6 +1759,19 @@ PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = {
}
# Assistant AUR posé sur l'invité Arch par l'amorçage d'installation. Les
# formes viennent du manuel de yay : il reprend les options de pacman, sauf
# « -Yc » qui lui est propre. Jamais sous sudo — yay appelle sudo lui-même
# pour la seule étape qui en a besoin, et le lancer en root fait échouer
# makepkg, qui refuse de construire sous cet utilisateur.
AUR_GUIDE: tuple[tuple[str, str, str], ...] = (
("yay -Syu", "mettre à jour dépôts + AUR", "upgrade repos and AUR"),
("yay -S <paquet>", "installer depuis l'AUR", "install from the AUR"),
("yay -Ss <motif>", "chercher dans l'AUR", "search the AUR"),
("yay -Yc", "retirer les orphelins", "remove orphans"),
)
def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]:
"""Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap.
@ -1955,6 +1978,12 @@ def build_motd(
lang,
narrow,
)
# yay arrive avec l'amorçage d'installation, pas avec l'image : une VM
# déployée sans installation n'annonce donc pas une commande absente.
# C'est la règle du bloc ERPLibre ci-dessous, appliquée au même signal.
if mgr == "pacman" and el_dir:
body.append("")
body += motd_block("AUR — yay", AUR_GUIDE, lang, narrow)
if el_dir:
body.append("")
el_rows = erplibre_guide(el_dir, el_make, editor)
@ -2468,18 +2497,23 @@ def _ip_taken(ip: str) -> bool:
except (OSError, subprocess.SubprocessError):
pass
try:
if subprocess.run(
["ping", "-c", "1", "-W", "1", ip],
capture_output=True,
timeout=5,
).returncode == 0:
if (
subprocess.run(
["ping", "-c", "1", "-W", "1", ip],
capture_output=True,
timeout=5,
).returncode
== 0
):
return True
except (OSError, subprocess.SubprocessError):
pass
return _ip_reachable(ip, port=22, timeout=1.5)
def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str] | None:
def static_net_plan(
net: str | None, use_sudo: bool, name: str
) -> dict[str, str] | None:
"""Adresse fixe libre pour une VM installée par debian-installer.
L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i
@ -3019,9 +3053,11 @@ def virt_install(
# VM que personne ne regarde, et il ne reste RIEN à lire ensuite —
# exactement « l'installation a échoué, pas de sortie pertinente ».
# Le fichier, lui, survit à l'arrêt du domaine.
f"pty,target_type={console_target},log.file={console_log}"
if installer
else f"pty,target_type={console_target}",
(
f"pty,target_type={console_target},log.file={console_log}"
if installer
else f"pty,target_type={console_target}"
),
# Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à
# virsh de piloter la VM SANS réseau (ex. étendre le FS invité après
# un redimensionnement de disque). Inoffensif si l'agent est absent.
@ -3495,8 +3531,15 @@ def build_parser() -> argparse.ArgumentParser:
g_run.add_argument(
"--reboot-if-needed",
action="store_true",
help="Avec --setup-host : redémarre si le noyau a été mis à jour "
"depuis le démarrage (sinon libvirt ne peut pas créer virbr0).",
help="Avec --setup-host : PROPOSE un redémarrage si le noyau a été "
"mis à jour depuis le démarrage (sinon libvirt ne peut pas créer "
"virbr0). La question est posée sur /dev/tty et vaut non par défaut.",
)
g_run.add_argument(
"--assume-yes-reboot",
action="store_true",
help="Redémarre sans poser la question. Réservé à une provision "
"sans personne devant l'écran ; --assume-yes ne l'implique pas.",
)
g_run.add_argument(
"--list-images",
@ -3555,6 +3598,7 @@ def main() -> None:
args.assume_yes,
args.no_install_deps,
args.reboot_if_needed,
args.assume_yes_reboot,
)
return
@ -3707,11 +3751,15 @@ def main() -> None:
network_name(args.network), not args.dry_run, args.name
)
if static:
print(f" Adresse fixe retenue : {static['ip']}"
f" (passerelle {static['gateway']})")
print(
f" Adresse fixe retenue : {static['ip']}"
f" (passerelle {static['gateway']})"
)
else:
print(" ⚠ Aucune adresse fixe déterminée : netcfg-static posera"
" la question à l'écran et l'installation s'arrêtera.")
print(
" ⚠ Aucune adresse fixe déterminée : netcfg-static posera"
" la question à l'écran et l'installation s'arrêtera."
)
build_installer_initrd(
build_preseed(args, pw_hash, ssh_keys, static),
initrd_src,

View file

@ -4,11 +4,13 @@
"""Menu QEMU/KVM : atteindre une VM \u2014 SSH, tunnels, consoles, \u00e9mulateur.\n\nTout ce qui relie l'humain \u00e0 une machine d\u00e9j\u00e0 d\u00e9ploy\u00e9e : ~/.ssh/config et ses\nProxyJump, la d\u00e9couverte des VM imbriqu\u00e9es, les tunnels de bureau distant, la\nconsole s\u00e9rie et graphique (virt-viewer), et l'\u00e9mulateur Android d'une VM\ngraphique avec son tunnel adb.\n\nS\u00e9par\u00e9 du reste parce que c'est le seul bloc qui parle de R\u00c9SEAU et de\nsessions interactives, jamais de cr\u00e9ation ni de destruction de VM."""
import os
import shlex
import shutil
import socket
import subprocess
import time
from script.todo import todo_install
from script.todo.todo_i18n import t
@ -597,34 +599,28 @@ class QemuAccessMixin:
)
print(f' {t("To close it:")} pkill -f "{port}:localhost:{port}"')
# Un paquet, quatre familles. virt-viewer porte le même nom partout, ce qui
# est rare et bienvenu : seule la commande d'installation change.
_QEMU_VIRT_VIEWER_INSTALL = (
("apt-get", "sudo apt-get install -y virt-viewer"),
("dnf", "sudo dnf install -y virt-viewer"),
("pacman", "sudo pacman -S --needed --noconfirm virt-viewer"),
("zypper", "sudo zypper --non-interactive install virt-viewer"),
)
def _qemu_ensure_virt_viewer(self):
"""virt-viewer sur CETTE machine, installé s'il manque.
Installé seulement là où il va SERVIR : sur un hyperviseur sans écran,
poser un client graphique ne rendrait service à personne. C'est
l'appelant qui a vérifié l'affichage."""
l'appelant qui a vérifié l'affichage, et c'est pourquoi celui-ci pose
sans demander — la seule installation du CLI dans ce cas.
virt-viewer porte le même nom de paquet dans les quatre familles, ce
qui est rare : seule la commande change, et todo_install la connaît."""
if shutil.which("virt-viewer"):
return True
print(f"\n {t('virt-viewer is missing here; installing it.')}")
for tool, cmd in self._QEMU_VIRT_VIEWER_INSTALL:
if shutil.which(tool):
print(f" {t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
break
else:
cmd = todo_install.install_command(["virt-viewer"])
if not cmd:
print(f" ⚠ {t('no known package manager here.')}")
return False
lisible = shlex.join(cmd)
print(f" {t('Will execute:')} {lisible}")
self.execute.exec_command_live(lisible, source_erplibre=False)
if shutil.which("virt-viewer"):
print(f" ✅ virt-viewer")
print(" ✅ virt-viewer")
return True
print(f" ⚠ {t('virt-viewer still missing after the install.')}")
return False

View file

@ -186,8 +186,12 @@ class QemuDeployMixin:
"{ sudo dnf clean all; sudo dnf install -y --refresh $PKGS; }; "
"elif command -v pacman >/dev/null 2>&1; then "
+ self._qemu_pacman_prepare_cmd()
+ "sudo pacman -S --needed --noconfirm $PKGS; "
"elif command -v zypper >/dev/null 2>&1; then "
# bash-completion n'est PAS dans une image cloud Arch, là où les
# images Debian et Fedora l'embarquent : sans lui, la tabulation
# ne complète que les noms de fichiers, pas les sous-commandes.
+ "sudo pacman -S --needed --noconfirm $PKGS bash-completion; "
+ self._qemu_yay_install_cmd()
+ "elif command -v zypper >/dev/null 2>&1; then "
# openSUSE : « --non-interactive » vaut le -y des autres, et
# « --auto-agree-with-licenses », qui va APRÈS « install »,
# évite un blocage sur une licence à accepter.

View file

@ -19,9 +19,13 @@ class QemuInstallMixin:
# Sans le groupe, virt-install retombe sur qemu:///session où « default »
# n'existe pas : la VM échoue alors que tous les paquets sont installés.
# L'ancien one-liner finissait par « || true » et masquait ses erreurs.
# Le redémarrage est consenti ICI et nulle part ailleurs : la VM vient
# d'être créée, personne ne la regarde, et le noyau fraîchement installé
# doit être chargé avant que libvirt puisse monter virbr0. Sur un poste de
# travail, la question se pose — voir _qemu_ensure_tools.
_QEMU_QEMU_PKGS = (
"./script/qemu/deploy_qemu.py --setup-host --assume-yes"
" --reboot-if-needed"
" --reboot-if-needed --assume-yes-reboot"
)
def _qemu_ask_prod(self):
@ -580,6 +584,41 @@ class QemuInstallMixin:
"fi; "
)
@staticmethod
def _qemu_yay_install_cmd():
"""Pose yay, l'assistant AUR, sur un invité Arch.
« yay-bin » et non « yay » : le paquet source compile son propre Go,
ce qui coûte plusieurs minutes et le compilateur avec ; le binaire
précompilé donne le même outil.
makepkg REFUSE de tourner en root et sort en erreur ; le clonage et la
construction restent donc sous l'utilisateur de la VM, qui appelle
sudo pour la seule installation finale. Le NOPASSWD posé par
cloud-init rend ce sudo silencieux.
yay est un bonus, pas une condition : le bloc se termine par « true »
pour qu'un AUR injoignable ne fasse pas échouer, sous « set -e », une
installation par ailleurs complète.
"""
return (
"command -v yay >/dev/null 2>&1 || { "
"sudo pacman -S --needed --noconfirm base-devel git && "
"yd=$(mktemp -d) && "
"git clone --depth 1 https://aur.archlinux.org/yay-bin.git "
'"$yd" && ( cd "$yd" && makepkg -si --noconfirm ); '
# « rm -rf » sur une variable vide rend 0 en silence sous -f : le
# nettoyage n'a donc pas besoin de savoir si le clonage a eu lieu.
'rm -rf "$yd"; '
# « || true » ferme le groupe ENTIER, et il porte. Le groupe est
# le DERNIER membre de la liste « || », donc set -e s'y applique
# et le premier sudo en échec emporterait toute l'installation.
# Un membre de plus l'y suspend, et rend le bloc inoffensif.
"} || true; "
"command -v yay >/dev/null 2>&1 "
'&& echo " yay installé" || echo " ⚠ yay non installé"; '
)
def _qemu_zypper_mirror_cmd(self):
"""Réécrit l'hôte des dépôts zypper vers un miroir plus proche."""
mirrors = " ".join(self._QEMU_ZYPPER_MIRRORS)

View file

@ -1089,17 +1089,6 @@ def cli_browser() -> str | None:
return None
def _os_id() -> str:
"""ID de la distribution hôte (/etc/os-release), ex. « ubuntu », « fedora »."""
try:
for line in open("/etc/os-release", encoding="utf-8"):
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"').lower()
except OSError:
pass
return ""
# Navigateurs CLI installables via apt/dnf/pacman (nom de paquet = binaire).
# browsh/carbonyl ne sont pas dans les dépôts standard -> non proposés ici.
INSTALLABLE_BROWSERS = (
@ -1111,29 +1100,17 @@ INSTALLABLE_BROWSERS = (
def browser_install_command(browser="w3m") -> list | None:
"""Commande d'installation du navigateur CLI `browser` adaptée à l'OS hôte :
apt (Ubuntu/Debian), dnf (Fedora), pacman (Arch). None si gestionnaire
inconnu."""
apt = ["sudo", "apt-get", "install", "-y", browser]
dnf = ["sudo", "dnf", "install", "-y", browser]
pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", browser]
by_id = {
"ubuntu": apt,
"debian": apt,
"linuxmint": apt,
"fedora": dnf,
"arch": pac,
}
cmd = by_id.get(_os_id())
if cmd:
return cmd
if shutil.which("apt-get"):
return apt
if shutil.which("dnf"):
return dnf
if shutil.which("pacman"):
return pac
return None
"""Commande d'installation du navigateur CLI `browser`, ou None si aucun
gestionnaire de paquets connu.
Les navigateurs proposés portent le même nom de paquet dans les quatre
familles ; todo_install choisit la commande. Cette écriture-ci ne
connaissait pas zypper, et openSUSE ne pouvait donc en installer aucun."""
# Importé ici et non en tête : l'import de todo_i18n de ce module est
# protégé pour qu'il tourne en autonome, et todo_install en dépend.
from script.todo import todo_install
return todo_install.install_command([browser])
def virsh_ip(name: str) -> str:

View file

@ -12,6 +12,7 @@ import shutil
import subprocess
import time
from script.todo import todo_install
from script.todo.todo_i18n import t
@ -45,12 +46,11 @@ def parse_ssh_blocks(content) -> dict:
def ssh_orphans(blocs, juge, prefixe="erplibre-"):
"""(gardées, orphelines) — chacune [(nom, raison)].
Un ProxyJump valait preuve de vie À LUI SEUL : « écrite pour une VM
imbriquée, que virsh ne connaîtra jamais ». Le raisonnement oubliait que
le rebond, lui, peut avoir disparu. Vécu : la VM Proxmox locale effacée,
le nettoyage a retiré son entrée — correctement — et GARDÉ les trois
entrées qui rebondissaient par elle. Trois culs-de-sac, présentés comme
« mènent encore quelque part ».
Un ProxyJump ne vaut pas preuve de vie à lui seul : « écrite pour une VM
imbriquée, que virsh ne connaîtra jamais » oublie que le rebond, lui, peut
avoir disparu. Effacer la VM qui servait de rebond retire son entrée —
correctement — et laisse celles qui rebondissaient par elle : des
culs-de-sac présentés comme « mènent encore quelque part ».
D'où le point fixe : retirer un parent peut orpheliner ses enfants, et
ceux-ci peuvent en orpheliner d'autres. On tourne jusqu'à ce que plus
@ -887,11 +887,9 @@ class QemuManageMixin:
"""{chemin: domaine} — TOUT ce que les domaines référencent.
L'autorité est libvirt, jamais le nom du fichier. Un domaine renommé
garde le nom de fichier d'avant : juger sur le nom faisait passer le
disque d'une VM EN MARCHE pour un orphelin. Rapporté sur
« erplibre-ubuntu-2404-MIGRATION », renommée depuis
« erplibre-ubuntu-2404 » : le nettoyage offrait ses trois fichiers —
disque de 63 Go, seed, nvram — au « rm -f ».
garde le nom de fichier d'avant : juger sur le nom fait passer le
disque d'une VM EN MARCHE pour un orphelin, et offre ses trois
fichiers — disque, seed, nvram — au « rm -f ».
Les deux vues, persistante et vivante, pour la raison dite dans
`_qemu_dumpxml`.
@ -1308,6 +1306,103 @@ class QemuManageMixin:
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
# Le nom du binaire n'est presque jamais celui du paquet. Ces cinq-là
# portent le même nom dans les quatre familles.
_SHRINK_PKG = {
"e2fsck": "e2fsprogs",
"resize2fs": "e2fsprogs",
"dumpe2fs": "e2fsprogs",
"partprobe": "parted",
"lsblk": "util-linux",
"blockdev": "util-linux",
}
# Les deux qui changent de famille en famille : sgdisk vit dans « gdisk »
# chez Debian et Fedora, dans « gptfdisk » chez Arch et openSUSE, et
# qemu-nbd porte quatre noms de paquet différents.
_SHRINK_PKG_FAMILY = {
"apt-get": {"sgdisk": "gdisk", "qemu-nbd": "qemu-utils"},
"dnf": {"sgdisk": "gdisk", "qemu-nbd": "qemu-img"},
"pacman": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-img"},
"zypper": {"sgdisk": "gptfdisk", "qemu-nbd": "qemu-tools"},
}
def _qemu_install_shrink_tools(self, manquants):
"""Poser les paquets qui fournissent les outils manquants.
Rend la liste de ce qui manque ENCORE, relue sur le disque : vide si
tout est là. Un refus, un gestionnaire de paquets inconnu ou une
installation en échec la rendent non vide, et l'appelant renonce.
"""
paquets, inconnus = todo_install.resolve(
manquants,
commun=self._SHRINK_PKG,
par_famille=self._SHRINK_PKG_FAMILY,
)
if inconnus:
print(
f" ⚠ {t('No package known here for:')} {', '.join(inconnus)}"
)
status = todo_install.ask_and_install(
self.execute,
todo_install.install_command(paquets),
t("Install them? (y/N): "),
self._is_yes,
)
if status:
print(f" {t('Error installing the tools: ')}{status}")
reste = [b for b in self._SHRINK_TOOLS if not shutil.which(b)]
if not reste:
# Sans cette ligne, la sortie du gestionnaire de paquets est
# suivie directement de la question suivante, qui porte sur tout
# autre chose : rien ne dit que l'installation a abouti ni qu'on
# a changé d'étape.
print(f" ✅ {t('Tools installed; on with the shrink.')}")
return reste
@staticmethod
def _qemu_backup_need_and_free(disk):
"""(besoin, libre) en octets pour la copie de sauvegarde du disque.
Le besoin est la taille ALLOUÉE et non la taille apparente :
« cp --sparse=always » ne recopie pas les trous d'un qcow2. C'est une
borne haute — « --reflink=auto » rend la copie presque gratuite sur
btrfs et XFS — mais rien ne garantit le reflink, et se tromper par
excès est le bon sens ici : une copie qui manque de place s'arrête à
mi-chemin et laisse un .bak tronqué.
"""
besoin = os.stat(disk).st_blocks * 512
libre = shutil.disk_usage(os.path.dirname(disk) or ".").free
return besoin, libre
def _qemu_ask_backup(self, disk):
"""Proposer la sauvegarde du disque, chiffres en main. True si oui.
Les deux tailles passent AVANT la question : une copie qui ne tient
pas s'arrête à mi-course et laisse un .bak tronqué sur un système de
fichiers désormais plein. Quand la place manque, le défaut bascule à
NON — une entrée distraite ne doit pas remplir le disque — sans pour
autant décider à la place de l'opérateur, qui peut insister.
"""
besoin, libre = self._qemu_backup_need_and_free(disk)
print(
f"\n{t('A backup doubles the space used:')}"
f" {self._human_size(besoin)} — {t('free here:')}"
f" {self._human_size(libre)}"
)
if libre > besoin * 1.05:
return self._is_yes_default_yes(
input(t("Back up the disk before shrinking? (Y/n): "))
)
print(f"⚠ {t('Not enough free space for a full backup.')}")
return self._is_yes(
input(
t(
"Back up anyway, at the risk of filling the disk?"
" (y/N): "
)
)
)
def _qemu_safe_shrink(self, name, disk, new_gb):
"""Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs +
sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition,
@ -1321,15 +1416,19 @@ class QemuManageMixin:
print(
f"{t('Missing tools for safe shrink:')} {', '.join(missing)}"
)
missing = self._qemu_install_shrink_tools(missing)
if missing:
print(
f"{t('Still missing, safe shrink cancelled:')}"
f" {', '.join(missing)}"
)
return False
target = int(round(new_gb * (1 << 30)))
# Sauvegarde OPTIONNELLE (défaut OUI) : permet de restaurer en cas
# d'échec, et de tester la VM avant de la supprimer (proposé à la fin).
self._shrink_backup = None
bak = None
if self._is_yes_default_yes(
input(t("Back up the disk before shrinking? (Y/n): "))
):
if self._qemu_ask_backup(disk):
bak = f"{disk}.bak"
print(f"\n{t('Backing up the disk before shrinking…')}")
if (
@ -2224,8 +2323,16 @@ class QemuManageMixin:
"""Nom de VM stable pour le parc, ex. erplibre-ubuntu-2404. Ajoute un
suffixe d'architecture quand elle diffère de la native de l'hôte (ex.
erplibre-ubuntu-2604-s390x sur un hôte amd64) pour éviter les collisions
de noms entre archis et rendre l'archi visible."""
base = f"erplibre-{distro}-{version.replace('.', '')}"
de noms entre archis et rendre l'archi visible.
La version « latest » ne figure pas dans le nom : une distribution en
publication continue n'en a qu'une, si bien que le segment ne
distingue aucune VM d'une autre. Une version nommée qui coexiste avec
d'autres au catalogue reste dans le nom, tumbleweed comprise."""
if version == "latest":
base = f"erplibre-{distro}"
else:
base = f"erplibre-{distro}-{version.replace('.', '')}"
if arch and arch != cls._native_arch():
base += f"-{arch}"
return base
@ -2248,12 +2355,10 @@ class QemuManageMixin:
"""Adresses IPv4 de L'HÔTE, à écarter des candidates d'une VM.
« virsh domifaddr --source arp » remonte la table ARP, où figurent les
passerelles des ponts libvirt (192.168.122.1, 192.168.123.1…). Une VM
n'a jamais l'adresse de son hôte : sans ce filtre, une VM RENOMMÉE —
dont le bail porte encore l'ancien nom d'hôte, donc sans
correspondance — se voyait attribuer la passerelle. Vécu sur
« erplibre-ubuntu-2404-MIGRATION », annoncée en 192.168.122.1 au lieu
de 192.168.123.170.
passerelles des ponts libvirt. Une VM n'a jamais l'adresse de son
hôte : sans ce filtre, une VM RENOMMÉE — dont le bail porte encore
l'ancien nom d'hôte, donc sans correspondance — se voit attribuer la
passerelle.
"""
try:
res = subprocess.run(
@ -2304,9 +2409,9 @@ class QemuManageMixin:
- agent : qemu-guest-agent DANS la VM (voit l'IP réelle même quand le
bail dnsmasq est absent) ;
- arp : table ARP de l'hôte (VM active sur le réseau).
On combine pour ne jamais rater une IP que le bail seul manquerait
(cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une
IP)."""
On combine pour ne jamais rater une IP que le bail seul manquerait :
sous forte charge, le bail dnsmasq reste vide alors que la VM a bien
une adresse."""
ips = []
siennes = QemuManageMixin._qemu_host_addresses()
for source in ("lease", "agent", "arp"):
@ -2396,8 +2501,8 @@ class QemuManageMixin:
# Sans correspondance de nom d'hôte — le cas d'une VM RENOMMÉE, dont
# le bail porte encore l'ancien nom — on prend la source la plus
# sûre : le bail, puis l'agent, puis la table ARP. Celle-ci contient
# les passerelles des ponts, et « la dernière candidate » y tombait :
# la VM était annoncée en 192.168.122.1.
# les passerelles des ponts, où « la dernière candidate » tombe : la
# VM se voit alors annoncée avec l'adresse de sa passerelle.
for source in ("lease", "agent", "arp"):
if par_source.get(source):
return par_source[source][-1]
@ -2549,10 +2654,9 @@ class QemuManageMixin:
tourne dans la VM — install_proxmox.sh, les scripts d'installation, le
Makefile — vient donc de là.
Vécu deux fois de suite. Un correctif de install_proxmox.sh, commité
ici, absent du distant : chaque VM déployée ensuite recevait l'ancien
script, et le défaut « revenait » alors qu'il était corrigé. Rien ne
le disait ; il a fallu comparer les deux versions à la main.
Un correctif commité ici mais pas poussé ne part donc pas : chaque VM
déployée ensuite reçoit l'ancien script, et le défaut « revient »
alors qu'il est corrigé. Rien ne le signale, d'où ce décompte.
"""
if not branche:
return 0, []

View file

@ -253,7 +253,14 @@ class QemuMenuMixin:
input(t("Install the QEMU/libvirt tools now? (Y/n): "))
):
return False
cmd = f"sudo {self._QEMU_QEMU_PKGS}"
# Sans --assume-yes-reboot : accepter d'installer des paquets n'est
# pas accepter de perdre ce qui tourne sur la machine. Quand le noyau
# a été remplacé depuis le démarrage, deploy_qemu.py pose la question
# sur /dev/tty, et un refus laisse l'hôte avec ses paquets posés.
cmd = (
"sudo ./script/qemu/deploy_qemu.py --setup-host --assume-yes"
" --reboot-if-needed"
)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
if shutil.which("virsh"):

View file

@ -26,7 +26,7 @@ sys.path.append(new_path)
from script.config import config_file
from script.execute import execute
from script.todo import todo_prefs
from script.todo import todo_install, todo_prefs
from script.todo.database_manager import DatabaseManager
from script.todo.longtest_menu import LongTestMenuMixin
from script.todo.proxmox_menu import ProxmoxMenuMixin
@ -256,7 +256,7 @@ class TODO(
[7] {t("Analyse - Odoo database analysis")}
── {t("Sources & documentation")} ──
[8] {t("Git - Git tools")}
[8] {t("Git - Git and shell tools")}
[9] {t("Doc - Documentation search")}
── {t("AI & automation")} ──
@ -2524,11 +2524,26 @@ class TODO(
if cmd_no_found:
print(t("Command not found !"))
# Les hooks que le dépôt fournit. git saute silencieusement un hook qui
# ne porte pas le bit d'exécution, d'où la vérification à l'installation.
_GIT_HOOKS = ("commit-msg", "pre-commit")
_GIT_HOOKS_PATH = os.path.join("script", "git", "hooks")
def prompt_execute_git(self):
print(f"🤖 {t('Git management tools!')}")
print(f"🤖 {t('Git and shell management tools!')}")
choices = [
{"prompt_description": t("Local git server")},
{"prompt_description": t("Add a remote to a local repository")},
{
"prompt_description": t(
"Install git hooks (commit-msg, pre-commit)"
)
},
{
"prompt_description": t(
"Set merge.conflictStyle to zdiff3 (global)"
)
},
]
# Append config-driven entries
@ -2536,6 +2551,29 @@ class TODO(
if config_entries:
choices.extend(config_entries)
# Starship ferme la liste : c'est un outil de shell, pas de git. Son
# rang dépend du nombre d'entrées venues de todo.json, donc « method »
# porte la destination dans l'entrée elle-même — un numéro codé en dur
# mènerait ailleurs dès qu'une entrée de configuration s'ajoute.
choices.append(
{
"prompt_description": t("Install Starship on Shell"),
"method": "_shell_install_starship",
}
)
choices.append(
{
"prompt_description": t("Install Claude Code"),
"method": "_shell_install_claude_code",
}
)
choices.append(
{
"prompt_description": t("Install opencode"),
"method": "_shell_install_opencode",
}
)
help_info = self.fill_help_info(choices)
while True:
@ -2547,6 +2585,10 @@ class TODO(
self.prompt_execute_git_local_server()
elif status == "2":
self._git_add_remote()
elif status == "3":
self._git_install_hooks()
elif status == "4":
self._git_set_conflict_style()
else:
cmd_no_found = True
try:
@ -2554,7 +2596,11 @@ class TODO(
if 0 < int_cmd <= len(choices):
cmd_no_found = False
instance = choices[int_cmd - 1]
self.execute_from_configuration(instance)
method = instance.get("method")
if method:
getattr(self, method)()
else:
self.execute_from_configuration(instance)
except ValueError:
pass
if cmd_no_found:
@ -2579,6 +2625,300 @@ class TODO(
except Exception as e:
print(f"{t('Error adding remote: ')}{e}")
def _git_install_hooks(self):
"""Pointer core.hooksPath sur les hooks du dépôt.
Le bit d'exécution fait partie de l'installation : sans lui git
ignore le hook sans rien dire, et le garde-fou du message de commit
passe inaperçu.
"""
racine = self._claude_context_root()
absolu = os.path.join(racine, self._GIT_HOOKS_PATH)
if not os.path.isdir(absolu):
print(f"{t('Hooks directory is missing: ')}{absolu}")
return
actuel = self._git_hooks_path(racine)
if actuel and actuel != self._GIT_HOOKS_PATH:
print(f"{t('Another hooks path is already set: ')}{actuel}")
if not self._is_yes(input(t("Replace it? (y/Y): "))):
print(t("Nothing to do."))
return
for hook in self._GIT_HOOKS:
chemin = os.path.join(absolu, hook)
if os.path.isfile(chemin) and not os.access(chemin, os.X_OK):
os.chmod(chemin, os.stat(chemin).st_mode | 0o111)
print(f"{t('Execution bit added: ')}{hook}")
# « -C racine » et non le cwd : lancé depuis un dépôt imbriqué
# (odoo18.0/addons/…), git écrirait core.hooksPath là-bas et la
# racine resterait sans garde-fou, sans le moindre message.
cmd = (
f"git -C {shlex.quote(racine)} config"
f" core.hooksPath {self._GIT_HOOKS_PATH}"
)
print(f"{t('Will execute:')} {cmd}")
# exec_command_live RETOURNE le code de sortie, il ne lève rien : sans
# ce test, un « fatal: not in a git directory » annonçait quand même
# « Hooks git installés! ». Le rapport qui suit ne rattrape pas, il
# relit le bit d'exécution et non core.hooksPath.
status = self.execute.exec_command_live(cmd, source_erplibre=False)
if status:
print(f"{t('Error installing hooks: ')}{status}")
return
print(t("Git hooks installed!"))
for hook in self._GIT_HOOKS:
pose = os.access(os.path.join(absolu, hook), os.X_OK)
marque = t("hook installed") if pose else t("hook not installed")
print(f" {hook:<26} {marque}")
def _git_set_conflict_style(self):
"""Poser merge.conflictStyle=zdiff3 dans la configuration globale.
zdiff3 ajoute la base commune aux marqueurs de conflit et sort de la
zone contestée les lignes que les deux côtés ont en commun : il reste
moins à arbitrer à la main. Le style demande git 2.35, que toutes les
plateformes supportées dépassent.
La valeur est relue après écriture : « git config » ne rend rien à
l'écriture, et une configuration globale en lecture seule échouerait
sans que le menu le sache.
"""
status = self.execute.exec_command_live(
"git config --global merge.conflictStyle zdiff3",
source_erplibre=False,
)
if status:
print(
f"❌ {t('Failed to set merge.conflictStyle, see the output above.')}"
)
return
result = self.execute.exec_command_live(
"git config --global --get merge.conflictStyle",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
value = (
" ".join(result[1]).strip() if isinstance(result, tuple) else ""
)
print(f"✅ merge.conflictStyle = {value}")
# Le shell -> son fichier de configuration.
_SHELL_RC = {
"bash": "~/.bashrc",
"zsh": "~/.zshrc",
"fish": "~/.config/fish/config.fish",
}
# Ce que chaque shell écrit pour lancer starship. La ligne va en FIN de
# fichier : starship compose le prompt et doit passer après tout ce qui y
# touche.
_STARSHIP_LINE = {
"bash": 'eval "$(starship init bash)"',
"zsh": 'eval "$(starship init zsh)"',
"fish": "starship init fish | source",
}
# L'installateur amont pose un binaire statique. Il sert de recours parce
# que le paquet manque d'une partie des dépôts des plateformes supportées.
_STARSHIP_UPSTREAM = "curl -sS https://starship.rs/install/install.sh | sh"
# Les assistants posés par un installateur amont : le nom du binaire mène
# à (commande, répertoire d'installation). Le répertoire sert à garantir
# le PATH — un binaire posé hors des chemins du shell reste introuvable.
_UPSTREAM_TOOLS = {
"claude": (
"curl -fsSL https://claude.ai/install.sh | bash",
"~/.local/bin",
),
"opencode": (
"curl -fsSL https://opencode.ai/install | bash",
"~/.opencode/bin",
),
}
@staticmethod
def _shell_name():
"""Le nom du shell de l'utilisateur d'après $SHELL, '' s'il est vide."""
return os.path.basename(os.environ.get("SHELL", "")).strip()
def _shell_rc_present(self):
"""Les shells dont le fichier de configuration existe déjà."""
return [
nom
for nom, fichier in self._SHELL_RC.items()
if os.path.exists(os.path.expanduser(fichier))
]
def _shell_rc_target(self):
"""Le shell à modifier. Ne demande que devant un vrai choix.
Aucun fichier de configuration présent : bash, sans question — l'appel
le créera. Un seul présent : celui-là, il n'y a rien à choisir. Deux ou
trois : à l'opérateur de trancher, le sien proposé par défaut.
"""
presents = self._shell_rc_present()
if not presents:
return "bash"
if len(presents) == 1:
return presents[0]
courant = self._shell_name()
defaut = courant if courant in presents else presents[0]
print(f"\n{t('Which shell configuration?')}")
for i, nom in enumerate(presents, 1):
print(f" [{i}] {nom:<5} {self._SHELL_RC[nom]}")
sel = input(
f"{t('Choice (number or name, default:')} {defaut}) : "
).strip()
if not sel:
return defaut
if sel in presents:
return sel
try:
idx = int(sel) - 1
if 0 <= idx < len(presents):
return presents[idx]
except ValueError:
pass
return defaut
def _shell_rc_append(self, shell, ligne, marqueur):
"""Ajouter la ligne au fichier du shell si le marqueur n'y est pas.
Rend le chemin du fichier quand la ligne est écrite, None quand le
marqueur y était déjà. Le marqueur, et non la ligne entière, parce
qu'une variante écrite à la main ou par un installateur amont compte
autant : ce qui importe est que l'effet soit là, pas la graphie.
"""
chemin = os.path.expanduser(self._SHELL_RC[shell])
contenu = ""
if os.path.exists(chemin):
with open(chemin, encoding="utf-8") as fh:
contenu = fh.read()
if marqueur in contenu:
return None
os.makedirs(os.path.dirname(chemin), exist_ok=True)
with open(chemin, "a", encoding="utf-8") as fh:
# Un fichier qui ne finit pas par un saut de ligne collerait la
# ligne ajoutée à la dernière commande.
if contenu and not contenu.endswith("\n"):
fh.write("\n")
fh.write(f"{ligne}\n")
return chemin
def _shell_path_line(self, shell, repertoire):
"""La ligne qui met un répertoire dans le PATH, selon le shell."""
if shell == "fish":
return f"fish_add_path {repertoire}"
return f'export PATH="{repertoire}:$PATH"'
def _shell_ensure_on_path(self, shell, repertoire):
"""Garantir que le répertoire est dans le PATH du shell choisi.
Ne fait rien si le répertoire y figure déjà, quelle que soit la
graphie — les installateurs amont écrivent souvent la ligne eux-mêmes.
"""
ligne = self._shell_path_line(shell, repertoire)
chemin = self._shell_rc_append(shell, ligne, repertoire)
if chemin is None:
print(f"✅ {t('Already on the PATH: ')}{repertoire}")
return
print(f"✅ {t('PATH line added to: ')}{chemin}")
print(f" {ligne}")
def _shell_install_starship(self):
"""Poser starship, puis l'accrocher au shell de l'utilisateur.
Deux étapes qui échouent séparément : le binaire, que le gestionnaire
de paquets de la distribution fournit quand il le connaît, et la ligne
d'initialisation dans le fichier de configuration du shell. Sans la
seconde, starship est installé et le prompt ne change pas.
"""
if shutil.which("starship") is None:
self._shell_install_starship_binary()
if shutil.which("starship") is None:
print(
f"❌ {t('starship is not installed, shell left untouched.')}"
)
return
self._shell_hook_starship()
def _shell_install_starship_binary(self):
"""Poser le binaire : le paquet de la distribution, sinon l'amont.
Un refus de l'opérateur arrête là. Un paquet inconnu ou une
installation en échec passent au recours amont, qui couvre les dépôts
où starship n'est pas empaqueté.
"""
cmd = todo_install.install_command(["starship"])
if cmd:
status = todo_install.ask_and_install(
self.execute,
cmd,
t("Install starship? (y/N): "),
self._is_yes,
)
if status is None:
return
if status == 0 and shutil.which("starship"):
return
print(f" {t('No starship package here, falling back upstream.')}")
todo_install.ask_and_install(
self.execute,
self._STARSHIP_UPSTREAM,
t("Run the upstream installer? (y/N): "),
self._is_yes,
)
def _shell_hook_starship(self):
"""Ajouter la ligne d'initialisation au fichier du shell choisi.
La ligne n'est écrite qu'une fois : « starship init » cherché dans le
fichier couvre les trois shells, dont les lignes diffèrent. L'écriture
ne demande pas de confirmation — le choix du fichier, quand il y en a
un à faire, l'a déjà donnée.
"""
shell = self._shell_rc_target()
ligne = self._STARSHIP_LINE[shell]
chemin = self._shell_rc_append(shell, ligne, "starship init")
if chemin is None:
fichier = os.path.expanduser(self._SHELL_RC[shell])
print(f"✅ {t('starship is already hooked into: ')}{fichier}")
return
print(f"✅ {t('starship hooked into: ')}{chemin}")
print(f" {ligne}")
print(f" {t('Open a new shell to see it.')}")
def _shell_install_claude_code(self):
self._shell_install_upstream_tool("claude")
def _shell_install_opencode(self):
self._shell_install_upstream_tool("opencode")
def _shell_install_upstream_tool(self, binaire):
"""Lancer l'installateur amont d'un assistant, puis garantir le PATH.
Ces installateurs posent leur binaire dans un répertoire du HOME que
le PATH d'un shell ne porte pas toujours : sans la ligne d'export, le
binaire est là et la commande reste introuvable. Le PATH du processus
courant, lui, est figé depuis son démarrage — le menu ne verra pas le
binaire avant d'être relancé.
"""
commande, repertoire = self._UPSTREAM_TOOLS[binaire]
status = self.execute.exec_command_live(
commande,
source_erplibre=False,
)
if status:
print(f"❌ {t('Installation failed, see the output above.')}")
return
self._shell_ensure_on_path(self._shell_rc_target(), repertoire)
pose = os.path.join(os.path.expanduser(repertoire), binaire)
if not os.path.exists(pose):
print(f"⚠ {t('Binary not found at: ')}{pose}")
return
print(f"✅ {binaire} : {pose}")
print(f" {t('Open a new shell to see it.')}")
def prompt_execute_git_local_server(self):
print(f"🤖 {t('Manage local git repository server!')}")
choices = [
@ -2687,6 +3027,11 @@ class TODO(
)
},
{"prompt_description": t("Show the context given to Claude")},
{
"prompt_description": t(
"Claude Code plugins - marketplaces and ERPLibre list"
)
},
]
help_info = self.fill_help_info(choices)
@ -2703,6 +3048,8 @@ class TODO(
self.prompt_execute_rtk()
elif status == "4":
self._show_claude_context()
elif status == "5":
self.prompt_execute_claude_plugins()
else:
print(t("Command not found !"))
@ -2712,7 +3059,19 @@ class TODO(
{"prompt_description": t("Commit - OCA/Odoo commit command")},
{
"prompt_description": t(
"Todo Add Command - Add a command to todo.py menu"
"Git prepare merge - Git merge preparation command"
)
},
{
"prompt_description": t(
"Todo Add Command + Plan Max - Plan and add a todo.py"
" command"
)
},
{
"prompt_description": t(
"Todo Generate Code - Code by the OCA rules at high"
" effort"
)
},
{"prompt_description": t("Show installed custom commands")},
@ -2731,11 +3090,28 @@ class TODO(
personalize=True,
)
elif status == "2":
self._setup_claude_command(
"git_prepare_merge",
"template_claude_commands_git_prepare_merge.md",
)
elif status == "3":
# Les deux gabarits vont ensemble : /todo_plan_max produit la
# spécification que /todo_add_command implémente, et l'un sans
# l'autre laisse la moitié de la chaîne.
self._setup_claude_command(
"todo_plan_max",
"template_claude_commands_todo_plan_max.md",
)
self._setup_claude_command(
"todo_add_command",
"template_claude_commands_todo_add_command.md",
)
elif status == "3":
elif status == "4":
self._setup_claude_command(
"todo_generate_code",
"template_claude_commands_todo_generate_code.md",
)
elif status == "5":
self._list_claude_commands()
else:
print(t("Command not found !"))
@ -2863,7 +3239,14 @@ class TODO(
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",
}
for nom, gabarit in sorted(gabarits.items()):
etat = self._claude_command_state(
@ -2879,7 +3262,7 @@ class TODO(
)
if chemin_hooks:
absolu = os.path.join(racine, chemin_hooks)
for hook in ("commit-msg", "pre-commit"):
for hook in self._GIT_HOOKS:
pose = os.access(os.path.join(absolu, hook), os.X_OK)
marque = (
t("hook installed") if pose else t("hook not installed")
@ -2981,6 +3364,231 @@ class TODO(
except Exception as e:
print(f"{t('Error adding automation: ')}{e}")
# Les plugins qu'ERPLibre pose par défaut, chacun avec la clé qui dit à
# quoi il sert. Tous viennent du marketplace officiel et travaillent sur
# le poste : aucun n'appelle un service tiers ni ne réclame de compte.
_CLAUDE_PREFERRED_PLUGINS = (
("superpowers", "brainstorming, subagent-driven development, TDD"),
("pyright-lsp", "Python type checking and code intelligence"),
("claude-security", "vulnerability scan run entirely in session"),
(
"skill-creator",
"write, improve and evaluate the repository skills",
),
)
_CLAUDE_MARKETPLACES_DIR = "~/.claude/plugins/marketplaces"
def prompt_execute_claude_plugins(self):
print(f"🤖 {t('Manage Claude Code plugins and marketplaces!')}")
choices = [
{"section": t("Inventory")},
{"prompt_description": t("List installed plugins")},
{"prompt_description": t("List configured marketplaces")},
{"prompt_description": t("Search a plugin in the marketplaces")},
{
"prompt_description": t(
"Show a plugin detail and its token cost"
)
},
{"section": t("Install")},
{"prompt_description": t("Install the ERPLibre preferred list")},
{"prompt_description": t("Install a plugin by name")},
{"prompt_description": t("Add a marketplace")},
{"section": t("Maintenance")},
{
"prompt_description": t(
"Update the marketplaces and the plugins"
)
},
{"prompt_description": t("Uninstall a plugin")},
]
help_info = self.fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return False
elif status == "1":
self._claude_plugin_exec("list")
elif status == "2":
self._claude_plugin_exec("marketplace list")
elif status == "3":
self._claude_plugin_search()
elif status == "4":
self._claude_plugin_details()
elif status == "5":
self._claude_install_preferred_plugins()
elif status == "6":
self._claude_plugin_install_by_name()
elif status == "7":
self._claude_marketplace_add()
elif status == "8":
self._claude_plugin_update()
elif status == "9":
self._claude_plugin_uninstall()
else:
print(t("Command not found !"))
def _claude_plugin_exec(self, args, quiet=False, capture=False):
"""Lance « claude plugin <args> », ou signale que claude est absent.
Rend le code de sortie, ou le couple (code, lignes) quand capture est
vrai. Le code 1 sans sortie signale l'absence de l'exécutable : rien
n'a tourné, et l'appelant ne doit pas conclure à un échec de la
commande elle-même.
"""
claude = shutil.which("claude")
if claude is None:
print(t("The claude command is not in the PATH."))
return (1, []) if capture else 1
return self.execute.exec_command_live(
f"{shlex.quote(claude)} plugin {args}",
source_erplibre=False,
quiet=quiet,
return_status_and_output=capture,
)
def _claude_plugin_is_installed(self, name):
"""Le plugin est-il déjà posé ?
La liste est lue telle que la CLI l'écrit, et le nom y est cherché
comme un mot entier : « code-review » ne doit pas se reconnaître dans
« pr-review-toolkit ». Un doute rend faux, et l'installation qui suit
est de toute façon idempotente.
"""
result = self._claude_plugin_exec("list", quiet=True, capture=True)
if not isinstance(result, tuple) or result[0] != 0:
return False
motif = re.compile(rf"(?<![\w-]){re.escape(name)}(?![\w-])")
return any(motif.search(ligne) for ligne in result[1])
def _claude_install_preferred_plugins(self):
"""Pose la liste préférée d'ERPLibre après confirmation.
L'installation passe par « -y » : la sortie de TODO est un tuyau, pas
un terminal, et la CLI refuse sans lui toute installation qui exécute
une commande déclarée par un marketplace. La liste est donc affichée
AVANT la confirmation, qui est la seule occasion de la lire.
"""
print(t("ERPLibre preferred plugins:"))
print("-" * 62)
for nom, raison in self._CLAUDE_PREFERRED_PLUGINS:
print(f" {nom:<18} {t(raison)}")
print("-" * 62)
if not self._is_yes(input(t("Install these plugins? (y/Y): "))):
print(t("Nothing to do."))
return
for nom, _ in self._CLAUDE_PREFERRED_PLUGINS:
print(f"\n📦 {nom}")
if self._claude_plugin_is_installed(nom):
print(t("Already installed, skipped."))
continue
self._claude_plugin_exec(f"install {shlex.quote(nom)} -y")
print(f"\n{t('A restart of Claude Code applies the change.')}")
def _claude_plugin_install_by_name(self):
nom = input(t("Plugin name: ")).strip()
if not nom:
print(t("Nothing to do."))
return
self._claude_plugin_exec(f"install {shlex.quote(nom)} -y")
print(t("A restart of Claude Code applies the change."))
def _claude_plugin_details(self):
nom = input(t("Plugin name: ")).strip()
if not nom:
print(t("Nothing to do."))
return
self._claude_plugin_exec(f"details {shlex.quote(nom)}")
def _claude_plugin_uninstall(self):
nom = input(t("Plugin name: ")).strip()
if not nom:
print(t("Nothing to do."))
return
if not self._is_yes(input(t("Uninstall this plugin? (y/Y): "))):
print(t("Nothing to do."))
return
self._claude_plugin_exec(f"uninstall {shlex.quote(nom)}")
print(t("A restart of Claude Code applies the change."))
def _claude_marketplace_add(self):
source = input(
t("Marketplace source (URL, path or owner/repo): ")
).strip()
if not source:
print(t("Nothing to do."))
return
self._claude_plugin_exec(f"marketplace add {shlex.quote(source)}")
def _claude_plugin_update(self):
"""Met à jour les marketplaces, puis les plugins déjà posés.
Les catalogues passent d'abord : « plugin update » installe la version
que le catalogue local annonce, et sur un catalogue périmé il ne fait
rien tout en sortant en 0.
"""
self._claude_plugin_exec("marketplace update")
for nom, _ in self._CLAUDE_PREFERRED_PLUGINS:
if self._claude_plugin_is_installed(nom):
self._claude_plugin_exec(f"update {shlex.quote(nom)}")
print(t("A restart of Claude Code applies the change."))
def _claude_marketplace_catalog(self):
"""Les plugins des marketplaces posés, en triplets (nom, source, mot).
Le catalogue est lu sur le disque plutôt que par la CLI : la recherche
reste possible hors ligne, et un marketplace dont le manifeste est
illisible est sauté sans faire échouer les autres.
"""
racine = os.path.expanduser(self._CLAUDE_MARKETPLACES_DIR)
catalogue = []
if not os.path.isdir(racine):
return catalogue
for nom_marche in sorted(os.listdir(racine)):
manifeste = os.path.join(
racine, nom_marche, ".claude-plugin", "marketplace.json"
)
try:
with open(manifeste, encoding="utf-8") as fh:
contenu = json.load(fh)
except (OSError, ValueError):
continue
for plugin in contenu.get("plugins", []):
nom = plugin.get("name", "")
if nom:
catalogue.append(
(nom, nom_marche, plugin.get("description", ""))
)
return catalogue
def _claude_plugin_search(self):
"""Cherche un mot-clé dans le nom et la description des plugins."""
catalogue = self._claude_marketplace_catalog()
if not catalogue:
print(t("No marketplace is configured."))
return
mot = input(t("Keyword to search: ")).strip().lower()
if not mot:
print(t("Nothing to do."))
return
trouves = [
(nom, marche, desc)
for nom, marche, desc in catalogue
if mot in nom.lower() or mot in desc.lower()
]
if not trouves:
print(t("No plugin matches this keyword."))
return
print("-" * 78)
for nom, marche, desc in trouves:
print(f" {nom}@{marche}")
if desc:
print(f" {desc[:70]}")
print("-" * 78)
print(f"{t('Total:')} {len(trouves)}")
def prompt_execute_doc(self):
print(f"🤖 {t('Looking for documentation?')}")
choices = [
@ -4170,6 +4778,83 @@ class TODO(
else:
print(t("Command not found !"))
def rtk_locate(self):
"""Localise l'exécutable rtk.
Rend le couple (chemin, visible_dans_le_PATH). Le chemin vaut None
quand rtk est introuvable. Le second membre est faux quand le binaire
existe à l'emplacement où l'installateur le dépose sans que le PATH y
mène : un processus garde le PATH qu'il avait au démarrage, donc une
installation faite pendant que TODO tourne lui reste invisible tant
qu'il n'est pas relancé.
"""
rtk_path = shutil.which("rtk")
if rtk_path:
return rtk_path, True
# Emplacement par défaut de l'installateur (RTK_INSTALL_DIR le change).
fallback = os.path.expanduser("~/.local/bin/rtk")
if os.access(fallback, os.X_OK):
return fallback, False
return None, False
def rtk_exec(self, args):
"""Lance rtk par son chemin absolu, ou signale qu'il est absent.
Le chemin absolu évite le code 127 d'un « rtk » nu quand le PATH du
processus ne mène pas à l'emplacement d'installation.
"""
rtk_path, _ = self.rtk_locate()
if rtk_path is None:
print(t("RTK is not installed. Use option 1 to install it."))
return 1
return self.execute.exec_command_live(
f"{shlex.quote(rtk_path)} {args}",
source_erplibre=False,
)
def rtk_version(self, rtk_path):
"""Rend la version qu'annonce le binaire, « ? » s'il ne répond pas."""
result = self.execute.exec_command_live(
f"{shlex.quote(rtk_path)} --version",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
)
if isinstance(result, tuple) and result[0] == 0:
return " ".join(result[1]).strip()
return "?"
def rtk_report_path_warning(self):
"""Dit comment rendre rtk appelable quand le PATH ne le porte pas."""
print(t("rtk is not in the PATH of this process, restart TODO."))
print(t("To make it permanent, add to your shell profile:"))
print(' export PATH="$HOME/.local/bin:$PATH"')
def rtk_report_install(self, exit_code):
"""Annonce le résultat de l'installation, PATH compris.
Une installation réussie ne rend pas rtk appelable pour autant : le
binaire atterrit dans un répertoire que le PATH du processus courant
peut ignorer. Distinguer les deux cas évite de conclure à un échec
devant un « commande introuvable » qui ne tient qu'au PATH.
"""
if exit_code:
print(f"❌ {t('RTK installation failed, see the output above.')}")
return
rtk_path, in_path = self.rtk_locate()
if rtk_path is None:
print(
"❌"
f" {t('Installation ended without error, but no rtk binary was found.')}"
)
return
print(
f"✅ {t('RTK is installed, version: ')}{self.rtk_version(rtk_path)}"
)
print(f" {rtk_path}")
if not in_path:
self.rtk_report_path_warning()
def rtk_install(self):
print(f"🤖 {t('Installation method:')}")
choices = [
@ -4187,64 +4872,47 @@ class TODO(
if status == "0":
return
elif status == "1":
self.execute.exec_command_live(
"curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh",
source_erplibre=False,
command = (
"curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/"
"refs/heads/master/install.sh | sh"
)
elif status == "2":
self.execute.exec_command_live(
"brew install rtk",
source_erplibre=False,
)
command = "brew install rtk"
elif status == "3":
self.execute.exec_command_live(
"cargo install --git https://github.com/rtk-ai/rtk",
source_erplibre=False,
)
command = "cargo install --git https://github.com/rtk-ai/rtk"
else:
print(t("Command not found !"))
return
exit_code = self.execute.exec_command_live(
command,
source_erplibre=False,
)
self.rtk_report_install(exit_code)
def rtk_check_version(self):
self.execute.exec_command_live(
"rtk --version",
source_erplibre=False,
)
self.rtk_exec("--version")
def rtk_show_gain(self):
self.execute.exec_command_live(
"rtk gain",
source_erplibre=False,
)
self.rtk_exec("gain")
def rtk_discover(self):
self.execute.exec_command_live(
"rtk discover",
source_erplibre=False,
)
self.rtk_exec("discover")
def rtk_init_global(self):
self.execute.exec_command_live(
"rtk init --global",
source_erplibre=False,
)
self.rtk_exec("init --global")
def rtk_check_status(self):
rtk_path = shutil.which("rtk")
rtk_path, in_path = self.rtk_locate()
if rtk_path is None:
print(t("RTK is not installed. Use option 1 to install it."))
return
result = self.execute.exec_command_live(
"rtk --version",
source_erplibre=False,
quiet=True,
return_status_and_output=True,
print(
f"{t('RTK is installed, version: ')}{self.rtk_version(rtk_path)}"
)
if isinstance(result, tuple) and result[0] == 0:
version_output = " ".join(result[1]).strip()
print(f"{t('RTK is installed, version: ')}{version_output}")
else:
print(f"{t('RTK is installed, version: ')}?")
print(f" {rtk_path}")
if not in_path:
self.rtk_report_path_warning()
config_path = os.path.expanduser("~/.config/rtk/config.toml")
if os.path.exists(config_path):

View file

@ -367,6 +367,25 @@ TRANSLATIONS = {
"fr": "RTK est installé, version : ",
"en": "RTK is installed, version: ",
},
"RTK installation failed, see the output above.": {
"fr": "L'installation de RTK a échoué, voir la sortie ci-dessus.",
"en": "RTK installation failed, see the output above.",
},
"Installation ended without error, but no rtk binary was found.": {
"fr": (
"L'installation s'est terminée sans erreur, mais aucun binaire"
" rtk n'a été trouvé."
),
"en": "Installation ended without error, but no rtk binary was found.",
},
"rtk is not in the PATH of this process, restart TODO.": {
"fr": "rtk n'est pas dans le PATH de ce processus, relancer TODO.",
"en": "rtk is not in the PATH of this process, restart TODO.",
},
"To make it permanent, add to your shell profile:": {
"fr": "Pour que ce soit permanent, ajouter au profil du shell :",
"en": "To make it permanent, add to your shell profile:",
},
"Global auto-rewrite hook: active": {
"fr": "Hook auto-rewrite global : actif",
"en": "Global auto-rewrite hook: active",
@ -802,17 +821,17 @@ TRANSLATIONS = {
"en": "Module name is required!",
},
# Git section
"Git - Git tools": {
"fr": "🌿 Git - Outils Git",
"en": "🌿 Git - Git tools",
"Git - Git and shell tools": {
"fr": "🌿 Git - Outils Git et Shell",
"en": "🌿 Git - Git and shell tools",
},
"Git management tools!": {
"fr": "Outils de gestion Git!",
"en": "Git management tools!",
"Git and shell management tools!": {
"fr": "Outils de gestion Git et Shell!",
"en": "Git and shell management tools!",
},
"Local git server": {
"fr": "Serveur git local",
"en": "Local git server",
"fr": "🗄 Serveur git local",
"en": "🗄 Local git server",
},
"Manage local git repository server!": {
"fr": "Gérer le serveur de dépôts git local!",
@ -860,8 +879,8 @@ TRANSLATIONS = {
},
# Git remote add
"Add a remote to a local repository": {
"fr": "Ajouter un remote vers un dépôt local",
"en": "Add a remote to a local repository",
"fr": "🔗 Ajouter un remote vers un dépôt local",
"en": "🔗 Add a remote to a local repository",
},
"Remote name (default: localhost): ": {
"fr": "Nom du remote (défaut: localhost) : ",
@ -883,14 +902,115 @@ TRANSLATIONS = {
"fr": "Erreur lors de l'ajout du remote : ",
"en": "Error adding remote: ",
},
# Git hooks
# Starship
"Install Starship on Shell": {
"fr": "🚀 Installer Starship sur le shell",
"en": "🚀 Install Starship on Shell",
},
"Install Claude Code": {
"fr": "🤖 Installer Claude Code",
"en": "🤖 Install Claude Code",
},
"Install opencode": {
"fr": "🧠 Installer opencode",
"en": "🧠 Install opencode",
},
"Installation failed, see the output above.": {
"fr": "L'installation a échoué, voir la sortie ci-dessus.",
"en": "Installation failed, see the output above.",
},
"Already on the PATH: ": {
"fr": "Déjà dans le PATH : ",
"en": "Already on the PATH: ",
},
"PATH line added to: ": {
"fr": "Ligne de PATH ajoutée à : ",
"en": "PATH line added to: ",
},
"Binary not found at: ": {
"fr": "Binaire introuvable à : ",
"en": "Binary not found at: ",
},
"Install starship? (y/N): ": {
"fr": "Installer starship ? (o/N) : ",
"en": "Install starship? (y/N): ",
},
"No starship package here, falling back upstream.": {
"fr": "Aucun paquet starship ici, recours à l'installateur amont.",
"en": "No starship package here, falling back upstream.",
},
"Run the upstream installer? (y/N): ": {
"fr": "Lancer l'installateur amont ? (o/N) : ",
"en": "Run the upstream installer? (y/N): ",
},
"starship is not installed, shell left untouched.": {
"fr": "starship n'est pas installé, le shell reste intact.",
"en": "starship is not installed, shell left untouched.",
},
"Which shell configuration?": {
"fr": "Quel fichier de configuration de shell ?",
"en": "Which shell configuration?",
},
"starship is already hooked into: ": {
"fr": "starship est déjà accroché à : ",
"en": "starship is already hooked into: ",
},
"starship hooked into: ": {
"fr": "starship accroché à : ",
"en": "starship hooked into: ",
},
"Open a new shell to see it.": {
"fr": "Ouvrir un nouveau shell pour le voir.",
"en": "Open a new shell to see it.",
},
"Set merge.conflictStyle to zdiff3 (global)": {
"fr": "🔀 Configurer merge.conflictStyle en zdiff3 (global)",
"en": "🔀 Set merge.conflictStyle to zdiff3 (global)",
},
"Failed to set merge.conflictStyle, see the output above.": {
"fr": (
"Échec du réglage de merge.conflictStyle, voir la sortie"
" ci-dessus."
),
"en": "Failed to set merge.conflictStyle, see the output above.",
},
"Install git hooks (commit-msg, pre-commit)": {
"fr": "🪝 Installer les hooks git (commit-msg, pre-commit)",
"en": "🪝 Install git hooks (commit-msg, pre-commit)",
},
"Hooks directory is missing: ": {
"fr": "Le répertoire des hooks est absent : ",
"en": "Hooks directory is missing: ",
},
"Another hooks path is already set: ": {
"fr": "Un autre chemin de hooks est déjà configuré : ",
"en": "Another hooks path is already set: ",
},
"Replace it? (y/Y): ": {
"fr": "Le remplacer? (y/Y) : ",
"en": "Replace it? (y/Y): ",
},
"Execution bit added: ": {
"fr": "Bit d'exécution ajouté : ",
"en": "Execution bit added: ",
},
"Git hooks installed!": {
"fr": "Hooks git installés!",
"en": "Git hooks installed!",
},
"Error installing hooks: ": {
"fr": "Erreur lors de l'installation des hooks : ",
"en": "Error installing hooks: ",
},
# Git config vim
"Configure git local editor to vim": {
"fr": "Configuration git local par vim",
"en": "Configure git local editor to vim",
"fr": "📝 Configuration git local par vim",
"en": "📝 Configure git local editor to vim",
},
"Generate git patch to /tmp": {
"fr": "Générer une patch git dans /tmp",
"en": "Generate git patch to /tmp",
"fr": "🩹 Générer une patch git dans /tmp",
"en": "🩹 Generate git patch to /tmp",
},
"Git editor configured to vim successfully!": {
"fr": "Éditeur git configuré sur vim avec succès!",
@ -1056,9 +1176,22 @@ TRANSLATIONS = {
"fr": "Commit - Commande de commit OCA/Odoo",
"en": "Commit - OCA/Odoo commit command",
},
"Todo Add Command - Add a command to todo.py menu": {
"fr": "Todo Add Command - Ajouter une commande au menu todo.py",
"en": "Todo Add Command - Add a command to todo.py menu",
"Git prepare merge - Git merge preparation command": {
"fr": "Git prepare merge - Commande de préparation merge git",
"en": "Git prepare merge - Git merge preparation command",
},
"Todo Add Command + Plan Max - Plan and add a todo.py command": {
"fr": (
"Todo Add Command + Plan Max - Planifier et ajouter une"
" commande au menu todo.py"
),
"en": "Todo Add Command + Plan Max - Plan and add a todo.py command",
},
"Todo Generate Code - Code by the OCA rules at high effort": {
"fr": (
"Todo Generate Code - Coder selon les règles OCA, effort élevé"
),
"en": "Todo Generate Code - Code by the OCA rules at high effort",
},
"Enter your full name: ": {
"fr": "Entrez votre nom complet : ",
@ -1072,6 +1205,123 @@ TRANSLATIONS = {
"fr": "Déployer les commandes Claude Code!",
"en": "Deploy Claude Code commands!",
},
# Claude Code plugins
"Claude Code plugins - marketplaces and ERPLibre list": {
"fr": "Plugins Claude Code - marketplaces et liste ERPLibre",
"en": "Claude Code plugins - marketplaces and ERPLibre list",
},
"Manage Claude Code plugins and marketplaces!": {
"fr": "Gérer les plugins et les marketplaces de Claude Code!",
"en": "Manage Claude Code plugins and marketplaces!",
},
"Inventory": {
"fr": "Inventaire",
"en": "Inventory",
},
"List installed plugins": {
"fr": "Lister les plugins installés",
"en": "List installed plugins",
},
"List configured marketplaces": {
"fr": "Lister les marketplaces configurés",
"en": "List configured marketplaces",
},
"Search a plugin in the marketplaces": {
"fr": "Chercher un plugin dans les marketplaces",
"en": "Search a plugin in the marketplaces",
},
"Show a plugin detail and its token cost": {
"fr": "Afficher le détail d'un plugin et son coût en tokens",
"en": "Show a plugin detail and its token cost",
},
"Install": {
"fr": "Installer",
"en": "Install",
},
"Install the ERPLibre preferred list": {
"fr": "Installer la liste préférée d'ERPLibre",
"en": "Install the ERPLibre preferred list",
},
"Install a plugin by name": {
"fr": "Installer un plugin par son nom",
"en": "Install a plugin by name",
},
"Add a marketplace": {
"fr": "Ajouter un marketplace",
"en": "Add a marketplace",
},
"Maintenance": {
"fr": "Maintenance",
"en": "Maintenance",
},
"Update the marketplaces and the plugins": {
"fr": "Mettre à jour les marketplaces et les plugins",
"en": "Update the marketplaces and the plugins",
},
"Uninstall a plugin": {
"fr": "Désinstaller un plugin",
"en": "Uninstall a plugin",
},
"The claude command is not in the PATH.": {
"fr": "La commande claude n'est pas dans le PATH.",
"en": "The claude command is not in the PATH.",
},
"ERPLibre preferred plugins:": {
"fr": "Plugins préférés d'ERPLibre :",
"en": "ERPLibre preferred plugins:",
},
"brainstorming, subagent-driven development, TDD": {
"fr": ("brainstorming, développement piloté par sous-agents, TDD"),
"en": "brainstorming, subagent-driven development, TDD",
},
"Python type checking and code intelligence": {
"fr": "typage Python et intelligence de code",
"en": "Python type checking and code intelligence",
},
"vulnerability scan run entirely in session": {
"fr": "analyse de vulnérabilités entièrement en session",
"en": "vulnerability scan run entirely in session",
},
"write, improve and evaluate the repository skills": {
"fr": "écrire, améliorer et évaluer les skills du dépôt",
"en": "write, improve and evaluate the repository skills",
},
"Install these plugins? (y/Y): ": {
"fr": "Installer ces plugins ? (o/O) : ",
"en": "Install these plugins? (y/Y): ",
},
"Already installed, skipped.": {
"fr": "Déjà installé, ignoré.",
"en": "Already installed, skipped.",
},
"Plugin name: ": {
"fr": "Nom du plugin : ",
"en": "Plugin name: ",
},
"Marketplace source (URL, path or owner/repo): ": {
"fr": "Source du marketplace (URL, chemin ou propriétaire/dépôt) : ",
"en": "Marketplace source (URL, path or owner/repo): ",
},
"Keyword to search: ": {
"fr": "Mot-clé à chercher : ",
"en": "Keyword to search: ",
},
"No marketplace is configured.": {
"fr": "Aucun marketplace n'est configuré.",
"en": "No marketplace is configured.",
},
"No plugin matches this keyword.": {
"fr": "Aucun plugin ne correspond à ce mot-clé.",
"en": "No plugin matches this keyword.",
},
"Uninstall this plugin? (y/Y): ": {
"fr": "Désinstaller ce plugin ? (o/O) : ",
"en": "Uninstall this plugin? (y/Y): ",
},
"A restart of Claude Code applies the change.": {
"fr": "Un redémarrage de Claude Code applique le changement.",
"en": "A restart of Claude Code applies the change.",
},
"Show installed custom commands": {
"fr": "Afficher les commandes personnalisées installées",
"en": "Show installed custom commands",
@ -2784,6 +3034,42 @@ TRANSLATIONS = {
"fr": "Outils manquants pour la réduction sûre :",
"en": "Missing tools for safe shrink:",
},
"Install them? (y/N): ": {
"fr": "Les installer? (y/N) : ",
"en": "Install them? (y/N): ",
},
"Error installing the tools: ": {
"fr": "Erreur lors de l'installation des outils : ",
"en": "Error installing the tools: ",
},
"Still missing, safe shrink cancelled:": {
"fr": "Manquent toujours, réduction sûre annulée :",
"en": "Still missing, safe shrink cancelled:",
},
"No package known here for:": {
"fr": "Aucun paquet connu ici pour :",
"en": "No package known here for:",
},
"Tools installed; on with the shrink.": {
"fr": "Outils installés ; on passe à la réduction.",
"en": "Tools installed; on with the shrink.",
},
"A backup doubles the space used:": {
"fr": "Une sauvegarde double la place occupée :",
"en": "A backup doubles the space used:",
},
"free here:": {
"fr": "libre ici :",
"en": "free here:",
},
"Not enough free space for a full backup.": {
"fr": "Pas assez de place libre pour une sauvegarde complète.",
"en": "Not enough free space for a full backup.",
},
"Back up anyway, at the risk of filling the disk? (y/N): ": {
"fr": "Sauvegarder quand même, au risque de remplir le disque? (y/N) : ",
"en": "Back up anyway, at the risk of filling the disk? (y/N): ",
},
"Backing up the disk before shrinking…": {
"fr": "Sauvegarde du disque avant réduction…",
"en": "Backing up the disk before shrinking…",

157
script/todo/todo_install.py Normal file
View file

@ -0,0 +1,157 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Poser un paquet, une seule fois pour tout le CLI.
Quatre familles de gestionnaires, et trois choses qui changent de l'une à
l'autre : la COMMANDE, le NOM du paquet, et le BINAIRE qu'il pose. Chaque
appelant qui refaisait ce travail chez lui en couvrait un sous-ensemble
différent — d'où un CLI où l'on pouvait installer virt-viewer sous openSUSE
mais pas un navigateur ni lm-sensors.
Deux règles que la composante tient à la place des appelants :
- l'ID de /etc/os-release décide AVANT le PATH. Une machine peut porter
plusieurs gestionnaires — un poste Debian avec dnf installé pour construire
un paquet — et c'est l'ID qui dit lequel gouverne le système ;
- la commande s'affiche AVANT la question. On approuve ce qu'on a lu, pas un
« (y/N) » posé à l'aveugle.
"""
import shlex
import shutil
from script.todo.todo_i18n import t
# Les familles, dans l'ordre où le PATH est interrogé quand l'ID de la
# distribution est inconnu.
FAMILIES = ("apt-get", "dnf", "pacman", "zypper")
# Le préfixe de commande de chaque famille ; les paquets s'ajoutent au bout.
# Tous non interactifs : un menu qui rend la main à un prompt apt bloque.
_INSTALL = {
"apt-get": ("sudo", "apt-get", "install", "-y"),
"dnf": ("sudo", "dnf", "install", "-y"),
"pacman": ("sudo", "pacman", "-S", "--needed", "--noconfirm"),
"zypper": ("sudo", "zypper", "--non-interactive", "install"),
}
# ID de /etc/os-release -> famille. Les dérivées sont nommées explicitement :
# ID_LIKE existe mais manque ou ment sur assez de distributions pour qu'on ne
# s'y fie pas seul, et le repli par le PATH couvre ce qui n'est pas listé.
_OS_ID_FAMILY = {
"ubuntu": "apt-get",
"debian": "apt-get",
"linuxmint": "apt-get",
"pop": "apt-get",
"raspbian": "apt-get",
"fedora": "dnf",
"rhel": "dnf",
"centos": "dnf",
"almalinux": "dnf",
"rocky": "dnf",
"arch": "pacman",
"manjaro": "pacman",
"endeavouros": "pacman",
"opensuse": "zypper",
"opensuse-leap": "zypper",
"opensuse-tumbleweed": "zypper",
"sles": "zypper",
}
def os_id() -> str:
"""L'ID de la distribution hôte, ou '' si /etc/os-release est illisible."""
try:
with open("/etc/os-release", encoding="utf-8") as fh:
for line in fh:
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"').lower()
except OSError:
pass
return ""
def family() -> str | None:
"""La famille qui gouverne cette machine, ou None si aucune n'est connue.
L'ID de la distribution d'abord, le PATH ensuite : sur une machine qui
porte deux gestionnaires, l'ID dit lequel possède le système.
"""
connue = _OS_ID_FAMILY.get(os_id())
if connue and shutil.which(connue):
return connue
for candidate in FAMILIES:
if shutil.which(candidate):
return candidate
return None
def install_command(paquets, famille=None) -> list | None:
"""La commande d'installation, en liste d'arguments. None si personne.
`paquets` est une liste de noms, ou un dict {famille: liste} quand le
paquet ne porte pas le même nom partout (lm-sensors chez Debian,
lm_sensors chez Fedora et Arch).
"""
famille = famille or family()
if not famille:
return None
if isinstance(paquets, dict):
paquets = paquets.get(famille) or []
paquets = [paquets] if isinstance(paquets, str) else list(paquets)
if not paquets:
return None
return list(_INSTALL[famille]) + paquets
def resolve(binaires, commun=None, par_famille=None, famille=None):
"""(paquets, inconnus) pour les binaires demandés.
Le nom du binaire n'est presque jamais celui du paquet : `commun` donne la
correspondance quand elle vaut pour les quatre familles, `par_famille` la
corrige là où elle diverge. Les paquets sortent dédoublonnés et dans
l'ordre demandé — trois binaires d'un même paquet ne le demandent qu'une
fois. `inconnus` liste ce pour quoi aucun paquet n'est déclaré : à dire,
jamais à deviner.
"""
famille = famille or family()
surcharge = (par_famille or {}).get(famille, {})
commun = commun or {}
paquets, inconnus = [], []
for binaire in binaires:
paquet = surcharge.get(binaire) or commun.get(binaire)
if not paquet:
inconnus.append(binaire)
elif paquet not in paquets:
paquets.append(paquet)
return paquets, inconnus
def show_and_ask(cmd, question, is_yes, prefix=" ") -> bool:
"""Afficher la commande, PUIS demander. True si l'opérateur accepte.
L'ordre est le fond de l'affaire : une question posée avant la commande
fait approuver à l'aveugle. Centralisé ici pour qu'aucun appelant ne
puisse l'inverser.
"""
lisible = cmd if isinstance(cmd, str) else shlex.join(cmd)
print(f"{prefix}{t('Will execute:')} {lisible}")
return bool(is_yes(input(question)))
def ask_and_install(execute, cmd, question, is_yes, prefix=" "):
"""Proposer puis lancer. Rend le code de sortie, ou None si rien n'a été
lancé — refus de l'opérateur, ou aucune commande à proposer.
`execute` est le lanceur du CLI : exec_command_live REND le code de sortie
et ne lève rien, donc l'appelant doit tester ce qui revient.
"""
if not cmd:
print(f"{prefix}⚠ {t('no known package manager here.')}")
return None
if not show_and_ask(cmd, question, is_yes, prefix=prefix):
print(t("Nothing to do."))
return None
lisible = cmd if isinstance(cmd, str) else shlex.join(cmd)
return execute.exec_command_live(lisible, source_erplibre=False)

View file

@ -530,37 +530,25 @@ def _command_columns(tree, paths):
# --------------------------------------------------------------------------- #
# Télémétrie SYSTÈME (vue F2)
# --------------------------------------------------------------------------- #
def _os_id() -> str:
try:
for line in open("/etc/os-release", encoding="utf-8"):
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"').lower()
except OSError:
pass
return ""
def sensors_install_command():
"""Commande d'installation de lm-sensors selon l'OS (nos 4 systèmes)."""
apt = ["sudo", "apt-get", "install", "-y", "lm-sensors"]
dnf = ["sudo", "dnf", "install", "-y", "lm_sensors"]
pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", "lm_sensors"]
cmd = {
"ubuntu": apt,
"debian": apt,
"linuxmint": apt,
"fedora": dnf,
"arch": pac,
}.get(_os_id())
if cmd:
return cmd
if shutil.which("apt-get"):
return apt
if shutil.which("dnf"):
return dnf
if shutil.which("pacman"):
return pac
return None
"""Commande d'installation de lm-sensors, ou None si aucun gestionnaire
de paquets connu.
Le paquet change de nom : « lm-sensors » chez Debian, « lm_sensors »
ailleurs. Cette écriture-ci ne connaissait pas zypper, et openSUSE ne
pouvait donc pas l'installer."""
# Importé ici et non en tête : l'import de todo_i18n de ce module est
# protégé pour qu'il tourne en autonome, et todo_install en dépend.
from script.todo import todo_install
return todo_install.install_command(
{
"apt-get": ["lm-sensors"],
"dnf": ["lm_sensors"],
"pacman": ["lm_sensors"],
"zypper": ["sensors"],
}
)
def _first_int(path):

154
test/test_qemu_arch_yay.py Normal file
View file

@ -0,0 +1,154 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'invité Arch : yay et bash-completion, puis leur trace dans l'accueil.
Une image cloud Arch est nue : ni bash-completion, ni accès à l'AUR. Les deux
s'ajoutent à l'amorçage d'installation, sur la seule branche pacman.
Ce que ces tests gardent :
- makepkg REFUSE de construire en root : la construction ne passe jamais par
sudo, et un « sudo makepkg » repasserait le test au rouge.
- yay est un bonus : sous « set -e », son échec ne doit pas emporter une
installation par ailleurs complète.
- L'accueil de session n'annonce yay que sur une VM qui l'aura vraiment —
la règle déjà tenue par le bloc ERPLibre, appliquée au même signal.
"""
import importlib.util
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
RACINE = Path(__file__).resolve().parents[1]
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
path = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
class ArchGuestBootstrap(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.cmd = self.todo._qemu_erplibre_remote_cmd(
"develop", None, False, False, "", "deb", ()
)
def test_bash_completion_rides_the_pacman_branch(self):
self.assertIn("bash-completion", self.cmd)
# Sur la branche pacman et nulle part ailleurs : les images Debian et
# Fedora l'embarquent déjà, l'ajouter là serait du bruit.
pacman = self.cmd.index("command -v pacman")
zypper = self.cmd.index("command -v zypper", pacman)
self.assertIn("bash-completion", self.cmd[pacman:zypper])
def test_yay_is_installed_from_the_prebuilt_package(self):
"""yay-bin plutôt que yay : le paquet source compile Go, et le
compilateur avec, pour le même outil."""
self.assertIn("aur.archlinux.org/yay-bin.git", self.cmd)
def test_makepkg_never_runs_under_sudo(self):
"""makepkg sort en erreur sous root : « running makepkg as root is
not allowed ». La construction reste sous l'utilisateur de la VM."""
yay = self.todo._qemu_yay_install_cmd()
self.assertIn("makepkg -si --noconfirm", yay)
self.assertNotIn("sudo makepkg", yay)
def test_a_failing_aur_does_not_break_the_chain(self):
"""Sous « set -e », un groupe qui échoue arrête tout. Le bloc doit
rendre 0 même sans réseau, sans quoi une VM par ailleurs installée
serait comptée en échec."""
yay = self.todo._qemu_yay_install_cmd()
script = "set -e\nPATH=/nonexistent\n" + yay + "\necho SURVECU"
with tempfile.NamedTemporaryFile(
"w", suffix=".sh", delete=False
) as fh:
fh.write(script)
chemin = fh.name
res = subprocess.run(
["bash", chemin], capture_output=True, text=True, timeout=60
)
self.assertEqual(res.returncode, 0, res.stderr)
self.assertIn("SURVECU", res.stdout)
def test_the_whole_remote_command_is_valid_shell(self):
"""Une erreur de syntaxe ne se verrait qu'une fois la VM déployée."""
res = subprocess.run(
["bash", "-n"],
input=self.cmd,
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(res.returncode, 0, res.stderr)
class AurInTheMotd(unittest.TestCase):
def test_arch_with_an_install_announces_yay(self):
motd = DQ.build_motd(
"arch", "latest", "amd64", "fr", "~/git/erplibre", "install", "vi"
)
self.assertIn("AUR — yay", motd)
self.assertIn("yay -Syu", motd)
def test_arch_without_an_install_announces_nothing(self):
motd = DQ.build_motd("arch", "latest", "amd64", "fr")
self.assertNotIn("yay", motd)
def test_another_distro_never_gets_the_block(self):
for distro, version in (
("ubuntu", "24.04"),
("debian", "13"),
("fedora", "43"),
("opensuse", "tumbleweed"),
):
with self.subTest(distro=distro):
motd = DQ.build_motd(
distro,
version,
"amd64",
"fr",
"~/git/erplibre",
"install",
"vi",
)
self.assertNotIn("yay", motd)
def test_the_block_is_translated(self):
motd = DQ.build_motd(
"arch", "latest", "amd64", "en", "~/git/erplibre", "install", "vi"
)
self.assertIn("search the AUR", motd)
self.assertNotIn("chercher dans l'AUR", motd)
def test_the_block_stays_under_a_standard_terminal(self):
for lang in ("fr", "en"):
motd = DQ.build_motd(
"arch",
"latest",
"amd64",
lang,
"~/git/erplibre",
"install",
"vi",
)
for line in motd.splitlines():
self.assertLessEqual(len(line), 80, line)
if __name__ == "__main__":
unittest.main()

View file

@ -182,7 +182,12 @@ class TestToolRemoteCommand(unittest.TestCase):
script = self.todo._qemu_erplibre_remote_cmd(
"develop", None, False, "gnome", "", "deb", ("pycharm",)
)
self.assertLess(script.index("PyCharm"), script.index("git clone"))
# « git clone --branch » et non « git clone » : l'amorçage Arch en
# pose un autre, celui de yay-bin, et il arrive plus tôt. Seul le
# clone du dépôt, qui seul porte une branche, est en jeu ici.
self.assertLess(
script.index("PyCharm"), script.index("git clone --branch")
)
self.assertLess(
script.index("PyCharm"), script.index("make install_os")
)

View file

@ -0,0 +1,196 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le redémarrage de l'hôte se demande, il ne se déduit pas.
« --setup-host » installe des paquets, et sur une distribution à noyau
roulant il peut avoir besoin d'un redémarrage pour que libvirt monte virbr0.
Ces deux actes n'ont pas le même prix : le premier s'annule, le second emporte
tout ce qui tourne sur la machine.
Ce que ces tests gardent :
- « --assume-yes » couvre le gestionnaire de paquets, JAMAIS le redémarrage.
- « --reboot-if-needed » PROPOSE ; un refus laisse la machine debout et sort
en erreur, sans jamais programmer le redémarrage.
- « --assume-yes-reboot » est le seul consentement qui se passe de question,
et il est réservé à la provision d'une VM que personne ne regarde.
- La commande du menu hôte ne le porte pas ; celle du profil invité le porte.
"""
import importlib.util
import io
import sys
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
path = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
# L'état exact qui déclenche la question : le noyau a été remplacé depuis le
# démarrage, donc le réseau « default » ne peut pas monter.
NOYAU_PERIME = "Le noyau en cours n'a plus ses modules."
class SetupHostReboot(unittest.TestCase):
def _lancer(self, reponse=False, **kwargs):
"""setup_host sur un hôte au noyau périmé et au réseau inactif.
Rend le triplet (redémarrages, questions, SystemExit ou None). Tout
ce qui touche au système est neutralisé : seul l'enchaînement des
décisions est sous test. « reponse » est ce que l'utilisateur répond
si la question lui est posée.
"""
runner = mock.MagicMock()
runner.dry_run = False
runner.use_sudo = False
reboots = []
questions = []
def question(texte, default=True):
questions.append(texte)
return reponse
with mock.patch.object(DQ, "ensure_tools"), mock.patch.object(
DQ, "ensure_libvirt_service"
), mock.patch.object(
DQ, "ensure_libvirt_group", return_value=True
), mock.patch.object(
DQ, "ensure_ssh_key"
), mock.patch.object(
DQ, "ensure_network"
), mock.patch.object(
DQ, "kernel_modules_stale", return_value=NOYAU_PERIME
), mock.patch.object(
DQ, "libvirt_ready", return_value=True
), mock.patch.object(
DQ, "network_state", return_value=(False, True)
), mock.patch.object(
DQ, "schedule_reboot", side_effect=lambda r: reboots.append(r)
), mock.patch.object(
DQ, "prompt_yes_no", side_effect=question
):
with redirect_stdout(io.StringIO()):
try:
DQ.setup_host(runner, **kwargs)
sortie = None
except SystemExit as exc:
sortie = exc
return reboots, questions, sortie
def test_assume_yes_alone_never_reboots(self):
"""La régression même : accepter l'installation des paquets ne
redémarrait pas la machine, mais l'appelant, lui, ajoutait le drapeau
qui le faisait. Sans le drapeau, rien ne redémarre et rien n'est
demandé."""
reboots, questions, sortie = self._lancer(
assume_yes=True, no_install=False, reboot_if_needed=False
)
self.assertEqual(reboots, [])
self.assertEqual(questions, [])
self.assertIsInstance(sortie, SystemExit)
def test_reboot_if_needed_asks_and_a_refusal_stops(self):
reboots, questions, sortie = self._lancer(
assume_yes=True,
no_install=False,
reboot_if_needed=True,
reponse=False,
)
self.assertEqual(len(questions), 1, questions)
self.assertEqual(reboots, [])
self.assertIsInstance(sortie, SystemExit)
# Le refus doit se lire dans le message : un « pas prêt » sec laisse
# croire à une panne alors que la machine a obéi.
self.assertIn("refusé", str(sortie))
def test_reboot_if_needed_reboots_when_accepted(self):
reboots, questions, sortie = self._lancer(
assume_yes=True,
no_install=False,
reboot_if_needed=True,
reponse=True,
)
self.assertEqual(len(questions), 1)
self.assertEqual(len(reboots), 1)
self.assertIsNone(sortie)
def test_assume_yes_reboot_skips_the_question(self):
"""La provision d'une VM neuve n'a personne pour répondre : sans ce
drapeau, la question tomberait sur un EOF et la VM resterait sur un
noyau sans modules."""
reboots, questions, sortie = self._lancer(
assume_yes=True,
no_install=False,
reboot_if_needed=True,
assume_yes_reboot=True,
)
self.assertEqual(questions, [])
self.assertEqual(len(reboots), 1)
self.assertIsNone(sortie)
class ConsentInTheCallers(unittest.TestCase):
"""Le drapeau se lit dans les commandes que TODO fabrique."""
@staticmethod
def _commande_hote():
"""La chaîne assignée à « cmd » dans _qemu_ensure_tools.
Lue par l'arbre syntaxique et non par le texte : un commentaire qui
NOMME le drapeau pour expliquer son absence est légitime, et une
recherche textuelle le prendrait pour la commande.
"""
import ast
source = (RACINE / "script/todo/qemu_menu.py").read_text(
encoding="utf-8"
)
for node in ast.walk(ast.parse(source)):
if (
isinstance(node, ast.FunctionDef)
and node.name == "_qemu_ensure_tools"
):
for stmt in ast.walk(node):
if (
isinstance(stmt, ast.Assign)
and getattr(stmt.targets[0], "id", "") == "cmd"
):
return ast.literal_eval(stmt.value)
raise AssertionError("cmd introuvable dans _qemu_ensure_tools")
def test_the_host_menu_never_assumes_the_reboot(self):
cmd = self._commande_hote()
self.assertIn("--setup-host", cmd)
self.assertIn("--reboot-if-needed", cmd)
self.assertNotIn("--assume-yes-reboot", cmd)
def test_the_guest_profile_carries_the_explicit_consent(self):
source = (RACINE / "script/todo/qemu_install.py").read_text(
encoding="utf-8"
)
self.assertIn("--assume-yes-reboot", source)
def test_the_flag_exists_in_the_parser(self):
parser = DQ.build_parser()
rendu = parser.format_help()
self.assertIn("--assume-yes-reboot", rendu)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,272 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les paquets qui fournissent les outils de la réduction sûre.
La réduction d'un disque de VM refuse de partir sans huit binaires. Le nom du
binaire n'est presque jamais celui du paquet, et il change de famille en
famille : sgdisk vit dans « gdisk » chez Debian et Fedora, dans « gptfdisk »
chez Arch et openSUSE. Une table pareille se démode sans bruit — un trou n'y
fait rien planter, il fait juste proposer une installation qui n'installe pas
ce qui manque.
Ce qui se vérifie ici sans VM et sans toucher au système : que chaque outil a
un paquet dans les quatre familles, que la commande construite est celle du
gestionnaire présent, que les paquets ne sont pas demandés deux fois, et qu'un
refus comme un échec laissent l'appelant renoncer plutôt que continuer sans
ses outils.
"""
import io
import os
import sys
import unittest
from contextlib import redirect_stdout
from unittest.mock import patch
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
from script.todo import todo_install # noqa: E402
from script.todo.todo_i18n import t # noqa: E402
FAMILIES = ("apt-get", "dnf", "pacman", "zypper")
class _Exec:
"""Le lanceur de commandes, qui retient au lieu d'exécuter."""
def __init__(self, status=0):
self.ran = []
self.status = status
def exec_command_live(self, cmd, source_erplibre=False):
self.ran.append(cmd)
return self.status
class ShrinkToolsBase(unittest.TestCase):
def setUp(self):
self.todo = TODO.__new__(TODO)
self.todo.execute = _Exec()
def _which(self, package_manager, missing, installed_after=()):
"""shutil.which vu depuis qemu_manage : un seul gestionnaire de
paquets sur le PATH, et les outils manquants qui le restent tant que
l'installation n'a pas tourné."""
done = self.todo.execute
def which(binaire):
if binaire in FAMILIES:
return (
f"/usr/bin/{binaire}"
if binaire == package_manager
else None
)
if binaire in missing:
if done.ran and binaire in installed_after:
return f"/usr/bin/{binaire}"
return None
return f"/usr/bin/{binaire}"
return which
def _run(self, package_manager, missing, answer="y", installed_after=()):
which = self._which(package_manager, missing, installed_after)
buf = io.StringIO()
# input() écrit son invite sur stdout comme le vrai : sans cela la
# question n'apparaît nulle part et l'ORDRE des deux ne se voit pas.
def demande(invite=""):
print(invite, end="")
return answer
# `shutil` est UN seul objet module partagé : patcher son « which »
# par n'importe quel importateur le patche pour todo_install aussi,
# qui est le vrai lecteur du PATH depuis le refactor.
with patch("script.todo.qemu_manage.shutil.which", which), patch(
"builtins.input", demande
), redirect_stdout(buf):
left = self.todo._qemu_install_shrink_tools(list(missing))
return self.todo.execute.ran, left, buf.getvalue()
class TestPackageTable(ShrinkToolsBase):
def test_every_tool_has_a_package_in_every_family(self):
"""Un trou ne plante pas : il propose une installation inutile."""
for family in FAMILIES:
per_family = TODO._SHRINK_PKG_FAMILY[family]
for binaire in TODO._SHRINK_TOOLS:
paquet = per_family.get(binaire) or TODO._SHRINK_PKG.get(
binaire
)
self.assertTrue(
paquet,
f"{family} : aucun paquet connu pour « {binaire} »",
)
def test_the_overrides_cover_exactly_the_known_families(self):
"""Une famille connue de todo_install sans surcharge ici proposerait
« gdisk » à un Arch, qui ne l'a pas."""
self.assertEqual(set(TODO._SHRINK_PKG_FAMILY), set(FAMILIES))
self.assertEqual(set(todo_install.FAMILIES), set(FAMILIES))
def test_sgdisk_is_the_one_that_changes_name(self):
"""Le cas qui a motivé la table, gardé explicitement."""
noms = {f: TODO._SHRINK_PKG_FAMILY[f]["sgdisk"] for f in FAMILIES}
self.assertEqual(noms["apt-get"], "gdisk")
self.assertEqual(noms["dnf"], "gdisk")
self.assertEqual(noms["pacman"], "gptfdisk")
self.assertEqual(noms["zypper"], "gptfdisk")
def test_no_family_override_repeats_the_common_table(self):
"""Un doublon entre les deux tables est une divergence en attente."""
for family in FAMILIES:
for binaire in TODO._SHRINK_PKG_FAMILY[family]:
self.assertNotIn(
binaire,
TODO._SHRINK_PKG,
f"{family} : « {binaire} » est dans les deux tables",
)
class TestInstallCommand(ShrinkToolsBase):
def test_each_family_builds_its_own_command(self):
attendu = {
"apt-get": "sudo apt-get install -y gdisk",
"dnf": "sudo dnf install -y gdisk",
"pacman": "sudo pacman -S --needed --noconfirm gptfdisk",
"zypper": "sudo zypper --non-interactive install gptfdisk",
}
for family, cmd in attendu.items():
self.setUp()
ran, left, _ = self._run(
family, ["sgdisk"], installed_after=("sgdisk",)
)
self.assertEqual(ran, [cmd])
self.assertEqual(left, [])
def test_a_package_is_asked_for_once(self):
"""e2fsck, resize2fs et dumpe2fs sortent du même paquet."""
ran, _, _ = self._run(
"apt-get",
["e2fsck", "resize2fs", "dumpe2fs", "sgdisk"],
installed_after=("e2fsck", "resize2fs", "dumpe2fs", "sgdisk"),
)
self.assertEqual(ran, ["sudo apt-get install -y e2fsprogs gdisk"])
def test_the_command_is_shown_before_the_question(self):
"""On approuve ce qu'on a lu : la commande passe AVANT la question.
L'ordre est le fond de l'affaire, pas la simple présence des deux :
une question posée avant la commande fait approuver à l'aveugle.
"""
ran, _, out = self._run("apt-get", ["sgdisk"], answer="n")
commande = out.index("sudo apt-get install -y gdisk")
question = out.index(t("Install them? (y/N): "))
self.assertLess(commande, question)
self.assertEqual(ran, [])
class TestGivingUp(ShrinkToolsBase):
def test_a_refusal_installs_nothing_and_keeps_the_list(self):
ran, left, _ = self._run("apt-get", ["sgdisk"], answer="n")
self.assertEqual(ran, [])
self.assertEqual(left, ["sgdisk"])
def test_an_unknown_package_manager_gives_up(self):
ran, left, _ = self._run("brew", ["sgdisk"])
self.assertEqual(ran, [])
self.assertEqual(left, ["sgdisk"])
def test_the_list_is_re_read_from_disk_not_assumed(self):
"""Une installation qui ne pose rien doit rester un échec."""
ran, left, _ = self._run("apt-get", ["sgdisk"], installed_after=())
self.assertEqual(len(ran), 1)
self.assertEqual(left, ["sgdisk"])
def test_a_failing_install_is_reported_and_not_swallowed(self):
"""exec_command_live REND le code de sortie, il ne lève rien."""
self.todo.execute = _Exec(status=100)
ran, left, out = self._run("apt-get", ["sgdisk"])
self.assertEqual(len(ran), 1)
self.assertEqual(left, ["sgdisk"])
self.assertIn("100", out)
class TestBackupSpace(unittest.TestCase):
"""La sauvegarde avant réduction, et la place qu'elle demande.
Elle doublait l'occupation sans rien annoncer : sur un disque presque
plein la copie s'arrête à mi-course et laisse un .bak tronqué, sur un
système de fichiers désormais saturé. Les deux chiffres passent donc
avant la question, et le défaut bascule quand la place manque — une
entrée distraite ne doit pas remplir le disque.
"""
GIB = 1 << 30
def setUp(self):
self.todo = TODO.__new__(TODO)
def test_the_need_is_the_allocated_size_not_the_apparent_one(self):
"""« cp --sparse=always » ne recopie pas les trous d'un qcow2 : un
disque de 60 Go apparents mais 8 Go alloués ne demande que 8 Go."""
faux = os.stat_result((0o644, 0, 0, 1, 0, 0, 60 * self.GIB, 0, 0, 0))
# st_blocks n'est pas dans le tuple : on le pose à part.
with patch("script.todo.qemu_manage.os.stat") as stat, patch(
"script.todo.qemu_manage.shutil.disk_usage"
) as du:
stat.return_value = type(
"S", (), {"st_blocks": 8 * self.GIB // 512}
)()
du.return_value = type("U", (), {"free": 99 * self.GIB})()
besoin, libre = TODO._qemu_backup_need_and_free("/x/d.qcow2")
self.assertEqual(besoin, 8 * self.GIB)
self.assertEqual(libre, 99 * self.GIB)
self.assertNotEqual(besoin, faux.st_size)
def _decision(self, besoin, libre, answer):
"""(question posée, sauvegarde retenue) — par le VRAI code.
Ce helper appelle _qemu_ask_backup et ne réimplémente rien : une
copie de la logique dans le test aurait laissé passer un défaut
remis à OUI sans place, ce qui est précisément le défaut à garder.
"""
vu = []
def demande(invite=""):
vu.append(invite)
return answer
with patch.object(
TODO,
"_qemu_backup_need_and_free",
staticmethod(lambda d: (besoin, libre)),
), patch("builtins.input", demande), redirect_stdout(io.StringIO()):
retenu = self.todo._qemu_ask_backup("/x/d.qcow2")
return vu[-1], retenu
def test_with_room_the_default_stays_yes(self):
question, retenu = self._decision(12 * self.GIB, 40 * self.GIB, "")
self.assertIn("(O/n", question)
self.assertTrue(retenu)
def test_without_room_the_default_flips_to_no(self):
"""Le cœur du correctif : entrée vide ne doit PAS remplir le disque."""
question, retenu = self._decision(12 * self.GIB, 3 * self.GIB, "")
self.assertIn("(y/N", question)
self.assertFalse(retenu)
def test_without_room_insisting_still_works(self):
"""On informe, on ne décide pas à la place de l'opérateur."""
_, retenu = self._decision(12 * self.GIB, 3 * self.GIB, "y")
self.assertTrue(retenu)
def test_a_margin_guards_the_exactly_equal_case(self):
"""Une place égale au besoin n'en laisse aucune : refusé."""
_, retenu = self._decision(12 * self.GIB, 12 * self.GIB, "")
self.assertFalse(retenu)
if __name__ == "__main__":
unittest.main()

View file

@ -240,8 +240,19 @@ class TestEnsureVirtViewer(unittest.TestCase):
self.assertEqual(1, len(self.ran))
def test_every_family_is_covered(self):
tools = [t for t, _c in TODO._QEMU_VIRT_VIEWER_INSTALL]
self.assertEqual(["apt-get", "dnf", "pacman", "zypper"], tools)
"""La table des familles a quitté ce menu pour todo_install, qui la
partage avec les autres installations du CLI."""
from script.todo import todo_install
self.assertEqual(
["apt-get", "dnf", "pacman", "zypper"],
list(todo_install.FAMILIES),
)
for famille in todo_install.FAMILIES:
self.assertIn(
"virt-viewer",
todo_install.install_command(["virt-viewer"], famille=famille),
)
class TestTunnelMenuTargets(_MenuCase):

99
test/test_qemu_vm_name.py Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le nom de VM du parc : que porte-t-il, et que retrouve-t-on dedans ?
Le nom se lit dans les deux sens. `_qemu_infra_name` le fabrique à partir de
(distro, version, archi) ; `_qemu_vm_meta` remonte de ce nom à la version en
rejouant le catalogue. Les deux doivent rester en miroir : une règle ajoutée
d'un seul côté rend une VM introuvable dans son propre parc.
Une distribution en publication continue n'a qu'une version, « latest », qui
ne distingue donc rien — elle sort du nom. Une version nommée qui coexiste
avec d'autres au catalogue, tumbleweed, reste.
"""
import unittest
from unittest.mock import patch
from script.todo.todo import TODO
class TestInfraName(unittest.TestCase):
"""Ce que le nom porte selon la distribution et l'architecture."""
def setUp(self):
patcher = patch.object(
TODO, "_native_arch", staticmethod(lambda: "amd64")
)
patcher.start()
self.addCleanup(patcher.stop)
def test_rolling_release_drops_the_version(self):
self.assertEqual(
TODO._qemu_infra_name("arch", "latest", "amd64"), "erplibre-arch"
)
def test_rolling_release_keeps_the_foreign_arch(self):
self.assertEqual(
TODO._qemu_infra_name("arch", "latest", "arm64"),
"erplibre-arch-arm64",
)
def test_named_version_stays(self):
"""tumbleweed coexiste avec Leap au catalogue : le nom doit trancher."""
self.assertEqual(
TODO._qemu_infra_name("opensuse", "tumbleweed", "amd64"),
"erplibre-opensuse-tumbleweed",
)
def test_numbered_version_loses_only_its_dots(self):
self.assertEqual(
TODO._qemu_infra_name("ubuntu", "24.04", "amd64"),
"erplibre-ubuntu-2404",
)
def test_every_catalogue_entry_yields_a_distinct_name(self):
"""Deux entrées du catalogue ne peuvent pas porter le même nom."""
import importlib.util
from pathlib import Path
path = (
Path(__file__).resolve().parent.parent
/ "script"
/ "qemu"
/ "deploy_qemu.py"
)
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
noms = [
TODO._qemu_infra_name(distro, version, "amd64")
for distro, (versions, _default) in mod.DISTROS.items()
for version in versions
]
self.assertEqual(len(noms), len(set(noms)), sorted(noms))
class TestNameRoundTrip(unittest.TestCase):
"""_qemu_vm_meta retrouve la version que _qemu_infra_name a effacée."""
def test_rolling_release_resolves_back_to_latest(self):
todo = TODO()
with patch.object(
TODO, "_qemu_vm_arch", lambda self, name: "amd64"
), patch.object(TODO, "_native_arch", staticmethod(lambda: "amd64")):
class Catalogue:
DISTROS = {"arch": (["latest"], "latest")}
self.assertEqual(
todo._qemu_vm_meta("erplibre-arch", Catalogue()),
("arch", "latest", "amd64"),
)
if __name__ == "__main__":
unittest.main()

View file

@ -485,6 +485,203 @@ class TestSetupClaudeCommit(unittest.TestCase):
mock_makedirs.assert_called_once()
class TestClaudeCommandTemplates(unittest.TestCase):
"""Chaque commande proposée par le menu doit avoir son gabarit.
Un nom de gabarit fautif ne se voit qu'à l'exécution, au moment où le
déploiement échoue devant l'utilisateur : rien ne relie le littéral passé
à `_setup_claude_command` au fichier de `conf/`.
"""
@staticmethod
def _deployed_templates():
"""Les gabarits nommés dans les appels à `_setup_claude_command`."""
import ast
source = Path("script/todo/todo.py").read_text(encoding="utf-8")
found = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.Call):
continue
attr = getattr(node.func, "attr", None)
if attr != "_setup_claude_command":
continue
# (nom_de_commande, nom_de_gabarit) : les deux sont des littéraux,
# sans quoi le test ne peut rien affirmer.
args = [
a.value
for a in node.args
if isinstance(a, ast.Constant) and isinstance(a.value, str)
]
if len(args) >= 2:
found.append(args[1])
return found
def test_every_menu_template_exists(self):
templates = self._deployed_templates()
self.assertGreaterEqual(len(templates), 4, templates)
for name in templates:
with self.subTest(template=name):
self.assertTrue(
os.path.isfile(os.path.join("conf", name)),
f"conf/{name} est nommé par le menu et n'existe pas",
)
def test_every_template_declares_its_own_name(self):
"""Le `name:` du frontmatter donne le nom de la commande `/…` ; un
gabarit qui en déclare un autre déploie un fichier dont le contenu
parle d'une commande différente."""
source = Path("script/todo/todo.py").read_text(encoding="utf-8")
import ast
pairs = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, ast.Call):
continue
if getattr(node.func, "attr", None) != "_setup_claude_command":
continue
args = [
a.value
for a in node.args
if isinstance(a, ast.Constant) and isinstance(a.value, str)
]
if len(args) >= 2:
pairs.append((args[0], args[1]))
self.assertTrue(pairs)
for command, template in pairs:
with self.subTest(command=command):
text = Path("conf", template).read_text(encoding="utf-8")
self.assertIn(f"name: {command}\n", text)
class TestClaudePlugins(unittest.TestCase):
"""Le menu des plugins Claude Code.
Ce qui est vérifié ici ne se voit pas à la lecture : la frontière de mot
qui distingue deux noms dont l'un contient l'autre, le refus qui n'installe
rien, et l'absence de l'exécutable, qui doit se dire au lieu de passer pour
un échec de la commande.
"""
def test_absent_binary_reports_without_executing(self):
todo = TODO()
todo.execute = MagicMock()
with patch("script.todo.todo.shutil.which", return_value=None), patch(
"builtins.print"
):
self.assertEqual(todo._claude_plugin_exec("list"), 1)
self.assertEqual(
todo._claude_plugin_exec("list", capture=True), (1, [])
)
# La forme du retour suit l'appelant : un appelant qui déballe un
# couple ne doit pas recevoir un entier nu.
todo.execute.exec_command_live.assert_not_called()
def test_installed_name_matches_on_word_boundary(self):
todo = TODO()
# Chaque cas négatif CONTIENT le nom cherché comme sous-chaîne : une
# recherche naïve les déclarerait tous posés.
cases = [
(["code-review-toolkit@market v1.0"], "code-review", False),
(["my-superpowers@market v1.0"], "superpowers", False),
(["superpowers2@market v1.0"], "superpowers", False),
(["code-review@market v1.0"], "code-review", True),
(["No plugins installed."], "superpowers", False),
(["superpowers@market (enabled)"], "superpowers", True),
]
for lines, name, expected in cases:
with self.subTest(name=name, lines=lines):
with patch.object(
todo, "_claude_plugin_exec", return_value=(0, lines)
):
self.assertIs(
todo._claude_plugin_is_installed(name), expected
)
def test_unreadable_list_reports_not_installed(self):
"""Un code de sortie non nul ne vaut pas « absent » par hasard : la
réinstallation qui suit est idempotente, l'inverse effacerait."""
todo = TODO()
with patch.object(
todo, "_claude_plugin_exec", return_value=(2, ["boom"])
):
self.assertFalse(todo._claude_plugin_is_installed("superpowers"))
def test_refusing_the_preferred_list_installs_nothing(self):
todo = TODO()
with patch("builtins.input", return_value="n"), patch.object(
todo, "_claude_plugin_exec"
) as mock_exec, patch("builtins.print"):
todo._claude_install_preferred_plugins()
mock_exec.assert_not_called()
def test_accepting_installs_only_what_is_missing(self):
todo = TODO()
with patch("builtins.input", return_value="y"), patch.object(
todo, "_claude_plugin_exec"
) as mock_exec, patch.object(
todo,
"_claude_plugin_is_installed",
side_effect=lambda name: name == "pyright-lsp",
), patch(
"builtins.print"
):
todo._claude_install_preferred_plugins()
called = [call.args[0] for call in mock_exec.call_args_list]
# « -y » est obligatoire : la sortie de TODO est un tuyau, et la CLI
# refuse sans lui toute installation qui exécute une commande.
self.assertEqual(
called,
[
"install superpowers -y",
"install claude-security -y",
"install skill-creator -y",
],
)
def test_catalog_skips_an_unreadable_manifest(self):
todo = TODO()
with tempfile.TemporaryDirectory() as tmp:
for name, body in (
("good", '{"plugins":[{"name":"a",' '"description":"d"}]}'),
("broken", "{not json"),
):
folder = os.path.join(tmp, name, ".claude-plugin")
os.makedirs(folder)
with open(
os.path.join(folder, "marketplace.json"), "w"
) as handle:
handle.write(body)
with patch.object(TODO, "_CLAUDE_MARKETPLACES_DIR", tmp):
catalog = todo._claude_marketplace_catalog()
self.assertEqual(catalog, [("a", "good", "d")])
def test_catalog_is_empty_without_any_marketplace(self):
todo = TODO()
with patch.object(
TODO, "_CLAUDE_MARKETPLACES_DIR", "/nonexistent-marketplaces"
):
self.assertEqual(todo._claude_marketplace_catalog(), [])
def test_search_matches_name_and_description(self):
todo = TODO()
catalog = [
("pyright-lsp", "official", "Python language server"),
("mongodb", "official", "Document database"),
]
with patch.object(
todo, "_claude_marketplace_catalog", return_value=catalog
), patch("builtins.input", return_value="python"), patch(
"builtins.print"
) as mock_print:
todo._claude_plugin_search()
printed = " ".join(
str(call.args[0]) for call in mock_print.call_args_list
)
self.assertIn("pyright-lsp", printed)
self.assertNotIn("mongodb", printed)
class TestSelectDatabase(unittest.TestCase):
@patch("script.todo.database_manager.click")
def test_select_database_returns_name(self, mock_click):

200
test/test_todo_install.py Normal file
View file

@ -0,0 +1,200 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""La composante d'installation partagée : familles, noms, ordre de décision.
Trois écritures séparées faisaient ce travail avant, chacune couvrant un
sous-ensemble différent : on pouvait installer virt-viewer sous openSUSE, mais
ni navigateur CLI ni lm-sensors. Ce qui se garde ici est donc surtout de la
COUVERTURE — qu'aucune famille ne retombe dans le trou — plus les deux règles
que la composante tient à la place des appelants : l'ID de la distribution
décide avant le PATH, et la commande s'affiche avant la question.
Rien n'est installé : le PATH et /etc/os-release sont simulés.
"""
import io
import sys
import unittest
from contextlib import redirect_stdout
from unittest.mock import mock_open, patch
sys.argv = ["todo.py"]
from script.todo import todo_install # noqa: E402
def _only(*present):
"""shutil.which qui ne trouve que `present`."""
return lambda b: f"/usr/bin/{b}" if b in present else None
class TestFamilyDetection(unittest.TestCase):
def test_every_family_has_a_non_interactive_command(self):
"""Un menu qui rend la main à un prompt apt reste bloqué."""
for famille in todo_install.FAMILIES:
cmd = todo_install.install_command(["p"], famille=famille)
self.assertEqual(cmd[0], "sudo")
self.assertEqual(cmd[-1], "p")
self.assertTrue(
{"-y", "--noconfirm", "--non-interactive"} & set(cmd),
f"{famille} : commande interactive {cmd}",
)
def test_the_distribution_id_decides_before_the_path(self):
"""Une machine peut porter deux gestionnaires ; l'ID dit lequel
possède le système."""
with patch(
"builtins.open", mock_open(read_data='ID="debian"\n')
), patch(
"script.todo.todo_install.shutil.which", _only("apt-get", "dnf")
):
self.assertEqual(todo_install.family(), "apt-get")
def test_the_path_decides_when_the_id_is_unknown(self):
with patch(
"builtins.open", mock_open(read_data='ID="nonesuch"\n')
), patch("script.todo.todo_install.shutil.which", _only("zypper")):
self.assertEqual(todo_install.family(), "zypper")
def test_an_id_whose_manager_is_absent_falls_back(self):
"""Un conteneur Debian minimal sans apt-get ne doit pas mener à une
commande apt-get qui n'existe pas."""
with patch(
"builtins.open", mock_open(read_data='ID="debian"\n')
), patch("script.todo.todo_install.shutil.which", _only("dnf")):
self.assertEqual(todo_install.family(), "dnf")
def test_no_manager_is_none_not_a_guess(self):
with patch("builtins.open", mock_open(read_data="")), patch(
"script.todo.todo_install.shutil.which", _only()
):
self.assertIsNone(todo_install.family())
self.assertIsNone(todo_install.install_command(["p"]))
def test_the_four_supported_families_are_all_mapped(self):
"""Les plateformes annoncées par le dépôt doivent toutes tomber sur
une famille, sans quoi l'installation leur est fermée."""
for os_id, attendu in (
("ubuntu", "apt-get"),
("linuxmint", "apt-get"),
("debian", "apt-get"),
("almalinux", "dnf"),
("rocky", "dnf"),
("opensuse-leap", "zypper"),
("opensuse-tumbleweed", "zypper"),
("arch", "pacman"),
):
with patch(
"builtins.open", mock_open(read_data=f'ID="{os_id}"\n')
), patch(
"script.todo.todo_install.shutil.which",
_only(*todo_install.FAMILIES),
):
self.assertEqual(todo_install.family(), attendu, os_id)
class TestPackageNames(unittest.TestCase):
def test_a_dict_picks_the_name_of_the_current_family(self):
"""lm-sensors chez Debian, lm_sensors ailleurs."""
noms = {
"apt-get": ["lm-sensors"],
"dnf": ["lm_sensors"],
"pacman": ["lm_sensors"],
"zypper": ["sensors"],
}
for famille, attendu in (
("apt-get", "lm-sensors"),
("dnf", "lm_sensors"),
("zypper", "sensors"),
):
cmd = todo_install.install_command(noms, famille=famille)
self.assertEqual(cmd[-1], attendu)
def test_a_family_absent_from_the_dict_installs_nothing(self):
"""Mieux vaut rien proposer qu'un paquet qui n'existe pas ici."""
self.assertIsNone(
todo_install.install_command({"apt-get": ["p"]}, famille="pacman")
)
def test_resolve_maps_binaries_and_deduplicates(self):
paquets, inconnus = todo_install.resolve(
["e2fsck", "resize2fs", "sgdisk"],
commun={"e2fsck": "e2fsprogs", "resize2fs": "e2fsprogs"},
par_famille={"pacman": {"sgdisk": "gptfdisk"}},
famille="pacman",
)
self.assertEqual(paquets, ["e2fsprogs", "gptfdisk"])
self.assertEqual(inconnus, [])
def test_resolve_says_what_it_cannot_map(self):
"""À dire, jamais à deviner : un nom inventé installerait au hasard."""
paquets, inconnus = todo_install.resolve(
["sgdisk"], commun={}, par_famille={}, famille="apt-get"
)
self.assertEqual(paquets, [])
self.assertEqual(inconnus, ["sgdisk"])
class TestAskAndInstall(unittest.TestCase):
class _Exec:
def __init__(self, status=0):
self.ran, self.status = [], status
def exec_command_live(self, cmd, source_erplibre=False):
self.ran.append(cmd)
return self.status
def _play(self, cmd, answer, status=0):
ex = self._Exec(status)
buf = io.StringIO()
def demande(invite=""):
print(invite, end="")
return answer
with patch("builtins.input", demande), redirect_stdout(buf):
got = todo_install.ask_and_install(
ex, cmd, "Installer? (y/N): ", lambda a: a.strip() == "y"
)
return ex.ran, got, buf.getvalue()
def test_the_command_is_printed_before_the_question(self):
"""Centralisé ici pour qu'aucun appelant ne puisse l'inverser."""
ran, got, out = self._play(
["sudo", "apt-get", "install", "-y", "p"], "n"
)
self.assertLess(
out.index("sudo apt-get install -y p"),
out.index("Installer? (y/N): "),
)
self.assertEqual(ran, [])
self.assertIsNone(got)
def test_accepting_runs_it_and_gives_the_exit_code_back(self):
"""exec_command_live REND le code, il ne lève rien : l'appelant doit
pouvoir le tester."""
ran, got, _ = self._play(["sudo", "dnf", "install", "-y", "p"], "y")
self.assertEqual(ran, ["sudo dnf install -y p"])
self.assertEqual(got, 0)
ran, got, _ = self._play(
["sudo", "dnf", "install", "-y", "p"], "y", status=127
)
self.assertEqual(got, 127)
def test_no_command_is_said_not_silently_skipped(self):
ran, got, out = self._play(None, "y")
self.assertEqual(ran, [])
self.assertIsNone(got)
self.assertTrue(out.strip())
def test_a_name_with_a_space_survives_the_display(self):
"""shlex.join, pour que la commande affichée soit celle qui tourne."""
ran, _, out = self._play(
["sudo", "apt-get", "install", "-y", "a b"], "y"
)
self.assertIn("'a b'", out)
self.assertIn("'a b'", ran[0])
if __name__ == "__main__":
unittest.main()

View file

@ -148,6 +148,10 @@ class MenuCoherence:
todo.py), ce socle sert DEUX menus : QEMU/KVM et Proxmox. Un troisième
n'aura qu'à déclarer ses quatre attributs.
Une entrée peut aussi porter sa destination dans « method » plutôt que
dans un « elif status » numéroté. Elle échappe alors à la renumérotation
par construction, et EXPECTED la vérifie contre cette clé.
À déclarer par la sous-classe : SOURCE (le fichier), ENTRY (la ligne
« def prompt_execute_… »), END (le membre suivant, qui borne la lecture) et
EXPECTED (où mène chaque entrée, par le début de son libellé).
@ -170,18 +174,33 @@ class MenuCoherence:
r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*'
r"\s*(?:status = )?self\.(\w+)\("
)
# Une entrée qui porte sa destination dans « method » se dispatche seule,
# par le repli générique. Elle n'a pas de numéro dans le code, donc aucune
# renumérotation ne peut la désaligner : c'est le seul moyen de placer une
# entrée codée en dur APRÈS des entrées venues de la configuration, dont
# le nombre n'est pas connu à la lecture du source.
RE_SELF_DISPATCH = re.compile(
r'"prompt_description": t\(\s*\n?\s*"([^"]+)"\s*\)?,?\s*\n'
r'\s*"method": "(\w+)"'
)
def setUp(self):
source = self.SOURCE.read_text(encoding="utf-8")
start = source.index(self.ENTRY)
end = source.index(self.END, start)
self.body = source[start:end]
self.self_dispatch = dict(self.RE_SELF_DISPATCH.findall(self.body))
num = 0
self.shown = []
for kind, label in self.RE_ENTRY.findall(self.body):
if kind == "prompt_description":
num += 1
self.shown.append((num, label))
self.numbered = [
(n, label)
for n, label in self.shown
if label not in self.self_dispatch
]
self.dispatch = [
(int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body)
]
@ -189,7 +208,7 @@ class MenuCoherence:
def test_the_menu_was_actually_parsed(self):
"""Sur une liste vide, tout test passe : mieux vaut tomber ici."""
self.assertGreater(len(self.shown), self.MINIMUM)
self.assertEqual(len(self.shown), len(self.dispatch))
self.assertEqual(len(self.numbered), len(self.dispatch))
def test_numbering_is_contiguous_from_one(self):
self.assertEqual(
@ -199,7 +218,7 @@ class MenuCoherence:
def test_every_shown_entry_has_the_matching_dispatch(self):
self.assertEqual(
[n for n, _ in self.shown], [n for n, _ in self.dispatch]
[n for n, _ in self.numbered], [n for n, _ in self.dispatch]
)
def _key(self, label):
@ -218,10 +237,11 @@ class MenuCoherence:
f"entrée [{num}] « {label} » absente d'EXPECTED :"
" déclarez où elle mène",
)
atteint = self.self_dispatch.get(label, dct.get(num))
self.assertEqual(
dct.get(num),
atteint,
self.EXPECTED[key],
f"[{num}] « {label} » mène à {dct.get(num)}"
f"[{num}] « {label} » mène à {atteint}"
f" au lieu de {self.EXPECTED[key]}",
)
@ -229,6 +249,16 @@ class MenuCoherence:
keys = {self._key(label) for _, label in self.shown}
self.assertEqual(set(self.EXPECTED) - keys, set())
def test_self_dispatched_entries_name_a_real_method(self):
"""« method » est une chaîne : rien ne la relie au code sans ceci."""
from script.todo.todo import TODO
for label, method in self.self_dispatch.items():
self.assertTrue(
hasattr(TODO, method),
f"« {label} » mène à {method}, qui n'existe pas",
)
class TestLaParitéProxmox(unittest.TestCase):
"""Deux manques signalés par l'audit du découpage, comblés.
@ -448,6 +478,32 @@ class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase):
}
class TestGitMenuNumbering(MenuCoherence, unittest.TestCase):
"""Le menu Git, le seul dont todo.json suit des entrées codées en dur.
Ses premières entrées sont écrites à la main, les suivantes viennent de
`git_from_makefile` et le repli générique les renumérote tout seul :
ajouter une entrée codée en dur pousse celles de todo.json d'un rang sans
que rien ne le dise. Une entrée codée en dur oubliée dans le dispatch
ferait lancer la commande du voisin sous le libellé attendu.
"""
SOURCE = TODO_DIR / "todo.py"
ENTRY = "def prompt_execute_git(self):"
END = "def _git_install_hooks(self):"
MINIMUM = 2
EXPECTED = {
"Local git server": "prompt_execute_git_local_server",
"Add a remote to a local repository": "_git_add_remote",
"Install git hooks": "_git_install_hooks",
"Set merge.conflictStyle": "_git_set_conflict_style",
"Install Starship on Shell": "_shell_install_starship",
"Install Claude Code": "_shell_install_claude_code",
"Install opencode": "_shell_install_opencode",
}
class TestMenuLabels(unittest.TestCase):
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.

132
test/test_todo_rtk.py Normal file
View file

@ -0,0 +1,132 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le menu RTK : dit-il ce qui s'est réellement passé ?
Deux modes de défaillance se ressemblent à l'écran et n'ont pas le même
remède. Le binaire peut être absent — l'installation a échoué. Il peut aussi
être posé sur le disque sans que le PATH du processus y mène : un processus
garde le PATH qu'il avait au démarrage, donc une installation faite pendant
que TODO tourne lui reste invisible jusqu'au redémarrage. Lancer « rtk » nu
rend alors 127, que rien ne distingue d'une absence.
Ce test vérifie que les deux cas sont annoncés séparément, et que les
commandes passent par le chemin absolu du binaire plutôt que par le PATH.
"""
import io
import os
import unittest
from contextlib import redirect_stdout
from unittest.mock import MagicMock, patch
from script.todo.todo import TODO
FALLBACK = os.path.expanduser("~/.local/bin/rtk")
PATH_HINT = 'export PATH="$HOME/.local/bin:$PATH"'
class TestRtkLocate(unittest.TestCase):
"""rtk_locate distingue « dans le PATH », « posé ailleurs » et « absent »."""
def test_found_in_path(self):
with patch(
"script.todo.todo.shutil.which", return_value="/usr/bin/rtk"
):
self.assertEqual(TODO().rtk_locate(), ("/usr/bin/rtk", True))
def test_found_outside_path(self):
with patch("script.todo.todo.shutil.which", return_value=None), patch(
"script.todo.todo.os.access", return_value=True
):
self.assertEqual(TODO().rtk_locate(), (FALLBACK, False))
def test_absent(self):
with patch("script.todo.todo.shutil.which", return_value=None), patch(
"script.todo.todo.os.access", return_value=False
):
self.assertEqual(TODO().rtk_locate(), (None, False))
class TestRtkExec(unittest.TestCase):
"""rtk_exec appelle le binaire par son chemin absolu, jamais « rtk » nu."""
def test_uses_absolute_path(self):
todo = TODO()
todo.execute = MagicMock()
todo.execute.exec_command_live.return_value = 0
with patch("script.todo.todo.shutil.which", return_value=None), patch(
"script.todo.todo.os.access", return_value=True
):
todo.rtk_exec("gain")
command = todo.execute.exec_command_live.call_args[0][0]
self.assertTrue(command.startswith(FALLBACK), command)
self.assertTrue(command.endswith(" gain"), command)
def test_absent_runs_nothing(self):
todo = TODO()
todo.execute = MagicMock()
with patch("script.todo.todo.shutil.which", return_value=None), patch(
"script.todo.todo.os.access", return_value=False
):
with redirect_stdout(io.StringIO()):
status = todo.rtk_exec("gain")
self.assertEqual(status, 1)
todo.execute.exec_command_live.assert_not_called()
class TestRtkReportInstall(unittest.TestCase):
"""Le compte rendu d'installation nomme le résultat, sans le supposer."""
def report(self, todo, exit_code):
out = io.StringIO()
with redirect_stdout(out):
todo.rtk_report_install(exit_code)
return out.getvalue()
def test_failure_is_not_announced_as_success(self):
todo = TODO()
todo.execute = MagicMock()
output = self.report(todo, 1)
self.assertIn("❌", output)
self.assertNotIn("✅", output)
todo.execute.exec_command_live.assert_not_called()
def test_success_reports_version_and_path(self):
todo = TODO()
todo.execute = MagicMock()
todo.execute.exec_command_live.return_value = (0, ["rtk 0.47.0"])
with patch(
"script.todo.todo.shutil.which", return_value="/usr/bin/rtk"
):
output = self.report(todo, 0)
self.assertIn("✅", output)
self.assertIn("rtk 0.47.0", output)
self.assertIn("/usr/bin/rtk", output)
self.assertNotIn(PATH_HINT, output)
def test_success_outside_path_tells_how_to_reach_it(self):
todo = TODO()
todo.execute = MagicMock()
todo.execute.exec_command_live.return_value = (0, ["rtk 0.47.0"])
with patch("script.todo.todo.shutil.which", return_value=None), patch(
"script.todo.todo.os.access", return_value=True
):
output = self.report(todo, 0)
self.assertIn("✅", output)
self.assertIn(PATH_HINT, output)
def test_success_without_binary_is_not_a_success(self):
todo = TODO()
todo.execute = MagicMock()
with patch("script.todo.todo.shutil.which", return_value=None), patch(
"script.todo.todo.os.access", return_value=False
):
output = self.report(todo, 0)
self.assertIn("❌", output)
self.assertNotIn("✅", output)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,384 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les outils de shell du menu Git et Shell : starship, Claude Code, opencode.
Trois installations, un même piège : poser le binaire ne suffit pas. Starship
ne change rien au prompt sans sa ligne d'initialisation ; les deux assistants
posent leur binaire dans un répertoire du HOME que le PATH ne porte pas
toujours. La seconde étape est celle qu'on oublie, et son absence ne se voit
qu'au prochain shell.
Le fichier de configuration à modifier ne se demande que devant un vrai
choix : plusieurs fichiers présents. Aucun, ou un seul, ne laisse rien à
trancher — une question posée là n'attend qu'une frappe pour rien.
"""
import io
import os
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import MagicMock, patch
from script.todo.todo import TODO
LIGNE_BASH = 'eval "$(starship init bash)"'
LIGNE_ZSH = 'eval "$(starship init zsh)"'
LIGNE_FISH = "starship init fish | source"
def refuse_input(*args, **kwargs):
raise AssertionError("aucune question ne devait être posée")
class TestShellName(unittest.TestCase):
"""Le shell se lit dans $SHELL, dont seul le nom de base compte."""
def test_basename_of_shell(self):
with patch.dict(os.environ, {"SHELL": "/usr/bin/zsh"}):
self.assertEqual(TODO._shell_name(), "zsh")
def test_empty_when_unset(self):
with patch.dict(os.environ, {"SHELL": ""}):
self.assertEqual(TODO._shell_name(), "")
class ShellFixture(unittest.TestCase):
"""Un faux HOME où l'on pose les fichiers de configuration voulus."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.racine = Path(self.tmp.name)
self.fichiers = {
"bash": self.racine / "bashrc",
"zsh": self.racine / "zshrc",
"fish": self.racine / "fish" / "config.fish",
}
patcher = patch.dict(
TODO._SHELL_RC,
{nom: str(p) for nom, p in self.fichiers.items()},
clear=True,
)
patcher.start()
self.addCleanup(patcher.stop)
self.todo = TODO()
def cree(self, *shells, contenu=""):
for shell in shells:
chemin = self.fichiers[shell]
chemin.parent.mkdir(parents=True, exist_ok=True)
chemin.write_text(contenu, encoding="utf-8")
class TestRcTarget(ShellFixture):
"""Quel fichier, et quand la question se pose."""
def choisit(self, shell_env="/bin/bash", reponse=None):
entree = refuse_input if reponse is None else (lambda *a: reponse)
with patch.dict(os.environ, {"SHELL": shell_env}), patch(
"builtins.input", entree
), redirect_stdout(io.StringIO()):
return self.todo._shell_rc_target()
def test_no_file_means_bash_without_asking(self):
self.assertEqual(self.choisit(), "bash")
def test_a_single_file_is_taken_without_asking(self):
self.cree("zsh")
self.assertEqual(self.choisit(), "zsh")
def test_a_single_file_wins_over_the_current_shell(self):
"""Le fichier présent l'emporte : $SHELL n'en a pas, lui."""
self.cree("fish")
self.assertEqual(self.choisit(shell_env="/bin/bash"), "fish")
def test_several_files_ask_and_default_to_the_current_shell(self):
self.cree("bash", "zsh")
self.assertEqual(
self.choisit(shell_env="/usr/bin/zsh", reponse=""), "zsh"
)
def test_several_files_answered_by_number(self):
self.cree("bash", "zsh")
self.assertEqual(self.choisit(reponse="2"), "zsh")
def test_several_files_answered_by_name(self):
self.cree("bash", "zsh")
self.assertEqual(self.choisit(reponse="zsh"), "zsh")
def test_a_nonsense_answer_falls_back_on_the_default(self):
self.cree("bash", "zsh")
self.assertEqual(self.choisit(reponse="42"), "bash")
class TestRcAppend(ShellFixture):
"""L'écriture d'une ligne dans le fichier du shell, une seule fois."""
def test_it_appends_and_returns_the_path(self):
self.cree("bash", contenu="export EDITOR=vim\n")
chemin = self.todo._shell_rc_append("bash", "LIGNE", "LIGNE")
self.assertEqual(chemin, str(self.fichiers["bash"]))
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
"export EDITOR=vim\nLIGNE\n",
)
def test_a_present_marker_writes_nothing(self):
self.cree("bash", contenu="déjà LIGNE ici\n")
self.assertIsNone(self.todo._shell_rc_append("bash", "LIGNE", "LIGNE"))
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
"déjà LIGNE ici\n",
)
def test_a_marker_written_otherwise_still_counts(self):
"""Une variante à la main compte : c'est l'effet qui importe."""
self.cree("bash", contenu='PATH="$HOME/.local/bin:$PATH"\n')
self.assertIsNone(
self.todo._shell_rc_append(
"bash", "export PATH=...", "/.local/bin"
)
)
def test_a_file_without_a_final_newline_keeps_its_last_command(self):
self.cree("bash", contenu="export EDITOR=vim")
self.todo._shell_rc_append("bash", "LIGNE", "LIGNE")
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
"export EDITOR=vim\nLIGNE\n",
)
def test_a_missing_parent_directory_is_created(self):
"""config.fish vit sous ~/.config/fish, que rien ne garantit."""
self.todo._shell_rc_append("fish", "LIGNE", "LIGNE")
self.assertEqual(
self.fichiers["fish"].read_text(encoding="utf-8"), "LIGNE\n"
)
class TestPathLine(unittest.TestCase):
"""La syntaxe du PATH n'est pas la même partout."""
def test_posix_shells_export(self):
todo = TODO()
for shell in ("bash", "zsh"):
self.assertEqual(
todo._shell_path_line(shell, "~/.local/bin"),
'export PATH="~/.local/bin:$PATH"',
)
def test_fish_has_its_own_builtin(self):
self.assertEqual(
TODO()._shell_path_line("fish", "~/.local/bin"),
"fish_add_path ~/.local/bin",
)
class TestHookStarship(ShellFixture):
"""L'écriture de la ligne d'initialisation, qui ne demande rien."""
def hook(self, shell_env="/bin/bash"):
out = io.StringIO()
with patch.dict(os.environ, {"SHELL": shell_env}), patch(
"builtins.input", refuse_input
), redirect_stdout(out):
self.todo._shell_hook_starship()
return out.getvalue()
def test_it_appends_the_line(self):
self.cree("bash", contenu="export EDITOR=vim\n")
self.hook()
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
f"export EDITOR=vim\n{LIGNE_BASH}\n",
)
def test_each_shell_gets_its_own_line(self):
self.cree("fish")
self.hook()
self.assertEqual(
self.fichiers["fish"].read_text(encoding="utf-8"),
f"{LIGNE_FISH}\n",
)
def test_it_writes_only_once(self):
self.cree("bash", contenu=f"{LIGNE_BASH}\n")
sortie = self.hook()
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
f"{LIGNE_BASH}\n",
)
self.assertIn("✅", sortie)
def test_no_file_at_all_creates_the_bash_one(self):
self.hook()
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
f"{LIGNE_BASH}\n",
)
class TestInstallStarship(unittest.TestCase):
"""L'enchaînement des deux étapes, et ce qui l'arrête."""
def test_an_installed_binary_goes_straight_to_the_shell(self):
todo = TODO()
todo.execute = MagicMock()
with patch(
"script.todo.todo.shutil.which", return_value="/usr/bin/starship"
), patch.object(TODO, "_shell_hook_starship") as hook, patch.object(
TODO, "_shell_install_starship_binary"
) as poser:
todo._shell_install_starship()
poser.assert_not_called()
hook.assert_called_once()
def test_a_missing_binary_leaves_the_shell_alone(self):
todo = TODO()
todo.execute = MagicMock()
out = io.StringIO()
with patch(
"script.todo.todo.shutil.which", return_value=None
), patch.object(TODO, "_shell_hook_starship") as hook, patch.object(
TODO, "_shell_install_starship_binary"
), redirect_stdout(
out
):
todo._shell_install_starship()
hook.assert_not_called()
self.assertIn("❌", out.getvalue())
def test_a_refused_package_does_not_chain_to_upstream(self):
"""Un refus est une décision : il n'appelle pas une seconde offre."""
todo = TODO()
todo.execute = MagicMock()
appels = []
with patch(
"script.todo.todo.todo_install.install_command",
return_value=["sudo", "pacman", "-S", "starship"],
), patch(
"script.todo.todo.todo_install.ask_and_install",
side_effect=lambda *a, **k: appels.append(a[1]) or None,
):
todo._shell_install_starship_binary()
self.assertEqual(len(appels), 1)
def test_no_package_falls_back_upstream(self):
todo = TODO()
todo.execute = MagicMock()
appels = []
with patch(
"script.todo.todo.todo_install.install_command", return_value=None
), patch(
"script.todo.todo.todo_install.ask_and_install",
side_effect=lambda *a, **k: appels.append(a[1]) or 0,
), redirect_stdout(
io.StringIO()
):
todo._shell_install_starship_binary()
self.assertEqual(appels, [TODO._STARSHIP_UPSTREAM])
class TestUpstreamTools(ShellFixture):
"""Claude Code et opencode : l'installateur, puis le PATH."""
def setUp(self):
super().setUp()
self.todo.execute = MagicMock()
# Chaque outil pose son binaire dans un répertoire du faux HOME.
self.repertoires = {
"claude": self.racine / "local" / "bin",
"opencode": self.racine / "opencode" / "bin",
}
patcher = patch.dict(
TODO._UPSTREAM_TOOLS,
{
nom: (
TODO._UPSTREAM_TOOLS[nom][0],
str(chemin),
)
for nom, chemin in self.repertoires.items()
},
)
patcher.start()
self.addCleanup(patcher.stop)
def pose_le_binaire(self, outil):
chemin = self.repertoires[outil]
chemin.mkdir(parents=True, exist_ok=True)
(chemin / outil).write_text("", encoding="utf-8")
def installe(self, outil, status=0):
self.todo.execute.exec_command_live.return_value = status
out = io.StringIO()
with patch.dict(os.environ, {"SHELL": "/bin/bash"}), patch(
"builtins.input", refuse_input
), redirect_stdout(out):
self.todo._shell_install_upstream_tool(outil)
return out.getvalue()
def test_the_documented_installers_are_the_ones_run(self):
self.assertEqual(
TODO._UPSTREAM_TOOLS["claude"][0],
"curl -fsSL https://claude.ai/install.sh | bash",
)
self.assertEqual(
TODO._UPSTREAM_TOOLS["opencode"][0],
"curl -fsSL https://opencode.ai/install | bash",
)
def test_a_failed_install_touches_no_shell_file(self):
self.cree("bash", contenu="export EDITOR=vim\n")
sortie = self.installe("claude", status=1)
self.assertIn("❌", sortie)
self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"),
"export EDITOR=vim\n",
)
def test_a_success_puts_the_directory_on_the_path(self):
self.cree("bash")
self.pose_le_binaire("claude")
self.installe("claude")
contenu = self.fichiers["bash"].read_text(encoding="utf-8")
self.assertIn(str(self.repertoires["claude"]), contenu)
self.assertIn("export PATH=", contenu)
def test_each_tool_gets_its_own_directory(self):
self.cree("bash")
self.pose_le_binaire("opencode")
self.installe("opencode")
contenu = self.fichiers["bash"].read_text(encoding="utf-8")
self.assertIn(str(self.repertoires["opencode"]), contenu)
self.assertNotIn(str(self.repertoires["claude"]), contenu)
def test_a_directory_already_on_the_path_is_not_added_twice(self):
"""L'installateur amont écrit souvent la ligne lui-même."""
ligne = f'export PATH="{self.repertoires["opencode"]}:$PATH"\n'
self.cree("bash", contenu=ligne)
self.pose_le_binaire("opencode")
self.installe("opencode")
contenu = self.fichiers["bash"].read_text(encoding="utf-8")
self.assertEqual(contenu.count(str(self.repertoires["opencode"])), 1)
def test_a_binary_that_did_not_land_is_said(self):
self.cree("bash")
sortie = self.installe("claude")
self.assertIn("⚠", sortie)
def test_the_two_menu_entries_reach_the_shared_path(self):
for methode, outil in (
("_shell_install_claude_code", "claude"),
("_shell_install_opencode", "opencode"),
):
with patch.object(TODO, "_shell_install_upstream_tool") as partage:
getattr(self.todo, methode)()
partage.assert_called_once_with(outil)
if __name__ == "__main__":
unittest.main()