feat: ensure-agent + resolve npm-global CLIs without PATH
Add guided multi-select ensure-agent, resolve backend binaries via npm prefix when not on PATH, and optionally persist npm bin in shell rc.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""Tests for ``myagents ensure-agent`` multi-select."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from myagents.commands.ensure_agent import ensure_agent_cmd, run_ensure_agents
|
||||
|
||||
|
||||
def test_ensure_agent_all_present() -> None:
|
||||
with patch(
|
||||
"myagents.commands.ensure_agent.backend_available", return_value=True
|
||||
):
|
||||
result = CliRunner().invoke(ensure_agent_cmd, [])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "ready" in result.output.lower()
|
||||
|
||||
|
||||
def test_run_ensure_agents_noninteractive_installs_missing() -> None:
|
||||
def available(name: str) -> bool:
|
||||
return name != "claude"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.backend_available",
|
||||
side_effect=available,
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent._is_interactive", return_value=False
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.ensure_backend", return_value=True
|
||||
) as ensure,
|
||||
):
|
||||
still = run_ensure_agents()
|
||||
assert still == []
|
||||
assert any(c.args[0] == "claude" for c in ensure.call_args_list)
|
||||
# Selected path uses yes=True (no second prompt).
|
||||
assert ensure.call_args.kwargs.get("yes") is True
|
||||
|
||||
|
||||
def test_run_ensure_agents_interactive_select() -> None:
|
||||
def available(name: str) -> bool:
|
||||
return name not in ("claude", "codex")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.backend_available",
|
||||
side_effect=available,
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent._is_interactive", return_value=True
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.Prompt.ask", return_value="1"
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.ensure_backend", return_value=True
|
||||
) as ensure,
|
||||
):
|
||||
still = run_ensure_agents()
|
||||
# Only first missing (claude) selected.
|
||||
assert ensure.call_count == 1
|
||||
assert ensure.call_args.args[0] == "claude"
|
||||
assert "codex" in still
|
||||
|
||||
|
||||
def test_run_ensure_agents_prefer_on_enter() -> None:
|
||||
def available(name: str) -> bool:
|
||||
return name not in ("claude", "codex")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.backend_available",
|
||||
side_effect=available,
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent._is_interactive", return_value=True
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.Prompt.ask", return_value=""
|
||||
),
|
||||
patch(
|
||||
"myagents.commands.ensure_agent.ensure_backend", return_value=True
|
||||
) as ensure,
|
||||
):
|
||||
still = run_ensure_agents(prefer=["codex"])
|
||||
assert ensure.call_count == 1
|
||||
assert ensure.call_args.args[0] == "codex"
|
||||
assert "claude" in still
|
||||
|
||||
|
||||
def test_ensure_agent_help() -> None:
|
||||
result = CliRunner().invoke(ensure_agent_cmd, ["--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "multi-select" in result.output.lower() or "ensure-agent" in result.output
|
||||
assert "--yes" not in result.output
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -63,21 +64,31 @@ class TestEnsureBackend:
|
||||
assert launcher_mod.ensure_backend("hermes") is False
|
||||
|
||||
|
||||
class TestResolveInstalledBinary:
|
||||
def test_falls_back_to_npm_global_bin(self, tmp_path: Path) -> None:
|
||||
class TestResolveBackendBinary:
|
||||
def test_falls_back_to_npm_global_bin(self, tmp_path: Path, monkeypatch) -> 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"]
|
||||
monkeypatch.delenv("CLAUDE_BIN", raising=False)
|
||||
with (
|
||||
patch.object(
|
||||
launcher_mod, "_resolve_backend_binary", return_value=None
|
||||
),
|
||||
patch.object(launcher_mod.shutil, "which", return_value=None),
|
||||
patch.object(launcher_mod, "_npm_global_bin", return_value=bin_dir),
|
||||
):
|
||||
assert launcher_mod._resolve_installed_binary(cfg) == str(claude)
|
||||
assert launcher_mod.resolve_backend_binary("claude") == str(claude)
|
||||
|
||||
def test_ensure_npm_bin_prepends_path(
|
||||
self, tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
bin_dir = tmp_path / "npm-bin"
|
||||
bin_dir.mkdir()
|
||||
monkeypatch.setenv("PATH", "/usr/bin")
|
||||
with patch.object(launcher_mod, "_npm_global_bin", return_value=bin_dir):
|
||||
got = launcher_mod.ensure_npm_bin_on_path(persist=False)
|
||||
assert got == bin_dir
|
||||
assert str(bin_dir) in os.environ["PATH"].split(os.pathsep)
|
||||
assert os.environ["PATH"].split(os.pathsep)[0] == str(bin_dir)
|
||||
|
||||
|
||||
class TestOfferInstallGuided:
|
||||
|
||||
Reference in New Issue
Block a user