uninstall.py
Rama main · réplica de lectura del repositorio original.
#!/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)