"""Install, uninstall, and manage CLIs and matrices.""" import json import shlex import shutil import subprocess import sys from datetime import datetime from pathlib import Path from cli_hub.registry import get_cli from cli_hub.matrix import get_matrix, resolve_install_scope, unmanaged_providers from cli_hub.matrix_skill import render_matrix_skill_file INSTALLED_FILE = Path.home() / ".cli-hub" / "installed.json" MATRIX_STATE_FILE = Path.home() / ".cli-hub" / "matrix_state.json" def _load_installed(): if INSTALLED_FILE.exists(): try: return json.loads(INSTALLED_FILE.read_text()) except json.JSONDecodeError: pass return {} def _save_installed(data): INSTALLED_FILE.parent.mkdir(parents=True, exist_ok=True) INSTALLED_FILE.write_text(json.dumps(data, indent=2)) def _load_matrix_state(): if MATRIX_STATE_FILE.exists(): try: return json.loads(MATRIX_STATE_FILE.read_text()) except json.JSONDecodeError: pass return {} def _save_matrix_state(data): MATRIX_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) MATRIX_STATE_FILE.write_text(json.dumps(data, indent=2)) def _find_npm(): """Find npm executable. Returns path or None.""" 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. 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( 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] return subprocess.CompletedProcess( args=cmd, returncode=127, stdout="", stderr=f"Command not found: {missing}", ) def _command_exists(cmd): """Check whether the executable for a command string exists on PATH.""" try: parts = shlex.split(cmd) except ValueError: return False if not parts: return False return shutil.which(parts[0]) is not None def _install_strategy(cli): """Return the install strategy for a CLI entry.""" strategy = cli.get("install_strategy") if strategy: return strategy if cli.get("_source", "harness") == "harness": 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" def _generic_install(cli): install_cmd = cli.get("install_cmd") if not install_cmd: return False, f"No install command is defined for {cli['display_name']}." result = _run_command(install_cmd) if result.returncode == 0: return True, f"Installed {cli['display_name']} ({cli['entry_point']})" return False, f"Install failed:\n{result.stderr or result.stdout}" def _generic_uninstall(cli): uninstall_cmd = cli.get("uninstall_cmd") if not uninstall_cmd: note = cli.get("uninstall_notes") or f"No uninstall command is defined for {cli['display_name']}." return False, note result = _run_command(uninstall_cmd) if result.returncode == 0: return True, f"Uninstalled {cli['display_name']}" return False, f"Uninstall failed:\n{result.stderr or result.stdout}" def _generic_update(cli): update_cmd = cli.get("update_cmd") if not update_cmd: note = cli.get("update_notes") or f"No update command is defined for {cli['display_name']}." return False, note result = _run_command(update_cmd) if result.returncode == 0: return True, f"Updated {cli['display_name']}" return False, f"Update failed:\n{result.stderr or result.stdout}" def _bundled_install(cli): detect_cmd = cli.get("detect_cmd") or cli.get("entry_point") if detect_cmd and _command_exists(detect_cmd): return True, f"{cli['display_name']} is already available ({cli['entry_point']})" note = cli.get("install_notes") or ( f"{cli['display_name']} is bundled with its parent app. " "Install or enable it in the upstream app first, then run this command again." ) return False, note def _bundled_uninstall(cli): note = cli.get("uninstall_notes") or ( f"{cli['display_name']} is bundled with its parent app. " "Disable it in the upstream app or uninstall the parent app manually." ) return False, note def _bundled_update(cli): note = cli.get("update_notes") or ( f"{cli['display_name']} is bundled with its parent app. " "Update the parent app to update this CLI." ) return False, note # ── pip operations (harness CLIs) ── def _pip_install(cli): install_cmd = cli["install_cmd"] result = subprocess.run( [sys.executable, "-m", "pip", "install"] + install_cmd.replace("pip install ", "").split(), capture_output=True, text=True ) if result.returncode == 0: return True, f"Installed {cli['display_name']} ({cli['entry_point']})" return False, f"pip install failed:\n{result.stderr}" def _pip_uninstall(cli): pkg_name = f"cli-anything-{cli['name']}" result = subprocess.run( [sys.executable, "-m", "pip", "uninstall", "-y", pkg_name], capture_output=True, text=True ) if result.returncode == 0: return True, f"Uninstalled {cli['display_name']}" return False, f"pip uninstall failed:\n{result.stderr}" def _pip_update(cli): install_cmd = cli["install_cmd"] result = subprocess.run( [sys.executable, "-m", "pip", "install", "--upgrade", "--force-reinstall"] + install_cmd.replace("pip install ", "").split(), capture_output=True, text=True ) if result.returncode == 0: return True, f"Updated {cli['display_name']} to {cli['version']}" 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) ── def _npm_install(cli): npm = _find_npm() if npm is None: return False, ( "npm is not installed. Install Node.js first:\n" " macOS: brew install node\n" " Linux: curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - && sudo apt-get install -y nodejs\n" " Windows: Download from https://nodejs.org" ) result = subprocess.run( [npm, "install", "-g", cli["npm_package"]], capture_output=True, text=True ) if result.returncode == 0: return True, f"Installed {cli['display_name']} ({cli['entry_point']})" return False, f"npm install failed:\n{result.stderr}" def _npm_uninstall(cli): npm = _find_npm() if npm is None: return False, "npm is not installed." result = subprocess.run( [npm, "uninstall", "-g", cli["npm_package"]], capture_output=True, text=True ) if result.returncode == 0: return True, f"Uninstalled {cli['display_name']}" return False, f"npm uninstall failed:\n{result.stderr}" def _npm_update(cli): npm = _find_npm() if npm is None: return False, "npm is not installed." result = subprocess.run( [npm, "install", "-g", cli["npm_package"] + "@latest"], capture_output=True, text=True ) if result.returncode == 0: return True, f"Updated {cli['display_name']} to latest" return False, f"Update failed:\n{result.stderr}" def _perform_action(cli, action): """Dispatch install/uninstall/update based on CLI strategy.""" strategy = _install_strategy(cli) 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}, } handler = actions.get(strategy, actions["command"]).get(action) return strategy, handler(cli) def _installed_entry(cli, source, strategy): """Return the installed.json payload for a CLI.""" entry = { "version": cli["version"], "entry_point": cli["entry_point"], "source": source, "strategy": strategy, } if cli.get("package_manager"): entry["package_manager"] = cli["package_manager"] if cli.get("npm_package"): entry["npm_package"] = cli["npm_package"] if cli.get("install_cmd"): entry["install_cmd"] = cli["install_cmd"] if cli.get("uninstall_cmd"): entry["uninstall_cmd"] = cli["uninstall_cmd"] if cli.get("update_cmd"): entry["update_cmd"] = cli["update_cmd"] return entry # ── Unified interface ── def install_cli(name): """Install a CLI by name. Dispatches to pip or npm based on source. Returns (success, message).""" cli = get_cli(name) if cli is None: return False, f"CLI '{name}' not found in registry. Use 'cli-hub list' to see available CLIs." source = cli.get("_source", "harness") strategy, (success, msg) = _perform_action(cli, "install") if success: installed = _load_installed() installed[cli["name"]] = _installed_entry(cli, source, strategy) _save_installed(installed) return success, msg def uninstall_cli(name): """Uninstall a CLI by name. Returns (success, message).""" cli = get_cli(name) if cli is None: return False, f"CLI '{name}' not found in registry." _, (success, msg) = _perform_action(cli, "uninstall") if success: installed = _load_installed() installed.pop(cli["name"], None) _save_installed(installed) return success, msg def update_cli(name): """Update a CLI by reinstalling. Returns (success, message).""" cli = get_cli(name, force_refresh=True) if cli is None: return False, f"CLI '{name}' not found in registry." source = cli.get("_source", "harness") strategy, (success, msg) = _perform_action(cli, "update") if success: installed = _load_installed() installed[cli["name"]] = _installed_entry(cli, source, strategy) _save_installed(installed) return success, msg def get_installed(): """Return dict of installed CLIs.""" return _load_installed() def _scope_label(scope, capabilities=None): """Human-readable label for an install scope (used in state + output).""" scope_type = scope.get("type") if scope_type == "capability": return f"capability {scope.get('value')}" if scope_type == "recipe": return f"recipe {scope.get('value')}" if scope_type == "only": return f"only {', '.join(scope.get('value', []))}" return "full matrix" def plan_matrix_install(name, capability=None, recipe=None, only=None): """Compute what ``matrix install`` would do — no side effects (F2.1). Returns ``(ok, payload)``. ``ok`` is false only on lookup/usage errors; ``payload['arg_error']`` distinguishes usage errors (exit 2) from not-found. """ matrix_item = get_matrix(name) if matrix_item is None: return False, {"error": f"Matrix '{name}' not found. Use 'cli-hub matrix list' to see available matrices."} scope = resolve_install_scope(matrix_item, capability=capability, recipe=recipe, only=only) if scope.get("error"): return False, {"error": scope["error"], "arg_error": True, "matrix": matrix_item} installed = set(get_installed()) plan = [] for cli_name in scope["cli_names"]: cli = get_cli(cli_name) if cli_name in installed: action, via = "skip", (_install_strategy(cli) if cli else None) elif cli is None: action, via = "error", None else: action, via = "install", _install_strategy(cli) plan.append({ "name": cli_name, "display_name": cli["display_name"] if cli else cli_name, "action": action, "via": via, "already_installed": cli_name in installed, "found": cli is not None, }) scope_caps = scope.get("capabilities") cap_objs = None if scope_caps is not None and scope["scope"].get("type") in {"capability", "recipe"}: cap_set = set(scope_caps) cap_objs = [c for c in matrix_item.get("capabilities", []) if c.get("id") in cap_set] not_managed = unmanaged_providers(matrix_item, cap_objs) summary = { "total": len(plan), "to_install": sum(1 for p in plan if p["action"] == "install"), "to_skip": sum(1 for p in plan if p["action"] == "skip"), "unresolved": sum(1 for p in plan if p["action"] == "error"), } payload = { "matrix": matrix_item, "scope": scope["scope"], "scope_label": _scope_label(scope["scope"]), "plan": plan, "not_managed": not_managed, "summary": summary, } return True, payload def install_matrix(name, capability=None, recipe=None, only=None, resume=False): """Install the CLIs in a named matrix, optionally scoped (F2.2) or resumed (F2.3). Returns ``(success, payload)``. ``payload['arg_error']`` marks usage errors. Records per-CLI outcomes to ``matrix_state.json`` so ``--resume`` and ``matrix doctor`` can act on them. """ matrix_item = get_matrix(name) if matrix_item is None: return False, {"error": f"Matrix '{name}' not found. Use 'cli-hub matrix list' to see available matrices."} state = _load_matrix_state() if resume: if capability or recipe or only: return False, {"error": "Cannot combine --resume with --capability/--recipe/--only.", "arg_error": True, "matrix": matrix_item} prior = state.get(name) if not prior: return False, {"error": f"No previous install of '{name}' to resume. " f"Run 'cli-hub matrix install {name}' first.", "arg_error": True, "matrix": matrix_item} target_names = [r["name"] for r in prior.get("results", []) if r.get("status") == "failed"] scope = prior.get("scope", {"type": "all"}) if not target_names: return True, {"matrix": matrix_item, "results": [], "scope": scope, "scope_label": _scope_label(scope), "summary": {"total": 0, "installed": 0, "skipped": 0, "failed": 0}, "resumed": True, "nothing_to_resume": True} else: scope_info = resolve_install_scope(matrix_item, capability=capability, recipe=recipe, only=only) if scope_info.get("error"): return False, {"error": scope_info["error"], "arg_error": True, "matrix": matrix_item} target_names = scope_info["cli_names"] scope = scope_info["scope"] installed = set(get_installed()) results = [] for cli_name in target_names: cli = get_cli(cli_name) display_name = cli["display_name"] if cli else cli_name via = _install_strategy(cli) if cli else None if cli_name in installed: results.append({"name": cli_name, "display_name": display_name, "status": "skipped", "via": via, "message": "Already installed"}) continue if cli is None: results.append({"name": cli_name, "display_name": display_name, "status": "failed", "via": via, "message": "CLI not found in registry"}) continue success, msg = install_cli(cli_name) results.append({"name": cli_name, "display_name": display_name, "status": "installed" if success else "failed", "via": via, "message": msg}) if success: installed.add(cli_name) summary = { "total": len(results), "installed": sum(1 for result in results if result["status"] == "installed"), "skipped": sum(1 for result in results if result["status"] == "skipped"), "failed": sum(1 for result in results if result["status"] == "failed"), } state[name] = { "last_run": datetime.now().isoformat(timespec="seconds"), "scope": scope, "results": results, } _save_matrix_state(state) installed_state = get_installed() rendered_skill_path = render_matrix_skill_file(matrix_item, installed=installed_state) payload = { "matrix": matrix_item, "results": results, "scope": scope, "scope_label": _scope_label(scope), "summary": summary, "resumed": resume, "rendered_skill_path": str(rendered_skill_path), } return summary["failed"] == 0, payload def doctor_matrix(name): """Audit install completeness for a matrix's CLIs against the environment (F2.3). Unlike preflight (which reports provider availability for selection), doctor checks whether the matrix's own ``clis[]`` are installed and on PATH, and emits a fix command per broken/missing member. """ matrix_item = get_matrix(name) if matrix_item is None: return False, {"error": f"Matrix '{name}' not found. Use 'cli-hub matrix list' to see available matrices."} installed = get_installed() state = _load_matrix_state().get(name) checks = [] for cli_name in matrix_item.get("clis", []): cli = get_cli(cli_name) entry_point = cli.get("entry_point") if cli else None record = installed.get(cli_name) if record is None: status = "not_installed" detail = "Not installed" fix = f"cli-hub install {cli_name}" elif entry_point and not _command_exists(entry_point): status = "broken" detail = f"Recorded as installed but '{entry_point}' is not on PATH" fix = f"cli-hub install {cli_name}" else: status = "ok" detail = "Installed" fix = None checks.append({ "name": cli_name, "entry_point": entry_point, "status": status, "detail": detail, "fix": fix, }) summary = { "total": len(checks), "ok": sum(1 for check in checks if check["status"] == "ok"), "broken": sum(1 for check in checks if check["status"] == "broken"), "not_installed": sum(1 for check in checks if check["status"] == "not_installed"), } payload = { "matrix": matrix_item, "last_run": state.get("last_run") if state else None, "checks": checks, "summary": summary, } healthy = summary["broken"] == 0 and summary["not_installed"] == 0 return healthy, payload