"""Tests for cli-hub — registry, installer, analytics, and CLI.""" import json import os import tempfile from pathlib import Path from unittest.mock import patch, MagicMock import pytest import click.testing 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.matrix import ( _package_available, all_recipes, capability_matches, check_provider_requirements, fetch_matrix_registry, fetch_all_matrices, get_matrix, preflight_matrix, provider_cli_name, provider_install_hint, resolve_install_scope, search_capabilities, search_matrices, unmanaged_providers, ) from cli_hub.matrix_skill import ( resolve_local_skill_path, render_matrix_skill_file, _render_capability_tooling, _render_stage_tooling, _render_discovery_section, ) from cli_hub.preview import ( inspect_bundle, inspect_session, open_in_browser, render_html, render_inspect_text, render_live_html, render_session_text, ) from cli_hub.installer import ( install_cli, install_matrix, 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, detect_invocation_context from cli_hub.cli import main # ─── Sample registry data ───────────────────────────────────────────── SAMPLE_REGISTRY = { "meta": {"repo": "https://github.com/HKUDS/CLI-Anything", "description": "test"}, "clis": [ { "name": "gimp", "display_name": "GIMP", "version": "1.0.0", "description": "Image editing via GIMP", "requires": "gimp", "homepage": "https://gimp.org", "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=gimp/agent-harness", "entry_point": "cli-anything-gimp", "skill_md": "skills/cli-anything-gimp/SKILL.md", "category": "image", "contributor": "test-user", "contributor_url": "https://github.com/test-user", }, { "name": "blender", "display_name": "Blender", "version": "1.0.0", "description": "3D modeling via Blender", "requires": "blender", "homepage": "https://blender.org", "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=blender/agent-harness", "entry_point": "cli-anything-blender", "skill_md": None, "category": "3d", "contributor": "test-user", "contributor_url": "https://github.com/test-user", }, { "name": "audacity", "display_name": "Audacity", "version": "1.0.0", "description": "Audio editing and processing via sox", "requires": "sox", "homepage": "https://audacityteam.org", "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=audacity/agent-harness", "entry_point": "cli-anything-audacity", "skill_md": None, "category": "audio", "contributor": "test-user", "contributor_url": "https://github.com/test-user", }, ], } SAMPLE_MATRIX_REGISTRY = { "meta": {"repo": "https://github.com/HKUDS/CLI-Anything", "description": "test matrices"}, "matrices": [ { "name": "video-creation", "display_name": "Video Creation & Editing", "description": "Curated video workflow matrix", "category": "video", "matrix": "cli-matrix", "matrix_id": "S1", "schema_version": "2", "skill_md": "cli-hub-matrix/video-creation/SKILL.md", "clis": ["gimp", "blender", "audacity"], "stages": [ { "name": "Thumbnail", "clis": ["gimp"], "goal": "Create a thumbnail image", "alternatives": {"python": ["Pillow"], "native": ["ImageMagick convert"]}, "skill_search_hints": ["thumbnail", "image editing"], }, {"name": "3D", "clis": ["blender"]}, { "name": "Audio", "clis": ["audacity"], "goal": "Edit and process audio", "alternatives": {"python": ["pydub"], "native": ["sox"]}, "skill_search_hints": ["audio editing"], }, ], "capabilities": [ { "id": "package.thumbnail", "intent": "Create a thumbnail image", "inputs": ["concept:text"], "outputs": ["image:path"], "skill_search_hints": ["thumbnail", "image editing"], "providers": [ { "kind": "harness-cli", "name": "cli-anything-gimp", "requires": {"binary": ["cli-anything-gimp"]}, "cost_tier": "free", "quality_tier": "high", "offline": True, }, { "kind": "python", "name": "Pillow", "requires": {"package": ["PIL"]}, "cost_tier": "free", "quality_tier": "good", "offline": True, }, ], }, { "id": "audio.capture", "intent": "Edit and process audio", "inputs": ["source:mic|file"], "outputs": ["audio_clip:path"], "skill_search_hints": ["audio editing"], "providers": [ { "kind": "harness-cli", "name": "cli-anything-audacity", "requires": {"binary": ["cli-anything-audacity"]}, "cost_tier": "free", "quality_tier": "high", "offline": True, }, { "kind": "native", "name": "sox", "requires": {"binary": ["sox"]}, "cost_tier": "free", "quality_tier": "high", "offline": True, }, ], }, ], "recipes": [ { "id": "social-short", "description": "Create a short with a thumbnail and cleaned audio.", "capabilities_used": ["package.thumbnail", "audio.capture"], } ], "known_gaps": [ { "capability": "publish.upload", "reason": "No platform upload CLI yet.", "workaround": "Ask the user to upload manually.", } ], } ], } def _make_preview_bundle(tmp_path: Path, *, with_trajectory: bool = False) -> Path: bundle_dir = tmp_path / "preview-bundle" artifacts_dir = bundle_dir / "artifacts" artifacts_dir.mkdir(parents=True) (artifacts_dir / "hero.png").write_bytes(b"\x89PNG\r\n\x1a\npreview") (artifacts_dir / "preview.mp4").write_bytes(b"\x00\x00\x00\x18ftypmp42") summary = { "headline": "Quick preview rendered", "facts": { "duration_s": 6.0, "resolution": "640x360", }, "warnings": [], } manifest = { "protocol_version": "preview-bundle/v1", "bundle_id": "20260419T104530Z_deadbeef_quick", "bundle_kind": "capture", "software": "shotcut", "recipe": "quick", "status": "ok", "created_at": "2026-04-19T10:45:30Z", "generator": {"entry_point": "cli-anything-shotcut", "command": "cli-anything-shotcut preview capture --recipe quick"}, "source": {"project_path": "/tmp/demo.mlt", "project_fingerprint": "sha256:test"}, "summary_path": "summary.json", "artifacts": [ { "artifact_id": "hero", "role": "hero", "kind": "image", "label": "Midpoint frame", "media_type": "image/png", "path": "artifacts/hero.png", "width": 960, "height": 540, "bytes": (artifacts_dir / "hero.png").stat().st_size, }, { "artifact_id": "clip", "role": "preview-clip", "kind": "clip", "label": "Preview clip", "media_type": "video/mp4", "path": "artifacts/preview.mp4", "width": 640, "height": 360, "duration_s": 6.0, "bytes": (artifacts_dir / "preview.mp4").stat().st_size, }, ], } if with_trajectory: trajectory = { "protocol_version": "preview-trajectory/v1", "step_count": 1, "current_step_id": "step-001", "steps": [ { "step_id": "step-001", "step_index": 1, "bundle_id": "20260419T104530Z_deadbeef_quick", "bundle_dir": str(bundle_dir), "manifest_path": str(bundle_dir / "manifest.json"), "summary_path": str(bundle_dir / "summary.json"), "created_at": "2026-04-19T10:45:30Z", "status": "ok", "cached": False, "publish_reason": "capture", "command": "cli-anything-shotcut preview capture --recipe quick", } ], } (tmp_path / "trajectory.json").write_text(json.dumps(trajectory, indent=2)) manifest["context"] = {"trajectory_path": "../trajectory.json"} (bundle_dir / "summary.json").write_text(json.dumps(summary, indent=2)) (bundle_dir / "manifest.json").write_text(json.dumps(manifest, indent=2)) return bundle_dir def _make_preview_session(tmp_path: Path, *, with_trajectory: bool = False) -> Path: bundle_dir = _make_preview_bundle(tmp_path) session_dir = tmp_path / "live-session" session_dir.mkdir() (session_dir / "current").symlink_to(bundle_dir, target_is_directory=True) session = { "protocol_version": "preview-live/v1", "software": "shotcut", "recipe": "quick", "status": "active", "session_name": "demo-live", "project_path": "/tmp/demo.mlt", "project_name": "demo.mlt", "updated_at": "2026-04-20T09:00:00Z", "current_link": "current", "current_bundle_id": "20260419T104530Z_deadbeef_quick", "watch_command": "cli-hub previews watch /tmp/live-session --open", "publish_command": "cli-anything-shotcut preview live push --recipe quick", "inspect_command": "cli-hub previews inspect /tmp/live-session", "history": [ { "bundle_id": "20260419T104530Z_deadbeef_quick", "bundle_dir": str(bundle_dir), "created_at": "2026-04-19T10:45:30Z", "status": "ok", } ], } if with_trajectory: trajectory = { "protocol_version": "preview-trajectory/v1", "step_count": 2, "current_step_id": "step-002", "steps": [ { "step_id": "step-001", "step_index": 0, "bundle_id": "20260419T104530Z_deadbeef_quick", "bundle_dir": str(bundle_dir), "manifest_path": str(bundle_dir / "manifest.json"), "summary_path": str(bundle_dir / "summary.json"), "created_at": "2026-04-19T10:45:30Z", "status": "ok", "cached": False, "publish_reason": "live-start", "command": "cli-anything-shotcut preview live start --recipe quick", "command_started_at": "2026-04-19T10:45:28Z", "command_finished_at": "2026-04-19T10:45:30Z", "source_fingerprint": "sha256:test-a", }, { "step_id": "step-002", "step_index": 1, "bundle_id": "20260419T104530Z_deadbeef_quick", "bundle_dir": str(bundle_dir), "manifest_path": str(bundle_dir / "manifest.json"), "summary_path": str(bundle_dir / "summary.json"), "created_at": "2026-04-19T10:47:10Z", "status": "ok", "cached": True, "publish_reason": "manual-push", "command": "cli-anything-shotcut preview live push --recipe quick", "command_started_at": "2026-04-19T10:47:07Z", "command_finished_at": "2026-04-19T10:47:10Z", "source_fingerprint": "sha256:test-b", }, ], } (session_dir / "trajectory.json").write_text(json.dumps(trajectory, indent=2)) session.update( { "trajectory_path": "trajectory.json", "trajectory_protocol_version": "preview-trajectory/v1", "trajectory_step_count": 2, "current_step_id": "step-002", "latest_command": "cli-anything-shotcut preview live push --recipe quick", "latest_publish_reason": "manual-push", } ) (session_dir / "session.json").write_text(json.dumps(session, indent=2)) return session_dir # ─── Registry tests ─────────────────────────────────────────────────── class TestRegistry: """Tests for registry.py — fetch, cache, search, and lookup.""" @patch("cli_hub.registry.requests.get") @patch("cli_hub.registry.CACHE_FILE", Path(tempfile.mktemp())) def test_fetch_registry_from_remote(self, mock_get): mock_resp = MagicMock() mock_resp.json.return_value = SAMPLE_REGISTRY mock_resp.raise_for_status = MagicMock() mock_get.return_value = mock_resp result = fetch_registry(force_refresh=True) assert result["clis"][0]["name"] == "gimp" mock_get.assert_called_once() @patch("cli_hub.registry.requests.get", side_effect=requests.ConnectionError("network down")) def test_fetch_registry_uses_cache_on_refresh_failure(self, mock_get, tmp_path): cache_file = tmp_path / "registry_cache.json" cache_payload = {"_cached_at": 0, "data": SAMPLE_REGISTRY} cache_file.write_text(json.dumps(cache_payload, indent=2)) with patch("cli_hub.registry.CACHE_FILE", cache_file): result = fetch_registry(force_refresh=True) assert result["clis"][0]["name"] == "gimp" mock_get.assert_called_once() @patch("cli_hub.registry.fetch_public_registry", return_value=None) @patch("cli_hub.registry.fetch_registry") def test_fetch_all_clis_does_not_mutate_registry_entries(self, mock_fetch_registry, mock_fetch_public): registry = { "meta": SAMPLE_REGISTRY["meta"], "clis": [dict(SAMPLE_REGISTRY["clis"][0])], } mock_fetch_registry.return_value = registry result = fetch_all_clis() assert result[0]["_source"] == "harness" assert "_source" not in registry["clis"][0] @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_get_cli_found(self, mock_fetch): cli = get_cli("gimp") assert cli is not None assert cli["display_name"] == "GIMP" @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_get_cli_case_insensitive(self, mock_fetch): cli = get_cli("GIMP") assert cli is not None assert cli["name"] == "gimp" @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_get_cli_not_found(self, mock_fetch): cli = get_cli("nonexistent") assert cli is None @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_search_by_name(self, mock_fetch): results = search_clis("gimp") assert len(results) == 1 assert results[0]["name"] == "gimp" @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_search_by_category(self, mock_fetch): results = search_clis("3d") assert len(results) == 1 assert results[0]["name"] == "blender" @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_search_by_description(self, mock_fetch): results = search_clis("audio") assert len(results) == 1 assert results[0]["name"] == "audacity" @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_search_no_results(self, mock_fetch): results = search_clis("nonexistent_xyz") assert len(results) == 0 @patch("cli_hub.registry.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) def test_list_categories(self, mock_fetch): cats = list_categories() assert cats == ["3d", "audio", "image"] class TestMatrixRegistry: """Tests for matrix.py — fetch, cache, search, and lookup.""" @patch("cli_hub.matrix.requests.get") @patch("cli_hub.matrix.MATRIX_CACHE_FILE", Path(tempfile.mktemp())) def test_fetch_matrix_registry_from_remote(self, mock_get): mock_resp = MagicMock() mock_resp.json.return_value = SAMPLE_MATRIX_REGISTRY mock_resp.raise_for_status = MagicMock() mock_get.return_value = mock_resp result = fetch_matrix_registry(force_refresh=True) assert result["matrices"][0]["name"] == "video-creation" mock_get.assert_called_once() @patch("cli_hub.matrix._load_local_registry", return_value=SAMPLE_MATRIX_REGISTRY) @patch("cli_hub.matrix.requests.get", side_effect=requests.HTTPError("not found")) def test_fetch_matrix_registry_falls_back_to_local_checkout(self, mock_get, mock_local, tmp_path): with patch("cli_hub.matrix.MATRIX_CACHE_FILE", tmp_path / "matrix_cache.json"): result = fetch_matrix_registry(force_refresh=True) assert result["matrices"][0]["name"] == "video-creation" mock_local.assert_called_once() @patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_get_matrix_found(self, mock_fetch): matrix_item = get_matrix("video-creation") assert matrix_item is not None assert matrix_item["display_name"] == "Video Creation & Editing" @patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_search_matrices_matches_description(self, mock_fetch): results = search_matrices("video") assert len(results) == 1 assert results[0]["name"] == "video-creation" @patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_search_matrices_matches_capability_provider(self, mock_fetch): results = search_matrices("Pillow") assert len(results) == 1 assert results[0]["name"] == "video-creation" @patch("cli_hub.matrix.importlib.util.find_spec") @patch("cli_hub.matrix.shutil.which") def test_check_provider_requirements(self, mock_which, mock_find_spec): provider = { "name": "Pillow", "kind": "python", "requires": {"package": ["PIL"], "binary": ["ffmpeg"], "env": ["MISSING_KEY"]}, "cost_tier": "free", "quality_tier": "good", "offline": True, } mock_which.return_value = "/usr/bin/ffmpeg" mock_find_spec.return_value = MagicMock() result = check_provider_requirements(provider) assert result["available"] is False assert result["present"]["binary"] == ["ffmpeg"] assert result["present"]["package"] == ["PIL"] assert result["missing"]["env"] == ["MISSING_KEY"] @patch("cli_hub.matrix.importlib.util.find_spec") @patch("cli_hub.matrix.shutil.which") def test_check_provider_requirements_marks_agent_skill_installable(self, mock_which, mock_find_spec): provider = { "name": "video-scriptwriting skill", "kind": "agent-skill", "requires": {"binary": ["some-skill-cli"]}, "cost_tier": "free", "quality_tier": "sota", "offline": True, } result = check_provider_requirements(provider) assert result["available"] is False assert result["agent_installable"] is True assert result["status"] == "agent-installable" assert result["missing"] == {"env": [], "binary": [], "package": []} mock_which.assert_not_called() mock_find_spec.assert_not_called() @patch("cli_hub.matrix.importlib.util.find_spec") @patch("cli_hub.matrix.shutil.which") def test_preflight_matrix_reports_provider_availability_without_recommendation(self, mock_which, mock_find_spec): mock_which.side_effect = lambda binary: "/usr/bin/sox" if binary == "sox" else None mock_find_spec.side_effect = lambda package: MagicMock() if package == "PIL" else None payload = preflight_matrix(SAMPLE_MATRIX_REGISTRY["matrices"][0], capability_id="package.thumbnail") assert payload["summary"]["capabilities"] == 1 assert payload["summary"]["available_providers"] == 1 assert "recommended" not in payload["capabilities"][0] assert payload["capabilities"][0]["providers"][0]["name"] == "cli-anything-gimp" assert payload["capabilities"][0]["providers"][0]["available"] is False assert payload["capabilities"][0]["providers"][1]["name"] == "Pillow" assert payload["capabilities"][0]["providers"][1]["available"] is True @patch("cli_hub.matrix.shutil.which", return_value=None) def test_preflight_matrix_keeps_agent_skills_out_of_available_counts(self, mock_which): matrix_item = { "name": "video-creation", "display_name": "Video Creation & Editing", "schema_version": "2", "capabilities": [ { "id": "script.storyboard", "intent": "Plan a video", "providers": [ { "kind": "agent-skill", "name": "video-scriptwriting skill", "requires": {}, "cost_tier": "free", "quality_tier": "sota", "offline": True, }, { "kind": "native", "name": "planner-cli", "requires": {"binary": ["planner-cli"]}, "cost_tier": "free", "quality_tier": "good", "offline": True, }, ], } ], } payload = preflight_matrix(matrix_item, capability_id="script.storyboard") assert payload["summary"]["available_providers"] == 0 assert payload["summary"]["agent_installable_providers"] == 1 assert payload["summary"]["with_agent_installable_provider"] == 1 assert payload["capabilities"][0]["available_count"] == 0 assert payload["capabilities"][0]["agent_installable_count"] == 1 assert "recommended" not in payload["capabilities"][0] assert payload["capabilities"][0]["providers"][0]["status"] == "agent-installable" class TestPackageAvailable: """Tests for _package_available() — import name, dist name, and error handling.""" def test_stdlib_import_name_detected(self): assert _package_available("json") is True @patch("cli_hub.matrix.importlib.util.find_spec", return_value=None) @patch("cli_hub.matrix.importlib.metadata.version", return_value="7.2.3") def test_dist_name_with_dash_detected_via_metadata(self, mock_version, mock_find_spec): assert _package_available("edge-tts") is True mock_version.assert_called_with("edge-tts") @patch("cli_hub.matrix.importlib.util.find_spec") @patch("cli_hub.matrix.importlib.metadata.version", side_effect=Exception("not found")) def test_dash_normalized_to_underscore_detected_via_find_spec(self, mock_version, mock_find_spec): mock_find_spec.side_effect = lambda n: MagicMock() if n == "edge_tts" else None assert _package_available("edge-tts") is True @patch("cli_hub.matrix.importlib.util.find_spec", return_value=None) @patch("cli_hub.matrix.importlib.metadata.version", side_effect=Exception("not found")) def test_uninstalled_garbage_name_returns_false(self, mock_version, mock_find_spec): assert _package_available("xyzzy-totally-fake-pkg-99999") is False @patch("cli_hub.matrix.importlib.util.find_spec", side_effect=RuntimeError("boom")) @patch("cli_hub.matrix.importlib.metadata.version", side_effect=RuntimeError("boom")) def test_exceptions_are_swallowed_returns_false(self, mock_version, mock_find_spec): assert _package_available("some-pkg") is False @patch("cli_hub.matrix.importlib.util.find_spec", return_value=MagicMock()) def test_plain_import_name_detected_via_find_spec(self, mock_find_spec): assert _package_available("PIL") is True mock_find_spec.assert_called_with("PIL") class TestMatrixSkill: """Tests for matrix_skill.py — local skill resolution and rendering.""" @patch("cli_hub.matrix_skill.metadata.distribution") def test_resolve_local_skill_path_from_distribution(self, mock_distribution, tmp_path): class FakeDist: files = [Path("cli_anything/audacity/skills/SKILL.md")] def locate_file(self, file): return tmp_path / file mock_distribution.return_value = FakeDist() cli = {"name": "audacity", "_source": "harness"} resolved = resolve_local_skill_path(cli) assert resolved == str((tmp_path / "cli_anything/audacity/skills/SKILL.md").resolve()) @patch("cli_hub.matrix_skill.MATRIX_SKILL_DIR", Path(tempfile.mkdtemp())) @patch("cli_hub.matrix_skill.resolve_local_skill_path") @patch("cli_hub.matrix_skill.get_cli") def test_render_matrix_skill_file_injects_paths(self, mock_get_cli, mock_resolve): mock_get_cli.side_effect = lambda name: next((c for c in SAMPLE_REGISTRY["clis"] if c["name"] == name), None) mock_resolve.side_effect = lambda cli: f"/tmp/{cli['name']}/skills/SKILL.md" if cli["name"] != "blender" else None rendered = render_matrix_skill_file(SAMPLE_MATRIX_REGISTRY["matrices"][0], installed={"gimp": {}, "audacity": {}}) content = Path(rendered).read_text() assert "## Installed CLI Skills" in content assert "/tmp/gimp/skills/SKILL.md" in content assert "skills/cli-anything-gimp/SKILL.md" in content assert "## Capability Provider Overview" in content assert "not installed" in content def test_render_capability_tooling_includes_providers_and_recipes(self): matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0] result = _render_capability_tooling(matrix_item, installed={"gimp": {}}) assert "## Capability Provider Overview" in result assert "`package.thumbnail`" in result assert "`cli-anything-gimp`" in result assert "binary: cli-anything-gimp" in result assert "## Recipes" in result assert "`social-short`" in result assert "## Known Gaps" in result class TestMultiApproachRendering: """Tests for multi-approach stage rendering in matrix_skill.py.""" def test_render_stage_tooling_includes_goals(self): matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0] result = _render_stage_tooling(matrix_item, installed={"gimp": {}}) assert "## Stage Tooling Overview" in result assert "Create a thumbnail image" in result assert "Edit and process audio" in result def test_render_stage_tooling_includes_alternatives(self): matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0] result = _render_stage_tooling(matrix_item, installed={}) assert "Pillow" in result assert "pydub" in result assert "sox" in result assert "ImageMagick convert" in result def test_render_stage_tooling_shows_install_status(self): matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0] result = _render_stage_tooling(matrix_item, installed={"gimp": {}}) assert "`gimp` (installed)" in result assert "`audacity` (not installed)" in result def test_render_stage_tooling_omits_skill_search_hints(self): matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0] result = _render_stage_tooling(matrix_item, installed={}) assert "npx skills search" not in result assert "Search for skills" not in result def test_render_stage_tooling_backward_compat_no_goal(self): """Stages without 'goal' field are skipped gracefully.""" matrix_no_goals = { "name": "test", "stages": [ {"name": "Stage1", "clis": ["foo"]}, ], } result = _render_stage_tooling(matrix_no_goals, installed={}) assert result == "" def test_render_discovery_section(self): matrix_item = SAMPLE_MATRIX_REGISTRY["matrices"][0] result = _render_discovery_section(matrix_item) assert result == "" def test_render_discovery_section_uses_capability_hints(self): matrix_item = { "name": "test", "capabilities": [ {"id": "publish.upload", "skill_search_hints": ["youtube upload"]}, ], } result = _render_discovery_section(matrix_item) assert result == "" def test_render_discovery_section_empty_when_no_hints(self): matrix_no_hints = { "name": "test", "stages": [{"name": "S1", "clis": ["foo"]}], } result = _render_discovery_section(matrix_no_hints) assert result == "" @patch("cli_hub.matrix_skill.MATRIX_SKILL_DIR", Path(tempfile.mkdtemp())) @patch("cli_hub.matrix_skill.resolve_local_skill_path") @patch("cli_hub.matrix_skill.get_cli") def test_render_matrix_skill_file_includes_stage_tooling(self, mock_get_cli, mock_resolve): mock_get_cli.side_effect = lambda name: next((c for c in SAMPLE_REGISTRY["clis"] if c["name"] == name), None) mock_resolve.return_value = None rendered = render_matrix_skill_file(SAMPLE_MATRIX_REGISTRY["matrices"][0], installed={"gimp": {}}) content = Path(rendered).read_text() assert "## Stage Tooling Overview" in content assert "## Skill Discovery Commands" not in content assert "npx skills search" not in content assert "Create a thumbnail image" in content class TestPreviewBundle: """Tests for preview bundle inspection and HTML rendering.""" def test_inspect_bundle(self, tmp_path): bundle_dir = _make_preview_bundle(tmp_path) payload = inspect_bundle(str(bundle_dir)) assert payload["artifact_count"] == 2 assert payload["manifest"]["software"] == "shotcut" assert payload["summary"]["headline"] == "Quick preview rendered" def test_inspect_bundle_loads_trajectory_from_context_path(self, tmp_path): bundle_dir = _make_preview_bundle(tmp_path, with_trajectory=True) payload = inspect_bundle(str(bundle_dir)) assert payload["trajectory"]["protocol"] == "preview-trajectory/v1" assert payload["trajectory"]["step_count"] == 1 assert payload["trajectory"]["recent_publish_reason"] == "capture" def test_render_inspect_text(self, tmp_path): bundle_dir = _make_preview_bundle(tmp_path) text = render_inspect_text(str(bundle_dir)) assert "Bundle:" in text assert "Artifacts" in text assert "Midpoint frame" in text def test_render_html(self, tmp_path): bundle_dir = _make_preview_bundle(tmp_path) output_path = tmp_path / "preview.html" rendered = render_html(str(bundle_dir), str(output_path)) assert rendered == str(output_path.resolve()) content = output_path.read_text() assert "CLI-Anything Preview Bundle" in content assert "Quick preview rendered" in content assert "artifacts/hero.png" in content assert "artifacts/preview.mp4" in content def test_previews_inspect_cli_command(self, tmp_path): bundle_dir = _make_preview_bundle(tmp_path) runner = click.testing.CliRunner() result = runner.invoke(main, ["previews", "inspect", str(bundle_dir)]) assert result.exit_code == 0 assert "Quick preview rendered" in result.output def test_previews_html_cli_command(self, tmp_path): bundle_dir = _make_preview_bundle(tmp_path) output_path = tmp_path / "bundle-preview.html" runner = click.testing.CliRunner() result = runner.invoke(main, ["previews", "html", str(bundle_dir), "-o", str(output_path)]) assert result.exit_code == 0 assert str(output_path) in result.output assert output_path.is_file() def test_inspect_session(self, tmp_path): session_dir = _make_preview_session(tmp_path) payload = inspect_session(str(session_dir)) assert payload["session"]["software"] == "shotcut" assert payload["current_bundle"]["manifest"]["bundle_id"] == "20260419T104530Z_deadbeef_quick" def test_inspect_session_loads_trajectory(self, tmp_path): session_dir = _make_preview_session(tmp_path, with_trajectory=True) payload = inspect_session(str(session_dir)) assert payload["trajectory"]["protocol"] == "preview-trajectory/v1" assert payload["trajectory"]["step_count"] == 2 assert payload["trajectory"]["current_step_id"] == "step-002" assert payload["trajectory"]["recent_publish_reason"] == "manual-push" def test_render_session_text(self, tmp_path): session_dir = _make_preview_session(tmp_path) text = render_session_text(str(session_dir)) assert "Live Session:" in text assert "Watch:" in text assert "History" in text def test_render_session_text_with_trajectory(self, tmp_path): session_dir = _make_preview_session(tmp_path, with_trajectory=True) text = render_session_text(str(session_dir)) assert "Trajectory" in text assert "Current step: step-002" in text assert "Recent publish: manual-push" in text assert "cli-anything-shotcut preview live push --recipe quick" in text def test_render_live_html(self, tmp_path): session_dir = _make_preview_session(tmp_path) output_path = tmp_path / "live.html" rendered = render_live_html(str(session_dir), str(output_path), poll_ms=800) assert rendered == str(output_path.resolve()) content = output_path.read_text() assert "CLI-Anything Live Preview Session" in content assert 'const CURRENT_LINK = "current";' in content assert "manifest = await fetchJson(`${CURRENT_LINK}/manifest.json`);" in content assert "const POLL_MS = 800;" in content def test_render_live_html_with_trajectory(self, tmp_path): session_dir = _make_preview_session(tmp_path, with_trajectory=True) output_path = tmp_path / "live-trajectory.html" render_live_html(str(session_dir), str(output_path), poll_ms=600) content = output_path.read_text() assert 'const TRAJECTORY_CANDIDATES = ["trajectory.json", "timeline.json"];' in content assert "function normalizeTrajectory(session, payload)" in content assert "Trajectory Timeline" in content assert "trajectory_step_count" in content assert "latest_publish_reason" in content @patch("cli_hub.preview.subprocess.Popen") @patch("cli_hub.preview.shutil.which") def test_open_in_browser_prefers_app_mode(self, mock_which, mock_popen): mock_which.side_effect = lambda binary: f"/usr/bin/{binary}" if binary == "chromium" else None mock_popen.return_value = MagicMock(pid=4321) result = open_in_browser("http://127.0.0.1:9933/live.html") assert result["launched"] is True assert result["browser"] == "chromium" assert "--app=http://127.0.0.1:9933/live.html" in result["command"] def test_previews_inspect_cli_handles_session(self, tmp_path): session_dir = _make_preview_session(tmp_path) runner = click.testing.CliRunner() result = runner.invoke(main, ["previews", "inspect", str(session_dir)]) assert result.exit_code == 0 assert "Live Session:" in result.output def test_previews_html_cli_renders_session(self, tmp_path): session_dir = _make_preview_session(tmp_path) output_path = tmp_path / "session-live.html" runner = click.testing.CliRunner() result = runner.invoke(main, ["previews", "html", str(session_dir), "-o", str(output_path), "--poll-ms", "700"]) assert result.exit_code == 0 assert output_path.is_file() assert "const POLL_MS = 700;" in output_path.read_text() def test_previews_help_and_cli(self, tmp_path): session_dir = _make_preview_session(tmp_path, with_trajectory=True) runner = click.testing.CliRunner() help_result = runner.invoke(main, ["--help"]) assert help_result.exit_code == 0 assert "previews" in help_result.output assert "\n review" not in help_result.output assert "\n open-preview" not in help_result.output inspect_result = runner.invoke(main, ["previews", "inspect", str(session_dir)]) assert inspect_result.exit_code == 0 assert "Trajectory" in inspect_result.output assert "Current step: step-002" in inspect_result.output # ─── Installer tests ────────────────────────────────────────────────── class TestInstaller: """Tests for installer.py — install, uninstall, tracking.""" def test_load_installed_empty(self, tmp_path): with patch("cli_hub.installer.INSTALLED_FILE", tmp_path / "installed.json"): assert _load_installed() == {} def test_save_and_load_installed(self, tmp_path): installed_file = tmp_path / "installed.json" with patch("cli_hub.installer.INSTALLED_FILE", installed_file): _save_installed({"gimp": {"version": "1.0.0"}}) data = _load_installed() assert data["gimp"]["version"] == "1.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_success(self, mock_get_cli, mock_run): mock_get_cli.return_value = SAMPLE_REGISTRY["clis"][0] mock_run.return_value = MagicMock(returncode=0) success, msg = install_cli("gimp") assert success assert "GIMP" in msg @patch("cli_hub.installer.get_cli") def test_install_not_found(self, mock_get_cli): mock_get_cli.return_value = None success, msg = install_cli("nonexistent") assert not success assert "not found" in 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_pip_failure(self, mock_get_cli, mock_run): mock_get_cli.return_value = SAMPLE_REGISTRY["clis"][0] mock_run.return_value = MagicMock(returncode=1, stderr="some error") success, msg = install_cli("gimp") assert not success assert "failed" in msg @patch("cli_hub.installer.subprocess.run") @patch("cli_hub.installer.get_cli") @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) def test_uninstall_success(self, mock_get_cli, mock_run): mock_get_cli.return_value = SAMPLE_REGISTRY["clis"][0] mock_run.return_value = MagicMock(returncode=0) success, msg = uninstall_cli("gimp") assert success assert "GIMP" in 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_command_strategy_success(self, mock_get_cli, mock_run): mock_get_cli.return_value = { "name": "onepassword-cli", "display_name": "1Password CLI", "version": "latest", "description": "Secrets automation", "entry_point": "op", "_source": "public", "install_strategy": "command", "package_manager": "brew", "install_cmd": "brew install --cask 1password-cli", } mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="") success, msg = install_cli("onepassword-cli") assert success assert "1Password CLI" in msg @patch("cli_hub.installer.subprocess.run", side_effect=FileNotFoundError(2, "No such file or directory", "brew")) @patch("cli_hub.installer.get_cli") @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) def test_install_command_strategy_missing_executable(self, mock_get_cli, mock_run): mock_get_cli.return_value = { "name": "onepassword-cli", "display_name": "1Password CLI", "version": "latest", "description": "Secrets automation", "entry_point": "op", "_source": "public", "install_strategy": "command", "package_manager": "brew", "install_cmd": "brew install --cask 1password-cli", } success, msg = install_cli("onepassword-cli") assert not success assert "Command not found: brew" in msg @patch("cli_hub.installer.shutil.which", return_value="/usr/local/bin/obsidian") @patch("cli_hub.installer.get_cli") @patch("cli_hub.installer.INSTALLED_FILE", Path(tempfile.mktemp())) def test_install_bundled_strategy_success_when_detected(self, mock_get_cli, mock_which): mock_get_cli.return_value = { "name": "obsidian-cli", "display_name": "Obsidian CLI", "version": "bundled", "description": "Bundled inside Obsidian", "entry_point": "obsidian", "_source": "public", "install_strategy": "bundled", "package_manager": "bundled", } success, msg = install_cli("obsidian-cli") assert success 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 ────────────────────────────────────────────────── class TestAnalytics: """Tests for analytics.py — opt-out, event firing, event names.""" def test_analytics_enabled_by_default(self): with patch.dict(os.environ, {}, clear=True): assert _is_enabled() def test_analytics_disabled_by_env(self): with patch.dict(os.environ, {"CLI_HUB_NO_ANALYTICS": "1"}): assert not _is_enabled() def test_analytics_disabled_by_true(self): with patch.dict(os.environ, {"CLI_HUB_NO_ANALYTICS": "true"}): assert not _is_enabled() @patch("cli_hub.analytics._send_event") def test_track_event_sends_request(self, mock_send): with patch.dict(os.environ, {}, clear=True): track_event("test-event", data={"key": "value"}) import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["event"] == "test-event" assert payload["properties"]["hostname"] == "clianything.cc" assert payload["properties"]["source"] == "cli" @patch("cli_hub.analytics._send_event") def test_track_event_noop_when_disabled(self, mock_send): with patch.dict(os.environ, {"CLI_HUB_NO_ANALYTICS": "1"}): track_event("test-event") import time time.sleep(0.2) mock_send.assert_not_called() @patch("cli_hub.analytics._send_event") def test_track_event_supports_umami_provider(self, mock_send): with patch.dict(os.environ, {"CLI_HUB_ANALYTICS_PROVIDER": "umami"}, clear=False): track_event("test-event") import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["payload"]["name"] == "test-event" assert payload["payload"]["hostname"] == "clianything.cc" @patch("cli_hub.analytics._send_event") def test_track_install_event_name_is_flat(self, mock_send): """cli-install event name is static; CLI name lives in properties.cli.""" with patch.dict(os.environ, {}, clear=True): track_install("gimp", "1.0.0") import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["event"] == "cli-install" assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/install/gimp" assert payload["properties"]["cli"] == "gimp" assert payload["properties"]["version"] == "1.0.0" assert "platform" in payload["properties"] @patch("cli_hub.analytics._send_event") def test_track_uninstall_event_name_is_flat(self, mock_send): """cli-uninstall event name is static; CLI name lives in properties.cli.""" with patch.dict(os.environ, {}, clear=True): analytics_track_uninstall("blender") import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["event"] == "cli-uninstall" assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/uninstall/blender" assert payload["properties"]["cli"] == "blender" assert "platform" in payload["properties"] @patch("cli_hub.analytics._send_event") def test_track_launch_fires(self, mock_send): """cli-launch event fires with the CLI name in properties.""" from cli_hub.analytics import track_launch with patch.dict(os.environ, {}, clear=True): track_launch("gimp") import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["event"] == "cli-launch" assert payload["properties"]["cli"] == "gimp" assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/launch/gimp" @patch("cli_hub.analytics._send_event") def test_track_visit_human(self, mock_send): """cli-hub call event sent when not detected as agent.""" with patch.dict(os.environ, {}, clear=True): track_visit(is_agent=False) import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["event"] == "cli-hub call" assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/call" assert payload["properties"]["command"] == "root" assert payload["properties"]["is_agent"] is False assert payload["properties"]["traffic_type"] == "human" @patch("cli_hub.analytics._send_event") def test_track_visit_agent(self, mock_send): """cli-hub call event captures the agent flag.""" with patch.dict(os.environ, {}, clear=True): track_visit(is_agent=True, command="--version") import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["event"] == "cli-hub call" assert payload["properties"]["command"] == "--version" assert payload["properties"]["is_agent"] is True assert payload["properties"]["traffic_type"] == "agent" def test_detect_agent_claude_code(self): with patch.dict(os.environ, {"CLAUDE_CODE": "1"}): assert _detect_is_agent() is True def test_detect_agent_codex(self): with patch.dict(os.environ, {"CODEX": "1"}): assert _detect_is_agent() is True @patch("cli_hub.analytics._parent_process_commands", return_value=["/usr/local/bin/codex --run"]) def test_detect_agent_from_parent_process(self, mock_cmds): with patch.dict(os.environ, {}, clear=True): context = detect_invocation_context() assert context["is_agent"] is True assert context["reason"] == "codex-process" assert "codex-process" in context["signals"] @pytest.mark.parametrize( ("command", "expected_reason"), [ ("/usr/local/bin/gemini --prompt fix tests", "gemini-process"), ("/usr/local/bin/copilot agent", "copilot-process"), ("/usr/local/bin/auggie --print review", "auggie-process"), ("/opt/augment/bin/augment", "augment-process"), ("/usr/local/bin/ampcode fix build", "amp-process"), ("/usr/local/bin/opencode agent create", "opencode-process"), ("/usr/local/bin/kilo auth", "kilo-process"), ("/usr/local/bin/qodo chat", "qodo-process"), ("/usr/local/bin/kiro /agent create", "kiro-process"), ], ) @patch("cli_hub.analytics._parent_process_commands") def test_detect_agent_from_expanded_parent_process_names(self, mock_cmds, command, expected_reason): mock_cmds.return_value = [command] with patch.dict(os.environ, {}, clear=True): context = detect_invocation_context() assert context["is_agent"] is True assert context["reason"] == expected_reason assert expected_reason in context["signals"] @patch("cli_hub.analytics._parent_process_commands", return_value=[]) def test_detect_not_agent_clean_env(self, mock_cmds): """Clean env with a tty should not detect as agent.""" with patch.dict(os.environ, {}, clear=True): with patch("sys.stdin") as mock_stdin: mock_stdin.isatty.return_value = True assert _detect_is_agent() is False @patch("cli_hub.analytics._parent_process_commands", return_value=[]) def test_detect_non_tty_is_agent(self, mock_cmds): with patch.dict(os.environ, {}, clear=True): with patch("sys.stdin") as mock_stdin: mock_stdin.isatty.return_value = False context = detect_invocation_context() assert context["is_agent"] is True assert context["traffic_type"] == "agent" assert context["category"] == "scripted_client" assert context["reason"] == "stdin-not-tty" @patch("cli_hub.analytics._send_event") def test_track_visit_uses_detection_context(self, mock_send): detection = { "is_agent": True, "traffic_type": "agent", "category": "agent_tool", "reason": "codex-process", "signals": ["codex-process", "stdin-not-tty"], "stdin_tty": False, "is_interactive": False, } with patch.dict(os.environ, {}, clear=True): track_visit(command="search", detection=detection) import time time.sleep(0.2) payload = mock_send.call_args[0][0] assert payload["properties"]["command"] == "search" assert payload["properties"]["agent_reason"] == "codex-process" assert payload["properties"]["agent_category"] == "agent_tool" assert payload["properties"]["agent_signals"] == ["codex-process", "stdin-not-tty"] assert payload["properties"]["stdin_tty"] is False assert payload["properties"]["is_interactive"] is False @patch("cli_hub.analytics._send_event") def test_first_run_sends_event(self, mock_send, tmp_path): """First invocation sends cli-hub-installed event.""" with patch.dict(os.environ, {"HOME": str(tmp_path)}, clear=False): track_first_run() import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] assert payload["event"] == "cli-anything-hub-installed" assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/installed" # Marker file should now exist assert (tmp_path / ".cli-hub" / ".first_run_sent").exists() @patch("cli_hub.analytics._send_event") def test_first_run_skips_if_marker_exists(self, mock_send, tmp_path): """Second invocation does NOT send cli-hub-installed event.""" cli_hub_dir = tmp_path / ".cli-hub" cli_hub_dir.mkdir() (cli_hub_dir / ".first_run_sent").write_text("0.1.0") with patch.dict(os.environ, {"HOME": str(tmp_path)}, clear=False): track_first_run() import time time.sleep(0.2) mock_send.assert_not_called() # ─── CLI tests ───────────────────────────────────────────────────────── class TestCLI: """Tests for the Click CLI interface.""" def setup_method(self): self.runner = click.testing.CliRunner() self.human_detection = { "is_agent": False, "traffic_type": "human", "category": "human", "reason": "human", "signals": [], "stdin_tty": True, "is_interactive": True, } self.agent_detection = { "is_agent": True, "traffic_type": "agent", "category": "agent_tool", "reason": "codex-env", "signals": ["codex-env"], "stdin_tty": False, "is_interactive": False, } @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") def test_version(self, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["--version"]) assert __version__ in result.output assert result.exit_code == 0 mock_visit.assert_called_once_with(command="--version", detection=self.human_detection) mock_first_run.assert_called_once() @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") def test_help(self, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["--help"]) assert "cli-hub" in result.output assert "matrix" in result.output assert "previews" in result.output assert result.exit_code == 0 @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_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) @patch("cli_hub.cli.get_installed", return_value={"gimp": {"version": "1.0.0"}}) def test_matrix_list_command(self, mock_installed, mock_fetch_matrices, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "list"]) assert "video-creation" in result.output assert "1/3" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.search_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) @patch("cli_hub.cli.get_installed", return_value={"gimp": {"version": "1.0.0"}}) def test_matrix_search_command(self, mock_installed, mock_search, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "search", "video"]) assert "video-creation" in result.output assert "1/3" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.search_matrices", return_value=[]) def test_matrix_search_no_results(self, mock_search, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "search", "nonexistent"]) assert "No matrices matching" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_matrix", return_value=SAMPLE_MATRIX_REGISTRY["matrices"][0]) @patch("cli_hub.cli.get_installed", return_value={"gimp": {"version": "1.0.0"}}) @patch("cli_hub.cli.get_rendered_matrix_skill_path", return_value=Path("/tmp/video-creation.SKILL.md")) @patch("pathlib.Path.exists", return_value=True) def test_matrix_info_command( self, mock_exists, mock_rendered, mock_installed, mock_get_matrix, mock_detect, mock_visit, mock_first_run, ): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "info", "video-creation"]) assert "Video Creation & Editing" in result.output assert "cli-hub matrix install video-creation" in result.output assert "cli-hub-matrix/video-creation/SKILL.md" in result.output assert "Local skill: /tmp/video-creation.SKILL.md" in result.output assert "Capabilities:" in result.output assert "package.thumbnail" in result.output assert "Known Gaps:" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_matrix", return_value=SAMPLE_MATRIX_REGISTRY["matrices"][0]) @patch("cli_hub.cli.preflight_matrix") def test_matrix_preflight_command(self, mock_preflight, mock_get_matrix, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection mock_preflight.return_value = { "matrix": {"display_name": "Video Creation & Editing"}, "offline": False, "summary": { "capabilities": 1, "with_available_provider": 1, "providers": 2, "available_providers": 1, "covered": 1, "gaps": 0, }, "capabilities": [ { "id": "package.thumbnail", "intent": "Create a thumbnail image", "providers": [ { "name": "Pillow", "kind": "python", "available": True, "quality_tier": "good", "cost_tier": "free", "missing": {"env": [], "binary": [], "package": []}, } ], } ], } result = self.runner.invoke(main, ["matrix", "preflight", "video-creation"]) assert result.exit_code == 0 assert "Video Creation & Editing Preflight" in result.output assert "Recommended:" not in result.output assert "Pillow [python; good; free]" in result.output mock_preflight.assert_called_once_with( SAMPLE_MATRIX_REGISTRY["matrices"][0], capability_id=None, offline=False, capability_ids=None, ) @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_matrix", return_value=SAMPLE_MATRIX_REGISTRY["matrices"][0]) @patch("cli_hub.cli.preflight_matrix") def test_matrix_preflight_command_renders_agent_skills_separately( self, mock_preflight, mock_get_matrix, mock_detect, mock_visit, mock_first_run, ): mock_detect.return_value = self.human_detection mock_preflight.return_value = { "matrix": {"display_name": "Video Creation & Editing"}, "offline": False, "summary": { "capabilities": 1, "with_available_provider": 0, "with_agent_installable_provider": 1, "providers": 1, "available_providers": 0, "agent_installable_providers": 1, "covered": 1, "gaps": 0, }, "capabilities": [ { "id": "script.storyboard", "intent": "Plan a video", "providers": [ { "name": "video-scriptwriting skill", "kind": "agent-skill", "available": False, "agent_installable": True, "quality_tier": "sota", "cost_tier": "free", "missing": {"env": [], "binary": [], "package": []}, } ], } ], } result = self.runner.invoke(main, ["matrix", "preflight", "video-creation"]) assert result.exit_code == 0 assert "1 agent-installable skill provider is not counted as installed or missing" in result.output assert "Recommended:" not in result.output assert "Agent-installable:" not in result.output assert "video-scriptwriting skill [agent-skill; sota; free] agent-installable" in result.output assert "missing:" not 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_matrix", return_value=(False, { "matrix": SAMPLE_MATRIX_REGISTRY["matrices"][0], "results": [ {"name": "gimp", "status": "skipped", "message": "Already installed"}, {"name": "blender", "status": "installed", "message": "Installed Blender"}, {"name": "audacity", "status": "failed", "message": "Install failed"}, ], "summary": {"installed": 1, "skipped": 1, "failed": 1}, "rendered_skill_path": "/tmp/video-creation.SKILL.md", })) def test_matrix_install_command_partial_failure(self, mock_install_matrix, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "install", "video-creation"]) # Partial failure (some installed, some failed) → exit code 3 per the contract. assert result.exit_code == 3 assert "Summary: 1 installed, 1 skipped, 1 failed" in result.output assert "Matrix skill: cli-hub-matrix/video-creation/SKILL.md" in result.output assert "Local matrix skill: /tmp/video-creation.SKILL.md" 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_matrix", return_value=(False, {"error": "Matrix 'missing' not found."})) def test_matrix_install_command_not_found(self, mock_install_matrix, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "install", "missing"]) assert result.exit_code == 1 assert "not 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", return_value=SAMPLE_REGISTRY["clis"]) @patch("cli_hub.cli.list_categories", return_value=["3d", "audio", "image"]) @patch("cli_hub.cli.get_installed", return_value={}) def test_list_command(self, mock_installed, mock_categories, mock_fetch, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["list"]) assert "gimp" in result.output assert "blender" in result.output assert result.exit_code == 0 @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=SAMPLE_REGISTRY["clis"]) @patch("cli_hub.cli.list_categories", return_value=["3d", "audio", "image"]) @patch("cli_hub.cli.get_installed", return_value={}) def test_list_with_category(self, mock_installed, mock_categories, mock_fetch, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["list", "-c", "image"]) assert "gimp" in result.output assert "blender" not 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.search_clis", return_value=[SAMPLE_REGISTRY["clis"][0]]) @patch("cli_hub.cli.get_installed", return_value={}) def test_search_command(self, mock_installed, mock_search, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["search", "gimp"]) assert "gimp" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_cli", return_value=SAMPLE_REGISTRY["clis"][0]) @patch("cli_hub.cli.get_installed", return_value={}) def test_info_command(self, mock_installed, mock_get, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["info", "gimp"]) assert "GIMP" in result.output assert "image" in result.output assert "Install: cli-hub install gimp" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_cli", return_value=None) def test_info_not_found(self, mock_get, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["info", "nonexistent"]) assert result.exit_code == 1 @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.get_cli", return_value=SAMPLE_REGISTRY["clis"][0]) def test_install_command(self, mock_get, mock_install, mock_track, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["install", "gimp"]) assert result.exit_code == 0 assert "Installed" in result.output mock_track.assert_called_once() @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_uninstall") @patch("cli_hub.cli.uninstall_cli", return_value=(True, "Uninstalled GIMP")) def test_uninstall_command(self, mock_uninstall, mock_track, mock_detect, mock_visit, mock_first_run): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["uninstall", "gimp"]) assert result.exit_code == 0 mock_track.assert_called_once() @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") def test_visit_agent_on_invocation(self, mock_detect, mock_visit, mock_first_run): """When agent env detected, track_visit is called with the new cli-hub call metadata.""" mock_detect.return_value = self.agent_detection result = self.runner.invoke(main, ["--version"]) mock_visit.assert_called_once_with(command="--version", detection=self.agent_detection) @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 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.""" mock_detect.return_value = self.human_detection 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_invocation_context") @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.""" mock_detect.return_value = self.human_detection 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_invocation_context") @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.""" mock_detect.return_value = self.human_detection 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_invocation_context") @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.""" mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["launch", "nonexistent"]) assert result.exit_code == 1 assert "not found" in result.output SAMPLE_MATRIX = SAMPLE_MATRIX_REGISTRY["matrices"][0] class TestMatrixScopeHelpers: """Pure-function tests for provider↔CLI resolution and install scoping (F2.2).""" def test_provider_cli_name_strips_harness_prefix(self): gimp = SAMPLE_MATRIX["capabilities"][0]["providers"][0] assert provider_cli_name(gimp, SAMPLE_MATRIX["clis"]) == "gimp" def test_provider_cli_name_none_for_non_installable(self): pillow = SAMPLE_MATRIX["capabilities"][0]["providers"][1] # python assert provider_cli_name(pillow, SAMPLE_MATRIX["clis"]) is None def test_provider_cli_name_explicit_field_wins(self): provider = {"kind": "public-cli", "name": "Whatever", "cli": "blender"} assert provider_cli_name(provider, SAMPLE_MATRIX["clis"]) == "blender" def test_scope_all_returns_every_cli(self): scope = resolve_install_scope(SAMPLE_MATRIX) assert scope["error"] is None assert scope["cli_names"] == ["gimp", "blender", "audacity"] assert scope["scope"]["type"] == "all" def test_scope_capability_maps_to_clis(self): scope = resolve_install_scope(SAMPLE_MATRIX, capability="package.thumbnail") assert scope["error"] is None assert scope["cli_names"] == ["gimp"] def test_scope_recipe_unions_capability_clis(self): scope = resolve_install_scope(SAMPLE_MATRIX, recipe="social-short") assert scope["error"] is None # social-short uses package.thumbnail (gimp) + audio.capture (audacity), in clis[] order assert scope["cli_names"] == ["gimp", "audacity"] def test_scope_only_validates_membership(self): ok = resolve_install_scope(SAMPLE_MATRIX, only="gimp,audacity") assert ok["error"] is None assert ok["cli_names"] == ["gimp", "audacity"] bad = resolve_install_scope(SAMPLE_MATRIX, only="gimp,bogus") assert bad["error"] is not None and "bogus" in bad["error"] def test_scope_mutually_exclusive(self): scope = resolve_install_scope(SAMPLE_MATRIX, capability="package.thumbnail", only="gimp") assert scope["error"] is not None def test_scope_unknown_capability_errors(self): scope = resolve_install_scope(SAMPLE_MATRIX, capability="nope") assert scope["error"] is not None and "nope" in scope["error"] def test_unmanaged_providers_groups_by_kind(self): groups = unmanaged_providers(SAMPLE_MATRIX) assert groups.get("python") == ["Pillow"] assert groups.get("native") == ["sox"] def test_provider_install_hint_derives_cli_hub_command(self): gimp = SAMPLE_MATRIX["capabilities"][0]["providers"][0] assert provider_install_hint(gimp, SAMPLE_MATRIX["clis"]) == "cli-hub install gimp" def test_provider_install_hint_prefers_explicit(self): provider = {"kind": "public-cli", "name": "tool", "install_hint": "brew install tool"} assert provider_install_hint(provider, SAMPLE_MATRIX["clis"]) == "brew install tool" class TestCapabilitySearch: """Capability-level search powering matrix search matched_in and `cli-hub can` (F1.1).""" def test_capability_matches_by_intent(self): matches = capability_matches(SAMPLE_MATRIX, "thumbnail") ids = {m["capability_id"] for m in matches} assert "package.thumbnail" in ids hit = next(m for m in matches if m["capability_id"] == "package.thumbnail") assert hit["match_field"] in {"id", "intent", "hint"} assert "cli-anything-gimp" in hit["providers_summary"] @patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_search_capabilities_includes_availability(self, mock_fetch): with patch("cli_hub.matrix.shutil.which", return_value=None), \ patch("cli_hub.matrix._package_available", return_value=False): hits = search_capabilities("audio") assert hits hit = hits[0] assert "providers" in hit and "available" in hit["providers"][0] @patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_all_recipes_filters_by_query(self, mock_fetch): assert {r["id"] for r in all_recipes()} == {"social-short"} assert all_recipes("nonexistent-recipe-xyz") == [] class TestMatrixF1F2Commands: """CLI-level tests for the F1/F2 matrix commands.""" def setup_method(self): self.runner = click.testing.CliRunner() self.human_detection = {"is_agent": False, "agent": None, "source": "tty"} @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_can_command_finds_capability(self, mock_fetch, mock_detect, mock_visit, mock_first): mock_detect.return_value = self.human_detection with patch("cli_hub.matrix.shutil.which", return_value=None), \ patch("cli_hub.matrix._package_available", return_value=False): result = self.runner.invoke(main, ["can", "thumbnail"]) assert result.exit_code == 0 assert "package.thumbnail" in result.output assert "cli-hub matrix preflight video-creation -c package.thumbnail" 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.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_can_command_no_match_exits_1(self, mock_fetch, mock_detect, mock_visit, mock_first): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["can", "zzz-no-such-capability"]) assert result.exit_code == 1 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.matrix.fetch_all_matrices", return_value=SAMPLE_MATRIX_REGISTRY["matrices"]) def test_recipes_command_lists_recipes(self, mock_fetch, mock_detect, mock_visit, mock_first): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "recipes"]) assert result.exit_code == 0 assert "social-short" in result.output assert "preflight video-creation --recipe social-short" 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.get_installed", return_value={}) @patch("cli_hub.installer.get_cli") @patch("cli_hub.installer.get_matrix", return_value=SAMPLE_MATRIX) def test_install_dry_run_no_side_effects(self, mock_get_matrix, mock_get_cli, mock_installed, mock_detect, mock_visit, mock_first): mock_detect.return_value = self.human_detection mock_get_cli.side_effect = lambda n: {"name": n, "display_name": n.title(), "_source": "harness", "entry_point": n} with patch("cli_hub.installer.install_cli") as mock_install: result = self.runner.invoke( main, ["matrix", "install", "video-creation", "--capability", "package.thumbnail", "--dry-run"]) mock_install.assert_not_called() assert result.exit_code == 0 assert "Install plan" in result.output assert "gimp" 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.installer.get_matrix", return_value=SAMPLE_MATRIX) def test_install_unknown_capability_exits_2(self, mock_get_matrix, mock_detect, mock_visit, mock_first): mock_detect.return_value = self.human_detection result = self.runner.invoke( main, ["matrix", "install", "video-creation", "--capability", "nope", "--dry-run"]) assert result.exit_code == 2 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.doctor_matrix", return_value=(False, { "matrix": SAMPLE_MATRIX, "last_run": "2026-06-14T10:00:00", "checks": [{"name": "gimp", "entry_point": "gimp", "status": "not_installed", "detail": "Not installed", "fix": "cli-hub install gimp"}], "summary": {"total": 1, "ok": 0, "broken": 0, "not_installed": 1}, })) def test_doctor_command_reports_gaps_exit_3(self, mock_doctor, mock_detect, mock_visit, mock_first): mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["matrix", "doctor", "video-creation"]) assert result.exit_code == 3 assert "cli-hub install gimp" in result.output