diff --git a/script/todo/todo.py b/script/todo/todo.py index 884cd03..dd19a01 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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[^"]*)" -c user\.email="(?P[^"]*)"' + ) + + 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"), diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 713de28..3e91dbf 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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:", diff --git a/test/test_todo.py b/test/test_todo.py index 58bf753..7f9dd38 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -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.