[IMP] todo claude configs: list missing and outdated commands, redeploy

The command list showed only what sat in ~/.claude/commands, with a date:
a command never installed stayed invisible, and a copy older than its
template in conf/ looked the same as a current one. Each ERPLibre template is
now compared with its copy, ignoring the git identity line: not installed,
up to date, or to redeploy with its +/- line count; other commands are
marked not from ERPLibre. On y/N it shows the diff, then redeploys the
outdated copies, keeping the name and e-mail /commit carried.
Checked: 7 new tests on a temporary directory; test_todo, 81 tests, passes.

--- FR ---

[IMP] todo configs claude : commandes absentes, périmées, redéploiement

La liste des commandes ne montrait que le contenu de ~/.claude/commands,
avec une date : une commande jamais installée restait invisible, et une copie
plus vieille que son gabarit de conf/ ressemblait à une copie à jour. Chaque
gabarit d'ERPLibre est désormais comparé à sa copie, hors ligne d'identité
git : non installée, à jour, ou à redéployer avec son compte +/- ; les autres
commandes sont marquées hors ERPLibre. Sur o/N elle montre le diff, puis
redéploie les copies périmées en gardant le nom et le courriel de /commit.
Vérifié : 7 nouveaux tests sur un dossier temporaire ; test_todo, 81 tests.

Assisted-by: Claude Opus 5.5
This commit is contained in:
Mathieu Benoit 2026-09-25 01:38:25 -04:00
parent 76c548a781
commit 087445c578
3 changed files with 311 additions and 37 deletions

View file

@ -5,6 +5,7 @@
import ast
import configparser
import datetime
import difflib
import importlib.util
import inspect
import json
@ -3380,29 +3381,172 @@ class TODO(
else:
print(t("Command not found !"))
def _list_claude_commands(self):
commands_dir = os.path.expanduser("~/.claude/commands")
if not os.path.isdir(commands_dir):
print(t("No custom commands found in ~/.claude/commands/"))
return
files = sorted(
f for f in os.listdir(commands_dir) if f.endswith(".md")
# Les commandes `/…` qu'ERPLibre déploie, et le gabarit de `conf/` dont
# chacune est la copie. La liste et l'écran de contexte en dérivent l'état
# de chaque copie ; le menu nomme les siens en littéraux, que les tests
# relient à `conf/`.
_CLAUDE_COMMAND_TEMPLATES = {
"commit": "template_claude_commands_commit.md",
"git_prepare_merge": "template_claude_commands_git_prepare_merge.md",
"todo_add_command": "template_claude_commands_todo_add_command.md",
"todo_generate_code": "template_claude_commands_todo_generate_code.md",
"todo_plan_max": "template_claude_commands_todo_plan_max.md",
}
_CLAUDE_COMMANDS_DIR = "~/.claude/commands"
# La ligne où le déploiement de /commit substitue le nom et le courriel.
_IDENTITE_GIT = re.compile(
r'user\.name="(?P<nom>[^"]*)" -c user\.email="(?P<courriel>[^"]*)"'
)
def _claude_template_path(self, gabarit):
"""Le chemin d'un gabarit de commande dans `conf/`."""
return os.path.join(self._claude_context_root(), "conf", gabarit)
@staticmethod
def _lignes_stables(chemin):
"""Les lignes d'un fichier, hors celles qui portent l'identité git.
Le déploiement de /commit y substitue le nom et le courriel : les
comparer déclarerait périmée toute copie personnalisée.
"""
with open(chemin, encoding="utf-8", errors="replace") as fh:
return [x for x in fh if "user.name=" not in x]
def _claude_command_diff(self, deployed, template):
"""Le diff unifié de la copie déployée vers le gabarit.
Rend une liste de lignes, vide quand les deux concordent hors
identité git. Les lignes « + » sont ce qu'un redéploiement apporte,
les lignes « - » ce qu'il retire.
"""
return list(
difflib.unified_diff(
self._lignes_stables(deployed),
self._lignes_stables(template),
fromfile=t("installed copy"),
tofile=t("template"),
)
)
if not files:
print(t("No custom commands found in ~/.claude/commands/"))
return
print(t("Claude Code custom commands:"))
print("-" * 50)
for f in files:
filepath = os.path.join(commands_dir, f)
mtime = os.path.getmtime(filepath)
date_str = datetime.datetime.fromtimestamp(mtime).strftime(
@staticmethod
def _compte_diff(diff):
"""(ajoutées, retirées) d'un diff unifié, en-têtes exclus."""
plus = sum(
1 for x in diff if x.startswith("+") and not x.startswith("+++")
)
moins = sum(
1 for x in diff if x.startswith("-") and not x.startswith("---")
)
return plus, moins
def _redeploy_claude_command(self, command_name, template_filename):
"""Réécrit une copie déployée depuis son gabarit, SANS question.
L'identité git que la copie portait est reportée dans la nouvelle :
une mise à jour ne redemande ni le nom ni le courriel. Rend True si
le fichier est écrit.
"""
dest_file = os.path.join(
os.path.expanduser(self._CLAUDE_COMMANDS_DIR),
f"{command_name}.md",
)
try:
with open(
self._claude_template_path(template_filename),
encoding="utf-8",
) as fh:
content = fh.read()
with open(dest_file, encoding="utf-8", errors="replace") as fh:
identite = self._IDENTITE_GIT.search(fh.read())
if identite:
content = content.replace(
"Your Name", identite.group("nom")
).replace("your@email.com", identite.group("courriel"))
with open(dest_file, "w", encoding="utf-8") as fh:
fh.write(content)
except OSError as e:
print(f"{t('Error creating file: ')}{e}")
return False
print(f"{t('File created successfully: ')}{dest_file}")
return True
def _list_claude_commands(self):
"""Les commandes `/…` : installées ou non, à jour ou à redéployer.
Chaque gabarit d'ERPLibre est comparé à sa copie de
~/.claude/commands ; une commande installée qui n'en vient pas est
listée à part. Propose ensuite le diff des copies périmées, puis leur
redéploiement, chaque fois sur un oui explicite.
"""
commands_dir = os.path.expanduser(self._CLAUDE_COMMANDS_DIR)
installees = set()
if os.path.isdir(commands_dir):
installees = {
f[:-3] for f in os.listdir(commands_dir) if f.endswith(".md")
}
def date_de(nom):
mtime = os.path.getmtime(os.path.join(commands_dir, f"{nom}.md"))
return datetime.datetime.fromtimestamp(mtime).strftime(
"%Y-%m-%d %H:%M"
)
name = f[:-3] # remove .md
print(f" /{name:<30} {date_str}")
print("-" * 50)
print(f"{t('Total:')} {len(files)}")
largeur = 70
print(t("Claude Code custom commands:"))
print("-" * largeur)
perimees, absentes = [], []
for nom, gabarit in sorted(self._CLAUDE_COMMAND_TEMPLATES.items()):
modele = self._claude_template_path(gabarit)
if nom not in installees:
absentes.append(nom)
etat = t("command not installed")
print(f" /{nom:<26} {'-':<16} {etat}")
continue
copie = os.path.join(commands_dir, f"{nom}.md")
if not os.path.isfile(modele):
etat = t("not in the repository")
else:
diff = self._claude_command_diff(copie, modele)
if diff:
perimees.append((nom, diff))
plus, moins = self._compte_diff(diff)
etat = f"{t('redeploy needed')} (+{plus} -{moins})"
else:
etat = t("up to date")
print(f" /{nom:<26} {date_de(nom):<16} {etat}")
autres = sorted(installees - set(self._CLAUDE_COMMAND_TEMPLATES))
for nom in autres:
etat = t("not from ERPLibre")
print(f" /{nom:<26} {date_de(nom):<16} {etat}")
print("-" * largeur)
print(
t("Total: %s installed, %s to redeploy, %s not installed")
% (len(installees), len(perimees), len(absentes))
)
if absentes:
print(t("A command not installed is deployed from this menu."))
if not perimees:
return
print()
if self._is_yes(input(t("Show the differences? [o/N] "))):
for nom, diff in perimees:
print(f"\n/{nom}")
for ligne in diff:
print(ligne.rstrip("\n"))
print()
question = t("Redeploy the %s outdated command(s)? [o/N] ") % len(
perimees
)
if not self._is_yes(input(question)):
print(t("Nothing to do."))
return
for nom, _ in perimees:
self._redeploy_claude_command(
nom, self._CLAUDE_COMMAND_TEMPLATES[nom]
)
def _claude_context_root(self):
"""La racine du dépôt, deux niveaux au-dessus de ce fichier."""
@ -3429,13 +3573,8 @@ class TODO(
return t("not in the repository")
if not os.path.isfile(deployed):
return t("missing")
def stables(chemin):
with open(chemin, encoding="utf-8", errors="replace") as fh:
return [x for x in fh if "user.name=" not in x]
try:
if stables(deployed) == stables(template):
if not self._claude_command_diff(deployed, template):
return t("up to date")
except OSError:
return t("missing")
@ -3501,17 +3640,7 @@ class TODO(
print(f"{t('Skills'):<22} .claude/skills/ {t('missing')}")
print(f"{t('Deployed commands'):<22} ~/.claude/commands/")
gabarits = {
"commit": "template_claude_commands_commit.md",
"git_prepare_merge": (
"template_claude_commands_git_prepare_merge.md"
),
"todo_add_command": "template_claude_commands_todo_add_command.md",
"todo_generate_code": (
"template_claude_commands_todo_generate_code.md"
),
"todo_plan_max": "template_claude_commands_todo_plan_max.md",
}
gabarits = self._CLAUDE_COMMAND_TEMPLATES
for nom, gabarit in sorted(gabarits.items()):
etat = self._claude_command_state(
os.path.expanduser(f"~/.claude/commands/{nom}.md"),

View file

@ -1387,6 +1387,38 @@ TRANSLATIONS = {
"fr": "Aucune commande personnalisée trouvée dans ~/.claude/commands/",
"en": "No custom commands found in ~/.claude/commands/",
},
"command not installed": {
"fr": "non installée",
"en": "not installed",
},
"not from ERPLibre": {
"fr": "hors ERPLibre",
"en": "not from ERPLibre",
},
"installed copy": {
"fr": "copie installée",
"en": "installed copy",
},
"template": {
"fr": "gabarit",
"en": "template",
},
"Total: %s installed, %s to redeploy, %s not installed": {
"fr": "Total : %s installées, %s à redéployer, %s non installées",
"en": "Total: %s installed, %s to redeploy, %s not installed",
},
"A command not installed is deployed from this menu.": {
"fr": "Une commande non installée se déploie depuis ce menu.",
"en": "A command not installed is deployed from this menu.",
},
"Show the differences? [o/N] ": {
"fr": "Afficher les différences ? [o/N] ",
"en": "Show the differences? [y/N] ",
},
"Redeploy the %s outdated command(s)? [o/N] ": {
"fr": "Redéployer les %s commande(s) périmée(s) ? [o/N] ",
"en": "Redeploy the %s outdated command(s)? [y/N] ",
},
"Claude Code custom commands:": {
"fr": "Commandes personnalisées Claude Code :",
"en": "Claude Code custom commands:",

View file

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