feat: guided auto-install for missing agent CLIs
Offer install with default Yes on TTY; auto-install when non-interactive (install/CI). Resolve binaries from npm global prefix after install.
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""Tests for myagents.launcher.ensure_backend / guided auto-install."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from myagents import launcher as launcher_mod
|
||||
|
||||
|
||||
class TestEnsureBackend:
|
||||
def test_already_available_skips_install(self) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
launcher_mod,
|
||||
"_resolve_backend_binary",
|
||||
return_value="/usr/bin/claude",
|
||||
),
|
||||
patch.object(launcher_mod, "_offer_install") as offer,
|
||||
):
|
||||
assert launcher_mod.ensure_backend("claude") is True
|
||||
offer.assert_not_called()
|
||||
|
||||
def test_noninteractive_auto_installs(self) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
launcher_mod, "_resolve_backend_binary", return_value=None
|
||||
),
|
||||
patch.object(launcher_mod, "_is_interactive", return_value=False),
|
||||
patch.object(
|
||||
launcher_mod, "_run_install", return_value="/opt/bin/claude"
|
||||
) as run_install,
|
||||
patch.object(launcher_mod, "click") as click_mod,
|
||||
):
|
||||
assert launcher_mod.ensure_backend("claude") is True
|
||||
run_install.assert_called_once()
|
||||
click_mod.prompt.assert_not_called()
|
||||
|
||||
def test_yes_true_skips_prompt(self) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
launcher_mod, "_resolve_backend_binary", return_value=None
|
||||
),
|
||||
patch.object(
|
||||
launcher_mod, "_run_install", return_value="/opt/bin/claude"
|
||||
) as run_install,
|
||||
patch.object(launcher_mod, "click") as click_mod,
|
||||
):
|
||||
assert launcher_mod.ensure_backend("claude", yes=True) is True
|
||||
run_install.assert_called_once()
|
||||
click_mod.prompt.assert_not_called()
|
||||
|
||||
def test_unknown_backend_false(self) -> None:
|
||||
assert launcher_mod.ensure_backend("nope") is False
|
||||
|
||||
def test_hermes_cannot_auto_install(self) -> None:
|
||||
with (
|
||||
patch.object(
|
||||
launcher_mod, "_resolve_backend_binary", return_value=None
|
||||
),
|
||||
patch.object(launcher_mod, "_is_interactive", return_value=False),
|
||||
):
|
||||
assert launcher_mod.ensure_backend("hermes") is False
|
||||
|
||||
|
||||
class TestResolveInstalledBinary:
|
||||
def test_falls_back_to_npm_global_bin(self, tmp_path: Path) -> None:
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
claude = bin_dir / "claude"
|
||||
claude.write_text("#!/bin/sh\n")
|
||||
claude.chmod(0o755)
|
||||
cfg = launcher_mod._BACKENDS["claude"]
|
||||
with (
|
||||
patch.object(
|
||||
launcher_mod, "_resolve_backend_binary", return_value=None
|
||||
),
|
||||
patch.object(launcher_mod, "_npm_global_bin", return_value=bin_dir),
|
||||
):
|
||||
assert launcher_mod._resolve_installed_binary(cfg) == str(claude)
|
||||
|
||||
|
||||
class TestOfferInstallGuided:
|
||||
def test_interactive_default_yes_when_npm_present(self) -> None:
|
||||
cfg = launcher_mod._BACKENDS["claude"]
|
||||
with (
|
||||
patch.object(launcher_mod, "_is_interactive", return_value=True),
|
||||
patch.object(
|
||||
launcher_mod.shutil, "which", return_value="/usr/bin/npm"
|
||||
),
|
||||
patch.object(
|
||||
launcher_mod, "_run_install", return_value="/opt/bin/claude"
|
||||
) as run_install,
|
||||
patch.object(
|
||||
launcher_mod.click,
|
||||
"prompt",
|
||||
return_value="y",
|
||||
) as prompt,
|
||||
):
|
||||
assert (
|
||||
launcher_mod._offer_install(cfg, yes=None) == "/opt/bin/claude"
|
||||
)
|
||||
prompt.assert_called_once()
|
||||
assert prompt.call_args.kwargs.get("default") == "y"
|
||||
run_install.assert_called_once()
|
||||
Reference in New Issue
Block a user