[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:
parent
76c548a781
commit
087445c578
3 changed files with 311 additions and 37 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue