diff --git a/cli-hub/cli_hub/cli.py b/cli-hub/cli_hub/cli.py index 651dbbb1b7..6e000ccdb8 100644 --- a/cli-hub/cli_hub/cli.py +++ b/cli-hub/cli_hub/cli.py @@ -8,9 +8,12 @@ import click +import requests as _requests + from cli_hub import __version__ from cli_hub.registry import fetch_all_clis, get_cli, search_clis, list_categories from cli_hub.installer import install_cli, uninstall_cli, get_installed, update_cli +from cli_hub.installer import _install_strategy from cli_hub.analytics import ( detect_invocation_context, track_first_run, @@ -103,6 +106,75 @@ def uninstall(name): raise SystemExit(1) +@main.command("install-all") +@click.option("--force", is_flag=True, help="Reinstall CLIs that are already installed.") +def install_all_cmd(force): + """Install all available CLIs from the registry.""" + try: + all_clis = fetch_all_clis() + except (_requests.RequestException, ValueError) as e: + click.secho(f"Failed to fetch registry: {e}", fg="red", err=True) + raise SystemExit(1) + + if not all_clis: + click.echo("No CLIs found in the registry.") + return + + installed_map = get_installed() if not force else {} + total = len(all_clis) + succeeded = 0 + failed = 0 + skipped = 0 + skipped_names = [] + failed_names = [] + + click.echo(f"Installing {total} CLIs...\n") + + for i, cli in enumerate(all_clis, 1): + name = cli["name"] + display = cli.get("display_name", name) + + # Skip bundled CLIs — they can't be installed independently + strategy = _install_strategy(cli) + if strategy == "bundled": + skipped += 1 + skipped_names.append(name) + click.secho(f" [{i}/{total}] {display} — skipped (bundled)", fg="yellow") + continue + + # Skip already-installed CLIs unless --force + if name in installed_map: + skipped += 1 + skipped_names.append(name) + click.echo(f" [{i}/{total}] {display} — already installed") + continue + + click.echo(f" [{i}/{total}] {display} ({name})... ", nl=False) + + success, msg = install_cli(name) + + if success: + succeeded += 1 + click.secho("✓", fg="green") + track_install(name, cli.get("version", "unknown")) + else: + failed += 1 + failed_names.append(name) + click.secho(f"✗ {msg}", fg="red") + + click.echo() + parts = [f"{succeeded} succeeded"] + if skipped: + parts.append(f"{skipped} skipped") + if failed: + parts.append(f"{failed} failed") + click.echo(f" Done: {', '.join(parts)} out of {total}") + + if failed_names: + click.echo(f" Failed: {', '.join(failed_names)}") + raise SystemExit(1) + + @main.command() @click.argument("name") def update(name): diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index ce2edc365b..076150837b 100644 --- a/cli-hub/tests/test_cli_hub.py +++ b/cli-hub/tests/test_cli_hub.py @@ -1228,3 +1228,112 @@ def test_launch_unknown_cli(self, mock_get, mock_detect, mock_visit, mock_first_ result = self.runner.invoke(main, ["launch", "nonexistent"]) assert result.exit_code == 1 assert "not found" in result.output + + # ── install-all tests ── + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli.detect_invocation_context") + @patch("cli_hub.cli.track_install") + @patch("cli_hub.cli.install_cli", return_value=(True, "Installed GIMP (cli-anything-gimp)")) + @patch("cli_hub.cli._install_strategy", return_value="pip") + @patch("cli_hub.cli.get_installed", return_value={}) + @patch("cli_hub.cli.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) + def test_install_all_success(self, mock_fetch, mock_installed, mock_strategy, mock_install, mock_track, mock_detect, mock_visit, mock_first_run): + """install-all shows per-CLI status and succeeds when all pass.""" + mock_detect.return_value = self.human_detection + result = self.runner.invoke(main, ["install-all"]) + assert result.exit_code == 0 + assert "3 CLIs" in result.output + assert "3 succeeded" in result.output + assert mock_install.call_count == 3 + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli.detect_invocation_context") + @patch("cli_hub.cli.track_install") + @patch("cli_hub.cli.install_cli", side_effect=[ + (True, "Installed GIMP"), + (False, "pip install failed: timeout"), + (True, "Installed Audacity"), + ]) + @patch("cli_hub.cli._install_strategy", return_value="pip") + @patch("cli_hub.cli.get_installed", return_value={}) + @patch("cli_hub.cli.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) + def test_install_all_partial_failure(self, mock_fetch, mock_installed, mock_strategy, mock_install, mock_track, mock_detect, mock_visit, mock_first_run): + """install-all reports partial failures and exits non-zero.""" + mock_detect.return_value = self.human_detection + result = self.runner.invoke(main, ["install-all"]) + assert result.exit_code == 1 + assert "2 succeeded" in result.output + assert "1 failed" in result.output + assert "blender" in result.output + assert "Failed:" in result.output + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli.detect_invocation_context") + @patch("cli_hub.cli.fetch_all_clis", return_value=[]) + def test_install_all_empty_registry(self, mock_fetch, mock_detect, mock_visit, mock_first_run): + """install-all handles empty registry gracefully.""" + mock_detect.return_value = self.human_detection + result = self.runner.invoke(main, ["install-all"]) + assert result.exit_code == 0 + assert "No CLIs found" in result.output + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli.detect_invocation_context") + @patch("cli_hub.cli.fetch_all_clis", side_effect=requests.RequestException("network error")) + def test_install_all_fetch_failure(self, mock_fetch, mock_detect, mock_visit, mock_first_run): + """install-all exits with error when registry fetch fails.""" + mock_detect.return_value = self.human_detection + result = self.runner.invoke(main, ["install-all"]) + assert result.exit_code == 1 + assert "Failed to fetch registry" in result.output + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli.detect_invocation_context") + @patch("cli_hub.cli.install_cli", return_value=(True, "Installed GIMP")) + @patch("cli_hub.cli._install_strategy", return_value="pip") + @patch("cli_hub.cli.get_installed", return_value={"gimp": {"version": "1.0.0"}, "blender": {"version": "1.0.0"}}) + @patch("cli_hub.cli.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) + def test_install_all_skips_installed(self, mock_fetch, mock_installed, mock_strategy, mock_install, mock_detect, mock_visit, mock_first_run): + """install-all skips CLIs that are already installed.""" + mock_detect.return_value = self.human_detection + result = self.runner.invoke(main, ["install-all"]) + assert result.exit_code == 0 + assert "2 skipped" in result.output + assert "1 succeeded" in result.output + assert mock_install.call_count == 1 # only audacity + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli.detect_invocation_context") + @patch("cli_hub.cli.track_install") + @patch("cli_hub.cli.install_cli", return_value=(True, "Installed GIMP")) + @patch("cli_hub.cli._install_strategy", return_value="pip") + @patch("cli_hub.cli.get_installed", return_value={}) + @patch("cli_hub.cli.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) + def test_install_all_force_reinstalls(self, mock_fetch, mock_installed, mock_strategy, mock_install, mock_track, mock_detect, mock_visit, mock_first_run): + """install-all --force reinstalls even already-installed CLIs.""" + mock_detect.return_value = self.human_detection + result = self.runner.invoke(main, ["install-all", "--force"]) + assert result.exit_code == 0 + assert "3 succeeded" in result.output + assert mock_install.call_count == 3 + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli.detect_invocation_context") + @patch("cli_hub.cli._install_strategy", return_value="bundled") + @patch("cli_hub.cli.get_installed", return_value={}) + @patch("cli_hub.cli.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) + def test_install_all_skips_bundled(self, mock_fetch, mock_installed, mock_strategy, mock_detect, mock_visit, mock_first_run): + """install-all skips bundled CLIs (cannot be installed independently).""" + mock_detect.return_value = self.human_detection + result = self.runner.invoke(main, ["install-all"]) + assert result.exit_code == 0 + assert "3 skipped" in result.output + assert "skipped (bundled)" in result.output