[ADD] script todo : gérer les plugins Claude Code et leur liste ERPLibre

Rien ne posait un plugin depuis TODO ; la CLI seule le faisait, hors du
menu. 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 préférée
s'affiche donc AVANT la confirmation, seule occasion de la lire ; ses
quatre plugins travaillent sur le poste, sans service tiers ni compte.
La recherche lit les manifestes sur le disque, donc hors ligne.

Vérifié : 8 tests neufs, dont la frontière de mot qui sépare deux noms
dont l'un contient l'autre — une recherche naïve en rate trois.

--- EN ---

Nothing installed a plugin from TODO; the CLI alone did, outside the
menu. Installing goes through « -y »: TODO's output is a pipe, not a
terminal, and without it the CLI refuses any install that runs a
command declared by a marketplace. The preferred list is therefore
shown BEFORE the confirmation, the only chance to read it; its four
plugins run on the workstation, with no third party and no account.
Search reads the manifests from disk, hence offline.

Checked: 8 new tests, among them the word boundary separating two names
where one contains the other — a naive search misses three of them.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-02 06:35:05 -04:00
parent c32e7d87c9
commit 6847891c1b
3 changed files with 477 additions and 0 deletions

View file

@ -3027,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)
@ -3043,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 !"))
@ -3334,6 +3341,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 = [

View file

@ -1196,6 +1196,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",

View file

@ -485,6 +485,134 @@ class TestSetupClaudeCommit(unittest.TestCase):
mock_makedirs.assert_called_once()
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):