[ADD] script todo : installer Claude Code et opencode, PATH garanti

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 répertoire diffère d'un outil à l'autre,
et l'un des deux écrit déjà sa propre ligne — la présence se teste donc sur
le répertoire, pas sur la graphie, et rien n'est ajouté deux fois.

Le PATH d'un processus est figé depuis son démarrage : un nouveau shell est
nécessaire, ce que la sortie dit. Troisième écrivain dans le fichier de
shell, starship compris, d'où l'écriture mise en commun. Vérifié : 31 tests.

--- EN ---

These installers put their binary in a HOME directory that a shell's PATH
does not always carry: without the export line, the binary is there and the
command stays not found. The directory differs from one tool to the other,
and one of the two already writes its own line — presence is therefore tested
on the directory, not on the spelling, and nothing is added twice.

A process PATH is frozen since its start: a new shell is needed, which the
output says. Third writer into the shell file, starship included, hence the
shared write. Checked: 31 tests.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-02 04:51:03 -04:00
parent 371605b90a
commit 8bdbf42a31
4 changed files with 372 additions and 101 deletions

View file

@ -2561,6 +2561,18 @@ class TODO(
"method": "_shell_install_starship", "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) help_info = self.fill_help_info(choices)
@ -2690,24 +2702,129 @@ class TODO(
) )
print(f"✅ merge.conflictStyle = {value}") print(f"✅ merge.conflictStyle = {value}")
# Le shell -> (fichier de configuration, ligne d'initialisation). La ligne # Le shell -> son fichier de configuration.
# va en FIN de fichier : starship compose le prompt et doit passer après _SHELL_RC = {
# tout ce qui y touche. "bash": "~/.bashrc",
_STARSHIP_SHELLS = { "zsh": "~/.zshrc",
"bash": ("~/.bashrc", 'eval "$(starship init bash)"'), "fish": "~/.config/fish/config.fish",
"zsh": ("~/.zshrc", 'eval "$(starship init zsh)"'), }
"fish": ("~/.config/fish/config.fish", "starship init fish | source"),
# 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 # 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. # 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" _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 @staticmethod
def _shell_name(): def _shell_name():
"""Le nom du shell de l'utilisateur d'après $SHELL, '' s'il est vide.""" """Le nom du shell de l'utilisateur d'après $SHELL, '' s'il est vide."""
return os.path.basename(os.environ.get("SHELL", "")).strip() 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): def _shell_install_starship(self):
"""Poser starship, puis l'accrocher au shell de l'utilisateur. """Poser starship, puis l'accrocher au shell de l'utilisateur.
@ -2752,46 +2869,6 @@ class TODO(
self._is_yes, 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): def _shell_hook_starship(self):
"""Ajouter la ligne d'initialisation au fichier du shell choisi. """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 ne demande pas de confirmation — le choix du fichier, quand il y en a
un à faire, l'a déjà donnée. un à faire, l'a déjà donnée.
""" """
shell = self._shell_starship_target() shell = self._shell_rc_target()
chemin, ligne = self._STARSHIP_SHELLS[shell] ligne = self._STARSHIP_LINE[shell]
chemin = os.path.expanduser(chemin) chemin = self._shell_rc_append(shell, ligne, "starship init")
contenu = "" if chemin is None:
if os.path.exists(chemin): fichier = os.path.expanduser(self._SHELL_RC[shell])
with open(chemin, encoding="utf-8") as fh: print(f"✅ {t('starship is already hooked into: ')}{fichier}")
contenu = fh.read()
if "starship init" in contenu:
print(f"✅ {t('starship is already hooked into: ')}{chemin}")
return 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"✅ {t('starship hooked into: ')}{chemin}")
print(f" {ligne}") print(f" {ligne}")
print(f" {t('Open a new shell to see it.')}") 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): def prompt_execute_git_local_server(self):
print(f"🤖 {t('Manage local git repository server!')}") print(f"🤖 {t('Manage local git repository server!')}")
choices = [ choices = [

View file

@ -908,6 +908,30 @@ TRANSLATIONS = {
"fr": "🚀 Installer Starship sur le shell", "fr": "🚀 Installer Starship sur le shell",
"en": "🚀 Install Starship on 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): ": { "Install starship? (y/N): ": {
"fr": "Installer starship ? (o/N) : ", "fr": "Installer starship ? (o/N) : ",
"en": "Install starship? (y/N): ", "en": "Install starship? (y/N): ",

View file

@ -499,6 +499,8 @@ class TestGitMenuNumbering(MenuCoherence, unittest.TestCase):
"Install git hooks": "_git_install_hooks", "Install git hooks": "_git_install_hooks",
"Set merge.conflictStyle": "_git_set_conflict_style", "Set merge.conflictStyle": "_git_set_conflict_style",
"Install Starship on Shell": "_shell_install_starship", "Install Starship on Shell": "_shell_install_starship",
"Install Claude Code": "_shell_install_claude_code",
"Install opencode": "_shell_install_opencode",
} }

View file

@ -2,14 +2,16 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca) # © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) # 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 Trois installations, un même piège : poser le binaire ne suffit pas. Starship
dans le fichier du shell qui le fait. Un menu qui confond les deux annonce un ne change rien au prompt sans sa ligne d'initialisation ; les deux assistants
succès devant un prompt inchangé, ou réécrit la ligne à chaque passage. 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 Le fichier de configuration à modifier ne se demande que devant un vrai
fichiers de configuration présents. Aucun, ou un seul, ne laisse rien à 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. 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_BASH = 'eval "$(starship init bash)"'
LIGNE_ZSH = 'eval "$(starship init zsh)"' LIGNE_ZSH = 'eval "$(starship init zsh)"'
LIGNE_FISH = "starship init fish | source"
def refuse_input(*args, **kwargs): def refuse_input(*args, **kwargs):
@ -49,21 +52,17 @@ class ShellFixture(unittest.TestCase):
def setUp(self): def setUp(self):
self.tmp = tempfile.TemporaryDirectory() self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup) self.addCleanup(self.tmp.cleanup)
racine = Path(self.tmp.name) self.racine = Path(self.tmp.name)
self.fichiers = { self.fichiers = {
"bash": racine / "bashrc", "bash": self.racine / "bashrc",
"zsh": racine / "zshrc", "zsh": self.racine / "zshrc",
"fish": racine / "fish" / "config.fish", "fish": self.racine / "fish" / "config.fish",
} }
table = { patcher = patch.dict(
"bash": (str(self.fichiers["bash"]), LIGNE_BASH), TODO._SHELL_RC,
"zsh": (str(self.fichiers["zsh"]), LIGNE_ZSH), {nom: str(p) for nom, p in self.fichiers.items()},
"fish": ( clear=True,
str(self.fichiers["fish"]), )
"starship init fish | source",
),
}
patcher = patch.dict(TODO._STARSHIP_SHELLS, table, clear=True)
patcher.start() patcher.start()
self.addCleanup(patcher.stop) self.addCleanup(patcher.stop)
self.todo = TODO() self.todo = TODO()
@ -75,7 +74,7 @@ class ShellFixture(unittest.TestCase):
chemin.write_text(contenu, encoding="utf-8") chemin.write_text(contenu, encoding="utf-8")
class TestStarshipTarget(ShellFixture): class TestRcTarget(ShellFixture):
"""Quel fichier, et quand la question se pose.""" """Quel fichier, et quand la question se pose."""
def choisit(self, shell_env="/bin/bash", reponse=None): 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( with patch.dict(os.environ, {"SHELL": shell_env}), patch(
"builtins.input", entree "builtins.input", entree
), redirect_stdout(io.StringIO()): ), 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): def test_no_file_means_bash_without_asking(self):
self.assertEqual(self.choisit(), "bash") self.assertEqual(self.choisit(), "bash")
@ -116,8 +115,71 @@ class TestStarshipTarget(ShellFixture):
self.assertEqual(self.choisit(reponse="42"), "bash") 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): 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"): def hook(self, shell_env="/bin/bash"):
out = io.StringIO() out = io.StringIO()
@ -135,12 +197,12 @@ class TestHookStarship(ShellFixture):
f"export EDITOR=vim\n{LIGNE_BASH}\n", f"export EDITOR=vim\n{LIGNE_BASH}\n",
) )
def test_a_file_without_a_final_newline_keeps_its_last_command(self): def test_each_shell_gets_its_own_line(self):
self.cree("bash", contenu="export EDITOR=vim") self.cree("fish")
self.hook() self.hook()
self.assertEqual( self.assertEqual(
self.fichiers["bash"].read_text(encoding="utf-8"), self.fichiers["fish"].read_text(encoding="utf-8"),
f"export EDITOR=vim\n{LIGNE_BASH}\n", f"{LIGNE_FISH}\n",
) )
def test_it_writes_only_once(self): def test_it_writes_only_once(self):
@ -159,18 +221,6 @@ class TestHookStarship(ShellFixture):
f"{LIGNE_BASH}\n", 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): class TestInstallStarship(unittest.TestCase):
"""L'enchaînement des deux étapes, et ce qui l'arrête.""" """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]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()