diff --git a/script/todo/todo.py b/script/todo/todo.py index 9b9769f..49ff113 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2561,6 +2561,18 @@ class TODO( "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) @@ -2690,24 +2702,129 @@ class TODO( ) print(f"✅ merge.conflictStyle = {value}") - # Le shell -> (fichier de configuration, ligne d'initialisation). La ligne - # va en FIN de fichier : starship compose le prompt et doit passer après - # tout ce qui y touche. - _STARSHIP_SHELLS = { - "bash": ("~/.bashrc", 'eval "$(starship init bash)"'), - "zsh": ("~/.zshrc", 'eval "$(starship init zsh)"'), - "fish": ("~/.config/fish/config.fish", "starship init fish | source"), + # 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. @@ -2752,46 +2869,6 @@ class TODO( self._is_yes, ) - def _shell_starship_present(self): - """Les shells dont le fichier de configuration existe déjà.""" - return [ - nom - for nom, (fichier, _ligne) in self._STARSHIP_SHELLS.items() - if os.path.exists(os.path.expanduser(fichier)) - ] - - def _shell_starship_target(self): - """Le shell à accrocher. Ne demande que devant un vrai choix. - - Aucun fichier de configuration présent : bash, sans question — la pose - 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_starship_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._STARSHIP_SHELLS[nom][0]}") - 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_hook_starship(self): """Ajouter la ligne d'initialisation au fichier du shell choisi. @@ -2800,27 +2877,48 @@ class TODO( 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_starship_target() - chemin, ligne = self._STARSHIP_SHELLS[shell] - chemin = os.path.expanduser(chemin) - contenu = "" - if os.path.exists(chemin): - with open(chemin, encoding="utf-8") as fh: - contenu = fh.read() - if "starship init" in contenu: - print(f"✅ {t('starship is already hooked into: ')}{chemin}") + 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 - 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 d'init à la dernière commande. - if contenu and not contenu.endswith("\n"): - fh.write("\n") - fh.write(f"{ligne}\n") 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 = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 089d6b8..ec5e701 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -908,6 +908,30 @@ TRANSLATIONS = { "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): ", diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 3adb286..9066c7c 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -499,6 +499,8 @@ class TestGitMenuNumbering(MenuCoherence, unittest.TestCase): "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", } diff --git a/test/test_todo_starship.py b/test/test_todo_shell_tools.py similarity index 50% rename from test/test_todo_starship.py rename to test/test_todo_shell_tools.py index fecbda6..ae647f0 100644 --- a/test/test_todo_starship.py +++ b/test/test_todo_shell_tools.py @@ -2,14 +2,16 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -"""Installer starship : deux étapes qui échouent séparément. +"""Les outils de shell du menu Git et Shell : starship, Claude Code, opencode. -Poser le binaire ne change pas le prompt ; c'est la ligne d'initialisation -dans le fichier du shell qui le fait. Un menu qui confond les deux annonce un -succès devant un prompt inchangé, ou réécrit la ligne à chaque passage. +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 à modifier ne se demande que devant un vrai choix : plusieurs -fichiers de configuration présents. Aucun, ou un seul, ne laisse rien à +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. """ @@ -25,6 +27,7 @@ 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): @@ -49,21 +52,17 @@ class ShellFixture(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) - racine = Path(self.tmp.name) + self.racine = Path(self.tmp.name) self.fichiers = { - "bash": racine / "bashrc", - "zsh": racine / "zshrc", - "fish": racine / "fish" / "config.fish", + "bash": self.racine / "bashrc", + "zsh": self.racine / "zshrc", + "fish": self.racine / "fish" / "config.fish", } - table = { - "bash": (str(self.fichiers["bash"]), LIGNE_BASH), - "zsh": (str(self.fichiers["zsh"]), LIGNE_ZSH), - "fish": ( - str(self.fichiers["fish"]), - "starship init fish | source", - ), - } - patcher = patch.dict(TODO._STARSHIP_SHELLS, table, clear=True) + 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() @@ -75,7 +74,7 @@ class ShellFixture(unittest.TestCase): chemin.write_text(contenu, encoding="utf-8") -class TestStarshipTarget(ShellFixture): +class TestRcTarget(ShellFixture): """Quel fichier, et quand la question se pose.""" def choisit(self, shell_env="/bin/bash", reponse=None): @@ -83,7 +82,7 @@ class TestStarshipTarget(ShellFixture): with patch.dict(os.environ, {"SHELL": shell_env}), patch( "builtins.input", entree ), redirect_stdout(io.StringIO()): - return self.todo._shell_starship_target() + return self.todo._shell_rc_target() def test_no_file_means_bash_without_asking(self): self.assertEqual(self.choisit(), "bash") @@ -116,8 +115,71 @@ class TestStarshipTarget(ShellFixture): 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, qui ne demande rien.""" + """L'écriture de la ligne d'initialisation, qui ne demande rien.""" def hook(self, shell_env="/bin/bash"): out = io.StringIO() @@ -135,12 +197,12 @@ class TestHookStarship(ShellFixture): f"export EDITOR=vim\n{LIGNE_BASH}\n", ) - def test_a_file_without_a_final_newline_keeps_its_last_command(self): - self.cree("bash", contenu="export EDITOR=vim") + def test_each_shell_gets_its_own_line(self): + self.cree("fish") self.hook() self.assertEqual( - self.fichiers["bash"].read_text(encoding="utf-8"), - f"export EDITOR=vim\n{LIGNE_BASH}\n", + self.fichiers["fish"].read_text(encoding="utf-8"), + f"{LIGNE_FISH}\n", ) def test_it_writes_only_once(self): @@ -159,18 +221,6 @@ class TestHookStarship(ShellFixture): f"{LIGNE_BASH}\n", ) - def test_a_missing_parent_directory_is_created(self): - """config.fish vit sous ~/.config/fish, que rien ne garantit.""" - self.todo._STARSHIP_SHELLS["bash"] = ( - str(self.fichiers["fish"]), - LIGNE_BASH, - ) - self.hook() - self.assertEqual( - self.fichiers["fish"].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.""" @@ -233,5 +283,102 @@ class TestInstallStarship(unittest.TestCase): 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()