Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions cli-hub/cli_hub/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
109 changes: 109 additions & 0 deletions cli-hub/tests/test_cli_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading