Ctrl K
ComunidadGuías…

uninstall.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""kbots Uninstall Wizard — remove a deployment (and optionally export it first).

Reverses what setup.py created: stops/removes the service, revokes passwordless
sudo, strips the shell-profile env exports, and deletes the overlay, the engine
clone, and the vault key file — with confirmation and a clear summary first.

Export your installation before removing it (to move to another machine):
    uv run python uninstall.py            # offers export, then removes
    uv run python uninstall.py --export-only

Usage: uv run python uninstall.py
"""

import os
import shutil
import subprocess
import sys
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))

from src.core.base import resolve_vault_key_file  # noqa: E402

BOLD, DIM, GREEN, YELLOW, RED, CYAN, RESET = (
    "\033[1m", "\033[2m", "\033[32m", "\033[33m", "\033[31m", "\033[36m", "\033[0m")


def header(t): print(f"\n{BOLD}{CYAN}── {t} ──{RESET}\n")
def ok(m): print(f"  {GREEN}✓{RESET} {m}")
def warn(m): print(f"  {YELLOW}!{RESET} {m}")
def err(m): print(f"  {RED}✗{RESET} {m}")
def info(m): print(f"  {DIM}{m}{RESET}")


def ask(prompt, default=""):
    s = f" [{default}]" if default else ""
    return input(f"  {prompt}{s}: ").strip() or default


def ask_yn(prompt, default=False):
    yn = "[Y/n]" if default else "[y/N]"
    r = input(f"  {prompt} {yn}: ").strip().lower()
    return default if not r else r in ("y", "yes")


def detect(state: dict):
    header("Detecting installation")

    # Overlay: env var, or ask
    overlay = os.environ.get("KBOTS_OVERLAY", "")
    if overlay and Path(overlay).is_dir():
        info(f"KBOTS_OVERLAY = {overlay}")
    else:
        overlay = ask("Overlay directory to remove (blank to skip)")
    state["overlay"] = Path(overlay).resolve() if overlay else None

    # Engine root: from an agent's .mcp.json cwd; else the default install dir
    # (covers partial installs whose overlay has no agents yet).
    engine = None
    if state["overlay"]:
        for mcp in state["overlay"].glob("agents/*/.mcp.json"):
            try:
                import json
                cwd = json.loads(mcp.read_text()).get(
                    "mcpServers", {}).get("kbots-tools", {}).get("cwd")
                if cwd:
                    engine = Path(cwd)
                    break
            except Exception:
                pass
    if engine is None:
        default = (Path.home() / "kbots" if sys.platform == "darwin"
                   else Path("/opt/kbots"))
        if (default / ".git").is_dir() and default.resolve() != PROJECT_ROOT:
            engine = default
    state["engine"] = engine

    # Service (platform-specific)
    state["darwin"] = sys.platform == "darwin"
    if state["darwin"]:
        plist = Path.home() / "Library" / "LaunchAgents" / "com.kbots.agent.plist"
        state["launchd_plist"] = plist if plist.exists() else None
    else:
        unit = Path("/etc/systemd/system/kbots.service")
        state["systemd_unit"] = unit if unit.exists() else None

    # Sudoers, profile, vault key
    sudoers = Path("/etc/sudoers.d/kbots-fullcontrol")
    state["sudoers"] = sudoers if sudoers.exists() else None
    state["key_file"] = resolve_vault_key_file() if resolve_vault_key_file().exists() else None
    profiles = []
    for pf in (Path.home() / ".zshrc", Path.home() / ".bashrc"):
        if pf.exists() and "KBOTS_OVERLAY" in pf.read_text():
            profiles.append(pf)
    state["profiles"] = profiles

    # Summary
    print()
    info("Found:")
    print(f"    Overlay:      {state['overlay'] or '(none)'}")
    print(f"    Engine clone: {state['engine'] or '(unknown / in-place)'}")
    svc = (state.get("launchd_plist") if state["darwin"] else state.get("systemd_unit"))
    print(f"    Service:      {svc or '(not installed)'}")
    print(f"    Sudo rule:    {state['sudoers'] or '(none)'}")
    print(f"    Vault key:    {state['key_file'] or '(none)'}")
    print(f"    Shell env:    {', '.join(str(p.name) for p in profiles) or '(none)'}")


def export_installation(state: dict):
    if not state["overlay"]:
        warn("No overlay to export.")
        return
    header("Export installation (move to another machine)")
    if not ask_yn("Export this installation to a portable bundle first?", default=True):
        return
    out = ask("Output directory for the bundle", str(Path.home()))
    with_key = ask_yn("Include the vault passphrase key? (needed to unlock secrets "
                      "on the new machine, but sensitive)", default=False)
    cmd = [sys.executable, str(PROJECT_ROOT / "scripts" / "migrate.py"), "export",
           "--overlay", str(state["overlay"]), "--out", out]
    if with_key:
        cmd.append("--with-key")
    # Stamp the filename with a timestamp from the shell (scripts can't use time here cheaply)
    import time
    cmd += ["--timestamp", time.strftime("%Y%m%d-%H%M%S")]
    subprocess.run(cmd, check=False)


def _stop_service(state: dict):
    if state["darwin"]:
        label = state["launchd_plist"].stem if state.get("launchd_plist") else "com.kbots.agent"
        subprocess.run(["launchctl", "bootout", f"gui/{os.getuid()}/{label}"],
                       capture_output=True)
        if state.get("launchd_plist"):
            state["launchd_plist"].unlink(missing_ok=True)
        ok("Stopped + removed launchd service")
    else:
        unit_name = state["systemd_unit"].stem if state.get("systemd_unit") else "kbots"
        subprocess.run(["sudo", "-n", "systemctl", "disable", "--now", unit_name],
                       capture_output=True)
        subprocess.run(["sudo", "-n", "rm", "-f", str(state["systemd_unit"] or "/etc/systemd/system/kbots.service")],
                       capture_output=True)
        subprocess.run(["sudo", "-n", "systemctl", "daemon-reload"], capture_output=True)
        ok("Stopped + removed systemd service")


def _strip_profiles(state: dict):
    for pf in state["profiles"]:
        lines = pf.read_text().splitlines(keepends=True)
        out, skip = [], False
        for line in lines:
            if line.strip() == "# kbots environment":
                if out and out[-1].strip() == "":
                    out.pop()
                skip = True
                continue
            if skip and line.startswith("export KBOTS_"):
                continue
            skip = False
            out.append(line)
        pf.write_text("".join(out))
        ok(f"Removed env exports from ~/{pf.name}")


def remove(state: dict):
    header("Remove installation")
    warn("This permanently deletes the overlay (config, vault, agents, memory),")
    warn("the engine clone, the vault key, the service, and shell env exports.")
    if state["overlay"]:
        info(f"Make sure you've exported first if you want to keep {state['overlay'].name}.")
    print()
    if not ask_yn("Proceed with uninstall?", default=False):
        info("Aborted — nothing removed.")
        return

    svc = (state.get("launchd_plist") if state["darwin"] else state.get("systemd_unit"))
    if svc:
        _stop_service(state)
    if state["sudoers"]:
        script = PROJECT_ROOT / "scripts" / "full-control.sh"
        if script.exists():
            subprocess.run(["bash", str(script), "revoke"])
        ok("Revoked passwordless sudo")
    if state["profiles"]:
        _strip_profiles(state)
    if state["key_file"]:
        state["key_file"].unlink(missing_ok=True)
        ok(f"Deleted vault key: {state['key_file']}")
    if state["overlay"] and state["overlay"].is_dir():
        shutil.rmtree(state["overlay"], ignore_errors=True)
        ok(f"Deleted overlay: {state['overlay']}")
    if state["engine"] and state["engine"] != PROJECT_ROOT and state["engine"].is_dir():
        if ask_yn(f"Also delete the engine clone at {state['engine']}?", default=True):
            shutil.rmtree(state["engine"], ignore_errors=True)
            ok(f"Deleted engine clone: {state['engine']}")

    print()
    ok("kbots uninstalled.")
    info("This checkout (the engine source) was left in place.")


def main():
    print(f"\n{BOLD}{CYAN}kbots Uninstall Wizard{RESET}")
    export_only = "--export-only" in sys.argv

    state: dict = {}
    detect(state)
    export_installation(state)
    if export_only:
        ok("Export-only mode — nothing removed.")
        return
    remove(state)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        print(f"\n\n  {YELLOW}Interrupted — nothing further removed.{RESET}\n")
        sys.exit(130)