From 6b71608563653d337511b9b9b06991fbb714104f Mon Sep 17 00:00:00 2001 From: Rachel Lu Date: Mon, 18 May 2026 14:17:01 +0000 Subject: [PATCH 1/4] feat(cli-hub): add install-all command with per-CLI status reporting --- cli-hub/cli_hub/cli.py | 46 ++++++++++++++++++++++++++- cli-hub/cli_hub/installer.py | 12 +++++++ cli-hub/tests/test_cli_hub.py | 60 +++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/cli-hub/cli_hub/cli.py b/cli-hub/cli_hub/cli.py index 651dbbb1b7..9817cc3742 100644 --- a/cli-hub/cli_hub/cli.py +++ b/cli-hub/cli_hub/cli.py @@ -10,7 +10,7 @@ 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_cli, uninstall_cli, get_installed, update_cli, install_all from cli_hub.analytics import ( detect_invocation_context, track_first_run, @@ -103,6 +103,50 @@ def uninstall(name): raise SystemExit(1) +@main.command("install-all") +def install_all_cmd(): + """Install all available CLIs from the registry.""" + try: + all_clis = fetch_all_clis() + except Exception 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 + + total = len(all_clis) + succeeded = 0 + failed = 0 + 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) + 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() + click.echo(f" Done: {succeeded} succeeded, {failed} failed 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/cli_hub/installer.py b/cli-hub/cli_hub/installer.py index 0a7451c489..b82408b329 100644 --- a/cli-hub/cli_hub/installer.py +++ b/cli-hub/cli_hub/installer.py @@ -368,6 +368,18 @@ def update_cli(name): return success, msg +def install_all(force_refresh=False): + """Install all CLIs from the registry. Returns list of (name, success, message) tuples.""" + from cli_hub.registry import fetch_all_clis + all_clis = fetch_all_clis(force_refresh) + results = [] + for cli in all_clis: + name = cli["name"] + success, msg = install_cli(name) + results.append((name, success, msg)) + return results + + def get_installed(): """Return dict of installed CLIs.""" return _load_installed() diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index ce2edc365b..ccee82c63c 100644 --- a/cli-hub/tests/test_cli_hub.py +++ b/cli-hub/tests/test_cli_hub.py @@ -1228,3 +1228,63 @@ 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.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) + def test_install_all_success(self, mock_fetch, 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 "0 failed" 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.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) + def test_install_all_partial_failure(self, mock_fetch, 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=Exception("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 From b1cbcca38832d4e772e9dd2cea10b4e79932c601 Mon Sep 17 00:00:00 2001 From: Rachel Lu Date: Wed, 27 May 2026 15:25:30 +0000 Subject: [PATCH 2/4] refactor(install-all): address review - skip installed/bundled, add --force Changes for cli-hub/cli_hub/cli.py --- cli-hub/cli_hub/cli.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/cli-hub/cli_hub/cli.py b/cli-hub/cli_hub/cli.py index 9817cc3742..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, install_all +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, @@ -104,11 +107,12 @@ def uninstall(name): @main.command("install-all") -def install_all_cmd(): +@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 Exception as e: + except (_requests.RequestException, ValueError) as e: click.secho(f"Failed to fetch registry: {e}", fg="red", err=True) raise SystemExit(1) @@ -116,9 +120,12 @@ def install_all_cmd(): 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") @@ -126,6 +133,22 @@ def install_all_cmd(): 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) @@ -140,7 +163,12 @@ def install_all_cmd(): click.secho(f"✗ {msg}", fg="red") click.echo() - click.echo(f" Done: {succeeded} succeeded, {failed} failed out of {total}") + 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)}") From 7a16f9dd68a801c75c457b079494aca19dccbb68 Mon Sep 17 00:00:00 2001 From: Rachel Lu Date: Wed, 27 May 2026 15:25:32 +0000 Subject: [PATCH 3/4] refactor(install-all): address review - skip installed/bundled, add --force Changes for cli-hub/cli_hub/installer.py --- cli-hub/cli_hub/installer.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/cli-hub/cli_hub/installer.py b/cli-hub/cli_hub/installer.py index b82408b329..0a7451c489 100644 --- a/cli-hub/cli_hub/installer.py +++ b/cli-hub/cli_hub/installer.py @@ -368,18 +368,6 @@ def update_cli(name): return success, msg -def install_all(force_refresh=False): - """Install all CLIs from the registry. Returns list of (name, success, message) tuples.""" - from cli_hub.registry import fetch_all_clis - all_clis = fetch_all_clis(force_refresh) - results = [] - for cli in all_clis: - name = cli["name"] - success, msg = install_cli(name) - results.append((name, success, msg)) - return results - - def get_installed(): """Return dict of installed CLIs.""" return _load_installed() From db5a8a309cfaa8d91acd1e91387b2b8620ea823c Mon Sep 17 00:00:00 2001 From: Rachel Lu Date: Wed, 27 May 2026 15:25:34 +0000 Subject: [PATCH 4/4] refactor(install-all): address review - skip installed/bundled, add --force Changes for cli-hub/tests/test_cli_hub.py --- cli-hub/tests/test_cli_hub.py | 57 ++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index ccee82c63c..076150837b 100644 --- a/cli-hub/tests/test_cli_hub.py +++ b/cli-hub/tests/test_cli_hub.py @@ -1236,15 +1236,16 @@ def test_launch_unknown_cli(self, mock_get, mock_detect, mock_visit, mock_first_ @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_install, mock_track, mock_detect, mock_visit, mock_first_run): + 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 "0 failed" in result.output assert mock_install.call_count == 3 @patch("cli_hub.cli.track_first_run") @@ -1256,8 +1257,10 @@ def test_install_all_success(self, mock_fetch, mock_install, mock_track, mock_de (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_install, mock_track, mock_detect, mock_visit, mock_first_run): + 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"]) @@ -1281,10 +1284,56 @@ def test_install_all_empty_registry(self, mock_fetch, mock_detect, mock_visit, m @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=Exception("network error")) + @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