#!/usr/bin/env python3 """Detect system CLIs and install selected missing tools only after confirmation.""" from __future__ import annotations import argparse import json import os import platform import shutil import subprocess import sys from dataclasses import dataclass from typing import Sequence @dataclass(frozen=True) class Tool: name: str display_name: str commands: tuple[str, ...] required: bool purpose: str brew_formula: str | None = None brew_cask: str | None = None apt_package: str | None = None dnf_package: str | None = None TOOLS = ( Tool( name="curl", display_name="curl", commands=("curl",), required=True, purpose="O11Y HTTP access and compatibility fallback", brew_formula="curl", apt_package="curl", dnf_package="curl", ), Tool( name="jq", display_name="jq", commands=("jq",), required=True, purpose="nutshell O11Y helper JSON processing", brew_formula="jq", apt_package="jq", dnf_package="jq", ), Tool( name="gh", display_name="GitHub CLI", commands=("gh",), required=False, purpose="optional private GitHub authentication and repository operations", brew_formula="gh", apt_package="gh", dnf_package="gh", ), Tool( name="aws", display_name="AWS CLI", commands=("aws",), required=False, purpose="optional O11Y S3 log/export access", brew_formula="awscli", apt_package="awscli", dnf_package="awscli", ), Tool( name="google-cloud-cli", display_name="Google Cloud CLI (gcloud + bq)", commands=("gcloud", "bq"), required=False, purpose="optional; BigQuery queries use the Python API and do not require this CLI", brew_cask="google-cloud-sdk", ), Tool( name="lark-cli", display_name="lark-cli", commands=("lark-cli",), required=False, purpose=( "optional Lark billing-rule retrieval and user-approved report publication; " "no portable installer is configured" ), ), ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Detect and optionally install Manual Invoice system CLI dependencies." ) parser.add_argument("--check", action="store_true", help="Report status without prompting.") parser.add_argument( "--plan", action="store_true", help="Print the exact installation plan for --tools without installing.", ) parser.add_argument("--yes", action="store_true", help="Install selected missing tools without prompting.") parser.add_argument( "--tools", help="Comma-separated tool names or 'all'. Defaults to every missing installable tool.", ) parser.add_argument("--dry-run", action="store_true", help="Show install commands without running them.") return parser.parse_args() def manager_name() -> str | None: if shutil.which("brew"): return "brew" if shutil.which("apt-get"): return "apt" if shutil.which("dnf"): return "dnf" if shutil.which("yum"): return "yum" return None def tool_installed(tool: Tool) -> bool: return all(shutil.which(command) for command in tool.commands) def install_command(tool: Tool, manager: str | None) -> list[str] | None: if manager == "brew": if tool.brew_cask: return ["brew", "install", "--cask", tool.brew_cask] if tool.brew_formula: return ["brew", "install", tool.brew_formula] return None prefix: list[str] = [] if hasattr(os, "geteuid") and os.geteuid() != 0: sudo = shutil.which("sudo") if not sudo: return None prefix = [sudo] if manager == "apt" and tool.apt_package: return [*prefix, "apt-get", "install", "-y", tool.apt_package] if manager in {"dnf", "yum"} and tool.dnf_package: return [*prefix, manager, "install", "-y", tool.dnf_package] return None def selected_names(value: str | None, candidates: Sequence[Tool]) -> set[str]: available = {tool.name for tool in TOOLS} if not value or value.strip().lower() == "all": return {tool.name for tool in candidates} selected = {item.strip() for item in value.split(",") if item.strip()} unknown = selected - available if unknown: raise ValueError(f"Unknown system tool names: {', '.join(sorted(unknown))}") return selected def choose_interactively(candidates: Sequence[Tool]) -> set[str]: if not candidates: return set() print("Missing installable tools:") for index, tool in enumerate(candidates, start=1): requirement = "required" if tool.required else "optional" print(f" {index}. {tool.display_name} [{requirement}] - {tool.purpose}") answer = input( "Select tools by number (comma-separated), type 'all', or press Enter to skip: " ).strip() if not answer: return set() if answer.lower() == "all": return {tool.name for tool in candidates} selected: set[str] = set() for item in answer.split(","): raw = item.strip() if not raw.isdigit() or not 1 <= int(raw) <= len(candidates): raise ValueError(f"Invalid tool selection: {raw!r}") selected.add(candidates[int(raw) - 1].name) return selected def plan_payload( selected: set[str], candidates: Sequence[Tool], manager: str | None ) -> dict[str, object]: commands = [] for tool in candidates: if tool.name not in selected: continue command = install_command(tool, manager) if command is None: continue commands.append( { "name": tool.name, "display_name": tool.display_name, "required": tool.required, "purpose": tool.purpose, "command": command, } ) return { "status": "planned" if commands else "nothing_selected", "package_manager": manager, "selected": sorted(selected), "commands": commands, } def print_plan(plan: dict[str, object]) -> None: commands = plan["commands"] if not commands: print("No system tools selected for installation.") return print("Installation plan:") for index, entry in enumerate(commands, start=1): print(f" {index}. {entry['display_name']}: {' '.join(entry['command'])}") def status_payload(manager: str | None) -> dict[str, object]: tools = [] for tool in TOOLS: command = install_command(tool, manager) commands = {name: shutil.which(name) for name in tool.commands} tools.append( { "name": tool.name, "display_name": tool.display_name, "required": tool.required, "purpose": tool.purpose, "installed": all(commands.values()), "commands": commands, "installable": command is not None, "install_command": command, } ) missing_required = [entry["name"] for entry in tools if entry["required"] and not entry["installed"]] return { "status": "ready" if not missing_required else "missing_required", "platform": platform.system().lower(), "package_manager": manager, "missing_required": missing_required, "tools": tools, } def main() -> int: args = parse_args() manager = manager_name() initial = status_payload(manager) if args.check: print(json.dumps(initial, ensure_ascii=False)) return 0 if not args.plan: print("System CLI dependency check:") for entry in initial["tools"]: requirement = "required" if entry["required"] else "optional" state = "installed" if entry["installed"] else "missing" suffix = ( "" if entry["installed"] or entry["installable"] else "; manual install required" ) print( f"- {entry['display_name']}: {state} " f"({requirement}; {entry['purpose']}){suffix}" ) by_name = {tool.name: tool for tool in TOOLS} missing_installable = [ by_name[entry["name"]] for entry in initial["tools"] if not entry["installed"] and entry["installable"] ] if args.tools is not None: selected = selected_names(args.tools, missing_installable) elif args.yes or args.plan: selected = selected_names(None, missing_installable) else: selected = choose_interactively(missing_installable) plan = plan_payload(selected, missing_installable, manager) if args.plan: print(json.dumps(plan, ensure_ascii=False)) return 0 if not args.yes: print_plan(plan) if plan["commands"]: approved = input("Proceed with this installation plan? [y/N]: ").strip().lower() if approved not in {"y", "yes"}: final = status_payload(manager) final["actions"] = [ {"name": entry["name"], "status": "cancelled_before_install"} for entry in plan["commands"] ] print(json.dumps(final, ensure_ascii=False)) return 0 actions: list[dict[str, object]] = [] for tool in missing_installable: if tool.name not in selected: continue command = install_command(tool, manager) if command is None: continue if args.dry_run: actions.append({"name": tool.name, "status": "dry_run", "command": command}) continue completed = subprocess.run(command, check=False) actions.append( { "name": tool.name, "status": "installed" if completed.returncode == 0 else "failed", "returncode": completed.returncode, } ) if completed.returncode != 0: print(json.dumps({"status": "install_failed", "actions": actions}, ensure_ascii=False)) return 2 final = status_payload(manager) final["actions"] = actions print(json.dumps(final, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())