diff --git a/myagents/cli.py b/myagents/cli.py index 0674263..fa9b6c1 100644 --- a/myagents/cli.py +++ b/myagents/cli.py @@ -6,6 +6,7 @@ import click from myagents.commands import update_cmd, upgrade_cmd from myagents.commands.completion import build_completion_group +from myagents.commands.ensure_agent import ensure_agent_cmd 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("hermes"), name="hermes") cli.add_command(build_cli("cursor"), name="cursor") +cli.add_command(ensure_agent_cmd) cli.add_command(update_cmd) cli.add_command(upgrade_cmd, name="upgrade") cli.add_command(build_completion_group(_progs)) diff --git a/myagents/commands/ensure_agent.py b/myagents/commands/ensure_agent.py new file mode 100644 index 0000000..11e7e2c --- /dev/null +++ b/myagents/commands/ensure_agent.py @@ -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) + ) diff --git a/myagents/launcher.py b/myagents/launcher.py index f319182..17b0f3d 100644 --- a/myagents/launcher.py +++ b/myagents/launcher.py @@ -166,7 +166,7 @@ def _translate_cursor_extra(extra: list[str]) -> list[str]: 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"]) if binary: return binary @@ -174,9 +174,175 @@ def _resolve_backend_binary(config: dict) -> str | None: found = shutil.which(alt) if 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: /bin; Windows npm usually puts shims in 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 +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: """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]" ) 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, ) 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: /bin; Windows npm usually puts shims in 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: """Run ``install_cmd`` and return the resolved binary path, or None.""" 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"{config['binary']}.\n" " 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 stderr_console.print( @@ -295,17 +413,19 @@ def _run_install(config: dict) -> str | None: f"[cyan]{shlex.join(install_cmd)}[/cyan]" ) stderr_console.print( - "[dim] (npm global install — separate from skills-node; " - "may take a minute)[/dim]" + "[dim] (npm global install — may take a minute)[/dim]" ) proc = subprocess.run([installer, *install_cmd[1:]], check=False) if proc.returncode != 0: stderr_console.print( 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 _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: @@ -341,17 +461,25 @@ def _offer_install(config: dict, *, yes: bool | None = None) -> str | None: return None if answer.strip().lower() not in ("y", "yes", ""): stderr_console.print( - "[dim]Skipped.[/dim] Later: [cyan]xiaohe ensure-deps[/cyan] " - "or [cyan]xiaohe init[/cyan]." + "[dim]Skipped.[/dim] Later: [cyan]myagents ensure-agent[/cyan] " + "or launch again (e.g. [cyan]myclaude[/cyan])." ) return None return _run_install(config) -def backend_available(backend: str) -> bool: - """True if the backend CLI (or its env override) resolves on PATH.""" +def resolve_backend_binary(backend: str) -> str | None: + """Resolve absolute path to a backend CLI (env → PATH → npm global).""" 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: @@ -363,6 +491,7 @@ def ensure_backend(backend: str, *, yes: bool | None = None) -> bool: explicit scripting. Backends without an installer (e.g. hermes) cannot be auto-installed. Returns True if the CLI is available afterwards. """ + ensure_npm_bin_on_path(persist=False) cfg = _BACKENDS.get(backend) if not cfg: return False @@ -379,6 +508,7 @@ def _launch( offer_install: bool = False, ) -> None: """Run backend CLI in chat_cwd, forwarding extra args. Never returns.""" + ensure_npm_bin_on_path(persist=False) config = _BACKENDS[backend] binary = _resolve_backend_binary(config) if not binary and offer_install: diff --git a/tests/test_ensure_agent_cmd.py b/tests/test_ensure_agent_cmd.py new file mode 100644 index 0000000..40aa277 --- /dev/null +++ b/tests/test_ensure_agent_cmd.py @@ -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 diff --git a/tests/test_ensure_backend.py b/tests/test_ensure_backend.py index 5d8ac43..f833872 100644 --- a/tests/test_ensure_backend.py +++ b/tests/test_ensure_backend.py @@ -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: