Files
2026-07-13 12:55:23 +08:00

605 lines
21 KiB
Python

"""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