From 56c9610f76e11b5983c8d7fbfa19770a670cd546 Mon Sep 17 00:00:00 2001 From: yuhao Date: Thu, 16 Apr 2026 06:52:28 +0000 Subject: [PATCH] update cli-hub; more public CLIs --- cli-hub/cli_hub/__init__.py | 2 +- cli-hub/cli_hub/cli.py | 26 ++++ cli-hub/cli_hub/installer.py | 68 ++++++++- cli-hub/setup.py | 2 +- cli-hub/tests/test_cli_hub.py | 271 +++++++++++++++++++++++++++++++++- public_registry.json | 52 ++++++- 6 files changed, 411 insertions(+), 10 deletions(-) diff --git a/cli-hub/cli_hub/__init__.py b/cli-hub/cli_hub/__init__.py index 7bbe2e5d6..ccb2cf165 100644 --- a/cli-hub/cli_hub/__init__.py +++ b/cli-hub/cli_hub/__init__.py @@ -1,3 +1,3 @@ """cli-hub — Download, manage, and browse CLI-Anything harnesses.""" -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/cli-hub/cli_hub/cli.py b/cli-hub/cli_hub/cli.py index 720866c0f..02684c30d 100644 --- a/cli-hub/cli_hub/cli.py +++ b/cli-hub/cli_hub/cli.py @@ -1,5 +1,8 @@ """cli-hub — CLI entry point.""" +import os +import shutil + import click from cli_hub import __version__ @@ -43,6 +46,7 @@ def install(name): click.secho(f"✓ {msg}", fg="green") if cli: click.echo(f" Run it with: {cli['entry_point']}") + click.echo(f" Or launch: cli-hub launch {cli['name']}") if cli.get("_source") == "public" and cli.get("npx_cmd"): click.echo(f" Or use npx: {cli['npx_cmd']}") else: @@ -201,5 +205,27 @@ def info(name): click.echo() +@main.command() +@click.argument("name") +@click.argument("args", nargs=-1) +def launch(name, args): + """Launch an installed CLI, passing through any extra arguments.""" + cli = get_cli(name) + if not cli: + click.secho(f"CLI '{name}' not found in registry.", fg="red", err=True) + raise SystemExit(1) + + entry = cli["entry_point"] + if not shutil.which(entry): + click.secho( + f"'{entry}' not found on PATH. Install it first: cli-hub install {name}", + fg="red", + err=True, + ) + raise SystemExit(1) + + os.execvp(entry, [entry] + list(args)) + + if __name__ == "__main__": main() diff --git a/cli-hub/cli_hub/installer.py b/cli-hub/cli_hub/installer.py index c6d68860e..0a7451c48 100644 --- a/cli-hub/cli_hub/installer.py +++ b/cli-hub/cli_hub/installer.py @@ -31,13 +31,38 @@ def _find_npm(): return shutil.which("npm") +def _find_uv(): + """Find uv executable. Returns path or None.""" + return shutil.which("uv") + + +_UV_INSTALL_HINT = ( + "uv is not installed. Install it first:\n" + " macOS / Linux: curl -LsSf https://astral.sh/uv/install.sh | sh\n" + " Windows: powershell -ExecutionPolicy ByPass -c \"irm https://astral.sh/uv/install.ps1 | iex\"\n" + " pip: pip install uv\n" + " brew: brew install uv\n" + " See also: https://docs.astral.sh/uv/getting-started/installation/" +) + + +_SHELL_METACHARACTERS = ("|", "&&", "||", ";", "$(", "`") + + def _run_command(cmd): - """Run a command string without invoking a shell.""" + """Run a command string. + + Uses shell=True when the command contains shell operators (pipes, &&, etc.) + so that script-type installs like ``curl … | bash`` work correctly. + Commands come from the trusted registry, not from user input. + """ + use_shell = any(c in cmd for c in _SHELL_METACHARACTERS) try: return subprocess.run( - shlex.split(cmd), + cmd if use_shell else shlex.split(cmd), capture_output=True, text=True, + shell=use_shell, ) except FileNotFoundError as exc: missing = exc.filename or shlex.split(cmd)[0] @@ -69,6 +94,8 @@ def _install_strategy(cli): return "pip" if cli.get("npm_package") or cli.get("package_manager") == "npm": return "npm" + if cli.get("package_manager") == "uv": + return "uv" if cli.get("package_manager") == "bundled": return "bundled" return "command" @@ -170,6 +197,42 @@ def _pip_update(cli): return False, f"Update failed:\n{result.stderr}" +# ── uv operations (public CLIs) ── + + +def _uv_install(cli): + if _find_uv() is None: + return False, _UV_INSTALL_HINT + result = _run_command(cli["install_cmd"]) + if result.returncode == 0: + return True, f"Installed {cli['display_name']} ({cli['entry_point']})" + return False, f"uv install failed:\n{result.stderr or result.stdout}" + + +def _uv_uninstall(cli): + if _find_uv() is None: + return False, _UV_INSTALL_HINT + uninstall_cmd = cli.get("uninstall_cmd") + if not uninstall_cmd: + return False, f"No uninstall command is defined for {cli['display_name']}." + result = _run_command(uninstall_cmd) + if result.returncode == 0: + return True, f"Uninstalled {cli['display_name']}" + return False, f"uv uninstall failed:\n{result.stderr or result.stdout}" + + +def _uv_update(cli): + if _find_uv() is None: + return False, _UV_INSTALL_HINT + update_cmd = cli.get("update_cmd") + if not update_cmd: + return False, f"No update command is defined for {cli['display_name']}." + result = _run_command(update_cmd) + if result.returncode == 0: + return True, f"Updated {cli['display_name']}" + return False, f"uv update failed:\n{result.stderr or result.stdout}" + + # ── npm operations (public CLIs) ── @@ -223,6 +286,7 @@ def _perform_action(cli, action): actions = { "pip": {"install": _pip_install, "uninstall": _pip_uninstall, "update": _pip_update}, "npm": {"install": _npm_install, "uninstall": _npm_uninstall, "update": _npm_update}, + "uv": {"install": _uv_install, "uninstall": _uv_uninstall, "update": _uv_update}, "command": {"install": _generic_install, "uninstall": _generic_uninstall, "update": _generic_update}, "bundled": {"install": _bundled_install, "uninstall": _bundled_uninstall, "update": _bundled_update}, } diff --git a/cli-hub/setup.py b/cli-hub/setup.py index b2810332a..9b412cc3c 100644 --- a/cli-hub/setup.py +++ b/cli-hub/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name="cli-anything-hub", - version="0.2.0", + version="0.2.1", description="Package manager for CLI-Anything — browse, install, and manage 40+ agent-native CLI interfaces for GUI applications", long_description=open("README.md").read(), long_description_content_type="text/markdown", diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index 509b8f3e0..71006c20e 100644 --- a/cli-hub/tests/test_cli_hub.py +++ b/cli-hub/tests/test_cli_hub.py @@ -12,7 +12,16 @@ import requests from cli_hub import __version__ from cli_hub.registry import fetch_registry, fetch_all_clis, get_cli, search_clis, list_categories -from cli_hub.installer import install_cli, uninstall_cli, get_installed, _load_installed, _save_installed +from cli_hub.installer import ( + install_cli, + uninstall_cli, + get_installed, + _load_installed, + _save_installed, + _run_command, + _install_strategy, + _UV_INSTALL_HINT, +) from cli_hub.analytics import _is_enabled, track_event, track_install, track_uninstall as analytics_track_uninstall, track_visit, track_first_run, _detect_is_agent from cli_hub.cli import main @@ -262,6 +271,221 @@ class TestInstaller: assert "already available" in msg +GENERATE_VEO_CLI = { + "name": "generate-veo-video", + "display_name": "Generate Veo Video", + "version": "0.2.5", + "description": "CLI for generating videos with Google Veo 3.1", + "category": "ai", + "entry_point": "generate-veo", + "_source": "public", + "package_manager": "uv", + "install_cmd": "uv tool install git+https://github.com/charles-forsyth/generate-veo-video.git", + "uninstall_cmd": "uv tool uninstall generate-veo-video", + "update_cmd": "uv tool upgrade generate-veo-video", +} + + +class TestUvStrategy: + """Tests for uv-managed public CLI installs (e.g. generate-veo-video).""" + + def test_strategy_detected_as_uv(self): + assert _install_strategy(GENERATE_VEO_CLI) == "uv" + + def test_strategy_uv_not_overridden_by_install_strategy_field(self): + """If install_strategy is explicitly set it takes priority over package_manager.""" + cli = {**GENERATE_VEO_CLI, "install_strategy": "command"} + assert _install_strategy(cli) == "command" + + @patch("cli_hub.installer.subprocess.run") + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) + @patch("cli_hub.installer._find_uv", return_value="/usr/bin/uv") + def test_install_uv_success(self, mock_find_uv, mock_get_cli, mock_run): + mock_get_cli.return_value = GENERATE_VEO_CLI + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + success, msg = install_cli("generate-veo-video") + assert success + assert "Generate Veo Video" in msg + + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer._find_uv", return_value=None) + def test_install_uv_missing_shows_hint(self, mock_find_uv, mock_get_cli): + mock_get_cli.return_value = GENERATE_VEO_CLI + success, msg = install_cli("generate-veo-video") + assert not success + assert "uv is not installed" in msg + assert "astral.sh/uv" in msg + assert "brew install uv" in msg + + @patch("cli_hub.installer.subprocess.run") + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) + @patch("cli_hub.installer._find_uv", return_value="/usr/bin/uv") + def test_uninstall_uv_success(self, mock_find_uv, mock_get_cli, mock_run): + mock_get_cli.return_value = GENERATE_VEO_CLI + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + success, msg = uninstall_cli("generate-veo-video") + assert success + assert "Generate Veo Video" in msg + + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer._find_uv", return_value=None) + def test_uninstall_uv_missing_shows_hint(self, mock_find_uv, mock_get_cli): + mock_get_cli.return_value = GENERATE_VEO_CLI + success, msg = uninstall_cli("generate-veo-video") + assert not success + assert "uv is not installed" in msg + + @patch("cli_hub.installer.subprocess.run") + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) + @patch("cli_hub.installer._find_uv", return_value="/usr/bin/uv") + def test_update_uv_success(self, mock_find_uv, mock_get_cli, mock_run): + mock_get_cli.return_value = GENERATE_VEO_CLI + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + from cli_hub.installer import update_cli + success, msg = update_cli("generate-veo-video") + assert success + assert "Generate Veo Video" in msg + + @patch("cli_hub.installer.subprocess.run") + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer._find_uv", return_value="/usr/bin/uv") + def test_install_uv_failure_propagated(self, mock_find_uv, mock_get_cli, mock_run): + mock_get_cli.return_value = GENERATE_VEO_CLI + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error: package not found") + success, msg = install_cli("generate-veo-video") + assert not success + assert "failed" in msg.lower() + + +# ─── Script / pipe-command strategy tests (jimeng / Dreamina) ───────── + +JIMENG_CLI = { + "name": "jimeng", + "display_name": "Jimeng / Dreamina CLI", + "version": "latest", + "description": "ByteDance AI image and video generation CLI", + "category": "ai", + "entry_point": "dreamina", + "_source": "public", + "install_strategy": "command", + "package_manager": "script", + "install_cmd": "curl -s https://jimeng.jianying.com/cli | bash", +} + + +class TestScriptStrategy: + """Tests for script/pipe-command installs (e.g. jimeng curl | bash).""" + + # ── _install_strategy routing ────────────────────────────────────── + + def test_strategy_detected_as_command(self): + """install_strategy field takes priority — jimeng routes to 'command'.""" + assert _install_strategy(JIMENG_CLI) == "command" + + def test_strategy_script_package_manager_without_field_falls_back_to_command(self): + """Without install_strategy field, script package_manager still routes to 'command'.""" + cli = {**JIMENG_CLI} + del cli["install_strategy"] + assert _install_strategy(cli) == "command" + + # ── _run_command shell detection ─────────────────────────────────── + + @patch("cli_hub.installer.subprocess.run") + def test_run_command_uses_shell_true_for_pipe(self, mock_run): + """Pipe character triggers shell=True so bash can interpret it.""" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_command("curl -s https://jimeng.jianying.com/cli | bash") + mock_run.assert_called_once() + _, kwargs = mock_run.call_args + assert kwargs.get("shell") is True + # cmd passed as a single string, not a list + args = mock_run.call_args[0][0] + assert isinstance(args, str) + assert "| bash" in args + + @patch("cli_hub.installer.subprocess.run") + def test_run_command_uses_shell_false_for_simple_command(self, mock_run): + """Simple commands (no shell operators) must NOT use shell=True.""" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_command("brew install --cask 1password-cli") + _, kwargs = mock_run.call_args + assert kwargs.get("shell") is False or kwargs.get("shell") is None + + @patch("cli_hub.installer.subprocess.run") + def test_run_command_uses_shell_true_for_and_operator(self, mock_run): + """&& operator also triggers shell=True.""" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + _run_command("curl -O https://example.com/install.sh && bash install.sh") + _, kwargs = mock_run.call_args + assert kwargs.get("shell") is True + + # ── Full install flow ────────────────────────────────────────────── + + @patch("cli_hub.installer.subprocess.run") + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) + def test_install_jimeng_success(self, mock_get_cli, mock_run): + """install_cli('jimeng') succeeds and invokes the pipe command via shell.""" + mock_get_cli.return_value = JIMENG_CLI + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + success, msg = install_cli("jimeng") + + assert success, f"Expected success but got: {msg}" + assert "Jimeng" in msg + + mock_run.assert_called_once() + _, kwargs = mock_run.call_args + assert kwargs.get("shell") is True + assert "| bash" in mock_run.call_args[0][0] + + @patch("cli_hub.installer.subprocess.run") + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) + def test_install_jimeng_failure_propagated(self, mock_get_cli, mock_run): + """A non-zero exit from the curl|bash script surfaces as failure.""" + mock_get_cli.return_value = JIMENG_CLI + mock_run.return_value = MagicMock( + returncode=1, stdout="", stderr="curl: (6) Could not resolve host" + ) + + success, msg = install_cli("jimeng") + + assert not success + assert "failed" in msg.lower() + + @patch("cli_hub.installer.get_cli") + def test_uninstall_jimeng_no_cmd_returns_graceful_message(self, mock_get_cli): + """Uninstalling jimeng (no uninstall_cmd defined) returns a non-crash message.""" + mock_get_cli.return_value = JIMENG_CLI # no uninstall_cmd key + + success, msg = uninstall_cli("jimeng") + + assert not success + # Should mention the CLI name or explain no command available — never crash + assert msg + + @patch("cli_hub.installer.subprocess.run") + @patch("cli_hub.installer.get_cli") + @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) + def test_install_jimeng_recorded_in_installed_json(self, mock_get_cli, mock_run): + """After a successful install, jimeng appears in installed.json.""" + installed_file = Path(tempfile.mktemp()) + mock_get_cli.return_value = JIMENG_CLI + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with patch("cli_hub.installer.INSTALLED_FILE", installed_file): + success, _ = install_cli("jimeng") + assert success + data = json.loads(installed_file.read_text()) + assert "jimeng" in data + assert data["jimeng"]["strategy"] == "command" + assert data["jimeng"]["package_manager"] == "script" + + # ─── Analytics tests ────────────────────────────────────────────────── @@ -501,3 +725,48 @@ class TestCLI: """When agent env detected, track_visit is called with is_agent=True.""" result = self.runner.invoke(main, ["--version"]) mock_visit.assert_called_once_with(is_agent=True) + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.install_cli", return_value=(True, "Installed Jimeng / Dreamina CLI (dreamina)")) + @patch("cli_hub.cli.get_cli", return_value={**SAMPLE_REGISTRY["clis"][0], "entry_point": "dreamina", "name": "jimeng", "display_name": "Jimeng / Dreamina CLI", "version": "latest", "_source": "public"}) + @patch("cli_hub.cli.track_install") + def test_install_shows_launch_hint(self, mock_track, mock_get, mock_install, mock_detect, mock_visit, mock_first_run): + """Post-install output includes both entry point and cli-hub launch hint.""" + result = self.runner.invoke(main, ["install", "jimeng"]) + assert result.exit_code == 0 + assert "dreamina" in result.output + assert "cli-hub launch jimeng" in result.output + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.shutil.which", return_value="/usr/bin/dreamina") + @patch("cli_hub.cli.os.execvp") + @patch("cli_hub.cli.get_cli", return_value=JIMENG_CLI) + def test_launch_execs_entry_point(self, mock_get, mock_execvp, mock_which, mock_detect, mock_visit, mock_first_run): + """launch execs the CLI entry point, passing through extra args.""" + result = self.runner.invoke(main, ["launch", "jimeng", "login"]) + mock_execvp.assert_called_once_with("dreamina", ["dreamina", "login"]) + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.shutil.which", return_value=None) + @patch("cli_hub.cli.get_cli", return_value=JIMENG_CLI) + def test_launch_not_on_path_shows_install_hint(self, mock_get, mock_which, mock_detect, mock_visit, mock_first_run): + """launch fails gracefully when entry point not on PATH.""" + result = self.runner.invoke(main, ["launch", "jimeng"]) + assert result.exit_code == 1 + assert "cli-hub install jimeng" in result.output + + @patch("cli_hub.cli.track_first_run") + @patch("cli_hub.cli.track_visit") + @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.get_cli", return_value=None) + def test_launch_unknown_cli(self, mock_get, mock_detect, mock_visit, mock_first_run): + """launch with an unknown CLI name exits with error.""" + result = self.runner.invoke(main, ["launch", "nonexistent"]) + assert result.exit_code == 1 + assert "not found" in result.output diff --git a/public_registry.json b/public_registry.json index ba6712d78..2bcaa8998 100644 --- a/public_registry.json +++ b/public_registry.json @@ -2,7 +2,7 @@ "meta": { "repo": "https://github.com/HKUDS/CLI-Anything", "description": "Public CLI Registry — Third-party and official CLIs managed by CLI-Hub across npm, bundled, brew, and other install methods", - "updated": "2026-04-15" + "updated": "2026-04-16" }, "clis": [ { @@ -15,10 +15,10 @@ "homepage": "https://github.com/larksuite/cli", "source_url": "https://github.com/larksuite/cli", "package_manager": "npm", - "npm_package": "@anthropic-ai/feishu-cli", - "install_cmd": "npm install -g @anthropic-ai/feishu-cli", - "npx_cmd": "npx @anthropic-ai/feishu-cli", - "entry_point": "feishu", + "npm_package": "@larksuite/cli", + "install_cmd": "npm install -g @larksuite/cli", + "npx_cmd": "npx @larksuite/cli", + "entry_point": "lark-cli", "contributors": [ { "name": "larksuite", @@ -174,6 +174,48 @@ } ] }, + { + "name": "generate-veo-video", + "display_name": "Generate Veo Video", + "version": "0.2.5", + "description": "CLI for generating videos with Google Veo 3.1 via Vertex AI/Gemini — text-to-video, image-to-video, reference images, frame morphing, and video extension", + "category": "ai", + "requires": "Python >= 3.10, uv, GOOGLE_CLOUD_PROJECT env var (GEMINI_API_KEY optional)", + "homepage": "https://github.com/charles-forsyth/generate-veo-video", + "source_url": "https://github.com/charles-forsyth/generate-veo-video", + "package_manager": "uv", + "install_cmd": "uv tool install git+https://github.com/charles-forsyth/generate-veo-video.git", + "uninstall_cmd": "uv tool uninstall generate-veo-video", + "update_cmd": "uv tool upgrade generate-veo-video", + "entry_point": "generate-veo", + "contributors": [ + { + "name": "charles-forsyth", + "url": "https://github.com/charles-forsyth" + } + ] + }, + { + "name": "jimeng", + "display_name": "Jimeng / Dreamina CLI", + "version": "latest", + "description": "Official ByteDance AI image and video generation CLI — text-to-image, text-to-video, image-to-video, digital human, and intelligent canvas; domestic brand is Jimeng (即梦), international brand is Dreamina", + "category": "ai", + "requires": "Jimeng / Dreamina account and API key", + "homepage": "https://jimeng.jianying.com/", + "docs_url": "https://bytedance.larkoffice.com/wiki/FVTwwm0bGiishxkKOoScdHR2nsg", + "source_url": null, + "package_manager": "script", + "install_strategy": "command", + "install_cmd": "curl -s https://jimeng.jianying.com/cli | bash", + "entry_point": "dreamina", + "contributors": [ + { + "name": "ByteDance", + "url": "https://www.bytedance.com" + } + ] + }, { "name": "obsidian-cli", "display_name": "Obsidian CLI",