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:
@@ -6,6 +6,7 @@ import click
|
|||||||
|
|
||||||
from myagents.commands import update_cmd, upgrade_cmd
|
from myagents.commands import update_cmd, upgrade_cmd
|
||||||
from myagents.commands.completion import build_completion_group
|
from myagents.commands.completion import build_completion_group
|
||||||
|
from myagents.commands.ensure_agent import ensure_agent_cmd
|
||||||
from myagents.launcher import build_cli
|
from myagents.launcher import build_cli
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ cli.add_command(build_cli("kimi"), name="kimi")
|
|||||||
cli.add_command(build_cli("codex"), name="codex")
|
cli.add_command(build_cli("codex"), name="codex")
|
||||||
cli.add_command(build_cli("hermes"), name="hermes")
|
cli.add_command(build_cli("hermes"), name="hermes")
|
||||||
cli.add_command(build_cli("cursor"), name="cursor")
|
cli.add_command(build_cli("cursor"), name="cursor")
|
||||||
|
cli.add_command(ensure_agent_cmd)
|
||||||
cli.add_command(update_cmd)
|
cli.add_command(update_cmd)
|
||||||
cli.add_command(upgrade_cmd, name="upgrade")
|
cli.add_command(upgrade_cmd, name="upgrade")
|
||||||
cli.add_command(build_completion_group(_progs))
|
cli.add_command(build_completion_group(_progs))
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""``myagents ensure-agent`` — install missing agent backend CLIs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from myagents.launcher import (
|
||||||
|
_BACKENDS,
|
||||||
|
_is_interactive,
|
||||||
|
backend_available,
|
||||||
|
ensure_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
console = Console()
|
||||||
|
|
||||||
|
|
||||||
|
def installable_backends() -> list[str]:
|
||||||
|
"""Backends that have a one-shot ``install_cmd`` (claude, codex, …)."""
|
||||||
|
return [name for name, cfg in _BACKENDS.items() if cfg.get("install_cmd")]
|
||||||
|
|
||||||
|
|
||||||
|
def run_ensure_agents(
|
||||||
|
*,
|
||||||
|
prefer: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Show status, let user pick missing CLIs, install them.
|
||||||
|
|
||||||
|
``prefer``: when interactive, Enter defaults to these names (intersected
|
||||||
|
with missing) instead of all missing — used by xiaohe for its default
|
||||||
|
agent. Returns backends that remain missing afterwards.
|
||||||
|
"""
|
||||||
|
targets = installable_backends()
|
||||||
|
if not targets:
|
||||||
|
console.print(
|
||||||
|
"[yellow]No auto-installable backends registered.[/yellow]"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
"[bold]Agent backend CLIs[/bold] (npm global — not skill Node deps)"
|
||||||
|
)
|
||||||
|
table = Table()
|
||||||
|
table.add_column("Backend", style="dim")
|
||||||
|
table.add_column("Status")
|
||||||
|
missing: list[str] = []
|
||||||
|
for name in targets:
|
||||||
|
ok = backend_available(name)
|
||||||
|
if not ok:
|
||||||
|
missing.append(name)
|
||||||
|
table.add_row(
|
||||||
|
name,
|
||||||
|
"[green]✓ installed[/green]" if ok else "[red]✗ missing[/red]",
|
||||||
|
)
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
manual = [
|
||||||
|
name for name, cfg in _BACKENDS.items() if not cfg.get("install_cmd")
|
||||||
|
]
|
||||||
|
if manual:
|
||||||
|
console.print(f"[dim]Manual install only: {', '.join(manual)}[/dim]")
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
console.print("[green]All installable agent CLIs ready.[/green]")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not _is_interactive():
|
||||||
|
to_install = (
|
||||||
|
[n for n in (prefer or []) if n in missing] or list(missing)
|
||||||
|
)
|
||||||
|
console.print(
|
||||||
|
f"[dim]Non-interactive — installing: {', '.join(to_install)}[/dim]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print()
|
||||||
|
console.print("[bold]Install which missing CLI(s)?[/bold]")
|
||||||
|
for i, name in enumerate(missing, 1):
|
||||||
|
tag = (
|
||||||
|
" [cyan](recommended)[/cyan]"
|
||||||
|
if prefer and name in prefer
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
console.print(f" {i}) [cyan]{name}[/cyan]{tag}")
|
||||||
|
console.print(f" {len(missing) + 1}) [dim]None — skip[/dim]")
|
||||||
|
|
||||||
|
default_set = (
|
||||||
|
[n for n in prefer if n in missing] if prefer else list(missing)
|
||||||
|
)
|
||||||
|
enter_label = (
|
||||||
|
f"recommended [{', '.join(default_set)}]"
|
||||||
|
if prefer and default_set
|
||||||
|
else "all missing"
|
||||||
|
)
|
||||||
|
console.print()
|
||||||
|
try:
|
||||||
|
choice = Prompt.ask(
|
||||||
|
f"Numbers (comma-separated; Enter = {enter_label})",
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
except (click.Abort, EOFError):
|
||||||
|
return list(missing)
|
||||||
|
|
||||||
|
choice = choice.strip()
|
||||||
|
if not choice:
|
||||||
|
to_install = default_set
|
||||||
|
else:
|
||||||
|
to_install = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for part in choice.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part.isdigit():
|
||||||
|
console.print(f"[red]Invalid choice: {choice}[/red]")
|
||||||
|
return list(missing)
|
||||||
|
idx = int(part) - 1
|
||||||
|
if idx == len(missing):
|
||||||
|
continue
|
||||||
|
if idx < 0 or idx >= len(missing):
|
||||||
|
console.print(f"[red]Invalid choice: {choice}[/red]")
|
||||||
|
return list(missing)
|
||||||
|
name = missing[idx]
|
||||||
|
if name not in seen:
|
||||||
|
seen.add(name)
|
||||||
|
to_install.append(name)
|
||||||
|
|
||||||
|
if not to_install:
|
||||||
|
console.print("[dim]Nothing selected.[/dim]")
|
||||||
|
return list(missing)
|
||||||
|
|
||||||
|
failed: list[str] = []
|
||||||
|
for name in to_install:
|
||||||
|
# Selection already confirmed — skip the second Install now? prompt.
|
||||||
|
if ensure_backend(name, yes=True):
|
||||||
|
console.print(f"[green]{name} CLI ready.[/green]")
|
||||||
|
else:
|
||||||
|
failed.append(name)
|
||||||
|
console.print(f"[yellow]{name} CLI still missing.[/yellow]")
|
||||||
|
|
||||||
|
still = [n for n in missing if n not in to_install or n in failed]
|
||||||
|
if failed:
|
||||||
|
console.print()
|
||||||
|
console.print(
|
||||||
|
"[yellow]Some installs failed.[/yellow] Install Node.js if needed, "
|
||||||
|
"then re-run [cyan]myagents ensure-agent[/cyan]."
|
||||||
|
)
|
||||||
|
elif not still:
|
||||||
|
console.print("[green]Selected agent CLIs ready.[/green]")
|
||||||
|
console.print(
|
||||||
|
" Launch: [cyan]myclaude[/cyan] / [cyan]mycodex[/cyan] / …"
|
||||||
|
)
|
||||||
|
return still
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("ensure-agent")
|
||||||
|
def ensure_agent_cmd() -> None:
|
||||||
|
"""Check/install agent backend CLIs (npm global; not skill Node deps).
|
||||||
|
|
||||||
|
Guided multi-select of missing CLIs on a TTY. Non-interactive installs
|
||||||
|
all missing auto-installable backends. Just run: myagents ensure-agent
|
||||||
|
"""
|
||||||
|
still = run_ensure_agents()
|
||||||
|
# Only fail hard when we attempted installs and some failed, or
|
||||||
|
# non-interactive left missing after auto-install.
|
||||||
|
if still and not _is_interactive():
|
||||||
|
raise click.ClickException(
|
||||||
|
"missing agent CLI(s): " + ", ".join(still)
|
||||||
|
)
|
||||||
+200
-70
@@ -166,7 +166,7 @@ def _translate_cursor_extra(extra: list[str]) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_backend_binary(config: dict) -> str | None:
|
def _resolve_backend_binary(config: dict) -> str | None:
|
||||||
"""Resolve backend CLI path from env override, primary name, then alts."""
|
"""Resolve backend CLI: env override → PATH → npm global prefix."""
|
||||||
binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
|
binary = os.environ.get(config["env_bin"]) or shutil.which(config["binary"])
|
||||||
if binary:
|
if binary:
|
||||||
return binary
|
return binary
|
||||||
@@ -174,9 +174,175 @@ def _resolve_backend_binary(config: dict) -> str | None:
|
|||||||
found = shutil.which(alt)
|
found = shutil.which(alt)
|
||||||
if found:
|
if found:
|
||||||
return found
|
return found
|
||||||
|
return _resolve_from_npm_global(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_global_bin() -> Path | None:
|
||||||
|
"""Directory where ``npm install -g`` places binaries, if resolvable."""
|
||||||
|
npm = shutil.which("npm")
|
||||||
|
if not npm:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[npm, "prefix", "-g"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
prefix = (proc.stdout or "").strip()
|
||||||
|
if proc.returncode != 0 or not prefix:
|
||||||
|
return None
|
||||||
|
root = Path(prefix)
|
||||||
|
# Unix: <prefix>/bin; Windows npm usually puts shims in <prefix> itself.
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return root
|
||||||
|
return root / "bin"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_from_npm_global(config: dict) -> str | None:
|
||||||
|
"""Look up binary under ``npm prefix -g`` (even when that dir is not on PATH)."""
|
||||||
|
names = (config["binary"], *config.get("alt_binaries", ()))
|
||||||
|
npm_bin = _npm_global_bin()
|
||||||
|
if not npm_bin or not npm_bin.is_dir():
|
||||||
|
return None
|
||||||
|
for name in names:
|
||||||
|
candidates = [npm_bin / name]
|
||||||
|
if sys.platform == "win32":
|
||||||
|
candidates.extend(
|
||||||
|
[npm_bin / f"{name}.cmd", npm_bin / f"{name}.exe"]
|
||||||
|
)
|
||||||
|
for cand in candidates:
|
||||||
|
if cand.is_file():
|
||||||
|
return str(cand)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_interactive() -> bool:
|
||||||
|
"""True when we can safely prompt the user (TTY, not CI)."""
|
||||||
|
if os.environ.get("CI", "").lower() in ("1", "true", "yes"):
|
||||||
|
return False
|
||||||
|
if os.environ.get("XIAOHE_ASSUME_YES", "").lower() in ("1", "true", "yes"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return bool(sys.stdin.isatty() and sys.stderr.isatty())
|
||||||
|
except Exception: # noqa: BLE001 — treat broken streams as non-interactive
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_NPM_BIN_ORIGINALLY_ON_PATH: dict[str, bool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_bin_on_path(npm_bin: Path) -> bool:
|
||||||
|
"""True if ``npm_bin`` already appears as a PATH entry."""
|
||||||
|
want = str(npm_bin.resolve()) if npm_bin.exists() else str(npm_bin)
|
||||||
|
for part in os.environ.get("PATH", "").split(os.pathsep):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if str(Path(part).resolve()) == want:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
if part.rstrip("/\\") == str(npm_bin).rstrip("/\\"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_npm_bin_on_path(*, persist: bool | None = None) -> Path | None:
|
||||||
|
"""Prepend npm global bin to ``PATH`` for this process.
|
||||||
|
|
||||||
|
When ``persist`` is True, or None on an interactive TTY when the bin was
|
||||||
|
not on PATH at first sight, offer to append an export line to the shell rc
|
||||||
|
so future shells find ``claude`` via ``which``.
|
||||||
|
Returns the npm bin directory when known.
|
||||||
|
"""
|
||||||
|
npm_bin = _npm_global_bin()
|
||||||
|
if not npm_bin:
|
||||||
|
return None
|
||||||
|
|
||||||
|
key = str(npm_bin)
|
||||||
|
if key not in _NPM_BIN_ORIGINALLY_ON_PATH:
|
||||||
|
_NPM_BIN_ORIGINALLY_ON_PATH[key] = _npm_bin_on_path(npm_bin)
|
||||||
|
originally = _NPM_BIN_ORIGINALLY_ON_PATH[key]
|
||||||
|
|
||||||
|
if not _npm_bin_on_path(npm_bin):
|
||||||
|
os.environ["PATH"] = str(npm_bin) + os.pathsep + os.environ.get(
|
||||||
|
"PATH", ""
|
||||||
|
)
|
||||||
|
# Quiet when silently refreshing PATH for this process (persist=False).
|
||||||
|
if persist is not False:
|
||||||
|
stderr_console.print(
|
||||||
|
f"[dim]Prepended to PATH (this process):[/dim] {npm_bin}"
|
||||||
|
)
|
||||||
|
|
||||||
|
do_persist = persist
|
||||||
|
if do_persist is None:
|
||||||
|
do_persist = (not originally) and _is_interactive()
|
||||||
|
if do_persist and not originally:
|
||||||
|
_offer_persist_npm_bin(npm_bin)
|
||||||
|
return npm_bin
|
||||||
|
|
||||||
|
|
||||||
|
def _shell_rc_path() -> Path:
|
||||||
|
shell = os.environ.get("SHELL") or ""
|
||||||
|
if "zsh" in shell:
|
||||||
|
return Path.home() / ".zshrc"
|
||||||
|
if "fish" in shell:
|
||||||
|
return Path.home() / ".config" / "fish" / "config.fish"
|
||||||
|
return Path.home() / ".bashrc"
|
||||||
|
|
||||||
|
|
||||||
|
def _offer_persist_npm_bin(npm_bin: Path) -> None:
|
||||||
|
"""Ask to add npm global bin to the shell rc (guided, default Yes)."""
|
||||||
|
rc = _shell_rc_path()
|
||||||
|
if "fish" in str(rc):
|
||||||
|
line = f'set -gx PATH "{npm_bin}" $PATH # myagents / npm global'
|
||||||
|
else:
|
||||||
|
line = f'export PATH="{npm_bin}:$PATH" # myagents / npm global'
|
||||||
|
|
||||||
|
if rc.is_file():
|
||||||
|
try:
|
||||||
|
if line.split("#")[0].strip() in rc.read_text(encoding="utf-8"):
|
||||||
|
return
|
||||||
|
# Also skip if the directory is already exported somehow.
|
||||||
|
if str(npm_bin) in rc.read_text(encoding="utf-8"):
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stderr_console.print(
|
||||||
|
f"[yellow]{npm_bin}[/yellow] is not on your login PATH.\n"
|
||||||
|
f" Add it to [cyan]{rc}[/cyan] so new terminals find "
|
||||||
|
f"[cyan]claude[/cyan] / [cyan]codex[/cyan]?"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
answer = click.prompt(
|
||||||
|
"Add to shell rc? [Y/n]", default="y", show_default=False, err=True
|
||||||
|
)
|
||||||
|
except (click.Abort, EOFError):
|
||||||
|
return
|
||||||
|
if answer.strip().lower() not in ("y", "yes", ""):
|
||||||
|
stderr_console.print(
|
||||||
|
f"[dim]Skipped.[/dim] Later, add:\n {line}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
rc.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with rc.open("a", encoding="utf-8") as fh:
|
||||||
|
fh.write(f"\n{line}\n")
|
||||||
|
stderr_console.print(
|
||||||
|
f"[green]Added[/green] to {rc} — open a new terminal "
|
||||||
|
f"(or [cyan]source {rc}[/cyan])."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_installed_binary(config: dict) -> str | None:
|
||||||
|
"""Resolve binary after install (PATH + npm global)."""
|
||||||
|
ensure_npm_bin_on_path(persist=False)
|
||||||
|
return _resolve_backend_binary(config)
|
||||||
|
|
||||||
|
|
||||||
def _tmux_session_name(backend: str, chat_cwd: Path) -> str:
|
def _tmux_session_name(backend: str, chat_cwd: Path) -> str:
|
||||||
"""Deterministic per-backend, per-directory tmux session name.
|
"""Deterministic per-backend, per-directory tmux session name.
|
||||||
|
|
||||||
@@ -212,70 +378,21 @@ def _exec_tmux(backend: str, chat_cwd: Path, cmd: list[str]) -> None:
|
|||||||
"[dim](detach: C-b d, reattach: same command)[/dim]"
|
"[dim](detach: C-b d, reattach: same command)[/dim]"
|
||||||
)
|
)
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[tmux, "new-session", "-A", "-s", name, "-c", str(chat_cwd), shlex.join(cmd)],
|
[
|
||||||
|
tmux,
|
||||||
|
"new-session",
|
||||||
|
"-A",
|
||||||
|
"-s",
|
||||||
|
name,
|
||||||
|
"-c",
|
||||||
|
str(chat_cwd),
|
||||||
|
shlex.join(cmd),
|
||||||
|
],
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
raise SystemExit(proc.returncode)
|
raise SystemExit(proc.returncode)
|
||||||
|
|
||||||
|
|
||||||
def _is_interactive() -> bool:
|
|
||||||
"""True when we can safely prompt the user (TTY, not CI)."""
|
|
||||||
if os.environ.get("CI", "").lower() in ("1", "true", "yes"):
|
|
||||||
return False
|
|
||||||
if os.environ.get("XIAOHE_ASSUME_YES", "").lower() in ("1", "true", "yes"):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return bool(sys.stdin.isatty() and sys.stderr.isatty())
|
|
||||||
except Exception: # noqa: BLE001 — treat broken streams as non-interactive
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _npm_global_bin() -> Path | None:
|
|
||||||
"""Directory where ``npm install -g`` places binaries, if resolvable."""
|
|
||||||
npm = shutil.which("npm")
|
|
||||||
if not npm:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
proc = subprocess.run(
|
|
||||||
[npm, "prefix", "-g"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
|
||||||
return None
|
|
||||||
prefix = (proc.stdout or "").strip()
|
|
||||||
if proc.returncode != 0 or not prefix:
|
|
||||||
return None
|
|
||||||
root = Path(prefix)
|
|
||||||
# Unix: <prefix>/bin; Windows npm usually puts shims in <prefix> itself.
|
|
||||||
if sys.platform == "win32":
|
|
||||||
return root
|
|
||||||
return root / "bin"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_installed_binary(config: dict) -> str | None:
|
|
||||||
"""Resolve binary on PATH, then under the npm global prefix (post-install)."""
|
|
||||||
found = _resolve_backend_binary(config)
|
|
||||||
if found:
|
|
||||||
return found
|
|
||||||
names = (config["binary"], *config.get("alt_binaries", ()))
|
|
||||||
npm_bin = _npm_global_bin()
|
|
||||||
if not npm_bin or not npm_bin.is_dir():
|
|
||||||
return None
|
|
||||||
for name in names:
|
|
||||||
candidates = [npm_bin / name]
|
|
||||||
if sys.platform == "win32":
|
|
||||||
candidates.extend(
|
|
||||||
[npm_bin / f"{name}.cmd", npm_bin / f"{name}.exe"]
|
|
||||||
)
|
|
||||||
for cand in candidates:
|
|
||||||
if cand.is_file():
|
|
||||||
return str(cand)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _run_install(config: dict) -> str | None:
|
def _run_install(config: dict) -> str | None:
|
||||||
"""Run ``install_cmd`` and return the resolved binary path, or None."""
|
"""Run ``install_cmd`` and return the resolved binary path, or None."""
|
||||||
install_cmd = config.get("install_cmd")
|
install_cmd = config.get("install_cmd")
|
||||||
@@ -287,7 +404,8 @@ def _run_install(config: dict) -> str | None:
|
|||||||
f"[red]{install_cmd[0]} not found[/red] — cannot auto-install "
|
f"[red]{install_cmd[0]} not found[/red] — cannot auto-install "
|
||||||
f"{config['binary']}.\n"
|
f"{config['binary']}.\n"
|
||||||
" Install Node.js (e.g. [cyan]brew install node[/cyan]), then run "
|
" Install Node.js (e.g. [cyan]brew install node[/cyan]), then run "
|
||||||
"[cyan]xiaohe ensure-deps[/cyan] or [cyan]xiaohe init[/cyan]."
|
"[cyan]myagents ensure-agent[/cyan] or launch "
|
||||||
|
f"[cyan]my{config.get('binary', 'claude')}[/cyan]."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
@@ -295,17 +413,19 @@ def _run_install(config: dict) -> str | None:
|
|||||||
f"[cyan]{shlex.join(install_cmd)}[/cyan]"
|
f"[cyan]{shlex.join(install_cmd)}[/cyan]"
|
||||||
)
|
)
|
||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
"[dim] (npm global install — separate from skills-node; "
|
"[dim] (npm global install — may take a minute)[/dim]"
|
||||||
"may take a minute)[/dim]"
|
|
||||||
)
|
)
|
||||||
proc = subprocess.run([installer, *install_cmd[1:]], check=False)
|
proc = subprocess.run([installer, *install_cmd[1:]], check=False)
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
f"[red]Install failed (exit {proc.returncode}).[/red]\n"
|
f"[red]Install failed (exit {proc.returncode}).[/red]\n"
|
||||||
" Fix the error above, then run [cyan]xiaohe ensure-deps[/cyan]."
|
" Fix the error above, then run "
|
||||||
|
"[cyan]myagents ensure-agent[/cyan]."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return _resolve_installed_binary(config)
|
# Refresh PATH for this process; offer to persist npm bin into shell rc.
|
||||||
|
ensure_npm_bin_on_path(persist=None)
|
||||||
|
return _resolve_backend_binary(config)
|
||||||
|
|
||||||
|
|
||||||
def _offer_install(config: dict, *, yes: bool | None = None) -> str | None:
|
def _offer_install(config: dict, *, yes: bool | None = None) -> str | None:
|
||||||
@@ -341,17 +461,25 @@ def _offer_install(config: dict, *, yes: bool | None = None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
if answer.strip().lower() not in ("y", "yes", ""):
|
if answer.strip().lower() not in ("y", "yes", ""):
|
||||||
stderr_console.print(
|
stderr_console.print(
|
||||||
"[dim]Skipped.[/dim] Later: [cyan]xiaohe ensure-deps[/cyan] "
|
"[dim]Skipped.[/dim] Later: [cyan]myagents ensure-agent[/cyan] "
|
||||||
"or [cyan]xiaohe init[/cyan]."
|
"or launch again (e.g. [cyan]myclaude[/cyan])."
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return _run_install(config)
|
return _run_install(config)
|
||||||
|
|
||||||
|
|
||||||
def backend_available(backend: str) -> bool:
|
def resolve_backend_binary(backend: str) -> str | None:
|
||||||
"""True if the backend CLI (or its env override) resolves on PATH."""
|
"""Resolve absolute path to a backend CLI (env → PATH → npm global)."""
|
||||||
cfg = _BACKENDS.get(backend)
|
cfg = _BACKENDS.get(backend)
|
||||||
return bool(cfg and _resolve_backend_binary(cfg))
|
if not cfg:
|
||||||
|
return None
|
||||||
|
ensure_npm_bin_on_path(persist=False)
|
||||||
|
return _resolve_backend_binary(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def backend_available(backend: str) -> bool:
|
||||||
|
"""True if the backend CLI resolves (PATH, env override, or npm global)."""
|
||||||
|
return resolve_backend_binary(backend) is not None
|
||||||
|
|
||||||
|
|
||||||
def ensure_backend(backend: str, *, yes: bool | None = None) -> bool:
|
def ensure_backend(backend: str, *, yes: bool | None = None) -> bool:
|
||||||
@@ -363,6 +491,7 @@ def ensure_backend(backend: str, *, yes: bool | None = None) -> bool:
|
|||||||
explicit scripting. Backends without an installer (e.g. hermes) cannot
|
explicit scripting. Backends without an installer (e.g. hermes) cannot
|
||||||
be auto-installed. Returns True if the CLI is available afterwards.
|
be auto-installed. Returns True if the CLI is available afterwards.
|
||||||
"""
|
"""
|
||||||
|
ensure_npm_bin_on_path(persist=False)
|
||||||
cfg = _BACKENDS.get(backend)
|
cfg = _BACKENDS.get(backend)
|
||||||
if not cfg:
|
if not cfg:
|
||||||
return False
|
return False
|
||||||
@@ -379,6 +508,7 @@ def _launch(
|
|||||||
offer_install: bool = False,
|
offer_install: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run backend CLI in chat_cwd, forwarding extra args. Never returns."""
|
"""Run backend CLI in chat_cwd, forwarding extra args. Never returns."""
|
||||||
|
ensure_npm_bin_on_path(persist=False)
|
||||||
config = _BACKENDS[backend]
|
config = _BACKENDS[backend]
|
||||||
binary = _resolve_backend_binary(config)
|
binary = _resolve_backend_binary(config)
|
||||||
if not binary and offer_install:
|
if not binary and offer_install:
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -63,21 +64,31 @@ class TestEnsureBackend:
|
|||||||
assert launcher_mod.ensure_backend("hermes") is False
|
assert launcher_mod.ensure_backend("hermes") is False
|
||||||
|
|
||||||
|
|
||||||
class TestResolveInstalledBinary:
|
class TestResolveBackendBinary:
|
||||||
def test_falls_back_to_npm_global_bin(self, tmp_path: Path) -> None:
|
def test_falls_back_to_npm_global_bin(self, tmp_path: Path, monkeypatch) -> None:
|
||||||
bin_dir = tmp_path / "bin"
|
bin_dir = tmp_path / "bin"
|
||||||
bin_dir.mkdir()
|
bin_dir.mkdir()
|
||||||
claude = bin_dir / "claude"
|
claude = bin_dir / "claude"
|
||||||
claude.write_text("#!/bin/sh\n")
|
claude.write_text("#!/bin/sh\n")
|
||||||
claude.chmod(0o755)
|
claude.chmod(0o755)
|
||||||
cfg = launcher_mod._BACKENDS["claude"]
|
monkeypatch.delenv("CLAUDE_BIN", raising=False)
|
||||||
with (
|
with (
|
||||||
patch.object(
|
patch.object(launcher_mod.shutil, "which", return_value=None),
|
||||||
launcher_mod, "_resolve_backend_binary", return_value=None
|
|
||||||
),
|
|
||||||
patch.object(launcher_mod, "_npm_global_bin", return_value=bin_dir),
|
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:
|
class TestOfferInstallGuided:
|
||||||
|
|||||||
Reference in New Issue
Block a user